Commit 1ae686e4 authored by Kourser's avatar Kourser
Browse files

Un administrateur pouvait vous renommer, vous non

L'écran des membres corrigeait le nom et l'adresse de quiconque ; l'intéressé
n'avait aucun chemin pour les siens. L'asymétrie n'avait pas de justification —
un nom mal orthographié, une adresse qui change, cela se corrige soi-même.

── L'adresse est traitée plus durement ici que côté administrateur

Là, c'est un tiers qui agit sur un compte : le courriel envoyé à l'ancienne
adresse suffit à ce que la personne l'apprenne, puisqu'elle le reçoit là où
l'auteur du changement n'a pas la main.

Ici, la demande vient de la session elle-même. Volée, elle changerait l'adresse
avant que quiconque le sache, et le courriel d'avertissement arriverait après
que le compte a changé de main. Le mot de passe est donc exigé : c'est la seule
chose qu'un voleur de session n'a pas.

Le reste ne change pas — l'ancienne adresse prévenue, la nouvelle non vérifiée
avec son lien, le journal qui retient les deux. Ce qui distingue les deux
chemins n'est pas le type d'événement mais l'acteur : ici, le titulaire.

── Une page qui avait deux natures et n'en déclarait qu'une

Préférences annonçait « Rien n'est envoyé au serveur ». La phrase serait
devenue fausse. L'écran est en deux parties nommées : « Votre identité », qui
vit sur le serveur et suit le compte d'un appareil à l'autre, et « Cet
appareil », où la langue, le thème et le son restent dans le navigateur. Le
préambule qui parlait de « ces trois réglages » est descendu sous le titre
qu'il décrit.

── Une clé de traduction en double

Rattrapée par le typage seul : pnpm verif passait, le build web passait. Une
entrée en double signifie que l'une des deux ne sert jamais, sans que rien ne
le dise.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 995fc299
Loading
Loading
Loading
Loading
Loading
+102 −0
Changes for apps/api/src/auth/comptes.ts: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -666,6 +666,108 @@ export async function changerRoleMembre(
}


/**
 * Corriger sa propre identité (EF-107).
 *
 * Un administrateur pouvait renommer quelqu'un ; l'intéressé, non. L'asymétrie
 * n'a aucune justification — un nom mal orthographié, une adresse qui change,
 * cela se corrige soi-même.
 *
 * L'adresse est traitée à part, et plus durement que côté administrateur. Là,
 * c'est un tiers qui agit sur un compte, et le journal plus le courriel à
 * l'ancienne adresse suffisent à ce que la personne l'apprenne. Ici, la
 * demande vient de la session elle-même : si elle a été volée, prévenir
 * l'ancienne adresse arrive trop tard. Le mot de passe est donc exigé — c'est
 * la seule chose qu'un voleur de session n'a pas.
 */

export async function changerSonNom(
  sql: Sql, compteId: string, nom: string,
): Promise<{ de: string; vers: string }> {
  const propre = nom.trim();
  if (propre.length < 2) {
    throw new ErreurAuth("operation_refusee", "Un nom fait au moins deux caractères.");
  }
  const [avant] = await sql<{ nom: string }[]>`
    select nom from compte where id = ${compteId}`;
  if (!avant) throw new ErreurAuth("introuvable", "Compte introuvable.");
  if (avant.nom === propre) throw new ErreurAuth("sans_effet", "C'est déjà votre nom.");

  await sql.begin(async (tx) => {
    await tx`update compte set nom = ${propre} where id = ${compteId}`;
    await journaliser(tx, {
      type: "compte.renomme", acteurId: compteId,
      charge: { compte_id: compteId, de: avant.nom, vers: propre },
    });
  });
  return { de: avant.nom, vers: propre };
}

