Remove remaining DB getter functions, inline at call site and add ResetToken.by_passphrase().
This commit is contained in:
@@ -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
@@ -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!")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user