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

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

View File

@@ -184,3 +184,140 @@ export async function purgeDb({ table, period, fromTs, toTs }) {
})
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 { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb } from '../api/client'
import { ShieldCheck, ArrowLeft, RefreshCw, ToggleLeft, ToggleRight, Check, X, Database, Trash2, AlertTriangle, Fingerprint, Key } from 'lucide-react'
import { getAdminSettings, setAdminSetting, getLoginLogs, getDbInfo, purgeDb, adminGetPasskeys, adminDeletePasskey } from '../api/client'
const PAGE_SIZE = 50
@@ -27,7 +27,7 @@ function ToggleRow({ label, description, enabled, onChange, loading }) {
}
export default function AdminPage({ onBack }) {
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'logs' | 'database'
const [activeTab, setActiveTab] = useState('settings') // 'settings' | 'passkeys' | 'logs' | 'database'
// ─── Settings ────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────
const [logs, setLogs] = useState([])
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">
{[
{ key: 'settings', label: 'Paramètres' },
{ key: 'passkeys', label: 'Passkeys' },
{ key: 'logs', label: 'Connexions' },
{ key: 'database', label: 'Base de données' },
].map(tab => (
@@ -236,12 +286,95 @@ export default function AdminPage({ onBack }) {
onChange={toggleRegistration}
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>
)
}
</section>
)}
{/* ── Tab: Connexions ── */}
{activeTab === 'logs' && (
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-6">

View File

@@ -1,16 +1,26 @@
import { useState } from 'react'
import { Monitor } from 'lucide-react'
import { login, register } from '../api/client'
import { useState, useEffect } from 'react'
import { Monitor, Fingerprint } from 'lucide-react'
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 [password, setPassword] = useState('')
const [password2, setPassword2] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const [passkeyLoading, setPasskeyLoading] = useState(false)
const [browserSupportsPasskey, setBrowserSupportsPasskey] = useState(false)
const isRegister = isFirstUser
useEffect(() => {
if (window.PublicKeyCredential) {
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(available => setBrowserSupportsPasskey(available))
.catch(() => setBrowserSupportsPasskey(false))
}
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
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 (
<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">
@@ -53,7 +82,7 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
</p>
</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 && (
<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>.
@@ -66,57 +95,79 @@ export default function LoginPage({ isFirstUser, onAuthenticated }) {
</div>
)}
<div>
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
<input
type="text"
required
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
<input
type="password"
required
autoComplete={isRegister ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
{isRegister && (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
<label className="block text-xs text-gray-400 mb-1.5">Nom d'utilisateur</label>
<input
type="password"
type="text"
required
autoComplete="new-password"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
)}
<button
type="submit"
disabled={loading}
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
? 'Chargement…'
: isRegister
? 'Créer le compte'
: 'Se connecter'}
</button>
</form>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Mot de passe</label>
<input
type="password"
required
autoComplete={isRegister ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
{isRegister && (
<div>
<label className="block text-xs text-gray-400 mb-1.5">Confirmer le mot de passe</label>
<input
type="password"
required
autoComplete="new-password"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
)}
<button
type="submit"
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"
>
{loading
? 'Chargement…'
: isRegister
? 'Créer le compte'
: 'Se connecter'}
</button>
</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>
)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { KeyRound, ArrowLeft, Check } from 'lucide-react'
import { changePassword } from '../api/client'
import { useState, useEffect, useCallback } from 'react'
import { KeyRound, ArrowLeft, Check, Fingerprint, Plus, Trash2, Key } from 'lucide-react'
import { changePassword, getMyPasskeys, deleteMyPasskey, registerPasskey } from '../api/client'
export default function ProfilePage({ username, onBack }) {
const [oldPassword, setOldPassword] = useState('')
@@ -10,6 +10,72 @@ export default function ProfilePage({ username, onBack }) {
const [success, setSuccess] = 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) => {
e.preventDefault()
setError(null)
@@ -121,6 +187,83 @@ export default function ProfilePage({ username, onBack }) {
</button>
</form>
</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>
)