From ae4c982a3034c60fb778a82ca64e1d28b0dc1652 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 23 Jan 2026 20:19:33 +0000 Subject: [PATCH] Fix actor fields and transactions for API operations as they are recorded to DB. --- paskia/authsession.py | 29 ----------------------- paskia/db/operations.py | 50 +++++++++++++++++++++++++++++++++++++--- paskia/fastapi/admin.py | 12 ---------- paskia/fastapi/api.py | 4 ++-- paskia/fastapi/remote.py | 33 +++++++++++++++----------- paskia/fastapi/user.py | 7 +++--- paskia/fastapi/ws.py | 26 ++++++++++++++------- 7 files changed, 91 insertions(+), 70 deletions(-) diff --git a/paskia/authsession.py b/paskia/authsession.py index 0023664..a8af2e3 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -31,35 +31,6 @@ def reset_expires() -> datetime: return datetime.now(timezone.utc) + RESET_LIFETIME -async def create_session( - user_uuid: UUID, - credential_uuid: UUID, - *, - host: str, - ip: str, - user_agent: str, -) -> str: - """Create a new session and return a session token.""" - normalized_host = hostutil.normalize_host(host) - if not normalized_host: - raise ValueError("Host required for session creation") - hostname = normalized_host.split(":")[0] # Domain names only, IPs aren't supported - rp_id = passkey.instance.rp_id - if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): - raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") - token = create_token() - db.create_session( - user_uuid=user_uuid, - credential_uuid=credential_uuid, - key=session_key(token), - host=normalized_host, - ip=ip, - user_agent=user_agent, - expiry=expires(), - ) - return token - - async def get_reset(token: str) -> ResetToken: """Validate a credential reset token.""" record = db.get_reset_token(reset_key(token)) diff --git a/paskia/db/operations.py b/paskia/db/operations.py index fb76b40..c20c995 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -530,7 +530,10 @@ def delete_permission(uuid: str | UUID, actor: str = "system") -> None: def create_organization(org: Org, actor: str = "system") -> None: - """Create a new organization.""" + """Create a new organization with an Administration role. + + Automatically creates an 'Administration' role with auth:org:admin permission. + """ if org.uuid in _db._data.orgs: raise ValueError(f"Organization {org.uuid} already exists") with _db.transaction(actor): @@ -542,6 +545,15 @@ def create_organization(org: Org, actor: str = "system") -> None: for pid, p in _db._data.permissions.items(): if p.scope == scope: p.orgs[org.uuid] = True + # Create Administration role with org admin permission + import uuid7 + + admin_role_uuid = uuid7.create() + _db._data.roles[admin_role_uuid] = _RoleData( + org=org.uuid, + display_name="Administration", + permissions={"auth:org:admin": True}, + ) def update_organization_name( @@ -939,8 +951,25 @@ def cleanup_expired(actor: str = "system") -> int: # ------------------------------------------------------------------------- -def login(user_uuid: str | UUID, credential: Credential, actor: str = "system") -> None: - """Update user last_seen and credential sign_count/last_used on login.""" +def login( + user_uuid: str | UUID, + credential: Credential, + session_key: bytes, + host: str | None, + ip: str | None, + user_agent: str | None, + expiry: datetime, +) -> None: + """Update user/credential on login and create session in a single transaction. + + Updates: + - user.last_seen, user.visits + - credential.sign_count, credential.last_used + Creates: + - new session + + Actor is set to the user UUID being logged in. + """ if isinstance(user_uuid, str): user_uuid = UUID(user_uuid) now = datetime.now(timezone.utc) @@ -948,11 +977,26 @@ def login(user_uuid: str | UUID, credential: Credential, actor: str = "system") raise ValueError(f"User {user_uuid} not found") if credential.uuid not in _db._data.credentials: raise ValueError(f"Credential {credential.uuid} not found") + if session_key in _db._data.sessions: + raise ValueError("Session already exists") + + actor = str(user_uuid) with _db.transaction(actor): + # Update user _db._data.users[user_uuid].last_seen = now _db._data.users[user_uuid].visits += 1 + # Update credential _db._data.credentials[credential.uuid].sign_count = credential.sign_count _db._data.credentials[credential.uuid].last_used = now + # Create session + _db._data.sessions[session_key] = _SessionData( + user=user_uuid, + credential=credential.uuid, + host=host, + ip=ip, + user_agent=user_agent, + expiry=expiry, + ) def create_credential_session( diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index b09cc6f..221df10 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -136,7 +136,6 @@ async def admin_create_org( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) from ..db import Org as OrgDC # local import to avoid cycles - from ..db import Role as RoleDC # local import to avoid cycles actor = str(ctx.user.uuid) org_uuid = uuid4() @@ -145,17 +144,6 @@ async def admin_create_org( org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) db.create_organization(org, actor=actor) - # Automatically create Administration role with org admin permission - # The auth:org:admin permission is automatically created/enabled by create_organization - role_uuid = uuid4() - admin_role = RoleDC( - uuid=role_uuid, - org_uuid=org_uuid, - display_name="Administration", - permissions=["auth:org:admin"], - ) - db.create_role(admin_role, actor=actor) - return {"uuid": str(org_uuid)} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index e22d753..6ce6f79 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -289,11 +289,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): if not auth: return {"message": "Already logged out"} try: - await get_session(auth, host=request.headers.get("host")) + s = await get_session(auth, host=request.headers.get("host")) except ValueError: return {"message": "Already logged out"} with suppress(Exception): - db.delete_session(session_key(auth)) + db.delete_session(session_key(auth), actor=str(s.user_uuid)) session.clear_session_cookie(response) return {"message": "Logged out successfully"} diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index b5581a2..e97cffd 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -16,7 +16,6 @@ import base64url from fastapi import FastAPI, WebSocket, WebSocketDisconnect from paskia import db, remoteauth -from paskia.authsession import create_session from paskia.fastapi.session import infodict from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey @@ -334,9 +333,6 @@ async def websocket_remote_auth_permit(ws: WebSocket): credential, webauthn_challenge, stored_cred, origin ) - # Update credential last_used - db.login(stored_cred.user_uuid, stored_cred) - # Create a session for the REQUESTING device assert stored_cred.uuid is not None @@ -346,7 +342,7 @@ async def websocket_remote_auth_permit(ws: WebSocket): if request.action == "register": # For registration, create a reset token for device addition from paskia.authsession import expires - from paskia.util import tokens + from paskia.util import hostutil, tokens token_str = passphrase.generate() expiry = expires() @@ -355,25 +351,36 @@ async def websocket_remote_auth_permit(ws: WebSocket): key=tokens.reset_key(token_str), expiry=expiry, token_type="device addition", + actor=str(stored_cred.user_uuid), ) reset_token = token_str - # Also create a session so the device is logged in? - # User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities." - session_token = await create_session( + # Also create a session so the device is logged in + session_token = passphrase.generate() + normalized_host = hostutil.normalize_host(request.host) + db.login( user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=request.host, + credential=stored_cred, + session_key=tokens.session_key(session_token), + host=normalized_host, ip=request.ip, user_agent=request.user_agent, + expiry=expires(), ) else: # Default login action - session_token = await create_session( + from paskia.authsession import expires + from paskia.util import hostutil, tokens + + session_token = passphrase.generate() + normalized_host = hostutil.normalize_host(request.host) + db.login( user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=request.host, + credential=stored_cred, + session_key=tokens.session_key(session_token), + host=normalized_host, ip=request.ip, user_agent=request.user_agent, + expiry=expires(), ) # Complete the remote auth request (notifies the waiting device) diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index ddb4172..2d4a16e 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -55,7 +55,7 @@ async def user_update_display_name( raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - db.update_user_display_name(s.user_uuid, new_name) + db.update_user_display_name(s.user_uuid, new_name, actor=str(s.user_uuid)) return {"status": "ok"} @@ -69,7 +69,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE) raise authz.AuthException( status_code=401, detail="Session expired", mode="login" ) - db.delete_sessions_for_user(s.user_uuid) + db.delete_sessions_for_user(s.user_uuid, actor=str(s.user_uuid)) session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -103,7 +103,7 @@ async def api_delete_session( if not target_session or target_session.user_uuid != current_session.user_uuid: raise HTTPException(status_code=404, detail="Session not found") - db.delete_session(target_key) + db.delete_session(target_key, actor=str(current_session.user_uuid)) current_terminated = target_key == session_key(auth) if current_terminated: session.clear_session_cookie(response) # explicit because 200 @@ -149,6 +149,7 @@ async def api_create_link( key=tokens.reset_key(token), expiry=expiry, token_type="device addition", + actor=str(s.user_uuid), ) url = hostutil.reset_link_url(token) return { diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index e260911..83d75ad 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -3,12 +3,12 @@ from uuid import UUID from fastapi import FastAPI, WebSocket from paskia import db -from paskia.authsession import create_session, get_reset, get_session +from paskia.authsession import expires, get_reset, get_session from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey -from paskia.util import passphrase +from paskia.util import hostutil, passphrase from paskia.util.tokens import create_token, session_key # Create a FastAPI subapp for WebSocket endpoints @@ -89,6 +89,7 @@ async def websocket_register_add( host=host, ip=metadata.get("ip"), user_agent=metadata.get("user_agent"), + actor=str(user_uuid), ) auth = token @@ -140,18 +141,27 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): # Verify the credential matches the stored data passkey.instance.auth_verify(credential, challenge, stored_cred, origin) - # Update both credential and user's last_seen timestamp - db.login(stored_cred.user_uuid, stored_cred) - # Create a session token for the authenticated user + # Create session and update user/credential in a single transaction assert stored_cred.uuid is not None metadata = infodict(ws, "auth") - token = await create_session( + token = create_token() + normalized_host = hostutil.normalize_host(host) + if not normalized_host: + raise ValueError("Host required for session creation") + hostname = normalized_host.split(":")[0] + rp_id = passkey.instance.rp_id + if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): + raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") + + db.login( user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=host, + credential=stored_cred, + session_key=session_key(token), + host=normalized_host, ip=metadata.get("ip") or "", user_agent=metadata.get("user_agent") or "", + expiry=expires(), ) await ws.send_json(