From 190f47f1343c245391dbb4a5d25cfa250a6f0a9d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 23 Mar 2026 15:44:37 -0400 Subject: [PATCH] feat: Introduce a comprehensive authentication system, including user management, JWT handling, and initial frontend components with Docker support. --- Dockerfile | 3 +- backend/auth/__init__.py | 1 + backend/auth/jwt_handler.py | 133 ++++++++++ backend/auth/middleware.py | 108 ++++++++ backend/auth/password.py | 32 +++ backend/auth/router.py | 276 +++++++++++++++++++ backend/auth/user_store.py | 172 ++++++++++++ backend/create_admin.py | 79 ++++++ backend/main.py | 156 +++++++++-- backend/requirements.txt | 2 + data/users.json | 20 ++ docker-compose.yml | 6 +- frontend/app.js | 515 +++++++++++++++++++++++++++++++++++- frontend/index.html | 43 ++- frontend/style.css | 371 ++++++++++++++++++++++++++ 15 files changed, 1874 insertions(+), 43 deletions(-) create mode 100644 backend/auth/__init__.py create mode 100644 backend/auth/jwt_handler.py create mode 100644 backend/auth/middleware.py create mode 100644 backend/auth/password.py create mode 100644 backend/auth/router.py create mode 100644 backend/auth/user_store.py create mode 100644 backend/create_admin.py create mode 100644 data/users.json diff --git a/Dockerfile b/Dockerfile index 0134aa3..04e1140 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,9 @@ COPY --from=builder /install /usr/local COPY backend/ ./backend/ COPY frontend/ ./frontend/ -# Create non-root user for security +# Create non-root user for security + data directory for auth persistence RUN groupadd -r obsigate && useradd -r -g obsigate -d /app -s /sbin/nologin obsigate \ + && mkdir -p /app/data \ && chown -R obsigate:obsigate /app USER obsigate diff --git a/backend/auth/__init__.py b/backend/auth/__init__.py new file mode 100644 index 0000000..851020f --- /dev/null +++ b/backend/auth/__init__.py @@ -0,0 +1 @@ +# backend/auth — Authentication & access control module for ObsiGate diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py new file mode 100644 index 0000000..f988807 --- /dev/null +++ b/backend/auth/jwt_handler.py @@ -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 diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py new file mode 100644 index 0000000..fe16422 --- /dev/null +++ b/backend/auth/middleware.py @@ -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 diff --git a/backend/auth/password.py b/backend/auth/password.py new file mode 100644 index 0000000..819f654 --- /dev/null +++ b/backend/auth/password.py @@ -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) diff --git a/backend/auth/router.py b/backend/auth/router.py new file mode 100644 index 0000000..732a8ed --- /dev/null +++ b/backend/auth/router.py @@ -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)) diff --git a/backend/auth/user_store.py b/backend/auth/user_store.py new file mode 100644 index 0000000..2e1a48a --- /dev/null +++ b/backend/auth/user_store.py @@ -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 diff --git a/backend/create_admin.py b/backend/create_admin.py new file mode 100644 index 0000000..bb41668 --- /dev/null +++ b/backend/create_admin.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""ObsiGate CLI user management script. + +Usage: + python backend/create_admin.py create [--role admin|user] [--vaults V1 V2...] [--display-name NAME] + python backend/create_admin.py list + python backend/create_admin.py delete + +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() diff --git a/backend/main.py b/backend/main.py index ce09ea2..4218bda 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/requirements.txt b/backend/requirements.txt index 2638822..58cd903 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/data/users.json b/data/users.json new file mode 100644 index 0000000..3434a38 --- /dev/null +++ b/data/users.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "users": { + "admin": { + "id": "f7b85dfa-bcf6-4e42-b861-7563673620a6", + "username": "admin", + "display_name": "admin", + "password_hash": "$argon2id$v=19$m=65536,t=2,p=2$H4Pa7H4HOWeQoEbj4Ndkbw$c61e9zRHuXXdrLB6uTrjIBo/FFQaaZMaNHkB7CZaHhM", + "role": "admin", + "vaults": [ + "*" + ], + "active": true, + "created_at": "2026-03-23T19:38:00.742597+00:00", + "last_login": null, + "failed_attempts": 0, + "locked_until": null + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ee97141..e6baa99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,4 +33,8 @@ services: - VAULT_5_PATH=/vaults/SessionsManager - VAULT_6_NAME=Bruno - VAULT_6_PATH=/vaults/bruno - + # Auth configuration (uncomment to enable) + # - OBSIGATE_AUTH_ENABLED=true + # - OBSIGATE_ADMIN_USER=admin + # - OBSIGATE_ADMIN_PASSWORD= # Leave empty = auto-generated (check logs) + # - OBSIGATE_SECURE_COOKIES=false # Set true if behind HTTPS reverse proxy diff --git a/frontend/app.js b/frontend/app.js index 710af2a..ce0bf92 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -779,12 +779,35 @@ async function api(path, opts) { var res; try { - res = await fetch(path, opts || {}); + // Inject auth header if authenticated + const authHeaders = AuthManager.getAuthHeaders(); + const mergedOpts = opts || {}; + if (authHeaders) { + mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders }; + } + mergedOpts.credentials = 'include'; + res = await fetch(path, mergedOpts); } catch (err) { if (err.name === "AbortError") throw err; // let callers handle abort showToast("Erreur réseau — vérifiez votre connexion"); throw err; } + if (res.status === 401 && AuthManager._authEnabled) { + // Token expired — try refresh + try { + await AuthManager.refreshAccessToken(); + // Retry the request with new token + const retryHeaders = AuthManager.getAuthHeaders(); + const retryOpts = opts || {}; + retryOpts.headers = { ...retryOpts.headers, ...retryHeaders }; + retryOpts.credentials = 'include'; + res = await fetch(path, retryOpts); + } catch (refreshErr) { + AuthManager.clearSession(); + AuthManager.showLoginScreen(); + throw new Error('Session expirée'); + } + } if (!res.ok) { var detail = ""; try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } @@ -794,6 +817,451 @@ return res.json(); } + // --------------------------------------------------------------------------- + // AuthManager — Authentication state & token management + // --------------------------------------------------------------------------- + + const AuthManager = { + ACCESS_TOKEN_KEY: 'obsigate_access_token', + TOKEN_EXPIRY_KEY: 'obsigate_token_expiry', + USER_KEY: 'obsigate_user', + _authEnabled: false, + + // ── Token storage (sessionStorage) ───────────────────────────── + + saveToken(tokenData) { + const expiresAt = Date.now() + (tokenData.expires_in * 1000); + sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token); + sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); + if (tokenData.user) { + sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); + } + }, + + getToken() { + return sessionStorage.getItem(this.ACCESS_TOKEN_KEY); + }, + + getUser() { + const raw = sessionStorage.getItem(this.USER_KEY); + return raw ? JSON.parse(raw) : null; + }, + + isTokenExpired() { + const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); + if (!expiry) return true; + // Renew 60s before expiration + return Date.now() > (parseInt(expiry) - 60000); + }, + + clearSession() { + sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); + sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY); + sessionStorage.removeItem(this.USER_KEY); + }, + + getAuthHeaders() { + const token = this.getToken(); + if (!token || !this._authEnabled) return null; + return { 'Authorization': 'Bearer ' + token }; + }, + + // ── API calls ────────────────────────────────────────────────── + + async login(username, password, rememberMe) { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ username, password, remember_me: rememberMe || false }), + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Erreur de connexion'); + } + const data = await response.json(); + this.saveToken(data); + return data.user; + }, + + async logout() { + try { + const token = this.getToken(); + await fetch('/api/auth/logout', { + method: 'POST', + headers: token ? { 'Authorization': 'Bearer ' + token } : {}, + credentials: 'include', + }); + } catch (e) { /* continue even if API fails */ } + this.clearSession(); + this.showLoginScreen(); + }, + + async refreshAccessToken() { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + this.clearSession(); + throw new Error('Session expirée'); + } + const data = await response.json(); + const expiry = Date.now() + (data.expires_in * 1000); + sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token); + sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString()); + return data.access_token; + }, + + // ── UI controls ──────────────────────────────────────────────── + + showLoginScreen() { + const app = document.getElementById('app'); + const login = document.getElementById('login-screen'); + if (app) app.classList.add('hidden'); + if (login) { + login.classList.remove('hidden'); + const usernameInput = document.getElementById('login-username'); + if (usernameInput) usernameInput.focus(); + } + }, + + showApp() { + const login = document.getElementById('login-screen'); + const app = document.getElementById('app'); + if (login) login.classList.add('hidden'); + if (app) app.classList.remove('hidden'); + this.renderUserMenu(); + }, + + renderUserMenu() { + const user = this.getUser(); + const userMenu = document.getElementById('user-menu'); + if (!userMenu) return; + if (!user || !this._authEnabled) { + userMenu.innerHTML = ''; + return; + } + userMenu.innerHTML = + '' + (user.display_name || user.username) + '' + + (user.role === 'admin' + ? '' + : '') + + ''; + safeCreateIcons(); + + const logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) logoutBtn.addEventListener('click', () => AuthManager.logout()); + + const adminBtn = document.getElementById('admin-btn'); + if (adminBtn) adminBtn.addEventListener('click', () => AdminPanel.show()); + }, + + // ── Initialization ────────────────────────────────────────────── + + async checkAuthStatus() { + try { + const res = await fetch('/api/auth/status'); + const data = await res.json(); + this._authEnabled = data.auth_enabled; + return data; + } catch (e) { + this._authEnabled = false; + return { auth_enabled: false }; + } + }, + + async initAuth() { + const status = await this.checkAuthStatus(); + if (!status.auth_enabled) { + // Auth disabled — show app immediately + this.showApp(); + return true; + } + + // Auth enabled — check for existing session + if (this.getToken() && !this.isTokenExpired()) { + this.showApp(); + return true; + } + + // Try silent refresh + try { + await this.refreshAccessToken(); + // Fetch user info + const token = this.getToken(); + const res = await fetch('/api/auth/me', { + headers: { 'Authorization': 'Bearer ' + token }, + credentials: 'include', + }); + if (res.ok) { + const user = await res.json(); + sessionStorage.setItem(this.USER_KEY, JSON.stringify(user)); + this.showApp(); + return true; + } + } catch (e) { /* silent refresh failed */ } + + // No valid session — show login + this.showLoginScreen(); + return false; + }, + }; + + // --------------------------------------------------------------------------- + // Login form handler + // --------------------------------------------------------------------------- + + function initLoginForm() { + const form = document.getElementById('login-form'); + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('login-username').value; + const password = document.getElementById('login-password').value; + const rememberMe = document.getElementById('remember-me').checked; + const errorEl = document.getElementById('login-error'); + const btn = document.getElementById('login-btn'); + + btn.disabled = true; + btn.querySelector('.btn-spinner').classList.remove('hidden'); + btn.querySelector('.btn-text').textContent = 'Connexion...'; + errorEl.classList.add('hidden'); + + try { + await AuthManager.login(username, password, rememberMe); + AuthManager.showApp(); + // Load app data after successful login + try { + await Promise.all([loadVaults(), loadTags()]); + } catch (err) { + console.error('Failed to load data after login:', err); + } + safeCreateIcons(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove('hidden'); + document.getElementById('login-password').value = ''; + document.getElementById('login-password').focus(); + } finally { + btn.disabled = false; + btn.querySelector('.btn-spinner').classList.add('hidden'); + btn.querySelector('.btn-text').textContent = 'Se connecter'; + } + }); + + // Toggle password visibility + const toggleBtn = document.getElementById('toggle-password'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + const input = document.getElementById('login-password'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); + } + } + + // --------------------------------------------------------------------------- + // Admin Panel — User management (admin only) + // --------------------------------------------------------------------------- + + const AdminPanel = { + _modal: null, + _allVaults: [], + + show() { + this._createModal(); + this._modal.classList.add('active'); + this._loadUsers(); + }, + + hide() { + if (this._modal) this._modal.classList.remove('active'); + }, + + _createModal() { + if (this._modal) return; + this._modal = document.createElement('div'); + this._modal.className = 'editor-modal'; + this._modal.id = 'admin-modal'; + this._modal.innerHTML = ` +
+
+
⚙️ Administration — Utilisateurs
+
+ +
+
+
+
+ +
+
+
+
+ `; + document.body.appendChild(this._modal); + safeCreateIcons(); + + document.getElementById('admin-close').addEventListener('click', () => this.hide()); + document.getElementById('admin-add-user').addEventListener('click', () => this._showUserForm(null)); + }, + + async _loadUsers() { + try { + const users = await api('/api/auth/admin/users'); + // Also load available vaults + try { + const vaultsData = await api('/api/vaults'); + this._allVaults = vaultsData.map(v => v.name); + } catch (e) { this._allVaults = []; } + this._renderUsers(users); + } catch (err) { + document.getElementById('admin-users-list').innerHTML = + '

Erreur : ' + err.message + '

'; + } + }, + + _renderUsers(users) { + const container = document.getElementById('admin-users-list'); + if (!users.length) { + container.innerHTML = '

Aucun utilisateur.

'; + return; + } + let html = '' + + '' + + ''; + users.forEach(u => { + const vaults = u.vaults.includes('*') ? 'Toutes' : (u.vaults.join(', ') || 'Aucune'); + const status = u.active ? '✅' : '🔴'; + const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString('fr-FR', { day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit' }) : 'Jamais'; + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
UtilisateurRôleVaultsStatutDernière connexionActions
' + u.username + '' + (u.display_name && u.display_name !== u.username ? '
' + u.display_name + '' : '') + '
' + u.role + '' + vaults + '' + status + '' + lastLogin + '' + + '' + + '' + + '
'; + container.innerHTML = html; + + // Bind action buttons + container.querySelectorAll('[data-action="edit"]').forEach(btn => { + btn.addEventListener('click', () => { + const user = users.find(u => u.username === btn.dataset.username); + if (user) this._showUserForm(user); + }); + }); + container.querySelectorAll('[data-action="delete"]').forEach(btn => { + btn.addEventListener('click', () => this._deleteUser(btn.dataset.username)); + }); + }, + + _showUserForm(user) { + const isEdit = !!user; + const title = isEdit ? 'Modifier : ' + user.username : 'Nouvel utilisateur'; + const vaultCheckboxes = this._allVaults.map(v => { + const checked = user && (user.vaults.includes(v) || user.vaults.includes('*')) ? 'checked' : ''; + return ''; + }).join(''); + const allVaultsChecked = user && user.vaults.includes('*') ? 'checked' : ''; + + // Create form modal overlay + const overlay = document.createElement('div'); + overlay.className = 'admin-form-overlay'; + overlay.innerHTML = ` +
+

