feat(inscription): route libre-service derrière une clé, noms d'équipe non uniquesUnverified
Ajoute GET|POST /inscription — identifiant, mot de passe, équipe initiale, clé — qui n'existe QUE si SIGNUP_KEY est posée (404 en GET comme en POST sinon). La clé se compare en temps constant et rien ne touche la base tant qu'elle est fausse. L'inscription connecte directement, sans e-mail ni confirmation. Lève l'unicité globale de teams.name (migration 0012) : deux foyers sans lien peuvent avoir chacun une « Maison ». db::teams::resolve accepte « nom ou id » et rend One / Ambiguous / None ; exego user add liste les homonymes (id + membres) et demande laquelle, --team accepte aussi un id. Le champ « équipe initiale » rejoint un nom unique, crée un nom libre, refuse un nom ambigu. Deux quotas glissants par IP en mémoire (src/ratelimit.rs, jamais en base) : 20 comptes créés par heure et 10 clés fausses par jour, ce dernier consulté avant la comparaison de clé pour freiner vraiment la force brute. IP tirée de X-Forwarded-For puis Forwarded, sinon de la socket.
a2acfa7 parent: e824891 modified
a11y/audit.mjs +5 -2 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. |
| 2 | 2 | // |
| 3 | -// Parcourt les 13 écrans de l'app (viewport mobile 390×844) et échoue | |
| 3 | +// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue | |
| 4 | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | 5 | // les violations moindres sont listées en avertissement. |
| 6 | 6 | // |
| @@ -71,6 +71,9 @@ const ids = await page.evaluate(async () => { | ||
| 71 | 71 | |
| 72 | 72 | const pages = [ |
| 73 | 73 | { name: 'login', url: '/login', anon: true }, |
| 74 | + // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec | |
| 75 | + // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription. | |
| 76 | + { name: 'signup', url: '/inscription', anon: true }, | |
| 74 | 77 | { name: 'add-choice', url: '/temps/nouveau' }, |
| 75 | 78 | { name: 'add-step1', url: '/temps/nouveau?mode=manuel' }, |
| 76 | 79 | ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` }, |
| @@ -84,7 +87,7 @@ const pages = [ | ||
| 84 | 87 | { name: 'todo-new', url: '/taches/nouvelle' }, |
| 85 | 88 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, |
| 86 | 89 | ].filter(Boolean); |
| 87 | -if (pages.length < 13) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | |
| 90 | +if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | |
| 88 | 91 | |
| 89 | 92 | let blocking = 0; |
| 90 | 93 | for (const p of pages) { |
| @@ -1,6 +1,6 @@ | |||
| 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. | 1 | // Audit d'accessibilité récurrent — axe-core dans Chrome headless. |
| 2 | // | 2 | // |
| 3 | -// Parcourt les 13 écrans de l'app (viewport mobile 390×844) et échoue | 3 | +// Parcourt les 14 écrans de l'app (viewport mobile 390×844) et échoue |
| 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; | 4 | // (exit 1) à la moindre violation axe « critical » ou « serious » ; |
| 5 | // les violations moindres sont listées en avertissement. | 5 | // les violations moindres sont listées en avertissement. |
| 6 | // | 6 | // |
| @@ -71,6 +71,9 @@ const ids = await page.evaluate(async () => { | |||
| 71 | 71 | ||
| 72 | const pages = [ | 72 | const pages = [ |
| 73 | { name: 'login', url: '/login', anon: true }, | 73 | { name: 'login', url: '/login', anon: true }, |
| 74 | + // 404 si SIGNUP_KEY n'est pas posée sur le serveur audité — lancer avec | ||
| 75 | + // SIGNUP_KEY=… pour auditer vraiment l'écran d'inscription. | ||
| 76 | + { name: 'signup', url: '/inscription', anon: true }, | ||
| 74 | { name: 'add-choice', url: '/temps/nouveau' }, | 77 | { name: 'add-choice', url: '/temps/nouveau' }, |
| 75 | { name: 'add-step1', url: '/temps/nouveau?mode=manuel' }, | 78 | { name: 'add-step1', url: '/temps/nouveau?mode=manuel' }, |
| 76 | ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` }, | 79 | ids.categorie && { name: 'add-step2', url: `/temps/nouveau?categorie=${ids.categorie}` }, |
| @@ -84,7 +87,7 @@ const pages = [ | |||
| 84 | { name: 'todo-new', url: '/taches/nouvelle' }, | 87 | { name: 'todo-new', url: '/taches/nouvelle' }, |
| 85 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, | 88 | ids.todo && { name: 'todo-done', url: `/taches/${ids.todo}/terminer` }, |
| 86 | ].filter(Boolean); | 89 | ].filter(Boolean); |
| 87 | -if (pages.length < 13) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); | 90 | +if (pages.length < 14) console.warn('⚠ catégorie, saisie ou todo manquante : certains écrans sont sautés.'); |
| 88 | 91 | ||
| 89 | let blocking = 0; | 92 | let blocking = 0; |
| 90 | for (const p of pages) { | 93 | for (const p of pages) { |
modified
assets/style.css +8 -0 | @@ -408,6 +408,14 @@ a.team-pill:hover { filter: brightness(.96); } | ||
| 408 | 408 | .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; } |
| 409 | 409 | .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; } |
| 410 | 410 | .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); } |
| 411 | +/* Aide sous un champ (inscription) — .7 est le plancher AA du projet. */ | |
| 412 | +.field-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; } | |
| 413 | +/* L'inscription porte trois champs et deux aides là où la connexion en a | |
| 414 | + deux : logo et titre rétrécissent pour que le formulaire reste visible sans | |
| 415 | + défiler sur un petit écran. */ | |
| 416 | +.signup .login-logo { width: 88px; height: 88px; margin-bottom: var(--space-4); } | |
| 417 | +.signup h1 { font-size: 34px; } | |
| 418 | +.signup .tagline { font-size: 16px; margin-bottom: var(--space-6); } | |
| 411 | 419 | |
| 412 | 420 | /* — ajout étape 1 : liste de catégories — */ |
| 413 | 421 | .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); } |
| @@ -408,6 +408,14 @@ a.team-pill:hover { filter: brightness(.96); } | |||
| 408 | .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; } | 408 | .login h1 { font-size: 48px; margin: 0 0 var(--space-2); letter-spacing: -0.02em; text-align: center; } |
| 409 | .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; } | 409 | .login .tagline { font-size: 20px; opacity: .7; margin: 0 auto var(--space-8); max-width: 20em; text-align: center; } |
| 410 | .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); } | 410 | .form-error { font-size: 13px; color: var(--color-accent-700); margin: 0 0 var(--space-3); } |
| 411 | +/* Aide sous un champ (inscription) — .7 est le plancher AA du projet. */ | ||
| 412 | +.field-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); text-wrap: pretty; } | ||
| 413 | +/* L'inscription porte trois champs et deux aides là où la connexion en a | ||
| 414 | + deux : logo et titre rétrécissent pour que le formulaire reste visible sans | ||
| 415 | + défiler sur un petit écran. */ | ||
| 416 | +.signup .login-logo { width: 88px; height: 88px; margin-bottom: var(--space-4); } | ||
| 417 | +.signup h1 { font-size: 34px; } | ||
| 418 | +.signup .tagline { font-size: 16px; margin-bottom: var(--space-6); } | ||
| 411 | 419 | ||
| 412 | /* — ajout étape 1 : liste de catégories — */ | 420 | /* — ajout étape 1 : liste de catégories — */ |
| 413 | .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); } | 421 | .step-hint { font-size: 13px; opacity: .7; margin: 0 0 var(--space-4); } |
added
migrations/0012_teams_name_not_unique.sql +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +-- Les noms d'équipe ne sont plus uniques GLOBALEMENT : deux foyers sans lien | |
| 2 | +-- peuvent chacun avoir une équipe « Maison ». La désambiguïsation se fait | |
| 3 | +-- désormais par identifiant — `exego user add --team` liste les homonymes et | |
| 4 | +-- demande, le champ « équipe initiale » de l'inscription accepte un id. | |
| 5 | +-- | |
| 6 | +-- Le nom de contrainte est cherché plutôt qu'écrit en dur : les bases créées | |
| 7 | +-- par 0001 le nomment `teams_name_key`, mais rien ne le garantit. | |
| 8 | +DO $$ | |
| 9 | +DECLARE c text; | |
| 10 | +BEGIN | |
| 11 | + SELECT conname INTO c | |
| 12 | + FROM pg_constraint | |
| 13 | + WHERE conrelid = 'teams'::regclass | |
| 14 | + AND contype = 'u' | |
| 15 | + AND pg_get_constraintdef(oid) = 'UNIQUE (name)'; | |
| 16 | + IF c IS NOT NULL THEN | |
| 17 | + EXECUTE format('ALTER TABLE teams DROP CONSTRAINT %I', c); | |
| 18 | + END IF; | |
| 19 | +END $$; | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +-- Les noms d'équipe ne sont plus uniques GLOBALEMENT : deux foyers sans lien | ||
| 2 | +-- peuvent chacun avoir une équipe « Maison ». La désambiguïsation se fait | ||
| 3 | +-- désormais par identifiant — `exego user add --team` liste les homonymes et | ||
| 4 | +-- demande, le champ « équipe initiale » de l'inscription accepte un id. | ||
| 5 | +-- | ||
| 6 | +-- Le nom de contrainte est cherché plutôt qu'écrit en dur : les bases créées | ||
| 7 | +-- par 0001 le nomment `teams_name_key`, mais rien ne le garantit. | ||
| 8 | +DO $$ | ||
| 9 | +DECLARE c text; | ||
| 10 | +BEGIN | ||
| 11 | + SELECT conname INTO c | ||
| 12 | + FROM pg_constraint | ||
| 13 | + WHERE conrelid = 'teams'::regclass | ||
| 14 | + AND contype = 'u' | ||
| 15 | + AND pg_get_constraintdef(oid) = 'UNIQUE (name)'; | ||
| 16 | + IF c IS NOT NULL THEN | ||
| 17 | + EXECUTE format('ALTER TABLE teams DROP CONSTRAINT %I', c); | ||
| 18 | + END IF; | ||
| 19 | +END $$; | ||
modified
src/cli.rs +76 -18 | @@ -1,3 +1,5 @@ | ||
| 1 | +use std::io::Write; | |
| 2 | + | |
| 1 | 3 | use anyhow::{Context, Result, bail}; |
| 2 | 4 | use biscuit_auth::KeyPair; |
| 3 | 5 | use sqlx::PgPool; |
| @@ -6,6 +8,7 @@ use uuid::Uuid; | ||
| 6 | 8 | |
| 7 | 9 | use crate::auth::password; |
| 8 | 10 | use crate::config; |
| 11 | +use crate::db::teams::{Resolved, TeamMatch}; | |
| 9 | 12 | use crate::db::{teams, users}; |
| 10 | 13 | |
| 11 | 14 | /// Génère un keypair racine et l'affiche (à faire une fois, en local). |
| @@ -32,15 +35,71 @@ pub async fn team_add(name: &str) -> Result<()> { | ||
| 32 | 35 | bail!("le nom d'équipe ne peut pas être vide"); |
| 33 | 36 | } |
| 34 | 37 | let pool = connect().await?; |
| 35 | - match teams::create(&pool, name).await { | |
| 36 | - Ok(id) => { | |
| 37 | - println!("Équipe « {name} » créée ({id})."); | |
| 38 | - Ok(()) | |
| 38 | + // Les homonymes sont permis depuis la 0012 : on prévient sans refuser, | |
| 39 | + // et l'identifiant affiché sert à désigner celle-ci sans ambiguïté. | |
| 40 | + let homonyms = teams::find_all_by_name(&pool, name).await?; | |
| 41 | + let id = teams::create(&pool, name).await?; | |
| 42 | + println!("Équipe « {name} » créée ({id})."); | |
| 43 | + if !homonyms.is_empty() { | |
| 44 | + println!( | |
| 45 | + "⚠ {} autre(s) équipe(s) portent déjà ce nom — désigne celle-ci par son identifiant :", | |
| 46 | + homonyms.len() | |
| 47 | + ); | |
| 48 | + for t in &homonyms { | |
| 49 | + println!(" {}", t.describe()); | |
| 39 | 50 | } |
| 40 | - Err(sqlx::Error::Database(db)) if db.is_unique_violation() => { | |
| 41 | - bail!("l'équipe « {name} » existe déjà") | |
| 51 | + } | |
| 52 | + Ok(()) | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Résout un `--team` (nom ou identifiant) en UNE équipe. Sur homonymes, | |
| 56 | +/// liste et demande. Rend le `TeamMatch` pour que l'appelant puisse nommer | |
| 57 | +/// l'équipe retenue plutôt que de répéter la saisie. | |
| 58 | +async fn pick_team(pool: &sqlx::PgPool, input: &str) -> Result<TeamMatch> { | |
| 59 | + match teams::resolve(pool, input).await? { | |
| 60 | + Resolved::One(t) => Ok(t), | |
| 61 | + Resolved::None { was_id: true } => { | |
| 62 | + bail!("aucune équipe n'a l'identifiant « {input} »") | |
| 42 | 63 | } |
| 43 | - Err(e) => Err(e.into()), | |
| 64 | + Resolved::None { was_id: false } => { | |
| 65 | + bail!("l'équipe « {input} » n'existe pas (exego team add)") | |
| 66 | + } | |
| 67 | + Resolved::Ambiguous(matches) => choose(matches, input), | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// Menu numéroté des homonymes. Accepte le rang ou l'identifiant complet. | |
| 72 | +/// Une entrée fermée (`< /dev/null`, cron) rend la main au lieu d'attendre : | |
| 73 | +/// la liste est déjà affichée, il reste à repasser l'identifiant en argument. | |
| 74 | +fn choose(matches: Vec<TeamMatch>, input: &str) -> Result<TeamMatch> { | |
| 75 | + println!("Plusieurs équipes portent le nom « {input} » :"); | |
| 76 | + for (i, t) in matches.iter().enumerate() { | |
| 77 | + println!(" {}) {}", i + 1, t.describe()); | |
| 78 | + } | |
| 79 | + loop { | |
| 80 | + print!("Laquelle ? [1-{}] ", matches.len()); | |
| 81 | + std::io::stdout().flush()?; | |
| 82 | + let mut line = String::new(); | |
| 83 | + if std::io::stdin().read_line(&mut line)? == 0 { | |
| 84 | + bail!("aucune réponse — relance avec --team <identifiant>"); | |
| 85 | + } | |
| 86 | + let line = line.trim(); | |
| 87 | + let picked = line | |
| 88 | + .parse::<usize>() | |
| 89 | + .ok() | |
| 90 | + .filter(|n| (1..=matches.len()).contains(n)) | |
| 91 | + .map(|n| n - 1) | |
| 92 | + .or_else(|| { | |
| 93 | + let id = line.parse::<uuid::Uuid>().ok()?; | |
| 94 | + matches.iter().position(|t| t.id == id) | |
| 95 | + }); | |
| 96 | + if let Some(i) = picked { | |
| 97 | + return Ok(matches.into_iter().nth(i).expect("rang vérifié")); | |
| 98 | + } | |
| 99 | + println!( | |
| 100 | + "Réponse invalide : un rang entre 1 et {}, ou un identifiant de la liste.", | |
| 101 | + matches.len() | |
| 102 | + ); | |
| 44 | 103 | } |
| 45 | 104 | } |
| 46 | 105 | |
| @@ -68,9 +127,7 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | ||
| 68 | 127 | } |
| 69 | 128 | |
| 70 | 129 | let pool = connect().await?; |
| 71 | - let team_id = teams::find_by_name(&pool, team.trim()) | |
| 72 | - .await? | |
| 73 | - .with_context(|| format!("l'équipe « {} » n'existe pas (exego team add)", team.trim()))?; | |
| 130 | + let team = pick_team(&pool, team.trim()).await?; | |
| 74 | 131 | |
| 75 | 132 | let hash = password::hash(&password)?; |
| 76 | 133 | let id = Uuid::now_v7(); |
| @@ -90,12 +147,15 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | ||
| 90 | 147 | Err(e) => return Err(e.into()), |
| 91 | 148 | } |
| 92 | 149 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") |
| 93 | - .bind(team_id) | |
| 150 | + .bind(team.id) | |
| 94 | 151 | .bind(id) |
| 95 | 152 | .execute(&mut *tx) |
| 96 | 153 | .await?; |
| 97 | 154 | tx.commit().await?; |
| 98 | - println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim()); | |
| 155 | + println!( | |
| 156 | + "Utilisateur « {username} » créé ({id}), membre de « {} » ({}).", | |
| 157 | + team.name, team.id | |
| 158 | + ); | |
| 99 | 159 | Ok(()) |
| 100 | 160 | } |
| 101 | 161 | |
| @@ -108,20 +168,18 @@ pub async fn user_join(username: &str, team: &str) -> Result<()> { | ||
| 108 | 168 | let user = users::find_by_username(&pool, username) |
| 109 | 169 | .await? |
| 110 | 170 | .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?; |
| 111 | - let team_id = teams::find_by_name(&pool, team) | |
| 112 | - .await? | |
| 113 | - .with_context(|| format!("l'équipe « {team} » n'existe pas (exego team add)"))?; | |
| 171 | + let picked = pick_team(&pool, team).await?; | |
| 114 | 172 | let res = sqlx::query( |
| 115 | 173 | "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", |
| 116 | 174 | ) |
| 117 | - .bind(team_id) | |
| 175 | + .bind(picked.id) | |
| 118 | 176 | .bind(user.id) |
| 119 | 177 | .execute(&pool) |
| 120 | 178 | .await?; |
| 121 | 179 | if res.rows_affected() == 0 { |
| 122 | - println!("« {username} » est déjà membre de « {team} »."); | |
| 180 | + println!("« {username} » est déjà membre de « {} » ({}).", picked.name, picked.id); | |
| 123 | 181 | } else { |
| 124 | - println!("« {username} » ajouté à « {team} »."); | |
| 182 | + println!("« {username} » ajouté à « {} » ({}).", picked.name, picked.id); | |
| 125 | 183 | } |
| 126 | 184 | Ok(()) |
| 127 | 185 | } |
| @@ -1,3 +1,5 @@ | |||
| 1 | +use std::io::Write; | ||
| 2 | + | ||
| 1 | use anyhow::{Context, Result, bail}; | 3 | use anyhow::{Context, Result, bail}; |
| 2 | use biscuit_auth::KeyPair; | 4 | use biscuit_auth::KeyPair; |
| 3 | use sqlx::PgPool; | 5 | use sqlx::PgPool; |
| @@ -6,6 +8,7 @@ use uuid::Uuid; | |||
| 6 | 8 | ||
| 7 | use crate::auth::password; | 9 | use crate::auth::password; |
| 8 | use crate::config; | 10 | use crate::config; |
| 11 | +use crate::db::teams::{Resolved, TeamMatch}; | ||
| 9 | use crate::db::{teams, users}; | 12 | use crate::db::{teams, users}; |
| 10 | 13 | ||
| 11 | /// Génère un keypair racine et l'affiche (à faire une fois, en local). | 14 | /// Génère un keypair racine et l'affiche (à faire une fois, en local). |
| @@ -32,15 +35,71 @@ pub async fn team_add(name: &str) -> Result<()> { | |||
| 32 | bail!("le nom d'équipe ne peut pas être vide"); | 35 | bail!("le nom d'équipe ne peut pas être vide"); |
| 33 | } | 36 | } |
| 34 | let pool = connect().await?; | 37 | let pool = connect().await?; |
| 35 | - match teams::create(&pool, name).await { | 38 | + // Les homonymes sont permis depuis la 0012 : on prévient sans refuser, |
| 36 | - Ok(id) => { | 39 | + // et l'identifiant affiché sert à désigner celle-ci sans ambiguïté. |
| 37 | - println!("Équipe « {name} » créée ({id})."); | 40 | + let homonyms = teams::find_all_by_name(&pool, name).await?; |
| 38 | - Ok(()) | 41 | + let id = teams::create(&pool, name).await?; |
| 42 | + println!("Équipe « {name} » créée ({id})."); | ||
| 43 | + if !homonyms.is_empty() { | ||
| 44 | + println!( | ||
| 45 | + "⚠ {} autre(s) équipe(s) portent déjà ce nom — désigne celle-ci par son identifiant :", | ||
| 46 | + homonyms.len() | ||
| 47 | + ); | ||
| 48 | + for t in &homonyms { | ||
| 49 | + println!(" {}", t.describe()); | ||
| 39 | } | 50 | } |
| 40 | - Err(sqlx::Error::Database(db)) if db.is_unique_violation() => { | 51 | + } |
| 41 | - bail!("l'équipe « {name} » existe déjà") | 52 | + Ok(()) |
| 53 | +} | ||
| 54 | + | ||
| 55 | +/// Résout un `--team` (nom ou identifiant) en UNE équipe. Sur homonymes, | ||
| 56 | +/// liste et demande. Rend le `TeamMatch` pour que l'appelant puisse nommer | ||
| 57 | +/// l'équipe retenue plutôt que de répéter la saisie. | ||
| 58 | +async fn pick_team(pool: &sqlx::PgPool, input: &str) -> Result<TeamMatch> { | ||
| 59 | + match teams::resolve(pool, input).await? { | ||
| 60 | + Resolved::One(t) => Ok(t), | ||
| 61 | + Resolved::None { was_id: true } => { | ||
| 62 | + bail!("aucune équipe n'a l'identifiant « {input} »") | ||
| 42 | } | 63 | } |
| 43 | - Err(e) => Err(e.into()), | 64 | + Resolved::None { was_id: false } => { |
| 65 | + bail!("l'équipe « {input} » n'existe pas (exego team add)") | ||
| 66 | + } | ||
| 67 | + Resolved::Ambiguous(matches) => choose(matches, input), | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +/// Menu numéroté des homonymes. Accepte le rang ou l'identifiant complet. | ||
| 72 | +/// Une entrée fermée (`< /dev/null`, cron) rend la main au lieu d'attendre : | ||
| 73 | +/// la liste est déjà affichée, il reste à repasser l'identifiant en argument. | ||
| 74 | +fn choose(matches: Vec<TeamMatch>, input: &str) -> Result<TeamMatch> { | ||
| 75 | + println!("Plusieurs équipes portent le nom « {input} » :"); | ||
| 76 | + for (i, t) in matches.iter().enumerate() { | ||
| 77 | + println!(" {}) {}", i + 1, t.describe()); | ||
| 78 | + } | ||
| 79 | + loop { | ||
| 80 | + print!("Laquelle ? [1-{}] ", matches.len()); | ||
| 81 | + std::io::stdout().flush()?; | ||
| 82 | + let mut line = String::new(); | ||
| 83 | + if std::io::stdin().read_line(&mut line)? == 0 { | ||
| 84 | + bail!("aucune réponse — relance avec --team <identifiant>"); | ||
| 85 | + } | ||
| 86 | + let line = line.trim(); | ||
| 87 | + let picked = line | ||
| 88 | + .parse::<usize>() | ||
| 89 | + .ok() | ||
| 90 | + .filter(|n| (1..=matches.len()).contains(n)) | ||
| 91 | + .map(|n| n - 1) | ||
| 92 | + .or_else(|| { | ||
| 93 | + let id = line.parse::<uuid::Uuid>().ok()?; | ||
| 94 | + matches.iter().position(|t| t.id == id) | ||
| 95 | + }); | ||
| 96 | + if let Some(i) = picked { | ||
| 97 | + return Ok(matches.into_iter().nth(i).expect("rang vérifié")); | ||
| 98 | + } | ||
| 99 | + println!( | ||
| 100 | + "Réponse invalide : un rang entre 1 et {}, ou un identifiant de la liste.", | ||
| 101 | + matches.len() | ||
| 102 | + ); | ||
| 44 | } | 103 | } |
| 45 | } | 104 | } |
| 46 | 105 | ||
| @@ -68,9 +127,7 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | |||
| 68 | } | 127 | } |
| 69 | 128 | ||
| 70 | let pool = connect().await?; | 129 | let pool = connect().await?; |
| 71 | - let team_id = teams::find_by_name(&pool, team.trim()) | 130 | + let team = pick_team(&pool, team.trim()).await?; |
| 72 | - .await? | ||
| 73 | - .with_context(|| format!("l'équipe « {} » n'existe pas (exego team add)", team.trim()))?; | ||
| 74 | 131 | ||
| 75 | let hash = password::hash(&password)?; | 132 | let hash = password::hash(&password)?; |
| 76 | let id = Uuid::now_v7(); | 133 | let id = Uuid::now_v7(); |
| @@ -90,12 +147,15 @@ pub async fn user_add(username: &str, password_arg: Option<String>, team: &str) | |||
| 90 | Err(e) => return Err(e.into()), | 147 | Err(e) => return Err(e.into()), |
| 91 | } | 148 | } |
| 92 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") | 149 | sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") |
| 93 | - .bind(team_id) | 150 | + .bind(team.id) |
| 94 | .bind(id) | 151 | .bind(id) |
| 95 | .execute(&mut *tx) | 152 | .execute(&mut *tx) |
| 96 | .await?; | 153 | .await?; |
| 97 | tx.commit().await?; | 154 | tx.commit().await?; |
| 98 | - println!("Utilisateur « {username} » créé ({id}), membre de « {} ».", team.trim()); | 155 | + println!( |
| 156 | + "Utilisateur « {username} » créé ({id}), membre de « {} » ({}).", | ||
| 157 | + team.name, team.id | ||
| 158 | + ); | ||
| 99 | Ok(()) | 159 | Ok(()) |
| 100 | } | 160 | } |
| 101 | 161 | ||
| @@ -108,20 +168,18 @@ pub async fn user_join(username: &str, team: &str) -> Result<()> { | |||
| 108 | let user = users::find_by_username(&pool, username) | 168 | let user = users::find_by_username(&pool, username) |
| 109 | .await? | 169 | .await? |
| 110 | .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?; | 170 | .with_context(|| format!("l'utilisateur « {username} » n'existe pas"))?; |
| 111 | - let team_id = teams::find_by_name(&pool, team) | 171 | + let picked = pick_team(&pool, team).await?; |
| 112 | - .await? | ||
| 113 | - .with_context(|| format!("l'équipe « {team} » n'existe pas (exego team add)"))?; | ||
| 114 | let res = sqlx::query( | 172 | let res = sqlx::query( |
| 115 | "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | 173 | "INSERT INTO team_members (team_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", |
| 116 | ) | 174 | ) |
| 117 | - .bind(team_id) | 175 | + .bind(picked.id) |
| 118 | .bind(user.id) | 176 | .bind(user.id) |
| 119 | .execute(&pool) | 177 | .execute(&pool) |
| 120 | .await?; | 178 | .await?; |
| 121 | if res.rows_affected() == 0 { | 179 | if res.rows_affected() == 0 { |
| 122 | - println!("« {username} » est déjà membre de « {team} »."); | 180 | + println!("« {username} » est déjà membre de « {} » ({}).", picked.name, picked.id); |
| 123 | } else { | 181 | } else { |
| 124 | - println!("« {username} » ajouté à « {team} »."); | 182 | + println!("« {username} » ajouté à « {} » ({}).", picked.name, picked.id); |
| 125 | } | 183 | } |
| 126 | Ok(()) | 184 | Ok(()) |
| 127 | } | 185 | } |
modified
src/config.rs +12 -0 | @@ -34,6 +34,18 @@ pub fn default_lang() -> crate::i18n::Lang { | ||
| 34 | 34 | .unwrap_or(crate::i18n::Lang::Fr) |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | +/// Clé d'inscription libre-service (`SIGNUP_KEY`). `None` = pas de variable, | |
| 38 | +/// donc **pas de route d'inscription du tout** : une instance qui n'a rien | |
| 39 | +/// configuré ne s'ouvre pas toute seule. La valeur doit être longue et | |
| 40 | +/// aléatoire — c'est le seul rempart, il n'y a ni e-mail ni confirmation, et | |
| 41 | +/// l'application ne limite pas le débit des tentatives. | |
| 42 | +pub fn signup_key() -> Option<String> { | |
| 43 | + std::env::var("SIGNUP_KEY") | |
| 44 | + .ok() | |
| 45 | + .map(|k| k.trim().to_string()) | |
| 46 | + .filter(|k| !k.is_empty()) | |
| 47 | +} | |
| 48 | + | |
| 37 | 49 | pub fn biscuit_private_key_hex() -> Result<String> { |
| 38 | 50 | std::env::var("BISCUIT_PRIVATE_KEY") |
| 39 | 51 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)") |
| @@ -34,6 +34,18 @@ pub fn default_lang() -> crate::i18n::Lang { | |||
| 34 | .unwrap_or(crate::i18n::Lang::Fr) | 34 | .unwrap_or(crate::i18n::Lang::Fr) |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | +/// Clé d'inscription libre-service (`SIGNUP_KEY`). `None` = pas de variable, | ||
| 38 | +/// donc **pas de route d'inscription du tout** : une instance qui n'a rien | ||
| 39 | +/// configuré ne s'ouvre pas toute seule. La valeur doit être longue et | ||
| 40 | +/// aléatoire — c'est le seul rempart, il n'y a ni e-mail ni confirmation, et | ||
| 41 | +/// l'application ne limite pas le débit des tentatives. | ||
| 42 | +pub fn signup_key() -> Option<String> { | ||
| 43 | + std::env::var("SIGNUP_KEY") | ||
| 44 | + .ok() | ||
| 45 | + .map(|k| k.trim().to_string()) | ||
| 46 | + .filter(|k| !k.is_empty()) | ||
| 47 | +} | ||
| 48 | + | ||
| 37 | pub fn biscuit_private_key_hex() -> Result<String> { | 49 | pub fn biscuit_private_key_hex() -> Result<String> { |
| 38 | std::env::var("BISCUIT_PRIVATE_KEY") | 50 | std::env::var("BISCUIT_PRIVATE_KEY") |
| 39 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)") | 51 | .context("BISCUIT_PRIVATE_KEY doit être définie (générer avec `exego keygen`)") |
modified
src/db/teams.rs +77 -6 | @@ -39,12 +39,83 @@ pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sql | ||
| 39 | 39 | Ok(id) |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | -pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> { | |
| 43 | - let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1") | |
| 44 | - .bind(name) | |
| 45 | - .fetch_optional(pool) | |
| 46 | - .await?; | |
| 47 | - Ok(row.map(|(id,)| id)) | |
| 42 | +/// Une équipe désignée par nom ou par id, avec de quoi la reconnaître quand | |
| 43 | +/// des homonymes existent (`teams.name` n'est plus unique — migration 0012). | |
| 44 | +#[derive(sqlx::FromRow)] | |
| 45 | +pub struct TeamMatch { | |
| 46 | + pub id: Uuid, | |
| 47 | + pub name: String, | |
| 48 | + /// Membres actuels, par ordre alphabétique — c'est ce qui distingue deux | |
| 49 | + /// « Maison » à l'œil nu. | |
| 50 | + pub members: Vec<String>, | |
| 51 | +} | |
| 52 | + | |
| 53 | +impl TeamMatch { | |
| 54 | + /// « 019f… — Maison (alex, camille) » / « … (aucun membre) » | |
| 55 | + pub fn describe(&self) -> String { | |
| 56 | + if self.members.is_empty() { | |
| 57 | + format!("{} — {} (aucun membre)", self.id, self.name) | |
| 58 | + } else { | |
| 59 | + format!("{} — {} ({})", self.id, self.name, self.members.join(", ")) | |
| 60 | + } | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +const TEAM_MATCH_SELECT: &str = "SELECT e.id, e.name, | |
| 65 | + COALESCE( | |
| 66 | + ARRAY_AGG(u.username ORDER BY u.username) FILTER (WHERE u.id IS NOT NULL), | |
| 67 | + ARRAY[]::text[] | |
| 68 | + ) AS members | |
| 69 | + FROM teams e | |
| 70 | + LEFT JOIN team_members m ON m.team_id = e.id | |
| 71 | + LEFT JOIN users u ON u.id = m.user_id"; | |
| 72 | + | |
| 73 | +/// Toutes les équipes portant ce nom — plusieurs depuis la 0012. | |
| 74 | +/// Tri par id = ordre de création (UUIDv7), donc stable d'un appel à l'autre. | |
| 75 | +pub async fn find_all_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Vec<TeamMatch>> { | |
| 76 | + sqlx::query_as(&format!( | |
| 77 | + "{TEAM_MATCH_SELECT} WHERE e.name = $1 GROUP BY e.id, e.name ORDER BY e.id" | |
| 78 | + )) | |
| 79 | + .bind(name) | |
| 80 | + .fetch_all(pool) | |
| 81 | + .await | |
| 82 | +} | |
| 83 | + | |
| 84 | +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<TeamMatch>> { | |
| 85 | + sqlx::query_as(&format!( | |
| 86 | + "{TEAM_MATCH_SELECT} WHERE e.id = $1 GROUP BY e.id, e.name" | |
| 87 | + )) | |
| 88 | + .bind(id) | |
| 89 | + .fetch_optional(pool) | |
| 90 | + .await | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Ce qu'une saisie « nom ou id » a désigné. | |
| 94 | +pub enum Resolved { | |
| 95 | + /// Une seule équipe : on peut y aller. | |
| 96 | + One(TeamMatch), | |
| 97 | + /// Plusieurs homonymes : il faut trancher par id. | |
| 98 | + Ambiguous(Vec<TeamMatch>), | |
| 99 | + /// Aucune — nom libre (à créer) ou id inexistant, selon `was_id`. | |
| 100 | + None { was_id: bool }, | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// Résout une saisie utilisateur : un UUID désigne une équipe précise, tout | |
| 104 | +/// le reste est un nom (potentiellement porté par plusieurs équipes). | |
| 105 | +pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> { | |
| 106 | + let input = input.trim(); | |
| 107 | + if let Ok(id) = input.parse::<Uuid>() { | |
| 108 | + return Ok(match find_by_id(pool, id).await? { | |
| 109 | + Some(t) => Resolved::One(t), | |
| 110 | + None => Resolved::None { was_id: true }, | |
| 111 | + }); | |
| 112 | + } | |
| 113 | + let mut matches = find_all_by_name(pool, input).await?; | |
| 114 | + Ok(match matches.len() { | |
| 115 | + 0 => Resolved::None { was_id: false }, | |
| 116 | + 1 => Resolved::One(matches.remove(0)), | |
| 117 | + _ => Resolved::Ambiguous(matches), | |
| 118 | + }) | |
| 48 | 119 | } |
| 49 | 120 | |
| 50 | 121 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { |
| @@ -39,12 +39,83 @@ pub async fn create_with_member(pool: &PgPool, name: &str, user_id: Uuid) -> sql | |||
| 39 | Ok(id) | 39 | Ok(id) |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | -pub async fn find_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Option<Uuid>> { | 42 | +/// Une équipe désignée par nom ou par id, avec de quoi la reconnaître quand |
| 43 | - let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM teams WHERE name = $1") | 43 | +/// des homonymes existent (`teams.name` n'est plus unique — migration 0012). |
| 44 | - .bind(name) | 44 | +#[derive(sqlx::FromRow)] |
| 45 | - .fetch_optional(pool) | 45 | +pub struct TeamMatch { |
| 46 | - .await?; | 46 | + pub id: Uuid, |
| 47 | - Ok(row.map(|(id,)| id)) | 47 | + pub name: String, |
| 48 | + /// Membres actuels, par ordre alphabétique — c'est ce qui distingue deux | ||
| 49 | + /// « Maison » à l'œil nu. | ||
| 50 | + pub members: Vec<String>, | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +impl TeamMatch { | ||
| 54 | + /// « 019f… — Maison (alex, camille) » / « … (aucun membre) » | ||
| 55 | + pub fn describe(&self) -> String { | ||
| 56 | + if self.members.is_empty() { | ||
| 57 | + format!("{} — {} (aucun membre)", self.id, self.name) | ||
| 58 | + } else { | ||
| 59 | + format!("{} — {} ({})", self.id, self.name, self.members.join(", ")) | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +const TEAM_MATCH_SELECT: &str = "SELECT e.id, e.name, | ||
| 65 | + COALESCE( | ||
| 66 | + ARRAY_AGG(u.username ORDER BY u.username) FILTER (WHERE u.id IS NOT NULL), | ||
| 67 | + ARRAY[]::text[] | ||
| 68 | + ) AS members | ||
| 69 | + FROM teams e | ||
| 70 | + LEFT JOIN team_members m ON m.team_id = e.id | ||
| 71 | + LEFT JOIN users u ON u.id = m.user_id"; | ||
| 72 | + | ||
| 73 | +/// Toutes les équipes portant ce nom — plusieurs depuis la 0012. | ||
| 74 | +/// Tri par id = ordre de création (UUIDv7), donc stable d'un appel à l'autre. | ||
| 75 | +pub async fn find_all_by_name(pool: &PgPool, name: &str) -> sqlx::Result<Vec<TeamMatch>> { | ||
| 76 | + sqlx::query_as(&format!( | ||
| 77 | + "{TEAM_MATCH_SELECT} WHERE e.name = $1 GROUP BY e.id, e.name ORDER BY e.id" | ||
| 78 | + )) | ||
| 79 | + .bind(name) | ||
| 80 | + .fetch_all(pool) | ||
| 81 | + .await | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<TeamMatch>> { | ||
| 85 | + sqlx::query_as(&format!( | ||
| 86 | + "{TEAM_MATCH_SELECT} WHERE e.id = $1 GROUP BY e.id, e.name" | ||
| 87 | + )) | ||
| 88 | + .bind(id) | ||
| 89 | + .fetch_optional(pool) | ||
| 90 | + .await | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +/// Ce qu'une saisie « nom ou id » a désigné. | ||
| 94 | +pub enum Resolved { | ||
| 95 | + /// Une seule équipe : on peut y aller. | ||
| 96 | + One(TeamMatch), | ||
| 97 | + /// Plusieurs homonymes : il faut trancher par id. | ||
| 98 | + Ambiguous(Vec<TeamMatch>), | ||
| 99 | + /// Aucune — nom libre (à créer) ou id inexistant, selon `was_id`. | ||
| 100 | + None { was_id: bool }, | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +/// Résout une saisie utilisateur : un UUID désigne une équipe précise, tout | ||
| 104 | +/// le reste est un nom (potentiellement porté par plusieurs équipes). | ||
| 105 | +pub async fn resolve(pool: &PgPool, input: &str) -> sqlx::Result<Resolved> { | ||
| 106 | + let input = input.trim(); | ||
| 107 | + if let Ok(id) = input.parse::<Uuid>() { | ||
| 108 | + return Ok(match find_by_id(pool, id).await? { | ||
| 109 | + Some(t) => Resolved::One(t), | ||
| 110 | + None => Resolved::None { was_id: true }, | ||
| 111 | + }); | ||
| 112 | + } | ||
| 113 | + let mut matches = find_all_by_name(pool, input).await?; | ||
| 114 | + Ok(match matches.len() { | ||
| 115 | + 0 => Resolved::None { was_id: false }, | ||
| 116 | + 1 => Resolved::One(matches.remove(0)), | ||
| 117 | + _ => Resolved::Ambiguous(matches), | ||
| 118 | + }) | ||
| 48 | } | 119 | } |
| 49 | 120 | ||
| 50 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { | 121 | pub async fn remove_member(pool: &PgPool, team_id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { |
modified
src/db/users.rs +71 -1 | @@ -79,7 +79,77 @@ pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Re | ||
| 79 | 79 | Ok(()) |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | -/// Cible d'une invitation. | |
| 82 | +/// Résultat d'une inscription — l'identifiant pris est un cas NORMAL du | |
| 83 | +/// formulaire, pas une erreur technique. | |
| 84 | +pub enum Signup { | |
| 85 | + Created { user_id: Uuid, team_id: Uuid }, | |
| 86 | + UsernameTaken, | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant. | |
| 90 | +pub enum InitialTeam { | |
| 91 | + /// Une équipe existante, désignée par nom unique ou par id. | |
| 92 | + Existing(Uuid), | |
| 93 | + /// Nom encore libre : on la crée au passage (les homonymes sont permis | |
| 94 | + /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit). | |
| 95 | + Create(String), | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Inscription libre-service : le compte ET son rattachement d'équipe, en une | |
| 99 | +/// transaction (invariant « jamais d'utilisateur sans équipe »). | |
| 100 | +/// | |
| 101 | +/// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend | |
| 102 | +/// une ligne vide au lieu d'avorter la transaction. | |
| 103 | +pub async fn signup( | |
| 104 | + pool: &PgPool, | |
| 105 | + username: &str, | |
| 106 | + password_hash: &str, | |
| 107 | + lang: &str, | |
| 108 | + team: InitialTeam, | |
| 109 | +) -> sqlx::Result<Signup> { | |
| 110 | + let user_id = Uuid::now_v7(); | |
| 111 | + let mut tx = pool.begin().await?; | |
| 112 | + | |
| 113 | + let created: Option<(Uuid,)> = sqlx::query_as( | |
| 114 | + "INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4) | |
| 115 | + ON CONFLICT (username) DO NOTHING | |
| 116 | + RETURNING id", | |
| 117 | + ) | |
| 118 | + .bind(user_id) | |
| 119 | + .bind(username) | |
| 120 | + .bind(password_hash) | |
| 121 | + .bind(lang) | |
| 122 | + .fetch_optional(&mut *tx) | |
| 123 | + .await?; | |
| 124 | + if created.is_none() { | |
| 125 | + return Ok(Signup::UsernameTaken); | |
| 126 | + } | |
| 127 | + | |
| 128 | + let team_id = match team { | |
| 129 | + InitialTeam::Existing(id) => id, | |
| 130 | + InitialTeam::Create(name) => { | |
| 131 | + let id = Uuid::now_v7(); | |
| 132 | + sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)") | |
| 133 | + .bind(id) | |
| 134 | + .bind(&name) | |
| 135 | + .execute(&mut *tx) | |
| 136 | + .await?; | |
| 137 | + id | |
| 138 | + } | |
| 139 | + }; | |
| 140 | + | |
| 141 | + // Équipe supprimée entre la résolution et ici : la clé étrangère fait | |
| 142 | + // retomber toute la transaction, aucun compte orphelin ne survit. | |
| 143 | + sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") | |
| 144 | + .bind(team_id) | |
| 145 | + .bind(user_id) | |
| 146 | + .execute(&mut *tx) | |
| 147 | + .await?; | |
| 148 | + tx.commit().await?; | |
| 149 | + Ok(Signup::Created { user_id, team_id }) | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// Cible d'une invitation./// Cible d'une invitation. | |
| 83 | 153 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 84 | 154 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| 85 | 155 | .bind(username) |
| @@ -79,7 +79,77 @@ pub async fn set_stats_layout(pool: &PgPool, id: Uuid, layout: &str) -> sqlx::Re | |||
| 79 | Ok(()) | 79 | Ok(()) |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | -/// Cible d'une invitation. | 82 | +/// Résultat d'une inscription — l'identifiant pris est un cas NORMAL du |
| 83 | +/// formulaire, pas une erreur technique. | ||
| 84 | +pub enum Signup { | ||
| 85 | + Created { user_id: Uuid, team_id: Uuid }, | ||
| 86 | + UsernameTaken, | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/// Équipe d'accueil du nouveau compte, déjà résolue par l'appelant. | ||
| 90 | +pub enum InitialTeam { | ||
| 91 | + /// Une équipe existante, désignée par nom unique ou par id. | ||
| 92 | + Existing(Uuid), | ||
| 93 | + /// Nom encore libre : on la crée au passage (les homonymes sont permis | ||
| 94 | + /// depuis la 0012, donc cet INSERT ne peut plus entrer en conflit). | ||
| 95 | + Create(String), | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +/// Inscription libre-service : le compte ET son rattachement d'équipe, en une | ||
| 99 | +/// transaction (invariant « jamais d'utilisateur sans équipe »). | ||
| 100 | +/// | ||
| 101 | +/// L'INSERT du compte fait `ON CONFLICT DO NOTHING` : un identifiant pris rend | ||
| 102 | +/// une ligne vide au lieu d'avorter la transaction. | ||
| 103 | +pub async fn signup( | ||
| 104 | + pool: &PgPool, | ||
| 105 | + username: &str, | ||
| 106 | + password_hash: &str, | ||
| 107 | + lang: &str, | ||
| 108 | + team: InitialTeam, | ||
| 109 | +) -> sqlx::Result<Signup> { | ||
| 110 | + let user_id = Uuid::now_v7(); | ||
| 111 | + let mut tx = pool.begin().await?; | ||
| 112 | + | ||
| 113 | + let created: Option<(Uuid,)> = sqlx::query_as( | ||
| 114 | + "INSERT INTO users (id, username, password_hash, lang) VALUES ($1, $2, $3, $4) | ||
| 115 | + ON CONFLICT (username) DO NOTHING | ||
| 116 | + RETURNING id", | ||
| 117 | + ) | ||
| 118 | + .bind(user_id) | ||
| 119 | + .bind(username) | ||
| 120 | + .bind(password_hash) | ||
| 121 | + .bind(lang) | ||
| 122 | + .fetch_optional(&mut *tx) | ||
| 123 | + .await?; | ||
| 124 | + if created.is_none() { | ||
| 125 | + return Ok(Signup::UsernameTaken); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + let team_id = match team { | ||
| 129 | + InitialTeam::Existing(id) => id, | ||
| 130 | + InitialTeam::Create(name) => { | ||
| 131 | + let id = Uuid::now_v7(); | ||
| 132 | + sqlx::query("INSERT INTO teams (id, name) VALUES ($1, $2)") | ||
| 133 | + .bind(id) | ||
| 134 | + .bind(&name) | ||
| 135 | + .execute(&mut *tx) | ||
| 136 | + .await?; | ||
| 137 | + id | ||
| 138 | + } | ||
| 139 | + }; | ||
| 140 | + | ||
| 141 | + // Équipe supprimée entre la résolution et ici : la clé étrangère fait | ||
| 142 | + // retomber toute la transaction, aucun compte orphelin ne survit. | ||
| 143 | + sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") | ||
| 144 | + .bind(team_id) | ||
| 145 | + .bind(user_id) | ||
| 146 | + .execute(&mut *tx) | ||
| 147 | + .await?; | ||
| 148 | + tx.commit().await?; | ||
| 149 | + Ok(Signup::Created { user_id, team_id }) | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +/// Cible d'une invitation./// Cible d'une invitation. | ||
| 83 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { | 153 | pub async fn find_by_username(pool: &PgPool, username: &str) -> sqlx::Result<Option<UserRow>> { |
| 84 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") | 154 | sqlx::query_as("SELECT id, username FROM users WHERE username = $1") |
| 85 | .bind(username) | 155 | .bind(username) |
modified
src/i18n.rs +86 -5 | @@ -261,10 +261,27 @@ pub struct Tr { | ||
| 261 | 261 | pub toast_team_renamed: &'static str, |
| 262 | 262 | pub toast_joined_pat: &'static str, |
| 263 | 263 | pub toast_team_left: &'static str, |
| 264 | - pub err_team_name_taken: &'static str, | |
| 265 | 264 | pub err_team_name_empty: &'static str, |
| 266 | 265 | pub err_last_team: &'static str, |
| 267 | 266 | pub err_password_short: &'static str, |
| 267 | + // ─── inscription libre-service (SIGNUP_KEY) ─── | |
| 268 | + pub signup_title: &'static str, | |
| 269 | + pub signup_intro: &'static str, | |
| 270 | + pub signup_key_label: &'static str, | |
| 271 | + pub signup_key_hint: &'static str, | |
| 272 | + pub signup_submit: &'static str, | |
| 273 | + pub signup_link: &'static str, | |
| 274 | + pub signup_have_account: &'static str, | |
| 275 | + pub err_signup_key: &'static str, | |
| 276 | + pub err_signup_username_taken: &'static str, | |
| 277 | + pub err_signup_username_invalid: &'static str, | |
| 278 | + pub signup_team_label: &'static str, | |
| 279 | + pub signup_team_hint: &'static str, | |
| 280 | + pub err_signup_team_empty: &'static str, | |
| 281 | + pub err_signup_team_ambiguous: &'static str, | |
| 282 | + pub err_signup_team_unknown: &'static str, | |
| 283 | + pub err_signup_rate_limited: &'static str, | |
| 284 | + pub err_signup_too_many_attempts: &'static str, | |
| 268 | 285 | pub err_password_mismatch: &'static str, |
| 269 | 286 | pub err_password_wrong: &'static str, |
| 270 | 287 | pub invite_unknown_pat: &'static str, |
| @@ -537,10 +554,26 @@ pub static FR: Tr = Tr { | ||
| 537 | 554 | toast_team_renamed: "Équipe renommée", |
| 538 | 555 | toast_joined_pat: "Tu as rejoint {}", |
| 539 | 556 | toast_team_left: "Équipe quittée", |
| 540 | - err_team_name_taken: "Ce nom d'équipe est déjà pris", | |
| 541 | 557 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", |
| 542 | 558 | err_last_team: "Impossible de quitter ta dernière équipe", |
| 543 | 559 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", |
| 560 | + signup_title: "Créer un compte", | |
| 561 | + signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.", | |
| 562 | + signup_key_label: "Clé d'inscription", | |
| 563 | + signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.", | |
| 564 | + signup_submit: "Créer le compte", | |
| 565 | + signup_link: "Créer un compte", | |
| 566 | + signup_have_account: "J'ai déjà un compte", | |
| 567 | + err_signup_key: "Clé d'inscription invalide.", | |
| 568 | + err_signup_username_taken: "Cet identifiant est déjà pris.", | |
| 569 | + err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.", | |
| 570 | + signup_team_label: "Équipe initiale", | |
| 571 | + signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.", | |
| 572 | + err_signup_team_empty: "Indique une équipe initiale.", | |
| 573 | + err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.", | |
| 574 | + err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.", | |
| 575 | + err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", | |
| 576 | + err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.", | |
| 544 | 577 | err_password_mismatch: "La confirmation ne correspond pas.", |
| 545 | 578 | err_password_wrong: "Mot de passe actuel incorrect.", |
| 546 | 579 | invite_unknown_pat: "Aucun compte « {} ».", |
| @@ -783,10 +816,26 @@ pub static EN: Tr = Tr { | ||
| 783 | 816 | toast_team_renamed: "Team renamed", |
| 784 | 817 | toast_joined_pat: "You joined {}", |
| 785 | 818 | toast_team_left: "Team left", |
| 786 | - err_team_name_taken: "This team name is already taken", | |
| 787 | 819 | err_team_name_empty: "The team name cannot be empty", |
| 788 | 820 | err_last_team: "You cannot leave your last team", |
| 789 | 821 | err_password_short: "The new password must be at least 8 characters.", |
| 822 | + signup_title: "Create an account", | |
| 823 | + signup_intro: "Pick a username and a password, name your team, then enter the signup key.", | |
| 824 | + signup_key_label: "Signup key", | |
| 825 | + signup_key_hint: "Ask someone who already uses Ex Ego for it.", | |
| 826 | + signup_submit: "Create the account", | |
| 827 | + signup_link: "Create an account", | |
| 828 | + signup_have_account: "I already have an account", | |
| 829 | + err_signup_key: "Invalid signup key.", | |
| 830 | + err_signup_username_taken: "That username is already taken.", | |
| 831 | + err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.", | |
| 832 | + signup_team_label: "Initial team", | |
| 833 | + signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.", | |
| 834 | + err_signup_team_empty: "Enter an initial team.", | |
| 835 | + err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.", | |
| 836 | + err_signup_team_unknown: "No team has that identifier.", | |
| 837 | + err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", | |
| 838 | + err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.", | |
| 790 | 839 | err_password_mismatch: "The confirmation does not match.", |
| 791 | 840 | err_password_wrong: "Current password is incorrect.", |
| 792 | 841 | invite_unknown_pat: "No account “{}”.", |
| @@ -1029,10 +1078,26 @@ pub static DE: Tr = Tr { | ||
| 1029 | 1078 | toast_team_renamed: "Team umbenannt", |
| 1030 | 1079 | toast_joined_pat: "Du bist {} beigetreten", |
| 1031 | 1080 | toast_team_left: "Team verlassen", |
| 1032 | - err_team_name_taken: "Dieser Teamname ist bereits vergeben", | |
| 1033 | 1081 | err_team_name_empty: "Der Teamname darf nicht leer sein", |
| 1034 | 1082 | err_last_team: "Du kannst dein letztes Team nicht verlassen", |
| 1035 | 1083 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", |
| 1084 | + signup_title: "Konto erstellen", | |
| 1085 | + signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.", | |
| 1086 | + signup_key_label: "Anmeldeschlüssel", | |
| 1087 | + signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.", | |
| 1088 | + signup_submit: "Konto erstellen", | |
| 1089 | + signup_link: "Konto erstellen", | |
| 1090 | + signup_have_account: "Ich habe schon ein Konto", | |
| 1091 | + err_signup_key: "Ungültiger Anmeldeschlüssel.", | |
| 1092 | + err_signup_username_taken: "Dieser Benutzername ist schon vergeben.", | |
| 1093 | + err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.", | |
| 1094 | + signup_team_label: "Erstes Team", | |
| 1095 | + signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.", | |
| 1096 | + err_signup_team_empty: "Gib ein erstes Team an.", | |
| 1097 | + err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.", | |
| 1098 | + err_signup_team_unknown: "Kein Team hat diese Kennung.", | |
| 1099 | + err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", | |
| 1100 | + err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.", | |
| 1036 | 1101 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", |
| 1037 | 1102 | err_password_wrong: "Aktuelles Passwort ist falsch.", |
| 1038 | 1103 | invite_unknown_pat: "Kein Konto „{}“.", |
| @@ -1275,10 +1340,26 @@ pub static PT_BR: Tr = Tr { | ||
| 1275 | 1340 | toast_team_renamed: "Equipe renomeada", |
| 1276 | 1341 | toast_joined_pat: "Você entrou em {}", |
| 1277 | 1342 | toast_team_left: "Você saiu da equipe", |
| 1278 | - err_team_name_taken: "Esse nome de equipe já está em uso", | |
| 1279 | 1343 | err_team_name_empty: "O nome da equipe não pode ficar vazio", |
| 1280 | 1344 | err_last_team: "Você não pode sair da sua última equipe", |
| 1281 | 1345 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", |
| 1346 | + signup_title: "Criar uma conta", | |
| 1347 | + signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.", | |
| 1348 | + signup_key_label: "Chave de inscrição", | |
| 1349 | + signup_key_hint: "Peça a alguém que já usa o Ex Ego.", | |
| 1350 | + signup_submit: "Criar a conta", | |
| 1351 | + signup_link: "Criar uma conta", | |
| 1352 | + signup_have_account: "Já tenho uma conta", | |
| 1353 | + err_signup_key: "Chave de inscrição inválida.", | |
| 1354 | + err_signup_username_taken: "Esse usuário já existe.", | |
| 1355 | + err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.", | |
| 1356 | + signup_team_label: "Equipe inicial", | |
| 1357 | + signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.", | |
| 1358 | + err_signup_team_empty: "Informe uma equipe inicial.", | |
| 1359 | + err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.", | |
| 1360 | + err_signup_team_unknown: "Nenhuma equipe tem esse identificador.", | |
| 1361 | + err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", | |
| 1362 | + err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.", | |
| 1282 | 1363 | err_password_mismatch: "A confirmação não confere.", |
| 1283 | 1364 | err_password_wrong: "Senha atual incorreta.", |
| 1284 | 1365 | invite_unknown_pat: "Nenhuma conta “{}”.", |
| @@ -261,10 +261,27 @@ pub struct Tr { | |||
| 261 | pub toast_team_renamed: &'static str, | 261 | pub toast_team_renamed: &'static str, |
| 262 | pub toast_joined_pat: &'static str, | 262 | pub toast_joined_pat: &'static str, |
| 263 | pub toast_team_left: &'static str, | 263 | pub toast_team_left: &'static str, |
| 264 | - pub err_team_name_taken: &'static str, | ||
| 265 | pub err_team_name_empty: &'static str, | 264 | pub err_team_name_empty: &'static str, |
| 266 | pub err_last_team: &'static str, | 265 | pub err_last_team: &'static str, |
| 267 | pub err_password_short: &'static str, | 266 | pub err_password_short: &'static str, |
| 267 | + // ─── inscription libre-service (SIGNUP_KEY) ─── | ||
| 268 | + pub signup_title: &'static str, | ||
| 269 | + pub signup_intro: &'static str, | ||
| 270 | + pub signup_key_label: &'static str, | ||
| 271 | + pub signup_key_hint: &'static str, | ||
| 272 | + pub signup_submit: &'static str, | ||
| 273 | + pub signup_link: &'static str, | ||
| 274 | + pub signup_have_account: &'static str, | ||
| 275 | + pub err_signup_key: &'static str, | ||
| 276 | + pub err_signup_username_taken: &'static str, | ||
| 277 | + pub err_signup_username_invalid: &'static str, | ||
| 278 | + pub signup_team_label: &'static str, | ||
| 279 | + pub signup_team_hint: &'static str, | ||
| 280 | + pub err_signup_team_empty: &'static str, | ||
| 281 | + pub err_signup_team_ambiguous: &'static str, | ||
| 282 | + pub err_signup_team_unknown: &'static str, | ||
| 283 | + pub err_signup_rate_limited: &'static str, | ||
| 284 | + pub err_signup_too_many_attempts: &'static str, | ||
| 268 | pub err_password_mismatch: &'static str, | 285 | pub err_password_mismatch: &'static str, |
| 269 | pub err_password_wrong: &'static str, | 286 | pub err_password_wrong: &'static str, |
| 270 | pub invite_unknown_pat: &'static str, | 287 | pub invite_unknown_pat: &'static str, |
| @@ -537,10 +554,26 @@ pub static FR: Tr = Tr { | |||
| 537 | toast_team_renamed: "Équipe renommée", | 554 | toast_team_renamed: "Équipe renommée", |
| 538 | toast_joined_pat: "Tu as rejoint {}", | 555 | toast_joined_pat: "Tu as rejoint {}", |
| 539 | toast_team_left: "Équipe quittée", | 556 | toast_team_left: "Équipe quittée", |
| 540 | - err_team_name_taken: "Ce nom d'équipe est déjà pris", | ||
| 541 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", | 557 | err_team_name_empty: "Le nom d'équipe ne peut pas être vide", |
| 542 | err_last_team: "Impossible de quitter ta dernière équipe", | 558 | err_last_team: "Impossible de quitter ta dernière équipe", |
| 543 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", | 559 | err_password_short: "Le nouveau mot de passe doit faire au moins 8 caractères.", |
| 560 | + signup_title: "Créer un compte", | ||
| 561 | + signup_intro: "Choisis un identifiant et un mot de passe, indique ton équipe, et saisis la clé d'inscription.", | ||
| 562 | + signup_key_label: "Clé d'inscription", | ||
| 563 | + signup_key_hint: "Demande-la à quelqu'un qui utilise déjà Ex Ego.", | ||
| 564 | + signup_submit: "Créer le compte", | ||
| 565 | + signup_link: "Créer un compte", | ||
| 566 | + signup_have_account: "J'ai déjà un compte", | ||
| 567 | + err_signup_key: "Clé d'inscription invalide.", | ||
| 568 | + err_signup_username_taken: "Cet identifiant est déjà pris.", | ||
| 569 | + err_signup_username_invalid: "Identifiant invalide : 32 caractères au maximum, sans espace.", | ||
| 570 | + signup_team_label: "Équipe initiale", | ||
| 571 | + signup_team_hint: "Le nom d'une équipe existante pour la rejoindre, ou un nouveau nom pour créer la tienne. Si plusieurs équipes portent le même nom, colle plutôt son identifiant.", | ||
| 572 | + err_signup_team_empty: "Indique une équipe initiale.", | ||
| 573 | + err_signup_team_ambiguous: "Plusieurs équipes portent ce nom : utilise l'identifiant de celle que tu veux rejoindre.", | ||
| 574 | + err_signup_team_unknown: "Aucune équipe ne porte cet identifiant.", | ||
| 575 | + err_signup_rate_limited: "Trop de comptes créés depuis cette connexion. Réessaie dans une heure.", | ||
| 576 | + err_signup_too_many_attempts: "Trop de clés incorrectes depuis cette connexion. Réessaie demain.", | ||
| 544 | err_password_mismatch: "La confirmation ne correspond pas.", | 577 | err_password_mismatch: "La confirmation ne correspond pas.", |
| 545 | err_password_wrong: "Mot de passe actuel incorrect.", | 578 | err_password_wrong: "Mot de passe actuel incorrect.", |
| 546 | invite_unknown_pat: "Aucun compte « {} ».", | 579 | invite_unknown_pat: "Aucun compte « {} ».", |
| @@ -783,10 +816,26 @@ pub static EN: Tr = Tr { | |||
| 783 | toast_team_renamed: "Team renamed", | 816 | toast_team_renamed: "Team renamed", |
| 784 | toast_joined_pat: "You joined {}", | 817 | toast_joined_pat: "You joined {}", |
| 785 | toast_team_left: "Team left", | 818 | toast_team_left: "Team left", |
| 786 | - err_team_name_taken: "This team name is already taken", | ||
| 787 | err_team_name_empty: "The team name cannot be empty", | 819 | err_team_name_empty: "The team name cannot be empty", |
| 788 | err_last_team: "You cannot leave your last team", | 820 | err_last_team: "You cannot leave your last team", |
| 789 | err_password_short: "The new password must be at least 8 characters.", | 821 | err_password_short: "The new password must be at least 8 characters.", |
| 822 | + signup_title: "Create an account", | ||
| 823 | + signup_intro: "Pick a username and a password, name your team, then enter the signup key.", | ||
| 824 | + signup_key_label: "Signup key", | ||
| 825 | + signup_key_hint: "Ask someone who already uses Ex Ego for it.", | ||
| 826 | + signup_submit: "Create the account", | ||
| 827 | + signup_link: "Create an account", | ||
| 828 | + signup_have_account: "I already have an account", | ||
| 829 | + err_signup_key: "Invalid signup key.", | ||
| 830 | + err_signup_username_taken: "That username is already taken.", | ||
| 831 | + err_signup_username_invalid: "Invalid username: 32 characters at most, no spaces.", | ||
| 832 | + signup_team_label: "Initial team", | ||
| 833 | + signup_team_hint: "The name of an existing team to join it, or a new name to create your own. If several teams share a name, paste its identifier instead.", | ||
| 834 | + err_signup_team_empty: "Enter an initial team.", | ||
| 835 | + err_signup_team_ambiguous: "Several teams share that name: use the identifier of the one you want to join.", | ||
| 836 | + err_signup_team_unknown: "No team has that identifier.", | ||
| 837 | + err_signup_rate_limited: "Too many accounts created from this connection. Try again in an hour.", | ||
| 838 | + err_signup_too_many_attempts: "Too many wrong keys from this connection. Try again tomorrow.", | ||
| 790 | err_password_mismatch: "The confirmation does not match.", | 839 | err_password_mismatch: "The confirmation does not match.", |
| 791 | err_password_wrong: "Current password is incorrect.", | 840 | err_password_wrong: "Current password is incorrect.", |
| 792 | invite_unknown_pat: "No account “{}”.", | 841 | invite_unknown_pat: "No account “{}”.", |
| @@ -1029,10 +1078,26 @@ pub static DE: Tr = Tr { | |||
| 1029 | toast_team_renamed: "Team umbenannt", | 1078 | toast_team_renamed: "Team umbenannt", |
| 1030 | toast_joined_pat: "Du bist {} beigetreten", | 1079 | toast_joined_pat: "Du bist {} beigetreten", |
| 1031 | toast_team_left: "Team verlassen", | 1080 | toast_team_left: "Team verlassen", |
| 1032 | - err_team_name_taken: "Dieser Teamname ist bereits vergeben", | ||
| 1033 | err_team_name_empty: "Der Teamname darf nicht leer sein", | 1081 | err_team_name_empty: "Der Teamname darf nicht leer sein", |
| 1034 | err_last_team: "Du kannst dein letztes Team nicht verlassen", | 1082 | err_last_team: "Du kannst dein letztes Team nicht verlassen", |
| 1035 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", | 1083 | err_password_short: "Das neue Passwort muss mindestens 8 Zeichen lang sein.", |
| 1084 | + signup_title: "Konto erstellen", | ||
| 1085 | + signup_intro: "Wähle einen Benutzernamen und ein Passwort, nenne dein Team und gib den Anmeldeschlüssel ein.", | ||
| 1086 | + signup_key_label: "Anmeldeschlüssel", | ||
| 1087 | + signup_key_hint: "Frag jemanden, der Ex Ego schon nutzt.", | ||
| 1088 | + signup_submit: "Konto erstellen", | ||
| 1089 | + signup_link: "Konto erstellen", | ||
| 1090 | + signup_have_account: "Ich habe schon ein Konto", | ||
| 1091 | + err_signup_key: "Ungültiger Anmeldeschlüssel.", | ||
| 1092 | + err_signup_username_taken: "Dieser Benutzername ist schon vergeben.", | ||
| 1093 | + err_signup_username_invalid: "Ungültiger Benutzername: höchstens 32 Zeichen, keine Leerzeichen.", | ||
| 1094 | + signup_team_label: "Erstes Team", | ||
| 1095 | + signup_team_hint: "Der Name eines bestehenden Teams, um beizutreten, oder ein neuer Name für dein eigenes. Tragen mehrere Teams denselben Namen, füge lieber die Kennung ein.", | ||
| 1096 | + err_signup_team_empty: "Gib ein erstes Team an.", | ||
| 1097 | + err_signup_team_ambiguous: "Mehrere Teams tragen diesen Namen: Nimm die Kennung des gewünschten Teams.", | ||
| 1098 | + err_signup_team_unknown: "Kein Team hat diese Kennung.", | ||
| 1099 | + err_signup_rate_limited: "Zu viele Konten von dieser Verbindung erstellt. Versuch es in einer Stunde erneut.", | ||
| 1100 | + err_signup_too_many_attempts: "Zu viele falsche Schlüssel von dieser Verbindung. Versuch es morgen erneut.", | ||
| 1036 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", | 1101 | err_password_mismatch: "Die Bestätigung stimmt nicht überein.", |
| 1037 | err_password_wrong: "Aktuelles Passwort ist falsch.", | 1102 | err_password_wrong: "Aktuelles Passwort ist falsch.", |
| 1038 | invite_unknown_pat: "Kein Konto „{}“.", | 1103 | invite_unknown_pat: "Kein Konto „{}“.", |
| @@ -1275,10 +1340,26 @@ pub static PT_BR: Tr = Tr { | |||
| 1275 | toast_team_renamed: "Equipe renomeada", | 1340 | toast_team_renamed: "Equipe renomeada", |
| 1276 | toast_joined_pat: "Você entrou em {}", | 1341 | toast_joined_pat: "Você entrou em {}", |
| 1277 | toast_team_left: "Você saiu da equipe", | 1342 | toast_team_left: "Você saiu da equipe", |
| 1278 | - err_team_name_taken: "Esse nome de equipe já está em uso", | ||
| 1279 | err_team_name_empty: "O nome da equipe não pode ficar vazio", | 1343 | err_team_name_empty: "O nome da equipe não pode ficar vazio", |
| 1280 | err_last_team: "Você não pode sair da sua última equipe", | 1344 | err_last_team: "Você não pode sair da sua última equipe", |
| 1281 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", | 1345 | err_password_short: "A nova senha deve ter pelo menos 8 caracteres.", |
| 1346 | + signup_title: "Criar uma conta", | ||
| 1347 | + signup_intro: "Escolha um usuário e uma senha, indique sua equipe e informe a chave de inscrição.", | ||
| 1348 | + signup_key_label: "Chave de inscrição", | ||
| 1349 | + signup_key_hint: "Peça a alguém que já usa o Ex Ego.", | ||
| 1350 | + signup_submit: "Criar a conta", | ||
| 1351 | + signup_link: "Criar uma conta", | ||
| 1352 | + signup_have_account: "Já tenho uma conta", | ||
| 1353 | + err_signup_key: "Chave de inscrição inválida.", | ||
| 1354 | + err_signup_username_taken: "Esse usuário já existe.", | ||
| 1355 | + err_signup_username_invalid: "Usuário inválido: no máximo 32 caracteres, sem espaços.", | ||
| 1356 | + signup_team_label: "Equipe inicial", | ||
| 1357 | + signup_team_hint: "O nome de uma equipe existente para entrar nela, ou um nome novo para criar a sua. Se várias equipes tiverem o mesmo nome, cole o identificador.", | ||
| 1358 | + err_signup_team_empty: "Informe uma equipe inicial.", | ||
| 1359 | + err_signup_team_ambiguous: "Várias equipes têm esse nome: use o identificador da que você quer entrar.", | ||
| 1360 | + err_signup_team_unknown: "Nenhuma equipe tem esse identificador.", | ||
| 1361 | + err_signup_rate_limited: "Contas demais criadas desta conexão. Tente de novo daqui a uma hora.", | ||
| 1362 | + err_signup_too_many_attempts: "Chaves incorretas demais desta conexão. Tente de novo amanhã.", | ||
| 1282 | err_password_mismatch: "A confirmação não confere.", | 1363 | err_password_mismatch: "A confirmação não confere.", |
| 1283 | err_password_wrong: "Senha atual incorreta.", | 1364 | err_password_wrong: "Senha atual incorreta.", |
| 1284 | invite_unknown_pat: "Nenhuma conta “{}”.", | 1365 | invite_unknown_pat: "Nenhuma conta “{}”.", |
modified
src/main.rs +4 -0 | @@ -5,6 +5,7 @@ mod config; | ||
| 5 | 5 | mod db; |
| 6 | 6 | mod error; |
| 7 | 7 | mod period; |
| 8 | +mod ratelimit; | |
| 8 | 9 | mod render; |
| 9 | 10 | mod i18n; |
| 10 | 11 | mod routes; |
| @@ -131,6 +132,9 @@ async fn serve() -> Result<()> { | ||
| 131 | 132 | .with_context(|| format!("bind {addr}"))?; |
| 132 | 133 | tracing::info!("écoute sur http://{addr}"); |
| 133 | 134 | |
| 135 | + // ConnectInfo : IP de repli du limiteur d'inscriptions quand aucun | |
| 136 | + // en-tête de proxy n'est là (développement local). | |
| 137 | + let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>(); | |
| 134 | 138 | axum::serve(listener, app) |
| 135 | 139 | .with_graceful_shutdown(async { |
| 136 | 140 | let _ = tokio::signal::ctrl_c().await; |
| @@ -5,6 +5,7 @@ mod config; | |||
| 5 | mod db; | 5 | mod db; |
| 6 | mod error; | 6 | mod error; |
| 7 | mod period; | 7 | mod period; |
| 8 | +mod ratelimit; | ||
| 8 | mod render; | 9 | mod render; |
| 9 | mod i18n; | 10 | mod i18n; |
| 10 | mod routes; | 11 | mod routes; |
| @@ -131,6 +132,9 @@ async fn serve() -> Result<()> { | |||
| 131 | .with_context(|| format!("bind {addr}"))?; | 132 | .with_context(|| format!("bind {addr}"))?; |
| 132 | tracing::info!("écoute sur http://{addr}"); | 133 | tracing::info!("écoute sur http://{addr}"); |
| 133 | 134 | ||
| 135 | + // ConnectInfo : IP de repli du limiteur d'inscriptions quand aucun | ||
| 136 | + // en-tête de proxy n'est là (développement local). | ||
| 137 | + let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>(); | ||
| 134 | axum::serve(listener, app) | 138 | axum::serve(listener, app) |
| 135 | .with_graceful_shutdown(async { | 139 | .with_graceful_shutdown(async { |
| 136 | let _ = tokio::signal::ctrl_c().await; | 140 | let _ = tokio::signal::ctrl_c().await; |
added
src/ratelimit.rs +295 -0 | new file mode 100644 | ||
| @@ -0,0 +1,295 @@ | ||
| 1 | +//! Fenêtres glissantes EN MÉMOIRE, par adresse IP. | |
| 2 | +//! | |
| 3 | +//! Volontairement sans table : les compteurs vivent dans l'instance, repartent | |
| 4 | +//! à zéro au déploiement et ne sont pas partagés entre instances. C'est un | |
| 5 | +//! frein à l'abus, pas un quota comptable. | |
| 6 | +//! | |
| 7 | +//! Deux quotas distincts sur l'inscription : | |
| 8 | +//! - `Quota::signups()` — 20 comptes CRÉÉS par heure. Un identifiant déjà pris | |
| 9 | +//! ou une équipe ambiguë ne consomment rien, seule une création compte. | |
| 10 | +//! - `Quota::bad_keys()` — 10 clés FAUSSES par jour, le frein anti-force brute | |
| 11 | +//! sur la clé d'inscription. | |
| 12 | + | |
| 13 | +use std::collections::HashMap; | |
| 14 | +use std::net::SocketAddr; | |
| 15 | +use std::sync::{Arc, Mutex}; | |
| 16 | +use std::time::{Duration, Instant}; | |
| 17 | + | |
| 18 | +use axum::http::HeaderMap; | |
| 19 | + | |
| 20 | +const HOUR: Duration = Duration::from_secs(3600); | |
| 21 | +const DAY: Duration = Duration::from_secs(24 * 3600); | |
| 22 | + | |
| 23 | +/// Garde-fou mémoire : `X-Forwarded-For` est fourni par le client, donc le | |
| 24 | +/// nombre de clés est contrôlable de l'extérieur. Au-delà, on purge les IP | |
| 25 | +/// dont le dernier passage est le plus ancien. | |
| 26 | +const MAX_TRACKED: usize = 10_000; | |
| 27 | + | |
| 28 | +/// Un quota « N passages par fenêtre glissante, par IP ». | |
| 29 | +#[derive(Clone)] | |
| 30 | +pub struct Quota { | |
| 31 | + hits: Arc<Mutex<HashMap<String, Vec<Instant>>>>, | |
| 32 | + max: usize, | |
| 33 | + window: Duration, | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl Quota { | |
| 37 | + pub fn new(max: usize, window: Duration) -> Self { | |
| 38 | + Self { | |
| 39 | + hits: Arc::new(Mutex::new(HashMap::new())), | |
| 40 | + max, | |
| 41 | + window, | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// 20 comptes créés par heure. | |
| 46 | + pub fn signups() -> Self { | |
| 47 | + Self::new(20, HOUR) | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// 10 clés d'inscription fausses par jour. | |
| 51 | + pub fn bad_keys() -> Self { | |
| 52 | + Self::new(10, DAY) | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// L'IP a-t-elle encore du quota ? N'enregistre rien. | |
| 56 | + pub fn allows(&self, ip: &str) -> bool { | |
| 57 | + self.allows_at(ip, Instant::now()) | |
| 58 | + } | |
| 59 | + | |
| 60 | + pub fn record(&self, ip: &str) { | |
| 61 | + self.record_at(ip, Instant::now()) | |
| 62 | + } | |
| 63 | + | |
| 64 | + fn allows_at(&self, ip: &str, now: Instant) -> bool { | |
| 65 | + let mut map = self.hits.lock().expect("quota empoisonné"); | |
| 66 | + match map.get_mut(ip) { | |
| 67 | + Some(hits) => { | |
| 68 | + prune(hits, now, self.window); | |
| 69 | + let allowed = hits.len() < self.max; | |
| 70 | + if hits.is_empty() { | |
| 71 | + map.remove(ip); | |
| 72 | + } | |
| 73 | + allowed | |
| 74 | + } | |
| 75 | + None => true, | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + fn record_at(&self, ip: &str, now: Instant) { | |
| 80 | + let mut map = self.hits.lock().expect("quota empoisonné"); | |
| 81 | + let hits = map.entry(ip.to_string()).or_default(); | |
| 82 | + prune(hits, now, self.window); | |
| 83 | + hits.push(now); | |
| 84 | + if map.len() > MAX_TRACKED { | |
| 85 | + evict(&mut map, now, self.window); | |
| 86 | + } | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// Jette les passages sortis de la fenêtre. Les instants sont poussés dans | |
| 91 | +/// l'ordre, donc le vecteur est trié : une coupe au premier survivant suffit. | |
| 92 | +fn prune(hits: &mut Vec<Instant>, now: Instant, window: Duration) { | |
| 93 | + match now.checked_sub(window) { | |
| 94 | + Some(cutoff) => { | |
| 95 | + let keep = hits.partition_point(|t| *t <= cutoff); | |
| 96 | + hits.drain(..keep); | |
| 97 | + } | |
| 98 | + // Uptime plus court que la fenêtre : rien n'a pu en sortir. | |
| 99 | + None => {} | |
| 100 | + } | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// Purge d'abord les IP entièrement périmées ; si ça ne suffit pas, sacrifie | |
| 104 | +/// les plus anciennes (elles repartent avec un quota neuf — la mémoire prime). | |
| 105 | +fn evict(map: &mut HashMap<String, Vec<Instant>>, now: Instant, window: Duration) { | |
| 106 | + map.retain(|_, hits| { | |
| 107 | + prune(hits, now, window); | |
| 108 | + !hits.is_empty() | |
| 109 | + }); | |
| 110 | + if map.len() <= MAX_TRACKED { | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + let mut last: Vec<(String, Instant)> = map | |
| 114 | + .iter() | |
| 115 | + .filter_map(|(ip, hits)| hits.last().map(|t| (ip.clone(), *t))) | |
| 116 | + .collect(); | |
| 117 | + last.sort_by_key(|(_, t)| *t); | |
| 118 | + for (ip, _) in last.into_iter().take(map.len() - MAX_TRACKED) { | |
| 119 | + map.remove(&ip); | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +/// IP du client : `X-Forwarded-For` puis `Forwarded` (RFC 7239), sinon la | |
| 124 | +/// socket. Derrière le reverse proxy de Clever Cloud, c'est toujours le premier | |
| 125 | +/// en-tête ; en local il n'y en a aucun. | |
| 126 | +/// | |
| 127 | +/// ⚠ Ces en-têtes sont déclaratifs : un client direct peut les forger. Ils ne | |
| 128 | +/// font foi que parce qu'un proxy de confiance les réécrit. | |
| 129 | +pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { | |
| 130 | + if let Some(ip) = headers | |
| 131 | + .get("x-forwarded-for") | |
| 132 | + .and_then(|v| v.to_str().ok()) | |
| 133 | + .and_then(|v| v.split(',').next()) | |
| 134 | + .map(str::trim) | |
| 135 | + .filter(|v| !v.is_empty()) | |
| 136 | + { | |
| 137 | + return normalize(ip); | |
| 138 | + } | |
| 139 | + if let Some(ip) = headers | |
| 140 | + .get("forwarded") | |
| 141 | + .and_then(|v| v.to_str().ok()) | |
| 142 | + .and_then(first_for) | |
| 143 | + { | |
| 144 | + return normalize(&ip); | |
| 145 | + } | |
| 146 | + peer.map(|p| p.ip().to_string()) | |
| 147 | + .unwrap_or_else(|| "-".to_string()) | |
| 148 | +} | |
| 149 | + | |
| 150 | +/// Premier `for=` d'un en-tête `Forwarded` : `for=192.0.2.1;proto=https, for=…` | |
| 151 | +fn first_for(value: &str) -> Option<String> { | |
| 152 | + value | |
| 153 | + .split(',') | |
| 154 | + .next()? | |
| 155 | + .split(';') | |
| 156 | + .filter_map(|p| { | |
| 157 | + let (k, v) = p.split_once('=')?; | |
| 158 | + k.trim().eq_ignore_ascii_case("for").then(|| v.trim()) | |
| 159 | + }) | |
| 160 | + .next() | |
| 161 | + .map(|v| v.trim_matches('"').to_string()) | |
| 162 | +} | |
| 163 | + | |
| 164 | +/// `"[2001:db8::1]:443"` → `2001:db8::1`, `"192.0.2.1:443"` → `192.0.2.1`. | |
| 165 | +/// Le port ne fait pas partie de l'identité du client. | |
| 166 | +fn normalize(raw: &str) -> String { | |
| 167 | + let raw = raw.trim().trim_matches('"'); | |
| 168 | + if let Some(rest) = raw.strip_prefix('[') { | |
| 169 | + return rest.split(']').next().unwrap_or(rest).to_string(); | |
| 170 | + } | |
| 171 | + // Un seul « : » = IPv4 avec port ; plusieurs = IPv6 nu, qu'on garde entier. | |
| 172 | + match (raw.matches(':').count(), raw.rsplit_once(':')) { | |
| 173 | + (1, Some((host, _))) => host.to_string(), | |
| 174 | + _ => raw.to_string(), | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +#[cfg(test)] | |
| 179 | +mod tests { | |
| 180 | + use super::*; | |
| 181 | + | |
| 182 | + fn hdr(pairs: &[(&str, &str)]) -> HeaderMap { | |
| 183 | + let mut h = HeaderMap::new(); | |
| 184 | + for (k, v) in pairs { | |
| 185 | + let name = axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(); | |
| 186 | + h.insert(name, v.parse().unwrap()); | |
| 187 | + } | |
| 188 | + h | |
| 189 | + } | |
| 190 | + | |
| 191 | + #[test] | |
| 192 | + fn x_forwarded_for_wins_and_keeps_the_first_hop() { | |
| 193 | + let h = hdr(&[ | |
| 194 | + ("x-forwarded-for", "203.0.113.7, 70.41.3.18"), | |
| 195 | + ("forwarded", "for=198.51.100.9"), | |
| 196 | + ]); | |
| 197 | + assert_eq!(client_ip(&h, None), "203.0.113.7"); | |
| 198 | + } | |
| 199 | + | |
| 200 | + #[test] | |
| 201 | + fn forwarded_is_the_fallback() { | |
| 202 | + let h = hdr(&[("forwarded", "proto=https;for=198.51.100.9, for=70.41.3.18")]); | |
| 203 | + assert_eq!(client_ip(&h, None), "198.51.100.9"); | |
| 204 | + let h = hdr(&[("forwarded", "For=\"[2001:db8::1]:443\"")]); | |
| 205 | + assert_eq!(client_ip(&h, None), "2001:db8::1"); | |
| 206 | + } | |
| 207 | + | |
| 208 | + #[test] | |
| 209 | + fn falls_back_to_the_socket_then_to_a_shared_bucket() { | |
| 210 | + let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap(); | |
| 211 | + assert_eq!(client_ip(&HeaderMap::new(), Some(peer)), "192.0.2.4"); | |
| 212 | + assert_eq!(client_ip(&HeaderMap::new(), None), "-"); | |
| 213 | + } | |
| 214 | + | |
| 215 | + /// Un en-tête vide ne doit pas créer une clé « tout le monde ensemble » | |
| 216 | + /// silencieuse : on retombe sur la socket. | |
| 217 | + #[test] | |
| 218 | + fn empty_header_is_ignored() { | |
| 219 | + let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap(); | |
| 220 | + let h = hdr(&[("x-forwarded-for", "")]); | |
| 221 | + assert_eq!(client_ip(&h, Some(peer)), "192.0.2.4"); | |
| 222 | + } | |
| 223 | + | |
| 224 | + #[test] | |
| 225 | + fn twenty_creations_per_hour_then_refusal() { | |
| 226 | + let q = Quota::signups(); | |
| 227 | + let t0 = Instant::now(); | |
| 228 | + for i in 0..20 { | |
| 229 | + assert!(q.allows_at("ip", t0), "refus prématuré au {i}e"); | |
| 230 | + q.record_at("ip", t0); | |
| 231 | + } | |
| 232 | + assert!(!q.allows_at("ip", t0), "le 21e aurait dû être refusé"); | |
| 233 | + } | |
| 234 | + | |
| 235 | + #[test] | |
| 236 | + fn ten_bad_keys_per_day_then_refusal() { | |
| 237 | + let q = Quota::bad_keys(); | |
| 238 | + let t0 = Instant::now(); | |
| 239 | + for i in 0..10 { | |
| 240 | + assert!(q.allows_at("ip", t0), "refus prématuré au {i}e"); | |
| 241 | + // Étalé sur la journée : tout reste dans la fenêtre. | |
| 242 | + q.record_at("ip", t0 + Duration::from_secs(3600 * i as u64)); | |
| 243 | + } | |
| 244 | + let end_of_day = t0 + Duration::from_secs(3600 * 23); | |
| 245 | + assert!(!q.allows_at("ip", end_of_day), "le 11e aurait dû être refusé"); | |
| 246 | + // Le lendemain, la première tentative est sortie de la fenêtre. | |
| 247 | + assert!(q.allows_at("ip", t0 + DAY + Duration::from_secs(1))); | |
| 248 | + } | |
| 249 | + | |
| 250 | + /// Les deux quotas sont indépendants : épuiser les clés fausses ne doit | |
| 251 | + /// pas entamer le quota de créations, et réciproquement. | |
| 252 | + #[test] | |
| 253 | + fn the_two_quotas_are_independent() { | |
| 254 | + let creations = Quota::signups(); | |
| 255 | + let bad = Quota::bad_keys(); | |
| 256 | + let t0 = Instant::now(); | |
| 257 | + for _ in 0..10 { | |
| 258 | + bad.record_at("ip", t0); | |
| 259 | + } | |
| 260 | + assert!(!bad.allows_at("ip", t0)); | |
| 261 | + assert!(creations.allows_at("ip", t0)); | |
| 262 | + } | |
| 263 | + | |
| 264 | + #[test] | |
| 265 | + fn the_window_slides() { | |
| 266 | + let q = Quota::signups(); | |
| 267 | + let t0 = Instant::now(); | |
| 268 | + for i in 0..20 { | |
| 269 | + // Un compte par minute : la fenêtre se vide dans le même ordre. | |
| 270 | + q.record_at("ip", t0 + Duration::from_secs(60 * i as u64)); | |
| 271 | + } | |
| 272 | + let full = t0 + Duration::from_secs(60 * 19); | |
| 273 | + assert!(!q.allows_at("ip", full)); | |
| 274 | + // Une heure après le tout premier, il ressort de la fenêtre : une place. | |
| 275 | + assert!(q.allows_at("ip", t0 + HOUR + Duration::from_secs(1))); | |
| 276 | + // Deux heures après, plus rien ne compte. | |
| 277 | + assert!(q.allows_at("ip", t0 + HOUR * 2)); | |
| 278 | + assert_eq!( | |
| 279 | + q.hits.lock().unwrap().len(), | |
| 280 | + 0, | |
| 281 | + "l'entrée vide doit disparaître" | |
| 282 | + ); | |
| 283 | + } | |
| 284 | + | |
| 285 | + #[test] | |
| 286 | + fn quotas_are_per_ip() { | |
| 287 | + let q = Quota::signups(); | |
| 288 | + let t0 = Instant::now(); | |
| 289 | + for _ in 0..20 { | |
| 290 | + q.record_at("a", t0); | |
| 291 | + } | |
| 292 | + assert!(!q.allows_at("a", t0)); | |
| 293 | + assert!(q.allows_at("b", t0)); | |
| 294 | + } | |
| 295 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,295 @@ | |||
| 1 | +//! Fenêtres glissantes EN MÉMOIRE, par adresse IP. | ||
| 2 | +//! | ||
| 3 | +//! Volontairement sans table : les compteurs vivent dans l'instance, repartent | ||
| 4 | +//! à zéro au déploiement et ne sont pas partagés entre instances. C'est un | ||
| 5 | +//! frein à l'abus, pas un quota comptable. | ||
| 6 | +//! | ||
| 7 | +//! Deux quotas distincts sur l'inscription : | ||
| 8 | +//! - `Quota::signups()` — 20 comptes CRÉÉS par heure. Un identifiant déjà pris | ||
| 9 | +//! ou une équipe ambiguë ne consomment rien, seule une création compte. | ||
| 10 | +//! - `Quota::bad_keys()` — 10 clés FAUSSES par jour, le frein anti-force brute | ||
| 11 | +//! sur la clé d'inscription. | ||
| 12 | + | ||
| 13 | +use std::collections::HashMap; | ||
| 14 | +use std::net::SocketAddr; | ||
| 15 | +use std::sync::{Arc, Mutex}; | ||
| 16 | +use std::time::{Duration, Instant}; | ||
| 17 | + | ||
| 18 | +use axum::http::HeaderMap; | ||
| 19 | + | ||
| 20 | +const HOUR: Duration = Duration::from_secs(3600); | ||
| 21 | +const DAY: Duration = Duration::from_secs(24 * 3600); | ||
| 22 | + | ||
| 23 | +/// Garde-fou mémoire : `X-Forwarded-For` est fourni par le client, donc le | ||
| 24 | +/// nombre de clés est contrôlable de l'extérieur. Au-delà, on purge les IP | ||
| 25 | +/// dont le dernier passage est le plus ancien. | ||
| 26 | +const MAX_TRACKED: usize = 10_000; | ||
| 27 | + | ||
| 28 | +/// Un quota « N passages par fenêtre glissante, par IP ». | ||
| 29 | +#[derive(Clone)] | ||
| 30 | +pub struct Quota { | ||
| 31 | + hits: Arc<Mutex<HashMap<String, Vec<Instant>>>>, | ||
| 32 | + max: usize, | ||
| 33 | + window: Duration, | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +impl Quota { | ||
| 37 | + pub fn new(max: usize, window: Duration) -> Self { | ||
| 38 | + Self { | ||
| 39 | + hits: Arc::new(Mutex::new(HashMap::new())), | ||
| 40 | + max, | ||
| 41 | + window, | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + /// 20 comptes créés par heure. | ||
| 46 | + pub fn signups() -> Self { | ||
| 47 | + Self::new(20, HOUR) | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + /// 10 clés d'inscription fausses par jour. | ||
| 51 | + pub fn bad_keys() -> Self { | ||
| 52 | + Self::new(10, DAY) | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + /// L'IP a-t-elle encore du quota ? N'enregistre rien. | ||
| 56 | + pub fn allows(&self, ip: &str) -> bool { | ||
| 57 | + self.allows_at(ip, Instant::now()) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + pub fn record(&self, ip: &str) { | ||
| 61 | + self.record_at(ip, Instant::now()) | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + fn allows_at(&self, ip: &str, now: Instant) -> bool { | ||
| 65 | + let mut map = self.hits.lock().expect("quota empoisonné"); | ||
| 66 | + match map.get_mut(ip) { | ||
| 67 | + Some(hits) => { | ||
| 68 | + prune(hits, now, self.window); | ||
| 69 | + let allowed = hits.len() < self.max; | ||
| 70 | + if hits.is_empty() { | ||
| 71 | + map.remove(ip); | ||
| 72 | + } | ||
| 73 | + allowed | ||
| 74 | + } | ||
| 75 | + None => true, | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + fn record_at(&self, ip: &str, now: Instant) { | ||
| 80 | + let mut map = self.hits.lock().expect("quota empoisonné"); | ||
| 81 | + let hits = map.entry(ip.to_string()).or_default(); | ||
| 82 | + prune(hits, now, self.window); | ||
| 83 | + hits.push(now); | ||
| 84 | + if map.len() > MAX_TRACKED { | ||
| 85 | + evict(&mut map, now, self.window); | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +/// Jette les passages sortis de la fenêtre. Les instants sont poussés dans | ||
| 91 | +/// l'ordre, donc le vecteur est trié : une coupe au premier survivant suffit. | ||
| 92 | +fn prune(hits: &mut Vec<Instant>, now: Instant, window: Duration) { | ||
| 93 | + match now.checked_sub(window) { | ||
| 94 | + Some(cutoff) => { | ||
| 95 | + let keep = hits.partition_point(|t| *t <= cutoff); | ||
| 96 | + hits.drain(..keep); | ||
| 97 | + } | ||
| 98 | + // Uptime plus court que la fenêtre : rien n'a pu en sortir. | ||
| 99 | + None => {} | ||
| 100 | + } | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +/// Purge d'abord les IP entièrement périmées ; si ça ne suffit pas, sacrifie | ||
| 104 | +/// les plus anciennes (elles repartent avec un quota neuf — la mémoire prime). | ||
| 105 | +fn evict(map: &mut HashMap<String, Vec<Instant>>, now: Instant, window: Duration) { | ||
| 106 | + map.retain(|_, hits| { | ||
| 107 | + prune(hits, now, window); | ||
| 108 | + !hits.is_empty() | ||
| 109 | + }); | ||
| 110 | + if map.len() <= MAX_TRACKED { | ||
| 111 | + return; | ||
| 112 | + } | ||
| 113 | + let mut last: Vec<(String, Instant)> = map | ||
| 114 | + .iter() | ||
| 115 | + .filter_map(|(ip, hits)| hits.last().map(|t| (ip.clone(), *t))) | ||
| 116 | + .collect(); | ||
| 117 | + last.sort_by_key(|(_, t)| *t); | ||
| 118 | + for (ip, _) in last.into_iter().take(map.len() - MAX_TRACKED) { | ||
| 119 | + map.remove(&ip); | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +/// IP du client : `X-Forwarded-For` puis `Forwarded` (RFC 7239), sinon la | ||
| 124 | +/// socket. Derrière le reverse proxy de Clever Cloud, c'est toujours le premier | ||
| 125 | +/// en-tête ; en local il n'y en a aucun. | ||
| 126 | +/// | ||
| 127 | +/// ⚠ Ces en-têtes sont déclaratifs : un client direct peut les forger. Ils ne | ||
| 128 | +/// font foi que parce qu'un proxy de confiance les réécrit. | ||
| 129 | +pub fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>) -> String { | ||
| 130 | + if let Some(ip) = headers | ||
| 131 | + .get("x-forwarded-for") | ||
| 132 | + .and_then(|v| v.to_str().ok()) | ||
| 133 | + .and_then(|v| v.split(',').next()) | ||
| 134 | + .map(str::trim) | ||
| 135 | + .filter(|v| !v.is_empty()) | ||
| 136 | + { | ||
| 137 | + return normalize(ip); | ||
| 138 | + } | ||
| 139 | + if let Some(ip) = headers | ||
| 140 | + .get("forwarded") | ||
| 141 | + .and_then(|v| v.to_str().ok()) | ||
| 142 | + .and_then(first_for) | ||
| 143 | + { | ||
| 144 | + return normalize(&ip); | ||
| 145 | + } | ||
| 146 | + peer.map(|p| p.ip().to_string()) | ||
| 147 | + .unwrap_or_else(|| "-".to_string()) | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +/// Premier `for=` d'un en-tête `Forwarded` : `for=192.0.2.1;proto=https, for=…` | ||
| 151 | +fn first_for(value: &str) -> Option<String> { | ||
| 152 | + value | ||
| 153 | + .split(',') | ||
| 154 | + .next()? | ||
| 155 | + .split(';') | ||
| 156 | + .filter_map(|p| { | ||
| 157 | + let (k, v) = p.split_once('=')?; | ||
| 158 | + k.trim().eq_ignore_ascii_case("for").then(|| v.trim()) | ||
| 159 | + }) | ||
| 160 | + .next() | ||
| 161 | + .map(|v| v.trim_matches('"').to_string()) | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +/// `"[2001:db8::1]:443"` → `2001:db8::1`, `"192.0.2.1:443"` → `192.0.2.1`. | ||
| 165 | +/// Le port ne fait pas partie de l'identité du client. | ||
| 166 | +fn normalize(raw: &str) -> String { | ||
| 167 | + let raw = raw.trim().trim_matches('"'); | ||
| 168 | + if let Some(rest) = raw.strip_prefix('[') { | ||
| 169 | + return rest.split(']').next().unwrap_or(rest).to_string(); | ||
| 170 | + } | ||
| 171 | + // Un seul « : » = IPv4 avec port ; plusieurs = IPv6 nu, qu'on garde entier. | ||
| 172 | + match (raw.matches(':').count(), raw.rsplit_once(':')) { | ||
| 173 | + (1, Some((host, _))) => host.to_string(), | ||
| 174 | + _ => raw.to_string(), | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +#[cfg(test)] | ||
| 179 | +mod tests { | ||
| 180 | + use super::*; | ||
| 181 | + | ||
| 182 | + fn hdr(pairs: &[(&str, &str)]) -> HeaderMap { | ||
| 183 | + let mut h = HeaderMap::new(); | ||
| 184 | + for (k, v) in pairs { | ||
| 185 | + let name = axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(); | ||
| 186 | + h.insert(name, v.parse().unwrap()); | ||
| 187 | + } | ||
| 188 | + h | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + #[test] | ||
| 192 | + fn x_forwarded_for_wins_and_keeps_the_first_hop() { | ||
| 193 | + let h = hdr(&[ | ||
| 194 | + ("x-forwarded-for", "203.0.113.7, 70.41.3.18"), | ||
| 195 | + ("forwarded", "for=198.51.100.9"), | ||
| 196 | + ]); | ||
| 197 | + assert_eq!(client_ip(&h, None), "203.0.113.7"); | ||
| 198 | + } | ||
| 199 | + | ||
| 200 | + #[test] | ||
| 201 | + fn forwarded_is_the_fallback() { | ||
| 202 | + let h = hdr(&[("forwarded", "proto=https;for=198.51.100.9, for=70.41.3.18")]); | ||
| 203 | + assert_eq!(client_ip(&h, None), "198.51.100.9"); | ||
| 204 | + let h = hdr(&[("forwarded", "For=\"[2001:db8::1]:443\"")]); | ||
| 205 | + assert_eq!(client_ip(&h, None), "2001:db8::1"); | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + #[test] | ||
| 209 | + fn falls_back_to_the_socket_then_to_a_shared_bucket() { | ||
| 210 | + let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap(); | ||
| 211 | + assert_eq!(client_ip(&HeaderMap::new(), Some(peer)), "192.0.2.4"); | ||
| 212 | + assert_eq!(client_ip(&HeaderMap::new(), None), "-"); | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + /// Un en-tête vide ne doit pas créer une clé « tout le monde ensemble » | ||
| 216 | + /// silencieuse : on retombe sur la socket. | ||
| 217 | + #[test] | ||
| 218 | + fn empty_header_is_ignored() { | ||
| 219 | + let peer: SocketAddr = "192.0.2.4:51000".parse().unwrap(); | ||
| 220 | + let h = hdr(&[("x-forwarded-for", "")]); | ||
| 221 | + assert_eq!(client_ip(&h, Some(peer)), "192.0.2.4"); | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + #[test] | ||
| 225 | + fn twenty_creations_per_hour_then_refusal() { | ||
| 226 | + let q = Quota::signups(); | ||
| 227 | + let t0 = Instant::now(); | ||
| 228 | + for i in 0..20 { | ||
| 229 | + assert!(q.allows_at("ip", t0), "refus prématuré au {i}e"); | ||
| 230 | + q.record_at("ip", t0); | ||
| 231 | + } | ||
| 232 | + assert!(!q.allows_at("ip", t0), "le 21e aurait dû être refusé"); | ||
| 233 | + } | ||
| 234 | + | ||
| 235 | + #[test] | ||
| 236 | + fn ten_bad_keys_per_day_then_refusal() { | ||
| 237 | + let q = Quota::bad_keys(); | ||
| 238 | + let t0 = Instant::now(); | ||
| 239 | + for i in 0..10 { | ||
| 240 | + assert!(q.allows_at("ip", t0), "refus prématuré au {i}e"); | ||
| 241 | + // Étalé sur la journée : tout reste dans la fenêtre. | ||
| 242 | + q.record_at("ip", t0 + Duration::from_secs(3600 * i as u64)); | ||
| 243 | + } | ||
| 244 | + let end_of_day = t0 + Duration::from_secs(3600 * 23); | ||
| 245 | + assert!(!q.allows_at("ip", end_of_day), "le 11e aurait dû être refusé"); | ||
| 246 | + // Le lendemain, la première tentative est sortie de la fenêtre. | ||
| 247 | + assert!(q.allows_at("ip", t0 + DAY + Duration::from_secs(1))); | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + /// Les deux quotas sont indépendants : épuiser les clés fausses ne doit | ||
| 251 | + /// pas entamer le quota de créations, et réciproquement. | ||
| 252 | + #[test] | ||
| 253 | + fn the_two_quotas_are_independent() { | ||
| 254 | + let creations = Quota::signups(); | ||
| 255 | + let bad = Quota::bad_keys(); | ||
| 256 | + let t0 = Instant::now(); | ||
| 257 | + for _ in 0..10 { | ||
| 258 | + bad.record_at("ip", t0); | ||
| 259 | + } | ||
| 260 | + assert!(!bad.allows_at("ip", t0)); | ||
| 261 | + assert!(creations.allows_at("ip", t0)); | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + #[test] | ||
| 265 | + fn the_window_slides() { | ||
| 266 | + let q = Quota::signups(); | ||
| 267 | + let t0 = Instant::now(); | ||
| 268 | + for i in 0..20 { | ||
| 269 | + // Un compte par minute : la fenêtre se vide dans le même ordre. | ||
| 270 | + q.record_at("ip", t0 + Duration::from_secs(60 * i as u64)); | ||
| 271 | + } | ||
| 272 | + let full = t0 + Duration::from_secs(60 * 19); | ||
| 273 | + assert!(!q.allows_at("ip", full)); | ||
| 274 | + // Une heure après le tout premier, il ressort de la fenêtre : une place. | ||
| 275 | + assert!(q.allows_at("ip", t0 + HOUR + Duration::from_secs(1))); | ||
| 276 | + // Deux heures après, plus rien ne compte. | ||
| 277 | + assert!(q.allows_at("ip", t0 + HOUR * 2)); | ||
| 278 | + assert_eq!( | ||
| 279 | + q.hits.lock().unwrap().len(), | ||
| 280 | + 0, | ||
| 281 | + "l'entrée vide doit disparaître" | ||
| 282 | + ); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + #[test] | ||
| 286 | + fn quotas_are_per_ip() { | ||
| 287 | + let q = Quota::signups(); | ||
| 288 | + let t0 = Instant::now(); | ||
| 289 | + for _ in 0..20 { | ||
| 290 | + q.record_at("a", t0); | ||
| 291 | + } | ||
| 292 | + assert!(!q.allows_at("a", t0)); | ||
| 293 | + assert!(q.allows_at("b", t0)); | ||
| 294 | + } | ||
| 295 | +} | ||
modified
src/routes/login.rs +14 -4 | @@ -20,6 +20,8 @@ pub struct LoginPage { | ||
| 20 | 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). |
| 21 | 21 | pub tr: &'static crate::i18n::Tr, |
| 22 | 22 | pub lang_code: &'static str, |
| 23 | + /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ». | |
| 24 | + pub signup: bool, | |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | 27 | #[derive(Deserialize)] |
| @@ -37,6 +39,7 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> | ||
| 37 | 39 | error: q.err.is_some(), |
| 38 | 40 | tr: lang.tr(), |
| 39 | 41 | lang_code: lang.code(), |
| 42 | + signup: crate::config::signup_key().is_some(), | |
| 40 | 43 | }) |
| 41 | 44 | .into_response() |
| 42 | 45 | } |
| @@ -62,8 +65,15 @@ pub async fn login_submit( | ||
| 62 | 65 | return Ok(Redirect::to("/login?err=1").into_response()); |
| 63 | 66 | } |
| 64 | 67 | |
| 65 | - let token = token::mint(&state.root, creds.id).map_err(AppError::Internal)?; | |
| 66 | - let cookie = Cookie::build((SESSION_COOKIE, token)) | |
| 68 | + let cookie = session_cookie(&state, creds.id)?; | |
| 69 | + Ok((jar.add(cookie), Redirect::to("/")).into_response()) | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Cookie de session d'un compte qui vient de s'authentifier — partagé par la | |
| 73 | +/// connexion et l'inscription (qui connecte directement : pas de confirmation). | |
| 74 | +pub fn session_cookie(state: &AppState, user_id: uuid::Uuid) -> Result<Cookie<'static>, AppError> { | |
| 75 | + let token = token::mint(&state.root, user_id).map_err(AppError::Internal)?; | |
| 76 | + Ok(Cookie::build((SESSION_COOKIE, token)) | |
| 67 | 77 | .path("/") |
| 68 | 78 | .http_only(true) |
| 69 | 79 | .secure(true) |
| @@ -71,8 +81,8 @@ pub async fn login_submit( | ||
| 71 | 81 | // Chrome retient les cookies Strict → re-login à chaque ouverture. |
| 72 | 82 | // Les POST restent protégés par le token CSRF. |
| 73 | 83 | .same_site(SameSite::Lax) |
| 74 | - .max_age(time::Duration::seconds(SESSION_SECONDS)); | |
| 75 | - Ok((jar.add(cookie), Redirect::to("/")).into_response()) | |
| 84 | + .max_age(time::Duration::seconds(SESSION_SECONDS)) | |
| 85 | + .build()) | |
| 76 | 86 | } |
| 77 | 87 | |
| 78 | 88 | /// Hash argon2id factice — uniquement pour égaliser le temps de réponse. |
| @@ -20,6 +20,8 @@ pub struct LoginPage { | |||
| 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | 20 | /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). |
| 21 | pub tr: &'static crate::i18n::Tr, | 21 | pub tr: &'static crate::i18n::Tr, |
| 22 | pub lang_code: &'static str, | 22 | pub lang_code: &'static str, |
| 23 | + /// SIGNUP_KEY posée : on affiche le lien « Créer un compte ». | ||
| 24 | + pub signup: bool, | ||
| 23 | } | 25 | } |
| 24 | 26 | ||
| 25 | #[derive(Deserialize)] | 27 | #[derive(Deserialize)] |
| @@ -37,6 +39,7 @@ pub async fn login_page(user: Option<AuthUser>, Query(q): Query<LoginQuery>) -> | |||
| 37 | error: q.err.is_some(), | 39 | error: q.err.is_some(), |
| 38 | tr: lang.tr(), | 40 | tr: lang.tr(), |
| 39 | lang_code: lang.code(), | 41 | lang_code: lang.code(), |
| 42 | + signup: crate::config::signup_key().is_some(), | ||
| 40 | }) | 43 | }) |
| 41 | .into_response() | 44 | .into_response() |
| 42 | } | 45 | } |
| @@ -62,8 +65,15 @@ pub async fn login_submit( | |||
| 62 | return Ok(Redirect::to("/login?err=1").into_response()); | 65 | return Ok(Redirect::to("/login?err=1").into_response()); |
| 63 | } | 66 | } |
| 64 | 67 | ||
| 65 | - let token = token::mint(&state.root, creds.id).map_err(AppError::Internal)?; | 68 | + let cookie = session_cookie(&state, creds.id)?; |
| 66 | - let cookie = Cookie::build((SESSION_COOKIE, token)) | 69 | + Ok((jar.add(cookie), Redirect::to("/")).into_response()) |
| 70 | +} | ||
| 71 | + | ||
| 72 | +/// Cookie de session d'un compte qui vient de s'authentifier — partagé par la | ||
| 73 | +/// connexion et l'inscription (qui connecte directement : pas de confirmation). | ||
| 74 | +pub fn session_cookie(state: &AppState, user_id: uuid::Uuid) -> Result<Cookie<'static>, AppError> { | ||
| 75 | + let token = token::mint(&state.root, user_id).map_err(AppError::Internal)?; | ||
| 76 | + Ok(Cookie::build((SESSION_COOKIE, token)) | ||
| 67 | .path("/") | 77 | .path("/") |
| 68 | .http_only(true) | 78 | .http_only(true) |
| 69 | .secure(true) | 79 | .secure(true) |
| @@ -71,8 +81,8 @@ pub async fn login_submit( | |||
| 71 | // Chrome retient les cookies Strict → re-login à chaque ouverture. | 81 | // Chrome retient les cookies Strict → re-login à chaque ouverture. |
| 72 | // Les POST restent protégés par le token CSRF. | 82 | // Les POST restent protégés par le token CSRF. |
| 73 | .same_site(SameSite::Lax) | 83 | .same_site(SameSite::Lax) |
| 74 | - .max_age(time::Duration::seconds(SESSION_SECONDS)); | 84 | + .max_age(time::Duration::seconds(SESSION_SECONDS)) |
| 75 | - Ok((jar.add(cookie), Redirect::to("/")).into_response()) | 85 | + .build()) |
| 76 | } | 86 | } |
| 77 | 87 | ||
| 78 | /// Hash argon2id factice — uniquement pour égaliser le temps de réponse. | 88 | /// Hash argon2id factice — uniquement pour égaliser le temps de réponse. |
modified
src/routes/mod.rs +7 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | mod entries; |
| 2 | -mod login; | |
| 2 | +pub(crate) mod login; | |
| 3 | 3 | mod settings; |
| 4 | +mod signup; | |
| 4 | 5 | mod statics; |
| 5 | 6 | mod stats; |
| 6 | 7 | mod teams; |
| @@ -27,6 +28,11 @@ pub fn router(state: AppState) -> Router { | ||
| 27 | 28 | .route("/", get(root)) |
| 28 | 29 | .route("/login", get(login::login_page).post(login::login_submit)) |
| 29 | 30 | .route("/logout", post(login::logout)) |
| 31 | + // 404 tant que SIGNUP_KEY n'est pas posée (cf. routes/signup.rs). | |
| 32 | + .route( | |
| 33 | + "/inscription", | |
| 34 | + get(signup::signup_page).post(signup::signup_submit), | |
| 35 | + ) | |
| 30 | 36 | .route( |
| 31 | 37 | "/temps", |
| 32 | 38 | get(entries::entries_list).post(entries::entry_create), |
| @@ -1,6 +1,7 @@ | |||
| 1 | mod entries; | 1 | mod entries; |
| 2 | -mod login; | 2 | +pub(crate) mod login; |
| 3 | mod settings; | 3 | mod settings; |
| 4 | +mod signup; | ||
| 4 | mod statics; | 5 | mod statics; |
| 5 | mod stats; | 6 | mod stats; |
| 6 | mod teams; | 7 | mod teams; |
| @@ -27,6 +28,11 @@ pub fn router(state: AppState) -> Router { | |||
| 27 | .route("/", get(root)) | 28 | .route("/", get(root)) |
| 28 | .route("/login", get(login::login_page).post(login::login_submit)) | 29 | .route("/login", get(login::login_page).post(login::login_submit)) |
| 29 | .route("/logout", post(login::logout)) | 30 | .route("/logout", post(login::logout)) |
| 31 | + // 404 tant que SIGNUP_KEY n'est pas posée (cf. routes/signup.rs). | ||
| 32 | + .route( | ||
| 33 | + "/inscription", | ||
| 34 | + get(signup::signup_page).post(signup::signup_submit), | ||
| 35 | + ) | ||
| 30 | .route( | 36 | .route( |
| 31 | "/temps", | 37 | "/temps", |
| 32 | get(entries::entries_list).post(entries::entry_create), | 38 | get(entries::entries_list).post(entries::entry_create), |
modified
src/routes/settings.rs +0 -1 | @@ -82,7 +82,6 @@ pub async fn settings_page( | ||
| 82 | 82 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), |
| 83 | 83 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), |
| 84 | 84 | _ => match q.err.as_deref() { |
| 85 | - Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), | |
| 86 | 85 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| 87 | 86 | Some("derniere") => Some(tr.err_last_team.to_string()), |
| 88 | 87 | _ => None, |
| @@ -82,7 +82,6 @@ pub async fn settings_page( | |||
| 82 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), | 82 | Some("affichage") => Some(tr.toast_theme_updated.to_string()), |
| 83 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), | 83 | Some("graphiques") => Some(tr.toast_chart_style_updated.to_string()), |
| 84 | _ => match q.err.as_deref() { | 84 | _ => match q.err.as_deref() { |
| 85 | - Some("eq-doublon") => Some(tr.err_team_name_taken.to_string()), | ||
| 86 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), | 85 | Some("eq-vide") => Some(tr.err_team_name_empty.to_string()), |
| 87 | Some("derniere") => Some(tr.err_last_team.to_string()), | 86 | Some("derniere") => Some(tr.err_last_team.to_string()), |
| 88 | _ => None, | 87 | _ => None, |
added
src/routes/signup.rs +204 -0 | new file mode 100644 | ||
| @@ -0,0 +1,204 @@ | ||
| 1 | +//! Inscription libre-service, ouverte UNIQUEMENT si `SIGNUP_KEY` est posée. | |
| 2 | +//! | |
| 3 | +//! Trois champs : identifiant, mot de passe, clé. Ni e-mail ni confirmation — | |
| 4 | +//! la clé partagée EST le contrôle d'accès. Elle se compare en temps constant, | |
| 5 | +//! et rien ne touche la base tant qu'elle est fausse. L'application ne limite | |
| 6 | +//! pas le débit : la clé doit être longue et aléatoire. | |
| 7 | + | |
| 8 | +use std::net::SocketAddr; | |
| 9 | + | |
| 10 | +use askama::Template; | |
| 11 | +use axum::Form; | |
| 12 | +use axum::extract::{ConnectInfo, Query, State}; | |
| 13 | +use axum::http::HeaderMap; | |
| 14 | +use axum::response::{IntoResponse, Redirect, Response}; | |
| 15 | +use axum_extra::extract::CookieJar; | |
| 16 | +use serde::Deserialize; | |
| 17 | +use subtle::ConstantTimeEq; | |
| 18 | + | |
| 19 | +use crate::auth::extractor::AuthUser; | |
| 20 | +use crate::auth::password; | |
| 21 | +use crate::db::teams::{self, Resolved}; | |
| 22 | +use crate::db::users::{self, InitialTeam, Signup}; | |
| 23 | +use crate::error::AppError; | |
| 24 | +use crate::i18n::Tr; | |
| 25 | +use crate::ratelimit::client_ip; | |
| 26 | +use crate::render::HtmlTemplate; | |
| 27 | +use crate::routes::login::session_cookie; | |
| 28 | +use crate::state::AppState; | |
| 29 | +use crate::team::team_cookie; | |
| 30 | + | |
| 31 | +#[derive(Template)] | |
| 32 | +#[template(path = "signup.html")] | |
| 33 | +pub struct SignupPage { | |
| 34 | + /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | |
| 35 | + pub tr: &'static Tr, | |
| 36 | + pub lang_code: &'static str, | |
| 37 | + pub error: Option<&'static str>, | |
| 38 | + /// Identifiant et équipe rejoués dans les champs après une erreur. | |
| 39 | + pub username: String, | |
| 40 | + pub team: String, | |
| 41 | +} | |
| 42 | + | |
| 43 | +#[derive(Deserialize)] | |
| 44 | +pub struct SignupQuery { | |
| 45 | + err: Option<String>, | |
| 46 | + /// Champs à rejouer (jamais le mot de passe ni la clé). | |
| 47 | + nom: Option<String>, | |
| 48 | + equipe: Option<String>, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout | |
| 52 | +/// (ni page, ni indice de son existence). | |
| 53 | +fn key_or_404() -> Result<String, AppError> { | |
| 54 | + crate::config::signup_key().ok_or(AppError::NotFound) | |
| 55 | +} | |
| 56 | + | |
| 57 | +pub async fn signup_page( | |
| 58 | + user: Option<AuthUser>, | |
| 59 | + Query(q): Query<SignupQuery>, | |
| 60 | +) -> Result<Response, AppError> { | |
| 61 | + key_or_404()?; | |
| 62 | + if user.is_some() { | |
| 63 | + return Ok(Redirect::to("/").into_response()); | |
| 64 | + } | |
| 65 | + let lang = crate::config::default_lang(); | |
| 66 | + let tr = lang.tr(); | |
| 67 | + Ok(HtmlTemplate(SignupPage { | |
| 68 | + tr, | |
| 69 | + lang_code: lang.code(), | |
| 70 | + error: match q.err.as_deref() { | |
| 71 | + Some("cle") => Some(tr.err_signup_key), | |
| 72 | + Some("pris") => Some(tr.err_signup_username_taken), | |
| 73 | + Some("nom") => Some(tr.err_signup_username_invalid), | |
| 74 | + Some("court") => Some(tr.err_password_short), | |
| 75 | + Some("equipe-vide") => Some(tr.err_signup_team_empty), | |
| 76 | + Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous), | |
| 77 | + Some("equipe-inconnue") => Some(tr.err_signup_team_unknown), | |
| 78 | + Some("trop") => Some(tr.err_signup_rate_limited), | |
| 79 | + Some("essais") => Some(tr.err_signup_too_many_attempts), | |
| 80 | + _ => None, | |
| 81 | + }, | |
| 82 | + username: q.nom.unwrap_or_default(), | |
| 83 | + team: q.equipe.unwrap_or_default(), | |
| 84 | + }) | |
| 85 | + .into_response()) | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[derive(Deserialize)] | |
| 89 | +pub struct SignupForm { | |
| 90 | + username: String, | |
| 91 | + password: String, | |
| 92 | + cle: String, | |
| 93 | + /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise. | |
| 94 | + equipe: String, | |
| 95 | +} | |
| 96 | + | |
| 97 | +/// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de | |
| 98 | +/// contrôle (il s'affiche partout et sert de nom à la première équipe). | |
| 99 | +fn valid_username(u: &str) -> bool { | |
| 100 | + !u.is_empty() | |
| 101 | + && u.chars().count() <= 32 | |
| 102 | + && !u.chars().any(|c| c.is_whitespace() || c.is_control()) | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub async fn signup_submit( | |
| 106 | + State(state): State<AppState>, | |
| 107 | + headers: HeaderMap, | |
| 108 | + ConnectInfo(peer): ConnectInfo<SocketAddr>, | |
| 109 | + jar: CookieJar, | |
| 110 | + Form(form): Form<SignupForm>, | |
| 111 | +) -> Result<Response, AppError> { | |
| 112 | + let expected = key_or_404()?; | |
| 113 | + let ip = client_ip(&headers, Some(peer)); | |
| 114 | + | |
| 115 | + // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se | |
| 116 | + // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée, | |
| 117 | + // sinon la limite ne ralentirait rien. | |
| 118 | + if !state.bad_keys.allows(&ip) { | |
| 119 | + tracing::warn!(%ip, "inscription refusée : trop de clés fausses"); | |
| 120 | + return Ok(back("essais", &form)); | |
| 121 | + } | |
| 122 | + // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé | |
| 123 | + // fausse ne dit rien de l'existence d'un identifiant. | |
| 124 | + let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into(); | |
| 125 | + if !ok { | |
| 126 | + state.bad_keys.record(&ip); | |
| 127 | + return Ok(back("cle", &form)); | |
| 128 | + } | |
| 129 | + | |
| 130 | + let username = form.username.trim().to_string(); | |
| 131 | + if !valid_username(&username) { | |
| 132 | + return Ok(back("nom", &form)); | |
| 133 | + } | |
| 134 | + if form.password.len() < 8 { | |
| 135 | + return Ok(back("court", &form)); | |
| 136 | + } | |
| 137 | + let wanted_team = form.equipe.trim().to_string(); | |
| 138 | + if wanted_team.is_empty() { | |
| 139 | + return Ok(back("equipe-vide", &form)); | |
| 140 | + } | |
| 141 | + | |
| 142 | + // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit. | |
| 143 | + if !state.signups.allows(&ip) { | |
| 144 | + tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); | |
| 145 | + return Ok(back("trop", &form)); | |
| 146 | + } | |
| 147 | + | |
| 148 | + let team = match teams::resolve(&state.pool, &wanted_team).await? { | |
| 149 | + Resolved::One(t) => InitialTeam::Existing(t.id), | |
| 150 | + Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)), | |
| 151 | + // Un identifiant qui ne désigne rien est une faute de frappe, pas une | |
| 152 | + // intention de créer : seul un NOM libre ouvre une nouvelle équipe. | |
| 153 | + Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)), | |
| 154 | + Resolved::None { was_id: false } => InitialTeam::Create(wanted_team), | |
| 155 | + }; | |
| 156 | + | |
| 157 | + let hash = password::hash(&form.password).map_err(AppError::Internal)?; | |
| 158 | + let lang = crate::config::default_lang(); | |
| 159 | + match users::signup(&state.pool, &username, &hash, lang.code(), team).await? { | |
| 160 | + Signup::UsernameTaken => Ok(back("pris", &form)), | |
| 161 | + Signup::Created { user_id, team_id } => { | |
| 162 | + state.signups.record(&ip); | |
| 163 | + // Connexion immédiate : sans confirmation à attendre, renvoyer sur | |
| 164 | + // le login ferait ressaisir ce qui vient d'être saisi. | |
| 165 | + let jar = jar | |
| 166 | + .add(session_cookie(&state, user_id)?) | |
| 167 | + .add(team_cookie(team_id)); | |
| 168 | + Ok((jar, Redirect::to("/")).into_response()) | |
| 169 | + } | |
| 170 | + } | |
| 171 | +} | |
| 172 | + | |
| 173 | +/// Retour au formulaire avec l'erreur et les champs non secrets rejoués. | |
| 174 | +fn back(err: &str, form: &SignupForm) -> Response { | |
| 175 | + Redirect::to(&format!( | |
| 176 | + "/inscription?err={err}&nom={}&equipe={}", | |
| 177 | + crate::routes::urlenc(form.username.trim()), | |
| 178 | + crate::routes::urlenc(form.equipe.trim()) | |
| 179 | + )) | |
| 180 | + .into_response() | |
| 181 | +} | |
| 182 | + | |
| 183 | +#[cfg(test)] | |
| 184 | +mod tests { | |
| 185 | + use super::valid_username; | |
| 186 | + | |
| 187 | + #[test] | |
| 188 | + fn username_rules() { | |
| 189 | + for ok in ["camille", "jean-luc", "a", "élodie", "u".repeat(32).as_str()] { | |
| 190 | + assert!(valid_username(ok), "« {ok} » devrait passer"); | |
| 191 | + } | |
| 192 | + for ko in ["", "deux mots", "tab\there", "u".repeat(33).as_str()] { | |
| 193 | + assert!(!valid_username(ko), "« {ko} » ne devrait pas passer"); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + /// La longueur se compte en CARACTÈRES : 32 accents ne doivent pas être | |
| 198 | + /// refusés comme 64 octets. | |
| 199 | + #[test] | |
| 200 | + fn username_length_counts_chars_not_bytes() { | |
| 201 | + assert!(valid_username(&"é".repeat(32))); | |
| 202 | + assert!(!valid_username(&"é".repeat(33))); | |
| 203 | + } | |
| 204 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,204 @@ | |||
| 1 | +//! Inscription libre-service, ouverte UNIQUEMENT si `SIGNUP_KEY` est posée. | ||
| 2 | +//! | ||
| 3 | +//! Trois champs : identifiant, mot de passe, clé. Ni e-mail ni confirmation — | ||
| 4 | +//! la clé partagée EST le contrôle d'accès. Elle se compare en temps constant, | ||
| 5 | +//! et rien ne touche la base tant qu'elle est fausse. L'application ne limite | ||
| 6 | +//! pas le débit : la clé doit être longue et aléatoire. | ||
| 7 | + | ||
| 8 | +use std::net::SocketAddr; | ||
| 9 | + | ||
| 10 | +use askama::Template; | ||
| 11 | +use axum::Form; | ||
| 12 | +use axum::extract::{ConnectInfo, Query, State}; | ||
| 13 | +use axum::http::HeaderMap; | ||
| 14 | +use axum::response::{IntoResponse, Redirect, Response}; | ||
| 15 | +use axum_extra::extract::CookieJar; | ||
| 16 | +use serde::Deserialize; | ||
| 17 | +use subtle::ConstantTimeEq; | ||
| 18 | + | ||
| 19 | +use crate::auth::extractor::AuthUser; | ||
| 20 | +use crate::auth::password; | ||
| 21 | +use crate::db::teams::{self, Resolved}; | ||
| 22 | +use crate::db::users::{self, InitialTeam, Signup}; | ||
| 23 | +use crate::error::AppError; | ||
| 24 | +use crate::i18n::Tr; | ||
| 25 | +use crate::ratelimit::client_ip; | ||
| 26 | +use crate::render::HtmlTemplate; | ||
| 27 | +use crate::routes::login::session_cookie; | ||
| 28 | +use crate::state::AppState; | ||
| 29 | +use crate::team::team_cookie; | ||
| 30 | + | ||
| 31 | +#[derive(Template)] | ||
| 32 | +#[template(path = "signup.html")] | ||
| 33 | +pub struct SignupPage { | ||
| 34 | + /// Page anonyme : langue par défaut de l'instance (DEFAULT_LANG). | ||
| 35 | + pub tr: &'static Tr, | ||
| 36 | + pub lang_code: &'static str, | ||
| 37 | + pub error: Option<&'static str>, | ||
| 38 | + /// Identifiant et équipe rejoués dans les champs après une erreur. | ||
| 39 | + pub username: String, | ||
| 40 | + pub team: String, | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +#[derive(Deserialize)] | ||
| 44 | +pub struct SignupQuery { | ||
| 45 | + err: Option<String>, | ||
| 46 | + /// Champs à rejouer (jamais le mot de passe ni la clé). | ||
| 47 | + nom: Option<String>, | ||
| 48 | + equipe: Option<String>, | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +/// Clé attendue, ou 404 : sans `SIGNUP_KEY`, la route n'existe pas du tout | ||
| 52 | +/// (ni page, ni indice de son existence). | ||
| 53 | +fn key_or_404() -> Result<String, AppError> { | ||
| 54 | + crate::config::signup_key().ok_or(AppError::NotFound) | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +pub async fn signup_page( | ||
| 58 | + user: Option<AuthUser>, | ||
| 59 | + Query(q): Query<SignupQuery>, | ||
| 60 | +) -> Result<Response, AppError> { | ||
| 61 | + key_or_404()?; | ||
| 62 | + if user.is_some() { | ||
| 63 | + return Ok(Redirect::to("/").into_response()); | ||
| 64 | + } | ||
| 65 | + let lang = crate::config::default_lang(); | ||
| 66 | + let tr = lang.tr(); | ||
| 67 | + Ok(HtmlTemplate(SignupPage { | ||
| 68 | + tr, | ||
| 69 | + lang_code: lang.code(), | ||
| 70 | + error: match q.err.as_deref() { | ||
| 71 | + Some("cle") => Some(tr.err_signup_key), | ||
| 72 | + Some("pris") => Some(tr.err_signup_username_taken), | ||
| 73 | + Some("nom") => Some(tr.err_signup_username_invalid), | ||
| 74 | + Some("court") => Some(tr.err_password_short), | ||
| 75 | + Some("equipe-vide") => Some(tr.err_signup_team_empty), | ||
| 76 | + Some("equipe-ambigue") => Some(tr.err_signup_team_ambiguous), | ||
| 77 | + Some("equipe-inconnue") => Some(tr.err_signup_team_unknown), | ||
| 78 | + Some("trop") => Some(tr.err_signup_rate_limited), | ||
| 79 | + Some("essais") => Some(tr.err_signup_too_many_attempts), | ||
| 80 | + _ => None, | ||
| 81 | + }, | ||
| 82 | + username: q.nom.unwrap_or_default(), | ||
| 83 | + team: q.equipe.unwrap_or_default(), | ||
| 84 | + }) | ||
| 85 | + .into_response()) | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +#[derive(Deserialize)] | ||
| 89 | +pub struct SignupForm { | ||
| 90 | + username: String, | ||
| 91 | + password: String, | ||
| 92 | + cle: String, | ||
| 93 | + /// Équipe initiale : un nom, ou l'identifiant d'une équipe précise. | ||
| 94 | + equipe: String, | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +/// Identifiant : non vide, ≤ 32 caractères, sans espace ni caractère de | ||
| 98 | +/// contrôle (il s'affiche partout et sert de nom à la première équipe). | ||
| 99 | +fn valid_username(u: &str) -> bool { | ||
| 100 | + !u.is_empty() | ||
| 101 | + && u.chars().count() <= 32 | ||
| 102 | + && !u.chars().any(|c| c.is_whitespace() || c.is_control()) | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +pub async fn signup_submit( | ||
| 106 | + State(state): State<AppState>, | ||
| 107 | + headers: HeaderMap, | ||
| 108 | + ConnectInfo(peer): ConnectInfo<SocketAddr>, | ||
| 109 | + jar: CookieJar, | ||
| 110 | + Form(form): Form<SignupForm>, | ||
| 111 | +) -> Result<Response, AppError> { | ||
| 112 | + let expected = key_or_404()?; | ||
| 113 | + let ip = client_ip(&headers, Some(peer)); | ||
| 114 | + | ||
| 115 | + // Frein anti-force brute : 10 clés fausses par jour et par IP. Le quota se | ||
| 116 | + // consulte AVANT la comparaison — au-delà, la clé n'est même plus testée, | ||
| 117 | + // sinon la limite ne ralentirait rien. | ||
| 118 | + if !state.bad_keys.allows(&ip) { | ||
| 119 | + tracing::warn!(%ip, "inscription refusée : trop de clés fausses"); | ||
| 120 | + return Ok(back("essais", &form)); | ||
| 121 | + } | ||
| 122 | + // Temps constant, et surtout : on sort AVANT toute requête SQL. Une clé | ||
| 123 | + // fausse ne dit rien de l'existence d'un identifiant. | ||
| 124 | + let ok: bool = expected.as_bytes().ct_eq(form.cle.as_bytes()).into(); | ||
| 125 | + if !ok { | ||
| 126 | + state.bad_keys.record(&ip); | ||
| 127 | + return Ok(back("cle", &form)); | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + let username = form.username.trim().to_string(); | ||
| 131 | + if !valid_username(&username) { | ||
| 132 | + return Ok(back("nom", &form)); | ||
| 133 | + } | ||
| 134 | + if form.password.len() < 8 { | ||
| 135 | + return Ok(back("court", &form)); | ||
| 136 | + } | ||
| 137 | + let wanted_team = form.equipe.trim().to_string(); | ||
| 138 | + if wanted_team.is_empty() { | ||
| 139 | + return Ok(back("equipe-vide", &form)); | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + // Quota AVANT le hash argon2id (coûteux) et avant d'écrire quoi que ce soit. | ||
| 143 | + if !state.signups.allows(&ip) { | ||
| 144 | + tracing::warn!(%ip, "inscription refusée : quota horaire atteint"); | ||
| 145 | + return Ok(back("trop", &form)); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + let team = match teams::resolve(&state.pool, &wanted_team).await? { | ||
| 149 | + Resolved::One(t) => InitialTeam::Existing(t.id), | ||
| 150 | + Resolved::Ambiguous(_) => return Ok(back("equipe-ambigue", &form)), | ||
| 151 | + // Un identifiant qui ne désigne rien est une faute de frappe, pas une | ||
| 152 | + // intention de créer : seul un NOM libre ouvre une nouvelle équipe. | ||
| 153 | + Resolved::None { was_id: true } => return Ok(back("equipe-inconnue", &form)), | ||
| 154 | + Resolved::None { was_id: false } => InitialTeam::Create(wanted_team), | ||
| 155 | + }; | ||
| 156 | + | ||
| 157 | + let hash = password::hash(&form.password).map_err(AppError::Internal)?; | ||
| 158 | + let lang = crate::config::default_lang(); | ||
| 159 | + match users::signup(&state.pool, &username, &hash, lang.code(), team).await? { | ||
| 160 | + Signup::UsernameTaken => Ok(back("pris", &form)), | ||
| 161 | + Signup::Created { user_id, team_id } => { | ||
| 162 | + state.signups.record(&ip); | ||
| 163 | + // Connexion immédiate : sans confirmation à attendre, renvoyer sur | ||
| 164 | + // le login ferait ressaisir ce qui vient d'être saisi. | ||
| 165 | + let jar = jar | ||
| 166 | + .add(session_cookie(&state, user_id)?) | ||
| 167 | + .add(team_cookie(team_id)); | ||
| 168 | + Ok((jar, Redirect::to("/")).into_response()) | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +/// Retour au formulaire avec l'erreur et les champs non secrets rejoués. | ||
| 174 | +fn back(err: &str, form: &SignupForm) -> Response { | ||
| 175 | + Redirect::to(&format!( | ||
| 176 | + "/inscription?err={err}&nom={}&equipe={}", | ||
| 177 | + crate::routes::urlenc(form.username.trim()), | ||
| 178 | + crate::routes::urlenc(form.equipe.trim()) | ||
| 179 | + )) | ||
| 180 | + .into_response() | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +#[cfg(test)] | ||
| 184 | +mod tests { | ||
| 185 | + use super::valid_username; | ||
| 186 | + | ||
| 187 | + #[test] | ||
| 188 | + fn username_rules() { | ||
| 189 | + for ok in ["camille", "jean-luc", "a", "élodie", "u".repeat(32).as_str()] { | ||
| 190 | + assert!(valid_username(ok), "« {ok} » devrait passer"); | ||
| 191 | + } | ||
| 192 | + for ko in ["", "deux mots", "tab\there", "u".repeat(33).as_str()] { | ||
| 193 | + assert!(!valid_username(ko), "« {ko} » ne devrait pas passer"); | ||
| 194 | + } | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + /// La longueur se compte en CARACTÈRES : 32 accents ne doivent pas être | ||
| 198 | + /// refusés comme 64 octets. | ||
| 199 | + #[test] | ||
| 200 | + fn username_length_counts_chars_not_bytes() { | ||
| 201 | + assert!(valid_username(&"é".repeat(32))); | ||
| 202 | + assert!(!valid_username(&"é".repeat(33))); | ||
| 203 | + } | ||
| 204 | +} | ||
modified
src/routes/teams.rs +10 -20 | @@ -10,10 +10,6 @@ use crate::error::AppError; | ||
| 10 | 10 | use crate::state::AppState; |
| 11 | 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; |
| 12 | 12 | |
| 13 | -fn is_unique_violation(e: &sqlx::Error) -> bool { | |
| 14 | - matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | |
| 15 | -} | |
| 16 | - | |
| 17 | 13 | /// Encodage minimal pour rejouer un username dans une query string. |
| 18 | 14 | #[derive(Deserialize)] |
| 19 | 15 | pub struct NameForm { |
| @@ -35,17 +31,13 @@ pub async fn team_create( | ||
| 35 | 31 | if name.is_empty() { |
| 36 | 32 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| 37 | 33 | } |
| 38 | - match teams::create_with_member(&state.pool, name, ctx.user.id).await { | |
| 39 | - Ok(id) => Ok(( | |
| 40 | - jar.add(team_cookie(id)), | |
| 41 | - Redirect::to("/parametres?ok=equipe-creee"), | |
| 42 | - ) | |
| 43 | - .into_response()), | |
| 44 | - Err(e) if is_unique_violation(&e) => { | |
| 45 | - Ok(Redirect::to("/parametres?err=eq-doublon").into_response()) | |
| 46 | - } | |
| 47 | - Err(e) => Err(e.into()), | |
| 48 | - } | |
| 34 | + // Les homonymes sont permis (migration 0012) : rien à refuser ici. | |
| 35 | + let id = teams::create_with_member(&state.pool, name, ctx.user.id).await?; | |
| 36 | + Ok(( | |
| 37 | + jar.add(team_cookie(id)), | |
| 38 | + Redirect::to("/parametres?ok=equipe-creee"), | |
| 39 | + ) | |
| 40 | + .into_response()) | |
| 49 | 41 | } |
| 50 | 42 | |
| 51 | 43 | #[derive(Deserialize)] |
| @@ -93,11 +85,9 @@ pub async fn team_rename( | ||
| 93 | 85 | if name.is_empty() { |
| 94 | 86 | return Ok(Redirect::to("/parametres?err=eq-vide")); |
| 95 | 87 | } |
| 96 | - match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await { | |
| 97 | - Ok(0) => Err(AppError::NotFound), | |
| 98 | - Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")), | |
| 99 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")), | |
| 100 | - Err(e) => Err(e.into()), | |
| 88 | + match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await? { | |
| 89 | + 0 => Err(AppError::NotFound), | |
| 90 | + _ => Ok(Redirect::to("/parametres?ok=eq-renommee")), | |
| 101 | 91 | } |
| 102 | 92 | } |
| 103 | 93 | |
| @@ -10,10 +10,6 @@ use crate::error::AppError; | |||
| 10 | use crate::state::AppState; | 10 | use crate::state::AppState; |
| 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; | 11 | use crate::team::{ActiveTeam, clear_team_cookie, team_cookie}; |
| 12 | 12 | ||
| 13 | -fn is_unique_violation(e: &sqlx::Error) -> bool { | ||
| 14 | - matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | ||
| 15 | -} | ||
| 16 | - | ||
| 17 | /// Encodage minimal pour rejouer un username dans une query string. | 13 | /// Encodage minimal pour rejouer un username dans une query string. |
| 18 | #[derive(Deserialize)] | 14 | #[derive(Deserialize)] |
| 19 | pub struct NameForm { | 15 | pub struct NameForm { |
| @@ -35,17 +31,13 @@ pub async fn team_create( | |||
| 35 | if name.is_empty() { | 31 | if name.is_empty() { |
| 36 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); | 32 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| 37 | } | 33 | } |
| 38 | - match teams::create_with_member(&state.pool, name, ctx.user.id).await { | 34 | + // Les homonymes sont permis (migration 0012) : rien à refuser ici. |
| 39 | - Ok(id) => Ok(( | 35 | + let id = teams::create_with_member(&state.pool, name, ctx.user.id).await?; |
| 40 | - jar.add(team_cookie(id)), | 36 | + Ok(( |
| 41 | - Redirect::to("/parametres?ok=equipe-creee"), | 37 | + jar.add(team_cookie(id)), |
| 42 | - ) | 38 | + Redirect::to("/parametres?ok=equipe-creee"), |
| 43 | - .into_response()), | 39 | + ) |
| 44 | - Err(e) if is_unique_violation(&e) => { | 40 | + .into_response()) |
| 45 | - Ok(Redirect::to("/parametres?err=eq-doublon").into_response()) | ||
| 46 | - } | ||
| 47 | - Err(e) => Err(e.into()), | ||
| 48 | - } | ||
| 49 | } | 41 | } |
| 50 | 42 | ||
| 51 | #[derive(Deserialize)] | 43 | #[derive(Deserialize)] |
| @@ -93,11 +85,9 @@ pub async fn team_rename( | |||
| 93 | if name.is_empty() { | 85 | if name.is_empty() { |
| 94 | return Ok(Redirect::to("/parametres?err=eq-vide")); | 86 | return Ok(Redirect::to("/parametres?err=eq-vide")); |
| 95 | } | 87 | } |
| 96 | - match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await { | 88 | + match teams::rename(&state.pool, ctx.team_id, name, ctx.user.id).await? { |
| 97 | - Ok(0) => Err(AppError::NotFound), | 89 | + 0 => Err(AppError::NotFound), |
| 98 | - Ok(_) => Ok(Redirect::to("/parametres?ok=eq-renommee")), | 90 | + _ => Ok(Redirect::to("/parametres?ok=eq-renommee")), |
| 99 | - Err(e) if is_unique_violation(&e) => Ok(Redirect::to("/parametres?err=eq-doublon")), | ||
| 100 | - Err(e) => Err(e.into()), | ||
| 101 | } | 91 | } |
| 102 | } | 92 | } |
| 103 | 93 | ||
modified
src/state.rs +7 -0 | @@ -6,6 +6,7 @@ use sha2::{Digest, Sha256}; | ||
| 6 | 6 | use sqlx::PgPool; |
| 7 | 7 | |
| 8 | 8 | use crate::auth::revocation::RevocationCache; |
| 9 | +use crate::ratelimit::Quota; | |
| 9 | 10 | |
| 10 | 11 | #[derive(Clone)] |
| 11 | 12 | pub struct AppState { |
| @@ -14,6 +15,10 @@ pub struct AppState { | ||
| 14 | 15 | pub public: PublicKey, |
| 15 | 16 | pub csrf_key: [u8; 32], |
| 16 | 17 | pub revocations: RevocationCache, |
| 18 | + /// Comptes créés par IP (20/h) — en mémoire, jamais en base. | |
| 19 | + pub signups: Quota, | |
| 20 | + /// Clés d'inscription fausses par IP (10/jour) — frein à la force brute. | |
| 21 | + pub bad_keys: Quota, | |
| 17 | 22 | } |
| 18 | 23 | |
| 19 | 24 | impl AppState { |
| @@ -34,6 +39,8 @@ impl AppState { | ||
| 34 | 39 | public, |
| 35 | 40 | csrf_key, |
| 36 | 41 | revocations: RevocationCache::default(), |
| 42 | + signups: Quota::signups(), | |
| 43 | + bad_keys: Quota::bad_keys(), | |
| 37 | 44 | }) |
| 38 | 45 | } |
| 39 | 46 | } |
| @@ -6,6 +6,7 @@ use sha2::{Digest, Sha256}; | |||
| 6 | use sqlx::PgPool; | 6 | use sqlx::PgPool; |
| 7 | 7 | ||
| 8 | use crate::auth::revocation::RevocationCache; | 8 | use crate::auth::revocation::RevocationCache; |
| 9 | +use crate::ratelimit::Quota; | ||
| 9 | 10 | ||
| 10 | #[derive(Clone)] | 11 | #[derive(Clone)] |
| 11 | pub struct AppState { | 12 | pub struct AppState { |
| @@ -14,6 +15,10 @@ pub struct AppState { | |||
| 14 | pub public: PublicKey, | 15 | pub public: PublicKey, |
| 15 | pub csrf_key: [u8; 32], | 16 | pub csrf_key: [u8; 32], |
| 16 | pub revocations: RevocationCache, | 17 | pub revocations: RevocationCache, |
| 18 | + /// Comptes créés par IP (20/h) — en mémoire, jamais en base. | ||
| 19 | + pub signups: Quota, | ||
| 20 | + /// Clés d'inscription fausses par IP (10/jour) — frein à la force brute. | ||
| 21 | + pub bad_keys: Quota, | ||
| 17 | } | 22 | } |
| 18 | 23 | ||
| 19 | impl AppState { | 24 | impl AppState { |
| @@ -34,6 +39,8 @@ impl AppState { | |||
| 34 | public, | 39 | public, |
| 35 | csrf_key, | 40 | csrf_key, |
| 36 | revocations: RevocationCache::default(), | 41 | revocations: RevocationCache::default(), |
| 42 | + signups: Quota::signups(), | ||
| 43 | + bad_keys: Quota::bad_keys(), | ||
| 37 | }) | 44 | }) |
| 38 | } | 45 | } |
| 39 | } | 46 | } |
modified
templates/login.html +3 -0 | @@ -34,5 +34,8 @@ | ||
| 34 | 34 | <button type="submit" class="btn btn-primary btn-block" |
| 35 | 35 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> |
| 36 | 36 | </form> |
| 37 | + {% if signup %} | |
| 38 | + <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a> | |
| 39 | + {% endif %} | |
| 37 | 40 | </main> |
| 38 | 41 | {% endblock %} |
| @@ -34,5 +34,8 @@ | |||
| 34 | <button type="submit" class="btn btn-primary btn-block" | 34 | <button type="submit" class="btn btn-primary btn-block" |
| 35 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> | 35 | style="min-height:56px;font-size:20px">{{ tr.sign_in }}</button> |
| 36 | </form> | 36 | </form> |
| 37 | + {% if signup %} | ||
| 38 | + <a class="btn btn-ghost" style="margin-top:var(--space-4)" href="/inscription">{{ tr.signup_link }}</a> | ||
| 39 | + {% endif %} | ||
| 37 | </main> | 40 | </main> |
| 38 | {% endblock %} | 41 | {% endblock %} |
added
templates/signup.html +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ tr.signup_title }} · Ex Ego{% endblock %} | |
| 3 | +{% block lang %}{{ lang_code }}{% endblock %} | |
| 4 | +{% block body %} | |
| 5 | +<main class="login signup"> | |
| 6 | + {# Même logo « cadran ex aequo » que la connexion, en plus petit : la page | |
| 7 | + est plus longue (trois champs + aides). #} | |
| 8 | + <svg class="login-logo" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"> | |
| 9 | + <circle cx="48" cy="48" r="44" fill="#ebddc5"/> | |
| 10 | + <circle cx="48" cy="48" r="40" fill="none" stroke="#201e1d" stroke-width="2.4" opacity=".38"/> | |
| 11 | + <circle cx="48" cy="14" r="3.3" fill="#201e1d" opacity=".7"/> | |
| 12 | + <circle cx="48" cy="82" r="3.3" fill="#201e1d" opacity=".5"/> | |
| 13 | + <circle cx="14" cy="48" r="3.3" fill="#201e1d" opacity=".5"/> | |
| 14 | + <circle cx="82" cy="48" r="3.3" fill="#201e1d" opacity=".5"/> | |
| 15 | + <rect x="27" y="35.5" width="42" height="10.5" rx="5.25" fill="#c67139"/> | |
| 16 | + <path d="M53.5 35.5 h10.25 a5.25 5.25 0 0 1 0 10.5 h-10.25 z" fill="#7a8a5e"/> | |
| 17 | + <rect x="27" y="50" width="42" height="10.5" rx="5.25" fill="#7a8a5e"/> | |
| 18 | + <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/> | |
| 19 | + </svg> | |
| 20 | + <h1>{{ tr.signup_title }}</h1> | |
| 21 | + <p class="tagline">{{ tr.signup_intro }}</p> | |
| 22 | + <form method="post" action="/inscription"> | |
| 23 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 24 | + <label for="username" style="font-size:18px">{{ tr.username_label }}</label> | |
| 25 | + <input class="input" id="username" name="username" value="{{ username }}" | |
| 26 | + autocomplete="username" autocapitalize="none" autocorrect="off" | |
| 27 | + required autofocus maxlength="32" style="font-size:18px"> | |
| 28 | + </div> | |
| 29 | + <div class="field" style="margin-bottom:var(--space-3)"> | |
| 30 | + <label for="password" style="font-size:18px">{{ tr.password_label }}</label> | |
| 31 | + <input class="input" id="password" name="password" type="password" | |
| 32 | + autocomplete="new-password" required minlength="8" style="font-size:18px"> | |
| 33 | + </div> | |
| 34 | + <div class="field" style="margin-bottom:var(--space-2)"> | |
| 35 | + <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label> | |
| 36 | + <input class="input" id="equipe" name="equipe" value="{{ team }}" | |
| 37 | + autocapitalize="none" autocorrect="off" required | |
| 38 | + aria-describedby="equipe-aide" style="font-size:18px"> | |
| 39 | + </div> | |
| 40 | + <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p> | |
| 41 | + <div class="field" style="margin-bottom:var(--space-2)"> | |
| 42 | + <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> | |
| 43 | + <input class="input" id="cle" name="cle" type="password" autocomplete="off" | |
| 44 | + autocapitalize="none" autocorrect="off" required | |
| 45 | + aria-describedby="cle-aide" style="font-size:18px"> | |
| 46 | + </div> | |
| 47 | + <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p> | |
| 48 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | |
| 49 | + <button type="submit" class="btn btn-primary btn-block" | |
| 50 | + style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> | |
| 51 | + </form> | |
| 52 | + <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> | |
| 53 | +</main> | |
| 54 | +{% endblock %} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +{% extends "base.html" %} | ||
| 2 | +{% block title %}{{ tr.signup_title }} · Ex Ego{% endblock %} | ||
| 3 | +{% block lang %}{{ lang_code }}{% endblock %} | ||
| 4 | +{% block body %} | ||
| 5 | +<main class="login signup"> | ||
| 6 | + {# Même logo « cadran ex aequo » que la connexion, en plus petit : la page | ||
| 7 | + est plus longue (trois champs + aides). #} | ||
| 8 | + <svg class="login-logo" width="96" height="96" viewBox="0 0 96 96" aria-hidden="true"> | ||
| 9 | + <circle cx="48" cy="48" r="44" fill="#ebddc5"/> | ||
| 10 | + <circle cx="48" cy="48" r="40" fill="none" stroke="#201e1d" stroke-width="2.4" opacity=".38"/> | ||
| 11 | + <circle cx="48" cy="14" r="3.3" fill="#201e1d" opacity=".7"/> | ||
| 12 | + <circle cx="48" cy="82" r="3.3" fill="#201e1d" opacity=".5"/> | ||
| 13 | + <circle cx="14" cy="48" r="3.3" fill="#201e1d" opacity=".5"/> | ||
| 14 | + <circle cx="82" cy="48" r="3.3" fill="#201e1d" opacity=".5"/> | ||
| 15 | + <rect x="27" y="35.5" width="42" height="10.5" rx="5.25" fill="#c67139"/> | ||
| 16 | + <path d="M53.5 35.5 h10.25 a5.25 5.25 0 0 1 0 10.5 h-10.25 z" fill="#7a8a5e"/> | ||
| 17 | + <rect x="27" y="50" width="42" height="10.5" rx="5.25" fill="#7a8a5e"/> | ||
| 18 | + <path d="M43.5 50 h20.25 a5.25 5.25 0 0 1 0 10.5 h-20.25 z" fill="#c67139"/> | ||
| 19 | + </svg> | ||
| 20 | + <h1>{{ tr.signup_title }}</h1> | ||
| 21 | + <p class="tagline">{{ tr.signup_intro }}</p> | ||
| 22 | + <form method="post" action="/inscription"> | ||
| 23 | + <div class="field" style="margin-bottom:var(--space-3)"> | ||
| 24 | + <label for="username" style="font-size:18px">{{ tr.username_label }}</label> | ||
| 25 | + <input class="input" id="username" name="username" value="{{ username }}" | ||
| 26 | + autocomplete="username" autocapitalize="none" autocorrect="off" | ||
| 27 | + required autofocus maxlength="32" style="font-size:18px"> | ||
| 28 | + </div> | ||
| 29 | + <div class="field" style="margin-bottom:var(--space-3)"> | ||
| 30 | + <label for="password" style="font-size:18px">{{ tr.password_label }}</label> | ||
| 31 | + <input class="input" id="password" name="password" type="password" | ||
| 32 | + autocomplete="new-password" required minlength="8" style="font-size:18px"> | ||
| 33 | + </div> | ||
| 34 | + <div class="field" style="margin-bottom:var(--space-2)"> | ||
| 35 | + <label for="equipe" style="font-size:18px">{{ tr.signup_team_label }}</label> | ||
| 36 | + <input class="input" id="equipe" name="equipe" value="{{ team }}" | ||
| 37 | + autocapitalize="none" autocorrect="off" required | ||
| 38 | + aria-describedby="equipe-aide" style="font-size:18px"> | ||
| 39 | + </div> | ||
| 40 | + <p class="field-hint" id="equipe-aide">{{ tr.signup_team_hint }}</p> | ||
| 41 | + <div class="field" style="margin-bottom:var(--space-2)"> | ||
| 42 | + <label for="cle" style="font-size:18px">{{ tr.signup_key_label }}</label> | ||
| 43 | + <input class="input" id="cle" name="cle" type="password" autocomplete="off" | ||
| 44 | + autocapitalize="none" autocorrect="off" required | ||
| 45 | + aria-describedby="cle-aide" style="font-size:18px"> | ||
| 46 | + </div> | ||
| 47 | + <p class="field-hint" id="cle-aide">{{ tr.signup_key_hint }}</p> | ||
| 48 | + {% if let Some(e) = error %}<p class="form-error">{{ e }}</p>{% endif %} | ||
| 49 | + <button type="submit" class="btn btn-primary btn-block" | ||
| 50 | + style="min-height:56px;font-size:20px">{{ tr.signup_submit }}</button> | ||
| 51 | + </form> | ||
| 52 | + <a class="btn btn-ghost" style="margin-top:var(--space-2)" href="/login">{{ tr.signup_have_account }}</a> | ||
| 53 | +</main> | ||
| 54 | +{% endblock %} | ||