Feat : add Webauth
All checks were successful
Build and Push Docker Images / docker (push) Successful in 1m22s

This commit is contained in:
jeanotx32
2026-06-02 19:46:58 -04:00
parent 6616e6525d
commit 57132f92ee
8 changed files with 888 additions and 58 deletions

View File

@@ -5,6 +5,7 @@ Agrège les données de tous les agents et expose une API REST pour le frontend.
""" """
import asyncio import asyncio
import base64
import json import json
import os import os
import secrets import secrets
@@ -24,6 +25,25 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from pydantic import BaseModel from pydantic import BaseModel
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
AuthenticatorAttachment,
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
ResidentKeyRequirement,
PublicKeyCredentialDescriptor,
RegistrationCredential as WebAuthnRegistrationCredential,
AuthenticatorAttestationResponse,
AuthenticationCredential as WebAuthnAuthenticationCredential,
AuthenticatorAssertionResponse,
)
# ─── Config ─────────────────────────────────────────────────────────────────── # ─── Config ───────────────────────────────────────────────────────────────────
DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db")) DB_FILE = Path(os.getenv("DB_FILE", "data/monitor.db"))
@@ -53,6 +73,14 @@ def _load_jwt_secret() -> str:
JWT_SECRET = _load_jwt_secret() JWT_SECRET = _load_jwt_secret()
# ─── WebAuthn / Passkeys config ───────────────────────────────────────────────
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "VPS Monitor")
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "http://localhost:3020")
# In-memory challenge store: session_id → {challenge, username, expires_at}
_webauthn_challenges: dict[str, dict] = {}
bearer_scheme = HTTPBearer() bearer_scheme = HTTPBearer()
# ─── Modèles ────────────────────────────────────────────────────────────────── # ─── Modèles ──────────────────────────────────────────────────────────────────
@@ -110,6 +138,21 @@ class PurgeRequest(BaseModel):
to_ts: int | None = None # epoch seconds, utilisé si period == 'custom' to_ts: int | None = None # epoch seconds, utilisé si period == 'custom'
class PasskeyRegisterFinishRequest(BaseModel):
session_id: str
name: str = "Ma passkey"
credential: dict
class PasskeyLoginBeginRequest(BaseModel):
username: str | None = None
class PasskeyLoginFinishRequest(BaseModel):
session_id: str
credential: dict
# ─── SQLite ─────────────────────────────────────────────────────────────────── # ─── SQLite ───────────────────────────────────────────────────────────────────
@contextmanager @contextmanager
@@ -194,6 +237,20 @@ def init_db() -> None:
conn.execute(""" conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false') INSERT OR IGNORE INTO settings (key, value) VALUES ('registration_open', 'false')
""") """)
conn.execute("""
INSERT OR IGNORE INTO settings (key, value) VALUES ('passkey_enabled', 'true')
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
""")
init_db() init_db()
@@ -258,6 +315,98 @@ def _get_setting(key: str) -> str:
return row["value"] if row else "" return row["value"] if row else ""
# ─── WebAuthn / Passkey helpers ───────────────────────────────────────────────
def _b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def _b64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s)
def _store_challenge(challenge: bytes, username: str | None = None) -> str:
"""Stocke un challenge WebAuthn en mémoire et retourne un session_id."""
session_id = secrets.token_hex(16)
_webauthn_challenges[session_id] = {
"challenge": challenge,
"username": username,
"expires_at": time.time() + 300,
}
# Nettoyage des challenges expirés
now = time.time()
expired = [k for k, v in list(_webauthn_challenges.items()) if v["expires_at"] < now]
for k in expired:
_webauthn_challenges.pop(k, None)
return session_id
def _pop_challenge(session_id: str) -> dict | None:
entry = _webauthn_challenges.pop(session_id, None)
if entry is None:
return None
if time.time() > entry["expires_at"]:
return None
return entry
def _get_passkeys_for_user(username: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM passkeys WHERE username = ? ORDER BY created_at DESC",
(username,),
).fetchall()
return [dict(r) for r in rows]
def _get_passkey_by_credential_id(credential_id: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM passkeys WHERE credential_id = ?", (credential_id,)
).fetchone()
return dict(row) if row else None
def _update_passkey_sign_count(credential_id: str, sign_count: int) -> None:
with get_db() as conn:
conn.execute(
"UPDATE passkeys SET sign_count = ? WHERE credential_id = ?",
(sign_count, credential_id),
)
def _parse_registration_credential(data: dict) -> WebAuthnRegistrationCredential:
resp = data["response"]
return WebAuthnRegistrationCredential(
id=data["id"],
raw_id=_b64url_decode(data.get("rawId", data["id"])),
response=AuthenticatorAttestationResponse(
client_data_json=_b64url_decode(resp["clientDataJSON"]),
attestation_object=_b64url_decode(resp["attestationObject"]),
),
type=data["type"],
)
def _parse_authentication_credential(data: dict) -> WebAuthnAuthenticationCredential:
resp = data["response"]
user_handle = resp.get("userHandle")
return WebAuthnAuthenticationCredential(
id=data["id"],
raw_id=_b64url_decode(data.get("rawId", data["id"])),
response=AuthenticatorAssertionResponse(
client_data_json=_b64url_decode(resp["clientDataJSON"]),
authenticator_data=_b64url_decode(resp["authenticatorData"]),
signature=_b64url_decode(resp["signature"]),
user_handle=_b64url_decode(user_handle) if user_handle else None,
),
type=data["type"],
)
# ─── Auth helpers ───────────────────────────────────────────────────────────── # ─── Auth helpers ─────────────────────────────────────────────────────────────
def create_token(username: str, role: str) -> str: def create_token(username: str, role: str) -> str:
@@ -477,7 +626,10 @@ async def _cleanup_old_stats() -> None:
@app.get("/api/auth/status") @app.get("/api/auth/status")
def auth_status(): def auth_status():
"""Indique si des utilisateurs existent déjà (pour le frontend).""" """Indique si des utilisateurs existent déjà (pour le frontend)."""
return {"has_users": len(load_users()) > 0} return {
"has_users": len(load_users()) > 0,
"passkey_enabled": _get_setting("passkey_enabled") == "true",
}
@app.post("/api/auth/register", status_code=201) @app.post("/api/auth/register", status_code=201)
@@ -561,7 +713,7 @@ def admin_update_setting(
_: Annotated[dict, Depends(require_admin)], _: Annotated[dict, Depends(require_admin)],
): ):
"""Met à jour un paramètre d'administration.""" """Met à jour un paramètre d'administration."""
allowed_keys = {"registration_open"} allowed_keys = {"registration_open", "passkey_enabled"}
if key not in allowed_keys: if key not in allowed_keys:
raise HTTPException(status_code=400, detail="Clé de paramètre inconnue") raise HTTPException(status_code=400, detail="Clé de paramètre inconnue")
with get_db() as conn: with get_db() as conn:
@@ -680,6 +832,214 @@ def admin_db_purge(body: PurgeRequest, _: Annotated[dict, Depends(require_admin)
return {"deleted": deleted, "status": "ok"} return {"deleted": deleted, "status": "ok"}
# ─── Routes Passkeys ──────────────────────────────────────────────────────────
@app.post("/api/auth/passkey/register/begin")
def passkey_register_begin(
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Démarre l'enregistrement d'une passkey pour l'utilisateur connecté."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
username = current_user["username"]
existing = _get_passkeys_for_user(username)
exclude_creds = [
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
for pk in existing
]
opts = generate_registration_options(
rp_id=WEBAUTHN_RP_ID,
rp_name=WEBAUTHN_RP_NAME,
user_id=username.encode(),
user_name=username,
user_display_name=username,
authenticator_selection=AuthenticatorSelectionCriteria(
authenticator_attachment=AuthenticatorAttachment.PLATFORM,
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
exclude_credentials=exclude_creds,
)
session_id = _store_challenge(opts.challenge, username=username)
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
@app.post("/api/auth/passkey/register/finish")
def passkey_register_finish(
body: PasskeyRegisterFinishRequest,
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Finalise l'enregistrement d'une passkey."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
challenge_data = _pop_challenge(body.session_id)
if not challenge_data:
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
if challenge_data["username"] != current_user["username"]:
raise HTTPException(status_code=403, detail="Session invalide")
try:
cred = _parse_registration_credential(body.credential)
verification = verify_registration_response(
credential=cred,
expected_challenge=challenge_data["challenge"],
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Vérification échouée : {exc}")
cred_id = _b64url_encode(verification.credential_id)
pub_key = _b64url_encode(verification.credential_public_key)
if _get_passkey_by_credential_id(cred_id):
raise HTTPException(status_code=409, detail="Cette passkey est déjà enregistrée")
with get_db() as conn:
conn.execute(
"""INSERT INTO passkeys (username, credential_id, public_key, sign_count, name, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(current_user["username"], cred_id, pub_key,
verification.sign_count, body.name, int(time.time())),
)
return {"status": "ok"}
@app.post("/api/auth/passkey/login/begin")
def passkey_login_begin(body: PasskeyLoginBeginRequest):
"""Démarre l'authentification par passkey."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
allow_creds: list[PublicKeyCredentialDescriptor] = []
if body.username:
passkeys = _get_passkeys_for_user(body.username)
allow_creds = [
PublicKeyCredentialDescriptor(id=_b64url_decode(pk["credential_id"]))
for pk in passkeys
]
opts = generate_authentication_options(
rp_id=WEBAUTHN_RP_ID,
allow_credentials=allow_creds,
user_verification=UserVerificationRequirement.REQUIRED,
)
session_id = _store_challenge(opts.challenge, username=body.username)
return {"session_id": session_id, "options": json.loads(options_to_json(opts))}
@app.post("/api/auth/passkey/login/finish")
def passkey_login_finish(body: PasskeyLoginFinishRequest, request: Request):
"""Vérifie la passkey et retourne un JWT."""
if _get_setting("passkey_enabled") != "true":
raise HTTPException(status_code=403, detail="Les passkeys sont désactivées")
challenge_data = _pop_challenge(body.session_id)
if not challenge_data:
raise HTTPException(status_code=400, detail="Session expirée ou invalide")
cred_id = body.credential.get("id", "")
passkey = _get_passkey_by_credential_id(cred_id)
if not passkey:
ip = _get_client_ip(request)
_log_login("unknown", ip, False, "Passkey introuvable")
raise HTTPException(status_code=401, detail="Passkey non reconnue")
try:
cred = _parse_authentication_credential(body.credential)
verification = verify_authentication_response(
credential=cred,
expected_challenge=challenge_data["challenge"],
expected_rp_id=WEBAUTHN_RP_ID,
expected_origin=WEBAUTHN_ORIGIN,
credential_public_key=_b64url_decode(passkey["public_key"]),
credential_current_sign_count=passkey["sign_count"],
)
except Exception as exc:
ip = _get_client_ip(request)
_log_login(passkey["username"], ip, False, f"Passkey invalide : {exc}")
raise HTTPException(status_code=401, detail="Authentification échouée")
_update_passkey_sign_count(cred_id, verification.new_sign_count)
user = next((u for u in load_users() if u["username"] == passkey["username"]), None)
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
ip = _get_client_ip(request)
_log_login(user["username"], ip, True, "passkey")
token = create_token(user["username"], user["role"])
return {"access_token": token, "token_type": "bearer", "role": user["role"]}
@app.get("/api/auth/passkeys")
def list_my_passkeys(current_user: Annotated[dict, Depends(get_current_user)]):
"""Liste les passkeys de l'utilisateur connecté."""
passkeys = _get_passkeys_for_user(current_user["username"])
return [
{
"credential_id": pk["credential_id"],
"name": pk["name"],
"created_at": datetime.fromtimestamp(pk["created_at"], tz=timezone.utc).isoformat(),
}
for pk in passkeys
]
@app.delete("/api/auth/passkeys/{credential_id}")
def delete_my_passkey(
credential_id: str,
current_user: Annotated[dict, Depends(get_current_user)],
):
"""Supprime une passkey de l'utilisateur connecté."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM passkeys WHERE credential_id = ? AND username = ?",
(credential_id, current_user["username"]),
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Passkey introuvable")
return {"status": "ok"}
@app.get("/api/admin/passkeys")
def admin_list_passkeys(_: Annotated[dict, Depends(require_admin)]):
"""Admin : liste toutes les passkeys enregistrées."""
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM passkeys ORDER BY created_at DESC"
).fetchall()
return [
{
"credential_id": row["credential_id"],
"username": row["username"],
"name": row["name"],
"created_at": datetime.fromtimestamp(row["created_at"], tz=timezone.utc).isoformat(),
}
for row in rows
]
@app.delete("/api/admin/passkeys/{credential_id}")
def admin_delete_passkey(
credential_id: str,
_: Annotated[dict, Depends(require_admin)],
):
"""Admin : révoque n'importe quelle passkey."""
with get_db() as conn:
cur = conn.execute(
"DELETE FROM passkeys WHERE credential_id = ?", (credential_id,)
)
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Passkey introuvable")
return {"status": "ok"}
# ─── Routes VPS ─────────────────────────────────────────────────────────────── # ─── Routes VPS ───────────────────────────────────────────────────────────────
@app.get("/api/vps") @app.get("/api/vps")

