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
+