La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message : le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup. L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs publiés de la RFC, ce qui distingue « le code change toutes les trente secondes » de « le code est celui qu'attend l'application du téléphone ». L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client renomme librement : distribuer les droits, ou lire les rémunérations. Elle est tenue au point de passage de toutes les routes applicatives, où l'écran d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une redirection depuis un layout se joue aussi pendant la navigation qui suit la connexion, et Next y répond par une page vide. Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé, pas d'un code lu par-dessus l'épaule. Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait depuis le serveur existe — l'accès « break glass » que demande la même ligne de la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt que la protection. Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé, emportant les codes de secours avant que leur destinataire ait pu les noter. La suite de tests suit le produit : la mise en place enrôle réellement le compte de direction et calcule les codes comme le ferait un téléphone. Les écrans qui n'éprouvent pas l'authentification ont migré vers la session commune — deux connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui est le comportement voulu et non un défaut à contourner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
base32Decode,
|
||
base32Encode,
|
||
formatSecret,
|
||
otpauthUri,
|
||
totpCode,
|
||
totpCodeAtStep,
|
||
totpStep,
|
||
TOTP_STEP_SECONDS,
|
||
verifyTotp,
|
||
} from '@/domain/access/totp';
|
||
import { mfaRequired } from '@/domain/access/mfa-policy';
|
||
|
||
/**
|
||
* Vecteurs de la RFC 6238, appendice B.
|
||
*
|
||
* Le secret y est la chaîne ASCII « 12345678901234567890 ». Éprouver contre des
|
||
* valeurs publiées est ce qui distingue « le code change toutes les trente
|
||
* secondes » de « le code est celui qu'attend l'application du téléphone ».
|
||
*/
|
||
const RFC_SECRET = base32Encode(
|
||
Uint8Array.from(Buffer.from('12345678901234567890', 'ascii')),
|
||
);
|
||
|
||
describe('base32', () => {
|
||
it('fait l’aller-retour', () => {
|
||
const bytes = Uint8Array.from([0, 1, 2, 250, 255, 128, 64]);
|
||
expect(base32Decode(base32Encode(bytes))).toEqual(bytes);
|
||
});
|
||
|
||
it('encode selon l’alphabet standard', () => {
|
||
expect(base32Encode(Uint8Array.from(Buffer.from('foobar', 'ascii')))).toBe(
|
||
'MZXW6YTBOI',
|
||
);
|
||
});
|
||
|
||
it('tolère espaces, minuscules et remplissage', () => {
|
||
// Un secret recopié à la main arrive rarement propre.
|
||
expect(base32Decode('mzxw 6ytb oi==')).toEqual(
|
||
base32Decode('MZXW6YTBOI'),
|
||
);
|
||
});
|
||
|
||
it('refuse un caractère hors alphabet', () => {
|
||
expect(() => base32Decode('MZXW6YTB01')).toThrow(/base32/);
|
||
});
|
||
});
|
||
|
||
describe('TOTP — vecteurs RFC 6238', () => {
|
||
// La RFC publie huit chiffres ; PlanFlow en produit six, soit les six
|
||
// derniers du même calcul.
|
||
const vectors: Array<[seconds: number, expected: string]> = [
|
||
[59, '287082'],
|
||
[1_111_111_109, '081804'],
|
||
[1_111_111_111, '050471'],
|
||
[1_234_567_890, '005924'],
|
||
[2_000_000_000, '279037'],
|
||
];
|
||
|
||
for (const [seconds, expected] of vectors) {
|
||
it(`produit ${expected} à T=${seconds}`, () => {
|
||
expect(totpCode(RFC_SECRET, seconds * 1000)).toBe(expected);
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('pas de temps', () => {
|
||
it('avance toutes les trente secondes', () => {
|
||
expect(totpStep(0)).toBe(0);
|
||
expect(totpStep((TOTP_STEP_SECONDS - 1) * 1000)).toBe(0);
|
||
expect(totpStep(TOTP_STEP_SECONDS * 1000)).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('vérification', () => {
|
||
const now = 1_700_000_000_000;
|
||
|
||
it('accepte le code courant', () => {
|
||
expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, now), now).ok).toBe(true);
|
||
});
|
||
|
||
it('tolère un pas de dérive de part et d’autre', () => {
|
||
// Une horloge de téléphone en avance de quelques secondes est courante ;
|
||
// refuser cette dérive rendrait le facteur inutilisable un jour sur deux.
|
||
const step = totpStep(now);
|
||
for (const offset of [-1, 1]) {
|
||
const code = totpCodeAtStep(RFC_SECRET, step + offset);
|
||
expect(verifyTotp(RFC_SECRET, code, now).ok).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('refuse au-delà de la tolérance', () => {
|
||
const code = totpCodeAtStep(RFC_SECRET, totpStep(now) + 2);
|
||
expect(verifyTotp(RFC_SECRET, code, now).ok).toBe(false);
|
||
});
|
||
|
||
it('refuse le rejeu d’un code déjà employé', () => {
|
||
// Sans cela, le facteur protège d'un mot de passe volé mais pas d'un code
|
||
// lu par-dessus l'épaule pendant ses trente secondes.
|
||
const step = totpStep(now);
|
||
const code = totpCodeAtStep(RFC_SECRET, step);
|
||
expect(verifyTotp(RFC_SECRET, code, now, null)).toEqual({ ok: true, step });
|
||
expect(verifyTotp(RFC_SECRET, code, now, step).ok).toBe(false);
|
||
});
|
||
|
||
it('refuse aussi un code antérieur au dernier employé', () => {
|
||
const step = totpStep(now);
|
||
const previous = totpCodeAtStep(RFC_SECRET, step - 1);
|
||
expect(verifyTotp(RFC_SECRET, previous, now, step).ok).toBe(false);
|
||
});
|
||
|
||
it('refuse ce qui n’a pas la forme d’un code', () => {
|
||
expect(verifyTotp(RFC_SECRET, '12345', now).ok).toBe(false);
|
||
expect(verifyTotp(RFC_SECRET, 'abcdef', now).ok).toBe(false);
|
||
expect(verifyTotp(RFC_SECRET, '', now).ok).toBe(false);
|
||
});
|
||
|
||
it('ignore les espaces de saisie', () => {
|
||
const code = totpCode(RFC_SECRET, now);
|
||
const spaced = `${code.slice(0, 3)} ${code.slice(3)}`;
|
||
expect(verifyTotp(RFC_SECRET, spaced, now).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('URI otpauth', () => {
|
||
const uri = otpauthUri({
|
||
secret: 'JBSWY3DPEHPK3PXP',
|
||
account: 'camille@example.fr',
|
||
issuer: 'Maison Rivage',
|
||
});
|
||
|
||
it('porte l’émetteur dans le chemin et en paramètre', () => {
|
||
// Les applications n'ont jamais convergé : n'en fournir qu'une donne des
|
||
// entrées mal nommées chez la moitié des utilisateurs.
|
||
expect(uri).toContain('otpauth://totp/Maison%20Rivage:');
|
||
expect(uri).toContain('issuer=Maison+Rivage');
|
||
});
|
||
|
||
it('déclare les paramètres attendus', () => {
|
||
expect(uri).toContain('algorithm=SHA1');
|
||
expect(uri).toContain('digits=6');
|
||
expect(uri).toContain('period=30');
|
||
});
|
||
|
||
it('échappe l’adresse', () => {
|
||
expect(uri).toContain('camille%40example.fr');
|
||
});
|
||
});
|
||
|
||
describe('secret lisible', () => {
|
||
it('se groupe par quatre', () => {
|
||
expect(formatSecret('JBSWY3DPEHPK3PXP')).toBe('JBSW Y3DP EHPK 3PXP');
|
||
});
|
||
});
|
||
|
||
describe('obligation de second facteur', () => {
|
||
it('vise qui distribue les droits', () => {
|
||
expect(mfaRequired(new Set(['settings.roles.manage']))).toBe(true);
|
||
});
|
||
|
||
it('vise qui lit les rémunérations', () => {
|
||
expect(mfaRequired(new Set(['members.salary.view']))).toBe(true);
|
||
});
|
||
|
||
it('épargne un salarié ordinaire', () => {
|
||
// Imposer le facteur à toute l'équipe de vente le rendrait contournable
|
||
// par la première demande d'assistance.
|
||
expect(mfaRequired(new Set(['planning.view', 'timeoff.request']))).toBe(
|
||
false,
|
||
);
|
||
});
|
||
});
|