Renaming of users in registration, profile and admin app.

This commit is contained in:
Leo Vasanko
2025-09-01 18:13:01 -06:00
parent bc87f76d11
commit 37eaffff3f
9 changed files with 232 additions and 21 deletions
+25
View File
@@ -100,6 +100,10 @@ class DatabaseInterface(ABC):
async def create_user(self, user: User) -> None:
"""Create a new user."""
@abstractmethod
async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None:
"""Update a user's display name."""
# Role operations
@abstractmethod
async def create_role(self, role: Role) -> None:
@@ -312,6 +316,27 @@ class DatabaseInterface(ABC):
async def get_session_context(self, session_key: bytes) -> SessionContext | None:
"""Get complete session context including user, organization, role, and permissions."""
# Combined atomic operations
@abstractmethod
async def create_credential_session(
self,
user_uuid: UUID,
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
session_expires: datetime,
session_info: dict,
display_name: str | None = None,
) -> None:
"""Atomically add a credential and create a session.
Steps (single transaction):
1. Insert credential
2. Optionally delete old session (e.g. reset token) if provided
3. Optionally update user's display name
4. Insert new session referencing the credential
"""
__all__ = [
"User",
+60
View File
@@ -271,6 +271,17 @@ class DB(DatabaseInterface):
async with self.session() as session:
session.add(UserModel.from_dataclass(user))
async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None:
async with self.session() as session:
stmt = (
update(UserModel)
.where(UserModel.uuid == user_uuid.bytes)
.values(display_name=display_name)
)
result = await session.execute(stmt)
if result.rowcount == 0: # type: ignore[attr-defined]
raise ValueError("User not found")
async def create_role(self, role: Role) -> None:
async with self.session() as session:
# Create role record
@@ -389,6 +400,55 @@ class DB(DatabaseInterface):
)
session.add(credential_model)
async def create_credential_session(
self,
user_uuid: UUID,
credential: Credential,
reset_key: bytes | None,
session_key: bytes,
session_expires: datetime,
session_info: dict,
display_name: str | None = None,
) -> None:
"""Atomic credential + (optional old session delete) + (optional rename) + new session."""
async with self.session() as session:
# Insert credential
session.add(
CredentialModel(
uuid=credential.uuid.bytes,
credential_id=credential.credential_id,
user_uuid=credential.user_uuid.bytes,
aaguid=credential.aaguid.bytes,
public_key=credential.public_key,
sign_count=credential.sign_count,
created_at=credential.created_at,
last_used=credential.last_used,
last_verified=credential.last_verified,
)
)
# Delete old session if provided
if reset_key:
await session.execute(
delete(SessionModel).where(SessionModel.key == reset_key)
)
# Optional rename
if display_name:
await session.execute(
update(UserModel)
.where(UserModel.uuid == user_uuid.bytes)
.values(display_name=display_name)
)
# New session
session.add(
SessionModel(
key=session_key,
user_uuid=user_uuid.bytes,
credential_uuid=credential.uuid.bytes,
expires=session_expires,
info=session_info,
)
)
async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None:
async with self.session() as session:
stmt = (
+34
View File
@@ -487,6 +487,40 @@ def register_api_routes(app: FastAPI):
"aaguid_info": aaguid_info,
}
@app.put("/auth/user/display-name")
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
"""Authenticated user updates their own display name."""
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth)
new_name = (payload.get("display_name") or "").strip()
if not new_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")
await db.instance.update_user_display_name(s.user_uuid, new_name)
return {"status": "ok"}
@app.put("/auth/admin/users/{user_uuid}/display-name")
async def admin_update_user_display_name(
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
):
"""Admin updates a user's display name."""
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
try:
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
raise HTTPException(status_code=403, detail="Insufficient permissions")
new_name = (payload.get("display_name") or "").strip()
if not new_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")
await db.instance.update_user_display_name(user_uuid, new_name)
return {"status": "ok"}
# Admin API: Permissions (global)
@app.get("/auth/admin/permissions")
+20 -14
View File
@@ -5,9 +5,10 @@ from uuid import UUID
from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, get_reset, get_session
from ..authsession import create_session, expires, get_reset, get_session
from ..globals import db, passkey
from ..util import passphrase
from ..util.tokens import create_token, session_key
from .session import infodict
@@ -55,7 +56,7 @@ async def register_chat(
@app.websocket("/register")
@websocket_error_handler
async def websocket_register_add(
ws: WebSocket, reset: str | None = None, auth=Cookie(None)
ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None)
):
"""Register a new credential for an existing user.
@@ -64,11 +65,9 @@ async def websocket_register_add(
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
is_reset = False
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
is_reset = True
s = await get_reset(reset)
else:
if not auth:
@@ -76,23 +75,30 @@ async def websocket_register_add(
s = await get_session(auth)
user_uuid = s.user_uuid
# Get user information to get the user_name
# Get user information and determine effective user_name for this registration
user = await db.instance.get_user_by_uuid(user_uuid)
user_name = user.display_name
if name is not None:
stripped = name.strip()
if stripped:
user_name = stripped
challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
# WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
# IMPORTANT: Insert the credential before creating a session that references it
# to satisfy the sessions.credential_uuid foreign key (now enforced).
await db.instance.create_credential(credential)
if is_reset:
# Invalidate the one-time reset session only after credential persisted
await db.instance.delete_session(s.key)
auth = await create_session(
user_uuid, credential.uuid, infodict(ws, "authenticated")
)
# Create a new session and store everything in database
token = create_token()
await db.instance.create_credential_session( # type: ignore[attr-defined]
user_uuid=user_uuid,
credential=credential,
reset_key=(s.key if reset is not None else None),
session_key=session_key(token),
session_expires=expires(),
session_info=infodict(ws, "authenticated"),
display_name=user_name,
)
auth = token
assert isinstance(auth, str) and len(auth) == 16
await ws.send_json(
+1
View File
@@ -7,4 +7,5 @@ def assert_safe(value: str, *, field: str = "value") -> None:
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
__all__ = ["assert_safe"]