[feat] admin impersonate user (#3741)

This commit is contained in:
Vlad Stan
2026-01-28 16:59:23 +01:00
committed by GitHub
parent b22f88b264
commit 631f4e8455
13 changed files with 359 additions and 19 deletions
+60 -1
View File
@@ -4,9 +4,10 @@ import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Annotated
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
@@ -29,6 +30,7 @@ from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import (
@@ -120,6 +122,63 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl")
async def api_get_user_acls(
request: Request,
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -695,6 +695,8 @@ window.localisation.en = {
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
+13
View File
@@ -123,6 +123,19 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout'
})
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
@@ -60,5 +60,17 @@ window.app.component('lnbits-header', {
return 'User'
}
}
},
methods: {
async stopImpersonation() {
try {
await LNbits.api.stopImpersonation()
LNbits.utils.restoreLocalStorage('impersonation')
window.location = '/users'
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
}
}
})
+1
View File
@@ -15,6 +15,7 @@ window.g = Vue.reactive({
wallet: null,
isPublicPage: true,
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'),
offline: !navigator.onLine,
hasCamera: false,
visibleDrawer: false,
+14
View File
@@ -438,6 +438,20 @@ window.PageUsers = {
this.activeUser.show = false
}
},
async impersonateUser(user_id) {
try {
await LNbits.api.impersonateUser(user_id)
LNbits.utils.backupLocalStorage('impersonation', true)
this.$q.localStorage.setItem('lnbits.disclaimerShown', true)
window.location = '/wallet'
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'warning',
message: 'Failed to impersonate user!'
})
}
},
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
+23
View File
@@ -67,6 +67,29 @@ window._lnbitsUtils = {
}
})
},
backupLocalStorage(backupKey, cleanup = false) {
const lnbitsEntries =
Object.entries(Quasar.LocalStorage.getAll()).filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
) || []
Quasar.LocalStorage.setItem(`lnbits.${backupKey}`, lnbitsEntries)
if (cleanup) {
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
}
},
restoreLocalStorage(backupKey) {
Object.entries(Quasar.LocalStorage.getAll())
.filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
)
.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
const lnbitsEntries =
Quasar.LocalStorage.getItem(`lnbits.${backupKey}`) || []
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.setItem(k, v))
Quasar.LocalStorage.remove(`lnbits.${backupKey}`)
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
+14 -1
View File
@@ -72,7 +72,6 @@
</q-badge>
<lnbits-language-dropdown></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<template v-slot:label>
<q-avatar
@@ -136,6 +135,20 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="g.isUserImpersonated"
@click="stopImpersonation"
rounded
size="sm"
class="q-pl-sm"
color="negative"
icon="face_retouching_off"
label="Stop"
>
<q-tooltip
><span v-text="$t('stop_user_impersonation')"></span
></q-tooltip>
</q-btn>
</q-toolbar>
</q-header>
</template>
+14 -1
View File
@@ -656,7 +656,20 @@
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td>
<q-btn
icon="face"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="impersonateUser(props.row.id)"
>
<q-tooltip
><span v-text="$t('start_user_impersonation')"></span
></q-tooltip>
</q-btn>
<span v-text="props.row.username"></span>
</q-td>
<q-td v-text="props.row.email"></q-td>
+190
View File
@@ -2047,3 +2047,193 @@ async def test_api_update_user_labels(http_client: AsyncClient):
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)"""
in data["detail"][0]["msg"]
)
@pytest.mark.anyio
async def test_impersonate_user_success(http_client: AsyncClient, admin_user: User):
tiny_id = shortuuid.uuid()[:8]
user_id = uuid4().hex
account = Account(
id=user_id,
username=f"u_{tiny_id}",
email=f"u_{tiny_id}@lnbits.com",
)
account.hash_password("secret1234")
await create_user_account(account)
# Login as admin to get access token
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
assert response.status_code == 200
admin_token = response.json()["access_token"]
# Impersonate the user
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": user_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# Check impersonation cookies
assert "cookie_access_token" in response.cookies
assert "admin_access_token" in response.cookies
assert response.cookies.get("is_lnbits_user_impersonated") == "true"
response = await http_client.delete("/api/v1/auth/impersonate")
assert "cookie_access_token" in response.cookies
assert response.cookies.get("cookie_access_token") == admin_token
assert "admin_access_token" not in response.cookies
assert "is_lnbits_user_impersonated" not in response.cookies
@pytest.mark.anyio
async def test_impersonate_user_no_cookie(http_client: AsyncClient, admin_user: User):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
user_id = uuid4().hex
http_client.cookies.clear()
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": user_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Only cookie based impersonation is allowed."
@pytest.mark.anyio
async def test_impersonate_user_self(http_client: AsyncClient, admin_user: User):
# Admin tries to impersonate themselves
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": admin_user.id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "You cannot impersonate yourself."
@pytest.mark.anyio
async def test_impersonate_user_admin_target(
http_client: AsyncClient, admin_user: User, settings: Settings
):
other_admin_id = uuid4().hex
tiny_id = shortuuid.uuid()[:8]
account = Account(
id=other_admin_id, username=f"u_{tiny_id}", email=f"u_{tiny_id}@lnbits.com"
)
account.hash_password("secret1234")
await create_user_account(account)
settings.lnbits_admin_users.append(other_admin_id)
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": other_admin_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "You cannot impersonate another admin user."
@pytest.mark.anyio
async def test_impersonate_user_nonexistent(
http_client: AsyncClient, admin_user: User, settings: Settings
):
http_client.cookies.clear()
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
fake_id = "deadbeef"
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": fake_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "User ID does not exist."
@pytest.mark.anyio
async def test_impersonate_user_invalid_data(
http_client: AsyncClient, admin_user: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
assert "type_error.none.not_allowed" in str(response.json()["detail"])
@pytest.mark.anyio
async def test_impersonate_user_missing_usr_field(
http_client: AsyncClient, admin_user: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
assert "value_error.missing" in str(response.json()["detail"])
@pytest.mark.anyio
async def test_impersonate_user_by_non_admin(http_client: AsyncClient, user_alan: User):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
alan_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={},
headers={"Authorization": f"Bearer {alan_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "User not authorized. No admin privileges."
@pytest.mark.anyio
async def test_stop_impersonate_user_by_non_admin(
http_client: AsyncClient, user_alan: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
response = await http_client.delete("/api/v1/auth/impersonate")
assert response.status_code == 401
assert (
response.json()["detail"]
== "No admin access token found to stop impersonation."
)
+5 -5
View File
@@ -14,9 +14,9 @@ from lnbits.core.crud import (
delete_account,
get_account_by_username,
get_payment,
get_user,
update_payment,
)
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance
@@ -114,10 +114,10 @@ async def user_alan():
@pytest.fixture(scope="session")
async def admin_user():
username = "admin"
account = await get_account_by_username(username)
if account:
return await get_user_from_account(account)
username = "admin_" + uuid4().hex[:8]
user = await get_user(ADMIN_USER_ID)
if user:
return user
account = Account(
id=ADMIN_USER_ID,