"""Auth routes — login, logout, register, change-password, OIDC, MFA.""" import json import re import secrets import uuid from datetime import datetime, timedelta from urllib.parse import urlencode from fastapi import APIRouter, HTTPException, Depends, Request, Query from fastapi.responses import HTMLResponse, RedirectResponse from config import JWT_EXP_H from database import db from auth.crypto import _hash_pw, _verify_pw, _totp_secret, _totp_verify, _totp_uri, _make_token from auth.oidc import _get_oidc_config, _oidc_discover, _oidc_store_state, _oidc_pop_state, _validate_id_token from auth.jwt_auth import current_user, require, _audit from auth.rate_limit import _check_rate_limit from models import LoginReq, RegisterReq, TOTPVerify, ChangePwReq router = APIRouter() # ── Auth endpoints ──────────────────────────────────────────────────────────── @router.post("/api/auth/login") async def login(req: LoginReq, request: Request): _check_rate_limit(request.client.host if request.client else "unknown") with db() as c: mode_row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone() if mode_row and mode_row["value"] == "oidc": raise HTTPException(400, "Login local desabilitado. Use Oracle IAM.") with db() as c: u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone() if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas") u = dict(u) if u["mfa_enabled"]: if not req.totp_code: return {"mfa_required": True, "message": "Código MFA necessário"} if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(401, "Código MFA inválido") sid = str(uuid.uuid4()); exp = (datetime.utcnow()+timedelta(hours=JWT_EXP_H)).isoformat() with db() as c: c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp)) c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],)) _audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None) return {"token":_make_token(u["id"],u["role"],sid), "user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")}, "must_change_password": bool(u.get("must_change_password", 0))} @router.post("/api/auth/logout") async def logout_ep(u=Depends(current_user)): with db() as c: c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],)) return {"ok": True} @router.post("/api/auth/register") async def register(req: RegisterReq, adm=Depends(require("admin"))): if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida") uid = str(uuid.uuid4()) with db() as c: if c.execute("SELECT 1 FROM users WHERE username=?", (req.username,)).fetchone(): raise HTTPException(400, "Usuário já existe") c.execute("INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)", (uid, req.first_name, req.last_name, req.username, req.email, _hash_pw(req.password), req.role)) _audit(adm["id"], adm["username"], "create_user", uid, f"user={req.username} role={req.role}") return {"id": uid, "username": req.username, "role": req.role} @router.post("/api/auth/change-password") async def change_pw(req: ChangePwReq, u=Depends(current_user)): if u.get("auth_provider") == "oidc": raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente") if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta") pw = req.new_password if len(pw) < 8: raise HTTPException(400, "Senha deve ter no mínimo 8 caracteres") if not re.search(r'[A-Z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra maiúscula") if not re.search(r'[a-z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra minúscula") if not re.search(r'\d', pw): raise HTTPException(400, "Senha deve conter ao menos um número") if not re.search(r'[^A-Za-z0-9]', pw): raise HTTPException(400, "Senha deve conter ao menos um caractere especial") if pw == req.current_password: raise HTTPException(400, "A nova senha deve ser diferente da atual") with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(pw), u["id"])) return {"ok": True} # ── OIDC endpoints ─────────────────────────────────────────────────────────── @router.get("/api/auth/oidc/config") async def oidc_public_config(): with db() as c: row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone() return {"auth_mode": row["value"] if row else "local"} @router.get("/api/auth/oidc/login") async def oidc_login(request: Request): _check_rate_limit(request.client.host if request.client else "unknown") cfg = _get_oidc_config() if cfg.get("auth_mode") not in ("oidc", "both"): raise HTTPException(400, "OIDC login não está habilitado") if not cfg.get("oidc_issuer") or not cfg.get("oidc_client_id"): raise HTTPException(500, "OIDC não configurado (issuer/client_id ausente)") disco = _oidc_discover(cfg["oidc_issuer"]) state = secrets.token_urlsafe(32) nonce = secrets.token_urlsafe(32) _oidc_store_state(state, nonce) params = {"response_type": "code", "client_id": cfg["oidc_client_id"], "redirect_uri": cfg["oidc_redirect_uri"], "scope": "openid profile email groups", "state": state, "nonce": nonce} auth_url = disco["authorization_endpoint"] + "?" + urlencode(params) return RedirectResponse(url=auth_url, status_code=302) @router.get("/api/auth/oidc/callback") async def oidc_callback(request: Request, code: str = Query(...), state: str = Query(...)): ip = request.client.host if request.client else "unknown" _check_rate_limit(ip) pending = _oidc_pop_state(state) if not pending: raise HTTPException(400, "OIDC: state inválido ou expirado") nonce = pending["nonce"] cfg = _get_oidc_config() disco = _oidc_discover(cfg["oidc_issuer"]) import requests as req token_resp = req.post(disco["token_endpoint"], data={ "grant_type": "authorization_code", "code": code, "redirect_uri": cfg["oidc_redirect_uri"], "client_id": cfg["oidc_client_id"], "client_secret": cfg["oidc_client_secret"], }, timeout=15) if token_resp.status_code != 200: from config import log log.error(f"OIDC token exchange failed: {token_resp.status_code} {token_resp.text[:500]}") raise HTTPException(502, "OIDC: falha ao trocar código por token") tokens = token_resp.json() id_token_raw = tokens.get("id_token") if not id_token_raw: raise HTTPException(502, "OIDC: id_token ausente na resposta") claims = _validate_id_token(id_token_raw, cfg["oidc_issuer"], cfg["oidc_client_id"], nonce) sub = claims.get("sub") email = claims.get("email", "") given_name = claims.get("given_name") or claims.get("name", "").split(" ")[0] or "OIDC" family_name = claims.get("family_name") or " ".join(claims.get("name", "User").split(" ")[1:]) or "User" groups = claims.get("groups", []) if not groups: groups = claims.get("http://www.oracle.com/groups", []) role = "viewer" if cfg.get("oidc_group_admin") and cfg["oidc_group_admin"] in groups: role = "admin" elif cfg.get("oidc_group_user") and cfg["oidc_group_user"] in groups: role = "user" with db() as c: u = c.execute("SELECT * FROM users WHERE oidc_subject=? AND is_active=1", (sub,)).fetchone() if u: u = dict(u) c.execute("UPDATE users SET role=?, last_login=datetime('now'), email=? WHERE id=?", (role, email or u.get("email"), u["id"])) u["role"] = role _audit(u["id"], u["username"], "oidc_login", details=f"sub={sub}", ip=ip) else: uid = str(uuid.uuid4()) username = email.split("@")[0] if email else f"oidc_{sub[:8]}" base_username = username counter = 1 while c.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone(): username = f"{base_username}_{counter}" counter += 1 fake_hash = _hash_pw(secrets.token_hex(32)) c.execute( "INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,auth_provider,oidc_subject) " "VALUES (?,?,?,?,?,?,?,?,?)", (uid, given_name, family_name, username, email, fake_hash, role, "oidc", sub)) u = {"id": uid, "first_name": given_name, "last_name": family_name, "username": username, "email": email, "role": role} _audit(uid, username, "oidc_jit_provision", details=f"sub={sub} email={email} role={role}", ip=ip) c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (uid,)) sid = str(uuid.uuid4()) exp = (datetime.utcnow() + timedelta(hours=JWT_EXP_H)).isoformat() with db() as c: c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp)) token = _make_token(u["id"], u["role"], sid) html = f"""OIDC Login """ return HTMLResponse(content=html) @router.post("/api/auth/oidc/logout") async def oidc_logout(u=Depends(current_user)): with db() as c: c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],)) _audit(u["id"], u["username"], "oidc_logout") idp_logout_url = None try: cfg = _get_oidc_config() if cfg.get("oidc_issuer"): disco = _oidc_discover(cfg["oidc_issuer"]) idp_logout_url = disco.get("end_session_endpoint") except Exception: pass return {"ok": True, "idp_logout_url": idp_logout_url} @router.post("/api/settings/oidc/test") async def test_oidc(u=Depends(require("admin"))): cfg = _get_oidc_config() if not cfg.get("oidc_issuer"): raise HTTPException(400, "Issuer URL não configurado") import requests as req try: url = cfg["oidc_issuer"].rstrip("/") + "/.well-known/openid-configuration" resp = req.get(url, timeout=10) resp.raise_for_status() disco = resp.json() has_auth = bool(disco.get("authorization_endpoint")) has_token = bool(disco.get("token_endpoint")) has_jwks = bool(disco.get("jwks_uri")) return {"ok": has_auth and has_token and has_jwks, "issuer": disco.get("issuer"), "authorization_endpoint": has_auth, "token_endpoint": has_token, "jwks_uri": has_jwks, "end_session_endpoint": bool(disco.get("end_session_endpoint"))} except Exception as e: raise HTTPException(502, f"Falha ao conectar ao discovery: {str(e)}") # ── MFA ─────────────────────────────────────────────────────────────────────── @router.post("/api/mfa/setup") async def mfa_setup(u=Depends(current_user)): sec = _totp_secret() with db() as c: c.execute("UPDATE users SET mfa_secret=? WHERE id=?", (sec, u["id"])) return {"secret": sec, "uri": _totp_uri(sec, u["username"])} @router.post("/api/mfa/verify") async def mfa_verify(req: TOTPVerify, u=Depends(current_user)): if not u.get("mfa_secret"): raise HTTPException(400, "Chame /api/mfa/setup primeiro") if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(400, "Código inválido") with db() as c: c.execute("UPDATE users SET mfa_enabled=1 WHERE id=?", (u["id"],)) return {"ok": True, "message": "MFA ativado"} @router.post("/api/mfa/disable/{user_id}") async def mfa_disable(user_id: str, adm=Depends(require("admin"))): with db() as c: c.execute("UPDATE users SET mfa_enabled=0,mfa_secret=NULL WHERE id=?", (user_id,)) _audit(adm["id"], adm["username"], "disable_mfa", user_id) return {"ok": True}