feat: Introduce a comprehensive authentication system, including user management, JWT handling, and initial frontend components with Docker support.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# backend/auth — Authentication & access control module for ObsiGate
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user