View File

@@ -4,3 +4,4 @@ aiohttp>=3.9.0
pydantic>=2.0.0 pydantic>=2.0.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0 bcrypt>=4.0.0
webauthn>=2.0.0

View File

@@ -25,6 +25,7 @@ export default function App() {
const [role, setRole] = useState(null) const [role, setRole] = useState(null)
const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin' const [page, setPage] = useState('main') // 'main' | 'profile' | 'admin'
const [isFirstUser, setIsFirstUser] = useState(false) const [isFirstUser, setIsFirstUser] = useState(false)
const [passkeyEnabled, setPasskeyEnabled] = useState(false)
const [authChecked, setAuthChecked] = useState(false) const [authChecked, setAuthChecked] = useState(false)
const [vpsList, setVpsList] = useState([]) const [vpsList, setVpsList] = useState([])
@@ -56,7 +57,10 @@ export default function App() {
// Vérifie si des utilisateurs existent (pour afficher login ou register) // Vérifie si des utilisateurs existent (pour afficher login ou register)
useEffect(() => { useEffect(() => {
authStatus() authStatus()
.then(({ has_users }) => setIsFirstUser(!has_users)) .then(({ has_users, passkey_enabled }) => {
setIsFirstUser(!has_users)
setPasskeyEnabled(!!passkey_enabled)
})
.catch(() => setIsFirstUser(false)) .catch(() => setIsFirstUser(false))
.finally(() => setAuthChecked(true)) .finally(() => setAuthChecked(true))
}, []) }, [])
@@ -211,6 +215,7 @@ export default function App() {
return ( return (
<LoginPage <LoginPage
isFirstUser={isFirstUser} isFirstUser={isFirstUser}
passkeyEnabled={passkeyEnabled}
onAuthenticated={handleAuthenticated} onAuthenticated={handleAuthenticated}
/> />
) )

View File

@@ -184,3 +184,140 @@ export async function purgeDb({ table, period, fromTs, toTs }) {
}) })
return handleResponse(res) return handleResponse(res)
} }
// ─── Passkeys (WebAuthn) ──────────────────────────────────────────────────────
function _b64url(buffer) {
const bytes = new Uint8Array(buffer)
let str = ''
for (const b of bytes) str += String.fromCharCode(b)
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
function _fromB64url(str) {
const padding = '='.repeat((4 - (str.length % 4)) % 4)
const base64 = (str + padding).replace(/-/g, '+').replace(/_/g, '/')
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
function _prepareRegistrationOptions(opts) {
return {
...opts,
challenge: _fromB64url(opts.challenge),
user: { ...opts.user, id: _fromB64url(opts.user.id) },
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })),
}
}
function _prepareAuthenticationOptions(opts) {
return {
...opts,
challenge: _fromB64url(opts.challenge),
allowCredentials: (opts.allowCredentials || []).map(c => ({ ...c, id: _fromB64url(c.id) })),
}
}
function _serializeRegistrationCredential(cred) {
return {
id: cred.id,
rawId: _b64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: _b64url(cred.response.clientDataJSON),
attestationObject: _b64url(cred.response.attestationObject),
},
}
}
function _serializeAuthenticationCredential(cred) {
const out = {
id: cred.id,
rawId: _b64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: _b64url(cred.response.clientDataJSON),
authenticatorData: _b64url(cred.response.authenticatorData),
signature: _b64url(cred.response.signature),
},
}
if (cred.response.userHandle) {
out.response.userHandle = _b64url(cred.response.userHandle)
}
return out
}
/** Lance l'enregistrement complet d'une passkey (begin → browser → finish). */
export async function registerPasskey(name) {
// 1. Obtenir le challenge
const beginRes = await fetch(`${BASE}/auth/passkey/register/begin`, {
method: 'POST',
headers: authHeaders(),
})
const { session_id, options } = await handleResponse(beginRes)
// 2. Appel biométrique
const cred = await navigator.credentials.create({
publicKey: _prepareRegistrationOptions(options),
})
// 3. Finaliser
const finishRes = await fetch(`${BASE}/auth/passkey/register/finish`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ session_id, name, credential: _serializeRegistrationCredential(cred) }),
})
return handleResponse(finishRes)
}
/** Authentification complète par passkey (begin → browser → finish). Retourne {access_token, role}. */
export async function loginWithPasskey(username = null) {
// 1. Obtenir le challenge
const beginRes = await fetch(`${BASE}/auth/passkey/login/begin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
})
const { session_id, options } = await handleResponse(beginRes)
// 2. Appel biométrique (TouchID / FaceID)
const cred = await navigator.credentials.get({
publicKey: _prepareAuthenticationOptions(options),
})
// 3. Vérification côté serveur
const finishRes = await fetch(`${BASE}/auth/passkey/login/finish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id, credential: _serializeAuthenticationCredential(cred) }),
})
return handleResponse(finishRes)
}
export async function getMyPasskeys() {
const res = await fetch(`${BASE}/auth/passkeys`, { headers: authHeaders() })
return handleResponse(res)
}
export async function deleteMyPasskey(credentialId) {
const res = await fetch(`${BASE}/auth/passkeys/${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
headers: authHeaders(),
})
return handleResponse(res)
}
export async function adminGetPasskeys() {
const res = await fetch(`${BASE}/admin/passkeys`, { headers: authHeaders() })
return handleResponse(res)
}
export async function adminDeletePasskey(credentialId) {
const res = await fetch(`${BASE}/admin/passkeys/${encodeURIComponent(credentialId)}`, {
method: 'DELETE',
headers: authHeaders(),
})
return handleResponse(res)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react' import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client' import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@@ -27,7 +27,7 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
} }
export default function AdminPage({ onBack }) { export default function AdminPage({ onBack }) {
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database' const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'logs' | 'database'
// ─── Settings ──────────────────────────────────────────────────────────── // ─── Settings ────────────────────────────────────────────────────────────
const [settings, setSettings] = useState(null) const [settings, setSettings] = useState(null)
@@ -64,6 +64,55 @@ export default function AdminPage({ onBack }) {
} }
} }
const togglePasskeys = async () => {
if (!settings) return
const newValue = settings.passkey_enabled === 'true' ? 'false' : 'true'
setToggleLoading(true)
try {
await setAdminSetting('passkey_enabled', newValue)
setSettings(prev => ({ ...prev, passkey_enabled: newValue }))
} catch (err) {
setSettingsError(err.message)
} finally {
setToggleLoading(false)
}
}
// ─── Passkeys admin ──────────────────────────────────────────────────────
const [adminPasskeys, setAdminPasskeys] = useState([])
const [adminPasskeysLoading, setAdminPasskeysLoading] = useState(false)
const [adminPasskeysError, setAdminPasskeysError] = useState(null)
const [revokeLoading, setRevokeLoading] = useState(null)
const loadAdminPasskeys = useCallback(async () => {
setAdminPasskeysLoading(true)
setAdminPasskeysError(null)
try {
const data = await adminGetPasskeys()
setAdminPasskeys(data)
} catch (err) {
setAdminPasskeysError(err.message)
} finally {
setAdminPasskeysLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'passkeys') loadAdminPasskeys()
}, [activeTab, loadAdminPasskeys])
const handleRevokePasskey = async (credentialId) => {
setRevokeLoading(credentialId)
try {
await adminDeletePasskey(credentialId)
setAdminPasskeys(prev => prev.filter(p => p.credential_id !== credentialId))
} catch (err) {
setAdminPasskeysError(err.message)
} finally {
setRevokeLoading(null)
}
}
// ─── Login logs ────────────────────────────────────────────────────────── // ─── Login logs ──────────────────────────────────────────────────────────
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
const [logsTotal, setLogsTotal] = useState(0) const [logsTotal, setLogsTotal] = useState(0)
@@ -196,6 +245,7 @@ export default function AdminPage({ onBack }) {
<div className="flex gap-1 mb-8 border-b border-gray-800"> <div className="flex gap-1 mb-8 border-b border-gray-800">
{[ {[
{ key: 'settings', label: 'Paramètres' }, { key: 'settings', label: 'Paramètres' },
{ key: 'passkeys', label: 'Passkeys' },
{ key: 'logs', label: 'Connexions' }, { key: 'logs', label: 'Connexions' },
{ key: 'database', label: 'Base de données' }, { key: 'database', label: 'Base de données' },
].map(tab => ( ].map(tab => (
@@ -236,6 +286,89 @@ export default function AdminPage({ onBack }) {
onChange={toggleRegistration} onChange={toggleRegistration}
loading={toggleLoading} loading={toggleLoading}
/> />
<ToggleRow
label="Authentification par passkey"
description="Autorise la connexion via TouchID / FaceID (WebAuthn)."
enabled={settings?.passkey_enabled === 'true'}
onChange={togglePasskeys}
loading={toggleLoading}
/>
</div>
)
}
</section>
)}
{/* ── Tab: Passkeys ── */}
{activeTab === 'passkeys' && (
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-sm font-semibold text-gray-300">Passkeys enregistrées</h2>
<p className="text-xs text-gray-500 mt-0.5">
Gérez les passkeys (TouchID / FaceID) de tous les utilisateurs.
</p>
</div>
<button
onClick={loadAdminPasskeys}
disabled={adminPasskeysLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<RefreshCw size={12} className={adminPasskeysLoading ? 'animate-spin' : ''} />
Actualiser
</button>
</div>
{adminPasskeysError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
{adminPasskeysError}
</div>
)}
{adminPasskeysLoading && adminPasskeys.length === 0
? <p className="text-xs text-gray-500 py-8 text-center">Chargement…</p>
: adminPasskeys.length === 0
? (
<div className="flex flex-col items-center gap-2 py-10 text-gray-600">
<Fingerprint size={28} />
<p className="text-xs">Aucune passkey enregistrée.</p>
</div>
)
: (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="pb-2 px-2 font-medium">Utilisateur</th>
<th className="pb-2 px-2 font-medium">Appareil</th>
<th className="pb-2 px-2 font-medium">Enregistrée le</th>
<th className="pb-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/60">
{adminPasskeys.map(pk => (
<tr key={pk.credential_id} className="hover:bg-gray-800/30 transition-colors">
<td className="py-2 px-2 font-mono text-gray-200">{pk.username}</td>
<td className="py-2 px-2 text-gray-300 flex items-center gap-1.5">
<Key size={11} className="text-indigo-400 flex-shrink-0" />
{pk.name}
</td>
<td className="py-2 px-2 text-gray-500 whitespace-nowrap">
{new Date(pk.created_at).toLocaleString('fr-FR')}
</td>
<td className="py-2 px-2 text-right">
<button
onClick={() => handleRevokePasskey(pk.credential_id)}
disabled={revokeLoading === pk.credential_id}
className="px-2 py-1 rounded-md text-xs text-red-400 hover:bg-red-950/50 disabled:opacity-50 transition-colors"
>
{revokeLoading === pk.credential_id ? '' : 'Révoquer'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) )
} }

View File

@@ -1,16 +1,26 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Monitor } from 'lucide-react' import { Monitor, Fingerprint } from 'lucide-react'
import { login, register } from '../api/client' import { login, register, loginWithPasskey } from '../api/client'
export default function LoginPage({ isFirstUser, onAuthenticated }) { export default function LoginPage({ isFirstUser, passkeyEnabled, onAuthenticated }) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [password2, setPassword2] = useState('') const [password2, setPassword2] = useState('')
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [passkeyLoading, setPasskeyLoading] = useState(false)
const [browserSupportsPasskey, setBrowserSupportsPasskey] = useState(false)
const isRegister = isFirstUser const isRegister = isFirstUser
useEffect(() => {
if (window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(available => setBrowserSupportsPasskey(available))
.catch(() => setBrowserSupportsPasskey(false))
}
}, [])
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
@@ -39,6 +49,25 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
} }
} }
const handlePasskeyLogin = async () => {
setError(null)
setPasskeyLoading(true)
try {
const data = await loginWithPasskey(username.trim() || null)
onAuthenticated(data.access_token, data.role)
} catch (err) {
if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) {
setError('Authentification annulée.')
} else {
setError(err.message)
}
} finally {
setPasskeyLoading(false)
}
}
const showPasskeyButton = !isRegister && passkeyEnabled && browserSupportsPasskey
return ( return (
<div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4"> <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
@@ -53,7 +82,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4"> <div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
{isRegister && ( {isRegister && (
<div className="bg-indigo-950/40 border border-indigo-800/50 rounded-lg px-3 py-2 text-xs text-indigo-300"> <div className="bg-indigo-950/40 border border-indigo-800/50 rounded-lg px-3 py-2 text-xs text-indigo-300">
Aucun utilisateur n'existe encore. Le premier compte créé sera <strong>admin</strong>. Aucun utilisateur n'existe encore. Le premier compte créé sera <strong>admin</strong>.
@@ -66,6 +95,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label> <label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
<input <input
@@ -107,7 +137,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || passkeyLoading}
className="w-full py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors mt-2" className="w-full py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors mt-2"
> >
{loading {loading
@@ -117,6 +147,27 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
: 'Se connecter'} : 'Se connecter'}
</button> </button>
</form> </form>
{showPasskeyButton && (
<>
<div className="relative flex items-center gap-3">
<div className="flex-1 border-t border-gray-800" />
<span className="text-xs text-gray-600">ou</span>
<div className="flex-1 border-t border-gray-800" />
</div>
<button
type="button"
onClick={handlePasskeyLogin}
disabled={loading || passkeyLoading}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-sm font-medium transition-colors border border-gray-700"
>
<Fingerprint size={16} className="text-indigo-400" />
{passkeyLoading ? 'Vérification…' : 'Se connecter avec une passkey'}
</button>
</>
)}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import { KeyRound, ArrowLeft, Check } from 'lucide-react' import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react'
import { changePassword } from '../api/client' import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client'
export default function ProfilePage({ username, onBack }) { export default function ProfilePage({ username, onBack }) {
const [oldPassword, setOldPassword] = useState('') const [oldPassword, setOldPassword] = useState('')
@@ -10,6 +10,72 @@ export default function ProfilePage({ username, onBack }) {
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// ─── Passkeys ─────────────────────────────────────────────────────────────
const [passkeys, setPasskeys] = useState([])
const [passkeysLoading, setPasskeysLoading] = useState(true)
const [passkeysError, setPasskeysError] = useState(null)
const [addName, setAddName] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState(null)
const [deleteLoading, setDeleteLoading] = useState(null)
const [browserSupports, setBrowserSupports] = useState(false)
useEffect(() => {
if (window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(ok => setBrowserSupports(ok))
.catch(() => setBrowserSupports(false))
}
}, [])
const loadPasskeys = useCallback(async () => {
setPasskeysLoading(true)
setPasskeysError(null)
try {
const data = await getMyPasskeys()
setPasskeys(data)
} catch (err) {
setPasskeysError(err.message)
} finally {
setPasskeysLoading(false)
}
}, [])
useEffect(() => { loadPasskeys() }, [loadPasskeys])
const handleAddPasskey = async (e) => {
e.preventDefault()
setAddError(null)
setAdding(true)
try {
const name = addName.trim() || 'Ma passkey'
await registerPasskey(name)
setAddName('')
await loadPasskeys()
} catch (err) {
if (err.name === 'NotAllowedError' || err.message?.includes('NotAllowedError')) {
setAddError('Enregistrement annulé.')
} else {
setAddError(err.message)
}
} finally {
setAdding(false)
}
}
const handleDeletePasskey = async (credentialId) => {
setDeleteLoading(credentialId)
try {
await deleteMyPasskey(credentialId)
setPasskeys(prev => prev.filter(p => p.credential_id !== credentialId))
} catch (err) {
setPasskeysError(err.message)
} finally {
setDeleteLoading(null)
}
}
// ─── Password change ──────────────────────────────────────────────────────
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
@@ -121,6 +187,83 @@ export default function ProfilePage({ username, onBack }) {
</button> </button>
</form> </form>
</div> </div>
{/* ── Passkeys ── */}
{browserSupports && (
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 mt-6">
<div className="flex items-center gap-2 mb-1">
<Fingerprint size={15} className="text-indigo-400" />
<h2 className="text-sm font-medium text-gray-300">Passkeys (TouchID / FaceID)</h2>
</div>
<p className="text-xs text-gray-500 mb-4">
Connectez-vous sans mot de passe grâce à la biométrie de votre appareil.
</p>
{passkeysError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
{passkeysError}
</div>
)}
{/* Liste des passkeys */}
{passkeysLoading
? <p className="text-xs text-gray-500 mb-4">Chargement</p>
: passkeys.length === 0
? <p className="text-xs text-gray-500 mb-4">Aucune passkey enregistrée.</p>
: (
<ul className="divide-y divide-gray-800 mb-4">
{passkeys.map(pk => (
<li key={pk.credential_id} className="flex items-center justify-between gap-3 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<Key size={13} className="text-indigo-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm text-gray-200 truncate">{pk.name}</p>
<p className="text-xs text-gray-500">
{new Date(pk.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
</div>
<button
onClick={() => handleDeletePasskey(pk.credential_id)}
disabled={deleteLoading === pk.credential_id}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-red-400 hover:bg-red-950/40 disabled:opacity-50 transition-colors flex-shrink-0"
>
<Trash2 size={11} />
{deleteLoading === pk.credential_id ? '…' : 'Supprimer'}
</button>
</li>
))}
</ul>
)
}
{/* Formulaire d'ajout */}
{addError && (
<div className="bg-red-950/40 border border-red-800/50 rounded-lg px-3 py-2 text-xs text-red-300 mb-3">
{addError}
</div>
)}
<form onSubmit={handleAddPasskey} className="flex gap-2">
<input
type="text"
placeholder="Nom de l'appareil (ex : MacBook)"
value={addName}
onChange={e => setAddName(e.target.value)}
disabled={adding}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors disabled:opacity-50"
/>
<button
type="submit"
disabled={adding}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm font-medium transition-colors flex-shrink-0"
>
<Plus size={14} />
{adding ? 'En cours…' : 'Ajouter'}
</button>
</form>
</div>
)}
</div> </div>
</div> </div>
) )