feature: add oidc login flow
This commit is contained in:
100
backend/app.py
100
backend/app.py
@ -1,14 +1,21 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend import actions
|
||||
from backend.actions import ActionError
|
||||
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
|
||||
from backend.auth import (
|
||||
authenticate_admin_user,
|
||||
get_current_admin,
|
||||
is_authorized_admin,
|
||||
issue_token,
|
||||
list_manageable_users,
|
||||
)
|
||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||
from backend.oidc import OIDCClient, OIDCError
|
||||
from backend.settings import Settings, get_settings
|
||||
|
||||
logging.basicConfig(
|
||||
@ -21,6 +28,20 @@ app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
||||
templates = Jinja2Templates(directory="backend/templates")
|
||||
|
||||
|
||||
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OIDC auth not enabled",
|
||||
)
|
||||
try:
|
||||
return OIDCClient(settings)
|
||||
except OIDCError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||
allowed = set(list_manageable_users(settings))
|
||||
if username not in allowed:
|
||||
@ -33,13 +54,84 @@ def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
return {"status": "ok", "dry_run": settings.dry_run}
|
||||
|
||||
|
||||
@app.get("/me")
|
||||
def whoami(
|
||||
current_user: str = Depends(get_current_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
return {"user": current_user, "auth_mode": settings.auth_mode}
|
||||
|
||||
|
||||
@app.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
|
||||
def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> LoginResponse:
|
||||
authenticate_admin_user(payload.username, payload.password, settings)
|
||||
token = issue_token(payload.username, settings)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
|
||||
|
||||
|
||||
@app.get("/login/oidc/start")
|
||||
def oidc_start(
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
state = oidc.build_state_token()
|
||||
redirect = RedirectResponse(url=oidc.authorization_url(state))
|
||||
redirect.set_cookie(
|
||||
settings.oidc_state_cookie_name,
|
||||
state,
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/login/oidc/callback")
|
||||
def oidc_callback(
|
||||
request: Request,
|
||||
code: str,
|
||||
state: str,
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
stored_state = request.cookies.get(settings.oidc_state_cookie_name, "")
|
||||
if not oidc.is_state_valid(state, stored_state):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state")
|
||||
|
||||
claims = oidc.exchange_code_for_claims(code)
|
||||
username = oidc.extract_username(claims)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
|
||||
if not is_authorized_admin(username, settings):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
|
||||
|
||||
token = issue_token(username, settings)
|
||||
redirect = RedirectResponse(url="/")
|
||||
redirect.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
redirect.delete_cookie(settings.oidc_state_cookie_name)
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
|
||||
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
||||
logged_in = set(actions.list_logged_in_users())
|
||||
|
||||
Reference in New Issue
Block a user