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. # adresse. Indispensable pour accéder à l'app depuis un autre appareil.
LIVEFLOW_HOST=192.168.1.16 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"...). # Code langue ISO forcé pour la transcription ("fr", "en"...).
# Laisser vide pour la détection automatique de la langue. # Laisser vide pour la détection automatique de la langue.
ASR_LANGUAGE= 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 → > Première utilisation : le paquet ghcr.io doit être **public** (GitHub →
> page du dépôt → Packages → liveflow → Package settings → Change visibility). > 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 ## Configuration
Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) : Variables d'environnement (fichier `.env` à la racine, voir `.env.example`) :
+92 -3
View File
@@ -1,16 +1,21 @@
import asyncio import asyncio
import hashlib
import hmac
import io import io
import json import json
import os import os
import secrets
import time
import wave import wave
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
import aiosqlite import aiosqlite
import httpx import httpx
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, Response from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from segmenter import SAMPLE_RATE, Segment, SpeechSegmenter 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_API_KEY = os.environ.get("ASR_API_KEY", "sk-local")
ASR_LANGUAGE = os.environ.get("ASR_LANGUAGE", "").strip() 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 db: aiosqlite.Connection | None = None
http: httpx.AsyncClient | 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global db, http global db, http, session_secret
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
session_secret = load_session_secret()
db = await aiosqlite.connect(DB_PATH) db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
await db.executescript( await db.executescript(
@@ -57,6 +97,52 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="LiveFlow", lifespan=lifespan) 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: def pcm_to_wav(pcm: bytes) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
with wave.open(buf, "wb") as w: with wave.open(buf, "wb") as w:
@@ -98,6 +184,9 @@ async def transcribe(pcm: bytes) -> str:
@app.websocket("/ws") @app.websocket("/ws")
async def ws_transcribe(ws: WebSocket): async def ws_transcribe(ws: WebSocket):
if not session_valid(ws.cookies.get(SESSION_COOKIE, "")):
await ws.close(code=4401)
return
await ws.accept() await ws.accept()
# Premier message : {"type": "start", "title": "..."} # 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 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 // ----------------------------------------------------------- enregistrement
async function startRecording() { async function startRecording() {
@@ -43,7 +53,10 @@ async function startRecording() {
state.ws = new WebSocket(`${proto}://${location.host}/ws`); state.ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value })); state.ws.onopen = () => state.ws.send(JSON.stringify({ type: 'start', title: $('title').value }));
state.ws.onmessage = onServerMessage; 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(); state.audioContext = new AudioContext();
await state.audioContext.audioWorklet.addModule('worklet.js'); await state.audioContext.audioWorklet.addModule('worklet.js');
@@ -174,7 +187,7 @@ function showExportBar(meetingId) {
// ----------------------------------------------------------------- réunions // ----------------------------------------------------------------- réunions
async function loadMeetings() { async function loadMeetings() {
const meetings = await (await fetch('/api/meetings')).json(); const meetings = await (await api('/api/meetings')).json();
const ul = $('meeting-list'); const ul = $('meeting-list');
ul.innerHTML = ''; ul.innerHTML = '';
for (const m of meetings) { for (const m of meetings) {
@@ -190,7 +203,7 @@ async function loadMeetings() {
async function openMeeting(id) { async function openMeeting(id) {
if (state.recording) return; 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; state.currentMeetingId = id;
$('transcript-title').textContent = meeting.title; $('transcript-title').textContent = meeting.title;
clearTranscript(); clearTranscript();
@@ -206,7 +219,7 @@ async function openMeeting(id) {
async function deleteCurrentMeeting() { async function deleteCurrentMeeting() {
if (!state.currentMeetingId || state.recording) return; if (!state.currentMeetingId || state.recording) return;
if (!confirm('Supprimer définitivement cette réunion et sa transcription ?')) 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; state.currentMeetingId = null;
$('transcript-title').textContent = 'Transcription'; $('transcript-title').textContent = 'Transcription';
clearTranscript(); clearTranscript();
@@ -226,6 +239,7 @@ async function copyTranscript() {
// --------------------------------------------------------------------- init // --------------------------------------------------------------------- init
$('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording()); $('record-btn').onclick = () => (state.recording ? stopRecording() : startRecording());
$('logout-btn').onclick = async () => { await fetch('/api/logout', { method: 'POST' }); location.href = '/login'; };
$('copy-btn').onclick = copyTranscript; $('copy-btn').onclick = copyTranscript;
$('delete-btn').onclick = deleteCurrentMeeting; $('delete-btn').onclick = deleteCurrentMeeting;
loadMeetings(); loadMeetings();
+1
View File
@@ -11,6 +11,7 @@
<header> <header>
<h1>🎙️ LiveFlow</h1> <h1>🎙️ LiveFlow</h1>
<span id="status" class="badge idle">Prêt</span> <span id="status" class="badge idle">Prêt</span>
<button id="logout-btn" title="Se déconnecter">Déconnexion</button>
</header> </header>
<div class="layout"> <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.busy { background: #2a2410; color: #ffd166; }
.badge.error { background: #3a181a; color: #ff8589; } .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; } .layout { display: flex; flex: 1; min-height: 0; }
aside { aside {
+3
View File
@@ -23,6 +23,9 @@ services:
# "off" si un reverse proxy (Nginx Proxy Manager, SWAG...) gère déjà le # "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. # HTTPS : l'app sert alors du HTTP simple sur le port 8443.
- LIVEFLOW_TLS=off - 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_BASE_URL=http://asr:8000/v1
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B - ASR_MODEL=Qwen/Qwen3-ASR-1.7B
- ASR_API_KEY=sk-local - ASR_API_KEY=sk-local
+3
View File
@@ -7,6 +7,9 @@ services:
environment: environment:
# IP ou nom d'hôte du serveur, pour le certificat HTTPS auto-signé # IP ou nom d'hôte du serveur, pour le certificat HTTPS auto-signé
- LIVEFLOW_HOST=${LIVEFLOW_HOST:-localhost} - 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_BASE_URL=http://asr:8000/v1
- ASR_MODEL=Qwen/Qwen3-ASR-1.7B - ASR_MODEL=Qwen/Qwen3-ASR-1.7B
- ASR_API_KEY=${ASR_API_KEY:-sk-local} - ASR_API_KEY=${ASR_API_KEY:-sk-local}