feat(auth): get_current_user + login/logout/me + admin user management
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user