init aegis
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from 'react';
|
||||
import { pb } from '../../config/pocketbase';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function Login({ onLoginSuccess }: LoginProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
|
||||
// Nouveaux états pour la cryptographie TOTP
|
||||
const [userId, setUserId] = useState('');
|
||||
const [totpSecret, setTotpSecret] = useState('');
|
||||
const [qrUrl, setQrUrl] = useState('');
|
||||
const [isFirstSetup, setIsFirstSetup] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 1. Authentification PocketBase
|
||||
const authData = await pb.collection('aegis_users').authWithPassword(email, password);
|
||||
|
||||
// 2. Vérification du MFA
|
||||
if (authData.record.mfa_enabled) {
|
||||
setUserId(authData.record.id);
|
||||
|
||||
// Si le client n'a pas encore configuré son MFA
|
||||
if (!authData.record.totp_secret) {
|
||||
// Génération cryptographique native pour le navigateur
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'AEGIS by GISE',
|
||||
label: email,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: new OTPAuth.Secret({ size: 20 })
|
||||
});
|
||||
|
||||
const newSecret = totp.secret.base32;
|
||||
const otpauth = totp.toString(); // Génère l'URL pour le QR Code
|
||||
|
||||
setTotpSecret(newSecret);
|
||||
setQrUrl(otpauth);
|
||||
setIsFirstSetup(true);
|
||||
} else {
|
||||
// Le client l'a déjà configuré dans le passé
|
||||
setTotpSecret(authData.record.totp_secret);
|
||||
setIsFirstSetup(false);
|
||||
}
|
||||
setStep(2);
|
||||
} else {
|
||||
onLoginSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Erreur d'authentification", err);
|
||||
setError("Identifiants institutionnels incorrects ou accès révoqué.");
|
||||
pb.authStore.clear();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalStep = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// On recrée l'instance TOTP avec le secret pour vérifier le code saisi
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'AEGIS by GISE',
|
||||
label: email,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(totpSecret)
|
||||
});
|
||||
|
||||
// Validation : Retourne un nombre si valide, null sinon (tolérance de 1 fenêtre de 30s)
|
||||
const isValid = totp.validate({ token: mfaCode, window: 1 }) !== null;
|
||||
|
||||
if (isValid) {
|
||||
// Si c'était la première configuration, on sauvegarde le secret en base de données
|
||||
if (isFirstSetup) {
|
||||
await pb.collection('aegis_users').update(userId, {
|
||||
totp_secret: totpSecret
|
||||
});
|
||||
}
|
||||
onLoginSuccess(); // La porte du coffre s'ouvre !
|
||||
} else {
|
||||
setError("Code de sécurité invalide ou expiré.");
|
||||
setMfaCode('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Une erreur critique est survenue lors de la vérification.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMFA = () => {
|
||||
pb.authStore.clear();
|
||||
setStep(1);
|
||||
setPassword('');
|
||||
setMfaCode('');
|
||||
setError('');
|
||||
setIsFirstSetup(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-sans selection:bg-blue-900 selection:text-white">
|
||||
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-12 w-12 bg-blue-900 rounded-lg flex items-center justify-center shadow-sm border border-blue-800">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
|
||||
AEGIS <span className="font-light text-slate-500">by GISE</span>
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-slate-500 uppercase tracking-widest font-semibold">
|
||||
Accès restreint
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-sm border border-slate-200 rounded-xl sm:px-10">
|
||||
|
||||
{step === 1 ? (
|
||||
<form className="space-y-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm font-medium animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Identifiant institutionnel</label>
|
||||
<div className="mt-1">
|
||||
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-400 shadow-sm focus:border-blue-900 focus:outline-none focus:ring-blue-900 sm:text-sm" placeholder="direction@client.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Mot de passe</label>
|
||||
<div className="mt-1">
|
||||
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-2 placeholder-slate-400 shadow-sm focus:border-blue-900 focus:outline-none focus:ring-blue-900 sm:text-sm" placeholder="••••••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" disabled={isLoading} className="flex w-full justify-center rounded-md border border-transparent bg-blue-900 py-2.5 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-800 focus:outline-none transition-colors disabled:opacity-70">
|
||||
{isLoading ? 'Chiffrement en cours...' : 'Authentification'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-6 animate-in fade-in slide-in-from-right-4 duration-300" onSubmit={handleFinalStep}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm font-medium animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFirstSetup ? (
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-medium text-slate-900">Configuration de la sécurité</p>
|
||||
<p className="text-xs text-slate-500 mt-2 mb-4">Scannez ce QR Code avec Google Authenticator ou Authy pour lier votre appareil.</p>
|
||||
<div className="flex justify-center p-4 bg-white border border-slate-200 rounded-lg inline-block shadow-sm">
|
||||
<QRCodeSVG value={qrUrl} size={150} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-sm font-medium text-slate-900">Validation à double facteur</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Saisissez le code généré par votre application d'authentification.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value)}
|
||||
required
|
||||
maxLength={6}
|
||||
className="block w-full appearance-none rounded-md border border-slate-300 px-3 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 placeholder-slate-300 shadow-sm focus:border-blue-900 focus:outline-none font-mono"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button type="button" onClick={handleCancelMFA} disabled={isLoading} className="flex w-1/3 justify-center rounded-md border border-slate-300 bg-white py-2.5 px-4 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 transition-colors disabled:opacity-70">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" disabled={isLoading || mfaCode.length !== 6} className="flex w-2/3 justify-center rounded-md border border-transparent bg-emerald-600 py-2.5 px-4 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors disabled:opacity-70">
|
||||
{isLoading ? 'Vérification...' : 'Déverrouiller'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user