API cleanup: msgspec structs for all JSON responses, aaguid icon normalization, User kw_only, migration v4.
This commit is contained in:
@@ -30,4 +30,16 @@ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
|
|||||||
Dictionary mapping AAGUID string to authenticator information for only
|
Dictionary mapping AAGUID string to authenticator information for only
|
||||||
the AAGUIDs that the user has and that we have data for
|
the AAGUIDs that the user has and that we have data for
|
||||||
"""
|
"""
|
||||||
return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
|
result = {}
|
||||||
|
for a in aaguids:
|
||||||
|
s = str(a)
|
||||||
|
if s in AAGUID:
|
||||||
|
info = AAGUID[s].copy()
|
||||||
|
# Rename icon_light to icon
|
||||||
|
if "icon_light" in info:
|
||||||
|
info["icon"] = info.pop("icon_light")
|
||||||
|
# If icons are the same, set dark to None to save space
|
||||||
|
if info.get("icon") == info.get("icon_dark"):
|
||||||
|
info["icon_dark"] = None
|
||||||
|
result[s] = info
|
||||||
|
return result
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from paskia.db.operations import (
|
|||||||
delete_session,
|
delete_session,
|
||||||
delete_sessions_for_user,
|
delete_sessions_for_user,
|
||||||
delete_user,
|
delete_user,
|
||||||
|
is_username_taken,
|
||||||
login,
|
login,
|
||||||
oidc_login,
|
oidc_login,
|
||||||
remove_permission_from_org,
|
remove_permission_from_org,
|
||||||
@@ -64,7 +65,6 @@ from paskia.db.operations import (
|
|||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
update_user_info,
|
update_user_info,
|
||||||
update_user_role,
|
update_user_role,
|
||||||
is_username_taken,
|
|
||||||
)
|
)
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ def bootstrap(
|
|||||||
created_at=now,
|
created_at=now,
|
||||||
last_seen=None,
|
last_seen=None,
|
||||||
visits=0,
|
visits=0,
|
||||||
|
theme="",
|
||||||
)
|
)
|
||||||
admin_user.uuid = user_uuid
|
admin_user.uuid = user_uuid
|
||||||
admin_user.store()
|
admin_user.store()
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
|
|||||||
|
|
||||||
|
|
||||||
def migrate_v3(d: dict, **kwargs) -> None:
|
def migrate_v3(d: dict, **kwargs) -> None:
|
||||||
|
"""Ensure all users have visits field."""
|
||||||
|
for user_data in d["users"].values():
|
||||||
|
user_data.setdefault("visits", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v4(d: dict, **kwargs) -> None:
|
||||||
"""OpenID Connect support and hardened session keys."""
|
"""OpenID Connect support and hardened session keys."""
|
||||||
d["oid_clients"] = {}
|
d["oid_clients"] = {}
|
||||||
d["sessions"] = {
|
d["sessions"] = {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, dict=True, omit_defaults=True):
|
class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||||
"""User data structure.
|
"""User data structure.
|
||||||
|
|
||||||
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
|
Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username
|
||||||
@@ -206,9 +206,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
display_name: str
|
display_name: str
|
||||||
role_uuid: UUID = msgspec.field(name="role")
|
role_uuid: UUID = msgspec.field(name="role")
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
visits: int
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
visits: int = 0
|
theme: str = ""
|
||||||
theme: str = "" # "" or "auto" = OS default, "light", "dark"
|
|
||||||
email: str | None = None # OIDC email claim
|
email: str | None = None # OIDC email claim
|
||||||
preferred_username: str | None = None # OIDC preferred_username claim
|
preferred_username: str | None = None # OIDC preferred_username claim
|
||||||
telephone: str | None = None # Telephone number
|
telephone: str | None = None # Telephone number
|
||||||
@@ -278,6 +278,9 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
role_uuid=role_uuid,
|
role_uuid=role_uuid,
|
||||||
created_at=created_at or datetime.now(UTC),
|
created_at=created_at or datetime.now(UTC),
|
||||||
|
last_seen=None,
|
||||||
|
visits=0,
|
||||||
|
theme="",
|
||||||
)
|
)
|
||||||
user.uuid = uuid7.create(user.created_at)
|
user.uuid = uuid7.create(user.created_at)
|
||||||
return user
|
return user
|
||||||
|
|||||||
+46
-66
@@ -24,7 +24,16 @@ from paskia.util import (
|
|||||||
querysafe,
|
querysafe,
|
||||||
vitedev,
|
vitedev,
|
||||||
)
|
)
|
||||||
from paskia.util.apistructs import ApiPermission, ApiSession, format_datetime
|
from paskia.util.apistructs import (
|
||||||
|
ApiAaguidInfo,
|
||||||
|
ApiCreateLinkResponse,
|
||||||
|
ApiOrgResponse,
|
||||||
|
ApiPermission,
|
||||||
|
ApiUser,
|
||||||
|
ApiUserDetail,
|
||||||
|
ApiUserSession,
|
||||||
|
ApiUuidResponse,
|
||||||
|
)
|
||||||
from paskia.util.hostutil import normalize_host
|
from paskia.util.hostutil import normalize_host
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
@@ -86,34 +95,15 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
|||||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||||
|
|
||||||
def org_to_dict(o):
|
def org_to_dict(o):
|
||||||
return {
|
roles = o.roles
|
||||||
"uuid": o.uuid,
|
return ApiOrgResponse(
|
||||||
"display_name": o.display_name,
|
org=o,
|
||||||
"permissions": {p.uuid for p in o.permissions},
|
permissions={p.uuid: p for p in o.permissions},
|
||||||
"roles": [
|
roles={r.uuid: r for r in roles},
|
||||||
{
|
users={u.uuid: u for r in roles for u in r.users},
|
||||||
"uuid": r.uuid,
|
)
|
||||||
"org": r.org_uuid,
|
|
||||||
"display_name": r.display_name,
|
|
||||||
"permissions": list(r.permissions.keys()),
|
|
||||||
}
|
|
||||||
for r in o.roles
|
|
||||||
],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"uuid": u.uuid,
|
|
||||||
"display_name": u.display_name,
|
|
||||||
"role": r.display_name,
|
|
||||||
"role_uuid": u.role_uuid,
|
|
||||||
"visits": u.visits,
|
|
||||||
"last_seen": u.last_seen,
|
|
||||||
}
|
|
||||||
for r in o.roles
|
|
||||||
for u in r.users
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return MsgspecResponse([org_to_dict(o) for o in orgs])
|
return MsgspecResponse({o.uuid: org_to_dict(o) for o in orgs})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/orgs")
|
@app.post("/orgs")
|
||||||
@@ -132,7 +122,7 @@ async def admin_create_org(
|
|||||||
for perm in permissions:
|
for perm in permissions:
|
||||||
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
||||||
|
|
||||||
return {"uuid": str(org.uuid)}
|
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/orgs/{org_uuid}")
|
@app.patch("/orgs/{org_uuid}")
|
||||||
@@ -281,7 +271,7 @@ async def admin_create_role(
|
|||||||
permissions=permission_uuids,
|
permissions=permission_uuids,
|
||||||
)
|
)
|
||||||
db.create_role(role, ctx=ctx)
|
db.create_role(role, ctx=ctx)
|
||||||
return {"uuid": str(role.uuid)}
|
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/roles/{role_uuid}")
|
@app.patch("/roles/{role_uuid}")
|
||||||
@@ -454,7 +444,7 @@ async def admin_create_user(
|
|||||||
role=role_obj.uuid,
|
role=role_obj.uuid,
|
||||||
)
|
)
|
||||||
db.create_user(user, ctx=ctx)
|
db.create_user(user, ctx=ctx)
|
||||||
return {"uuid": str(user.uuid)}
|
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/users/{user_uuid}/role")
|
@app.patch("/users/{user_uuid}/role")
|
||||||
@@ -541,11 +531,13 @@ async def admin_create_user_registration_link(
|
|||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(token)
|
url = hostutil.reset_link_url(token)
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"url": url,
|
ApiCreateLinkResponse(
|
||||||
"expires": format_datetime(expiry),
|
url=url,
|
||||||
"token_type": token_type,
|
expires=expiry,
|
||||||
}
|
token_type=token_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/users/{user_uuid}")
|
@app.get("/users/{user_uuid}")
|
||||||
@@ -556,7 +548,6 @@ async def admin_get_user_detail(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user = db.data().users[user_uuid]
|
user = db.data().users[user_uuid]
|
||||||
role_name = user.role.display_name
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
@@ -571,39 +562,28 @@ async def admin_get_user_detail(
|
|||||||
)
|
)
|
||||||
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
|
||||||
return MsgspecResponse(
|
sessions = [
|
||||||
{
|
ApiUserSession.from_db(
|
||||||
"display_name": user.display_name,
|
|
||||||
"org": {"display_name": user.org.display_name},
|
|
||||||
"role": role_name,
|
|
||||||
"visits": user.visits,
|
|
||||||
"created_at": user.created_at,
|
|
||||||
"last_seen": user.last_seen,
|
|
||||||
"email": user.email,
|
|
||||||
"preferred_username": user.preferred_username,
|
|
||||||
"telephone": user.telephone,
|
|
||||||
"credentials": [
|
|
||||||
{
|
|
||||||
"credential": c.uuid,
|
|
||||||
"aaguid": c.aaguid,
|
|
||||||
"created_at": c.created_at,
|
|
||||||
"last_used": c.last_used,
|
|
||||||
"last_verified": c.last_verified,
|
|
||||||
"sign_count": c.sign_count,
|
|
||||||
}
|
|
||||||
for c in user.credentials
|
|
||||||
],
|
|
||||||
"aaguid_info": aaguid_mod.filter(c.aaguid for c in user.credentials),
|
|
||||||
"sessions": [
|
|
||||||
ApiSession.from_db(
|
|
||||||
s,
|
s,
|
||||||
current_key=ctx.session.key,
|
current_key=auth,
|
||||||
normalized_host=normalized_host,
|
normalized_host=normalized_host,
|
||||||
expires_delta=EXPIRES,
|
expires_delta=EXPIRES,
|
||||||
)
|
)
|
||||||
for s in user.sessions
|
for s in user.sessions
|
||||||
],
|
]
|
||||||
}
|
|
||||||
|
return MsgspecResponse(
|
||||||
|
ApiUserDetail(
|
||||||
|
user=ApiUser.from_db(user),
|
||||||
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
|
aaguid_info={
|
||||||
|
k: ApiAaguidInfo(**v)
|
||||||
|
for k, v in aaguid_mod.filter(
|
||||||
|
c.aaguid for c in user.credentials
|
||||||
|
).items()
|
||||||
|
},
|
||||||
|
sessions=sessions,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -852,7 +832,7 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||||
return MsgspecResponse([ApiPermission.from_db(p) for p in perms])
|
return MsgspecResponse({p.uuid: ApiPermission.from_db(p) for p in perms})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/permissions")
|
@app.post("/permissions")
|
||||||
|
|||||||
+24
-18
@@ -21,6 +21,7 @@ from paskia.fastapi.response import MsgspecResponse
|
|||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||||
|
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -100,11 +101,11 @@ async def validate_token(
|
|||||||
session.set_session_cookie(response, auth)
|
session.set_session_cookie(response, auth)
|
||||||
renewed = True
|
renewed = True
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
{
|
ApiValidateResponse(
|
||||||
"valid": True,
|
valid=True,
|
||||||
"renewed": renewed,
|
renewed=renewed,
|
||||||
"ctx": userinfo.build_session_context(ctx),
|
ctx=userinfo.build_session_context(ctx),
|
||||||
}
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,15 +190,17 @@ async def forward_authentication(
|
|||||||
async def get_settings():
|
async def get_settings():
|
||||||
pk = global_passkey.instance
|
pk = global_passkey.instance
|
||||||
base_path = hostutil.ui_base_path()
|
base_path = hostutil.ui_base_path()
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"rp_id": pk.rp_id,
|
ApiSettings(
|
||||||
"rp_name": pk.rp_name,
|
rp_id=pk.rp_id,
|
||||||
"ui_base_path": base_path,
|
rp_name=pk.rp_name,
|
||||||
"auth_host": hostutil.dedicated_auth_host(),
|
ui_base_path=base_path,
|
||||||
"auth_site_url": hostutil.auth_site_url(),
|
auth_host=hostutil.dedicated_auth_host(),
|
||||||
"session_cookie": AUTH_COOKIE_NAME,
|
auth_site_url=hostutil.auth_site_url(),
|
||||||
"version": __version__,
|
session_cookie=AUTH_COOKIE_NAME,
|
||||||
}
|
version=__version__,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/user-info")
|
@app.post("/user-info")
|
||||||
@@ -223,6 +226,7 @@ async def api_user_info(
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
session_record=ctx.session,
|
session_record=ctx.session,
|
||||||
request_host=request.headers.get("host"),
|
request_host=request.headers.get("host"),
|
||||||
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,10 +245,12 @@ async def token_info(credentials=Depends(bearer_auth)):
|
|||||||
raise HTTPException(401, str(e))
|
raise HTTPException(401, str(e))
|
||||||
|
|
||||||
u = reset_token.user
|
u = reset_token.user
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"token_type": reset_token.token_type,
|
ApiTokenInfo(
|
||||||
"display_name": u.display_name,
|
token_type=reset_token.token_type,
|
||||||
}
|
display_name=u.display_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from fastapi.responses import FileResponse, RedirectResponse
|
|||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend
|
||||||
|
|
||||||
from paskia import authcode, globals
|
from paskia import authcode, globals
|
||||||
|
from paskia.__main__ import DEVMODE
|
||||||
from paskia.db import start_background, stop_background
|
from paskia.db import start_background, stop_background
|
||||||
from paskia.db.logging import configure_db_logging
|
from paskia.db.logging import configure_db_logging
|
||||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||||
from paskia.__main__ import DEVMODE
|
|
||||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, passphrase, vitedev
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
|
|||||||
+10
-11
@@ -1,4 +1,3 @@
|
|||||||
from datetime import UTC
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import base64url
|
import base64url
|
||||||
@@ -17,8 +16,10 @@ from paskia.authsession import (
|
|||||||
expires,
|
expires,
|
||||||
)
|
)
|
||||||
from paskia.fastapi import authz, session
|
from paskia.fastapi import authz, session
|
||||||
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
|
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||||
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
@@ -207,13 +208,11 @@ async def api_create_link(
|
|||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
url = hostutil.reset_link_url(token)
|
url = hostutil.reset_link_url(token)
|
||||||
return {
|
return MsgspecResponse(
|
||||||
"message": "Registration link generated successfully",
|
ApiCreateLinkResponse(
|
||||||
"url": url,
|
message="Registration link generated successfully",
|
||||||
"expires": (
|
url=url,
|
||||||
expiry.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
expires=expiry,
|
||||||
if expiry.tzinfo
|
token_type="device addition",
|
||||||
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
)
|
||||||
),
|
)
|
||||||
"token_type": "device addition",
|
|
||||||
}
|
|
||||||
|
|||||||
+131
-33
@@ -1,36 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
"""API response utilities using msgspec for JSON serialization.
|
"""API response utilities using msgspec for JSON serialization.
|
||||||
|
|
||||||
msgspec handles UUID and datetime conversion automatically.
|
msgspec handles UUID and datetime conversion automatically.
|
||||||
API structs inherit from db structs with kw_only=True to add uuid/key fields.
|
API structs inherit from db structs with kw_only=True to add uuid/key fields.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.db.structs import Org, Permission, Role, User
|
from paskia.db.structs import Credential, Org, Permission, Role, User
|
||||||
from paskia.util import useragent
|
from paskia.util import useragent
|
||||||
|
|
||||||
|
|
||||||
def _utc_datetime(dt: datetime | None) -> datetime | None:
|
|
||||||
"""Convert datetime to UTC, handling both aware and naive datetimes."""
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo:
|
|
||||||
return dt.astimezone(UTC)
|
|
||||||
return dt.replace(tzinfo=UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def format_datetime(dt: datetime | None) -> str | None:
|
|
||||||
"""Format a datetime to ISO 8601 string with Z suffix for UTC."""
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
utc_dt = _utc_datetime(dt)
|
|
||||||
return utc_dt.isoformat().replace("+00:00", "Z") if utc_dt else None
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# API structs - inherit from db structs, add uuid for serialization
|
# API structs - inherit from db structs, add uuid for serialization
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -42,7 +26,7 @@ class ApiUser(User, kw_only=True):
|
|||||||
uuid: UUID
|
uuid: UUID
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, u: User) -> "ApiUser":
|
def from_db(cls, u: User) -> ApiUser:
|
||||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +36,7 @@ class ApiOrg(Org, kw_only=True):
|
|||||||
uuid: UUID
|
uuid: UUID
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, o: Org) -> "ApiOrg":
|
def from_db(cls, o: Org) -> ApiOrg:
|
||||||
return cls(uuid=o.uuid, **msgspec.structs.asdict(o))
|
return cls(uuid=o.uuid, **msgspec.structs.asdict(o))
|
||||||
|
|
||||||
|
|
||||||
@@ -62,28 +46,42 @@ class ApiRole(Role, kw_only=True):
|
|||||||
uuid: UUID
|
uuid: UUID
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, r: Role) -> "ApiRole":
|
def from_db(cls, r: Role) -> ApiRole:
|
||||||
return cls(uuid=r.uuid, **msgspec.structs.asdict(r))
|
return cls(uuid=r.uuid, **msgspec.structs.asdict(r))
|
||||||
|
|
||||||
|
|
||||||
class ApiPermission(Permission, kw_only=True):
|
class ApiPermission(msgspec.Struct, kw_only=True):
|
||||||
"""Permission with uuid serialized."""
|
"""Permission for API responses, without org details."""
|
||||||
|
|
||||||
uuid: UUID
|
scope: str
|
||||||
|
display_name: str
|
||||||
|
domain: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_db(cls, p: Permission) -> "ApiPermission":
|
def from_db(cls, p: Permission) -> ApiPermission:
|
||||||
return cls(uuid=p.uuid, **msgspec.structs.asdict(p))
|
return cls(
|
||||||
|
scope=p.scope,
|
||||||
|
display_name=p.display_name,
|
||||||
|
domain=p.domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiSession(msgspec.Struct, omit_defaults=True):
|
class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True):
|
||||||
"""Session for API responses with computed fields."""
|
"""AAGUID information for authenticators."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
icon: str | None = None
|
||||||
|
icon_dark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUserSession(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""Session for user info responses with computed fields."""
|
||||||
|
|
||||||
id: str
|
|
||||||
credential_uuid: UUID = msgspec.field(name="credential")
|
credential_uuid: UUID = msgspec.field(name="credential")
|
||||||
host: str
|
host: str
|
||||||
ip: str
|
ip: str
|
||||||
user_agent: str
|
user_agent: str
|
||||||
|
expiry: datetime
|
||||||
last_renewed: datetime
|
last_renewed: datetime
|
||||||
is_current: bool = False
|
is_current: bool = False
|
||||||
is_current_host: bool = False
|
is_current_host: bool = False
|
||||||
@@ -98,17 +96,17 @@ class ApiSession(msgspec.Struct, omit_defaults=True):
|
|||||||
current_key: str,
|
current_key: str,
|
||||||
normalized_host: str | None,
|
normalized_host: str | None,
|
||||||
expires_delta, # timedelta
|
expires_delta, # timedelta
|
||||||
) -> "ApiSession":
|
) -> ApiUserSession:
|
||||||
client_name = None
|
client_name = None
|
||||||
if s.client_uuid:
|
if s.client_uuid:
|
||||||
c = db.data().oid_clients.get(s.client_uuid)
|
c = db.data().oid_clients.get(s.client_uuid)
|
||||||
client_name = c.name if c else str(s.client_uuid)
|
client_name = c.name if c else str(s.client_uuid)
|
||||||
return cls(
|
return cls(
|
||||||
id=s.key,
|
|
||||||
credential_uuid=s.credential_uuid,
|
credential_uuid=s.credential_uuid,
|
||||||
host=s.host,
|
host=s.host,
|
||||||
ip=s.ip,
|
ip=s.ip,
|
||||||
user_agent=useragent.compact_user_agent(s.user_agent),
|
user_agent=useragent.compact_user_agent(s.user_agent),
|
||||||
|
expiry=s.expiry,
|
||||||
last_renewed=s.expiry - expires_delta,
|
last_renewed=s.expiry - expires_delta,
|
||||||
is_current=s.key == current_key,
|
is_current=s.key == current_key,
|
||||||
is_current_host=not s.client_uuid
|
is_current_host=not s.client_uuid
|
||||||
@@ -116,3 +114,103 @@ class ApiSession(msgspec.Struct, omit_defaults=True):
|
|||||||
client_uuid=s.client_uuid,
|
client_uuid=s.client_uuid,
|
||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUserDetail(msgspec.Struct, kw_only=True):
|
||||||
|
"""User detail response with credentials and sessions."""
|
||||||
|
|
||||||
|
user: ApiUser
|
||||||
|
credentials: dict[UUID, Credential]
|
||||||
|
aaguid_info: dict[str, ApiAaguidInfo]
|
||||||
|
sessions: list[ApiUserSession]
|
||||||
|
permissions: dict[UUID, ApiPermission] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Nested API structs for org response - without uuid
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||||
|
"""Org response containing Org with roles and users as UUID-keyed dicts."""
|
||||||
|
|
||||||
|
org: Org
|
||||||
|
permissions: dict[UUID, Permission]
|
||||||
|
roles: dict[UUID, Role]
|
||||||
|
users: dict[UUID, User]
|
||||||
|
|
||||||
|
|
||||||
|
class ApiSettings(msgspec.Struct):
|
||||||
|
"""Settings response struct."""
|
||||||
|
|
||||||
|
rp_id: str
|
||||||
|
rp_name: str
|
||||||
|
ui_base_path: str
|
||||||
|
auth_host: str | None
|
||||||
|
auth_site_url: str
|
||||||
|
session_cookie: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenInfo(msgspec.Struct):
|
||||||
|
"""Token info response struct."""
|
||||||
|
|
||||||
|
token_type: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUuidResponse(msgspec.Struct):
|
||||||
|
"""Response struct for creation endpoints returning a UUID."""
|
||||||
|
|
||||||
|
uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCreateLinkResponse(msgspec.Struct):
|
||||||
|
"""Response struct for create-link endpoints."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
expires: datetime
|
||||||
|
token_type: str
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUserContext(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""User context for session validation."""
|
||||||
|
|
||||||
|
uuid: UUID
|
||||||
|
display_name: str
|
||||||
|
theme: str = ""
|
||||||
|
email: str | None = None
|
||||||
|
preferred_username: str | None = None
|
||||||
|
telephone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiOrgContext(msgspec.Struct):
|
||||||
|
"""Org context for session validation."""
|
||||||
|
|
||||||
|
uuid: UUID
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiRoleContext(msgspec.Struct):
|
||||||
|
"""Role context for session validation."""
|
||||||
|
|
||||||
|
uuid: UUID
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiSessionContext(msgspec.Struct):
|
||||||
|
"""Session context struct."""
|
||||||
|
|
||||||
|
user: ApiUserContext
|
||||||
|
org: ApiOrgContext
|
||||||
|
role: ApiRoleContext
|
||||||
|
permissions: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ApiValidateResponse(msgspec.Struct):
|
||||||
|
"""Response struct for validate endpoint."""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
renewed: bool
|
||||||
|
ctx: ApiSessionContext
|
||||||
|
|||||||
+50
-46
@@ -3,27 +3,38 @@
|
|||||||
from paskia import aaguid, db
|
from paskia import aaguid, db
|
||||||
from paskia.authsession import EXPIRES
|
from paskia.authsession import EXPIRES
|
||||||
from paskia.db import SessionContext
|
from paskia.db import SessionContext
|
||||||
from paskia.util import hostutil, permutil
|
from paskia.util import hostutil
|
||||||
from paskia.util.apistructs import ApiSession
|
from paskia.util.apistructs import (
|
||||||
|
ApiAaguidInfo,
|
||||||
|
ApiOrgContext,
|
||||||
|
ApiPermission,
|
||||||
|
ApiRoleContext,
|
||||||
|
ApiSessionContext,
|
||||||
|
ApiUser,
|
||||||
|
ApiUserContext,
|
||||||
|
ApiUserDetail,
|
||||||
|
ApiUserSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_session_context(ctx: SessionContext) -> dict:
|
def build_session_context(ctx: SessionContext) -> ApiSessionContext:
|
||||||
"""Build session context dict from SessionContext."""
|
"""Build session context struct from SessionContext."""
|
||||||
result = {
|
user = ApiUserContext(
|
||||||
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
|
uuid=ctx.user.uuid,
|
||||||
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
|
display_name=ctx.user.display_name,
|
||||||
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
|
theme=ctx.user.theme,
|
||||||
"permissions": [p.scope for p in ctx.permissions],
|
email=ctx.user.email,
|
||||||
}
|
preferred_username=ctx.user.preferred_username,
|
||||||
if ctx.user.theme:
|
telephone=ctx.user.telephone,
|
||||||
result["user"]["theme"] = ctx.user.theme
|
)
|
||||||
if ctx.user.email:
|
org = ApiOrgContext(uuid=ctx.org.uuid, display_name=ctx.org.display_name)
|
||||||
result["user"]["email"] = ctx.user.email
|
role = ApiRoleContext(uuid=ctx.role.uuid, display_name=ctx.role.display_name)
|
||||||
if ctx.user.preferred_username:
|
return ApiSessionContext(
|
||||||
result["user"]["preferred_username"] = ctx.user.preferred_username
|
user=user,
|
||||||
if ctx.user.telephone:
|
org=org,
|
||||||
result["user"]["telephone"] = ctx.user.telephone
|
role=role,
|
||||||
return result
|
permissions=[p.scope for p in ctx.permissions],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def build_user_info(
|
async def build_user_info(
|
||||||
@@ -32,38 +43,31 @@ async def build_user_info(
|
|||||||
auth: str,
|
auth: str,
|
||||||
session_record,
|
session_record,
|
||||||
request_host: str | None,
|
request_host: str | None,
|
||||||
) -> dict:
|
ctx: SessionContext | None = None,
|
||||||
"""Build user info dict for authenticated users."""
|
) -> ApiUserDetail:
|
||||||
ctx = await permutil.session_context(auth, request_host)
|
"""Build user info struct for authenticated users."""
|
||||||
user = db.data().users[user_uuid]
|
user = db.data().users[user_uuid]
|
||||||
normalized_host = hostutil.normalize_host(request_host)
|
normalized_host = hostutil.normalize_host(request_host)
|
||||||
|
|
||||||
credentials = sorted(user.credentials, key=lambda c: c.created_at)
|
sessions = [
|
||||||
return {
|
ApiUserSession.from_db(
|
||||||
"ctx": build_session_context(ctx),
|
|
||||||
"created_at": ctx.user.created_at,
|
|
||||||
"last_seen": ctx.user.last_seen,
|
|
||||||
"visits": ctx.user.visits,
|
|
||||||
"credentials": [
|
|
||||||
{
|
|
||||||
"credential": c.uuid,
|
|
||||||
"aaguid": c.aaguid,
|
|
||||||
"created_at": c.created_at,
|
|
||||||
"last_used": c.last_used,
|
|
||||||
"last_verified": c.last_verified,
|
|
||||||
"sign_count": c.sign_count,
|
|
||||||
"is_current_session": session_record.credential == c.uuid,
|
|
||||||
}
|
|
||||||
for c in credentials
|
|
||||||
],
|
|
||||||
"aaguid_info": aaguid.filter(c.aaguid for c in credentials),
|
|
||||||
"sessions": [
|
|
||||||
ApiSession.from_db(
|
|
||||||
s,
|
s,
|
||||||
current_key=ctx.session.key,
|
current_key=session_record.key,
|
||||||
normalized_host=normalized_host,
|
normalized_host=normalized_host,
|
||||||
expires_delta=EXPIRES,
|
expires_delta=EXPIRES,
|
||||||
)
|
)
|
||||||
for s in user.sessions
|
for s in user.sessions
|
||||||
],
|
]
|
||||||
}
|
|
||||||
|
return ApiUserDetail(
|
||||||
|
user=ApiUser.from_db(user),
|
||||||
|
credentials={c.uuid: c for c in user.credentials},
|
||||||
|
aaguid_info={
|
||||||
|
k: ApiAaguidInfo(**v)
|
||||||
|
for k, v in aaguid.filter(c.aaguid for c in user.credentials).items()
|
||||||
|
},
|
||||||
|
sessions=sessions,
|
||||||
|
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
||||||
|
if ctx
|
||||||
|
else {},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user