Exiger un second facteur des rôles administrateur et RH
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
This commit is contained in:
27 files changed
+1997
-79
No files matched your search
@@ -1,7 +1,11 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { MfaSettings } from '@/components/settings/MfaSettings';
|
||||
import { AppShell } from '@/components/shell/AppShell';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { mfaRequired } from '@/domain/access/mfa-policy';
|
||||
import { currentSession } from '@/server/auth/session';
|
||||
|
||||
/**
|
||||
@@ -16,6 +20,17 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const session = await currentSession();
|
||||
if (!session) redirect('/connexion');
|
||||
|
||||
// Un rôle qui lit les rémunérations ou distribue les droits doit porter un
|
||||
// second facteur (matrice n° 15). L'obligation est tenue ici, au point de
|
||||
// passage de toutes les routes applicatives.
|
||||
//
|
||||
// L'écran d'enrôlement **remplace** le contenu au lieu de rediriger vers
|
||||
// lui : une redirection depuis un layout se joue aussi pendant la navigation
|
||||
// qui suit la connexion, et Next y répond par une page vide. Substituer le
|
||||
// contenu tient la même garantie sans dépendre du chemin demandé.
|
||||
const enrolmentDue =
|
||||
mfaRequired(session.actor.permissions) && !session.user.mfaEnrolled;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
initials={session.user.initials}
|
||||
@@ -23,7 +38,22 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
roleName={session.roleName}
|
||||
accountName={session.accountName}
|
||||
>
|
||||
{children}
|
||||
{enrolmentDue ? <EnrolmentGate /> : children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EnrolmentGate() {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Sécurité"
|
||||
subtitle="Votre rôle donne accès aux rémunérations ou à la distribution des droits : un second facteur est exigé avant d’aller plus loin."
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader title="Second facteur" />
|
||||
<MfaSettings enrolled={false} required />
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MfaSettings } from '@/components/settings/MfaSettings';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { mfaRequired } from '@/domain/access/mfa-policy';
|
||||
import { requireSession } from '@/server/context';
|
||||
|
||||
export const metadata = { title: 'Sécurité · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const session = await requireSession();
|
||||
const required = mfaRequired(session.actor.permissions);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Sécurité"
|
||||
subtitle="Le second facteur protège votre compte, pas celui des autres : chacun gère le sien."
|
||||
actions={
|
||||
session.user.mfaEnrolled ? (
|
||||
<Badge tone="ok">Second facteur actif</Badge>
|
||||
) : (
|
||||
<Badge tone={required ? 'danger' : 'warn'}>
|
||||
{required ? 'Exigé, non activé' : 'Non activé'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Second facteur" />
|
||||
<MfaSettings enrolled={session.user.mfaEnrolled} required={required} />
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { verifyMfaAction, type ChallengeState } from '@/server/auth/actions';
|
||||
|
||||
const empty: ChallengeState = {};
|
||||
|
||||
export function MfaChallengeForm() {
|
||||
const [state, formAction, pending] = useActionState(verifyMfaAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Code
|
||||
</span>
|
||||
<input
|
||||
name="code"
|
||||
// `one-time-code` permet aux gestionnaires de mots de passe de
|
||||
// proposer le code sans le faire recopier.
|
||||
autoComplete="one-time-code"
|
||||
inputMode="text"
|
||||
autoFocus
|
||||
required
|
||||
className="tnum h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">
|
||||
Six chiffres, ou l’un de vos codes de secours si vous n’avez pas votre
|
||||
téléphone.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
Vérifier
|
||||
</Button>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { MfaChallengeForm } from '@/app/(auth)/connexion/verification/MfaChallengeForm';
|
||||
import { pendingChallenge, SESSION_COOKIE } from '@/server/auth/session';
|
||||
|
||||
export const metadata = { title: 'Vérification · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Second facteur, à la connexion.
|
||||
*
|
||||
* La session existe déjà mais ne résout aucun acteur : elle ne porte que ce
|
||||
* défi, et expire d'elle-même en dix minutes.
|
||||
*/
|
||||
export default async function VerificationPage() {
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
const challenge = token ? await pendingChallenge(token) : null;
|
||||
|
||||
// Sans défi en cours, il n'y a rien à vérifier : ou la session est pleine, ou
|
||||
// elle a expiré. Dans les deux cas la page de connexion tranche.
|
||||
if (!challenge) redirect('/connexion');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center bg-canvas p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex items-center gap-2.5">
|
||||
<span aria-hidden className="size-6 rounded-2 bg-accent" />
|
||||
<span className="text-xl font-semibold tracking-[-0.015em]">
|
||||
PlanFlow
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3 border border-line-1 bg-surface p-6 shadow-e1">
|
||||
<h1 className="text-lg font-semibold">Vérification</h1>
|
||||
<p className="mt-1 mb-5 text-sm text-ink-2">
|
||||
Saisissez le code affiché par votre application
|
||||
d’authentification pour {challenge.email}.
|
||||
</p>
|
||||
<MfaChallengeForm />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user