From 0706317d258dee1410a6518583ef0129bda59ffa Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Thu, 28 May 2026 10:09:49 +0200 Subject: [PATCH] feat(core): SSO-lite session cookie + /auth/verify for nginx auth_request (ref #400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- common/secubox_core/auth.py | 81 ++++++++++++++++++++++++++++++++++++- secubox.conf.example | 4 ++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/common/secubox_core/auth.py b/common/secubox_core/auth.py index beb91474..b8bbc856 100644 --- a/common/secubox_core/auth.py +++ b/common/secubox_core/auth.py @@ -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 *. 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} diff --git a/secubox.conf.example b/secubox.conf.example index 0f28c85f..c021a47b 100644 --- a/secubox.conf.example +++ b/secubox.conf.example @@ -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 +# *.. Vide = cookie host-only. Ex: ".gk2.secubox.in" +sso_cookie_domain = "" # Utilisateurs locaux (fallback si pas d'OAuth) [auth]