Authentification : login/mot de passe (admin/admin par défaut)

- Page de connexion, session 7 jours par cookie HMAC signé (secret persisté)
- Protection de l'API (401) et du WebSocket (code 4401), / redirige vers /login
- Bouton de déconnexion, redirection automatique à l'expiration de session
- Identifiants configurables via LIVEFLOW_USER / LIVEFLOW_PASSWORD

https://claude.ai/code/session_01YHMp3EKzr4s6o8w1ygxuUe
This commit is contained in:
Claude committed 2026-06-12 12:43:39 +00:00
1 parent c9b4094049
commit b802a3c95f
9 files changed
+199 -7

No files matched your search

+4
View File
@@ -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=
+7
View File
@@ -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`) :
+92 -3
View File
@@ -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": "..."}
+18 -4
View File
@@ -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();
+1
View File
@@ -11,6 +11,7 @@
<header>
<h1>🎙️ LiveFlow</h1>
<span id="status" class="badge idle">Prêt</span>
<button id="logout-btn" title="Se déconnecter">Déconnexion</button>
</header>
<div class="layout">
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiveFlow — Connexion</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%EF%B8%8F%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
<style>
body { align-items: center; justify-content: center; }
.login-box {
background: var(--panel);
border: 1px solid #262b3a;
border-radius: 14px;
padding: 32px;
width: min(360px, 90vw);
display: flex;
flex-direction: column;
gap: 14px;
}
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 6px; }
.login-box input {
background: var(--panel-2);
border: 1px solid #2c3245;
color: var(--text);
padding: 11px 14px;
border-radius: 10px;
font-size: 0.95rem;
}
.login-box input:focus { outline: none; border-color: var(--accent); }
.login-box button { background: var(--accent); font-weight: 600; padding: 11px; }
#login-error { color: #ff8589; font-size: 0.85rem; text-align: center; min-height: 1.2em; }
</style>
</head>
<body>
<form class="login-box" id="login-form">
<h1>🎙️ LiveFlow</h1>
<input id="username" type="text" placeholder="Utilisateur" autocomplete="username" required autofocus>
<input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password" required>
<button type="submit">Se connecter</button>
<div id="login-error"></div>
</form>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
location.href = '/';
} else {
document.getElementById('login-error').textContent = 'Identifiants invalides';
document.getElementById('password').value = '';
}
});
</script>
</body>
</html>
+8
View File
@@ -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 {
+3
View File
@@ -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
+3
View File
@@ -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}