| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687 |
- //! Login page: password + TOTP on a Redis-backed session.
- //!
- //! First-login users pass through forced password change + TOTP enrollment
- //! in a restricted session that reaches nothing else.
- //!
- //! Flow: `GET /login` mints (or reuses) an empty pre-session and renders the
- //! form with a stateless login-CSRF token. `POST /login` verifies the CSRF
- //! token, the password (Argon2id), and the second factor (TOTP ±1 step with a
- //! Redis replay claim, or a one-time recovery code), enforces the Redis
- //! lockout, then rotates the pre-session into an authenticated session and
- //! redirects strictly to the bound pending-auth record (task 24). Every
- //! credential failure — wrong password, unknown email, disabled account,
- //! lockout, wrong/replayed code — renders the same message and status, so no
- //! response reveals whether an email exists. Passwords, codes, and secrets
- //! never reach logs; pages are server-rendered with no `localStorage`.
- //!
- //! The session record itself is task 12's [`SessionRecord`]; the two fields
- //! that record cannot hold (`auth_time`, CSRF secret, pending `request_id`)
- //! ride a sidecar hash (`<session-key>:meta`, same `{state}` slot, same TTL)
- //! instead of editing `store::sessions` — the task-29 sidecar precedent. The
- //! pending `request_id` lives only in that sidecar after `GET /login`
- //! validates it, so `POST /login` cannot be steered off-record.
- use std::time::Duration;
- use askama::Template;
- use axum::{
- Extension, Form,
- extract::{Query, State},
- http::{StatusCode, header},
- response::{IntoResponse, Redirect, Response},
- };
- use axum_extra::extract::{
- CookieJar,
- cookie::{Cookie, SameSite},
- };
- use hmac::{Hmac, KeyInit as _, Mac as _};
- use secrecy::ExposeSecret as _;
- use serde::{Deserialize, Serialize};
- use sha2::Sha256;
- use sqlx::PgPool;
- use time::OffsetDateTime;
- use crate::{
- config::Config,
- crypto::{
- ct::ct_eq,
- secret::{Argon2Params, hash_secret},
- tokens::mint_token,
- totp,
- },
- db::{
- client_repo, redirect_repo,
- user_repo::{self, normalize_email},
- },
- error::AppError,
- keys::KeyRing,
- middleware::{audit::AuditEvent, request_id::RequestId},
- protocol::redirect_repo as redirect_match,
- state::{AppState, RedisConn},
- store::{self, Store, StoreError},
- };
- /// Session cookie name (also the rendezvous for task 26/29 readers).
- pub const SESSION_COOKIE: &str = "session_id";
- /// Login form page.
- pub const LOGIN_PATH: &str = "/login";
- /// Landing page for logins with no bound authorization request.
- pub const LOGIN_SUCCESS_PATH: &str = "/login/success";
- /// Restricted-session password-change page.
- pub const CHANGE_PASSWORD_PATH: &str = "/login/change-password";
- /// Restricted-session TOTP provisioning page.
- pub const TOTP_SETUP_PATH: &str = "/login/totp/setup";
- /// Restricted-session TOTP confirmation page.
- pub const TOTP_CONFIRM_PATH: &str = "/login/totp/confirm";
- /// Logout endpoint.
- pub const LOGOUT_PATH: &str = "/logout";
- /// Post-login target for a bound authorization request (owned by task 26).
- pub const CONSENT_PATH: &str = "/consent";
- /// Failed logins within [`LOCKOUT_WINDOW`] that lock an account.
- pub const LOCKOUT_THRESHOLD: u64 = 5;
- /// Window that doubles as the lock duration.
- pub const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60);
- /// Minimum accepted password length on change.
- pub const MIN_PASSWORD_LEN: usize = 12;
- /// The single credential-failure message for every rejection reason.
- pub const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
- #[cfg(test)]
- mod tests;
- /// Server-side session metadata the task-12 record cannot hold.
- ///
- /// Stored as a hash at `<session-key>:meta` with the session TTL.
- /// `pending_request_id` is the validated pending-auth id from `GET /login`;
- /// `POST /login` and every later step read it only from here, never from the
- /// client, so the post-login redirect cannot be steered off-record.
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
- pub struct SessionMeta {
- /// Authentication instant (Unix seconds, from the injectable clock).
- pub auth_time: i64,
- /// Per-session CSRF secret (256-bit opaque); form tokens derive from it.
- pub csrf_secret: String,
- /// Validated pending-auth `request_id`, when the login serves a flow.
- pub pending_request_id: Option<String>,
- /// The client the last flow served, when known. Used at logout only to
- /// re-check the client is still active and still registers the bound
- /// post-logout URI before redirecting to it.
- pub post_logout_client_id: Option<String>,
- /// The client's post-logout return URI for the flow this session last
- /// served, frozen at binding time. Read only at logout, never from a request,
- /// so the logout redirect cannot be steered off-record.
- pub post_logout_uri: Option<String>,
- }
- /// Everything the login core needs, borrowed from the HTTP layer.
- pub struct LoginDeps<'a> {
- /// Postgres pool for user lookups and updates.
- pub pool: &'a PgPool,
- /// Redis connection for sessions, lockout counters, and replay claims.
- pub conn: &'a mut RedisConn,
- /// Key layout plus retry/durability knobs.
- pub store: &'a Store,
- /// Versioned peppers and TOTP-encryption keys.
- pub keys: &'a KeyRing,
- /// Validated runtime configuration.
- pub config: &'a Config,
- /// Current time (injectable via [`AppState::clock`]).
- pub now: OffsetDateTime,
- }
- /// Input to a login attempt (one `POST /login`).
- pub struct LoginAttempt {
- /// Pre-session id from the session cookie.
- pub presession_id: String,
- /// Raw email as typed.
- pub email: String,
- /// Raw password as typed.
- pub password: String,
- /// Raw second-factor code as typed (TOTP or recovery).
- pub code: String,
- /// Presented login-CSRF token.
- pub csrf: String,
- }
- /// Outcome of [`attempt_login`].
- pub enum LoginOutcome {
- /// Fully authenticated: session minted, redirect to consent/success.
- Authenticated {
- /// New session id (already stored; the handler sets the cookie).
- session_id: String,
- /// Strictly bound redirect target.
- redirect_to: String,
- },
- /// Bootstrap required: restricted session minted, redirect to change-password.
- Restricted {
- /// New restricted session id (already stored).
- session_id: String,
- /// Always [`CHANGE_PASSWORD_PATH`].
- redirect_to: String,
- },
- /// Credentials rejected (uniform message; the handler renders 401).
- Rejected,
- }
- /// Whether a restricted session may touch `path`.
- ///
- /// Restricted sessions reach the login/bootstrap/logout routes only.
- /// `/authorize`, `/oauth/*`, and any admin action return `false`; their
- /// handlers deny restricted sessions before any other check.
- #[must_use]
- pub fn restricted_allows_path(path: &str) -> bool {
- matches!(
- path,
- LOGIN_PATH
- | LOGIN_SUCCESS_PATH
- | CHANGE_PASSWORD_PATH
- | TOTP_SETUP_PATH
- | TOTP_CONFIRM_PATH
- | LOGOUT_PATH
- )
- }
- /// Build the session cookie: `HttpOnly`, `SameSite=Lax`, `Secure` exactly
- /// when [`Config::cookie_secure`] is set (disabled for localhost development).
- #[must_use]
- pub fn session_cookie(config: &Config, session_id: &str) -> Cookie<'static> {
- let mut builder = Cookie::build((SESSION_COOKIE, session_id.to_owned()))
- .path("/")
- .http_only(true)
- .same_site(SameSite::Lax);
- if config.cookie_secure {
- builder = builder.secure(true);
- }
- if let Some(domain) = config.cookie_domain.clone() {
- builder = builder.domain(domain);
- }
- builder.build()
- }
- /// Build an expired session cookie for logout.
- #[must_use]
- pub fn expired_session_cookie() -> Cookie<'static> {
- Cookie::build((SESSION_COOKIE, String::new()))
- .path("/")
- .removal()
- .build()
- }
- /// Mint the stateless pre-login CSRF token: HMAC under the active token
- /// pepper over `session_id || "|login"`. No server-side CSRF state is stored.
- ///
- /// # Errors
- ///
- /// Returns `server_error` when no pepper is configured.
- pub fn login_csrf_token(keys: &KeyRing, session_id: &str) -> Result<String, AppError> {
- hmac_hex(active_pepper(keys)?, session_id.as_bytes(), b"|login")
- }
- /// Verify a pre-login CSRF token in constant time.
- #[must_use]
- pub fn verify_login_csrf(keys: &KeyRing, session_id: &str, presented: &str) -> bool {
- let Ok(expected) = login_csrf_token(keys, session_id) else {
- return false;
- };
- ct_eq(expected.as_bytes(), presented.as_bytes())
- }
- /// Mint a form token bound to the session CSRF secret for one action.
- ///
- /// A key failure degrades to an empty token (which never verifies) rather
- /// than an error, so form rendering stays infallible.
- #[must_use]
- pub fn form_token(csrf_secret: &str, action: &str) -> String {
- hmac_hex(csrf_secret.as_bytes(), action.as_bytes(), b"").unwrap_or_default()
- }
- /// Verify a session-bound form token in constant time.
- #[must_use]
- pub fn verify_form_token(csrf_secret: &str, action: &str, presented: &str) -> bool {
- let expected = form_token(csrf_secret, action);
- ct_eq(expected.as_bytes(), presented.as_bytes())
- }
- /// A live session: task-12 record plus the task-25 sidecar.
- pub struct LiveSession {
- /// Authentication record (subject, MFA state, restriction flag).
- pub record: store::sessions::SessionRecord,
- /// Sidecar metadata (`auth_time`, CSRF secret, pending request).
- pub meta: SessionMeta,
- }
- /// Load a session; `Ok(None)` covers every non-actionable case (no record,
- /// expired record, or missing sidecar) so callers bounce to login uniformly.
- ///
- /// # Errors
- ///
- /// Returns a masked `5xx` when either store read fails.
- pub async fn load_session(
- conn: &mut RedisConn,
- store: &Store,
- session_id: &str,
- ) -> Result<Option<LiveSession>, AppError> {
- if session_id.is_empty() {
- return Ok(None);
- }
- let record = store::sessions::get(conn, store, session_id).await?;
- let meta = meta_get(conn, store, session_id).await?;
- match (record, meta) {
- (Some(record), Some(meta)) => Ok(Some(LiveSession { record, meta })),
- _ => Ok(None),
- }
- }
- /// Borrow shared state into core dependencies around one Redis connection.
- fn deps_of<'a>(state: &'a AppState, conn: &'a mut RedisConn, store: &'a Store) -> LoginDeps<'a> {
- LoginDeps {
- pool: &state.pool,
- conn,
- store,
- keys: &state.keys,
- config: &state.config,
- now: state.clock.now(),
- }
- }
- /// Ensure a pre-session for `GET /login`: reuse the presented session id when
- /// it names a live session, else mint an empty one. Returns the id and
- /// whether it was freshly minted (the handler sets the cookie only then).
- ///
- /// # Errors
- ///
- /// Returns a masked `5xx` when minting or the session store fails.
- pub async fn ensure_presession(
- deps: &mut LoginDeps<'_>,
- presented: Option<&str>,
- ) -> Result<(String, bool), AppError> {
- if let Some(id) = presented.filter(|id| !id.is_empty())
- && load_session(deps.conn, deps.store, id).await?.is_some()
- {
- return Ok((id.to_owned(), false));
- }
- let session_id = mint_token().map_err(AppError::internal)?;
- let record = store::sessions::SessionRecord {
- subject: String::new(),
- mfa_verified: false,
- restricted: false,
- };
- let meta = SessionMeta {
- auth_time: deps.now.unix_timestamp(),
- csrf_secret: mint_token().map_err(AppError::internal)?,
- pending_request_id: None,
- post_logout_client_id: None,
- post_logout_uri: None,
- };
- store::sessions::put(
- deps.conn,
- deps.store,
- &session_id,
- &record,
- deps.config.ttl.session,
- )
- .await?;
- meta_put(deps.conn, deps.store, deps.config, &session_id, &meta).await?;
- Ok((session_id, true))
- }
- /// Bind a validated pending-auth id to a pre-session (from `GET /login`).
- ///
- /// The id must already be validated with `pending::get` by the caller; this
- /// only records the server-side binding `POST /login` later trusts. The flow's
- /// resolved `post_logout_uri` is frozen here too, overwriting any stale binding
- /// so logout returns to the client the session last served.
- ///
- /// # Errors
- ///
- /// Returns a masked `5xx` when the session store fails.
- pub async fn bind_pending_request(
- deps: &mut LoginDeps<'_>,
- presession_id: &str,
- request_id: &str,
- client_id: &str,
- post_logout_uri: Option<&str>,
- ) -> Result<(), AppError> {
- let Some(mut live) = load_session(deps.conn, deps.store, presession_id).await? else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- live.meta.pending_request_id = Some(request_id.to_owned());
- live.meta.post_logout_client_id = Some(client_id.to_owned());
- live.meta.post_logout_uri = post_logout_uri.map(str::to_owned);
- meta_put(
- deps.conn,
- deps.store,
- deps.config,
- presession_id,
- &live.meta,
- )
- .await
- }
- /// Verify credentials and rotate the pre-session into an authenticated (or
- /// restricted) session. See the module docs for the exact order.
- ///
- /// # Errors
- ///
- /// Returns `invalid_request` for a bad CSRF token, a missing pre-session, or
- /// an expired/consumed pending record, and a masked `5xx` for store, crypto,
- /// or database failures. Credential rejections are `Ok(Rejected)`, never an
- /// error, so handlers render one uniform page for all of them.
- pub async fn attempt_login(
- deps: &mut LoginDeps<'_>,
- attempt: LoginAttempt,
- ) -> Result<LoginOutcome, AppError> {
- if !verify_login_csrf(deps.keys, &attempt.presession_id, &attempt.csrf) {
- return Err(AppError::invalid_request("invalid form submission"));
- }
- let email = normalize_email(&attempt.email);
- let policy = user_repo::LockoutPolicy {
- threshold: LOCKOUT_THRESHOLD,
- lock_duration: LOCKOUT_WINDOW,
- };
- if user_repo::is_locked_out(deps.conn, deps.store, policy, &email).await? {
- audit_failure(None, None, "locked");
- return Ok(LoginOutcome::Rejected);
- }
- let (pending_id, post_logout_client_id, post_logout_uri) =
- match load_session(deps.conn, deps.store, &attempt.presession_id).await? {
- Some(live) => (
- live.meta.pending_request_id,
- live.meta.post_logout_client_id,
- live.meta.post_logout_uri,
- ),
- None => return Err(AppError::invalid_request("the login session has expired")),
- };
- match user_repo::verify_password(deps.pool, deps.keys, &email, &attempt.password).await? {
- user_repo::PasswordVerdict::Unknown => {
- burn_timing(deps.config, &attempt.password)?;
- user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
- audit_failure(pending_id.as_deref(), None, "bad_credentials");
- return Ok(LoginOutcome::Rejected);
- }
- user_repo::PasswordVerdict::Disabled => {
- user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
- audit_failure(pending_id.as_deref(), Some(&email), "disabled");
- return Ok(LoginOutcome::Rejected);
- }
- user_repo::PasswordVerdict::Mismatched => {
- user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
- audit_failure(pending_id.as_deref(), Some(&email), "bad_credentials");
- return Ok(LoginOutcome::Rejected);
- }
- user_repo::PasswordVerdict::Matched => {}
- }
- let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &email).await? else {
- audit_failure(pending_id.as_deref(), None, "bad_credentials");
- return Ok(LoginOutcome::Rejected);
- };
- let mut mfa_done = false;
- if user.totp_enabled {
- if !verify_second_factor(deps, user.id, &attempt.code).await? {
- user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
- audit_failure(pending_id.as_deref(), Some(&email), "bad_code");
- return Ok(LoginOutcome::Rejected);
- }
- mfa_done = true;
- }
- user_repo::clear_login_failures(deps.conn, deps.store, &email).await?;
- // Bind strictly: the pending record must still exist at login time, and the
- // redirect leaves from the stored record — never from client input.
- let redirect_to = match pending_id.as_deref() {
- Some(request_id) => {
- if store::pending::get(deps.conn, deps.store, request_id)
- .await?
- .is_none()
- {
- return Err(AppError::invalid_request(
- "the authorization request is unknown or has expired",
- ));
- }
- CONSENT_PATH.to_owned()
- }
- None => LOGIN_SUCCESS_PATH.to_owned(),
- };
- let restricted = user.must_change_password || !user.totp_enabled;
- let session_id = mint_token().map_err(AppError::internal)?;
- let record = store::sessions::SessionRecord {
- subject: email.clone(),
- mfa_verified: mfa_done,
- restricted,
- };
- let meta = SessionMeta {
- auth_time: deps.now.unix_timestamp(),
- csrf_secret: mint_token().map_err(AppError::internal)?,
- pending_request_id: pending_id.clone(),
- post_logout_client_id,
- post_logout_uri,
- };
- store::sessions::rotate(
- deps.conn,
- deps.store,
- &attempt.presession_id,
- &session_id,
- &record,
- deps.config.ttl.session,
- )
- .await?;
- if meta_put(deps.conn, deps.store, deps.config, &session_id, &meta)
- .await
- .is_err()
- {
- let _ = store::sessions::destroy(deps.conn, deps.store, &session_id).await;
- return Err(AppError::server_error());
- }
- meta_del(deps.conn, deps.store, &attempt.presession_id).await?;
- audit_success(pending_id.as_deref(), &email);
- if restricted {
- Ok(LoginOutcome::Restricted {
- session_id,
- redirect_to: CHANGE_PASSWORD_PATH.to_owned(),
- })
- } else {
- Ok(LoginOutcome::Authenticated {
- session_id,
- redirect_to,
- })
- }
- }
- /// Outcome of [`change_password`].
- pub enum PasswordOutcome {
- /// Bootstrap complete and TOTP enrolled: full session re-issued.
- Promoted {
- /// New full session id (already stored).
- session_id: String,
- /// Consent page when bound, else the success page.
- redirect_to: String,
- },
- /// Password set; TOTP enrollment still pending (session stays restricted).
- StillRestricted {
- /// Unchanged session id.
- session_id: String,
- },
- }
- /// Set a new password on a restricted (or full) session.
- ///
- /// Clears `must_change_password` in the database. When TOTP is already
- /// enrolled the session is re-issued as full and the caller redirects on;
- /// otherwise the session stays restricted for the enrollment step.
- ///
- /// # Errors
- ///
- /// Returns `invalid_request` for a missing session, mismatched confirmation,
- /// or a weak password, and a masked `5xx` for store/crypto/database failures.
- pub async fn change_password(
- deps: &mut LoginDeps<'_>,
- session_id: &str,
- new_password: &str,
- confirm: &str,
- ) -> Result<PasswordOutcome, AppError> {
- let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- if live.record.subject.is_empty() {
- return Err(AppError::invalid_request("the login session has expired"));
- }
- if new_password != confirm {
- return Err(AppError::invalid_request("the passwords do not match"));
- }
- if new_password.len() < MIN_PASSWORD_LEN {
- return Err(AppError::invalid_request(
- "the password does not meet the length requirement",
- ));
- }
- let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
- else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- let params = secret_params(deps.config)?;
- let hash = hash_secret(new_password, ¶ms).map_err(AppError::internal)?;
- if !user_repo::set_password(deps.pool, user.id, &hash).await? {
- return Err(AppError::server_error());
- }
- let enrolled = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject)
- .await?
- .is_some_and(|user| user.totp_enabled);
- if !enrolled {
- return Ok(PasswordOutcome::StillRestricted {
- session_id: session_id.to_owned(),
- });
- }
- promote_to_full(deps, session_id, &live).await
- }
- /// One-time enrollment material: the secret is already stored encrypted; the
- /// plaintext below is shown once and never stored.
- pub struct EnrollmentOffer {
- /// The raw secret (for display alongside the URI).
- pub secret: String,
- /// `otpauth://` URI for authenticator apps.
- pub otpauth_uri: String,
- /// Plaintext recovery codes, shown exactly once.
- pub recovery_codes: Vec<String>,
- }
- /// Provision (or re-provision) a TOTP secret plus recovery codes.
- ///
- /// Requires a session with a subject (never a pre-session). Overwrites any
- /// previous secret and code set; the session stays restricted until
- /// [`confirm_totp`] succeeds.
- ///
- /// # Errors
- ///
- /// Returns `invalid_request` without a subject session and a masked `5xx` for
- /// store/crypto/database failures.
- pub async fn provision_totp(
- deps: &mut LoginDeps<'_>,
- session_id: &str,
- ) -> Result<EnrollmentOffer, AppError> {
- let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- if live.record.subject.is_empty() {
- return Err(AppError::invalid_request("the login session has expired"));
- }
- let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
- else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- let secret = totp::generate_secret()?;
- let blob = totp::encrypt_secret(deps.keys, &secret, !deps.config.pii_dual_write)?;
- if !user_repo::set_totp_secret(deps.pool, user.id, &blob).await? {
- return Err(AppError::server_error());
- }
- let codes = totp::generate_recovery_codes()?;
- let params = secret_params(deps.config)?;
- let mut hashes = Vec::with_capacity(codes.len());
- for code in &codes {
- hashes.push(hash_secret(code, ¶ms).map_err(AppError::internal)?);
- }
- if !user_repo::set_recovery_codes(deps.pool, user.id, &hashes).await? {
- return Err(AppError::server_error());
- }
- Ok(EnrollmentOffer {
- secret: secret.clone(),
- otpauth_uri: totp::otpauth_uri(&deps.config.issuer, &user.email, &secret),
- recovery_codes: codes,
- })
- }
- /// Outcome of [`confirm_totp`].
- pub enum ConfirmOutcome {
- /// Authenticator proven and bootstrap complete: full session re-issued.
- Completed {
- /// New full session id (already stored).
- session_id: String,
- /// Consent page when bound, else the success page.
- redirect_to: String,
- },
- /// Authenticator proven but a password change is still required.
- StillRestricted,
- /// Wrong code, no enrolled secret, or a locked-out account: the session is
- /// unchanged and may retry.
- Rejected,
- }
- /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
- /// codes never confirm enrollment). Failed attempts increment the shared
- /// login lockout counter; a successful confirmation clears it.
- ///
- /// # Errors
- ///
- /// Returns `invalid_request` without a subject session and a masked `5xx`
- /// for store/crypto/database failures. A wrong code, and a locked-out account
- /// even when the code is correct, are `Ok(Rejected)` with no distinguishing
- /// signal.
- pub async fn confirm_totp(
- deps: &mut LoginDeps<'_>,
- session_id: &str,
- code: &str,
- ) -> Result<ConfirmOutcome, AppError> {
- let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- if live.record.subject.is_empty() {
- return Err(AppError::invalid_request("the login session has expired"));
- }
- // Failed-confirmation lockout: the same counter and policy `attempt_login`
- // uses, keyed by the session subject. Checked before verification so a
- // locked account rejects even a correct code, indistinguishably from a
- // wrong one, until the window expires.
- let policy = user_repo::LockoutPolicy {
- threshold: LOCKOUT_THRESHOLD,
- lock_duration: LOCKOUT_WINDOW,
- };
- if user_repo::is_locked_out(deps.conn, deps.store, policy, &live.record.subject).await? {
- return Ok(ConfirmOutcome::Rejected);
- }
- let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
- else {
- return Err(AppError::invalid_request("the login session has expired"));
- };
- let Some(blob) = user_repo::totp_secret_blob(deps.pool, user.id).await? else {
- user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
- return Ok(ConfirmOutcome::Rejected);
- };
- let secret = totp::decrypt_secret(deps.keys, &blob)?;
- let now_unix = u64::try_from(deps.now.unix_timestamp().max(0)).unwrap_or(0);
- let key_for = |step: u64| {
- deps.store.totp_replay_key(&totp::replay_fingerprint(
- &user.id.to_string(),
- code.trim(),
- step,
- ))
- };
- if !totp::verify_and_claim(
- deps.conn,
- key_for,
- &secret,
- code,
- now_unix,
- totp::USED_CODE_TTL,
- )
- .await?
- {
- user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
- audit_failure(
- live.meta.pending_request_id.as_deref(),
- Some(&live.record.subject),
- "bad_confirm_code",
- );
- return Ok(ConfirmOutcome::Rejected);
- }
- user_repo::clear_login_failures(deps.conn, deps.store, &live.record.subject).await?;
- let fresh = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?;
- if fresh.is_some_and(|user| !user.must_change_password) {
- let promoted = promote_to_full(deps, session_id, &live).await?;
- match promoted {
- PasswordOutcome::Promoted {
- session_id,
- redirect_to,
- } => Ok(ConfirmOutcome::Completed {
- session_id,
- redirect_to,
- }),
- PasswordOutcome::StillRestricted { .. } => Ok(ConfirmOutcome::StillRestricted),
- }
- } else {
- Ok(ConfirmOutcome::StillRestricted)
- }
- }
- /// Destroy a session and its sidecar; missing sessions destroy to success.
- ///
- /// # Errors
- ///
- /// Returns a masked `5xx` when the store fails.
- pub async fn logout(conn: &mut RedisConn, store: &Store, session_id: &str) -> Result<(), AppError> {
- store::sessions::destroy(conn, store, session_id).await?;
- meta_del(conn, store, session_id).await?;
- Ok(())
- }
- /// Verify the second factor: TOTP first, else exactly one recovery code.
- ///
- /// Returns `false` for a wrong, replayed, or already-consumed code. Only a
- /// malformed stored secret or a store/database failure is an error.
- async fn verify_second_factor(
- deps: &mut LoginDeps<'_>,
- user_id: uuid::Uuid,
- code: &str,
- ) -> Result<bool, AppError> {
- let user_label = user_id.to_string();
- if let Some(blob) = user_repo::totp_secret_blob(deps.pool, user_id).await? {
- let secret = totp::decrypt_secret(deps.keys, &blob)?;
- let now_unix = u64::try_from(deps.now.unix_timestamp().max(0)).unwrap_or(0);
- let key_for = |step: u64| {
- deps
- .store
- .totp_replay_key(&totp::replay_fingerprint(&user_label, code.trim(), step))
- };
- if totp::verify_and_claim(
- deps.conn,
- key_for,
- &secret,
- code,
- now_unix,
- totp::USED_CODE_TTL,
- )
- .await?
- {
- return Ok(true);
- }
- }
- let normalized = totp::normalize_recovery_code(code);
- if normalized.is_empty() {
- return Ok(false);
- }
- user_repo::consume_recovery_code(deps.pool, user_id, &normalized).await
- }
- /// Re-issue a session as full: MFA passed, not restricted, fresh CSRF secret.
- /// The pending-request binding and original `auth_time` carry over.
- async fn promote_to_full(
- deps: &mut LoginDeps<'_>,
- session_id: &str,
- live: &LiveSession,
- ) -> Result<PasswordOutcome, AppError> {
- let next_id = mint_token().map_err(AppError::internal)?;
- let record = store::sessions::SessionRecord {
- subject: live.record.subject.clone(),
- mfa_verified: true,
- restricted: false,
- };
- let meta = SessionMeta {
- auth_time: live.meta.auth_time,
- csrf_secret: mint_token().map_err(AppError::internal)?,
- pending_request_id: live.meta.pending_request_id.clone(),
- post_logout_client_id: live.meta.post_logout_client_id.clone(),
- post_logout_uri: live.meta.post_logout_uri.clone(),
- };
- store::sessions::rotate(
- deps.conn,
- deps.store,
- session_id,
- &next_id,
- &record,
- deps.config.ttl.session,
- )
- .await?;
- if meta_put(deps.conn, deps.store, deps.config, &next_id, &meta)
- .await
- .is_err()
- {
- let _ = store::sessions::destroy(deps.conn, deps.store, &next_id).await;
- return Err(AppError::server_error());
- }
- meta_del(deps.conn, deps.store, session_id).await?;
- let redirect_to = match live.meta.pending_request_id.as_deref() {
- Some(request_id) => {
- if store::pending::get(deps.conn, deps.store, request_id)
- .await?
- .is_none()
- {
- return Err(AppError::invalid_request(
- "the authorization request is unknown or has expired",
- ));
- }
- CONSENT_PATH.to_owned()
- }
- None => LOGIN_SUCCESS_PATH.to_owned(),
- };
- audit_success(
- live.meta.pending_request_id.as_deref(),
- &live.record.subject,
- );
- Ok(PasswordOutcome::Promoted {
- session_id: next_id,
- redirect_to,
- })
- }
- /// Hash once and discard: equalizes timing between unknown and known emails.
- fn burn_timing(config: &Config, password: &str) -> Result<(), AppError> {
- let params = secret_params(config)?;
- hash_secret(password, ¶ms).map_err(AppError::internal)?;
- Ok(())
- }
- /// Argon2id parameters from configuration for password/code hashing.
- fn secret_params(config: &Config) -> Result<Argon2Params, AppError> {
- let raw = &config.argon2;
- Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32).map_err(AppError::internal)
- }
- /// The active token pepper's bytes for stateless HMACs.
- fn active_pepper(keys: &KeyRing) -> Result<&[u8], AppError> {
- keys
- .active_pepper()
- .map(|(_, secret)| secret.expose_secret().as_bytes())
- .ok_or_else(AppError::server_error)
- }
- /// HMAC-SHA256 hex over joined parts; `server_error` only when the key is bad.
- fn hmac_hex(key: &[u8], first: &[u8], second: &[u8]) -> Result<String, AppError> {
- let mut mac = Hmac::<Sha256>::new_from_slice(key).map_err(|_| AppError::server_error())?;
- mac.update(first);
- mac.update(second);
- Ok(hex_bytes(&mac.finalize().into_bytes()))
- }
- /// Lowercase hex encoding of raw bytes.
- fn hex_bytes(bytes: &[u8]) -> String {
- let mut out = String::with_capacity(bytes.len() * 2);
- for b in bytes {
- out.push_str(&format!("{b:02x}"));
- }
- out
- }
- /// Audit a successful authentication (subject only; never secrets).
- fn audit_success(request_id: Option<&str>, subject: &str) {
- AuditEvent::LoginSuccess {
- request_id,
- subject,
- }
- .emit();
- }
- /// Audit a rejected attempt: a short reason, the subject only when the
- /// account exists, and never the presented credentials.
- fn audit_failure(request_id: Option<&str>, subject: Option<&str>, reason: &str) {
- AuditEvent::LoginFailure {
- request_id,
- subject,
- reason,
- }
- .emit();
- }
- /// Audit a stored-value open failure as `pii.decrypt_failure` (fail-closed).
- ///
- /// Matches [`AppError::PiiDecrypt`] specifically; any other error passes
- /// through silently. The event carries only the request id — never the value
- /// that failed to open and never any plaintext — and the error itself still
- /// renders 503 via [`AppError`].
- fn emit_pii_decrypt(request_id: &RequestId, error: &AppError) {
- if matches!(error, AppError::PiiDecrypt) {
- AuditEvent::PiiDecryptFailure {
- request_id: Some(request_id.as_str()),
- }
- .emit();
- }
- }
- /// Sidecar key for a session id (same `{state}` slot as the record).
- fn meta_key(store: &Store, session_id: &str) -> String {
- format!("{}:meta", store.session_key(session_id))
- }
- /// Write sidecar metadata with the session TTL (idempotent rewrite).
- async fn meta_put(
- conn: &mut RedisConn,
- store: &Store,
- config: &Config,
- session_id: &str,
- meta: &SessionMeta,
- ) -> Result<(), AppError> {
- let key = meta_key(store, session_id);
- let pairs = vec![
- ("auth_time".to_owned(), meta.auth_time.to_string()),
- ("csrf_secret".to_owned(), meta.csrf_secret.clone()),
- (
- "pending_request_id".to_owned(),
- meta.pending_request_id.clone().unwrap_or_default(),
- ),
- (
- "post_logout_client_id".to_owned(),
- meta.post_logout_client_id.clone().unwrap_or_default(),
- ),
- (
- "post_logout_uri".to_owned(),
- meta.post_logout_uri.clone().unwrap_or_default(),
- ),
- ];
- let budget = store.retry_budget();
- let ttl = config.ttl.session;
- crate::store::retry_op!(conn, budget, async {
- store::hset_record(conn, &key, &pairs, ttl)
- .await
- .map_err(meta_redis)
- })
- .map_err(StoreError::Redis)?;
- Ok(())
- }
- /// Read sidecar metadata; `Ok(None)` when absent or malformed.
- async fn meta_get(
- conn: &mut RedisConn,
- store: &Store,
- session_id: &str,
- ) -> Result<Option<SessionMeta>, AppError> {
- let key = meta_key(store, session_id);
- let budget = store.retry_budget();
- let fields = crate::store::retry_op!(conn, budget, async {
- store::hget_record(conn, &key).await.map_err(meta_redis)
- })
- .map_err(StoreError::Redis)?;
- let Some(fields) = fields else {
- return Ok(None);
- };
- let auth_time = fields
- .get("auth_time")
- .and_then(|value| value.parse::<i64>().ok());
- let csrf_secret = fields.get("csrf_secret").cloned();
- match (auth_time, csrf_secret) {
- (Some(auth_time), Some(csrf_secret)) => Ok(Some(SessionMeta {
- auth_time,
- csrf_secret,
- pending_request_id: fields
- .get("pending_request_id")
- .filter(|value| !value.is_empty())
- .cloned(),
- post_logout_client_id: fields
- .get("post_logout_client_id")
- .filter(|value| !value.is_empty())
- .cloned(),
- post_logout_uri: fields
- .get("post_logout_uri")
- .filter(|value| !value.is_empty())
- .cloned(),
- })),
- _ => Ok(None),
- }
- }
- /// Delete sidecar metadata; missing keys delete to success.
- async fn meta_del(conn: &mut RedisConn, store: &Store, session_id: &str) -> Result<(), AppError> {
- let key = meta_key(store, session_id);
- let _: () = redis::cmd("DEL")
- .arg(&key)
- .query_async(conn)
- .await
- .map_err(StoreError::Redis)?;
- Ok(())
- }
- /// Convert a store error into a Redis error for the retry macro.
- fn meta_redis(error: StoreError) -> redis::RedisError {
- match error {
- StoreError::Redis(inner) => inner,
- _ => redis::RedisError::from((redis::ErrorKind::Client, "store session meta failed")),
- }
- }
- // ---------------------------------------------------------------------------
- // HTTP handlers (thin wrappers; task 40 mounts them in `routes.rs`)
- // ---------------------------------------------------------------------------
- /// Query string for `GET /login`: the opaque pending-auth id, if any.
- #[derive(Debug, Deserialize)]
- pub struct LoginPageQuery {
- /// Pending-auth `request_id` handed off from `/authorize`.
- #[serde(default)]
- pub request_id: Option<String>,
- }
- /// Form posted to `POST /login`.
- #[derive(Debug, Deserialize)]
- pub struct LoginForm {
- /// Email as typed.
- #[serde(default)]
- pub email: String,
- /// Password as typed.
- #[serde(default)]
- pub password: String,
- /// TOTP or recovery code as typed.
- #[serde(default)]
- pub code: String,
- /// Login-CSRF token from the form.
- #[serde(default)]
- pub csrf: String,
- }
- /// Form posted to `POST /login/change-password`.
- #[derive(Debug, Deserialize)]
- pub struct PasswordForm {
- /// New password.
- #[serde(default)]
- pub new_password: String,
- /// New password, repeated.
- #[serde(default)]
- pub confirm: String,
- /// Session-bound form token.
- #[serde(default)]
- pub csrf: String,
- }
- /// Form posted to `POST /login/totp/confirm`.
- #[derive(Debug, Deserialize)]
- pub struct ConfirmForm {
- /// Authenticator code as typed.
- #[serde(default)]
- pub code: String,
- /// Session-bound form token.
- #[serde(default)]
- pub csrf: String,
- }
- /// Login page.
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Sign in</title></head><body>\
- <h1>Sign in</h1>\
- {% if has_client %}<p>Continue to <strong>{{ client }}</strong>.</p>{% endif %}\
- {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
- <form method=\"post\" action=\"/login\">\
- <label for=\"email\">Email</label>\
- <input id=\"email\" name=\"email\" type=\"email\" value=\"{{ email }}\" inputmode=\"email\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\">\
- <label for=\"password\">Password</label>\
- <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autocapitalize=\"none\" spellcheck=\"false\">\
- <label for=\"code\">Authenticator code or recovery code</label>\
- <input id=\"code\" name=\"code\" autocomplete=\"one-time-code\">\
- <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
- <button type=\"submit\">Sign in</button></form></body></html>",
- ext = "html"
- )]
- struct LoginTemplate {
- csrf: String,
- email: String,
- client: String,
- has_client: bool,
- error: String,
- has_error: bool,
- }
- /// Password-change page.
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Change password</title></head><body>\
- <h1>Change your password</h1>\
- {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
- <form method=\"post\" action=\"/login/change-password\">\
- <label for=\"new_password\">New password (at least 12 characters)</label>\
- <input id=\"new_password\" name=\"new_password\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
- <label for=\"confirm\">Confirm new password</label>\
- <input id=\"confirm\" name=\"confirm\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
- <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
- <button type=\"submit\">Change password</button></form></body></html>",
- ext = "html"
- )]
- struct PasswordTemplate {
- csrf: String,
- error: String,
- has_error: bool,
- }
- /// TOTP enrollment page: secret, URI, and one-time recovery codes.
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Set up authenticator</title></head><body>\
- <h1>Set up your authenticator</h1>\
- <p>Scan the QR code or enter the secret into your authenticator app, then confirm with a code.</p>\
- <div class=\"qr\">{{ qr_svg|safe }}</div>\
- <p>Secret: <strong>{{ secret }}</strong></p>\
- <p>URI: {{ uri }}</p>\
- <p>Recovery codes (shown once, store them safely):</p>\
- <ul>{% for code in codes %}<li><strong>{{ code }}</strong></li>{% endfor %}</ul>\
- {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
- <form method=\"post\" action=\"/login/totp/confirm\">\
- <label for=\"code\">Authenticator code</label>\
- <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
- <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
- <button type=\"submit\">Confirm</button></form></body></html>",
- ext = "html"
- )]
- struct SetupTemplate {
- csrf: String,
- secret: String,
- uri: String,
- qr_svg: String,
- codes: Vec<String>,
- error: String,
- has_error: bool,
- }
- /// TOTP confirmation retry page (codes were shown once already).
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Confirm authenticator</title></head><body>\
- <h1>Confirm your authenticator</h1>\
- {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
- <form method=\"post\" action=\"/login/totp/confirm\">\
- <label for=\"code\">Authenticator code</label>\
- <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
- <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
- <button type=\"submit\">Confirm</button></form></body></html>",
- ext = "html"
- )]
- struct ConfirmTemplate {
- csrf: String,
- error: String,
- has_error: bool,
- }
- /// Signed-in landing page for logins with no bound request.
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Signed in</title></head><body>\
- <h1>Signed in</h1><p>You are signed in.</p>\
- <form method=\"post\" action=\"/logout\">\
- <button type=\"submit\">Sign out</button></form></body></html>",
- ext = "html"
- )]
- struct SuccessTemplate;
- /// Neutral landing page after an RP-initiated logout. Deliberately has no
- /// auto-redirect and no form post: a forced navigation to `GET /logout` (for
- /// example an `<img src="/logout">`) must only end the session, never bounce
- /// the browser into a fresh login or an external target. This is a
- /// server-specific session endpoint, not an OAuth 2.1 or OIDC defined feature.
- #[derive(Template)]
- #[template(
- source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Signed out</title></head><body>\
- <h1>Signed out</h1><p>Your session has ended.</p>\
- <p><a href=\"/login\">Sign in again</a>.</p></body></html>",
- ext = "html"
- )]
- struct SignedOutTemplate;
- /// `GET /login`: bind the presented `request_id` to the session — overwriting
- /// any stale binding so an authenticated session serves the new flow — then
- /// render the form, or redirect an already-authenticated session to its landing.
- pub async fn get_login(
- State(state): State<AppState>,
- jar: CookieJar,
- Query(query): Query<LoginPageQuery>,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let presented = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned());
- let (presession_id, fresh) = {
- let mut deps = deps_of(&state, &mut conn, &store);
- match ensure_presession(&mut deps, presented.as_deref()).await {
- Ok(pair) => pair,
- Err(error) => return error.into_response(),
- }
- };
- // Bind a fresh `?request_id=` (overwriting any stale binding) before deciding
- // the landing, so an already-authenticated session serves the new
- // authorization request instead of the one it carried from a prior flow.
- let mut client = String::new();
- if let Some(request_id) = query.request_id.as_deref() {
- match store::pending::get(&mut conn, &store, request_id).await {
- Ok(Some(record)) => {
- let mut deps = deps_of(&state, &mut conn, &store);
- if bind_pending_request(
- &mut deps,
- &presession_id,
- request_id,
- &record.client_id,
- record.post_logout_uri.as_deref(),
- )
- .await
- .is_err()
- {
- return AppError::server_error().into_response();
- }
- client = record.client_id;
- }
- Ok(None) => {
- return AppError::invalid_request("the authorization request is unknown or has expired")
- .into_response();
- }
- Err(error) => return AppError::from(error).into_response(),
- }
- }
- // A live authenticated session does not need the form again.
- if !fresh
- && let Some(live) = load_session(&mut conn, &store, &presession_id)
- .await
- .unwrap_or(None)
- && !live.record.subject.is_empty()
- {
- let target = login_landing(&live);
- return Redirect::to(&target).into_response();
- }
- let csrf = match login_csrf_token(&state.keys, &presession_id) {
- Ok(token) => token,
- Err(error) => return error.into_response(),
- };
- let template = LoginTemplate {
- csrf,
- email: String::new(),
- client: client.clone(),
- has_client: !client.is_empty(),
- error: String::new(),
- has_error: false,
- };
- let mut response = render(template);
- if fresh {
- response = (
- jar.add(session_cookie(&state.config, &presession_id)),
- response,
- )
- .into_response();
- }
- response
- }
- /// `POST /login`: verify credentials, rotate the session, redirect strictly.
- pub async fn post_login(
- State(state): State<AppState>,
- Extension(request_id): Extension<RequestId>,
- jar: CookieJar,
- Form(form): Form<LoginForm>,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let presession_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let attempt = LoginAttempt {
- presession_id,
- email: form.email.clone(),
- password: form.password,
- code: form.code,
- csrf: form.csrf,
- };
- let mut deps = deps_of(&state, &mut conn, &store);
- match attempt_login(&mut deps, attempt).await {
- Ok(LoginOutcome::Authenticated {
- session_id,
- redirect_to,
- })
- | Ok(LoginOutcome::Restricted {
- session_id,
- redirect_to,
- }) => (
- jar.add(session_cookie(&state.config, &session_id)),
- Redirect::to(&redirect_to),
- )
- .into_response(),
- Ok(LoginOutcome::Rejected) => (
- StatusCode::UNAUTHORIZED,
- [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
- login_rejected_page(&form.email),
- )
- .into_response(),
- Err(error) => {
- emit_pii_decrypt(&request_id, &error);
- error.into_response()
- }
- }
- }
- /// `GET /login/change-password`: the password-change form (session required).
- pub async fn get_change_password(State(state): State<AppState>, jar: CookieJar) -> Response {
- gated_form(
- &state,
- &jar,
- CHANGE_PASSWORD_ACTION,
- BootstrapForm::Password,
- )
- .await
- }
- /// `POST /login/change-password`: set the password, promote when enrolled.
- pub async fn post_change_password(
- State(state): State<AppState>,
- Extension(request_id): Extension<RequestId>,
- jar: CookieJar,
- Form(form): Form<PasswordForm>,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let live = match load_session(&mut conn, &store, &session_id).await {
- Ok(Some(live)) => live,
- Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
- Err(error) => return error.into_response(),
- };
- if !verify_form_token(&live.meta.csrf_secret, CHANGE_PASSWORD_ACTION, &form.csrf) {
- return AppError::invalid_request("invalid form submission").into_response();
- }
- let mut deps = deps_of(&state, &mut conn, &store);
- match change_password(&mut deps, &session_id, &form.new_password, &form.confirm).await {
- Ok(PasswordOutcome::Promoted {
- session_id,
- redirect_to,
- }) => (
- jar.add(session_cookie(&state.config, &session_id)),
- Redirect::to(&redirect_to),
- )
- .into_response(),
- Ok(PasswordOutcome::StillRestricted { .. }) => Redirect::to(TOTP_SETUP_PATH).into_response(),
- Err(error) => {
- emit_pii_decrypt(&request_id, &error);
- error.into_response()
- }
- }
- }
- /// `GET /login/totp/setup`: provision and display enrollment material once.
- pub async fn get_totp_setup(
- State(state): State<AppState>,
- Extension(request_id): Extension<RequestId>,
- jar: CookieJar,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let live = match load_session(&mut conn, &store, &session_id).await {
- Ok(Some(live)) => live,
- Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
- Err(error) => return error.into_response(),
- };
- if live.record.subject.is_empty() {
- return Redirect::to(LOGIN_PATH).into_response();
- }
- let mut deps = deps_of(&state, &mut conn, &store);
- match provision_totp(&mut deps, &session_id).await {
- Ok(offer) => {
- let qr_svg = match totp::qr_svg(&offer.otpauth_uri) {
- Ok(svg) => svg,
- Err(_) => return AppError::server_error().into_response(),
- };
- render(SetupTemplate {
- csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
- secret: offer.secret,
- uri: offer.otpauth_uri,
- qr_svg,
- codes: offer.recovery_codes,
- error: String::new(),
- has_error: false,
- })
- }
- Err(error) => {
- emit_pii_decrypt(&request_id, &error);
- error.into_response()
- }
- }
- }
- /// `GET /login/totp/confirm`: the confirmation retry form (session required).
- pub async fn get_totp_confirm(State(state): State<AppState>, jar: CookieJar) -> Response {
- gated_form(&state, &jar, TOTP_CONFIRM_ACTION, BootstrapForm::Confirm).await
- }
- /// `POST /login/totp/confirm`: verify the authenticator code, promote or stay.
- pub async fn post_totp_confirm(
- State(state): State<AppState>,
- Extension(request_id): Extension<RequestId>,
- jar: CookieJar,
- Form(form): Form<ConfirmForm>,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let live = match load_session(&mut conn, &store, &session_id).await {
- Ok(Some(live)) => live,
- Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
- Err(error) => return error.into_response(),
- };
- if !verify_form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION, &form.csrf) {
- return AppError::invalid_request("invalid form submission").into_response();
- }
- let mut deps = deps_of(&state, &mut conn, &store);
- match confirm_totp(&mut deps, &session_id, &form.code).await {
- Ok(ConfirmOutcome::Completed {
- session_id,
- redirect_to,
- }) => (
- jar.add(session_cookie(&state.config, &session_id)),
- Redirect::to(&redirect_to),
- )
- .into_response(),
- Ok(ConfirmOutcome::StillRestricted) => Redirect::to(CHANGE_PASSWORD_PATH).into_response(),
- Ok(ConfirmOutcome::Rejected) => render(ConfirmTemplate {
- csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
- error: "That code was not accepted. Check your authenticator and try again.".to_owned(),
- has_error: true,
- }),
- Err(error) => {
- emit_pii_decrypt(&request_id, &error);
- error.into_response()
- }
- }
- }
- /// `POST /logout`: destroy the session and expire the cookie.
- pub async fn post_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- if logout(&mut conn, &store, &session_id).await.is_err() {
- return AppError::server_error().into_response();
- }
- (jar.add(expired_session_cookie()), Redirect::to(LOGIN_PATH)).into_response()
- }
- /// `GET /logout`: destroy the session, expire the cookie, then render a neutral
- /// signed-out page or redirect to the flow's registered post-logout URI.
- ///
- /// No query parameter is ever honored, so the target cannot be supplied by the
- /// caller. The return URI was frozen in session meta when the authorization
- /// flow was bound, and it is used only if the owning client is still active and
- /// still registers that exact URI. If there is no session, no bound client, or
- /// the client/URI is no longer valid, the neutral page is rendered and no
- /// redirect is issued. The server session is destroyed idempotently; a missing
- /// session is a no-op.
- pub async fn get_logout(
- State(state): State<AppState>,
- Extension(request_id): Extension<RequestId>,
- jar: CookieJar,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let target = match load_session(&mut conn, &store, &session_id)
- .await
- .unwrap_or(None)
- {
- Some(live) => match resolve_post_logout_target(&state, &live).await {
- Ok(target) => target,
- Err(error) => {
- emit_pii_decrypt(&request_id, &error);
- if logout(&mut conn, &store, &session_id).await.is_err() {
- return AppError::server_error().into_response();
- }
- return (jar.add(expired_session_cookie()), error.into_response()).into_response();
- }
- },
- None => None,
- };
- if logout(&mut conn, &store, &session_id).await.is_err() {
- return AppError::server_error().into_response();
- }
- let response = match target {
- Some(uri) => Redirect::to(&uri).into_response(),
- None => render(SignedOutTemplate),
- };
- (jar.add(expired_session_cookie()), response).into_response()
- }
- /// Resolve the session's bound post-logout target, if it is still trustworthy.
- ///
- /// Returns the frozen URI only when the flow recorded both a client and a URI,
- /// the client is still active, and that exact URI is still in the client's
- /// effective post-logout set (exact-match or RFC 8252 loopback). Any miss or
- /// database failure yields `Ok(None)` (no redirect), never a guessed target.
- /// A stored-value open failure is fail-closed as `Err(AppError::PiiDecrypt)`
- /// (503), never downgraded to the neutral page.
- async fn resolve_post_logout_target(
- state: &AppState,
- live: &LiveSession,
- ) -> Result<Option<String>, AppError> {
- let (Some(client_id), Some(bound)) = (
- live.meta.post_logout_client_id.as_deref(),
- live.meta.post_logout_uri.as_deref(),
- ) else {
- return Ok(None);
- };
- let client =
- match client_repo::find_active_by_client_id(&state.pool, &state.keys, client_id).await {
- Ok(client) => client,
- Err(AppError::PiiDecrypt) => return Err(AppError::PiiDecrypt),
- Err(_) => return Ok(None),
- };
- if client.is_none() {
- return Ok(None);
- }
- let effective = match redirect_repo::effective_post_logout_for_client(
- &state.pool,
- &state.keys,
- client_id,
- )
- .await
- {
- Ok(effective) => effective,
- Err(AppError::PiiDecrypt) => return Err(AppError::PiiDecrypt),
- Err(_) => return Ok(None),
- };
- let registered: Vec<&str> = effective.iter().map(String::as_str).collect();
- match redirect_match::match_redirect_uri(®istered, bound) {
- redirect_match::RedirectDecision::Exact | redirect_match::RedirectDecision::Loopback => {
- Ok(Some(bound.to_owned()))
- }
- redirect_match::RedirectDecision::Reject => Ok(None),
- }
- }
- /// `GET /login/success`: landing page for logins with no bound request.
- pub async fn get_login_success(State(state): State<AppState>, jar: CookieJar) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- match load_session(&mut conn, &store, &session_id).await {
- Ok(Some(live)) if !live.record.subject.is_empty() && !live.record.restricted => {
- render(SuccessTemplate)
- }
- Ok(_) => Redirect::to(LOGIN_PATH).into_response(),
- Err(error) => error.into_response(),
- }
- }
- /// Form-token action for the password-change form.
- const CHANGE_PASSWORD_ACTION: &str = "change-password";
- /// Form-token action for the TOTP-confirmation form.
- const TOTP_CONFIRM_ACTION: &str = "totp-confirm";
- /// Which bootstrap form a gated `GET` renders.
- enum BootstrapForm {
- /// The password-change form.
- Password,
- /// The TOTP-confirmation retry form.
- Confirm,
- }
- /// Render a session-gated bootstrap form, bouncing anonymous callers to login.
- async fn gated_form(
- state: &AppState,
- jar: &CookieJar,
- action: &str,
- form: BootstrapForm,
- ) -> Response {
- let mut conn = state.redis.clone();
- let store = Store::from_config(&state.config.redis);
- let session_id = jar
- .get(SESSION_COOKIE)
- .map(|cookie| cookie.value().to_owned())
- .unwrap_or_default();
- let live = match load_session(&mut conn, &store, &session_id).await {
- Ok(Some(live)) => live,
- Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
- Err(error) => return error.into_response(),
- };
- if live.record.subject.is_empty() {
- return Redirect::to(LOGIN_PATH).into_response();
- }
- let csrf = form_token(&live.meta.csrf_secret, action);
- match form {
- BootstrapForm::Password => render(PasswordTemplate {
- csrf,
- error: String::new(),
- has_error: false,
- }),
- BootstrapForm::Confirm => render(ConfirmTemplate {
- csrf,
- error: String::new(),
- has_error: false,
- }),
- }
- }
- /// Where an already-authenticated `GET /login` lands.
- fn login_landing(live: &LiveSession) -> String {
- if live.record.restricted {
- CHANGE_PASSWORD_PATH.to_owned()
- } else if live.meta.pending_request_id.is_some() {
- CONSENT_PATH.to_owned()
- } else {
- LOGIN_SUCCESS_PATH.to_owned()
- }
- }
- /// Render an Askama template as `text/html`; a render failure is a masked 500.
- fn render(template: impl Template) -> Response {
- match template.render() {
- Ok(body) => (
- StatusCode::OK,
- [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
- body,
- )
- .into_response(),
- Err(_) => AppError::server_error().into_response(),
- }
- }
- /// Uniform 401 page: identical for every credential-failure reason.
- fn login_rejected_page(email: &str) -> String {
- format!(
- "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
- <link rel=\"stylesheet\" href=\"/assets/app.css\">\
- <title>Sign in</title></head><body>\
- <h1>Sign in</h1>\
- <p role=\"alert\">{INVALID_CREDENTIALS}</p>\
- <p>Attempted sign-in as {email} was rejected.</p>\
- <p><a href=\"{LOGIN_PATH}\">Try again</a>.</p></body></html>",
- email = html_escape(email)
- )
- }
- /// Minimal HTML escaping for reflected values in hand-built pages.
- fn html_escape(value: &str) -> String {
- let mut out = String::with_capacity(value.len());
- for c in value.chars() {
- match c {
- '&' => out.push_str("&"),
- '<' => out.push_str("<"),
- '>' => out.push_str(">"),
- '"' => out.push_str("""),
- _ => out.push(c),
- }
- }
- out
- }
|