MIGRATION_GUIDE.md 39564 octets

Guide de migration — Rustwarden

Ce document liste les changements qui impactent les projets consommateurs de rustwarden-core et rustwarden-ui, avec les instructions de migration pour chacun.

Les entrees sont classees par date, de la plus recente a la plus ancienne. rustwarden-cli update-project recopie ce fichier dans docs/ du projet consommateur (avec SERVICES_REFERENCE.md).


2026-10-01 — RFC 0039 phase 2 : CSP par défaut sans unsafe-eval

Le défaut de SecurityHeadersConfig est maintenant CSP_WITHOUT_UNSAFE_EVAL (script-src 'self' 'unsafe-inline'). 'unsafe-inline' reste : les scripts de boot (thème, langue) et HTMX sont encore inline. HSTS, X-Frame-Options, COOP et CORP ne changent pas.

L'UI de référence charge @alpinejs/csp (/static/js/alpine-csp.min.js) et enregistre ses composants dans /static/js/alpine-components.js. Les expressions Alpine qui appellent window, document ou une fonction globale sont interdites par ce build : elles vivent dans Alpine.data(...).

Retour arrière si une app dépend encore du build Alpine standard (new Function) :

use std::borrow::Cow;
use rustwarden_core::config::security_headers::{
    DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig,
};

let headers = SecurityHeadersConfig {
    content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
    ..SecurityHeadersConfig::default()
};
// .security_headers(headers) sur RustwardenBuilder

DEFAULT_CONTENT_SECURITY_POLICY est la politique 1.0.1, avec 'unsafe-eval'.


2026-09-18 — RFC 0036-0041 (1.1.0)

Minor de securite. Les RFC 0036 a 0040 sont Accepted (2026-09-18), la RFC 0041 Accepted (2026-09-19) ; toutes sont implementees. La RFC 0041 remplace la RFC 0038, passee Superseded avant toute publication : lisez directement la section 0041 ci-dessous, il n'y a pas de transition v1 → v2 a faire. Fenetre d'adoption 14 jours (RUSTWARDEN_EVOLUTION_PROCESS.md §9). Details : CHANGELOG [1.1.0], SERVICES_REFERENCE.md bandeau 1.1.0 + §3, §4, §9, §10, §19.

Trois API ajoutees (types::Username, config::security_headers::SecurityHeadersConfig, services::email_change_service::EmailChangeService), quatre changements de comportement sur des chemins d'auth existants, deux migrations SeaORM a jouer, cinq routes ajoutees.

⚠️ Ordre imperatif : jouer la migration m20260918_000010_totp_last_used_step avant de demarrer le binaire 1.1.0. Le modele SeaORM user_totp declare last_used_step, donc SeaORM emet la liste de colonnes explicite : sur un schema pre-migration, toute requete sur user_totp echoue — y compris TotpService::is_totp_enabled, appelee a chaque login. Un replica 1.1.0 sur un schema pre-migration fait donc echouer tous les logins en HTTP 500 (pas seulement ceux des comptes 2FA) tant que la colonne user_totp.last_used_step est absente.

Ordre d'operations

# 1. Sauvegarde avant tout
pg_dump "$DATABASE_URL" > backup-avant-1.1.0.sql

# 2. Recuperer le framework (vendor + docs framework-owned)
cargo run -p rustwarden-cli -- update-project

# 3. Aligner les pins de dependances de crates/rustwarden-core/Cargo.toml
#    sea-orm / sea-orm-migration = "2.0.0", sea-bae = "0.2.2",
#    validator_derive = "0.20.1"
cargo update

# 4. Migrations AVANT le deploiement du binaire
#    m20260918_000010_totp_last_used_step + m20260919_000011_pending_email_changes
cargo run --bin migrate
#    (ou auto_migrate(true) si deploiement mono-instance)

# 5. Rediriger les appels qui changeaient l'email (RFC 0041)
grep -rn "update_profile" src/

# 6. Lint
cargo clippy --workspace -- -D warnings

# 7. Smoke tests auth : login, register, changement d'email, 2FA

cargo clippy --workspace --all-features est a eviter : la feature redis-session de rustwarden-core ne compile pas.


RFC 0036 — newtype Username

docs/rfcs/0036-username-charset-validation.md

