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

This commit is contained in:
Bruno Charest 2026-03-23 15:44:37 -04:00
parent 757b72c549
commit 190f47f134
15 changed files with 1874 additions and 43 deletions

View File

@ -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

1
backend/auth/__init__.py Normal file
View File

@ -0,0 +1 @@
# backend/auth — Authentication & access control module for ObsiGate

133
backend/auth/jwt_handler.py Normal file
View File

@ -0,0 +1,133 @@
# backend/auth/jwt_handler.py
# JWT token generation, validation, and revocation.
# Secret key auto-generated on first startup and persisted to data/secret.key.
# Revoked token JTIs persisted to data/revoked_tokens.json.
import json
import secrets
import uuid
import time
import logging
from pathlib import Path
from jose import jwt, JWTError
from typing import Optional
logger = logging.getLogger("obsigate.auth.jwt")
# Paths relative to working directory (Docker: /app)
SECRET_KEY_FILE = Path("data/secret.key")
REVOKED_TOKENS_FILE = Path("data/revoked_tokens.json")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_SECONDS = 3600 # 1 hour
REFRESH_TOKEN_EXPIRE_SECONDS = 604800 # 7 days
# In-memory revoked token set (loaded from disk on startup)
_revoked_jtis: set = set()
_revoked_loaded = False
def get_secret_key() -> str:
"""Read or generate the JWT secret key.
On first call, generates a 512-bit random key and writes it to
data/secret.key with 600 permissions. Subsequent calls read from disk.
"""
if not SECRET_KEY_FILE.exists():
SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_hex(64) # 512 bits
SECRET_KEY_FILE.write_text(key)
try:
SECRET_KEY_FILE.chmod(0o600)
except OSError:
pass # Windows doesn't support Unix permissions
logger.info("Generated new JWT secret key")
return key
return SECRET_KEY_FILE.read_text().strip()
def create_access_token(user: dict) -> str:
"""Create a JWT access token with user claims."""
now = int(time.time())
payload = {
"sub": user["username"],
"role": user["role"],
"vaults": user["vaults"],
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS,
"type": "access",
}
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
def create_refresh_token(username: str) -> tuple:
"""Create a JWT refresh token. Returns (token_string, jti)."""
now = int(time.time())
jti = str(uuid.uuid4())
payload = {
"sub": username,
"jti": jti,
"iat": now,
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS,
"type": "refresh",
}
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
def decode_token(token: str) -> Optional[dict]:
"""Decode and validate a JWT. Returns None if invalid/expired."""
try:
return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
except JWTError:
return None
# ---------------------------------------------------------------------------
# Token revocation
# ---------------------------------------------------------------------------
def _load_revoked():
"""Load revoked token JTIs from disk into memory (once)."""
global _revoked_loaded, _revoked_jtis
if _revoked_loaded:
return
if REVOKED_TOKENS_FILE.exists():
try:
data = json.loads(REVOKED_TOKENS_FILE.read_text())
# Clean expired entries (older than 7 days)
now = int(time.time())
_revoked_jtis = {
jti for jti, exp in data.items()
if exp > now
}
except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_jtis = set()
_revoked_loaded = True
def _save_revoked():
"""Persist revoked JTIs to disk."""
REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Store with expiry timestamp for cleanup
now = int(time.time())
# Keep entries for 7 days max
data = {jti: now + REFRESH_TOKEN_EXPIRE_SECONDS for jti in _revoked_jtis}
tmp = REVOKED_TOKENS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data))
tmp.replace(REVOKED_TOKENS_FILE)
def revoke_token(jti: str):
"""Add a token JTI to the revocation list."""
_load_revoked()
_revoked_jtis.add(jti)
_save_revoked()
logger.debug(f"Revoked token JTI: {jti[:8]}...")
def is_token_revoked(jti: str) -> bool:
"""Check if a token JTI has been revoked."""
_load_revoked()
return jti in _revoked_jtis

108
backend/auth/middleware.py Normal file
View File

