[feat] access control lists (with access tokens) (#2864)

This commit is contained in:
Vlad Stan
2025-01-16 17:25:27 +02:00
committed by GitHub
parent f415a92914
commit b164317121
25 changed files with 2131 additions and 67 deletions
+57 -21
View File
@@ -18,6 +18,7 @@ from lnbits.core.crud import (
get_user_from_account,
get_wallet_for_key,
)
from lnbits.core.crud.users import get_user_access_control_lists
from lnbits.core.models import (
AccessTokenPayload,
Account,
@@ -140,7 +141,7 @@ async def check_user_exists(
usr: Optional[UUID4] = None,
) -> User:
if access_token:
account = await _get_account_from_token(access_token)
account = await _get_account_from_token(access_token, r["path"], r["method"])
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
account = await get_account(usr.hex)
else:
@@ -161,13 +162,14 @@ async def check_user_exists(
async def optional_user_id(
r: Request,
access_token: Annotated[Optional[str], Depends(check_access_token)],
usr: Optional[UUID4] = None,
) -> Optional[str]:
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
return usr.hex
if access_token:
account = await _get_account_from_token(access_token)
account = await _get_account_from_token(access_token, r["path"], r["method"])
return account.id if account else None
return None
@@ -257,9 +259,8 @@ async def check_user_extension_access(
return SimpleStatus(success=True, message="OK")
async def _check_user_extension_access(user_id: str, current_path: str):
path = current_path.split("/")
ext_id = path[3] if path[1] == "upgrades" else path[1]
async def _check_user_extension_access(user_id: str, path: str):
ext_id = _path_segments(path)[0]
status = await check_user_extension_access(user_id, ext_id)
if not status.success:
raise HTTPException(
@@ -268,16 +269,14 @@ async def _check_user_extension_access(user_id: str, current_path: str):
)
async def _get_account_from_token(access_token) -> Optional[Account]:
async def _get_account_from_token(
access_token: str, path: str, method: str
) -> Optional[Account]:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
user = await _get_user_from_jwt_payload(payload)
if not user:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
)
return user
return await _get_account_from_jwt_payload(
AccessTokenPayload(**payload), path, method
)
except jwt.ExpiredSignatureError as exc:
raise HTTPException(
@@ -288,11 +287,48 @@ async def _get_account_from_token(access_token) -> Optional[Account]:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
return await get_account(str(payload.get("usr")))
if "email" in payload and payload.get("email"):
return await get_account_by_email(str(payload.get("email")))
return None
async def _get_account_from_jwt_payload(
payload: AccessTokenPayload, path: str, method: str
) -> Optional[Account]:
account = None
if payload.sub is not None:
account = await get_account_by_username(payload.sub)
if payload.usr is not None:
account = await get_account(payload.usr)
if payload.email is not None:
account = await get_account_by_email(payload.email)
if not account:
return None
if payload.api_token_id:
await _check_account_api_access(account.id, payload.api_token_id, path, method)
return account
async def _check_account_api_access(
user_id: str, token_id: str, path: str, method: str
):
segments = path.split("/")
if len(segments) < 3:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
acls = await get_user_access_control_lists(user_id)
acl = acls.get_acl_by_token_id(token_id)
if not acl:
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
path = "/" + "/".join(_path_segments(path)[:3])
endpoint = acl.get_endpoint(path)
if not endpoint:
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
if not endpoint.supports_method(method):
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
def _path_segments(path: str) -> list[str]:
segments = path.split("/")
if segments[1] == "upgrades":
return segments[3:]
return segments[1:]