Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
@@ -0,0 +1,173 @@
|
||||
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)
|
||||
Reference in new issue
Block a user