174 lines
7.0 KiB
Python
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)
|