Remove remaining DB getter functions, inline at call site and add ResetToken.by_passphrase().

This commit is contained in:
2026-02-10 22:48:31 +00:00
parent bad709a3ab
commit d41cffc03e
8 changed files with 52 additions and 96 deletions
+2 -1
View File
@@ -14,6 +14,7 @@ from uuid import UUID
from paskia import db from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil from paskia.util import hostutil
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -33,7 +34,7 @@ def reset_expires() -> datetime:
def get_reset(token: str) -> "ResetToken": def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token.""" """Validate a credential reset token."""
record = db.get_reset_token(token) record = ResetToken.by_passphrase(token)
if record: if record:
return record return record
raise ValueError("This authentication link is no longer valid.") raise ValueError("This authentication link is no longer valid.")
+1 -1
View File
@@ -81,7 +81,7 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials # Check first admin user for credentials
admin_user = admin_users[0] 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 # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!") logger.info("⚠️ Admin user has no credentials!")
-6
View File
@@ -48,9 +48,6 @@ from paskia.db.operations import (
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
get_config, get_config,
get_reset_token,
get_user_credential_ids,
get_user_organization,
login, login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
@@ -111,9 +108,6 @@ __all__ = [
"build_user", "build_user",
# Read ops # Read ops
"get_config", "get_config",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops # Write ops
"add_permission_to_org", "add_permission_to_org",
"add_permission_to_role", "add_permission_to_role",
-57
View File
@@ -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. Write operations: Functions that validate and commit, or raise ValueError.
""" """
import hashlib
import logging import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
@@ -29,7 +28,6 @@ from paskia.db.structs import (
SessionContext, SessionContext,
User, User,
) )
from paskia.util.passphrase import is_well_formed as _is_passphrase
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -40,61 +38,6 @@ _db._store = _store
_initialized = False _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) # Write operations (validate, modify, commit or raise ValueError)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+22 -4
View File
@@ -11,7 +11,12 @@ from msgspec import field
from paskia import db from paskia import db
from paskia.util.hostutil import normalize_host 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 # Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0) _UUID_UNSET = UUID(int=0)
@@ -229,6 +234,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all credentials for this user.""" """Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid] 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 @property
def sessions(self) -> list[Session]: def sessions(self) -> list[Session]:
"""Get all sessions for this user.""" """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.""" """Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self db.data().reset_tokens[self.key] = self
def delete(self) -> None: @classmethod
"""Delete this reset token from the database. Must be called inside a transaction.""" def by_passphrase(cls, passphrase: str) -> ResetToken | None:
del db.data().reset_tokens[self.key] """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 @classmethod
def create( def create(
+25 -25
View File
@@ -462,8 +462,8 @@ async def admin_update_user_role(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _current_role = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -471,7 +471,7 @@ async def admin_update_user_role(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -483,7 +483,7 @@ async def admin_update_user_role(
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValueError("Invalid role UUID") raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_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") raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access # Sanity check: prevent admin from removing their own access
@@ -511,8 +511,8 @@ async def admin_create_user_registration_link(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -521,13 +521,13 @@ async def admin_create_user_registration_link(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
# Check if user has existing credentials # 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" token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires() expiry = reset_expires()
@@ -552,8 +552,9 @@ async def admin_get_user_detail(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: role_name = user.role.display_name
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(
auth, auth,
@@ -561,17 +562,16 @@ async def admin_get_user_detail(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host")) normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse( return MsgspecResponse(
{ {
"display_name": user.display_name, "display_name": user.display_name,
"org": {"display_name": user_org.display_name}, "org": {"display_name": user.org.display_name},
"role": role_name, "role": role_name,
"visits": user.visits, "visits": user.visits,
"created_at": user.created_at, "created_at": user.created_at,
@@ -609,8 +609,8 @@ async def admin_update_user_display_name(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -618,7 +618,7 @@ async def admin_update_user_display_name(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" 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.""" """Delete a user and all their credentials/sessions."""
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -649,7 +649,7 @@ async def admin_delete_user(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -668,8 +668,8 @@ async def admin_delete_user_credential(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -678,7 +678,7 @@ async def admin_delete_user_credential(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -694,8 +694,8 @@ async def admin_delete_user_session(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: 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(
auth, auth,
@@ -703,7 +703,7 @@ async def admin_delete_user_session(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), 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( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
+1 -1
View File
@@ -52,7 +52,7 @@ async def websocket_register_add(
stripped = name.strip() stripped = name.strip()
if stripped: if stripped:
user_name = stripped user_name = stripped
credential_ids = db.get_user_credential_ids(user_uuid) or None credential_ids = user.credential_ids or None
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
+1 -1
View File
@@ -92,7 +92,7 @@ async def authenticate_and_login(
if auth: if auth:
existing_ctx = db.data().session_ctx(auth, host) existing_ctx = db.data().session_ctx(auth, host)
if existing_ctx: 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) cred, new_sign_count = await authenticate_chat(ws, credential_ids)