[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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user