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
194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
||
|
||
import { codeNow, stepNow, waitForFreshCode } from './mfa';
|
||
|
||
/**
|
||
* Second facteur — matrice n° 15.
|
||
*
|
||
* Les codes sont calculés depuis le secret affiché, exactement comme le ferait
|
||
* l'application d'un téléphone. Court-circuiter la vérification testerait un
|
||
* produit que personne n'utilise.
|
||
*
|
||
* La direction est enrôlée par la mise en place et son rôle l'exige : c'est sur
|
||
* elle qu'on éprouve le refus de retrait. Le cycle complet — activer, se
|
||
* connecter, désactiver — se joue sur un compte ordinaire, créé pour l'occasion.
|
||
*/
|
||
|
||
const OWNER_PASSWORD = 'planflow-demo-2026';
|
||
const EMPLOYEE_PASSWORD = 'les mesanges du soir';
|
||
|
||
test('la direction ne peut pas retirer un facteur que son rôle exige', async ({
|
||
page,
|
||
}) => {
|
||
await page.goto('/reglages/securite');
|
||
|
||
await expect(page.getByText('Second facteur actif')).toBeVisible();
|
||
await expect(page.getByText(/exigé.+ne peut pas être retiré/)).toBeVisible();
|
||
// Le formulaire de retrait n'est pas seulement masqué : il n'existe pas.
|
||
await expect(page.getByRole('button', { name: 'Désactiver' })).toHaveCount(0);
|
||
});
|
||
|
||
// Le parcours attend deux fois le pas TOTP suivant — trente secondes chacune —
|
||
// parce qu'un code ne sert qu'une fois. C'est le comportement voulu, pas une
|
||
// lenteur à corriger.
|
||
test('un compte ordinaire active, éprouve, puis retire son second facteur', async ({
|
||
page,
|
||
browser,
|
||
}) => {
|
||
test.setTimeout(150_000);
|
||
const account = await createAccessibleEmployee(page);
|
||
|
||
const context = await browser.newContext({ storageState: undefined });
|
||
const own = await context.newPage();
|
||
|
||
await signIn(own, account.email, EMPLOYEE_PASSWORD);
|
||
// Un salarié ordinaire n'est pas contraint au second facteur : il entre
|
||
// directement, sans écran d'enrôlement.
|
||
await expect(own.getByRole('button', { name: 'Déconnexion' })).toBeVisible();
|
||
|
||
await own.goto('/reglages/securite');
|
||
await expect(own.getByText('Non activé')).toBeVisible();
|
||
await own.getByRole('button', { name: 'Activer le second facteur' }).click();
|
||
|
||
const secret = await readSecret(own);
|
||
const codeField = own.getByRole('textbox', {
|
||
name: 'Recopiez le code affiché',
|
||
});
|
||
|
||
// Un code faux n'enregistre rien : poser un secret sans preuve que son
|
||
// détenteur sait le produire fermerait le compte.
|
||
await codeField.fill('000000');
|
||
await own.getByRole('button', { name: 'Valider' }).click();
|
||
await expect(own.getByText(/ne correspond pas/)).toBeVisible();
|
||
|
||
const enrolStep = stepNow();
|
||
await codeField.fill(codeNow(secret));
|
||
await own.getByRole('button', { name: 'Valider' }).click();
|
||
|
||
const codes = own.getByTestId('recovery-codes');
|
||
await expect(codes).toBeVisible();
|
||
await expect(codes.getByRole('listitem')).toHaveCount(10);
|
||
const recoveryCode = (await codes.getByRole('listitem').first().textContent())!.trim();
|
||
|
||
// Le mot de passe seul n'ouvre plus rien.
|
||
const second = await browser.newContext({ storageState: undefined });
|
||
const fresh = await second.newPage();
|
||
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
|
||
await expect(fresh.getByRole('heading', { name: 'Vérification' })).toBeVisible();
|
||
|
||
// Une session en attente n'ouvre aucun écran : elle ne porte qu'un défi.
|
||
await fresh.goto('/equipe');
|
||
await expect(fresh).toHaveURL(/connexion/);
|
||
|
||
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
|
||
await fresh.getByLabel('Code').fill('123456');
|
||
await fresh.getByRole('button', { name: 'Vérifier' }).click();
|
||
await expect(fresh.getByText(/Code refusé/)).toBeVisible();
|
||
|
||
await waitForFreshCode(enrolStep);
|
||
await fresh.getByLabel('Code').fill(codeNow(secret));
|
||
await fresh.getByRole('button', { name: 'Vérifier' }).click();
|
||
await expect(fresh).not.toHaveURL(/verification/);
|
||
await second.close();
|
||
|
||
// Un code de secours ouvre aussi, et ne sert qu'une fois.
|
||
const third = await browser.newContext({ storageState: undefined });
|
||
const rescued = await third.newPage();
|
||
await signIn(rescued, account.email, EMPLOYEE_PASSWORD);
|
||
await rescued.getByLabel('Code').fill(recoveryCode);
|
||
await rescued.getByRole('button', { name: 'Vérifier' }).click();
|
||
await expect(rescued).toHaveURL(/reglages\/securite/);
|
||
|
||
const fourth = await browser.newContext({ storageState: undefined });
|
||
const replay = await fourth.newPage();
|
||
await signIn(replay, account.email, EMPLOYEE_PASSWORD);
|
||
await replay.getByLabel('Code').fill(recoveryCode);
|
||
await replay.getByRole('button', { name: 'Vérifier' }).click();
|
||
await expect(replay.getByText(/Code refusé/)).toBeVisible();
|
||
await fourth.close();
|
||
|
||
// Le retrait exige le mot de passe : un poste laissé ouvert ne suffit pas.
|
||
await rescued.goto('/reglages/securite');
|
||
await rescued.getByLabel('Mot de passe').fill('pas le bon mot de passe');
|
||
await rescued.getByRole('button', { name: 'Désactiver' }).click();
|
||
await expect(rescued.getByText('Mot de passe incorrect.')).toBeVisible();
|
||
|
||
await rescued.getByLabel('Mot de passe').fill(EMPLOYEE_PASSWORD);
|
||
await rescued.getByRole('button', { name: 'Désactiver' }).click();
|
||
await expect(rescued.getByText('Second facteur désactivé.')).toBeVisible();
|
||
await third.close();
|
||
|
||
await context.close();
|
||
});
|
||
|
||
async function signIn(page: Page, email: string, password: string) {
|
||
await page.goto('/connexion');
|
||
await page.getByLabel('Adresse électronique').fill(email);
|
||
await page.getByLabel('Mot de passe').fill(password);
|
||
await page.getByRole('button', { name: 'Se connecter' }).click();
|
||
// Attendre que la page ait quitté le formulaire : naviguer pendant que
|
||
// l'action est encore en vol l'annulerait, et la session ne serait jamais
|
||
// posée.
|
||
await page.waitForURL((url) => !url.pathname.endsWith('/connexion'));
|
||
}
|
||
|
||
/** Le secret est affiché en clair pour qui n'a pas d'appareil photo. */
|
||
async function readSecret(page: Page): Promise<string> {
|
||
const shown = await page.locator('p.font-mono').first().textContent();
|
||
return (shown ?? '').replace(/\s/g, '');
|
||
}
|
||
|
||
/**
|
||
* Crée un salarié et lui ouvre un accès.
|
||
*
|
||
* Chaque exécution le sien : le second facteur est porté par la personne, et
|
||
* deux passages qui se partageraient un compte se heurteraient au facteur posé
|
||
* par le précédent.
|
||
*/
|
||
async function createAccessibleEmployee(page: Page) {
|
||
const suffix = `${Date.now()}-mfa`;
|
||
const lastName = `Facteur${suffix}`;
|
||
const email = `facteur.${suffix}@exemple.test`;
|
||
|
||
await page.goto('/equipe');
|
||
const form = page.locator('form').filter({ hasText: 'Ajouter' });
|
||
await form.getByLabel('Prénom').fill('Noé');
|
||
await form.getByLabel('Nom', { exact: true }).fill(lastName);
|
||
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
|
||
await form.getByLabel('Adresse électronique').fill(email);
|
||
await form.getByRole('button', { name: 'Ajouter' }).click();
|
||
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
|
||
|
||
await page.getByRole('link', { name: new RegExp(lastName) }).click();
|
||
await page
|
||
.locator('form')
|
||
.filter({ hasText: 'Adresse d’invitation' })
|
||
.getByRole('button')
|
||
.click();
|
||
|
||
const link = page.getByTestId('invitation-link');
|
||
await expect(link).toBeVisible();
|
||
const url = (await link.textContent())!.trim();
|
||
|
||
const guest = await page.context().browser()!.newContext({
|
||
storageState: undefined,
|
||
});
|
||
const guestPage = await guest.newPage();
|
||
await guestPage.goto(url);
|
||
await guestPage.getByLabel('Choisissez un mot de passe').fill(EMPLOYEE_PASSWORD);
|
||
await guestPage.getByLabel('Confirmez').fill(EMPLOYEE_PASSWORD);
|
||
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
|
||
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
|
||
await guest.close();
|
||
|
||
return { email, lastName };
|
||
}
|
||
|
||
test('le mot de passe du propriétaire reste inchangé', async ({ page }) => {
|
||
// Garde-fou : les tests ci-dessus manipulent l'authentification, et une
|
||
// dérive silencieuse fermerait la suite entière au passage suivant.
|
||
await page.goto('/reglages/securite');
|
||
await expect(page.getByRole('heading', { name: 'Sécurité' })).toBeVisible();
|
||
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
|
||
});
|