feat(auth): get_current_user + login/logout/me + admin user management

This commit is contained in:
Nora
2026-06-22 14:36:57 -06:00
parent e158871a9a
commit 86b635104c
6 changed files with 534 additions and 0 deletions
+24
View File
@@ -273,6 +273,16 @@ app.include_router(ta1_acks.router)
app.include_router(admin.router)
@app.exception_handler(HTTPException)
async def _http_exc_handler(request, exc: HTTPException):
code = exc.detail if isinstance(exc.detail, str) else "error"
return JSONResponse(
status_code=exc.status_code,
content={"error": code, "detail": str(exc.detail)},
headers=exc.headers,
)
def _resolve_payer(name: str) -> PayerConfig:
if name not in PAYER_FACTORIES:
raise HTTPException(
@@ -3545,4 +3555,18 @@ def reload_config():
return {"ok": True, "loaded": len(configs), "errors": []}
# --------------------------------------------------------------------------- #
# Auth routers (login/logout/me + admin user management) #
# --------------------------------------------------------------------------- #
from cyclone.auth.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router
app.include_router(auth_router)
app.include_router(admin_users_router)
__all__ = ["app"]
+95
View File
@@ -0,0 +1,95 @@
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from cyclone.auth import users
from cyclone.auth.deps import get_current_user
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
if user.get("role") != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
def _validate_role(role: str) -> None:
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
if role not in valid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of {sorted(valid)}",
)
@router.get("")
def list_users(_admin=Depends(_require_admin)):
with SessionLocal()() as db:
all_users = db.query(User).all()
return [users.to_public(u) for u in all_users]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_user(body: dict, _admin=Depends(_require_admin)):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
role = body.get("role") or ""
if not username or len(username) < 3:
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
if len(password) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
_validate_role(role)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
raise HTTPException(status_code=409, detail="username already exists")
u = users.create(db, username=username, password=password, role=role)
return users.to_public(u)
@router.patch("/{user_id}")
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_demote_self",
)
with SessionLocal()() as db:
if body.get("role") is not None:
_validate_role(body["role"])
users.update_role(db, user_id, body["role"])
if body.get("password") is not None:
if len(body["password"]) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
users.update_password(db, user_id, body["password"])
if body.get("disabled") is True:
users.disable(db, user_id)
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
return users.to_public(u)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_delete_self",
)
with SessionLocal()() as db:
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
users.disable(db, user_id)
return None
+104
View File
@@ -0,0 +1,104 @@
"""FastAPI dependencies for auth."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session as DbSession
from cyclone.auth import sessions, users
from cyclone.auth.permissions import Role, allowed_roles
from cyclone.db import SessionLocal
def _db():
db = SessionLocal()()
try:
yield db
finally:
db.close()
DbSessionDep = Annotated[DbSession, Depends(_db)]
AUTH_DISABLED = False
async def get_current_user(
request: Request,
db: DbSessionDep,
) -> dict:
"""Return the public User shape. Raises 401 if session is missing/expired.
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
user without checking credentials.
"""
if AUTH_DISABLED:
return {
"id": 0,
"username": "dev",
"role": Role.ADMIN.value,
"createdAt": None,
"disabledAt": None,
}
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
sess = sessions.get_valid(db, sid)
if sess is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
user = users.get(db, sess.user_id)
if user is None or user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="account_disabled",
)
# Sliding expiry: refresh both DB and cookie.
sessions.touch(db, sid)
request.state.user = user
request.state.session_id = sid
return users.to_public(user)
def require_role(*allowed: Role):
"""Dependency factory: gate the endpoint to specific roles.
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
"""
async def _dep(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
user_role = user.get("role")
if allowed:
if user_role not in {r.value for r in allowed}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
# Otherwise consult the matrix.
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
return _dep
+97
View File
@@ -0,0 +1,97 @@
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from cyclone.auth import rate_limit, sessions, users
from cyclone.auth.deps import get_current_user
from cyclone.db import SessionLocal
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "cyclone_session"
COOKIE_MAX_AGE = 86400 # 24h
def _is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
def _set_cookie(response: Response, sid: str, request: Request) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=sid,
max_age=COOKIE_MAX_AGE,
path="/api",
httponly=True,
samesite="lax",
secure=_is_https(request),
)
def _clear_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/api")
@router.post("/login")
def login(body: dict, request: Request, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username and password are required",
)
retry_after = rate_limit.check(username)
if retry_after > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="rate_limited",
headers={"Retry-After": str(retry_after)},
)
with SessionLocal()() as db:
user = users.get_by_username(db, username)
if user is None or not users.verify_password(password, user.password_hash):
rate_limit.record_failure(username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_credentials",
)
if user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="account_disabled",
)
sid, _ = sessions.create(db, user_id=user.id)
rate_limit.reset(username)
public = users.to_public(user)
_set_cookie(response, sid, request)
return public
@router.post("/logout")
def logout(
request: Request,
response: Response,
_user: dict = Depends(get_current_user),
):
sid = request.cookies.get(COOKIE_NAME)
if sid:
with SessionLocal()() as db:
sessions.delete(db, sid)
_clear_cookie(response)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
def me(user: dict = Depends(get_current_user)):
return user