SujetAvant (1.0.1)Apres (1.1.0)
Charset usernameAucune contrainte : John_Doe, alice.bob, backticks et $ acceptes[a-z0-9-] uniquement. Majuscule rejetee (pas de fold : Alice → erreur, pas alice)
LongueurRegisterRequest : 3–643–39 (apres trim), RegisterRequest : max = 39
TiretsLibresNi en tete ni en queue, pas de -- consecutifs
Noms reservesAucuntypes::team_slug::RESERVED + me + explore
Porte de validationAucuneUserService::create_user (unique gate) + POST /api/v1/auth/register (defense en profondeur)
Rename (update_profile)Unicite seulementUsername::parse seulement sur un vrai rename ; echo du username courant = no-op
Bootstrap admincreate_usercreate_user_allow_reserved (pub(crate), reserve a ensure_default_admin) → ADMIN_USERNAME=admin continue de booter
OAuth auto-username_ possible, longueur libreOAuthService::generate_unique_username : minuscules [a-z0-9], base a 30 chars, suffixes -NNN / -user / -<hex8>
Schema DB—Aucun CHECK SQL, aucune migration, aucun rewrite
Login des comptes existants—Inchange — les handles hors charset se connectent toujours

Actions consommateur

# Usernames generes par votre app : passent-ils Username::parse ?
grep -rn "create_user(" src/
grep -rn "update_profile(" src/
# Newtype Username local a retirer (ex. gitrust)
grep -rni "struct Username\|fn parse_username\|validate_username" src/
  • Si votre app genere des usernames (import LDAP, seed, provisioning SSO maison), verifier qu'ils sont en minuscules, [a-z0-9-], 3–39 caracteres, sans --. Sinon create_user renvoie desormais AppError::Validation (HTTP 400).

  • Retirer un newtype Username local : utiliser rustwarden_core::types::Username (re-exporte au prelude). Pour valider un identifiant avant de le stocker ailleurs :

    use rustwarden_core::prelude::*;
    
    let username = Username::parse(&raw_input)?;   // AppError::Validation si invalide
    UserService::create_user(&db, username.into_inner(), email, hash, false).await?;
    
  • Formulaires cote client : aligner le maxlength / le pattern HTML sur 39 et [a-z0-9-] pour ne pas renvoyer une erreur serveur a l'utilisateur. rustwarden-ui le fait desormais pour ses propres ecrans (login.html inscription, profile.html, admin/user_form.html en mode create) et ses six locales decrivent la bonne regle — si vous avez forke ces templates ou ces fichiers locales/*.yml, reportez le changement a la main : maxlength="39", pattern="[a-z0-9-]+", et le texte d'aide « minuscules, chiffres et tirets uniquement, 3 a 39 caracteres ».

Pieges

  • Comptes existants hors charset : ils se connectent toujours, mais toute tentative de rename vers une autre valeur passe par Username::parse. Un utilisateur John_Doe peut renvoyer John_Doe (no-op), il ne peut pas renommer en John_Doe2.
  • ADMIN_USERNAME avec une majuscule ou un _ : le chemin de creation de l'admin par defaut (ensure_default_admin → validate_new_admin_credentials → AdminConfig::validate) delegue a Username::parse_allow_reserved, qui refuse toujours le mauvais charset. build() propage l'erreur : le boot echoue. A noter : ce controle ne s'applique que sur le chemin de creation (aucun admin en base + ADMIN_PASSWORD defini). Un deploiement dont l'admin existe deja n'est pas impacte. Le wizard cargo run -p rustwarden-cli -- start-project valide maintenant cette saisie au moment de la poser, pour que l'erreur remonte dans le wizard et non au premier build().
  • Le nom reserve admin reste accepte pour le bootstrap uniquement ; un POST /api/v1/auth/register avec admin est refuse.

RFC 0037 — anti-rejeu TOTP persistant (last_used_step)

docs/rfcs/0037-totp-last-used-step.md

SujetAvant (1.0.1)Apres (1.1.0)
Anti-rejeuGarde in-process (HashMap OnceLock, TTL 90 s)Garde persistee en base : user_totp.last_used_step BIGINT NULL
Multi-replicasUn code valide rejouable sur un autre replicaFerme : compare-and-set SQL partage
Mecanisme—UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < $step), succes ssi rows_affected == 1
Pas consomme—Le pas qui a matche (matching_totp_step, skew ±1), jamais now / 30
Rejeu detecte—Ok(false) — meme reponse qu'un code invalide, pas d'oracle
Couvertureverify_codeverify_code et verify_and_enable (le code de setup ne peut plus servir de login immediat)
Erreur DB—Propagee en AppError — jamais fail-open
Plafonds 1.0.115 tentatives / 15 min (somme), 5 par challengeInchanges, la garde in-process est conservee en defense en profondeur

Actions consommateur

# Patch local du rejeu TOTP a retirer
grep -rn "last_used_step\|replay_guard\|totp_replay" src/
  • Jouer la migration (cargo run --bin migrate ou auto_migrate(true)).
  • Verifier la colonne : \d user_totp doit lister last_used_step | bigint | nullable.
  • Retirer tout patch local dupliquant l'anti-rejeu TOTP.

