mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
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:
parent
02528cbc85
commit
0706317d25
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user