@ -0,0 +1,108 @@
# backend/auth/middleware.py
# FastAPI dependencies for authentication and authorization.
# Reads JWT from Authorization header OR access_token cookie.
import os
import logging
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
from .jwt_handler import decode_token
from .user_store import get_user
logger = logging.getLogger("obsigate.auth.middleware")
security = HTTPBearer(auto_error=False)
def is_auth_enabled() -> bool:
"""Check if authentication is enabled via environment variable.
Default: True (auth enabled). Set OBSIGATE_AUTH_ENABLED=false to disable.
"""
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[dict]:
"""Extract and validate the current user from JWT.
Reads token from Authorization header first, falls back to access_token cookie.
Returns None if no valid token is found.
"""
# If auth is disabled, return a fake admin user with full access
if not is_auth_enabled():
return {
"username": "anonymous",
"display_name": "Anonymous",
"role": "admin",
"vaults": ["*"],
"active": True,
"_token_vaults": ["*"],
}
token = None
if credentials:
token = credentials.credentials
elif "access_token" in request.cookies:
token = request.cookies["access_token"]
if not token:
return None
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
raise HTTPException(
status_code=401,
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
def require_admin(current_user=Depends(require_auth)):
"""Dependency: require admin role."""
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès admin requis")
return current_user
def check_vault_access(vault_name: str, user: dict) -> bool:
"""Check if a user has access to a specific vault.
Rules:
- vaults == ["*"] full access (admin default)
- vault_name in vaults access granted
- otherwise denied
"""
vaults = user.get("_token_vaults") or user.get("vaults", [])
if "*" in vaults:
return True
return vault_name in vaults
def require_vault_access(vault_name: str, user: dict = Depends(require_auth)):
"""Dependency: require access to a specific vault."""
if not check_vault_access(vault_name, user):
raise HTTPException(
status_code=403,
detail=f"Accès refusé à la vault '{vault_name}'",
)
return user

32
backend/auth/password.py Normal file
View File

@ -0,0 +1,32 @@
# backend/auth/password.py
# Argon2id password hashing — OWASP 2024 recommended algorithm.
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
ph = PasswordHasher(
time_cost=2,
memory_cost=65536, # 64 MB
parallelism=2,
hash_len=32,
salt_len=16,
)
def hash_password(password: str) -> str:
"""Hash a password with Argon2id."""
return ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its Argon2id hash."""
try:
return ph.verify(hashed, password)
except (VerifyMismatchError, VerificationError):
return False
def needs_rehash(hashed: str) -> bool:
"""Check if hash needs updating (parameters changed)."""
return ph.check_needs_rehash(hashed)

276
backend/auth/router.py Normal file
View File

@ -0,0 +1,276 @@
# backend/auth/router.py
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import re
import logging
from fastapi import APIRouter, HTTPException, Response, Request, Depends
from pydantic import BaseModel, validator
from typing import List, Optional
from .user_store import (
get_user, get_all_users, create_user, update_user, delete_user,
record_login_success, record_login_failure, is_locked, has_users,
)
from .jwt_handler import (
create_access_token, create_refresh_token, decode_token,
revoke_token, is_token_revoked,
ACCESS_TOKEN_EXPIRE_SECONDS,
)
from .middleware import require_auth, require_admin, is_auth_enabled
from .password import verify_password, hash_password
logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
# ── Pydantic request models ──────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
remember_me: bool = False # True → refresh token 30d instead of 7d
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@validator("new_password")
def password_strength(cls, v):
if len(v) < 8:
raise ValueError("Minimum 8 caractères")
return v
class CreateUserRequest(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
vaults: List[str] = []
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class UpdateUserRequest(BaseModel):
display_name: Optional[str] = None
vaults: Optional[List[str]] = None
active: Optional[bool] = None
password: Optional[str] = None
role: Optional[str] = None
# ── Public endpoints ──────────────────────────────────────────────────
@router.get("/status")
async def auth_status():
"""Public endpoint: returns whether auth is enabled.
The frontend uses this to decide whether to show the login screen.
Also returns whether any users exist (for first-run detection).
"""
return {
"auth_enabled": is_auth_enabled(),
"has_users": has_users(),
}
@router.post("/login")
async def login(request: LoginRequest, response: Response):
"""Authenticate a user. Returns access token and sets refresh cookie.
Implements timing-safe responses to prevent user enumeration:
a failed login with an unknown user takes the same time as one
with a known user (dummy hash is computed).
"""
user = get_user(request.username)
if not user:
# Timing-safe: simulate hash computation to prevent user enumeration
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("active"):
raise HTTPException(403, "Compte désactivé")
if is_locked(request.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(request.password, user["password_hash"]):
attempts = record_login_failure(request.username)
remaining = max(0, 5 - attempts)
detail = "Identifiants invalides"
if 0 < remaining <= 2:
detail += f" ({remaining} tentative(s) restante(s))"
raise HTTPException(401, detail)
# Success — generate tokens
record_login_success(request.username)
access_token = create_access_token(user)
refresh_token, refresh_jti = create_refresh_token(request.username)
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
max_age = 2592000 if request.remember_me else 604800 # 30d or 7d
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
response.set_cookie(
key="refresh_token",
value=refresh_token,
max_age=max_age,
httponly=True,
samesite="strict",
secure=secure,
path="/api/auth/refresh",
)
logger.info(f"User '{request.username}' logged in")
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
"user": {
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
"vaults": user["vaults"],
},
}
@router.post("/refresh")
async def refresh_token_endpoint(request: Request, response: Response):
"""Renew access token via refresh token cookie.
Called automatically by the frontend when the access token expires.
"""
refresh_tok = request.cookies.get("refresh_token")
if not refresh_tok:
raise HTTPException(401, "Refresh token manquant")
payload = decode_token(refresh_tok)
if not payload or payload.get("type") != "refresh":
raise HTTPException(401, "Refresh token invalide")
if is_token_revoked(payload["jti"]):
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
user = get_user(payload["sub"])
if not user or not user.get("active"):
raise HTTPException(401, "Utilisateur introuvable ou inactif")
new_access_token = create_access_token(user)
return {
"access_token": new_access_token,
"token_type": "bearer",
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
current_user=Depends(require_auth),
):
"""Logout: revoke refresh token and delete cookie."""
refresh_tok = request.cookies.get("refresh_token")
if refresh_tok:
payload = decode_token(refresh_tok)
if payload:
revoke_token(payload["jti"])
response.delete_cookie("refresh_token", path="/api/auth/refresh")
logger.info(f"User '{current_user['username']}' logged out")
return {"message": "Déconnecté avec succès"}
@router.get("/me")
async def get_me(current_user=Depends(require_auth)):
"""Return current authenticated user info."""
return {
"username": current_user["username"],
"display_name": current_user["display_name"],
"role": current_user["role"],
"vaults": current_user["vaults"],
"last_login": current_user.get("last_login"),
}
@router.post("/change-password")
async def change_password(
req: ChangePasswordRequest,
current_user=Depends(require_auth),
):
"""Change own password."""
user = get_user(current_user["username"])
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
update_user(current_user["username"], {"password": req.new_password})
return {"message": "Mot de passe mis à jour"}
# ── Admin endpoints ───────────────────────────────────────────────────
@router.get("/admin/users")
async def list_users(admin=Depends(require_admin)):
"""List all users (admin only). Password hashes are never included."""
return get_all_users()
@router.post("/admin/users")
async def create_user_endpoint(
req: CreateUserRequest,
admin=Depends(require_admin),
):
"""Create a new user (admin only)."""
try:
user = create_user(
req.username, req.password, req.role, req.vaults, req.display_name
)
return user
except ValueError as e:
raise HTTPException(400, str(e))
@router.patch("/admin/users/{username}")
async def update_user_endpoint(
username: str,
req: UpdateUserRequest,
admin=Depends(require_admin),
):
"""Update a user (admin only)."""
updates = req.dict(exclude_none=True)
try:
return update_user(username, updates)
except ValueError as e:
raise HTTPException(404, str(e))
@router.delete("/admin/users/{username}")
async def delete_user_endpoint(
username: str,
admin=Depends(require_admin),
):
"""Delete a user (admin only). Cannot delete own account."""
if username == admin["username"]:
raise HTTPException(400, "Impossible de supprimer son propre compte")
try:
delete_user(username)
return {"message": f"Utilisateur '{username}' supprimé"}
except ValueError as e:
raise HTTPException(404, str(e))

172
backend/auth/user_store.py Normal file
View File

@ -0,0 +1,172 @@
# backend/auth/user_store.py
# CRUD operations on data/users.json with atomic writes.
# File is read on each auth request (no stale cache), written atomically
# via tmp+rename to prevent corruption on crash.
import json
import uuid
import logging
import shutil
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from .password import hash_password
logger = logging.getLogger("obsigate.auth.users")
USERS_FILE = Path("data/users.json")
def _read() -> dict:
"""Read users.json. Returns empty structure if file doesn't exist."""
if not USERS_FILE.exists():
return {"version": 1, "users": {}}
try:
return json.loads(USERS_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.error(f"Failed to read users.json: {e}")
return {"version": 1, "users": {}}
def _write(data: dict):
"""Atomic write: write to .tmp then rename to prevent corruption."""
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = USERS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
shutil.move(str(tmp), str(USERS_FILE))
try:
USERS_FILE.chmod(0o600)
except OSError:
pass # Windows doesn't support Unix permissions
def has_users() -> bool:
"""Check if any users exist."""
return bool(_read()["users"])
def get_user(username: str) -> Optional[dict]:
"""Get a user by username. Returns None if not found."""
return _read()["users"].get(username)
def get_all_users() -> List[dict]:
"""Get all users WITHOUT password_hash (safe for API responses)."""
users = _read()["users"]
return [
{k: v for k, v in u.items() if k != "password_hash"}
for u in users.values()
]
def create_user(
username: str,
password: str,
role: str = "user",
vaults: Optional[List[str]] = None,
display_name: Optional[str] = None,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
data = _read()
if username in data["users"]:
raise ValueError(f"User '{username}' already exists")
user = {
"id": str(uuid.uuid4()),
"username": username,
"display_name": display_name or username,
"password_hash": hash_password(password),
"role": role,
"vaults": vaults or [],
"active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
}
data["users"][username] = user
_write(data)
logger.info(f"Created user '{username}' (role={role})")
return {k: v for k, v in user.items() if k != "password_hash"}
def update_user(username: str, updates: dict) -> dict:
"""Update user fields. Raises ValueError if user not found.
Forbidden fields (id, username, created_at) are silently ignored.
If 'password' is in updates, it's hashed and stored as password_hash.
"""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
forbidden = {"id", "username", "created_at"}
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
data["users"][username].update(safe_updates)
_write(data)
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
def delete_user(username: str):
"""Delete a user. Raises ValueError if not found."""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
del data["users"][username]
_write(data)
logger.info(f"Deleted user '{username}'")
def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts."""
update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(),
"failed_attempts": 0,
"locked_until": None,
})
def record_login_failure(username: str) -> int:
"""Record a failed login. Returns the new attempt count.
After 5 failures, locks the account for 15 minutes.
"""
data = _read()
user = data["users"].get(username)
if not user:
return 0
attempts = user.get("failed_attempts", 0) + 1
updates = {"failed_attempts": attempts}
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
update_user(username, updates)
return attempts
def is_locked(username: str) -> bool:
"""Check if an account is temporarily locked.
Auto-unlocks if the lock period has expired.
"""
user = get_user(username)
if not user or not user.get("locked_until"):
return False
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until:
# Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0})
return False
return True

79
backend/create_admin.py Normal file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""ObsiGate CLI user management script.
Usage:
python backend/create_admin.py create <username> <password> [--role admin|user] [--vaults V1 V2...] [--display-name NAME]
python backend/create_admin.py list
python backend/create_admin.py delete <username>
Docker usage:
docker exec obsigate python backend/create_admin.py create admin MyPassword --role admin --vaults "*"
docker exec obsigate python backend/create_admin.py list
"""
import sys
import argparse
# Add parent directory to path for imports
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backend.auth.user_store import create_user, get_all_users, delete_user
def main():
parser = argparse.ArgumentParser(description="ObsiGate user management")
subparsers = parser.add_subparsers(dest="command")
# create
create_p = subparsers.add_parser("create", help="Create a user")
create_p.add_argument("username")
create_p.add_argument("password")
create_p.add_argument("--role", default="user", choices=["admin", "user"])
create_p.add_argument("--vaults", nargs="+", default=[])
create_p.add_argument("--display-name")
# list
subparsers.add_parser("list", help="List all users")
# delete
del_p = subparsers.add_parser("delete", help="Delete a user")
del_p.add_argument("username")
args = parser.parse_args()
if args.command == "create":
try:
user = create_user(
args.username, args.password, args.role,
args.vaults, args.display_name,
)
print(f"✅ User '{user['username']}' created (role: {user['role']})")
except ValueError as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
elif args.command == "list":
users = get_all_users()
if not users:
print("No users found.")
return
for u in users:
vaults = ", ".join(u["vaults"]) or "none"
status = "" if u["active"] else "🔴"
print(f"{status} {u['username']} ({u['role']}) — Vaults: {vaults}")
elif args.command == "delete":
try:
delete_user(args.username)
print(f"✅ User '{args.username}' deleted")
except ValueError as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

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

View File

@ -5,3 +5,5 @@ mistune==3.0.2
python-multipart==0.0.9
aiofiles==23.2.1
watchdog>=4.0.0
argon2-cffi>=23.1.0
python-jose[cryptography]>=3.3.0

20
data/users.json Normal file
View File

@ -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
}
}
}

