"""Auth router — login, logout, status, registration, profile, and user-management endpoints.""" from contextvars import Token as _CtxToken import logging import re from fastapi import ( APIRouter, Cookie, Depends, File, Header, HTTPException, Response, UploadFile, WebSocket, status, ) from fastapi.responses import FileResponse from pydantic import BaseModel, field_validator from deeptutor.services.config import load_auth_settings # SameSite=None lets the cookie work when the browser accesses the frontend via # 127.0.0.1 and the backend via localhost (different origins on the same machine). # Browsers require Secure=True for SameSite=None, but that needs HTTPS — so in # local dev we fall back to SameSite=Lax and tell users to use localhost:// URLs. _SECURE = bool(load_auth_settings()["cookie_secure"]) _SAMESITE = "none" if _SECURE else "lax" from deeptutor.multi_user.context import set_current_user, user_from_token_payload from deeptutor.multi_user.paths import local_admin_user from deeptutor.services.auth import ( AUTH_ENABLED, POCKETBASE_ENABLED, TOKEN_EXPIRE_HOURS, TokenPayload, add_user, authenticate, authenticate_pb, create_token, decode_token, delete_user, get_user_info, is_first_user, list_users, register_pb, set_avatar, set_role, ) logger = logging.getLogger(__name__) router = APIRouter() _COOKIE_NAME = "dt_token" _COOKIE_MAX_AGE = TOKEN_EXPIRE_HOURS * 3600 # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class LoginRequest(BaseModel): """Payload for the POST /login endpoint.""" username: str password: str class RegisterRequest(BaseModel): """Payload for the POST /register endpoint.""" username: str password: str @field_validator("username") @classmethod def username_valid(cls, v: str) -> str: import re v = v.strip() if not v: raise ValueError("Email cannot be empty") # Accept standard email addresses (used by PocketBase mode) or plain # usernames (used by the built-in SQLite/JSON auth mode). email_re = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") plain_re = re.compile(r"^[A-Za-z0-9_\-.]{3,64}$") if not email_re.match(v) and not plain_re.match(v): raise ValueError("Enter a valid email address") return v @field_validator("password") @classmethod def password_valid(cls, v: str) -> str: if len(v) < 8: raise ValueError("Password must be at least 8 characters") return v class SetRoleRequest(BaseModel): """Payload for the PUT /users/{username}/role endpoint.""" role: str @field_validator("role") @classmethod def role_valid(cls, v: str) -> str: if v not in ("admin", "user"): raise ValueError("Role must be 'admin' or 'user'") return v class AuthStatusResponse(BaseModel): """Response body for the GET /status endpoint.""" enabled: bool authenticated: bool user_id: str | None = None username: str | None = None role: str | None = None is_admin: bool = False avatar: str = "" class UserInfo(BaseModel): """Single user record returned by the GET /users and /profile endpoints.""" id: str = "" username: str role: str created_at: str disabled: bool = False avatar: str = "" # Markers settable through PUT /profile. Image markers ("img:") are # managed exclusively by the upload endpoint so users cannot point their # avatar at a file that was never validated. _ICON_MARKER_RE = re.compile(r"^icon:[a-z0-9-]{1,32}:[a-z0-9-]{1,32}$") # User ids are generated as "u_" (plus the "local-admin" / # "env-admin" sentinels); reject anything else before it reaches the # filesystem layer. _USER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") class UpdateProfileRequest(BaseModel): """Payload for the PUT /profile endpoint.""" avatar: str @field_validator("avatar") @classmethod def avatar_valid(cls, v: str) -> str: v = v.strip() if v and not _ICON_MARKER_RE.match(v): raise ValueError("Avatar must be empty or 'icon::'") return v # --------------------------------------------------------------------------- # Shared helper — extract token from cookie or Bearer header # --------------------------------------------------------------------------- def _bearer_token_from_header(authorization: str | None) -> str | None: """Parse ``Authorization: Bearer `` without using ``HTTPBearer``. ``HTTPBearer`` is a class-based dependency whose ``__call__`` is annotated ``request: Request``. FastAPI doesn't inject a Request into WebSocket dependency resolution, which makes ``HTTPBearer`` raise ``TypeError`` the moment a router with this dep mounts a WS endpoint. Doing the parse by hand keeps ``require_auth`` HTTP/WS-symmetric. """ if not authorization: return None parts = authorization.split(None, 1) if len(parts) == 2 and parts[0].lower() == "bearer": token = parts[1].strip() return token or None return None def _extract_token(authorization: str | None, dt_token: str | None) -> str | None: return _bearer_token_from_header(authorization) or dt_token # --------------------------------------------------------------------------- # Dependencies — reusable auth guards for other routers # --------------------------------------------------------------------------- def _install_current_user(payload: TokenPayload | None) -> _CtxToken: """Install the request-local current-user ContextVar from an auth result. Single point of truth for ``payload → CurrentUser`` so HTTP and WebSocket entry points produce identical user objects. ``payload is None`` means "no JWT was required" (AUTH_ENABLED=false) and resolves to the local admin user; a non-None payload resolves through ``user_from_token_payload``. Returns the ContextVar reset token. HTTP callers ignore it (the request ends with the task, so the var is GC'd with the task context). WebSocket callers keep it and call ``reset_current_user`` in their ``finally`` block, because a WS connection outlives the dependency-resolution task. ⚠ Invariant: every authenticated entry point MUST call this before the handler runs. Skipping it leaves ``get_current_path_service()`` falling back to the admin workspace — the silent-routing root cause of #481. """ user = local_admin_user() if payload is None else user_from_token_payload(payload) return set_current_user(user) async def require_auth( authorization: str | None = Header(default=None, alias="Authorization"), dt_token: str | None = Cookie(default=None), ) -> TokenPayload | None: """ FastAPI dependency that enforces authentication when AUTH_ENABLED=true. Accepts the JWT from either: - Authorization: Bearer header - dt_token cookie ``Header`` and ``Cookie`` are kept here in place of ``HTTPBearer`` so the function stays usable from WebSocket call sites that don't go through FastAPI's standard HTTP request lifecycle. Returns the authenticated TokenPayload, or None if auth is disabled. Raises HTTP 401 if auth is enabled but the token is missing or invalid. Declared ``async def`` so the ``set_current_user`` call runs in the same asyncio context as the endpoint. A sync dependency is dispatched via ``anyio.to_thread.run_sync``, which executes the function in a worker thread under a *copy* of the request context; any ``ContextVar.set`` inside that thread is discarded when the thread returns, leaving the endpoint to read the unset default. That regression was the root cause of #481. """ if not AUTH_ENABLED: _install_current_user(None) return None token = _extract_token(authorization, dt_token) if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) payload = decode_token(token) if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) _install_current_user(payload) return payload class _WsAuthFailed: """Sentinel: ws_require_auth failed and closed the WebSocket.""" ws_auth_failed: _WsAuthFailed = _WsAuthFailed() async def ws_require_auth(ws: WebSocket) -> _CtxToken | _WsAuthFailed: """Authenticate a WebSocket connection and set the user ContextVar. Must be called **before** ``ws.accept()`` so the server can reject unauthenticated upgrades cleanly. Returns a ContextVar reset token on success, or ``ws_auth_failed`` on failure (the WebSocket is already closed — the caller should ``return`` immediately). Usage:: user_token = await ws_require_auth(ws) if user_token is ws_auth_failed: return await ws.accept() try: ... finally: reset_current_user(user_token) """ if not AUTH_ENABLED: return _install_current_user(None) token = ws.query_params.get("token") or ws.cookies.get("dt_token") payload = decode_token(token) if token else None if not payload: await ws.close(code=4001) return ws_auth_failed return _install_current_user(payload) async def require_admin( payload: TokenPayload | None = Depends(require_auth), ) -> TokenPayload: """ FastAPI dependency that requires the caller to be an admin. Raises HTTP 403 if the authenticated user is not an admin. When AUTH_ENABLED=false, all requests are treated as admin. ``async def`` mirrors ``require_auth`` so the dependency chain stays on the event loop and the user ContextVar set by ``require_auth`` is visible to the endpoint. """ if not AUTH_ENABLED: return _local_admin_token_payload() if payload is None or payload.role != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required", ) return payload def _local_admin_token_payload() -> TokenPayload: """Synthetic admin payload used when AUTH_ENABLED=false. Mirrors the local admin identity (LOCAL_ADMIN_USERNAME / LOCAL_ADMIN_ID) so audit logs and self-reference checks behave the same as in multi-user mode. Values are kept aligned with ``local_admin_user()`` in ``deeptutor/multi_user/paths.py``. """ from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME return TokenPayload( username=LOCAL_ADMIN_USERNAME, role="admin", user_id=LOCAL_ADMIN_ID, ) # --------------------------------------------------------------------------- # Public endpoints (no auth required) # --------------------------------------------------------------------------- @router.get("/status", response_model=AuthStatusResponse) async def auth_status( authorization: str | None = Header(default=None, alias="Authorization"), dt_token: str | None = Cookie(default=None), ) -> AuthStatusResponse: """Return whether auth is enabled and whether the current request is authenticated.""" if not AUTH_ENABLED: return AuthStatusResponse( enabled=False, authenticated=True, user_id="local-admin", username="local", role="admin", is_admin=True, ) token = _extract_token(authorization, dt_token) payload = decode_token(token) if token else None avatar = "" if payload is not None: info = get_user_info(payload.username) if info: avatar = str(info.get("avatar") or "") return AuthStatusResponse( enabled=True, authenticated=payload is not None, user_id=payload.user_id if payload else None, username=payload.username if payload else None, role=payload.role if payload else None, is_admin=payload.role == "admin" if payload else False, avatar=avatar, ) @router.post("/login") async def login(body: LoginRequest, response: Response) -> dict: """Validate credentials and set a JWT cookie.""" if not AUTH_ENABLED: return {"ok": True, "message": "Auth is disabled — no login required."} if POCKETBASE_ENABLED: # PocketBase mode: email = username field for backwards-compat with the # existing LoginRequest schema; users can pass their email as "username". pb_result = authenticate_pb(body.username, body.password) if not pb_result: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) payload, pb_token = pb_result response.set_cookie( key=_COOKIE_NAME, value=pb_token, httponly=True, samesite=_SAMESITE, max_age=_COOKIE_MAX_AGE, secure=_SECURE, ) logger.info(f"User '{payload.username}' logged in via PocketBase (role={payload.role!r})") return { "ok": True, "user_id": payload.user_id, "username": payload.username, "role": payload.role, "is_admin": payload.role == "admin", } # Standard JWT + bcrypt mode result = authenticate(body.username, body.password) if not result: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", ) token = create_token(result.username, result.role, result.user_id) response.set_cookie( key=_COOKIE_NAME, value=token, httponly=True, samesite=_SAMESITE, max_age=_COOKIE_MAX_AGE, secure=_SECURE, ) logger.info(f"User '{result.username}' logged in (role={result.role!r})") return { "ok": True, "user_id": result.user_id, "username": result.username, "role": result.role, "is_admin": result.role == "admin", } @router.post("/logout") async def logout(response: Response) -> dict: """Clear the JWT cookie.""" response.delete_cookie(key=_COOKIE_NAME, samesite=_SAMESITE) return {"ok": True} @router.post("/register", status_code=status.HTTP_201_CREATED) async def register(body: RegisterRequest) -> dict: """ Bootstrap-only registration. Public endpoint that creates the *first* admin account when the user store is empty. Once an admin exists, this endpoint is closed; further accounts must be created by an admin via ``POST /api/v1/auth/users``. Only available when AUTH_ENABLED=true. """ if not AUTH_ENABLED: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Auth is disabled — registration is not available.", ) if POCKETBASE_ENABLED: # PocketBase deployments are documented as single-user. Keep registration # closed and require admins to provision users in the PocketBase admin UI. if not is_first_user(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Self-registration is closed. Ask an administrator to create your account.", ) result = register_pb(username=body.username, email=body.username, password=body.password) if not result: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Registration failed — username or email may already be taken.", ) logger.info(f"First user registered via PocketBase: '{body.username}'") return { "ok": True, "user_id": result.get("id", ""), "username": body.username, "role": "user", "is_first_user": True, "is_admin": False, } # Standard mode — only allowed before the first admin exists. if not is_first_user(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Self-registration is closed. Ask an administrator to create your account.", ) existing = {u["username"] for u in list_users()} if body.username in existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username already taken", ) add_user(body.username, body.password) user_id = "" role = "user" for item in list_users(): if item.get("username") == body.username: user_id = str(item.get("id") or "") role = str(item.get("role") or "user") break logger.info(f"First user (admin) registered: '{body.username}'") return { "ok": True, "user_id": user_id, "username": body.username, "role": role, "is_first_user": True, "is_admin": role == "admin", } @router.get("/is_first_user") async def check_is_first_user() -> dict: """Return whether the user store is empty (used by the register UI).""" return {"is_first_user": is_first_user() if AUTH_ENABLED else False} # --------------------------------------------------------------------------- # Profile endpoints (any authenticated user, self-service) # --------------------------------------------------------------------------- _AVATAR_MAX_BYTES = 1 * 1024 * 1024 _AVATAR_MEDIA_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} def _sniff_image(data: bytes) -> str | None: """Detect a supported raster image format from its magic bytes. The uploaded filename and Content-Type are attacker-controlled, so the stored extension (and the media type served back) is derived from the bytes alone. SVG is deliberately unsupported — serving user-supplied SVG is a stored-XSS vector. """ if data[:8] == b"\x89PNG\r\n\x1a\n": return "png" if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "webp" return None def _require_profile_identity(payload: TokenPayload | None) -> TokenPayload: """Shared guard for the self-service profile endpoints.""" if not AUTH_ENABLED or payload is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Auth is disabled — profiles are not available.", ) return payload @router.get("/profile", response_model=UserInfo) async def get_profile( payload: TokenPayload | None = Depends(require_auth), ) -> UserInfo: """Return the current user's own account info.""" current = _require_profile_identity(payload) info = get_user_info(current.username) if info is None: # PocketBase-backed identities have no local record; fall back to the # token claims so the profile page still renders. return UserInfo( id=current.user_id, username=current.username, role=current.role, created_at="", ) return UserInfo(**info) @router.put("/profile") async def update_profile( body: UpdateProfileRequest, payload: TokenPayload | None = Depends(require_auth), ) -> dict: """Update the current user's own avatar marker (icon choice or reset). Only the validated ``icon::`` form (or empty string) is accepted here; ``img:`` markers are owned by the upload endpoint. """ current = _require_profile_identity(payload) if not set_avatar(current.username, body.avatar): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") # The marker no longer references an uploaded image, so drop the file. from deeptutor.multi_user.identity import delete_avatar_file if current.user_id and _USER_ID_RE.match(current.user_id): delete_avatar_file(current.user_id) return {"ok": True, "avatar": body.avatar} @router.put("/profile/avatar") async def upload_avatar( file: UploadFile = File(...), payload: TokenPayload | None = Depends(require_auth), ) -> dict: """Upload an avatar image for the current user. The client is expected to crop/resize before uploading; the server only enforces a size cap and validates the format by magic bytes. Not available in PocketBase mode (those identities have no local user record). """ current = _require_profile_identity(payload) if POCKETBASE_ENABLED: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Avatar upload is not available in PocketBase mode.", ) if not current.user_id or not _USER_ID_RE.match(current.user_id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot store an avatar for this account.", ) info = get_user_info(current.username) if info is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") data = await file.read(_AVATAR_MAX_BYTES + 1) if len(data) > _AVATAR_MAX_BYTES: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Avatar image is too large (max 1 MB).", ) ext = _sniff_image(data) if ext is None: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="Avatar must be a PNG, JPEG or WebP image.", ) from deeptutor.multi_user.identity import save_avatar_file # Bump the version embedded in the marker so clients cache-bust the URL. previous = str(info.get("avatar") or "") version = 1 if previous.startswith("img:"): try: version = int(previous.split(":", 1)[1]) + 1 except ValueError: version = 1 marker = f"img:{version}" save_avatar_file(current.user_id, data, ext) if not set_avatar(current.username, marker): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") logger.info(f"User '{current.username}' uploaded a new avatar ({ext}, {len(data)} bytes)") return {"ok": True, "avatar": marker} @router.delete("/profile/avatar") async def remove_avatar( payload: TokenPayload | None = Depends(require_auth), ) -> dict: """Remove the current user's uploaded avatar image and reset the marker.""" current = _require_profile_identity(payload) from deeptutor.multi_user.identity import delete_avatar_file if current.user_id and _USER_ID_RE.match(current.user_id): delete_avatar_file(current.user_id) set_avatar(current.username, "") return {"ok": True, "avatar": ""} @router.get("/avatar/{user_id}") async def get_avatar_image( user_id: str, _: TokenPayload | None = Depends(require_auth), ) -> FileResponse: """Serve a stored avatar image. Any authenticated user may view avatars (they appear in the admin table and next to the viewer's own profile).""" if not _USER_ID_RE.match(user_id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found") from deeptutor.multi_user.identity import get_avatar_file target = get_avatar_file(user_id) if target is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Avatar not found") media_type = _AVATAR_MEDIA_TYPES.get(target.suffix.lstrip("."), "application/octet-stream") headers = { # Private user content; the marker version in the URL handles busting. "Cache-Control": "private, max-age=86400", "X-Content-Type-Options": "nosniff", "Content-Disposition": "inline", } return FileResponse(path=str(target), media_type=media_type, headers=headers) # --------------------------------------------------------------------------- # Admin-only endpoints # --------------------------------------------------------------------------- @router.get("/users", response_model=list[UserInfo]) async def get_users(_: TokenPayload = Depends(require_admin)) -> list[UserInfo]: """List all registered users. Requires admin role.""" return [UserInfo(**u) for u in list_users()] @router.post("/users", status_code=status.HTTP_201_CREATED) async def admin_create_user( body: RegisterRequest, current: TokenPayload = Depends(require_admin), ) -> dict: """Admin-only: create a new user account. Replaces the public ``/register`` flow once the first admin exists. The new account is always created with role=``user``; admins can promote later via ``PUT /users/{username}/role``. """ if not AUTH_ENABLED: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Auth is disabled — user creation is not available.", ) if POCKETBASE_ENABLED: result = register_pb(username=body.username, email=body.username, password=body.password) if not result: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Failed to create user — username may already be taken.", ) logger.info( f"Admin '{current.username if current else 'local'}' created PocketBase user " f"'{body.username}'" ) return { "ok": True, "user_id": result.get("id", ""), "username": body.username, "role": "user", "is_admin": False, } existing = {u["username"] for u in list_users()} if body.username in existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Username already taken", ) add_user(body.username, body.password) user_id = "" role = "user" for item in list_users(): if item.get("username") == body.username: user_id = str(item.get("id") or "") role = str(item.get("role") or "user") break logger.info( f"Admin '{current.username if current else 'local'}' created user '{body.username}' " f"(role={role!r})" ) return { "ok": True, "user_id": user_id, "username": body.username, "role": role, "is_admin": role == "admin", } @router.delete("/users/{username}", status_code=status.HTTP_200_OK) async def remove_user( username: str, current: TokenPayload = Depends(require_admin), ) -> dict: """Delete a user. Admins cannot delete their own account.""" if current and username == current.username: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="You cannot delete your own account", ) # Capture the id before the record disappears so the avatar file can go too. info = get_user_info(username) removed = delete_user(username) if not removed: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") user_id = str(info.get("id") or "") if info else "" if user_id and _USER_ID_RE.match(user_id): from deeptutor.multi_user.identity import delete_avatar_file delete_avatar_file(user_id) logger.info(f"Admin '{current.username if current else 'local'}' deleted user '{username}'") return {"ok": True} @router.put("/users/{username}/role", status_code=status.HTTP_200_OK) async def update_user_role( username: str, body: SetRoleRequest, current: TokenPayload = Depends(require_admin), ) -> dict: """Change a user's role. Admins cannot change their own role.""" if current and username == current.username: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="You cannot change your own role", ) updated = set_role(username, body.role) if not updated: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") logger.info( f"Admin '{current.username if current else 'local'}' set '{username}' role to {body.role!r}" ) return {"ok": True, "username": username, "role": body.role}