From 23c1bb6b3434d7e27c87f791aa1dc5fbf5227615 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 14:36:57 -0600 Subject: [PATCH] feat(auth): get_current_user + login/logout/me + admin user management --- backend/src/cyclone/api.py | 24 +++++++ backend/src/cyclone/auth/admin.py | 95 ++++++++++++++++++++++++ backend/src/cyclone/auth/deps.py | 104 +++++++++++++++++++++++++++ backend/src/cyclone/auth/routes.py | 97 +++++++++++++++++++++++++ backend/tests/test_auth_admin.py | 103 ++++++++++++++++++++++++++ backend/tests/test_auth_routes.py | 111 +++++++++++++++++++++++++++++ 6 files changed, 534 insertions(+) create mode 100644 backend/src/cyclone/auth/admin.py create mode 100644 backend/src/cyclone/auth/deps.py create mode 100644 backend/src/cyclone/auth/routes.py create mode 100644 backend/tests/test_auth_admin.py create mode 100644 backend/tests/test_auth_routes.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 6dd98e6..16e33f5 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -131,6 +131,16 @@ app.add_middleware( ) +@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( @@ -2587,4 +2597,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"] diff --git a/backend/src/cyclone/auth/admin.py b/backend/src/cyclone/auth/admin.py new file mode 100644 index 0000000..169b4ba --- /dev/null +++ b/backend/src/cyclone/auth/admin.py @@ -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 diff --git a/backend/src/cyclone/auth/deps.py b/backend/src/cyclone/auth/deps.py new file mode 100644 index 0000000..4660381 --- /dev/null +++ b/backend/src/cyclone/auth/deps.py @@ -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 diff --git a/backend/src/cyclone/auth/routes.py b/backend/src/cyclone/auth/routes.py new file mode 100644 index 0000000..9b22ee7 --- /dev/null +++ b/backend/src/cyclone/auth/routes.py @@ -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 diff --git a/backend/tests/test_auth_admin.py b/backend/tests/test_auth_admin.py new file mode 100644 index 0000000..db4a516 --- /dev/null +++ b/backend/tests/test_auth_admin.py @@ -0,0 +1,103 @@ +"""Admin-only user management endpoints.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from cyclone.api import app +from cyclone.auth import users +from cyclone.db import Session as DbSession +from cyclone.db import SessionLocal, User + + +@pytest.fixture(autouse=True) +def _clear(): + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + yield + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + + +@pytest.fixture +def admin_client(): + client = TestClient(app) + with SessionLocal()() as db: + users.create(db, username="root", password="rootpassword1", role="admin") + login = client.post( + "/api/auth/login", + json={"username": "root", "password": "rootpassword1"}, + ) + assert login.status_code == 200 + return client + + +@pytest.fixture +def user_client(): + client = TestClient(app) + with SessionLocal()() as db: + users.create(db, username="plain", password="plainpassword1", role="user") + login = client.post( + "/api/auth/login", + json={"username": "plain", "password": "plainpassword1"}, + ) + return client + + +def test_list_users_as_admin(admin_client): + with SessionLocal()() as db: + users.create(db, username="alice", password="hunter2hunter2", role="user") + resp = admin_client.get("/api/admin/users") + assert resp.status_code == 200 + body = resp.json() + usernames = {u["username"] for u in body} + assert {"root", "alice"} <= usernames + + +def test_list_users_as_nonadmin_returns_403(user_client): + resp = user_client.get("/api/admin/users") + assert resp.status_code == 403 + + +def test_create_user_as_admin(admin_client): + resp = admin_client.post( + "/api/admin/users", + json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"}, + ) + assert resp.status_code == 201 + assert resp.json()["username"] == "newbie" + assert resp.json()["role"] == "viewer" + + +def test_create_user_rejects_invalid_role(admin_client): + resp = admin_client.post( + "/api/admin/users", + json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"}, + ) + assert resp.status_code == 422 + + +def test_patch_user_role_and_password(admin_client): + with SessionLocal()() as db: + u = users.create(db, username="subject", password="hunter2hunter2", role="viewer") + resp = admin_client.patch( + f"/api/admin/users/{u.id}", + json={"role": "user", "password": "newpassword1"}, + ) + assert resp.status_code == 200 + assert resp.json()["role"] == "user" + + +def test_admin_cannot_demote_self(admin_client): + me = admin_client.get("/api/auth/me").json() + resp = admin_client.patch( + f"/api/admin/users/{me['id']}", + json={"role": "viewer"}, + ) + assert resp.status_code == 409 diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py new file mode 100644 index 0000000..858577d --- /dev/null +++ b/backend/tests/test_auth_routes.py @@ -0,0 +1,111 @@ +"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from cyclone.api import app +from cyclone.auth import users +from cyclone.db import Session as DbSession +from cyclone.db import SessionLocal, User + + +@pytest.fixture(autouse=True) +def _clear(): + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + yield + with SessionLocal()() as db: + db.execute(delete(DbSession)) + db.execute(delete(User)) + db.commit() + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def seeded_admin(client): + with SessionLocal()() as db: + users.create(db, username="admin", password="adminpassword1", role="admin") + return client + + +def test_login_success_returns_user_and_cookie(client): + with SessionLocal()() as db: + users.create(db, username="alice", password="hunter2hunter2", role="user") + resp = client.post( + "/api/auth/login", + json={"username": "alice", "password": "hunter2hunter2"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["username"] == "alice" + assert body["role"] == "user" + assert "password_hash" not in body + assert "cyclone_session" in resp.cookies + + +def test_login_bad_password_returns_401(client): + with SessionLocal()() as db: + users.create(db, username="bob", password="hunter2hunter2", role="user") + resp = client.post( + "/api/auth/login", + json={"username": "bob", "password": "WRONG"}, + ) + assert resp.status_code == 401 + assert resp.json()["error"] == "invalid_credentials" + + +def test_login_unknown_user_returns_401(client): + resp = client.post( + "/api/auth/login", + json={"username": "ghost", "password": "whatever"}, + ) + assert resp.status_code == 401 + assert resp.json()["error"] == "invalid_credentials" + + +def test_login_disabled_user_returns_403(client): + with SessionLocal()() as db: + u = users.create(db, username="carol", password="hunter2hunter2", role="user") + users.disable(db, u.id) + resp = client.post( + "/api/auth/login", + json={"username": "carol", "password": "hunter2hunter2"}, + ) + assert resp.status_code == 403 + assert resp.json()["error"] == "account_disabled" + + +def test_logout_clears_session_and_cookie(seeded_admin): + login = seeded_admin.post( + "/api/auth/login", + json={"username": "admin", "password": "adminpassword1"}, + ) + cookie = login.cookies.get("cyclone_session") + assert cookie + resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie}) + assert resp.status_code == 204 + + +def test_me_returns_current_user(seeded_admin): + login = seeded_admin.post( + "/api/auth/login", + json={"username": "admin", "password": "adminpassword1"}, + ) + cookie = login.cookies.get("cyclone_session") + resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie}) + assert resp.status_code == 200 + assert resp.json()["username"] == "admin" + + +def test_me_without_cookie_returns_401(seeded_admin): + resp = seeded_admin.get("/api/auth/me") + assert resp.status_code == 401