View File

@ -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

View File

@ -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 =
'<span class="user-display-name">' + (user.display_name || user.username) + '</span>' +
(user.role === 'admin'
? '<button class="user-menu-link" id="admin-btn" title="Administration"><i data-lucide="shield" style="width:14px;height:14px"></i></button>'
: '') +
'<button class="btn-logout" id="logout-btn" title="Déconnexion"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
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 = `
<div class="editor-container">
<div class="editor-header">
<div class="editor-title"> Administration Utilisateurs</div>
<div class="editor-actions">
<button class="editor-btn" id="admin-close" title="Fermer">
<i data-lucide="x" style="width:16px;height:16px"></i>
</button>
</div>
</div>
<div class="editor-body" id="admin-body">
<div class="admin-toolbar">
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">+ Nouvel utilisateur</button>
</div>
<div id="admin-users-list" class="admin-users-list"></div>
</div>
</div>
`;
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 =
'<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + '</p>';
}
},
_renderUsers(users) {
const container = document.getElementById('admin-users-list');
if (!users.length) {
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
return;
}
let html = '<table class="admin-table"><thead><tr>' +
'<th>Utilisateur</th><th>Rôle</th><th>Vaults</th><th>Statut</th><th>Dernière connexion</th><th>Actions</th>' +
'</tr></thead><tbody>';
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 += '<tr>' +
'<td><strong>' + u.username + '</strong>' + (u.display_name && u.display_name !== u.username ? '<br><small>' + u.display_name + '</small>' : '') + '</td>' +
'<td><span class="admin-role-badge admin-role-' + u.role + '">' + u.role + '</span></td>' +
'<td><span class="admin-vaults-text">' + vaults + '</span></td>' +
'<td>' + status + '</td>' +
'<td><small>' + lastLogin + '</small></td>' +
'<td class="admin-actions">' +
'<button class="admin-action-btn" data-action="edit" data-username="' + u.username + '" title="Modifier">✏️</button>' +
'<button class="admin-action-btn danger" data-action="delete" data-username="' + u.username + '" title="Supprimer">🗑️</button>' +
'</td></tr>';
});
html += '</tbody></table>';
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 '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + '><span>' + v + '</span></label>';
}).join('');
const allVaultsChecked = user && user.vaults.includes('*') ? 'checked' : '';
// Create form modal overlay
const overlay = document.createElement('div');
overlay.className = 'admin-form-overlay';
overlay.innerHTML = `
<div class="admin-form-card">
<h3>${title}</h3>
<form id="admin-user-form">
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{2,32}" placeholder="username"></div>' : ''}
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? (user.display_name || '') : ''}"></div>
<div class="form-group"><label>${isEdit ? 'Nouveau mot de passe (vide = inchangé)' : 'Mot de passe'}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ''} placeholder="${isEdit ? 'Laisser vide pour ne pas changer' : 'Min. 8 caractères'}"></div>
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === 'user' ? 'selected' : ''}>Utilisateur</option><option value="admin" ${isEdit && user.role === 'admin' ? 'selected' : ''}>Admin</option></select></div>
<div class="form-group">
<label>Vaults autorisées</label>
<div class="admin-vault-list">${vaultCheckboxes}</div>
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
</div>
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? 'checked' : '') + '><span>Actif</span></label></div>' : ''}
<div class="admin-form-actions">
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
</div>
</form>
</div>
`;
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();

