diff --git a/.env.example b/.env.example index 7f1e295..ef37c47 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ # adresse. Indispensable pour accéder à l'app depuis un autre appareil. LIVEFLOW_HOST=192.168.1.16 +# Identifiants de connexion à l'interface (admin/admin par défaut). +LIVEFLOW_USER=admin +LIVEFLOW_PASSWORD=admin + # Code langue ISO forcé pour la transcription ("fr", "en"...). # Laisser vide pour la détection automatique de la langue. ASR_LANGUAGE= diff --git a/README.md b/README.md index 5d6480d..4c12eda 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,13 @@ docker compose -f docker-compose.unraid.yml up -d app > Première utilisation : le paquet ghcr.io doit être **public** (GitHub → > page du dépôt → Packages → liveflow → Package settings → Change visibility). +## Authentification + +L'interface est protégée par un identifiant/mot de passe (**admin / admin** +par défaut), définis par les variables `LIVEFLOW_USER` et `LIVEFLOW_PASSWORD` +du compose. La session dure 7 jours (cookie signé). **Changez le mot de passe +par défaut si l'application est accessible depuis internet.** + ## Configuration Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) : diff --git a/app/main.py b/app/main.py index 826a0c2..0c0a978 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,21 @@ import asyncio +import hashlib +import hmac import io import json import os +import secrets +import time import wave from contextlib import asynccontextmanager from datetime import datetime, timezone import aiosqlite import httpx -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse, Response +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel from segmenter import SAMPLE_RATE, Segment, SpeechSegmenter @@ -20,14 +25,49 @@ ASR_MODEL = os.environ.get("ASR_MODEL", "Qwen/Qwen3-ASR-1.7B") ASR_API_KEY = os.environ.get("ASR_API_KEY", "sk-local") ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip() +LIVEFLOW_USER = os.environ.get("LIVEFLOW_USER", "admin") +LIVEFLOW_PASSWORD = os.environ.get("LIVEFLOW_PASSWORD", "admin") +SESSION_TTL = 7 * 24 * 3600 # 7 jours +SESSION_COOKIE = "liveflow_session" + db: aiosqlite.Connection | None = None http: httpx.AsyncClient | None = None +session_secret: bytes = b"" + + +def load_session_secret() -> bytes: + """Secret HMAC persistant pour signer les cookies de session.""" + path = os.path.join(os.path.dirname(DB_PATH), "session-secret") + try: + with open(path, "rb") as f: + return f.read() + except FileNotFoundError: + secret = secrets.token_bytes(32) + with open(path, "wb") as f: + f.write(secret) + return secret + + +def make_session_token() -> str: + expiry = str(int(time.time()) + SESSION_TTL) + sig = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest() + return f"{expiry}.{sig}" + + +def session_valid(token: str) -> bool: + try: + expiry, sig = token.split(".", 1) + expected = hmac.new(session_secret, expiry.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(sig, expected) and time.time() < int(expiry) + except (ValueError, AttributeError): + return False @asynccontextmanager async def lifespan(app: FastAPI): - global db, http + global db, http, session_secret os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + session_secret = load_session_secret() db = await aiosqlite.connect(DB_PATH) db.row_factory = aiosqlite.Row await db.executescript( @@ -57,6 +97,52 @@ async def lifespan(app: FastAPI): app = FastAPI(title="LiveFlow", lifespan=lifespan) +# ----------------------------------------------------------- authentification + +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + authed = session_valid(request.cookies.get(SESSION_COOKIE, "")) + if path.startswith("/api") and path != "/api/login" and not authed: + return JSONResponse({"detail": "Non authentifié"}, status_code=401) + if path == "/" and not authed: + return RedirectResponse("/login") + if path == "/login" and authed: + return RedirectResponse("/") + return await call_next(request) + + +class LoginBody(BaseModel): + username: str + password: str + + +@app.get("/login") +async def login_page(): + return FileResponse("static/login.html") + + +@app.post("/api/login") +async def login(body: LoginBody): + user_ok = hmac.compare_digest(body.username.encode(), LIVEFLOW_USER.encode()) + pass_ok = hmac.compare_digest(body.password.encode(), LIVEFLOW_PASSWORD.encode()) + if not (user_ok and pass_ok): + raise HTTPException(401, "Identifiants invalides") + resp = JSONResponse({"ok": True}) + resp.set_cookie( + SESSION_COOKIE, make_session_token(), + max_age=SESSION_TTL, httponly=True, samesite="lax", + ) + return resp + + +@app.post("/api/logout") +async def logout(): + resp = JSONResponse({"ok": True}) + resp.delete_cookie(SESSION_COOKIE) + return resp + + def pcm_to_wav(pcm: bytes) -> bytes: buf = io.BytesIO() with wave.open(buf, "wb") as w: @@ -98,6 +184,9 @@ async def transcribe(pcm: bytes) -> str: @app.websocket("/ws") async def ws_transcribe(ws: WebSocket): + if not session_valid(ws.cookies.get(SESSION_COOKIE, "")): + await ws.close(code=4401) + return await ws.accept() # Premier message : {"type": "start", "title": "..."} diff --git a/app/static/app.js b/app/static/app.js index a49bac3..c40b95e 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -15,6 +15,16 @@ const state = { const BATCH_SAMPLES = 4096; // ~256 ms de PCM 16 kHz par message WebSocket +// fetch avec redirection vers la page de connexion si la session a expiré +async function api(url, opts) { + const resp = await fetch(url, opts); + if (resp.status === 401) { + location.href = '/login'; + throw new Error('session expirée'); + } + return resp; +} + // ----------------------------------------------------------- enregistrement async function startRecording() { @@ -43,7 +53,10 @@ async function startRecording() { state.ws = new WebSocket(`${proto}://${location.host}/ws`); state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value })); state.ws.onmessage = onServerMessage; - state.ws.onclose = () => { if (state.recording) stopRecording(true); }; + state.ws.onclose = (e) => { + if (e.code === 4401) { location.href = '/login'; return; } + if (state.recording) stopRecording(true); + }; state.audioContext = new AudioContext(); await state.audioContext.audioWorklet.addModule('worklet.js'); @@ -174,7 +187,7 @@ function showExportBar(meetingId) { // ----------------------------------------------------------------- réunions async function loadMeetings() { - const meetings = await (await fetch('/api/meetings')).json(); + const meetings = await (await api('/api/meetings')).json(); const ul = $('meeting-list'); ul.innerHTML = ''; for (const m of meetings) { @@ -190,7 +203,7 @@ async function loadMeetings() { async function openMeeting(id) { if (state.recording) return; - const meeting = await (await fetch(`/api/meetings/${id}`)).json(); + const meeting = await (await api(`/api/meetings/${id}`)).json(); state.currentMeetingId = id; $('transcript-title').textContent = meeting.title; clearTranscript(); @@ -206,7 +219,7 @@ async function openMeeting(id) { async function deleteCurrentMeeting() { if (!state.currentMeetingId || state.recording) return; if (!confirm('Supprimer définitivement cette réunion et sa transcription ?')) return; - await fetch(`/api/meetings/${state.currentMeetingId}`, { method: 'DELETE' }); + await api(`/api/meetings/${state.currentMeetingId}`, { method: 'DELETE' }); state.currentMeetingId = null; $('transcript-title').textContent = 'Transcription'; clearTranscript(); @@ -226,6 +239,7 @@ async function copyTranscript() { // --------------------------------------------------------------------- init $('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording()); +$('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; }; $('copy-btn').onclick = copyTranscript; $('delete-btn').onclick = deleteCurrentMeeting; loadMeetings(); diff --git a/app/static/index.html b/app/static/index.html index d4f8fd9..5f04228 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -11,6 +11,7 @@

