[feat] admin impersonate user (#3741)
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user