View File

@ -72,7 +72,46 @@
</head>
<body>
<div class="search-progress-bar" id="search-progress-bar"><div class="search-progress-bar__fill"></div></div>
<div class="app-container">
<!-- Login Screen (hidden by default, shown when auth is required) -->
<div id="login-screen" class="login-screen hidden">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<span class="login-logo-icon">📖</span>
<span class="login-logo-text">ObsiGate</span>
</div>
<p class="login-subtitle">Accès sécurisé à vos vaults</p>
</div>
<form id="login-form" class="login-form" autocomplete="on">
<div class="form-group">
<label for="login-username">Nom d'utilisateur</label>
<input type="text" id="login-username" name="username" autocomplete="username" placeholder="utilisateur" required autofocus />
</div>
<div class="form-group">
<label for="login-password">Mot de passe</label>
<div class="password-input-wrapper">
<input type="password" id="login-password" name="password" autocomplete="current-password" placeholder="••••••••" required />
<button type="button" id="toggle-password" class="password-toggle" aria-label="Afficher le mot de passe">👁</button>
</div>
</div>
<div class="form-group form-group--inline">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="remember_me" />
<span>Se souvenir de moi (30 jours)</span>
</label>
</div>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-btn" class="btn-login">
<span class="btn-text">Se connecter</span>
<span class="btn-spinner hidden"></span>
</button>
</form>
</div>
</div>
<!-- Main Application (wrapped for show/hide) -->
<div id="app" class="app-container">
<!-- Header -->
<header class="header">
@ -138,6 +177,8 @@
<i data-lucide="database" style="width:14px;height:14px"></i>
<span id="vault-context-text">All</span>
</div>
<!-- User menu (visible when logged in) -->
<div class="user-menu" id="user-menu"></div>
<div class="header-menu">
<button class="header-menu-btn" id="header-menu-btn" title="Options">
<i data-lucide="settings" style="width:18px;height:18px"></i>

