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
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""ObsiGate CLI user management script.
Usage:
python backend/create_admin.py create <username> <password> [--role admin|user] [--vaults V1 V2...] [--display-name NAME]
python backend/create_admin.py list
python backend/create_admin.py delete <username>
Docker usage:
docker exec obsigate python backend/create_admin.py create admin MyPassword --role admin --vaults "*"
docker exec obsigate python backend/create_admin.py list
"""
import sys
import argparse
# Add parent directory to path for imports
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backend.auth.user_store import create_user, get_all_users, delete_user
def main():
parser = argparse.ArgumentParser(description="ObsiGate user management")
subparsers = parser.add_subparsers(dest="command")
# create
create_p = subparsers.add_parser("create", help="Create a user")
create_p.add_argument("username")
create_p.add_argument("password")
create_p.add_argument("--role", default="user", choices=["admin", "user"])
create_p.add_argument("--vaults", nargs="+", default=[])
create_p.add_argument("--display-name")
# list
subparsers.add_parser("list", help="List all users")
# delete
del_p = subparsers.add_parser("delete", help="Delete a user")
del_p.add_argument("username")
args = parser.parse_args()
if args.command == "create":
try:
user = create_user(
args.username, args.password, args.role,
args.vaults, args.display_name,
)
print(f"✅ User '{user['username']}' created (role: {user['role']})")
except ValueError as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
elif args.command == "list":
users = get_all_users()
if not users:
print("No users found.")
return
for u in users:
vaults = ", ".join(u["vaults"]) or "none"
status = "✅" if u["active"] else "🔴"
print(f"{status} {u['username']} ({u['role']}) — Vaults: {vaults}")
elif args.command == "delete":
try:
delete_user(args.username)
print(f"✅ User '{args.username}' deleted")
except ValueError as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
else:
parser.print_help()
if __name__ == "__main__":
main()
+129 -27
View File
@@ -1,9 +1,12 @@
import asyncio
import json as _json
import os
import re
import html as html_mod
import logging
import mimetypes
import secrets
import string
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from functools import partial
@@ -12,10 +15,11 @@ from typing import Optional, List, Dict, Any
import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query, Body
from fastapi import FastAPI, HTTPException, Query, Body, Depends
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
from backend.indexer import (
build_index,
@@ -321,6 +325,70 @@ async def _on_vault_change(events: list):
logger.info(f"Hot-reload: {len(changes)} change(s) in {list(updated_vaults)}")
# ---------------------------------------------------------------------------
# Authentication bootstrap
# ---------------------------------------------------------------------------
def bootstrap_admin():
"""Create the initial admin account if no users exist.
Reads OBSIGATE_ADMIN_USER and OBSIGATE_ADMIN_PASSWORD from environment.
If no password is set, generates a random one and logs it ONCE.
Only runs when auth is enabled and no users.json exists yet.
"""
from backend.auth.middleware import is_auth_enabled
from backend.auth.user_store import has_users, create_user
if not is_auth_enabled():
return
if has_users():
return # Users already exist, skip
admin_user = os.environ.get("OBSIGATE_ADMIN_USER", "admin")
admin_pass = os.environ.get("OBSIGATE_ADMIN_PASSWORD", "")
if not admin_pass:
# Generate a random password and display it ONCE in logs
admin_pass = "".join(
secrets.choice(string.ascii_letters + string.digits)
for _ in range(16)
)
logger.warning("=" * 60)
logger.warning("PREMIER DÉMARRAGE — Compte admin créé automatiquement")
logger.warning(f" Utilisateur : {admin_user}")
logger.warning(f" Mot de passe : {admin_pass}")
logger.warning("CHANGEZ CE MOT DE PASSE dès la première connexion !")
logger.warning("=" * 60)
create_user(admin_user, admin_pass, role="admin", vaults=["*"])
logger.info(f"Admin '{admin_user}' créé avec succès")
# ---------------------------------------------------------------------------
# Security headers middleware
# ---------------------------------------------------------------------------
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all HTTP responses."""
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"font-src 'self';"
)
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: build index on startup, cleanup on shutdown."""
@@ -329,6 +397,9 @@ async def lifespan(app: FastAPI):
logger.info("ObsiGate starting \u2014 building index...")
await build_index()
# Bootstrap admin account if needed
bootstrap_admin()
# Start file watcher
config = _load_config()
watcher_enabled = config.get("watcher_enabled", True)
@@ -359,7 +430,16 @@ async def lifespan(app: FastAPI):
_search_executor = None
app = FastAPI(title="ObsiGate", version="1.3.0", lifespan=lifespan)
app = FastAPI(title="ObsiGate", version="1.4.0", lifespan=lifespan)
# Security headers on all responses
app.add_middleware(SecurityHeadersMiddleware)
# Auth router
from backend.auth.router import router as auth_router
from backend.auth.middleware import require_auth, require_admin, check_vault_access
app.include_router(auth_router)
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
@@ -500,24 +580,26 @@ async def api_health():
@app.get("/api/vaults", response_model=List[VaultInfo])
async def api_vaults():
"""List all configured vaults with file and tag counts.
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
Returns:
List of vault summary objects.
List of vault summary objects filtered by user permissions.
"""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
result = []
for name, data in index.items():
result.append({
"name": name,
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
})
if "*" in user_vaults or name in user_vaults:
result.append({
"name": name,
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
})
return result
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = ""):
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
@@ -530,6 +612,8 @@ async def api_browse(vault_name: str, path: str = ""):
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -597,7 +681,7 @@ EXT_TO_LANG = {
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")):
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
@@ -607,6 +691,8 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -637,7 +723,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
@app.get("/api/file/{vault_name}/download")
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
@@ -647,6 +733,8 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -669,6 +757,7 @@ async def api_file_save(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Save (overwrite) a file's content.
@@ -683,6 +772,8 @@ async def api_file_save(
Returns:
``FileSaveResponse`` confirming the write.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -707,7 +798,7 @@ async def api_file_save(
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")):
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Delete a file from the vault.
The path is validated against traversal attacks before deletion.
@@ -719,6 +810,8 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
Returns:
``FileDeleteResponse`` confirming the deletion.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -741,7 +834,7 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")):
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
@@ -755,6 +848,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -829,6 +924,7 @@ async def api_search(
tag: Optional[str] = Query(None, description="Tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user=Depends(require_auth),
):
"""Full-text search across vaults with relevance scoring.
@@ -862,7 +958,7 @@ async def api_search(
@app.get("/api/tags", response_model=TagsResponse)
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")):
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Return all unique tags with occurrence counts.
Args:
@@ -879,6 +975,7 @@ async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"
async def api_tree_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
current_user=Depends(require_auth),
):
"""Search for files and directories in the tree structure using pre-built index.
@@ -926,6 +1023,7 @@ async def api_advanced_search(
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
current_user=Depends(require_auth),
):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
@@ -962,6 +1060,7 @@ async def api_suggest(
q: str = Query("", description="Prefix to search for in file titles"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest file titles matching a prefix (accent-insensitive).
@@ -984,6 +1083,7 @@ async def api_tags_suggest(
q: str = Query("", description="Prefix to search for in tags"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest tags matching a prefix (accent-insensitive).
@@ -1002,7 +1102,7 @@ async def api_tags_suggest(
@app.get("/api/index/reload", response_model=ReloadResponse)
async def api_reload():
async def api_reload(current_user=Depends(require_admin)):
"""Force a full re-index of all configured vaults.
Returns:
@@ -1021,7 +1121,7 @@ async def api_reload():
# ---------------------------------------------------------------------------
@app.get("/api/events")
async def api_events():
async def api_events(current_user=Depends(require_auth)):
"""SSE stream for real-time index update notifications.
Sends keepalive comments every 30s. Events:
@@ -1064,7 +1164,7 @@ async def api_events():
# ---------------------------------------------------------------------------
@app.post("/api/vaults/add")
async def api_add_vault(body: dict = Body(...)):
async def api_add_vault(body: dict = Body(...), current_user=Depends(require_admin)):
"""Add a new vault dynamically without restarting.
Body:
@@ -1094,7 +1194,7 @@ async def api_add_vault(body: dict = Body(...)):
@app.delete("/api/vaults/{vault_name}")
async def api_remove_vault(vault_name: str):
async def api_remove_vault(vault_name: str, current_user=Depends(require_admin)):
"""Remove a vault from the index and stop watching it.
Args:
@@ -1113,7 +1213,7 @@ async def api_remove_vault(vault_name: str):
@app.get("/api/vaults/status")
async def api_vaults_status():
async def api_vaults_status(current_user=Depends(require_auth)):
"""Detailed status of all vaults including watcher state.
Returns per-vault: file count, tag count, watching status, vault path.
@@ -1135,7 +1235,7 @@ async def api_vaults_status():
@app.get("/api/image/{vault_name}")
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image")):
async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)):
"""Serve an image file with proper MIME type.
Args:
@@ -1145,6 +1245,8 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
Returns:
Image file with appropriate content-type header.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
@@ -1173,7 +1275,7 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
@app.post("/api/attachments/rescan/{vault_name}")
async def api_rescan_attachments(vault_name: str):
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
"""Rescan attachments for a specific vault.
Args:
@@ -1194,7 +1296,7 @@ async def api_rescan_attachments(vault_name: str):
@app.get("/api/attachments/stats")
async def api_attachment_stats(vault: Optional[str] = Query(None, description="Vault filter")):
async def api_attachment_stats(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Get attachment statistics for vaults.
Args:
@@ -1258,13 +1360,13 @@ def _save_config(config: dict) -> None:
@app.get("/api/config")
async def api_get_config():
async def api_get_config(current_user=Depends(require_auth)):
"""Return current configuration with defaults for missing keys."""
return _load_config()
@app.post("/api/config")
async def api_set_config(body: dict = Body(...)):
async def api_set_config(body: dict = Body(...), current_user=Depends(require_admin)):
"""Update configuration. Only known keys are accepted.
Keys matching ``_DEFAULT_CONFIG`` are validated and persisted.
@@ -1294,7 +1396,7 @@ async def api_set_config(body: dict = Body(...)):
# ---------------------------------------------------------------------------
@app.get("/api/diagnostics")
async def api_diagnostics():
async def api_diagnostics(current_user=Depends(require_admin)):
"""Return index statistics and system diagnostics.
Includes document counts, token counts, memory estimates,
+2
View File
@@ -5,3 +5,5 @@ mistune==3.0.2
python-multipart==0.0.9
aiofiles==23.2.1
watchdog>=4.0.0
argon2-cffi>=23.1.0
python-jose[cryptography]>=3.3.0