diff --git a/paskia/authsession.py b/paskia/authsession.py index 9eb3f11..f8bf667 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -14,6 +14,7 @@ from uuid import UUID from paskia import db from paskia.config import RESET_LIFETIME, SESSION_LIFETIME +from paskia.db.structs import ResetToken from paskia.util import hostutil if TYPE_CHECKING: @@ -33,7 +34,7 @@ def reset_expires() -> datetime: def get_reset(token: str) -> "ResetToken": """Validate a credential reset token.""" - record = db.get_reset_token(token) + record = ResetToken.by_passphrase(token) if record: return record raise ValueError("This authentication link is no longer valid.") diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index a6dae16..327c5a0 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -81,7 +81,7 @@ async def check_admin_credentials() -> bool: # Check first admin user for credentials admin_user = admin_users[0] - if not db.get_user_credential_ids(admin_user.uuid): + if not admin_user.credential_ids: # Admin exists but has no credentials, create reset link logger.info("⚠️ Admin user has no credentials!") diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index cd19278..be7a287 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -48,9 +48,6 @@ from paskia.db.operations import ( delete_sessions_for_user, delete_user, get_config, - get_reset_token, - get_user_credential_ids, - get_user_organization, login, remove_permission_from_org, remove_permission_from_role, @@ -111,9 +108,6 @@ __all__ = [ "build_user", # Read ops "get_config", - "get_reset_token", - "get_user_credential_ids", - "get_user_organization", # Write ops "add_permission_to_org", "add_permission_to_role", diff --git a/paskia/db/operations.py b/paskia/db/operations.py index eec2775..0617cfe 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -6,7 +6,6 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per Write operations: Functions that validate and commit, or raise ValueError. """ -import hashlib import logging from datetime import UTC, datetime, timedelta from uuid import UUID @@ -29,7 +28,6 @@ from paskia.db.structs import ( SessionContext, User, ) -from paskia.util.passphrase import is_well_formed as _is_passphrase _logger = logging.getLogger(__name__) @@ -40,61 +38,6 @@ _db._store = _store _initialized = False -# ------------------------------------------------------------------------- -# Read/lookup functions -# ------------------------------------------------------------------------- - - -def get_user_organization(user_uuid: UUID) -> tuple[Org, str]: - """Get the organization a user belongs to and their role name. - - Raises ValueError if user not found. - - Call sites: - - admin_create_user_registration_link: org only - - admin_get_user_detail: org and role - - admin_update_user_display_name: org only - - admin_delete_user_credential: org only - - admin_delete_user_session: org only - - admin_update_user_role: org only - """ - if user_uuid not in _db.users: - raise ValueError(f"User {user_uuid} not found") - user = _db.users[user_uuid] - role = user.role - return role.org, role.display_name - - -def get_user_credential_ids(user_uuid: UUID) -> list[bytes]: - """Get credential IDs for a user (for WebAuthn exclude lists). - - Returns empty list if user has no credentials. - """ - assert user_uuid - return [c.credential_id for c in _db.users[user_uuid].credentials] - - -def _reset_key(passphrase: str) -> bytes: - """Hash a passphrase to bytes for reset token storage.""" - if not _is_passphrase(passphrase): - raise ValueError( - "Trying to reset with a session token in place of a passphrase" - if len(passphrase) == 16 - else "Invalid passphrase format" - ) - return hashlib.sha512(passphrase.encode()).digest()[:9] - - -def get_reset_token(passphrase: str) -> ResetToken | None: - """Get reset token by passphrase. - - Call sites: - - Get reset token to validate it (authsession.py:34) - """ - key = _reset_key(passphrase) - return _db.reset_tokens.get(key) - - # ------------------------------------------------------------------------- # Write operations (validate, modify, commit or raise ValueError) # ------------------------------------------------------------------------- diff --git a/paskia/db/structs.py b/paskia/db/structs.py index d31a2c9..9e62481 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -11,7 +11,12 @@ from msgspec import field from paskia import db from paskia.util.hostutil import normalize_host -from paskia.util.passphrase import generate as generate_passphrase +from paskia.util.passphrase import ( + generate as generate_passphrase, +) +from paskia.util.passphrase import ( + is_well_formed as _is_passphrase, +) # Sentinel for uuid fields before they are set by create() or DB post init _UUID_UNSET = UUID(int=0) @@ -229,6 +234,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True): """Get all credentials for this user.""" return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid] + @property + def credential_ids(self) -> list[bytes]: + """Get credential IDs for this user (for WebAuthn exclude lists).""" + return [c.credential_id for c in self.credentials] + @property def sessions(self) -> list[Session]: """Get all sessions for this user.""" @@ -452,9 +462,17 @@ class ResetToken(msgspec.Struct, dict=True): """Store this reset token in the database. Must be called inside a transaction.""" db.data().reset_tokens[self.key] = self - def delete(self) -> None: - """Delete this reset token from the database. Must be called inside a transaction.""" - del db.data().reset_tokens[self.key] + @classmethod + def by_passphrase(cls, passphrase: str) -> ResetToken | None: + """Get a reset token by passphrase.""" + if not _is_passphrase(passphrase): + raise ValueError( + "Trying to reset with a session token in place of a passphrase" + if len(passphrase) == 16 + else "Invalid passphrase format" + ) + key = hashlib.sha512(passphrase.encode()).digest()[:9] + return db.data().reset_tokens.get(key) @classmethod def create( diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 20d861c..1a2c5b1 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -462,8 +462,8 @@ async def admin_update_user_role( auth=AUTH_COOKIE, ): try: - user_org, _current_role = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -471,7 +471,7 @@ async def admin_update_user_role( match=permutil.has_any, host=request.headers.get("host"), ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) @@ -483,7 +483,7 @@ async def admin_update_user_role( except (ValueError, TypeError): raise ValueError("Invalid role UUID") new_role = db.data().roles.get(new_role_uuid) - if not new_role or new_role.org_uuid != user_org.uuid: + if not new_role or new_role.org_uuid != user.org.uuid: raise ValueError("Role not found in organization") # Sanity check: prevent admin from removing their own access @@ -511,8 +511,8 @@ async def admin_create_user_registration_link( auth=AUTH_COOKIE, ): try: - user_org, _role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -521,13 +521,13 @@ async def admin_create_user_registration_link( host=request.headers.get("host"), max_age="5m", ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) # Check if user has existing credentials - has_credentials = db.get_user_credential_ids(user_uuid) + has_credentials = db.data().users[user_uuid].credential_ids token_type = "user registration" if not has_credentials else "account recovery" expiry = reset_expires() @@ -552,8 +552,9 @@ async def admin_get_user_detail( auth=AUTH_COOKIE, ): try: - user_org, role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + role_name = user.role.display_name + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -561,17 +562,16 @@ async def admin_get_user_detail( match=permutil.has_any, host=request.headers.get("host"), ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - user = db.data().users.get(user_uuid) normalized_host = hostutil.normalize_host(request.headers.get("host")) return MsgspecResponse( { "display_name": user.display_name, - "org": {"display_name": user_org.display_name}, + "org": {"display_name": user.org.display_name}, "role": role_name, "visits": user.visits, "created_at": user.created_at, @@ -609,8 +609,8 @@ async def admin_update_user_display_name( auth=AUTH_COOKIE, ): try: - user_org, _role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -618,7 +618,7 @@ async def admin_update_user_display_name( match=permutil.has_any, host=request.headers.get("host"), ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) @@ -639,8 +639,8 @@ async def admin_delete_user( ): """Delete a user and all their credentials/sessions.""" try: - user_org, _role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -649,7 +649,7 @@ async def admin_delete_user( host=request.headers.get("host"), max_age="5m", ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) @@ -668,8 +668,8 @@ async def admin_delete_user_credential( auth=AUTH_COOKIE, ): try: - user_org, _role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -678,7 +678,7 @@ async def admin_delete_user_credential( host=request.headers.get("host"), max_age="5m", ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) @@ -694,8 +694,8 @@ async def admin_delete_user_session( auth=AUTH_COOKIE, ): try: - user_org, _role_name = db.get_user_organization(user_uuid) - except ValueError: + user = db.data().users[user_uuid] + except KeyError: raise HTTPException(status_code=404, detail="User not found") ctx = await authz.verify( auth, @@ -703,7 +703,7 @@ async def admin_delete_user_session( match=permutil.has_any, host=request.headers.get("host"), ) - if not can_manage_org(ctx, user_org.uuid): + if not can_manage_org(ctx, user.org.uuid): raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index faa7ae1..30649d7 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -52,7 +52,7 @@ async def websocket_register_add( stripped = name.strip() if stripped: user_name = stripped - credential_ids = db.get_user_credential_ids(user_uuid) or None + credential_ids = user.credential_ids or None # WebAuthn registration credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) diff --git a/paskia/fastapi/wschat.py b/paskia/fastapi/wschat.py index 1611ef0..9fd0cb9 100644 --- a/paskia/fastapi/wschat.py +++ b/paskia/fastapi/wschat.py @@ -92,7 +92,7 @@ async def authenticate_and_login( if auth: existing_ctx = db.data().session_ctx(auth, host) if existing_ctx: - credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None + credential_ids = existing_ctx.user.credential_ids or None cred, new_sign_count = await authenticate_chat(ws, credential_ids)