export async function changerSonEmail(
  sql: Sql, d: { compteId: string; email: string; motDePasse: string; urlPublique: string },
): Promise<{ de: string; vers: string; prevenu: boolean; confirmation: boolean }> {
  const [compte] = await sql<{ email: string; nom: string; mdp_hash: string | null }[]>`
    select email, nom, mdp_hash from compte where id = ${d.compteId}`;
  if (!compte) throw new ErreurAuth("introuvable", "Compte introuvable.");
  // Le mot de passe, et pas seulement la session : c'est ce qui distingue le
  // titulaire de qui aurait mis la main sur son navigateur.
  if (!await mdp.verifier(d.motDePasse, compte.mdp_hash)) {
    throw new ErreurAuth("identifiants_invalides", "Mot de passe incorrect.");
  }

  const email = normaliserEmail(d.email);
  if (compte.email === email) {
    throw new ErreurAuth("sans_effet", "C'est déjà votre adresse.");
  }
  const [pris] = await sql`
    select 1 from compte where email = ${email} and id <> ${d.compteId}`;
  if (pris) {
    throw new ErreurAuth("email_deja_pris",
      "Un compte porte déjà cette adresse. Deux comptes ne peuvent pas la partager : "
      + "c'est par elle qu'on se connecte.");
  }

  const jeton = engendrer();
  await sql.begin(async (tx) => {
    await tx`
      update compte set email = ${email}, email_verifie = false where id = ${d.compteId}`;
    await tx`
      insert into jeton_usage_unique (empreinte, genre, compte_id, expire_at)
      values (${empreinte(jeton)}, 'verification_email', ${d.compteId},
              ${new Date(Date.now() + TTL_VERIFICATION_MS)})`;
    await journaliser(tx, {
      type: "compte.email_change", acteurId: d.compteId,
      charge: { compte_id: d.compteId, de: compte.email, vers: email,
        motif: "Changée par le titulaire du compte." },
    });
  });

  const partie = async (m: Parameters<typeof envoyer>[0]) =>
    envoyer(m).then(() => true).catch(() => false);

  const prevenu = await partie({
    destinataire: compte.email,
    sujet: "Votre adresse de connexion a changé — Kastell",
    texte:
      `Bonjour ${compte.nom},\n\n`
      + `L'adresse qui ouvre votre compte Kastell est désormais ${email}.\n\n`
      + `Cette adresse-ci ne connecte plus. Si vous n'êtes pas à l'origine de ce\n`
      + `changement, quelqu'un a la main sur votre compte : changez votre mot de\n`
      + `passe et prévenez la direction de votre organisation.\n`,
  });
  const confirmation = await partie({
    destinataire: email,
    sujet: "Confirmez votre nouvelle adresse — Kastell",
    texte:
      `Bonjour ${compte.nom},\n\n`
      + `Cette adresse ouvre désormais votre compte Kastell. Confirmez-la :\n`
      + `${d.urlPublique}/verification?jeton=${jeton}\n\n`
      + `Ce lien expire dans 24 heures.\n`,
  });

  return { de: compte.email, vers: email, prevenu, confirmation };
}

