Commit 7c40860b authored by Kourser's avatar Kourser
Browse files

On pouvait entrer sans mot de passe, jamais en poser un neuf

La mire proposait bien « recevoir un lien », et ce lien ouvrait bien une
session. Mais « changer de mot de passe » exigeait l'ancien — celui qu'on a
oublié, par hypothèse, puisqu'on est là. La boucle était fermée : on entrait,
sans rien pouvoir changer. Pour un outil qu'on ouvre une fois par an et le
jour où tout brûle, c'est la panne la plus probable de toutes.

La session retient désormais par quel moyen elle s'est ouverte. Venue d'un
lien à usage unique, elle vaut preuve d'accès à la boîte — la preuve même sur
laquelle repose toute réinitialisation — et l'ancien mot de passe n'est plus
demandé. Le second facteur, lui, reste exigé quand il est actif : un courriel
prouve l'accès à la boîte, pas la possession du téléphone.

Le journal portait déjà ce moyen ; la session, non. On le pose sur elle parce
que c'est d'elle qu'on a besoin au moment de la requête, et qu'un journal ne
se relit pas pour décider.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 8d02d32b
Loading
Loading
Loading
Loading
+35 −6
Changes for apps/api/src/auth/comptes.ts: 35 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -280,6 +280,7 @@ async function ouvrirSession(
  return (await sql.begin(async (tx) => {
    const { jeton, session } = await sessions.ouvrir(tx, compte.id, {
      agent: ctx.agent ?? null, ip: ctx.ip ?? null, secondFacteurFait: false,
      ouvertePar: moyen,
    });
    await tx`update compte set derniere_connexion_at = now() where id = ${compte.id}`;
    await journaliser(tx, {
@@ -411,8 +412,28 @@ export async function activerTotp(
 * volée dans la fenêtre d'installation suffirait à s'approprier le compte qui
 * va devenir root.
 */
/**
 * Changer son mot de passe, y compris quand on l'a oublié (EF-103).
 *
 * L'ancien est exigé — sauf dans deux cas où personne ne peut le connaître, et
 * où l'exiger ne protégerait rien.
 *
 *   Un compte amorcé par lien n'en a jamais reçu : sa session vient d'un jeton
 *   à usage unique envoyé à son adresse.
 *
 *   Un compte dont le titulaire a oublié le sien s'est reconnecté par ce même
 *   lien. C'était la boucle fermée du produit : on pouvait entrer sans mot de
 *   passe, et rien ne permettait d'en poser un neuf. La mire proposait bien
 *   « recevoir un lien », mais cela ne menait qu'à une session — pas à une
 *   sortie.
 *
 * Dans les deux cas la preuve est la même, et c'est celle de toute
 * réinitialisation : l'accès à la boîte. Le second facteur reste exigé quand il
 * est actif — un courriel ne prouve pas la possession du téléphone.
 */
export async function changerMotDePasse(
  sql: Sql, compteId: string, ancien: string | null, neuf: string,
  sessionId?: string,
): Promise<void> {
  const [compte] = await sql<{ mdp_hash: string | null; mdp_a_changer: boolean }[]>`
    select mdp_hash, mdp_a_changer from compte where id = ${compteId}`;
@@ -423,12 +444,20 @@ export async function changerMotDePasse(
    throw new ErreurAuth("operation_refusee", "Douze caractères au moins.");
  }

  // L'ancien mot de passe est exigé — sauf quand il n'y en a pas à connaître.
  // Un compte amorcé par lien n'en a jamais reçu : sa session vient d'un jeton
  // à usage unique envoyé à son adresse, ce qui est la même preuve qu'une
  // réinitialisation. Exiger un secret que personne ne détient n'ajouterait pas
  // de sécurité, seulement une impasse.
  if (!compte.mdp_a_changer) {
  // La session vient-elle d'un lien reçu par courriel ? C'est la preuve d'une
  // réinitialisation, et elle vaut l'ancien mot de passe — que le titulaire ne
  // connaît, par hypothèse, pas.
  let parLien = false;
  if (sessionId) {
    const [s] = await sql<{ ouverte_par: string | null }[]>`
      select ouverte_par from session where id = ${sessionId}`;
    parLien = s?.ouverte_par === "lien_usage_unique";
  }

  // L'ancien est exigé — sauf quand il n'y en a pas à connaître. Exiger un
  // secret que personne ne détient n'ajouterait pas de sécurité, seulement une
  // impasse.
  if (!compte.mdp_a_changer && !parLien) {
    if (ancien === null || !await mdp.verifier(ancien, compte.mdp_hash ?? null)) {
      throw new ErreurAuth("identifiants_invalides", "Mot de passe actuel incorrect.");
    }
+17 −5
Changes for apps/api/src/auth/sessions.ts: 17 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -32,6 +32,8 @@ export interface Session {
  expire_at: Date;
  dernier_acces_at: Date;
  agent: string | null;
  /** « mot_de_passe » ou « lien_usage_unique ». Nul avant que la colonne existe. */
  ouverte_par: string | null;
}

export interface Contexte {
@@ -39,6 +41,14 @@ export interface Contexte {
  ip?: string | null;
  /** Vrai quand le moyen de connexion vaut à lui seul second facteur. */
  secondFacteurFait?: boolean;
  /**
   * Par quel moyen la session s'ouvre.
   *
   * Retenu sur la session et pas seulement au journal : c'est au moment de
   * changer de mot de passe qu'on en a besoin, et le journal ne se relit pas
   * pour décider d'une requête.
   */
  ouvertePar?: "mot_de_passe" | "lien_usage_unique" | null;
  /**
   * L'heure à laquelle le second facteur a réellement été présenté, quand elle
   * n'est pas maintenant.
@@ -69,13 +79,15 @@ export async function ouvrir(
  const maintenant = new Date();
  const [ligne] = await tx<Session[]>`
    insert into session (compte_id, empreinte, auth_at, second_facteur_at,
                         expire_at, dernier_acces_at, agent, ip, via_passage)
                         expire_at, dernier_acces_at, agent, ip, via_passage,
                         ouverte_par)
    values (${compteId}, ${empreinte(jeton)}, ${maintenant},
            ${ctx.secondFacteurAt ?? (ctx.secondFacteurFait ? maintenant : null)},
            ${new Date(maintenant.getTime() + ABSOLUE_MS)}, ${maintenant},
            ${ctx.agent ?? null}, ${ctx.ip ?? null}, ${ctx.viaPassage ?? false})
            ${ctx.agent ?? null}, ${ctx.ip ?? null}, ${ctx.viaPassage ?? false},
            ${ctx.ouvertePar ?? null})
    returning id, compte_id, auth_at, second_facteur_at, expire_at, dernier_acces_at, agent,
              via_passage`;
              via_passage, ouverte_par`;
  if (!ligne) throw new Error("Ouverture de session refusée.");
  return { jeton, session: ligne };
}
@@ -111,7 +123,7 @@ export async function resoudre(sql: Sql, jeton: string): Promise<SessionResolue
       and s.dernier_acces_at > now() - ${`${INACTIVITE_MS / 1000} seconds`}::interval
       and c.desactive_at is null
    returning s.id, s.compte_id, s.auth_at, s.second_facteur_at, s.expire_at,
              s.dernier_acces_at, s.agent, s.via_passage,
              s.dernier_acces_at, s.agent, s.via_passage, s.ouverte_par,
              c.email, c.nom, c.email_verifie, c.desactive_at, c.totp_actif_at`;

  if (!ligne) return null;
@@ -120,7 +132,7 @@ export async function resoudre(sql: Sql, jeton: string): Promise<SessionResolue
      id: ligne.id, compte_id: ligne.compte_id, auth_at: ligne.auth_at,
      second_facteur_at: ligne.second_facteur_at, expire_at: ligne.expire_at,
      dernier_acces_at: ligne.dernier_acces_at, agent: ligne.agent,
      via_passage: ligne.via_passage,
      via_passage: ligne.via_passage, ouverte_par: ligne.ouverte_par,
    },
    compte: {
      id: ligne.compte_id, email: ligne.email, nom: ligne.nom,
+22 −0
Changes for apps/api/src/db/migrations/0046_session_ouverte_par.sql: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Par quel moyen une session a été ouverte (EF-103).
--
-- Le journal le retenait déjà — « compte.connecte » porte son moyen — mais la
-- session, non. Or c'est d'elle qu'on a besoin au moment de changer de mot de
-- passe : un compte dont le titulaire a oublié le sien n'avait aucun chemin.
--
--   Le lien à usage unique ouvre bien une session, et prouve l'accès à la
--   boîte — la preuve même sur laquelle repose toute réinitialisation.
--
--   Mais « changerMotDePasse » exige l'ancien, qu'on ne connaît précisément
--   pas. La boucle était fermée : on entrait, sans pouvoir rien changer.
--
-- Le second facteur reste exigé quand il est actif : un lien reçu par courriel
-- prouve l'accès à la boîte, pas la possession du téléphone.
-- ═══════════════════════════════════════════════════════════════════════════

alter table session add column if not exists ouverte_par text;

comment on column session.ouverte_par is
  'EF-103 : « mot_de_passe » ou « lien_usage_unique ». Nul pour les sessions '
  'ouvertes avant que la colonne existe.';
+5 −1
Changes for apps/api/src/routes/auth.ts: 5 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -240,7 +240,8 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
      ancien: z.string().min(1).nullish(), nouveau: z.string().min(12).max(200),
    }).parse(req.body);
    const auth = req.auth!;
    await comptes.changerMotDePasse(sql, auth.compte.id, c.ancien ?? null, c.nouveau);
    await comptes.changerMotDePasse(sql, auth.compte.id, c.ancien ?? null, c.nouveau,
      auth.session.id);
    // En production, le droit root est accordé à l'activation du second
    // facteur, et cet appel ne fait rien : l'amorce n'est pas encore achevée.
    // En mode test, où le second facteur n'est plus exigé, le remplacement du
@@ -317,6 +318,9 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
        id: auth.session.id,
        complete: auth.complete,
        fraicheurAdmin: sessions.fraicheurAdmin(auth),
        // L'écran a besoin de savoir s'il doit demander l'ancien mot de passe :
        // qui vient d'un lien ne le connaît pas, et le champ serait une impasse.
        parLien: auth.session.ouverte_par === "lien_usage_unique",
      },
      organisations,
      amorce: etatAmorce,
+29 −0
Changes for apps/api/src/verification/auth.ts: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -177,6 +177,35 @@ export async function verifierAuthentification(
    await r.refuse("EF-103", "le lien ne fonctionne pas une seconde fois",
      () => comptes.consommerLienConnexion(sql, jetonLien!), "jeton_invalide");

    // ── Le mot de passe oublié (EF-103) ──────────────────────────────────
    //
    // La boucle était fermée : la mire proposait bien un lien, il ouvrait bien
    // une session — et « changerMotDePasse » exigeait l'ancien, que le
    // titulaire ne connaît par hypothèse pas. On entrait sans pouvoir sortir,
    // et aucun écran ne portait même de formulaire.
    //
    // La preuve est celle de toute réinitialisation : l'accès à la boîte. Elle
    // vaut l'ancien mot de passe, et rien de plus — le second facteur reste
    // exigé, un courriel ne prouvant pas la possession du téléphone.
    const neufMdp = "phare-du-creach-1884";
    await r.refuse("EF-103", "sur une session ouverte au mot de passe, l'ancien reste exigé",
      () => comptes.changerMotDePasse(sql, inscription.compteId, null, neufMdp,
        ouverture.sessionId), "identifiants_invalides");
    await comptes.changerMotDePasse(sql, inscription.compteId, null, neufMdp,
      parLien.sessionId);
    r.verifie("EF-103", "sur une session ouverte par lien, il ne l'est plus",
      (await comptes.connecterParMotDePasse(sql,
        { email, motDePasse: neufMdp })).sessionId.length > 0);
    await r.refuse("EF-103", "et l'ancien mot de passe ne connecte plus",
      () => comptes.connecterParMotDePasse(sql,
        { email, motDePasse: "houle-atlantique-38" }),
      "identifiants_invalides");
    // La session retient son moyen : sans cela, rien ne distingue les deux cas.
    const [ouvertePar] = await sql<{ ouverte_par: string | null }[]>`
      select ouverte_par from session where id = ${parLien.sessionId}`;
    r.verifie("EF-103", "la session retient par quel moyen elle a été ouverte",
      ouvertePar?.ouverte_par === "lien_usage_unique");

    vider();
    await comptes.demanderLienConnexion(sql, adresse("fantome"), URL_TEST);
    r.verifie("EF-106", "aucun message n'est émis pour une adresse inconnue, sans le dire",
Loading