feat: Introduce a comprehensive authentication system, including user management, JWT handling, and initial frontend components with Docker support.

This commit is contained in:
2026-03-23 15:44:37 -04:00
parent 757b72c549
commit 190f47f134
15 changed files with 1874 additions and 43 deletions
+1
View File
@@ -0,0 +1 @@
# backend/auth — Authentication & access control module for ObsiGate
+133
View File
@@ -0,0 +1,133 @@
# backend/auth/jwt_handler.py
# JWT token generation, validation, and revocation.
# Secret key auto-generated on first startup and persisted to data/secret.key.
# Revoked token JTIs persisted to data/revoked_tokens.json.
import json
import secrets
import uuid
import time
import logging
from pathlib import Path
from jose import jwt, JWTError
from typing import Optional
logger = logging.getLogger("obsigate.auth.jwt")
# Paths relative to working directory (Docker: /app)
SECRET_KEY_FILE = Path("data/secret.key")
REVOKED_TOKENS_FILE = Path("data/revoked_tokens.json")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days
# In-memory revoked token set (loaded from disk on startup)
_revoked_jtis: set = set()
_revoked_loaded = False
def get_secret_key() -> str:
"""Read or generate the JWT secret key.
On first call, generates a 512-bit random key and writes it to
data/secret.key with 600 permissions. Subsequent calls read from disk.
"""
if not SECRET_KEY_FILE.exists():
SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_hex(64) # 512 bits
SECRET_KEY_FILE.write_text(key)
try:
SECRET_KEY_FILE.chmod(0o600)
except OSError:
pass # Windows doesn't support Unix permissions
logger.info("Generated new JWT secret key")
return key
return SECRET_KEY_FILE.read_text().strip()
def create_access_token(user: dict) -> str:
"""Create a JWT access token with user claims."""
now = int(time.time())
payload = {
"sub": user["username"],
"role": user["role"],
"vaults": user["vaults"],
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS,
"type": "access",
}
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
def create_refresh_token(username: str) -> tuple:
"""Create a JWT refresh token. Returns (token_string, jti)."""
now = int(time.time())
jti = str(uuid.uuid4())
payload = {
"sub": username,
"jti": jti,
"iat": now,
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS,
"type": "refresh",
}
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT. Returns None if invalid/expired."""
try:
return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
except JWTError:
return None
# ---------------------------------------------------------------------------
# Token revocation
# ---------------------------------------------------------------------------
def _load_revoked():
"""Load revoked token JTIs from disk into memory (once)."""
global _revoked_loaded, _revoked_jtis
if _revoked_loaded:
return
if REVOKED_TOKENS_FILE.exists():
try:
data = json.loads(REVOKED_TOKENS_FILE.read_text())
# Clean expired entries (older than 7 days)
now = int(time.time())
_revoked_jtis = {
jti for jti, exp in data.items()
if exp > now
}
except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_jtis = set()
_revoked_loaded = True
def _save_revoked():
"""Persist revoked JTIs to disk."""
REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Store with expiry timestamp for cleanup
now = int(time.time())
# Keep entries for 7 days max
data = {jti: now + REFRESH_TOKEN_EXPIRE_SECONDS for jti in _revoked_jtis}
tmp = REVOKED_TOKENS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data))
tmp.replace(REVOKED_TOKENS_FILE)
def revoke_token(jti: str):
"""Add a token JTI to the revocation list."""
_load_revoked()
_revoked_jtis.add(jti)
_save_revoked()
logger.debug(f"Revoked token JTI: {jti[:8]}...")
def is_token_revoked(jti: str) -> bool:
"""Check if a token JTI has been revoked."""
_load_revoked()
return jti in _revoked_jtis
+108
View File
@@ -0,0 +1,108 @@
# backend/auth/middleware.py
# FastAPI dependencies for authentication and authorization.
# Reads JWT from Authorization header OR access_token cookie.
import os
import logging
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
from .jwt_handler import decode_token
from .user_store import get_user
logger = logging.getLogger("obsigate.auth.middleware")
security = HTTPBearer(auto_error=False)
def is_auth_enabled() -> bool:
"""Check if authentication is enabled via environment variable.
Default: True (auth enabled). Set OBSIGATE_AUTH_ENABLED=false to disable.
"""
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[dict]:
"""Extract and validate the current user from JWT.
Reads token from Authorization header first, falls back to access_token cookie.
Returns None if no valid token is found.
"""
# If auth is disabled, return a fake admin user with full access
if not is_auth_enabled():
return {
"username": "anonymous",
"display_name": "Anonymous",
"role": "admin",
"vaults": ["*"],
"active": True,
"_token_vaults": ["*"],
}
token = None
if credentials:
token = credentials.credentials
elif "access_token" in request.cookies:
token = request.cookies["access_token"]
if not token:
return None
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
raise HTTPException(
status_code=401,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def require_admin(current_user=Depends(require_auth)):
"""Dependency: require admin role."""
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès admin requis")
return current_user
def check_vault_access(vault_name: str, user: dict) -> bool:
"""Check if a user has access to a specific vault.
Rules:
- vaults == ["*"] → full access (admin default)
- vault_name in vaults → access granted
- otherwise → denied
"""
vaults = user.get("_token_vaults") or user.get("vaults", [])
if "*" in vaults:
return True
return vault_name in vaults
def require_vault_access(vault_name: str, user: dict = Depends(require_auth)):
"""Dependency: require access to a specific vault."""
if not check_vault_access(vault_name, user):
raise HTTPException(
status_code=403,
detail=f"Accès refusé à la vault '{vault_name}'",
)
return user
+32
View File
@@ -0,0 +1,32 @@
# backend/auth/password.py
# Argon2id password hashing — OWASP 2024 recommended algorithm.
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
ph = PasswordHasher(
time_cost=2,
memory_cost=65536, # 64 MB
parallelism=2,
hash_len=32,
salt_len=16,
)
def hash_password(password: str) -> str:
"""Hash a password with Argon2id."""
return ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its Argon2id hash."""
try:
return ph.verify(hashed, password)
except (VerifyMismatchError, VerificationError):
return False
def needs_rehash(hashed: str) -> bool:
"""Check if hash needs updating (parameters changed)."""
return ph.check_needs_rehash(hashed)
+276
View File
@@ -0,0 +1,276 @@
# backend/auth/router.py
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import re
import logging
from fastapi import APIRouter, HTTPException, Response, Request, Depends
from pydantic import BaseModel, validator
from typing import List, Optional
from .user_store import (
get_user, get_all_users, create_user, update_user, delete_user,
record_login_success, record_login_failure, is_locked, has_users,
)
from .jwt_handler import (
create_access_token, create_refresh_token, decode_token,
revoke_token, is_token_revoked,
ACCESS_TOKEN_EXPIRE_SECONDS,
)
from .middleware import require_auth, require_admin, is_auth_enabled
from .password import verify_password, hash_password
logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
# ── Pydantic request models ──────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
remember_me: bool = False # True → refresh token 30d instead of 7d
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@validator("new_password")
def password_strength(cls, v):
if len(v) < 8:
raise ValueError("Minimum 8 caractères")
return v
class CreateUserRequest(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
vaults: List[str] = []
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class UpdateUserRequest(BaseModel):
display_name: Optional[str] = None
vaults: Optional[List[str]] = None
active: Optional[bool] = None
password: Optional[str] = None
role: Optional[str] = None
# ── Public endpoints ──────────────────────────────────────────────────
@router.get("/status")
async def auth_status():
"""Public endpoint: returns whether auth is enabled.
The frontend uses this to decide whether to show the login screen.
Also returns whether any users exist (for first-run detection).
"""
return {
"auth_enabled": is_auth_enabled(),
"has_users": has_users(),
}
@router.post("/login")
async def login(request: LoginRequest, response: Response):
"""Authenticate a user. Returns access token and sets refresh cookie.
Implements timing-safe responses to prevent user enumeration:
a failed login with an unknown user takes the same time as one
with a known user (dummy hash is computed).
"""
user = get_user(request.username)
if not user:
# Timing-safe: simulate hash computation to prevent user enumeration
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("active"):
raise HTTPException(403, "Compte désactivé")
if is_locked(request.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(request.password, user["password_hash"]):
attempts = record_login_failure(request.username)
remaining = max(0, 5 - attempts)
detail = "Identifiants invalides"
if 0 < remaining <= 2:
detail += f" ({remaining} tentative(s) restante(s))"
raise HTTPException(401, detail)
# Success — generate tokens
record_login_success(request.username)
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(request.username)
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
max_age = 2592000 if request.remember_me else 604800 # 30d or 7d
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
response.set_cookie(
key="refresh_token",
value=refresh_token,
max_age=max_age,
httponly=True,
samesite="strict",
secure=secure,
path="/api/auth/refresh",
)
logger.info(f"User '{request.username}' logged in")
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
"user": {
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
"vaults": user["vaults"],
},
}
@router.post("/refresh")
async def refresh_token_endpoint(request: Request, response: Response):
"""Renew access token via refresh token cookie.
Called automatically by the frontend when the access token expires.
"""
refresh_tok = request.cookies.get("refresh_token")
if not refresh_tok:
raise HTTPException(401, "Refresh token manquant")
payload = decode_token(refresh_tok)
if not payload or payload.get("type") != "refresh":
raise HTTPException(401, "Refresh token invalide")
if is_token_revoked(payload["jti"]):
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
user = get_user(payload["sub"])
if not user or not user.get("active"):
raise HTTPException(401, "Utilisateur introuvable ou inactif")
new_access_token = create_access_token(user)
return {
"access_token": new_access_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
current_user=Depends(require_auth),
):
"""Logout: revoke refresh token and delete cookie."""
refresh_tok = request.cookies.get("refresh_token")
if refresh_tok:
payload = decode_token(refresh_tok)
if payload:
revoke_token(payload["jti"])
response.delete_cookie("refresh_token", path="/api/auth/refresh")
logger.info(f"User '{current_user['username']}' logged out")
return {"message": "Déconnecté avec succès"}
@router.get("/me")
async def get_me(current_user=Depends(require_auth)):
"""Return current authenticated user info."""
return {
"username": current_user["username"],
"display_name": current_user["display_name"],
"role": current_user["role"],
"vaults": current_user["vaults"],
"last_login": current_user.get("last_login"),
}
@router.post("/change-password")
async def change_password(
req: ChangePasswordRequest,
current_user=Depends(require_auth),
):
"""Change own password."""
user = get_user(current_user["username"])
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
update_user(current_user["username"], {"password": req.new_password})
return {"message": "Mot de passe mis à jour"}
# ── Admin endpoints ───────────────────────────────────────────────────
@router.get("/admin/users")
async def list_users(admin=Depends(require_admin)):
"""List all users (admin only). Password hashes are never included."""
return get_all_users()
@router.post("/admin/users")
async def create_user_endpoint(
req: CreateUserRequest,
admin=Depends(require_admin),
):
"""Create a new user (admin only)."""
try:
user = create_user(
req.username, req.password, req.role, req.vaults, req.display_name
)
return user
except ValueError as e:
raise HTTPException(400, str(e))
@router.patch("/admin/users/{username}")
async def update_user_endpoint(
username: str,
req: UpdateUserRequest,
admin=Depends(require_admin),
):
"""Update a user (admin only)."""
updates = req.dict(exclude_none=True)
try:
return update_user(username, updates)
except ValueError as e:
raise HTTPException(404, str(e))
@router.delete("/admin/users/{username}")
async def delete_user_endpoint(
username: str,
admin=Depends(require_admin),
):
"""Delete a user (admin only). Cannot delete own account."""
if username == admin["username"]:
raise HTTPException(400, "Impossible de supprimer son propre compte")
try:
delete_user(username)
return {"message": f"Utilisateur '{username}' supprimé"}
except ValueError as e:
raise HTTPException(404, str(e))
+172
View File
@@ -0,0 +1,172 @@
# backend/auth/user_store.py
# CRUD operations on data/users.json with atomic writes.
# File is read on each auth request (no stale cache), written atomically
# via tmp+rename to prevent corruption on crash.
import json
import uuid
import logging
import shutil
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from .password import hash_password
logger = logging.getLogger("obsigate.auth.users")
USERS_FILE = Path("data/users.json")
def _read() -> dict:
"""Read users.json. Returns empty structure if file doesn't exist."""
if not USERS_FILE.exists():
return {"version": 1, "users": {}}
try:
return json.loads(USERS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.error(f"Failed to read users.json: {e}")
return {"version": 1, "users": {}}
def _write(data: dict):
"""Atomic write: write to .tmp then rename to prevent corruption."""
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = USERS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
shutil.move(str(tmp), str(USERS_FILE))
try:
USERS_FILE.chmod(0o600)
except OSError:
pass # Windows doesn't support Unix permissions
def has_users() -> bool:
"""Check if any users exist."""
return bool(_read()["users"])
def get_user(username: str) -> Optional[dict]:
"""Get a user by username. Returns None if not found."""
return _read()["users"].get(username)
def get_all_users() -> List[dict]:
"""Get all users WITHOUT password_hash (safe for API responses)."""
users = _read()["users"]
return [
{k: v for k, v in u.items() if k != "password_hash"}
for u in users.values()
]
def create_user(
username: str,
password: str,
role: str = "user",
vaults: Optional[List[str]] = None,
display_name: Optional[str] = None,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
data = _read()
if username in data["users"]:
raise ValueError(f"User '{username}' already exists")
user = {
"id": str(uuid.uuid4()),
"username": username,
"display_name": display_name or username,
"password_hash": hash_password(password),
"role": role,
"vaults": vaults or [],
"active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
}
data["users"][username] = user
_write(data)
logger.info(f"Created user '{username}' (role={role})")
return {k: v for k, v in user.items() if k != "password_hash"}
def update_user(username: str, updates: dict) -> dict:
"""Update user fields. Raises ValueError if user not found.
Forbidden fields (id, username, created_at) are silently ignored.
If 'password' is in updates, it's hashed and stored as password_hash.
"""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
forbidden = {"id", "username", "created_at"}
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
data["users"][username].update(safe_updates)
_write(data)
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
def delete_user(username: str):
"""Delete a user. Raises ValueError if not found."""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
del data["users"][username]
_write(data)
logger.info(f"Deleted user '{username}'")
def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts."""
update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(),
"failed_attempts": 0,
"locked_until": None,
})
def record_login_failure(username: str) -> int:
"""Record a failed login. Returns the new attempt count.
After 5 failures, locks the account for 15 minutes.
"""
data = _read()
user = data["users"].get(username)
if not user:
return 0
attempts = user.get("failed_attempts", 0) + 1
updates = {"failed_attempts": attempts}
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
update_user(username, updates)
return attempts
def is_locked(username: str) -> bool:
"""Check if an account is temporarily locked.
Auto-unlocks if the lock period has expired.
"""
user = get_user(username)
if not user or not user.get("locked_until"):
return False
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until:
# Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0})
return False
return True