/**
 * Corriger l'identité d'un membre : son nom, son adresse.
 *
+34 −0
Changes for apps/api/src/routes/auth.ts: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -256,6 +256,40 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
  });

  // ── Session courante (EF-109, EF-110) ────────────────────────────────────
  /** Corriger son propre nom. Bénin, journalisé. */
  app.post("/auth/moi/nom", { preHandler: requerirSession() }, async (req, reply) => {
    const c = z.object({
      nom: z.string().trim().min(2, "Un nom fait au moins deux caractères.").max(120),
    }).parse(req.body);
    const r = await comptes.changerSonNom(sql, req.auth!.compte.id, c.nom);
    return reply.send({ message: `Vous vous appelez désormais « ${r.vers} ».` });
  });

  /**
   * Corriger sa propre adresse.
   *
   * Le mot de passe est exigé et non seulement la session : si la session a été
   * volée, prévenir l'ancienne adresse arriverait après que le compte a changé
   * de main. Le mot de passe est ce que le voleur n'a pas.
   */
  app.post("/auth/moi/email", { preHandler: requerirSession() }, async (req, reply) => {
    const c = z.object({
      email: z.string().email("Une adresse électronique complète."),
      motDePasse: z.string().min(1, "Votre mot de passe est demandé."),
    }).parse(req.body);
    const r = await comptes.changerSonEmail(sql, {
      compteId: req.auth!.compte.id, email: c.email, motDePasse: c.motDePasse,
      urlPublique: env.KASTELL_PUBLIC_URL,
    });
    return reply.send({
      message: `Votre adresse devient « ${r.vers} ». `
        + (r.prevenu ? "L'ancienne en a été prévenue. " : "L'ancienne n'a pas pu être prévenue. ")
        + (r.confirmation
          ? "Un lien de confirmation vous attend à la nouvelle."
          : "Le lien de confirmation n'a pas pu partir : l'adresse restera non vérifiée."),
    });
  });

  app.get("/auth/moi", { preHandler: requerirSession(false) }, async (req, reply) => {
    const auth = req.auth!;
    const [organisations, codesRestants, root] = await Promise.all([
+76 −0
Changes for apps/api/src/verification/renforts.ts: 76 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -529,6 +529,82 @@ export async function verifierRenforts(
      nom: "Usurpé", parCompteId: corrige.compteId,
    }), "operation_refusee");

  // ═══════════════════════════════════════════════════════════════════════
  r.titre("Corriger sa propre identité");

  /**
   * L'asymétrie que ceci lève.
   *
   * Un administrateur pouvait renommer quelqu'un ; l'intéressé, non. Un nom mal
   * orthographié, une adresse qui change, cela se corrige soi-même.
   *
   * L'adresse est traitée plus durement ici que côté administrateur, et c'est
   * voulu : là, c'est un tiers qui agit, et le courriel à l'ancienne adresse
   * suffit à ce que la personne l'apprenne. Ici la demande vient de la session
   * elle-même — volée, elle changerait l'adresse avant que quiconque le sache.
   * Le mot de passe est ce que le voleur n'a pas.
   */
  const seul = await comptes.inscrire(sql, {
    email: adresse("seul"), nom: "Loeiza Cadiou", motDePasse,
    nomOrganisation: "Libre service", urlPublique: URL_TEST,
  });

  const soiMeme = await comptes.changerSonNom(sql, seul.compteId, "Loeiza Cadiou-Kervella");
  r.verifie("EF-107", "on corrige son propre nom, sans passer par un administrateur",
    soiMeme.vers === "Loeiza Cadiou-Kervella");
  await r.refuse("EF-107", "et le corriger à l'identique est refusé",
    () => comptes.changerSonNom(sql, seul.compteId, "Loeiza Cadiou-Kervella"),
    "sans_effet");

  // ── L'adresse : le mot de passe, et pas seulement la session ─────────────
  vider();
  const sienne = (await sql<{ email: string }[]>`
    select email from compte where id = ${seul.compteId}`)[0]!.email;
  const voulue = adresse("voulue");
  await r.refuse("EF-107", "changer d'adresse sans son mot de passe est refusé",
    () => comptes.changerSonEmail(sql, {
      compteId: seul.compteId, email: voulue, motDePasse: "ce-n-est-pas-le-bon",
      urlPublique: URL_TEST,
    }), "identifiants_invalides");
  r.verifie("EF-107", "et le refus n'a rien changé",
    (await sql<{ email: string }[]>`
      select email from compte where id = ${seul.compteId}`)[0]!.email === sienne);

  const sonEmail = await comptes.changerSonEmail(sql, {
    compteId: seul.compteId, email: voulue, motDePasse, urlPublique: URL_TEST,
  });
  r.verifie("EF-107", "avec le mot de passe, l'adresse change",
    sonEmail.vers === voulue.toLowerCase());
  const ouvre = await comptes.connecterParMotDePasse(sql,
    { email: voulue, motDePasse });
  r.verifie("EF-107", "la nouvelle ouvre le compte", ouvre.jeton.length > 0);
  await r.refuse("EF-107", "et l'ancienne ne l'ouvre plus",
    () => comptes.connecterParMotDePasse(sql, { email: sienne, motDePasse }),
    "identifiants_invalides");
  r.verifie("EF-107", "l'ancienne est prévenue, comme lorsqu'un tiers la change",
    boite.some((m) => m.destinataire === sienne && /ne connecte plus/.test(m.texte)));
  r.verifie("EF-107", "et un lien de confirmation attend à la nouvelle",
    jetonDans("/verification", voulue.toLowerCase()) !== null);

  // Le journal ne distingue pas qui a agi par le type d'événement mais par
  // l'acteur : ici, le titulaire lui-même.
  const [sonJournal] = await sql<{ acteur_id: string; charge: { motif: string } }[]>`
    select acteur_id, charge from evenement_administration
     where type = 'compte.email_change' and charge->>'compte_id' = ${seul.compteId}
     order by seq desc limit 1`;
  r.verifie("EF-107", "et le journal dit que le titulaire a agi lui-même",
    sonJournal?.acteur_id === seul.compteId
      && /titulaire/.test(sonJournal.charge.motif));

  // ── Les deux routes exigent une session ──────────────────────────────────
  for (const chemin of ["nom", "email"]) {
    const nu = await app.inject({
      method: "POST", url: `/api/auth/moi/${chemin}`, payload: { nom: "X", email: "a@b.c" },
    });
    r.verifie("EF-107", `« /auth/moi/${chemin} » refuse une requête sans session`,
      nu.statusCode === 401, `${nu.statusCode}`);
  }

  // ═══════════════════════════════════════════════════════════════════════
  r.titre("Sortir quelqu'un d'une cellule");

+94 −8
Changes for apps/web/src/ecrans/Preferences.tsx: 94 added lines, 8 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { useEtat } from "../etat.js";
import { ChoixLangue, useT } from "../langues/index.js";
import { ChoixTheme } from "../theme.js";
import { definirMuet, muet } from "../signal.js";
@@ -14,14 +17,43 @@ import { definirMuet, muet } from "../signal.js";
 * Ils ne quittent pas la crise pour autant : la coque de crise sert cette même
 * page. Aller changer de thème ne doit pas faire sortir de la main courante.
 *
 * Rien n’est envoyé au serveur. La langue et le thème vivent dans le stockage
 * du navigateur, le son aussi : ce sont des préférences d’appareil, et le même
 * compte ouvert sur le poste de la salle de crise et sur un téléphone n’a pas
 * les mêmes besoins.
 * Deux natures sur une page, et l’écran le dit plutôt que de les mêler.
 *
 * Votre identité — le nom qui accompagne chaque entrée que vous consignez,
 * l’adresse par laquelle vous vous connectez — vit sur le serveur et vous suit
 * partout. Un administrateur pouvait la corriger et vous non, ce qui n’avait
 * aucune justification.
 *
 * La langue, le thème et le son vivent dans le stockage du navigateur : ce sont
 * des préférences d’appareil, et le même compte ouvert sur le poste de la salle
 * de crise et sur un téléphone n’a pas les mêmes besoins.
 */
export function Preferences() {
  const t = useT();
  const client = useQueryClient();
  const { moi } = useEtat();
  const [silence, definirSilence] = useState(muet);
  const [nom, definirNom] = useState(moi?.compte.nom ?? "");
  const [email, definirEmail] = useState(moi?.compte.email ?? "");
  const [motDePasse, definirMotDePasse] = useState("");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [message, definirMessage] = useState<string | null>(null);

  const nomChange = nom.trim() !== (moi?.compte.nom ?? "") && nom.trim().length >= 2;
  const emailChange = email.trim().toLowerCase() !== (moi?.compte.email ?? "").toLowerCase()
    && email.trim().length > 3;

  async function enregistrer(chemin: string, corps: unknown) {
    definirErreur(null); definirMessage(null);
    try {
      const r = await api.poster<{ message: string }>(`/api/auth/moi/${chemin}`, corps);
      definirMessage(r.message);
      definirMotDePasse("");
      await client.invalidateQueries({ queryKey: ["moi"] });
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : t("Enregistrement refusé."));
    }
  }

  return (
    <div className="scene">
@@ -31,9 +63,64 @@ export function Preferences() {
      </header>

      <div className="contenu">
        <p className="aide mesure" style={{ marginBottom: 20 }}>
          {t("Ces trois "
                                                                             + "réglages "

        <div className="pile" style={{ maxWidth: 640, marginBottom: 24 }}>
          {erreur && <div className="erreur">{erreur}</div>}
          {message && <div className="succes">{message}</div>}

          <div className="carte">
            <span className="etiq">{t("Votre identité")}</span>
            <p className="aide" style={{ marginTop: 6 }}>
              {t("Elle vit sur le serveur et vous suit d’un appareil à l’autre. Votre "
                + "nom accompagne chaque entrée que vous consignez.")}
            </p>
            <div className="champ" style={{ marginTop: 10 }}>
              <label htmlFor="moi-nom">{t("Nom")}</label>
              <input id="moi-nom" maxLength={120} value={nom}
                onChange={(e) => definirNom(e.target.value)} />
            </div>
            <button className="accent petit" disabled={!nomChange}
              onClick={() => void enregistrer("nom", { nom: nom.trim() })}>
              {t("Enregistrer le nom")}
            </button>

            <div className="champ" style={{ marginTop: 16 }}>
              <label htmlFor="moi-email">{t("Adresse électronique")}</label>
              <input id="moi-email" type="email" value={email}
                onChange={(e) => definirEmail(e.target.value)} />
              <div className="aide">
                {t("C’est par elle que vous vous connectez. L’ancienne cessera de "
                  + "l’ouvrir et en sera prévenue ; la nouvelle recevra un lien de "
                  + "confirmation.")}
              </div>
            </div>
            {/* Le mot de passe n'est demandé que si l'adresse bouge : c'est ce
                qu'un voleur de session n'a pas, et le demander pour un nom ne
                protégerait rien. */}
            {emailChange && (
              <div className="champ">
                <label htmlFor="moi-mdp">{t("Votre mot de passe")}</label>
                <input id="moi-mdp" type="password" value={motDePasse}
                  autoComplete="current-password"
                  onChange={(e) => definirMotDePasse(e.target.value)} />
                <div className="aide">
                  {t("Demandé parce que changer d’adresse change la façon dont on "
                    + "entre dans votre compte.")}
                </div>
              </div>
            )}
            <button className="accent petit"
              disabled={!emailChange || motDePasse.length === 0}
              onClick={() => void enregistrer("email",
                { email: email.trim(), motDePasse })}>
              {t("Enregistrer l’adresse")}
            </button>
          </div>
        </div>

        <span className="etiq">{t("Cet appareil")}</span>
        <p className="aide mesure" style={{ margin: "6px 0 16px" }}>
          {t("Les réglages ci-dessous "
                                                                             + "valent pour "
                                                                             + "ce navigateur "
                                                                             + "et pour lui "
@@ -51,7 +138,6 @@ export function Preferences() {
                                                                             + "mêmes "
                                                                             + "besoins.")}
        </p>

        <div className="pile" style={{ maxWidth: 640 }}>
          <div className="carte">
            <div className="ligne">
+13 −0
Changes for apps/web/src/langues/en.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2453,4 +2453,17 @@ export const EN: Record<string, string> = {
    "This is the sign-in identifier. Changing it cuts off the old address: it will be told, and the new one will get a confirmation link.",
  "Pourquoi changer l’adresse ?": "Why change the address?",
  "par ex. changement de domaine": "e.g. domain change",
  "Votre identité": "Your identity",
  "Elle vit sur le serveur et vous suit d’un appareil à l’autre. Votre nom accompagne chaque entrée que vous consignez.":
    "It lives on the server and follows you from one device to the next. Your name accompanies every entry you log.",
  "Enregistrer le nom": "Save the name",
  "C’est par elle que vous vous connectez. L’ancienne cessera de l’ouvrir et en sera prévenue ; la nouvelle recevra un lien de confirmation.":
    "This is how you sign in. The old address will stop opening the account and will be told; the new one will get a confirmation link.",
  "Votre mot de passe": "Your password",
  "Demandé parce que changer d’adresse change la façon dont on entre dans votre compte.":
    "Asked because changing the address changes how your account is entered.",
  "Enregistrer l’adresse": "Save the address",
  "Cet appareil": "This device",
  "Les réglages ci-dessous valent pour ce navigateur et pour lui seul. Ils ne suivent pas votre compte d’un appareil à l’autre — le poste de la salle de crise et votre téléphone n’ont pas les mêmes besoins.":
    "The settings below apply to this browser and to it alone. They do not follow your account from one device to the next — the crisis-room workstation and your phone do not have the same needs.",
};