feat: add external id for users (#3219)

This commit is contained in:
Vlad Stan
2025-06-26 16:33:38 +03:00
committed by dni ⚡
parent ff24847980
commit c7b7832a88
11 changed files with 458 additions and 25 deletions

View File

@@ -68,6 +68,7 @@ async def get_accounts(
accounts.username,
accounts.email,
accounts.pubkey,
accounts.external_id,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
@@ -128,8 +129,8 @@ async def get_account_by_username(
if len(username) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username",
{"username": username},
"SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username.lower()},
Account,
)
@@ -138,8 +139,8 @@ async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey},
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
Account,
)
@@ -150,8 +151,8 @@ async def get_account_by_email(
if len(email) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email",
{"email": email},
"SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email.lower()},
Account,
)
@@ -160,8 +161,11 @@ async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :value or username = :value",
{"value": username_or_email},
"""
SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower()},
Account,
)
@@ -183,6 +187,7 @@ async def get_user_from_account(
email=account.email,
username=account.username,
pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,

View File

@@ -711,3 +711,11 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
Adds icon and color columns to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")

View File

@@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
from lnbits.helpers import (
is_valid_email_address,
is_valid_external_id,
is_valid_pubkey,
is_valid_username,
)
from lnbits.settings import settings
from .wallets import Wallet
@@ -93,6 +98,7 @@ class UserAcls(BaseModel):
class Account(BaseModel):
id: str
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
@@ -130,6 +136,11 @@ class Account(BaseModel):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
@@ -143,7 +154,14 @@ class AccountOverview(Account):
class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
__search_fields__ = [
"user",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [
"balance_msat",
"email",
@@ -157,6 +175,7 @@ class AccountFilters(FilterModel):
user: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None
@@ -167,6 +186,7 @@ class User(BaseModel):
email: str | None = None
username: str | None = None
pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
@@ -207,13 +227,13 @@ class CreateUser(BaseModel):
password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None
extra: UserExtra | None = None
class UpdateUser(BaseModel):
user_id: str
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None

View File

@@ -272,10 +272,21 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.external_id"
:label="$t('external_id')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.extra.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>

View File

@@ -147,6 +147,14 @@
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.external_id"
:label="$t('external_id')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="activeUser.data.extra.picture"
:label="$t('picture')"

View File

@@ -411,23 +411,13 @@ async def update(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and data.email != user.email
and await get_account_by_email(data.email)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
account = await get_account(user.id)
if not account:
@@ -435,8 +425,6 @@ async def update(
if data.username:
account.username = data.username
if data.email:
account.email = data.email
if data.extra:
account.extra = data.extra

View File

@@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
username=data.username,
email=data.email,
pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra,
)
account.validate_fields()
@@ -132,6 +133,7 @@ async def api_update_user(
username=data.username,
email=data.email,
pubkey=data.pubkey,
external_id=data.external_id,
extra=data.extra or UserExtra(),
)
await update_user_account(account)

View File

@@ -198,6 +198,14 @@ def is_valid_username(username: str) -> bool:
return re.fullmatch(username_regex, username) is not None
def is_valid_external_id(external_id: str) -> bool:
if len(external_id) > 256:
return False
if " " in external_id or "\n" in external_id:
return False
return True
def is_valid_pubkey(pubkey: str) -> bool:
if len(pubkey) != 64:
return False

File diff suppressed because one or more lines are too long

View File

@@ -350,6 +350,7 @@ window.localisation.en = {
update_account: 'Update Account',
invalid_username: 'Invalid Username',
auth_provider: 'Auth Provider',
external_id: 'External ID',
my_account: 'My Account',
existing_account_question: 'Already have an account?',
background_image: 'Background Image',

382
tests/api/test_users.py Normal file
View File

@@ -0,0 +1,382 @@
from typing import Any
import pytest
import shortuuid
from httpx import AsyncClient
from lnbits.core.models.users import User
from lnbits.settings import Settings
from lnbits.utils.nostr import generate_keypair
@pytest.mark.anyio
async def test_create_user_success(http_client: AsyncClient, superuser_token):
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"user_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"user_{tiny_id}@lnbits.com",
}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
resp = response.json()
assert resp["username"] == data["username"]
assert resp["email"] == data["email"]
assert resp["id"] is not None
@pytest.mark.anyio
async def test_create_user_passwords_do_not_match(
http_client: AsyncClient, superuser_token
):
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"user_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret0000",
"email": f"user_{tiny_id}@lnbits.com",
}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
assert response.json()["detail"] == "Passwords do not match."
@pytest.mark.anyio
async def test_create_user_missing_username_with_password(
http_client: AsyncClient, superuser_token
):
data = {
"password": "secret1234",
"password_repeat": "secret1234",
"email": "nouser@lnbits.com",
}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
assert response.json()["detail"] == "Username required when password provided."
@pytest.mark.anyio
async def test_create_user_no_password_random_generated(
http_client: AsyncClient, superuser_token
):
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"user_{tiny_id}",
"email": f"user_{tiny_id}@lnbits.com",
}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
resp = response.json()
assert resp["username"] == data["username"]
assert resp["email"] == data["email"]
assert resp["id"] is not None
assert resp["password"] is not None
@pytest.mark.anyio
async def test_create_user_with_extensions_and_extra(
http_client: AsyncClient, superuser_token
):
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"user_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"user_{tiny_id}@lnbits.com",
"extensions": ["testext1", "testext2"],
"extra": {"provider": "custom", "foo": "bar"},
}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
resp = response.json()
assert resp["username"] == data["username"]
assert resp["email"] == data["email"]
assert resp["id"] is not None
assert resp["extra"]["provider"] == "custom"
assert "foo" not in resp["extra"], "random fields should not be in extra"
@pytest.mark.anyio
async def test_create_user_minimum_fields(http_client: AsyncClient, superuser_token):
data: dict[str, str] = {}
response = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
resp = response.json()
assert resp["id"] is not None
assert resp["extra"]["provider"] == "lnbits"
@pytest.mark.anyio
async def test_create_user_duplicate_username(
http_client: AsyncClient, superuser_token
):
tiny_id = shortuuid.uuid()[:8]
username = f"user_{tiny_id}"
data = {
"username": username,
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"user_{tiny_id}@lnbits.com",
}
# First creation should succeed
response1 = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response1.status_code == 200
# Second creation with same username should fail
data2 = data.copy()
data2["email"] = f"other_{tiny_id}@lnbits.com"
response2 = await http_client.post(
"/users/api/v1/user",
json=data2,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response2.status_code == 400 or response2.status_code == 422
@pytest.mark.anyio
async def test_create_user_duplicate_email(http_client: AsyncClient, superuser_token):
tiny_id = shortuuid.uuid()[:8]
email = f"user_{tiny_id}@lnbits.com"
data = {
"username": f"user_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": email,
}
# First creation should succeed
response1 = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response1.status_code == 200
# Second creation with same email should fail
data2 = data.copy()
data2["username"] = f"other_{tiny_id}"
response2 = await http_client.post(
"/users/api/v1/user",
json=data2,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response2.status_code == 400 or response2.status_code == 422
@pytest.mark.anyio
async def test_update_user_success(http_client: AsyncClient, superuser_token):
# Create a user first
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"update_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"update_{tiny_id}@lnbits.com",
}
create_resp = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
user_id = create_resp.json()["id"]
# Update the user
_, pubkey = generate_keypair()
update_data = {
"id": user_id,
"username": f"updated_{tiny_id}",
"email": f"updated_{tiny_id}@lnbits.com",
"pubkey": pubkey,
"external_id": "external_1234",
"extra": {"provider": "lnbits"},
"extensions": [],
}
resp = await http_client.put(
f"/users/api/v1/user/{user_id}",
json=update_data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resp.status_code == 200
assert resp.json()["username"] == update_data["username"]
assert resp.json()["email"] == update_data["email"]
assert resp.json()["pubkey"] == update_data["pubkey"]
assert resp.json()["external_id"] == update_data["external_id"]
@pytest.mark.anyio
async def test_update_bad_external_id(
http_client: AsyncClient, user_alan: User, superuser_token
):
update_data = {"id": user_alan.id, "external_id": "external 1234"}
resp = await http_client.put(
f"/users/api/v1/user/{user_alan.id}",
json=update_data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resp.status_code == 400
assert (
resp.json()["detail"] == "Invalid external id. "
"Max length is 256 characters. Space and newlines are not allowed."
)
@pytest.mark.anyio
async def test_update_user_id_mismatch(http_client: AsyncClient, superuser_token):
# Create a user first
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"mismatch_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"mismatch_{tiny_id}@lnbits.com",
}
create_resp = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
user_id = create_resp.json()["id"]
# Try to update with mismatched id
update_data: dict[str, Any] = {
"id": "wrongid",
"username": f"updated_{tiny_id}",
"email": f"updated_{tiny_id}@lnbits.com",
"extra": {"provider": "lnbits"},
"extensions": [],
}
resp = await http_client.put(
f"/users/api/v1/user/{user_id}",
json=update_data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "User Id missmatch."
@pytest.mark.anyio
async def test_update_user_password_fields(http_client: AsyncClient, superuser_token):
# Create a user first
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"pwfield_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"pwfield_{tiny_id}@lnbits.com",
}
create_resp = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
user_id = create_resp.json()["id"]
# Try to update with password fields set
update_data = {
"id": user_id,
"username": f"updated_{tiny_id}",
"email": f"updated_{tiny_id}@lnbits.com",
"extra": {"provider": "lnbits"},
"extensions": [],
"password": "newpass1234",
"password_repeat": "newpass1234",
}
resp = await http_client.put(
f"/users/api/v1/user/{user_id}",
json=update_data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "Use 'reset password' functionality."
@pytest.mark.anyio
async def test_update_user_invalid_username(http_client: AsyncClient, superuser_token):
# Create a user first
tiny_id = shortuuid.uuid()[:8]
data = {
"username": f"valid_{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"valid_{tiny_id}@lnbits.com",
}
create_resp = await http_client.post(
"/users/api/v1/user",
json=data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert create_resp.status_code == 200
user_id = create_resp.json()["id"]
# Try to update with invalid username
update_data = {
"id": user_id,
"username": "!@#invalid", # invalid username
"email": f"valid_{tiny_id}@lnbits.com",
"extra": {"provider": "lnbits"},
"extensions": [],
}
resp = await http_client.put(
f"/users/api/v1/user/{user_id}",
json=update_data,
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid username."
@pytest.mark.anyio
async def test_update_superuser_only_allowed_by_superuser(
http_client: AsyncClient, user_alan: User, settings: Settings
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
assert response.status_code == 200, "Alan logs in OK."
alan_access_token = response.json().get("access_token")
assert alan_access_token is not None, "Expected access token after login."
settings.lnbits_admin_users = [user_alan.id]
update_data: dict[str, Any] = {
"id": settings.super_user,
"username": "superadmin",
"email": "superadmin@lnbits.com",
"extra": {"provider": "lnbits"},
"extensions": [],
}
resp = await http_client.put(
f"/users/api/v1/user/{settings.super_user}",
json=update_data,
headers={"Authorization": f"Bearer {alan_access_token}"},
)
assert resp.json()["detail"] == "Action only allowed for super user."