Pieges

  • Ordre de deploiement : migration d'abord, binaire ensuite — le rayon de souffle depasse le 2FA. Le modele SeaORM user_totp declare desormais last_used_step ; SeaORM genere une liste de colonnes explicite, donc sur un schema pre-migration toute requete sur user_totp echoue (column user_totp.last_used_step does not exist), pas seulement le compare-and-set. Or TotpService::is_totp_enabled est appelee a chaque login par auth/handlers.rs : tous les logins renvoient 500, y compris ceux des comptes sans 2FA. Ce n'est donc pas une degradation partielle du 2FA, c'est une panne totale de l'authentification jusqu'a ce que la colonne existe.
  • last_used_step IS NULL = « aucun pas consomme depuis la migration ». Le premier code presente apres migration est donc toujours accepte (grandfathering des lignes existantes) — c'est voulu.
  • La colonne est ajoutee sans defaut : l'ALTER TABLE ... ADD COLUMN ... NULL ne reecrit pas la table.

RFC 0041 — changement d'email en deux temps (remplace la RFC 0038)

docs/rfcs/0041-pending-email-changes.md

La RFC 0038 est Superseded : elle n'a jamais ete publiee, il n'y a donc aucune note de transition v1 → v2. Si vous aviez commence a l'implementer sur une pre-release, la colonne de droite ci-dessous est la seule qui compte.

SujetAvant (1.0.1)Apres (1.1.0)
update_profile + nouvelle adresseusers.email ecrit immediatement, email_verified conserve a trueRefus : AppError::Validation qui renvoie vers EmailChangeService — l'adresse n'est jamais ecrite par ce chemin
update_profile + echo de l'adresse couranteEcriture silencieuse (valeur identique)No-op complet, comparaison exacte apres trim — les formulaires qui resoumettent tous les champs continuent de marcher
Chemin utilisateur—EmailChangeService::request_change(&db, user_id, new_email, current_password) : mot de passe courant exige, ligne pending_email_changes, deux mails en file
Etat de la base tant que le lien n'est pas suivi—Rien ne bouge : users.email inchange, email_verified toujours true
Confirmation—POST /api/v1/auth/email-change/confirm/{token} : adresse ecrite deja verifiee, tous les refresh tokens revoques, demandes de changement de mot de passe invalidees, audit_logs email_changed
Mail vers la nouvelle adresseAucunLien de confirmation
Mail vers l'ancienne adresseAucun (ou duplique cote UI)Avis + lien de signalement (alerte les admins, ne bloque pas)
Chemin adminPUT /admin/users/:id ecrivait l'adresseEmailChangeService::admin_force_change — role admin exige, sessions de la cible revoquees, audit_logs email_changed_by_admin (WARN). PUT /admin/users/{id} ne change plus l'adresse
Echec SMTP / file d'e-mailsBest-effort (l'update reussissait quand meme)Fail-loud : request_change echoue, rien n'est ecrit — pas de demande dont le lien n'arriverait jamais
LimiteurBudget IP seulement3 demandes / heure / utilisateur (email_change:{user_id}) + budget IP sur POST /api/v1/auth/email-change
Logs / audit / hooksAdresse en clair possibleAdresses masquees (jo***@ex***), y compris dans le corps de reponse
Faute de frappe sur la nouvelle adresseLock-out definitif du compteSans effet : le lien part dans le vide, la demande expire, le compte n'a jamais bouge
Rename username seul—Aucun impact sur l'email

Nouvelles routes (canal machine, merge_machine_routes / auth_api_routes())

Les liens envoyes par e-mail ouvrent une page interstitielle : le GET affiche un formulaire, c'est le POST du bouton qui agit. Aucun GET n'a d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens ne peut confirmer un changement d'adresse a la place de l'utilisateur.

MethodeCheminAuthEffet
POST/api/v1/auth/email-changeBearer + mot de passe courant dans le corpsouvre la demande
GET/api/v1/auth/email-change/confirm/{token}public (lien de mail)rend la page, n'ecrit rien
POST/api/v1/auth/email-change/confirm/{token}public (bouton de la page)applique le changement
GET/api/v1/auth/email-change/report/{token}public (lien de mail)rend la page, n'ecrit rien
POST/api/v1/auth/email-change/report/{token}public (bouton de la page)enregistre le signalement

Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni rustwarden-ui, ni JavaScript, ni ressource externe. Un consommateur qui veut sa propre presentation monte sa propre route GET et poste vers la route POST du core.

Actions consommateur

# 1. Appels qui passaient une nouvelle adresse
grep -rn "update_profile" src/

