feat: Introduce a comprehensive authentication system, including user management, JWT handling, and initial frontend components with Docker support.
This commit is contained in:
parent
757b72c549
commit
190f47f134
@ -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
1
backend/auth/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# backend/auth — Authentication & access control module for ObsiGate
|
||||
133
backend/auth/jwt_handler.py
Normal file
133
backend/auth/jwt_handler.py
Normal 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
108
backend/auth/middleware.py
Normal 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
32
backend/auth/password.py
Normal 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
276
backend/auth/router.py
Normal 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
172
backend/auth/user_store.py
Normal 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
79
backend/create_admin.py
Normal 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()
|
||||
156
backend/main.py
156
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,
|
||||
|
||||
@ -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
20
data/users.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
515
frontend/app.js
515
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 =
|
||||
'<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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user