${title}

+
+ ${!isEdit ? '
' : ''} +
+
+
+
+ +
${vaultCheckboxes}
+ +
+ ${isEdit ? '
' : ''} +
+ + +
+
+
+ `; + this._modal.appendChild(overlay); + + document.getElementById('admin-form-cancel').addEventListener('click', () => overlay.remove()); + + document.getElementById('admin-user-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const allVaults = document.getElementById('admin-all-vaults').checked; + const selectedVaults = allVaults + ? ['*'] + : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map(cb => cb.value); + + try { + if (isEdit) { + const updates = { + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }; + if (form.password.value) updates.password = form.password.value; + const activeCheckbox = form.querySelector('input[name="active"]'); + if (activeCheckbox) updates.active = activeCheckbox.checked; + await api('/api/auth/admin/users/' + user.username, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + } else { + await api('/api/auth/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: form.username.value, + password: form.password.value, + display_name: form.display_name.value || null, + role: form.role.value, + vaults: selectedVaults, + }), + }); + } + overlay.remove(); + this._loadUsers(); + showToast(isEdit ? 'Utilisateur modifié' : 'Utilisateur créé', 'success'); + } catch (err) { + showToast(err.message); + } + }); + }, + + async _deleteUser(username) { + const currentUser = AuthManager.getUser(); + if (currentUser && currentUser.username === username) { + showToast('Impossible de supprimer son propre compte'); + return; + } + if (!confirm('Supprimer l\'utilisateur "' + username + '" ?')) return; + try { + await api('/api/auth/admin/users/' + username, { method: 'DELETE' }); + this._loadUsers(); + showToast('Utilisateur supprimé', 'success'); + } catch (err) { + showToast(err.message); + } + }, + }; + // --------------------------------------------------------------------------- // Sidebar toggle (desktop) // --------------------------------------------------------------------------- @@ -1408,9 +1876,9 @@ // --------------------------------------------------------------------------- const TagFilterService = { defaultFilters: [ - { pattern: "#<% ... %>", regex: "^#<%.*%>$", enabled: true }, - { pattern: "#{{ ... }}", regex: "^#\\{\\{.*\\}\\}$", enabled: true }, - { pattern: "#{ ... }", regex: "^#\\{.*\\}$", enabled: true } + { pattern: "#<% ... %>", regex: "#<%.*%>", enabled: true }, + { pattern: "#{{ ... }}", regex: "#\\{\\{.*\\}\\}", enabled: true }, + { pattern: "#{ ... }", regex: "#\\{.*\\}", enabled: true } ], getConfig() { @@ -1430,20 +1898,35 @@ }, patternToRegex(pattern) { - let regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - regex = regex.replace(/\\\.\\\.\\\./g, '.*'); + // 1. Escape ALL special regex characters + // We use a broader set including * and . + let regex = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // 2. Convert escaped '*' to '.*' (wildcard) + regex = regex.replace(/\\\*/g, '.*'); + + // 3. Convert escaped '...' (or any sequence of 2+ dots like ..) to '.*' + // We also handle optional whitespace around it to make it more user-friendly + regex = regex.replace(/\s*\\\.{2,}\s*/g, '.*'); + return regex; }, isTagFiltered(tag) { const config = this.getConfig(); const filters = config.tagFilters || this.defaultFilters; + const tagWithHash = `#${tag}`; for (const filter of filters) { if (!filter.enabled) continue; try { - const regex = new RegExp(`^${filter.regex}$`); - if (regex.test(`#${tag}`)) { + // Robustly handle regex with or without ^/$ + let patternStr = filter.regex; + if (!patternStr.startsWith('^')) patternStr = '^' + patternStr; + if (!patternStr.endsWith('$')) patternStr = patternStr + '$'; + + const regex = new RegExp(patternStr); + if (regex.test(tagWithHash)) { return true; } } catch (e) { @@ -3460,12 +3943,18 @@ initSidebarResize(); initEditor(); initSyncStatus(); + initLoginForm(); - try { - await Promise.all([loadVaults(), loadTags()]); - } catch (err) { - console.error("Failed to initialize ObsiGate:", err); - showToast("Erreur lors de l'initialisation"); + // Check auth status first + const authOk = await AuthManager.initAuth(); + + if (authOk) { + try { + await Promise.all([loadVaults(), loadTags()]); + } catch (err) { + console.error("Failed to initialize ObsiGate:", err); + showToast("Erreur lors de l'initialisation"); + } } safeCreateIcons(); diff --git a/frontend/index.html b/frontend/index.html index 24f58e3..0a0c65b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -72,7 +72,46 @@
-
+ + + + + +
@@ -138,6 +177,8 @@ All
+ +