# 2. Code applicatif devenu redondant
grep -rn "email_verified" src/
grep -rn "account_modified" src/
grep -rn "invalidate_on_email_change" src/
  • Jouer la migration m20260919_000011_pending_email_changes. Elle fait deux choses :

    1. elle cree la table pending_email_changes — nouvelle, aucune donnee existante n'est touchee, down la supprime ;
    2. elle elargit la contrainte chk_email_queue_email_type de email_queue aux quatre types de la RFC 0041 (email_change_validation, email_change_notice, email_change_reported, email_change_by_admin). Sans cet elargissement, le premier INSERT du flux serait rejete par PostgreSQL (23514). Si vous avez redefini cette contrainte dans une migration a vous, rejouez-la apres celle-ci ou ajoutez-y les quatre valeurs : la migration part de la liste du schema initial et n'a aucun moyen de deviner vos ajouts. Le down ne retrecit la contrainte que si aucune ligne d'email_queue ne porte encore un de ces types — un rollback ne detruit pas l'historique des mails envoyes.
  • Rediriger les appels a update_profile qui passent une nouvelle adresse vers EmailChangeService::request_change. Un echo de l'adresse courante reste un no-op : rien a changer pour les formulaires qui resoumettent tous les champs.

  • Chemin administrateur : tout consommateur qui appelait PUT /admin/users/{id} (ou update_profile depuis un handler admin) pour corriger l'e-mail d'un compte doit basculer sur :

    EmailChangeService::new(email_config)
        .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
        .await?;
    

    C'est la meme capacite qu'avant, mais explicite et auditee au lieu d'etre un effet de bord de la mise a jour de profil. Le reste de PUT /admin/users/{id} (username, roles, statut) est inchange.

  • Retirer tout code applicatif qui, apres un changement d'adresse, remettait email_verified a la main, enqueuait un mail de validation / une alerte account_modified, ou appelait PasswordChangeService::invalidate_on_email_change : le core emet ce dernier lui-meme dans confirm_change et admin_force_change, c'est-a-dire aux deux seuls instants ou l'adresse change reellement.

  • Verifier SMTP et le worker email_queue avant le bump : sans file fonctionnelle, request_change echoue (volontairement) au lieu d'ecrire une adresse non prouvee.

  • Facultatif, purge des demandes expirees — le framework ne la demarre pas, comme pour les processeurs existants :

    PendingEmailChangeCleanupProcessor::new(db.clone()).start();
    

    Retention : 30 jours apres expiration, constante de code, aucune variable d'environnement a definir. Sans ce demarrage, une purge opportuniste limitee a l'utilisateur courant s'execute a chaque nouvelle demande.

  • Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans dedies (demande en cours, annulation, double saisie) sont un chantier UI separe.

  • Si vous surchargez les templates d'e-mail du framework, exposez les deux liens. Attention au libelle : le lien ouvre une page de confirmation, il ne confirme pas directement.

Pieges

  • Duree de validite : le lien utilise email_validation_expiration_hours, le reglage deja en place pour la validation d'adresse. Pas de nouvelle variable d'environnement.
  • Les sessions tombent a la confirmation, pas a la demande. L'utilisateur qui confirme depuis sa nouvelle boite est deconnecte partout et doit se reconnecter avec la nouvelle adresse : dites-le dans l'ecran qui ouvre la demande, sinon le support recoit des tickets « je suis deconnecte ».
  • Le signalement n'annule rien. Le lien envoye a l'ancienne adresse alerte les administrateurs et trace en audit_logs (WARN) ; il ne bloque pas le changement. La boite qui recoit ce lien peut alerter, pas decider — c'est un choix explicite de la RFC (un attaquant qui controle l'ancienne boite ne doit pas pouvoir bloquer la reprise de controle legitime).
  • Course sur la meme adresse cible : si deux comptes demandent la meme adresse, le second a confirmer recoit un Validation neutre (« adresse plus disponible ») qui ne nomme pas le compte gagnant, et sa ligne n'est pas consommee — son lien refonctionne si l'adresse se libere avant expiration.
  • Recours perdu : un utilisateur qui a perdu a la fois son mot de passe et l'acces a son adresse courante ne peut plus se debloquer seul ; c'est un recours administrateur (admin_force_change) apres verification d'identite hors bande.
  • Faute de frappe a l'inscription : hors perimetre. register ecrit l'adresse saisie sans double verification ; un utilisateur qui se trompe au signup releve du meme recours administrateur. Si vous voulez fermer ce cas, ajoutez une double saisie dans votre formulaire d'inscription.
  • La locale des mails suit le Locale de la requete quand l'application monte l'I18nService ; sans extension i18n, le service retombe sur les libelles anglais embarques.

RFC 0039 — SecurityHeadersConfig

docs/rfcs/0039-security-headers-config.md

