Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_user_auth.py
T

174 lines
7.0 KiB
Python

from __future__ import annotations
from typing import Annotated
from urllib.parse import urlsplit
import httpx
import jwt
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import delete
from sqlmodel import Session, select
from ..core.config import settings
from ..db.models import GatewayUser, UserSession
from ..db.session import get_session
from ..services.audit_logger import log_event
from ..services.oidc_login import (
FLOW_COOKIE,
finish_login,
login_provider,
provider_status,
start_login,
)
from ..services.user_access import (
PERMISSIONS,
ROLES,
USER_COOKIE,
authorize_request,
current_user,
digest,
issue_session,
provision_user,
session_status,
user_view,
)
router = APIRouter(prefix="/api/auth")
DatabaseSession = Annotated[Session, Depends(get_session)]
@router.get("/session")
def get_session_status(request: Request):
return session_status(request)
@router.get("/providers")
def get_providers():
return provider_status()
@router.get("/login/{provider}")
def login(provider: str, request: Request, db: DatabaseSession):
try:
effective_provider = login_provider(provider)
# A Secure browser cookie cannot follow a LAN/IP login to the public
# HTTPS callback. Move to the canonical origin before issuing any flow.
if request.headers.get("host", "").casefold() != urlsplit(settings.auth_public_url).netloc.casefold():
response = RedirectResponse(f"{settings.auth_public_url}/api/auth/login/{provider}", status_code=307)
response.headers["Cache-Control"] = "no-store"
return response
url, browser = start_login(db, effective_provider)
except (httpx.HTTPError, ValueError, KeyError):
return login_failure("failed")
except HTTPException as exc:
if exc.status_code != 503:
raise
return login_failure("failed")
response = RedirectResponse(url, status_code=302)
response.set_cookie(FLOW_COOKIE, browser, max_age=600, httponly=True, secure=True, samesite="lax", path="/api/auth")
response.headers["Cache-Control"] = "no-store"
return response
def login_failure(reason: str) -> RedirectResponse:
response = RedirectResponse(f"/?signin_error={reason}", status_code=303)
response.headers["Cache-Control"] = "no-store"
return response
@router.get("/callback/{provider}")
def callback(provider: str, request: Request, db: DatabaseSession):
state, code = request.query_params.get("state", ""), request.query_params.get("code", "")
if not state or not code or request.query_params.get("error"):
return login_failure("cancelled" if request.query_params.get("error") else "failed")
try:
issuer, claims = finish_login(db, provider, state, request.cookies.get(FLOW_COOKIE, ""), code)
user = provision_user(db, provider, issuer, claims)
opaque = issue_session(db, user)
except HTTPException as exc:
if exc.status_code not in {401, 403, 503}:
raise
expired = exc.detail in {"Login expired or browser validation failed", "Login has already been used"}
return login_failure("expired" if expired else "failed")
except (httpx.HTTPError, jwt.PyJWTError, ValueError, KeyError, TypeError):
return login_failure("failed")
# Rotate any previous app session on sign-in.
previous = request.cookies.get(USER_COOKIE)
if previous:
db.exec(delete(UserSession).where(UserSession.id == digest(previous)))
db.commit()
log_event(db, source="manual", identity=f"{user.provider}:{user.subject}", action_id="user.login",
target=user.id, target_type="user", risk="low", policy_decision="allowed",
reason="Verified identity provider login", result="success")
response = RedirectResponse("/", status_code=303)
response.set_cookie(USER_COOKIE, opaque, max_age=8 * 60 * 60, httponly=True, secure=True, samesite="lax", path="/")
response.delete_cookie(FLOW_COOKIE, path="/api/auth", secure=True, httponly=True, samesite="lax")
response.headers["Cache-Control"] = "no-store"
return response
@router.post("/logout")
def logout(request: Request, response: Response, db: DatabaseSession):
current_user(request)
cookie = request.cookies.get(USER_COOKIE, "")
if cookie:
db.exec(delete(UserSession).where(UserSession.id == digest(cookie)))
db.commit()
response.delete_cookie(USER_COOKIE, path="/", secure=True, httponly=True, samesite="lax")
return {"authenticated": False, "user": None, "permissions": []}
@router.get("/permissions")
def permissions(request: Request):
authorize_request(request)
return [{"id": key, "label": label} for key, label in PERMISSIONS.items()]
@router.get("/users")
def users(request: Request, db: DatabaseSession):
authorize_request(request)
return [user_view(user) for user in db.exec(select(GatewayUser).order_by(GatewayUser.email, GatewayUser.id)).all()]
class UserAccessUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
role: str
enabled: bool
permissions: list[str] = Field(default_factory=list, max_length=20)
@router.patch("/users/{user_id}")
def update_user(user_id: str, payload: UserAccessUpdate, request: Request, db: DatabaseSession):
admin = authorize_request(request)
if payload.role not in ROLES or set(payload.permissions) - set(PERMISSIONS):
raise HTTPException(422, "Unknown role or permission")
if payload.role != "admin" and "users.manage" in payload.permissions:
raise HTTPException(422, "User management requires the admin role")
# Serialize last-admin updates, including concurrent changes on SQLite.
from sqlalchemy import text
db.execute(text("BEGIN IMMEDIATE"))
user = db.get(GatewayUser, user_id)
if user is None:
raise HTTPException(404, "User not found")
if user.role == "admin" and user.enabled and (payload.role != "admin" or not payload.enabled):
admins = db.exec(
select(GatewayUser).where(
GatewayUser.role == "admin", GatewayUser.enabled.is_(True)
)
).all()
if len(admins) < 2:
raise HTTPException(409, "The last enabled administrator cannot be disabled or demoted")
before = user_view(user)
user.role, user.enabled = payload.role, payload.enabled
user.permissions = sorted(set(payload.permissions)) if payload.role == "custom" else []
db.add(user)
if not user.enabled:
db.exec(delete(UserSession).where(UserSession.user_id == user.id))
db.flush()
log_event(db, source="manual", identity=f"{admin.provider}:{admin.subject}", action_id="user.permissions.update",
target=user.id, target_type="user", risk="high", policy_decision="allowed",
reason="Administrator updated account access", result="success", before_state=before, after_state=user_view(user))
return user_view(user)