View File

@ -2958,3 +2958,374 @@ body.resizing-v {
transform: translateX(18px);
background: #22c55e;
}
/* ---------------------------------------------------------------------------
Utility hidden class
--------------------------------------------------------------------------- */
.hidden { display: none !important; }
/* ---------------------------------------------------------------------------
Login Screen
--------------------------------------------------------------------------- */
.login-screen {
position: fixed;
inset: 0;
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0c29 0%, #1a1a3e 50%, #24243e 100%);
}
.login-card {
width: 380px;
max-width: 90vw;
background: rgba(30, 30, 46, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 40px 36px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
animation: loginFadeIn 0.4s ease-out;
}
@keyframes loginFadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.login-logo-icon {
font-size: 2rem;
}
.login-logo-text {
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #a78bfa, #818cf8, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
}
.login-subtitle {
font-size: 0.88rem;
color: rgba(255,255,255,0.45);
margin: 0;
}
.login-form .form-group {
margin-bottom: 20px;
}
.login-form .form-group label {
display: block;
font-size: 0.82rem;
font-weight: 500;
color: rgba(255,255,255,0.6);
margin-bottom: 6px;
}
.login-form input[type="text"],
.login-form input[type="password"] {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
background: rgba(255,255,255,0.06);
color: #e2e8f0;
font-size: 0.92rem;
font-family: inherit;
outline: none;
transition: border-color 200ms, box-shadow 200ms;
box-sizing: border-box;
}
.login-form input:focus {
border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(129,140,248,0.2);
}
.login-form input::placeholder {
color: rgba(255,255,255,0.25);
}
.password-input-wrapper {
position: relative;
}
.password-toggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
opacity: 0.5;
transition: opacity 150ms;
padding: 4px;
}
.password-toggle:hover { opacity: 0.9; }
.form-group--inline {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.82rem;
color: rgba(255,255,255,0.5);
}
.checkbox-label input[type="checkbox"] {
accent-color: #818cf8;
width: 15px;
height: 15px;
}
.login-error {
padding: 10px 14px;
border-radius: 8px;
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.3);
color: #fca5a5;
font-size: 0.82rem;
margin-bottom: 16px;
}
.btn-login {
width: 100%;
padding: 13px 24px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #7c3aed, #6366f1);
color: #fff;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 200ms, transform 100ms;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-login:hover:not(:disabled) {
opacity: 0.92;
}
.btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.btn-login:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-spinner { font-size: 1rem; }
/* Light theme login */
[data-theme="light"] .login-screen {
background: linear-gradient(135deg, #e0e7ff, #f0f5ff, #dbeafe);
}
[data-theme="light"] .login-card {
background: rgba(255,255,255,0.9);
border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 20px 60px rgba(0,0,0,0.12);
}
[data-theme="light"] .login-subtitle { color: rgba(0,0,0,0.45); }
[data-theme="light"] .login-form .form-group label { color: rgba(0,0,0,0.6); }
[data-theme="light"] .login-form input[type="text"],
[data-theme="light"] .login-form input[type="password"] {
background: rgba(0,0,0,0.04);
border-color: rgba(0,0,0,0.12);
color: #1e293b;
}
[data-theme="light"] .login-form input::placeholder { color: rgba(0,0,0,0.3); }
[data-theme="light"] .checkbox-label { color: rgba(0,0,0,0.5); }
[data-theme="light"] .login-error {
background: rgba(239,68,68,0.08);
color: #dc2626;
}
/* ---------------------------------------------------------------------------
User Menu (header)
--------------------------------------------------------------------------- */
.user-menu {
display: flex;
align-items: center;
gap: 8px;
}
.user-display-name {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu-link,
.btn-logout {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 150ms, background 150ms;
}
.user-menu-link:hover,
.btn-logout:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.btn-logout:hover { color: var(--danger); }
/* ---------------------------------------------------------------------------
Admin Panel
--------------------------------------------------------------------------- */
.admin-toolbar {
padding: 16px;
display: flex;
justify-content: flex-end;
}
.admin-users-list {
padding: 0 16px 16px;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.admin-table th,
.admin-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.admin-table th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
background: var(--bg-primary);
position: sticky;
top: 0;
}
.admin-table td { color: var(--text-primary); }
.admin-role-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-role-admin {
background: rgba(129,140,248,0.15);
color: #818cf8;
}
.admin-role-user {
background: rgba(34,197,94,0.12);
color: #22c55e;
}
.admin-vaults-text {
font-size: 0.78rem;
color: var(--text-secondary);
}
.admin-actions {
white-space: nowrap;
}
.admin-action-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
font-size: 0.9rem;
transition: background 150ms;
}
.admin-action-btn:hover { background: var(--bg-hover); }
.admin-action-btn.danger:hover { background: rgba(239,68,68,0.12); }
/* Admin form overlay */
.admin-form-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
backdrop-filter: blur(4px);
}
.admin-form-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 24px;
width: 440px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
}
.admin-form-card h3 {
margin: 0 0 20px;
font-size: 1.05rem;
color: var(--text-primary);
}
.admin-form-card .form-group {
margin-bottom: 16px;
}
.admin-form-card .form-group label {
display: block;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.admin-form-card input[type="text"],
.admin-form-card input[type="password"],
.admin-form-card select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.88rem;
font-family: inherit;
outline: none;
transition: border-color 200ms;
box-sizing: border-box;
}
.admin-form-card input:focus,
.admin-form-card select:focus {
border-color: var(--accent);
}
.admin-vault-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 160px;
overflow-y: auto;
padding: 8px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
}
.admin-vault-list .checkbox-label {
color: var(--text-primary);
font-size: 0.82rem;
}
.admin-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}