SujetAvant (1.0.1)Apres (1.1.0)
CSPLitteral hardcode dans security_headers_middlewareSecurityHeadersConfig.content_security_policy, configurable
COOP / CORPJamais emisOpt-in : Option<Cow<'static, str>>, None par defaut = header non emis
Wiring—RustwardenApp::builder().security_headers(cfg) ; le middleware capture un Arc a la construction (pas d'Extension : le layer est plus externe que les Extension(...))
Valeur par defaut—Bit-identique a 1.0.1 — 'unsafe-inline' et 'unsafe-eval' conserves (Alpine.js)
Autres headersX-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy, HSTSInchanges et non configurables (non-goals de la RFC)
Valeur invalide—CSP invalide → fallback sur le defaut + warning ; COOP/CORP invalide → header saute + warning. Jamais de panique

Ce que la RFC ne fait PAS : elle n'ameliore aucune politique par defaut. Un consommateur qui n'appelle jamais .security_headers(..) obtient exactement les memes en-tetes qu'en 1.0.1. La constante CSP_WITHOUT_UNSAFE_EVAL est fournie mais non utilisee par Default — le durcissement du defaut est une Phase 2 ulterieure, apres migration Alpine CSP (plans/PLAN_CSP_HTMX.md).

Actions consommateur

# Overlay CSP local a retirer (ex. gitrust)
grep -rn "SetResponseHeaderLayer" src/
grep -rn "CONTENT_SECURITY_POLICY\|Cross-Origin-Opener-Policy\|content-security-policy" src/
use rustwarden_core::prelude::*;   // SecurityHeadersConfig

// AVANT — overlay tower-http par-dessus le middleware framework
let router = router.layer(SetResponseHeaderLayer::overriding(
    header::CONTENT_SECURITY_POLICY,
    HeaderValue::from_static("default-src 'self'; script-src 'self'"),
));

// APRES — configuration native
let app = RustwardenApp::builder()
    .from_env()
    .security_headers(
        SecurityHeadersConfig {
            content_security_policy: "default-src 'self'; script-src 'self'".into(),
            ..SecurityHeadersConfig::default()
        }
        .with_same_origin_isolation(),   // COOP + CORP = same-origin
    )
    .build()
    .await?;

Pieges

  • Une CSP custom trop stricte casse l'UI rustwarden-ui (HTMX + Alpine.js ont besoin de 'unsafe-inline' et, pour la build Alpine standard, 'unsafe-eval'). A reserver aux apps headless ou a UI maison.
  • COOP/CORP same-origin cassent les integrations cross-origin (iframes, CDN d'assets, widgets tiers). Tester avant d'activer.

RFC 0040 — authenticate_user honore validation_email_required

docs/rfcs/0040-authenticate-user-email-verified.md

SujetAvant (1.0.1)Apres (1.1.0)
Ou vit le controleHandler HTTP POST /api/v1/auth/loginAuthService::authenticate_user (service)
Appel direct au serviceContourne la politiqueApplique la politique
Reglagevalidation_email_required / alias validation_email_requiseIdentique
Defauttruetrue (fail-closed : erreur de lookup = verification requise)
Message d'erreurFrancaisIdentique, meme AppError::Validation (HTTP 400)
Ordre des controles—Mot de passe d'abord, politique email ensuite → pas d'oracle « compte existant non verifie »
verify_credentialsMot de passe seulInchange — primitive mot de passe seul, pas de controle email (utilisee par le re-prompt TOTP)
Hook auth.login_blockedEmis par le handlerPlus emis (decision v1) — la ligne audit_logs est toujours ecrite

Actions consommateur

grep -rn "authenticate_user\|verify_credentials" src/
grep -rn "login_blocked\|on_audit_event" src/
  • Retirer tout patch local qui dupliquait le controle email_verified apres authenticate_user (double verification = double message d'erreur).
  • Si votre SSR appelait authenticate_user : il applique desormais la politique. Verifier que votre ecran de login affiche le message d'erreur et propose « renvoyer l'email de validation ».
  • Si vous avez volontairement besoin d'un login sans controle email (outil interne, migration), utilisez verify_credentials en connaissance de cause — c'est un contournement explicite de la politique.

Pieges

  • Le hook on_audit_event / auth.login_blocked n'est plus emis sur ce chemin. Une alerte SIEM branchee sur ce hook devient silencieuse : basculer la detection sur la table audit_logs (action = "login_blocked").
  • Aucune interaction avec le changement d'email : la RFC 0041 ecrit la nouvelle adresse deja verifiee, donc un changement d'adresse ne bloque jamais le login suivant. Le couplage « changement d'adresse => prochain login refuse » qu'annoncait la RFC 0038 n'existe pas. Le compte reste soumis a cette politique sur le seul cas qu'elle vise : une inscription dont l'adresse n'a jamais ete validee.
  • Le reglage reste global (app_settings), pas par utilisateur.

Recapitulatif des workarounds consommateur a retirer

WorkaroundRemplace par
Newtype Username / validate_username() local (gitrust)rustwarden_core::types::Username (prelude)
SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …) (gitrust)builder().security_headers(SecurityHeadersConfig { .. })
Headers COOP/CORP poses a la mainSecurityHeadersConfig::with_same_origin_isolation()
Ecriture de users.email via update_profile / PUT /admin/users/{id}EmailChangeService::request_change (utilisateur) ou admin_force_change (operateur) (RFC 0041)
Reset manuel de email_verified apres un changement d'adresseSans objet : l'adresse est ecrite deja verifiee a la confirmation (RFC 0041)
Enqueue applicatif du mail de validation / account_modified sur changement d'emailFait par le core (RFC 0041)
Appel applicatif a PasswordChangeService::invalidate_on_email_changeFait par le core dans confirm_change / admin_force_change (RFC 0041)
Double saisie maison pour eviter le lock-out sur faute de frappeSans objet : l'adresse courante ne bouge pas tant que la nouvelle n'est pas confirmee (RFC 0041)
Re-verification de email_verified apres authenticate_userFait par le service (RFC 0040)
Patch local de l'anti-rejeu TOTPuser_totp.last_used_step (RFC 0037)

2026-09-18 — Security 1.0.1 (patch interne)

Patch de securite sans nouvelle API pub. Fenetre d'adoption 14 jours apres diffusion de [1.0.1]. Details techniques : CHANGELOG [1.0.1], SERVICES_REFERENCE.md bandeau 1.0.1 + §4, §6, §12, §15, §18, §19.

Mise a jour 2026-09-19 : les RFC 0036–0040 (charset username, last_used_step TOTP, re-validation email, headers de securite configurables, email_verified dans authenticate_user) sont depuis passees Accepted et livrees dans [1.1.0], la RFC 0038 ayant ete remplacee par la RFC 0041 (changement d'email en deux temps) avant publication. Voir l'entree 2026-09-18 — RFC 0036-0041 (1.1.0) ci-dessus.

Breaking operational (pas d'API)

SujetAvantApres 1.0.1
Serve HTTPaxum::serve(listener, router) sans ConnectInfo → toutes les cles auth:unknownRustwardenApp::run() utilise into_make_service_with_connect_info::<SocketAddr>(). Si vous servez le routeur vous-memes, faire de meme.
JWT_SECRETPlaceholders anglais uniquementEgalite exacte aussi avec REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE → refus de boot.
ADMIN_PASSWORDPlaceholder / trop faible pouvait casser un redeploiementAdminConfig::validate() seulement si creation d'admin. Sans variable, pas de bootstrap.
PostgreSQL composeMot de passe par defaut, bind 0.0.0.0Copier database/env.sample → database/.env, POSTGRES_PASSWORD obligatoire (:?), bind 127.0.0.1:5432.
Reset MDP faibleToken consomme puis 400Complexite verifiee avant hash / used_at.
Rate limiter plein429 sur une nouvelle cleEviction LRU a 100 000 entrees.
Auth middlewareFail-open lookup user / blacklist / TOTP forceFail-closed (503 sur erreur TOTP force).
TOTP loginCompteur de challenges, pas de rejeu in-process15 tentatives / 5 min ; rejeu in-process seulement.
.env.example CLIJWT_SECRET / ADMIN_PASSWORD placeholderSecrets a definir ; ne plus copier un placeholder.

Workarounds consommateur a retirer

Apres vendor / update-project + rebuild :

  • garde JWT maison dupliquant AuthConfig (placeholders FR) ;
  • rate-limit login base uniquement sur HeaderMap / X-Forwarded-For pour compenser auth:unknown — le layer JSON lit ConnectInfo ; un POST /login SSR hors auth_api_routes() reste a limiter cote app ;
  • RateLimitService::new() dans chaque handler de reset / verify email (les services framework partagent un OnceLock) ;
  • patch local du plafond TOTP ou du rejeu in-process.

check_auth_rate_limit(&HeaderMap) n'a pas change de signature. Il ignore toujours ConnectInfo.

Dependances

rustwarden-core pin sea-bae = "0.2.2" et validator_derive = "0.20.1" (drop proc-macro-error2 / RUSTSEC-2026-0173). Apres sync des crates, cargo update puis verifier l'absence de proc-macro-error2 dans cargo tree -i proc-macro-error2.

validator reste 0.20 (pas de 0.20.1 sur crates.io pour le crate principal).


2026-03-26 — UserService : nouvelles methodes + hook on_user_deleted

Changement

4 methodes ajoutees a UserService et 1 hook au trait RustwardenHooks :

MethodeDescription
unassign_role_from_user(db, user_id, role_id)Retire un role. Protege le dernier admin.
delete_user(db, user_id, hooks)Supprime un utilisateur avec nettoyage complet.
admin_reset_password(db, user_id, new_password)Reset admin sans confirmation email. Force must_change_password.
change_password_direct(db, user_id, old, new)Changement direct avec verification de l'ancien mot de passe.

Hook ajoute : on_user_deleted(db, user_id, username) — appele avant la suppression pour permettre le nettoyage des donnees metier (walls, repos, etc.).

Migration

Aucune migration requise. Toutes les methodes sont additives. Le hook a une implementation par defaut (no-op).

Remplacement des contournements

Si votre projet manipulait directement les entites SeaORM pour ces operations, vous pouvez maintenant utiliser les methodes du framework :

// AVANT — suppression manuelle
user::Entity::delete_by_id(user_id).exec(&db).await?;

// APRES — nettoyage complet (tokens, hook, cascade)
UserService::delete_user(&db, user_id, Some(&my_hooks)).await?;
// AVANT — retrait de role manuel
user_role::Entity::delete_many()
    .filter(user_role::Column::UserId.eq(user_id))
    .filter(user_role::Column::RoleId.eq(role_id))
    .exec(&db).await?;

// APRES — avec protection du dernier admin
UserService::unassign_role_from_user(&db, user_id, role_id).await?;
// AVANT — reset admin manuel
let hash = PasswordService::hash_password(new_pwd).await?;
UserService::update_password_hash(&db, user_id, &hash).await?;

// APRES — hash + must_change + revocation sessions
UserService::admin_reset_password(&db, user_id, new_pwd).await?;

2026-03-26 — PasswordService : methodes rendues async

Changement

PasswordService::hash_password et PasswordService::verify_password sont desormais async.

Bcrypt est CPU-intensif (~100-300ms a cost 12). Les methodes utilisent maintenant tokio::task::spawn_blocking en interne pour ne pas bloquer le runtime Tokio.

Avant

let hash = PasswordService::hash_password("motdepasse")?;
let valid = PasswordService::verify_password("motdepasse", &hash)?;

Apres

let hash = PasswordService::hash_password("motdepasse").await?;
let valid = PasswordService::verify_password("motdepasse", &hash).await?;

Migration

La migration est mecanique : ajouter .await a chaque appel.

Rechercher dans votre projet :

grep -rn "PasswordService::hash_password\|PasswordService::verify_password" src/

Pour chaque occurrence, ajouter .await avant le ?. Les fonctions appelantes doivent etre async (ce qui est deja le cas dans les handlers Axum et les fonctions #[tokio::main]).

Ce qui ne change pas

  • PasswordService::generate_secure_password() reste synchrone (pas de travail CPU lourd)
  • PasswordService::generate_default_password() reste synchrone
  • PasswordService::validate_password_strength() reste synchrone
  • La signature de retour (Result<String, AppError> / Result<bool, AppError>) est inchangee
  • Le comportement est identique (meme algorithme, meme cout bcrypt)

Workaround obsolete

Si votre projet wrappait manuellement les appels dans spawn_blocking, ce workaround est maintenant inutile et peut etre supprime :

// AVANT — workaround dans le projet consommateur (a supprimer)
let password = password.to_string();
let hash = tokio::task::spawn_blocking(move || {
    PasswordService::hash_password(&password)
}).await??;

// APRES — le spawn_blocking est integre dans le framework
let hash = PasswordService::hash_password(&password).await?;

2026-03-26 — Getters sur RustwardenApp

Changement

RustwardenApp expose desormais 4 getters apres build() :

GetterType retourneDescription
database()&DatabaseConnectionConnexion base de donnees du framework
auth_config()&AuthConfigConfiguration d'authentification
i18n_service()&Arc<I18nService>Service de traduction
address()&strAdresse de bind du serveur

Migration

Aucune migration requise. Les getters sont purement additifs. Le code existant continue de fonctionner sans modification.

Nouveau pattern recommande

Les projets consommateurs peuvent simplifier leur main.rs :

// AVANT — gestion manuelle de la connexion DB
let db = rustwarden_core::config::database::create_connection().await?;
my_app::migrations::run_migrations(&db).await?;

let app = RustwardenApp::builder()
    .from_env()
    .database(db.clone())    // clone obligatoire
    .auto_migrate(false)
    .build().await?;

app.run().await

// APRES — le framework gere la DB
let app = RustwardenApp::builder()
    .from_env()
    .auto_migrate(true)      // migrations framework automatiques
    .build().await?;

// Migrations metier sur la meme connexion
my_app::migrations::run_migrations(app.database()).await?;

app.run().await

L'ancien pattern (create_connection() + db.clone()) reste fonctionnel.


2026-03-26 — rustwarden-cli start-project : documentation copiee

Changement

Le wizard start-project copie desormais les fichiers de documentation framework dans le dossier docs/ du nouveau projet :

  • docs/SERVICES_REFERENCE.md — reference complete des services du framework
  • docs/MIGRATION_GUIDE.md — ce fichier

Migration

Aucune migration requise. Les projets existants peuvent copier ces fichiers manuellement depuis le depot Rustwarden.


2026-03-23 — Remember Me : session longue duree

Changement

Le flux d'authentification supporte un champ optionnel remember_me qui etend la duree du refresh token (30 jours au lieu de 7 par defaut).

Cote serveur (rustwarden-core)

LoginRequest — nouveau champ :

pub struct LoginRequest {
    pub username: String,
    pub password: String,
    #[serde(default)]        // retrocompatible : false si absent
    pub remember_me: bool,
}

AuthConfig — nouvelle variable d'environnement :

VariableDefautDescription
REMEMBER_ME_EXPIRATION_DAYS30Duree du refresh token avec remember me

LoginResponse et RefreshResponse — nouveau champ :

pub refresh_expires_in: i64,  // duree du refresh token en secondes

refresh_token() handler — conserve la duree d'origine lors de la rotation : un token remember me de 30 jours genere un nouveau token de 30 jours (pas de raccourcissement a 7 jours). Le handler set aussi le cookie jwt_token dans la reponse (coherence avec le flux login).

Cote client (rustwarden-ui)

login.html — checkbox "Se souvenir de moi" ajoutee au formulaire. Au login reussi, le JS stocke dans localStorage :

  • refresh_token — le token de rafraichissement
  • remember_me — "true" si coche
  • jwt_expires_in — duree du JWT en secondes

base.html — script d'auto-refresh ajoute. Si remember_me === "true" en localStorage, un setInterval appelle POST /api/v1/auth/refresh toutes les (JWT_EXPIRATION - 2 min) pour renouveler le JWT avant expiration.

Migration

Aucune migration requise. Le champ remember_me est optionnel (#[serde(default)]). Les clients existants qui n'envoient pas ce champ obtiennent le comportement standard (refresh token de 7 jours).

Migration optionnelle pour en beneficier :

  1. Ajouter REMEMBER_ME_EXPIRATION_DAYS=30 au .env (ou garder le defaut)
  2. Si votre projet a un formulaire de login custom (qui n'utilise pas login.html du framework), ajouter une checkbox qui envoie "remember_me": true dans le body JSON du POST /api/v1/auth/login
  3. Si votre projet a un template de base custom (qui n'etend pas base.html du framework), ajouter le script d'auto-refresh JWT (voir crates/rustwarden-ui/templates/base.html lignes 174-221)

Aucune migration de base de donnees

La table refresh_token stocke deja expires_at par token. Le remember me utilise cette colonne existante avec une duree plus longue.


2026-03-10 — OAuth / SSO (feature oauth)

Changement

Support OpenID Connect / OAuth2 pour Google, GitHub, Discord et Microsoft. Protege derriere la feature oauth dans Cargo.toml.

Migration

Aucune migration requise si vous n'activez pas la feature.

Pour activer OAuth dans un projet existant :

  1. Ajouter la feature dans Cargo.toml :

    rustwarden-core = { path = "crates/rustwarden-core", features = ["oauth"] }
    
  2. Ajouter les variables d'environnement (voir .env.example) :

    OAUTH_ENABLED=true
    OAUTH_REDIRECT_BASE_URL=https://votre-domaine.com
    OAUTH_ENCRYPTION_KEY=<64 chars hex>
    OAUTH_GOOGLE_ENABLED=true
    OAUTH_GOOGLE_CLIENT_ID=...
    OAUTH_GOOGLE_CLIENT_SECRET=...
    
  3. La migration de base de donnees (create_oauth_accounts_table) est incluse dans core_migrations() et sera executee automatiquement au prochain demarrage si auto_migrate(true).

Routes ajoutees

RouteDescription
GET /api/v1/auth/oauth/{provider}/redirectInitie le flux OAuth
GET /api/v1/auth/oauth/{provider}/callbackCallback apres autorisation
GET /api/v1/auth/oauth/providersListe les providers actifs (JSON)
GET /api/v1/auth/oauth/accountsComptes OAuth lies de l'utilisateur
DELETE /api/v1/auth/oauth/accounts/{id}Delier un compte OAuth