🎙️ LiveFlow

Prêt +
diff --git a/app/static/login.html b/app/static/login.html new file mode 100644 index 0000000..03aa7cf --- /dev/null +++ b/app/static/login.html @@ -0,0 +1,63 @@ + + + + + + LiveFlow — Connexion + + + + + +
+

🎙️ LiveFlow

+ + + +
+
+ + + diff --git a/app/static/style.css b/app/static/style.css index ea03c74..ec4dcae 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -41,6 +41,14 @@ header h1 { font-size: 1.2rem; } .badge.busy { background: #2a2410; color: #ffd166; } .badge.error { background: #3a181a; color: #ff8589; } +#logout-btn { + margin-left: auto; + font-size: 0.8rem; + padding: 6px 12px; + color: var(--muted); +} +#logout-btn:hover { color: var(--text); } + .layout { display: flex; flex: 1; min-height: 0; } aside { diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 24df49e..238a098 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -23,6 +23,9 @@ services: # "off" si un reverse proxy (Nginx Proxy Manager, SWAG...) gère déjà le # HTTPS : l'app sert alors du HTTP simple sur le port 8443. - LIVEFLOW_TLS=off + # Identifiants de connexion à l'interface — À CHANGER si exposé sur internet + - LIVEFLOW_USER=admin + - LIVEFLOW_PASSWORD=admin - ASR_BASE_URL=http://asr:8000/v1 - ASR_MODEL=Qwen/Qwen3-ASR-1.7B - ASR_API_KEY=sk-local diff --git a/docker-compose.yml b/docker-compose.yml index 5c0607f..757fe26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,9 @@ services: environment: # IP ou nom d'hôte du serveur, pour le certificat HTTPS auto-signé - LIVEFLOW_HOST=${LIVEFLOW_HOST:-localhost} + # Identifiants de connexion à l'interface + - LIVEFLOW_USER=${LIVEFLOW_USER:-admin} + - LIVEFLOW_PASSWORD=${LIVEFLOW_PASSWORD:-admin} - ASR_BASE_URL=http://asr:8000/v1 - ASR_MODEL=Qwen/Qwen3-ASR-1.7B - ASR_API_KEY=${ASR_API_KEY:-sk-local}