diff --git a/package-lock.json b/package-lock.json index 4963b67..93be1fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@zitadel/react-auth": "^1.2.1", + "oidc-client-ts": "^3.5.0", "otpauth": "^9.5.1", "pocketbase": "^0.27.0", "qrcode.react": "^4.2.0", @@ -1664,6 +1666,26 @@ } } }, + "node_modules/@zitadel/react-auth": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@zitadel/react-auth/-/react-auth-1.2.1.tgz", + "integrity": "sha512-+aq52lxjH1sz9SImqfjfu/bZhY/yvMS9RfbvbjnFURTu6ZdMtysW+rrfuVQtu8YM4UYlX4KBvnVNzHb0eosNzw==", + "license": "Apache-2.0", + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "oidc-client-ts": ">=3.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "react-router-dom": ">=6.0.0" + }, + "peerDependenciesMeta": { + "react-router-dom": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1728,16 +1750,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -2361,6 +2383,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2727,6 +2758,18 @@ "node": ">=18" } }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 6ee9e78..9f34c13 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@zitadel/react-auth": "^1.2.1", + "oidc-client-ts": "^3.5.0", "otpauth": "^9.5.1", "pocketbase": "^0.27.0", "qrcode.react": "^4.2.0", diff --git a/src/pages/Auth/Login.tsx b/src/pages/Auth/Login.tsx index 5fb9d83..1670a72 100644 --- a/src/pages/Auth/Login.tsx +++ b/src/pages/Auth/Login.tsx @@ -15,28 +15,51 @@ export default function Login({ onLoginSuccess }: LoginProps) { const [isLoading, setIsLoading] = useState(false); const [step, setStep] = useState<1 | 2>(1); - // Nouveaux états pour la cryptographie TOTP + // États pour la cryptographie TOTP (Flux Local) const [userId, setUserId] = useState(''); const [totpSecret, setTotpSecret] = useState(''); const [qrUrl, setQrUrl] = useState(''); const [isFirstSetup, setIsFirstSetup] = useState(false); - const handleLogin = async (e: React.FormEvent) => { + // -------------------------------------------------------- + // FLUX 1 : Connexion SSO via Zitadel (OIDC) + // -------------------------------------------------------- + const handleZitadelLogin = async () => { + setError(''); + setIsLoading(true); + try { + // PocketBase gère automatiquement la popup vers Zitadel et le retour du token + const authData = await pb.collection('aegis_users').authWithOAuth2({ provider: 'oidc' }); + + if (authData) { + // Zitadel a déjà géré la sécurité et le MFA de son côté. + // On ouvre directement le coffre-fort. + onLoginSuccess(); + } + } catch (err: any) { + console.error("Erreur d'authentification SSO :", err); + setError("Échec de la connexion sécurisée via GISE Identity."); + pb.authStore.clear(); + } finally { + setIsLoading(false); + } + }; + + // -------------------------------------------------------- + // FLUX 2 : Connexion Classique (Email + Mot de passe) + // -------------------------------------------------------- + const handleLocalLogin = 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, @@ -46,23 +69,18 @@ export default function Login({ onLoginSuccess }: LoginProps) { 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); + setTotpSecret(totp.secret.base32); + setQrUrl(totp.toString()); setIsFirstSetup(true); } else { - // Le client l'a déjà configuré dans le passé setTotpSecret(authData.record.totp_secret); setIsFirstSetup(false); } - setStep(2); + setStep(2); // On passe à l'étape MFA locale } else { onLoginSuccess(); } } catch (err: any) { - console.error("Erreur d'authentification", err); setError("Identifiants institutionnels incorrects ou accès révoqué."); pb.authStore.clear(); } finally { @@ -76,7 +94,6 @@ export default function Login({ onLoginSuccess }: LoginProps) { 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, @@ -86,23 +103,18 @@ export default function Login({ onLoginSuccess }: LoginProps) { 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 - }); + await pb.collection('aegis_users').update(userId, { totp_secret: totpSecret }); } - onLoginSuccess(); // La porte du coffre s'ouvre ! + onLoginSuccess(); } 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); @@ -141,33 +153,61 @@ export default function Login({ onLoginSuccess }: LoginProps) {