feat(core): SSO-lite session cookie + /auth/verify for nginx auth_request (ref #400)

Foundation to replace Authelia with SecuBox's own users (reuses the argon2
user_store; those hashes can't feed nginx Basic auth, but a verify endpoint
can validate them). Source-only — no live cutover yet.

- auth.py: /login now also sets an HttpOnly, parent-domain-scoped session
  cookie (secubox_session = the JWT). New GET /auth/verify validates the
  cookie (or Bearer) via the same checks as require_jwt and echoes
  Remote-User/Remote-Groups for the proxied app (200 allow / 401 deny).
  New POST /auth/logout clears the cookie.
- secubox.conf.example: api.sso_cookie_domain (parent domain, e.g.
  ".gk2.secubox.in"; empty = host-only).

Remaining for #400 (follow-up, careful + board-specific): confirm which
socket serves /auth/verify given secubox-auth overrides /login; ship the
nginx auth_request snippet with the real SecuBox login-page redirect; live
cutover one vhost first (lyrion/zigbee) then all; stop+disable Authelia.
This commit is contained in:
CyberMind-FR 2026-05-28 10:09:49 +02:00
parent 02528cbc85
commit 0706317d25
2 changed files with 83 additions and 2 deletions

View File

@ -14,7 +14,8 @@ import secrets
import time
from typing import Any, Callable, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from pydantic import BaseModel
@ -60,6 +61,35 @@ def _secret() -> str:
return s
# SSO-lite (#400) ────────────────────────────────────────────────────────
# A session cookie + /verify endpoint let nginx `auth_request` gate vhosts
# against SecuBox users directly — replacing Authelia while reusing the same
# argon2 user_store. The cookie is set parent-domain-scoped so one login
# covers every *.<domain> vhost (SSO-lite). Configure the parent domain via
# api.sso_cookie_domain in secubox.conf (e.g. ".gk2.secubox.in"); empty =
# host-only cookie (still works, but not shared across subdomains).
SESSION_COOKIE = "secubox_session"
def _cookie_domain() -> Optional[str]:
cfg = get_config("api")
dom = cfg.get("sso_cookie_domain", "") or os.environ.get("SECUBOX_SSO_COOKIE_DOMAIN", "")
return dom or None
def _set_session_cookie(response: Response, token: str, expires_in: int = 86400) -> None:
response.set_cookie(
key=SESSION_COOKIE,
value=token,
max_age=expires_in,
httponly=True,
secure=True,
samesite="lax",
domain=_cookie_domain(),
path="/",
)
def create_token(
username: str,
expires_in: int = 86400,
@ -141,7 +171,7 @@ class TokenResponse(BaseModel):
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, request: Request):
async def login(req: LoginRequest, request: Request, response: Response):
"""Plain login endpoint — secubox-auth overrides this with the full branching flow."""
client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() \
or request.headers.get("X-Real-IP", "") \
@ -159,6 +189,9 @@ async def login(req: LoginRequest, request: Request):
)
jti = secrets.token_hex(8)
tok = create_token(req.username, jti=jti)
# SSO-lite: also drop a parent-domain session cookie so nginx auth_request
# (GET /auth/verify) gates sibling vhosts with this one login.
_set_session_cookie(response, tok)
_emit_session_event("login_success", req.username, {
"jti": jti,
"expires_in": 86400,
@ -166,3 +199,47 @@ async def login(req: LoginRequest, request: Request):
"user_agent": user_agent[:100] if user_agent else "",
})
return TokenResponse(access_token=tok)
@router.get("/verify")
async def verify(request: Request):
"""nginx `auth_request` target (SSO-lite, #400).
Validates the SecuBox session cookie (or a Bearer token for API clients)
against the same checks as require_jwt, and echoes the identity back as
`Remote-User` / `Remote-Groups` headers for the proxied app. 200 = allow,
401 = deny (nginx then 302s to the SecuBox login page)."""
tok = request.cookies.get(SESSION_COOKIE)
if not tok:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
tok = auth[7:]
if not tok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session")
# _decode_token raises 401 on invalid/expired.
payload = _decode_token(tok)
jti = payload.get("jti")
if not jti or not _session_validator(jti):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="session révoquée")
sub = payload["sub"]
if not user_store.is_enabled(sub):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="compte désactivé")
role = ""
try:
getter = getattr(user_store, "get_user", None)
if callable(getter):
u = getter(sub) or {}
role = u.get("role", "") if isinstance(u, dict) else ""
except Exception:
role = ""
return JSONResponse(
{"ok": True, "user": sub},
headers={"Remote-User": sub, "Remote-Groups": role},
)
@router.post("/logout")
async def logout(response: Response):
"""Clear the SSO-lite session cookie."""
response.delete_cookie(SESSION_COOKIE, domain=_cookie_domain(), path="/")
return {"ok": True}

View File

@ -14,6 +14,10 @@ log_level = "warning" # debug | info | warning | error
[api]
socket_dir = "/run/secubox" # Dossier des Unix sockets
jwt_secret = "" # Généré au firstboot — ne pas modifier
# SSO-lite (#400): domaine parent du cookie de session pour le gating
# nginx auth_request (/auth/verify) — un seul login couvre tous les vhosts
# *.<domaine>. Vide = cookie host-only. Ex: ".gk2.secubox.in"
sso_cookie_domain = ""
# Utilisateurs locaux (fallback si pas d'OAuth)
[auth]