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
@@ -53,8 +53,24 @@ export interface SignInInput {
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durée d'une session en attente du second facteur.
|
||||
*
|
||||
* Elle ne donne accès à rien, mais elle atteste d'un mot de passe juste : lui
|
||||
* laisser douze heures offrirait autant de temps pour éprouver les six chiffres
|
||||
* depuis un poste laissé ouvert.
|
||||
*/
|
||||
const MFA_CHALLENGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export type SignInResult =
|
||||
| { ok: true; token: string; expiresAt: Date; userId: string }
|
||||
| {
|
||||
ok: true;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
userId: string;
|
||||
/** Vrai quand la session attend encore le second facteur. */
|
||||
mfaPending: boolean;
|
||||
}
|
||||
| { ok: false; reason: 'invalid' | 'locked' };
|
||||
|
||||
export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
@@ -94,7 +110,13 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
// Le second facteur est exigé dès qu'il est enregistré, quel que soit le
|
||||
// rôle : un salarié qui a pris la peine de l'activer ne doit pas pouvoir
|
||||
// entrer sans lui.
|
||||
const mfaPending = user.mfaEnrolledAt !== null && user.mfaSecretEnc !== null;
|
||||
const expiresAt = new Date(
|
||||
Date.now() + (mfaPending ? MFA_CHALLENGE_MS : SESSION_DURATION_MS),
|
||||
);
|
||||
|
||||
await db.$transaction([
|
||||
db.session.create({
|
||||
@@ -104,6 +126,7 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
expiresAt,
|
||||
ip: input.ip ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
mfaSatisfied: !mfaPending,
|
||||
},
|
||||
}),
|
||||
db.user.update({
|
||||
@@ -111,12 +134,70 @@ export async function signIn(input: SignInInput): Promise<SignInResult> {
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastSignInAt: new Date(),
|
||||
// La date de dernière connexion n'est posée qu'une fois les deux
|
||||
// facteurs présentés : un mot de passe juste seul n'est pas une
|
||||
// connexion.
|
||||
...(mfaPending ? {} : { lastSignInAt: new Date() }),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { ok: true, token, expiresAt, userId: user.id };
|
||||
return { ok: true, token, expiresAt, userId: user.id, mfaPending };
|
||||
}
|
||||
|
||||
export interface PendingChallenge {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ouverte mais en attente du second facteur.
|
||||
*
|
||||
* Distincte de `resolveSession`, qui refuse ces sessions : l'écran de défi est
|
||||
* le seul endroit où elles ont un sens, et les confondre reviendrait à laisser
|
||||
* une session à demi ouverte circuler dans l'application.
|
||||
*/
|
||||
export async function pendingChallenge(
|
||||
token: string,
|
||||
): Promise<PendingChallenge | null> {
|
||||
const session = await unscoped().session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: { select: { id: true, email: true } } },
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.mfaSatisfied ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
};
|
||||
}
|
||||
|
||||
/** Promeut une session en attente en session pleine. */
|
||||
export async function satisfyMfa(token: string): Promise<Date> {
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
||||
const db = unscoped();
|
||||
|
||||
const session = await db.session.update({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
data: { mfaSatisfied: true, expiresAt },
|
||||
});
|
||||
|
||||
await db.user.update({
|
||||
where: { id: session.userId },
|
||||
data: { lastSignInAt: new Date() },
|
||||
});
|
||||
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
export async function signOut(token: string): Promise<void> {
|
||||
@@ -143,6 +224,8 @@ export interface SessionUser {
|
||||
lastName: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
/** Un second facteur est enregistré pour ce compte. */
|
||||
mfaEnrolled: boolean;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -182,7 +265,15 @@ export async function resolveSession(
|
||||
},
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt || session.expiresAt < new Date()) {
|
||||
// Une session en attente du second facteur ne résout aucun acteur : elle ne
|
||||
// porte qu'un défi. C'est ici, au point de passage unique, que la garantie
|
||||
// tient — pas dans chaque écran.
|
||||
if (
|
||||
!session ||
|
||||
!session.mfaSatisfied ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt < new Date()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -220,6 +311,7 @@ export async function resolveSession(
|
||||
lastName,
|
||||
email,
|
||||
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
|
||||
mfaEnrolled: session.user.mfaEnrolledAt !== null,
|
||||
},
|
||||
accountName: membership.account.name,
|
||||
roleName: membership.role.name,
|
||||
|
||||
Reference in new issue
Block a user