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

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