[feat] User activation (#3749)

This commit is contained in:
Vlad Stan
2026-02-25 07:54:00 +01:00
committed by dni ⚡
parent 40c055e373
commit 7e327b2c6a
16 changed files with 339 additions and 50 deletions
+75 -21
View File
@@ -4,10 +4,12 @@ from typing import Any
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import create_wallet, get_wallets
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import (
Account,
@@ -41,6 +43,7 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
await clear_user_id_cache(user_id)
async def get_accounts(
@@ -69,6 +72,7 @@ async def get_accounts(
accounts.email,
accounts.pubkey,
accounts.external_id,
accounts.activated,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
@@ -93,12 +97,18 @@ async def get_accounts(
)
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
"""
SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
Account,
)
@@ -124,55 +134,79 @@ async def delete_accounts_no_wallets(
async def get_account_by_username(
username: str, conn: Connection | None = None
username: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(username) = :username",
{"username": username.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Connection | None = None
pubkey: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
{"pubkey": pubkey.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account,
)
async def get_account_by_email(
email: str, conn: Connection | None = None
email: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(email) = :email",
{"email": email.lower()},
"""
SELECT * FROM accounts
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Connection | None = None
username_or_email: str,
active_only: bool = True,
conn: Connection | None = None,
) -> Account | None:
return await (conn or db).fetchone(
"""
SELECT * FROM accounts
WHERE LOWER(email) = :value or LOWER(username) = :value
WHERE
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
""",
{"value": username_or_email.lower()},
{"value": username_or_email.lower(), "activated": active_only},
Account,
)
async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
async def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, conn=conn)
account = await get_account(user_id, active_only, conn=conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
@@ -191,6 +225,7 @@ async def get_user_from_account(
return User(
id=account.id,
activated=account.activated,
email=account.email,
username=account.username,
pubkey=account.pubkey,
@@ -216,12 +251,31 @@ async def update_user_access_control_list(
async def get_user_access_control_lists(
user_id: str, conn: Connection | None = None
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
{"id": user_id},
"""
SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
+20 -8
View File
@@ -50,7 +50,7 @@ async def delete_wallet(
deleted: bool = True,
conn: Connection | None = None,
) -> None:
_clear_wallet_cache(wallet_id)
clear_wallet_id_cache(wallet_id)
now = int(time())
await (conn or db).execute(
@@ -65,7 +65,7 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
_clear_wallet_cache(wallet_id)
clear_wallet_id_cache(wallet_id)
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
@@ -75,7 +75,7 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None
) -> int | None:
_clear_wallet_cache(wallet_id)
clear_wallet_id_cache(wallet_id)
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
@@ -226,11 +226,14 @@ async def get_wallet_for_key(
) -> Wallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT wallets.*, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
Wallet,
@@ -250,8 +253,11 @@ async def get_base_wallet_for_key(
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
BaseWallet,
@@ -294,8 +300,14 @@ async def get_total_balance(conn: Connection | None = None):
return row.get("balance", 0) or 0
def _clear_wallet_cache(wallet_id):
def clear_wallet_id_cache(wallet_id: str):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
def clear_wallet_cache(wallet: Wallet):
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
+8
View File
@@ -856,3 +856,11 @@ async def m043_add_ui_customization_to_accounts(db: Connection):
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
+3
View File
@@ -181,6 +181,7 @@ class AccountId(BaseModel):
class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking
username: str | None = None
password_hash: str | None = None
@@ -241,6 +242,7 @@ class Account(AccountId):
class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
@@ -276,6 +278,7 @@ class AccountFilters(FilterModel):
class User(BaseModel):
id: str
activated: bool = True
created_at: datetime
updated_at: datetime
email: str | None = None
+6 -3
View File
@@ -86,6 +86,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email)
@@ -94,7 +95,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
account = await get_account_by_pubkey(event["pubkey"], active_only=False)
if not account:
account = Account(
id=uuid4().hex,
@@ -102,6 +103,8 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email)
@@ -357,7 +360,7 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username):
if await get_account_by_username(data.username, active_only=False):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email):
@@ -527,7 +530,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
account = await get_account_by_email(email)
account = await get_account_by_email(email, active_only=False)
if verified_user_id:
if account:
+40 -2
View File
@@ -20,6 +20,7 @@ from lnbits.core.crud import (
update_admin_settings,
update_wallet,
)
from lnbits.core.crud.users import clear_user_id_cache, get_account, update_account
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import (
AccountFilters,
@@ -73,7 +74,7 @@ async def api_get_users(
summary="Get user by Id",
)
async def api_get_user(user_id: str) -> User:
user = await get_user(user_id)
user = await get_user(user_id, active_only=False)
if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user
@@ -197,7 +198,7 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}"
@users_router.get(
@users_router.put(
"/user/{user_id}/admin",
dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user",
@@ -220,6 +221,43 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
)
@users_router.put(
"/user/{user_id}/activate",
name="Activate or deactivate a user",
)
async def api_users_toggle_activated(
user_id: str, admin_account: Account = Depends(check_admin)
) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot deactivate super user.",
)
if user_id == admin_account.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You cannot deactivate yourself.",
)
if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False)
if not user_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="User not found.",
)
user_account.activated = not user_account.activated
await update_account(user_account)
await clear_user_id_cache(user_id)
return SimpleStatus(
success=True,
message=f"User {'activated' if user_account.activated else 'deactivated'}.",
)
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id, deleted=None)
+2 -4
View File
@@ -9,6 +9,7 @@ from fastapi import (
)
from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet,
get_wallets_paginated,
)
@@ -37,7 +38,6 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.utils.cache import cache
from ..crud import (
delete_wallet,
@@ -134,9 +134,7 @@ async def api_reset_wallet_keys(
if not wallet or wallet.user != account_id.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
clear_wallet_cache(wallet)
wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex
+1
View File
@@ -261,6 +261,7 @@ async def check_account_id_exists(
account_id,
expiry=settings.auth_authentication_cache_minutes * 60,
)
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
return account_id
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -178,6 +178,8 @@ window.localisation.en = {
installed: 'Installed',
activated: 'Activated',
deactivated: 'Deactivated',
activate: 'Activate',
deactivate: 'Deactivate',
release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured',
@@ -186,6 +188,8 @@ window.localisation.en = {
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
+1 -1
View File
@@ -147,7 +147,7 @@ window._lnbitsApi = {
name: name,
wallet_type: walletType,
...opts
}).catch(LNbits.utils.notifyApiError)
})
},
updateWallet(name, wallet) {
return this.request('patch', '/api/v1/wallet', wallet.adminkey, {
@@ -97,6 +97,7 @@ window.app.component('lnbits-wallet-new', {
this.g.lastWalletId = res.data.id
this.$router.push(`/wallet/${res.data.id}`)
})
.catch(LNbits.utils.notifyApiError)
}
},
created() {
+17 -4
View File
@@ -70,10 +70,10 @@ window.PageUsers = {
usersTable: {
columns: [
{
name: 'admin',
name: 'activated',
align: 'left',
label: 'Admin',
field: 'admin',
label: this.$t('activated'),
field: 'activated',
sortable: false
},
{
@@ -401,7 +401,7 @@ window.PageUsers = {
toggleAdmin(userId) {
LNbits.api
.request('GET', `/users/api/v1/user/${userId}/admin`)
.request('PUT', `/users/api/v1/user/${userId}/admin`)
.then(() => {
this.fetchUsers()
Quasar.Notify.create({
@@ -412,6 +412,19 @@ window.PageUsers = {
})
.catch(LNbits.utils.notifyApiError)
},
toggleUserActivated(userId) {
LNbits.api
.request('PUT', `/users/api/v1/user/${userId}/activate`)
.then(res => {
this.fetchUsers()
Quasar.Notify.create({
type: 'positive',
message: res.data.message,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
async showAccountPage(user_id) {
this.activeUser.showPassword = false
this.activeUser.showUserId = false
+33 -5
View File
@@ -218,6 +218,26 @@
:label="$t('update_account')"
class="q-ml-md"
></q-btn>
<q-btn
v-if="activeUser.data.id"
outline
color="primary"
class="q-ml-md"
>
<q-toggle
size="xs"
color="secondary"
v-model="activeUser.data.admin"
@update:model-value="toggleAdmin(activeUser.data.id)"
:label="
activeUser.data.admin
? $t('revoke_admin')
: $t('make_user_admin')
"
>
</q-toggle>
</q-btn>
<q-btn
v-else
@click="createUser()"
@@ -590,9 +610,9 @@
<q-btn
@click="showAccountPage(props.row.id)"
round
icon="edit"
:icon="props.row.is_admin ? 'admin_panel_settings' : 'edit'"
size="sm"
color="secondary"
:color="props.row.is_admin ? 'primary' : 'secondary'"
class="q-ml-xs"
>
<q-tooltip>
@@ -605,10 +625,18 @@
size="xs"
v-if="!props.row.is_super_user"
color="secondary"
v-model="props.row.is_admin"
@update:model-value="toggleAdmin(props.row.id)"
v-model="props.row.activated"
@update:model-value="toggleUserActivated(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
<q-tooltip
><span
v-text="
props.row.activated
? $t('deactivate')
: $t('activate')
"
></span
></q-tooltip>
</q-toggle>
<q-btn
round
+126
View File
@@ -1,11 +1,13 @@
from typing import Any
from uuid import uuid4
import jwt
import pytest
import shortuuid
from httpx import AsyncClient
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.models import AccessTokenPayload, Payment
from lnbits.core.models.users import Account, User
from lnbits.core.services.users import create_user_account
from lnbits.settings import Settings
@@ -610,3 +612,127 @@ async def test_delete_and_undelete_wallet(http_client: AsyncClient, superuser_to
undeleted_wallet = next((w for w in wallets if w.id == wallet_id), None)
assert undeleted_wallet is not None
assert undeleted_wallet.deleted is False
@pytest.mark.anyio
async def test_user_activation(
http_client: AsyncClient, invoice: Payment, settings: Settings, superuser_token: str
):
# Register a new user
username = f"u21.{shortuuid.uuid()[:8]}"
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": username,
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"{username}@lnbits.com",
},
)
access_token = response.json().get("access_token")
assert response.status_code == 200, "User created."
assert response.json().get("access_token") is not None
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
user_id = access_token_payload.usr
assert user_id is not None
# Login works
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 200, "User logs in OK"
# Deactivate the user
respones = await http_client.put(
f"/users/api/v1/user/{user_id}/activate",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert respones.status_code == 200, "User deactivated."
assert respones.json().get("message") == "User deactivated."
# Login should now fail
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 401
assert response.json().get("detail") == "Invalid credentials."
response = await http_client.get(
"/api/v1/auth",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 401
assert response.json().get("detail") == "User not found."
wallets = await get_wallets(user_id=user_id)
assert len(wallets) == 1, "User's wallet still exists."
wallet = wallets[0]
response = await http_client.get(
"/api/v1/payments/paginated",
params={"limit": 2},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
response = await http_client.post(
"/api/v1/payments",
json={
"out": False,
"amount": 1000,
"memo": "test payment",
},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
data = {"out": True, "bolt11": invoice.bolt11}
response = await http_client.post(
"/api/v1/payments",
json=data,
headers={"x-Api-Key": wallet.adminkey},
)
assert response.status_code == 404
assert response.json().get("detail") == "Wallet not found."
# Reactivate the user
response = await http_client.put(
f"/users/api/v1/user/{user_id}/activate",
headers={"Authorization": f"Bearer {superuser_token}"},
)
print("### response", response.text)
assert response.status_code == 200
assert response.json().get("message") == "User activated."
# Login should now pass
response = await http_client.post(
"/api/v1/auth", json={"username": username, "password": "secret1234"}
)
assert response.status_code == 200, "User logs in OK again."
response = await http_client.get(
"/api/v1/payments/paginated",
params={"limit": 2},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 200
response = await http_client.post(
"/api/v1/payments",
json={
"out": False,
"amount": 1000,
"memo": "test payment",
},
headers={"x-Api-Key": wallet.inkey},
)
assert response.status_code == 201