mirror of
https://github.com/R0m1k3/LiveFlow.git
synced 2026-10-11 17:27:19 +02:00
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:
9 files changed
+199
-7
No files matched your search
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user