login.rs 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687
  1. //! Login page: password + TOTP on a Redis-backed session.
  2. //!
  3. //! First-login users pass through forced password change + TOTP enrollment
  4. //! in a restricted session that reaches nothing else.
  5. //!
  6. //! Flow: `GET /login` mints (or reuses) an empty pre-session and renders the
  7. //! form with a stateless login-CSRF token. `POST /login` verifies the CSRF
  8. //! token, the password (Argon2id), and the second factor (TOTP ±1 step with a
  9. //! Redis replay claim, or a one-time recovery code), enforces the Redis
  10. //! lockout, then rotates the pre-session into an authenticated session and
  11. //! redirects strictly to the bound pending-auth record (task 24). Every
  12. //! credential failure — wrong password, unknown email, disabled account,
  13. //! lockout, wrong/replayed code — renders the same message and status, so no
  14. //! response reveals whether an email exists. Passwords, codes, and secrets
  15. //! never reach logs; pages are server-rendered with no `localStorage`.
  16. //!
  17. //! The session record itself is task 12's [`SessionRecord`]; the two fields
  18. //! that record cannot hold (`auth_time`, CSRF secret, pending `request_id`)
  19. //! ride a sidecar hash (`<session-key>:meta`, same `{state}` slot, same TTL)
  20. //! instead of editing `store::sessions` — the task-29 sidecar precedent. The
  21. //! pending `request_id` lives only in that sidecar after `GET /login`
  22. //! validates it, so `POST /login` cannot be steered off-record.
  23. use std::time::Duration;
  24. use askama::Template;
  25. use axum::{
  26. Extension, Form,
  27. extract::{Query, State},
  28. http::{StatusCode, header},
  29. response::{IntoResponse, Redirect, Response},
  30. };
  31. use axum_extra::extract::{
  32. CookieJar,
  33. cookie::{Cookie, SameSite},
  34. };
  35. use hmac::{Hmac, KeyInit as _, Mac as _};
  36. use secrecy::ExposeSecret as _;
  37. use serde::{Deserialize, Serialize};
  38. use sha2::Sha256;
  39. use sqlx::PgPool;
  40. use time::OffsetDateTime;
  41. use crate::{
  42. config::Config,
  43. crypto::{
  44. ct::ct_eq,
  45. secret::{Argon2Params, hash_secret},
  46. tokens::mint_token,
  47. totp,
  48. },
  49. db::{
  50. client_repo, redirect_repo,
  51. user_repo::{self, normalize_email},
  52. },
  53. error::AppError,
  54. keys::KeyRing,
  55. middleware::{audit::AuditEvent, request_id::RequestId},
  56. protocol::redirect_repo as redirect_match,
  57. state::{AppState, RedisConn},
  58. store::{self, Store, StoreError},
  59. };
  60. /// Session cookie name (also the rendezvous for task 26/29 readers).
  61. pub const SESSION_COOKIE: &str = "session_id";
  62. /// Login form page.
  63. pub const LOGIN_PATH: &str = "/login";
  64. /// Landing page for logins with no bound authorization request.
  65. pub const LOGIN_SUCCESS_PATH: &str = "/login/success";
  66. /// Restricted-session password-change page.
  67. pub const CHANGE_PASSWORD_PATH: &str = "/login/change-password";
  68. /// Restricted-session TOTP provisioning page.
  69. pub const TOTP_SETUP_PATH: &str = "/login/totp/setup";
  70. /// Restricted-session TOTP confirmation page.
  71. pub const TOTP_CONFIRM_PATH: &str = "/login/totp/confirm";
  72. /// Logout endpoint.
  73. pub const LOGOUT_PATH: &str = "/logout";
  74. /// Post-login target for a bound authorization request (owned by task 26).
  75. pub const CONSENT_PATH: &str = "/consent";
  76. /// Failed logins within [`LOCKOUT_WINDOW`] that lock an account.
  77. pub const LOCKOUT_THRESHOLD: u64 = 5;
  78. /// Window that doubles as the lock duration.
  79. pub const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60);
  80. /// Minimum accepted password length on change.
  81. pub const MIN_PASSWORD_LEN: usize = 12;
  82. /// The single credential-failure message for every rejection reason.
  83. pub const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
  84. #[cfg(test)]
  85. mod tests;
  86. /// Server-side session metadata the task-12 record cannot hold.
  87. ///
  88. /// Stored as a hash at `<session-key>:meta` with the session TTL.
  89. /// `pending_request_id` is the validated pending-auth id from `GET /login`;
  90. /// `POST /login` and every later step read it only from here, never from the
  91. /// client, so the post-login redirect cannot be steered off-record.
  92. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  93. pub struct SessionMeta {
  94. /// Authentication instant (Unix seconds, from the injectable clock).
  95. pub auth_time: i64,
  96. /// Per-session CSRF secret (256-bit opaque); form tokens derive from it.
  97. pub csrf_secret: String,
  98. /// Validated pending-auth `request_id`, when the login serves a flow.
  99. pub pending_request_id: Option<String>,
  100. /// The client the last flow served, when known. Used at logout only to
  101. /// re-check the client is still active and still registers the bound
  102. /// post-logout URI before redirecting to it.
  103. pub post_logout_client_id: Option<String>,
  104. /// The client's post-logout return URI for the flow this session last
  105. /// served, frozen at binding time. Read only at logout, never from a request,
  106. /// so the logout redirect cannot be steered off-record.
  107. pub post_logout_uri: Option<String>,
  108. }
  109. /// Everything the login core needs, borrowed from the HTTP layer.
  110. pub struct LoginDeps<'a> {
  111. /// Postgres pool for user lookups and updates.
  112. pub pool: &'a PgPool,
  113. /// Redis connection for sessions, lockout counters, and replay claims.
  114. pub conn: &'a mut RedisConn,
  115. /// Key layout plus retry/durability knobs.
  116. pub store: &'a Store,
  117. /// Versioned peppers and TOTP-encryption keys.
  118. pub keys: &'a KeyRing,
  119. /// Validated runtime configuration.
  120. pub config: &'a Config,
  121. /// Current time (injectable via [`AppState::clock`]).
  122. pub now: OffsetDateTime,
  123. }
  124. /// Input to a login attempt (one `POST /login`).
  125. pub struct LoginAttempt {
  126. /// Pre-session id from the session cookie.
  127. pub presession_id: String,
  128. /// Raw email as typed.
  129. pub email: String,
  130. /// Raw password as typed.
  131. pub password: String,
  132. /// Raw second-factor code as typed (TOTP or recovery).
  133. pub code: String,
  134. /// Presented login-CSRF token.
  135. pub csrf: String,
  136. }
  137. /// Outcome of [`attempt_login`].
  138. pub enum LoginOutcome {
  139. /// Fully authenticated: session minted, redirect to consent/success.
  140. Authenticated {
  141. /// New session id (already stored; the handler sets the cookie).
  142. session_id: String,
  143. /// Strictly bound redirect target.
  144. redirect_to: String,
  145. },
  146. /// Bootstrap required: restricted session minted, redirect to change-password.
  147. Restricted {
  148. /// New restricted session id (already stored).
  149. session_id: String,
  150. /// Always [`CHANGE_PASSWORD_PATH`].
  151. redirect_to: String,
  152. },
  153. /// Credentials rejected (uniform message; the handler renders 401).
  154. Rejected,
  155. }
  156. /// Whether a restricted session may touch `path`.
  157. ///
  158. /// Restricted sessions reach the login/bootstrap/logout routes only.
  159. /// `/authorize`, `/oauth/*`, and any admin action return `false`; their
  160. /// handlers deny restricted sessions before any other check.
  161. #[must_use]
  162. pub fn restricted_allows_path(path: &str) -> bool {
  163. matches!(
  164. path,
  165. LOGIN_PATH
  166. | LOGIN_SUCCESS_PATH
  167. | CHANGE_PASSWORD_PATH
  168. | TOTP_SETUP_PATH
  169. | TOTP_CONFIRM_PATH
  170. | LOGOUT_PATH
  171. )
  172. }
  173. /// Build the session cookie: `HttpOnly`, `SameSite=Lax`, `Secure` exactly
  174. /// when [`Config::cookie_secure`] is set (disabled for localhost development).
  175. #[must_use]
  176. pub fn session_cookie(config: &Config, session_id: &str) -> Cookie<'static> {
  177. let mut builder = Cookie::build((SESSION_COOKIE, session_id.to_owned()))
  178. .path("/")
  179. .http_only(true)
  180. .same_site(SameSite::Lax);
  181. if config.cookie_secure {
  182. builder = builder.secure(true);
  183. }
  184. if let Some(domain) = config.cookie_domain.clone() {
  185. builder = builder.domain(domain);
  186. }
  187. builder.build()
  188. }
  189. /// Build an expired session cookie for logout.
  190. #[must_use]
  191. pub fn expired_session_cookie() -> Cookie<'static> {
  192. Cookie::build((SESSION_COOKIE, String::new()))
  193. .path("/")
  194. .removal()
  195. .build()
  196. }
  197. /// Mint the stateless pre-login CSRF token: HMAC under the active token
  198. /// pepper over `session_id || "|login"`. No server-side CSRF state is stored.
  199. ///
  200. /// # Errors
  201. ///
  202. /// Returns `server_error` when no pepper is configured.
  203. pub fn login_csrf_token(keys: &KeyRing, session_id: &str) -> Result<String, AppError> {
  204. hmac_hex(active_pepper(keys)?, session_id.as_bytes(), b"|login")
  205. }
  206. /// Verify a pre-login CSRF token in constant time.
  207. #[must_use]
  208. pub fn verify_login_csrf(keys: &KeyRing, session_id: &str, presented: &str) -> bool {
  209. let Ok(expected) = login_csrf_token(keys, session_id) else {
  210. return false;
  211. };
  212. ct_eq(expected.as_bytes(), presented.as_bytes())
  213. }
  214. /// Mint a form token bound to the session CSRF secret for one action.
  215. ///
  216. /// A key failure degrades to an empty token (which never verifies) rather
  217. /// than an error, so form rendering stays infallible.
  218. #[must_use]
  219. pub fn form_token(csrf_secret: &str, action: &str) -> String {
  220. hmac_hex(csrf_secret.as_bytes(), action.as_bytes(), b"").unwrap_or_default()
  221. }
  222. /// Verify a session-bound form token in constant time.
  223. #[must_use]
  224. pub fn verify_form_token(csrf_secret: &str, action: &str, presented: &str) -> bool {
  225. let expected = form_token(csrf_secret, action);
  226. ct_eq(expected.as_bytes(), presented.as_bytes())
  227. }
  228. /// A live session: task-12 record plus the task-25 sidecar.
  229. pub struct LiveSession {
  230. /// Authentication record (subject, MFA state, restriction flag).
  231. pub record: store::sessions::SessionRecord,
  232. /// Sidecar metadata (`auth_time`, CSRF secret, pending request).
  233. pub meta: SessionMeta,
  234. }
  235. /// Load a session; `Ok(None)` covers every non-actionable case (no record,
  236. /// expired record, or missing sidecar) so callers bounce to login uniformly.
  237. ///
  238. /// # Errors
  239. ///
  240. /// Returns a masked `5xx` when either store read fails.
  241. pub async fn load_session(
  242. conn: &mut RedisConn,
  243. store: &Store,
  244. session_id: &str,
  245. ) -> Result<Option<LiveSession>, AppError> {
  246. if session_id.is_empty() {
  247. return Ok(None);
  248. }
  249. let record = store::sessions::get(conn, store, session_id).await?;
  250. let meta = meta_get(conn, store, session_id).await?;
  251. match (record, meta) {
  252. (Some(record), Some(meta)) => Ok(Some(LiveSession { record, meta })),
  253. _ => Ok(None),
  254. }
  255. }
  256. /// Borrow shared state into core dependencies around one Redis connection.
  257. fn deps_of<'a>(state: &'a AppState, conn: &'a mut RedisConn, store: &'a Store) -> LoginDeps<'a> {
  258. LoginDeps {
  259. pool: &state.pool,
  260. conn,
  261. store,
  262. keys: &state.keys,
  263. config: &state.config,
  264. now: state.clock.now(),
  265. }
  266. }
  267. /// Ensure a pre-session for `GET /login`: reuse the presented session id when
  268. /// it names a live session, else mint an empty one. Returns the id and
  269. /// whether it was freshly minted (the handler sets the cookie only then).
  270. ///
  271. /// # Errors
  272. ///
  273. /// Returns a masked `5xx` when minting or the session store fails.
  274. pub async fn ensure_presession(
  275. deps: &mut LoginDeps<'_>,
  276. presented: Option<&str>,
  277. ) -> Result<(String, bool), AppError> {
  278. if let Some(id) = presented.filter(|id| !id.is_empty())
  279. && load_session(deps.conn, deps.store, id).await?.is_some()
  280. {
  281. return Ok((id.to_owned(), false));
  282. }
  283. let session_id = mint_token().map_err(AppError::internal)?;
  284. let record = store::sessions::SessionRecord {
  285. subject: String::new(),
  286. mfa_verified: false,
  287. restricted: false,
  288. };
  289. let meta = SessionMeta {
  290. auth_time: deps.now.unix_timestamp(),
  291. csrf_secret: mint_token().map_err(AppError::internal)?,
  292. pending_request_id: None,
  293. post_logout_client_id: None,
  294. post_logout_uri: None,
  295. };
  296. store::sessions::put(
  297. deps.conn,
  298. deps.store,
  299. &session_id,
  300. &record,
  301. deps.config.ttl.session,
  302. )
  303. .await?;
  304. meta_put(deps.conn, deps.store, deps.config, &session_id, &meta).await?;
  305. Ok((session_id, true))
  306. }
  307. /// Bind a validated pending-auth id to a pre-session (from `GET /login`).
  308. ///
  309. /// The id must already be validated with `pending::get` by the caller; this
  310. /// only records the server-side binding `POST /login` later trusts. The flow's
  311. /// resolved `post_logout_uri` is frozen here too, overwriting any stale binding
  312. /// so logout returns to the client the session last served.
  313. ///
  314. /// # Errors
  315. ///
  316. /// Returns a masked `5xx` when the session store fails.
  317. pub async fn bind_pending_request(
  318. deps: &mut LoginDeps<'_>,
  319. presession_id: &str,
  320. request_id: &str,
  321. client_id: &str,
  322. post_logout_uri: Option<&str>,
  323. ) -> Result<(), AppError> {
  324. let Some(mut live) = load_session(deps.conn, deps.store, presession_id).await? else {
  325. return Err(AppError::invalid_request("the login session has expired"));
  326. };
  327. live.meta.pending_request_id = Some(request_id.to_owned());
  328. live.meta.post_logout_client_id = Some(client_id.to_owned());
  329. live.meta.post_logout_uri = post_logout_uri.map(str::to_owned);
  330. meta_put(
  331. deps.conn,
  332. deps.store,
  333. deps.config,
  334. presession_id,
  335. &live.meta,
  336. )
  337. .await
  338. }
  339. /// Verify credentials and rotate the pre-session into an authenticated (or
  340. /// restricted) session. See the module docs for the exact order.
  341. ///
  342. /// # Errors
  343. ///
  344. /// Returns `invalid_request` for a bad CSRF token, a missing pre-session, or
  345. /// an expired/consumed pending record, and a masked `5xx` for store, crypto,
  346. /// or database failures. Credential rejections are `Ok(Rejected)`, never an
  347. /// error, so handlers render one uniform page for all of them.
  348. pub async fn attempt_login(
  349. deps: &mut LoginDeps<'_>,
  350. attempt: LoginAttempt,
  351. ) -> Result<LoginOutcome, AppError> {
  352. if !verify_login_csrf(deps.keys, &attempt.presession_id, &attempt.csrf) {
  353. return Err(AppError::invalid_request("invalid form submission"));
  354. }
  355. let email = normalize_email(&attempt.email);
  356. let policy = user_repo::LockoutPolicy {
  357. threshold: LOCKOUT_THRESHOLD,
  358. lock_duration: LOCKOUT_WINDOW,
  359. };
  360. if user_repo::is_locked_out(deps.conn, deps.store, policy, &email).await? {
  361. audit_failure(None, None, "locked");
  362. return Ok(LoginOutcome::Rejected);
  363. }
  364. let (pending_id, post_logout_client_id, post_logout_uri) =
  365. match load_session(deps.conn, deps.store, &attempt.presession_id).await? {
  366. Some(live) => (
  367. live.meta.pending_request_id,
  368. live.meta.post_logout_client_id,
  369. live.meta.post_logout_uri,
  370. ),
  371. None => return Err(AppError::invalid_request("the login session has expired")),
  372. };
  373. match user_repo::verify_password(deps.pool, deps.keys, &email, &attempt.password).await? {
  374. user_repo::PasswordVerdict::Unknown => {
  375. burn_timing(deps.config, &attempt.password)?;
  376. user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
  377. audit_failure(pending_id.as_deref(), None, "bad_credentials");
  378. return Ok(LoginOutcome::Rejected);
  379. }
  380. user_repo::PasswordVerdict::Disabled => {
  381. user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
  382. audit_failure(pending_id.as_deref(), Some(&email), "disabled");
  383. return Ok(LoginOutcome::Rejected);
  384. }
  385. user_repo::PasswordVerdict::Mismatched => {
  386. user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
  387. audit_failure(pending_id.as_deref(), Some(&email), "bad_credentials");
  388. return Ok(LoginOutcome::Rejected);
  389. }
  390. user_repo::PasswordVerdict::Matched => {}
  391. }
  392. let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &email).await? else {
  393. audit_failure(pending_id.as_deref(), None, "bad_credentials");
  394. return Ok(LoginOutcome::Rejected);
  395. };
  396. let mut mfa_done = false;
  397. if user.totp_enabled {
  398. if !verify_second_factor(deps, user.id, &attempt.code).await? {
  399. user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
  400. audit_failure(pending_id.as_deref(), Some(&email), "bad_code");
  401. return Ok(LoginOutcome::Rejected);
  402. }
  403. mfa_done = true;
  404. }
  405. user_repo::clear_login_failures(deps.conn, deps.store, &email).await?;
  406. // Bind strictly: the pending record must still exist at login time, and the
  407. // redirect leaves from the stored record — never from client input.
  408. let redirect_to = match pending_id.as_deref() {
  409. Some(request_id) => {
  410. if store::pending::get(deps.conn, deps.store, request_id)
  411. .await?
  412. .is_none()
  413. {
  414. return Err(AppError::invalid_request(
  415. "the authorization request is unknown or has expired",
  416. ));
  417. }
  418. CONSENT_PATH.to_owned()
  419. }
  420. None => LOGIN_SUCCESS_PATH.to_owned(),
  421. };
  422. let restricted = user.must_change_password || !user.totp_enabled;
  423. let session_id = mint_token().map_err(AppError::internal)?;
  424. let record = store::sessions::SessionRecord {
  425. subject: email.clone(),
  426. mfa_verified: mfa_done,
  427. restricted,
  428. };
  429. let meta = SessionMeta {
  430. auth_time: deps.now.unix_timestamp(),
  431. csrf_secret: mint_token().map_err(AppError::internal)?,
  432. pending_request_id: pending_id.clone(),
  433. post_logout_client_id,
  434. post_logout_uri,
  435. };
  436. store::sessions::rotate(
  437. deps.conn,
  438. deps.store,
  439. &attempt.presession_id,
  440. &session_id,
  441. &record,
  442. deps.config.ttl.session,
  443. )
  444. .await?;
  445. if meta_put(deps.conn, deps.store, deps.config, &session_id, &meta)
  446. .await
  447. .is_err()
  448. {
  449. let _ = store::sessions::destroy(deps.conn, deps.store, &session_id).await;
  450. return Err(AppError::server_error());
  451. }
  452. meta_del(deps.conn, deps.store, &attempt.presession_id).await?;
  453. audit_success(pending_id.as_deref(), &email);
  454. if restricted {
  455. Ok(LoginOutcome::Restricted {
  456. session_id,
  457. redirect_to: CHANGE_PASSWORD_PATH.to_owned(),
  458. })
  459. } else {
  460. Ok(LoginOutcome::Authenticated {
  461. session_id,
  462. redirect_to,
  463. })
  464. }
  465. }
  466. /// Outcome of [`change_password`].
  467. pub enum PasswordOutcome {
  468. /// Bootstrap complete and TOTP enrolled: full session re-issued.
  469. Promoted {
  470. /// New full session id (already stored).
  471. session_id: String,
  472. /// Consent page when bound, else the success page.
  473. redirect_to: String,
  474. },
  475. /// Password set; TOTP enrollment still pending (session stays restricted).
  476. StillRestricted {
  477. /// Unchanged session id.
  478. session_id: String,
  479. },
  480. }
  481. /// Set a new password on a restricted (or full) session.
  482. ///
  483. /// Clears `must_change_password` in the database. When TOTP is already
  484. /// enrolled the session is re-issued as full and the caller redirects on;
  485. /// otherwise the session stays restricted for the enrollment step.
  486. ///
  487. /// # Errors
  488. ///
  489. /// Returns `invalid_request` for a missing session, mismatched confirmation,
  490. /// or a weak password, and a masked `5xx` for store/crypto/database failures.
  491. pub async fn change_password(
  492. deps: &mut LoginDeps<'_>,
  493. session_id: &str,
  494. new_password: &str,
  495. confirm: &str,
  496. ) -> Result<PasswordOutcome, AppError> {
  497. let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
  498. return Err(AppError::invalid_request("the login session has expired"));
  499. };
  500. if live.record.subject.is_empty() {
  501. return Err(AppError::invalid_request("the login session has expired"));
  502. }
  503. if new_password != confirm {
  504. return Err(AppError::invalid_request("the passwords do not match"));
  505. }
  506. if new_password.len() < MIN_PASSWORD_LEN {
  507. return Err(AppError::invalid_request(
  508. "the password does not meet the length requirement",
  509. ));
  510. }
  511. let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
  512. else {
  513. return Err(AppError::invalid_request("the login session has expired"));
  514. };
  515. let params = secret_params(deps.config)?;
  516. let hash = hash_secret(new_password, &params).map_err(AppError::internal)?;
  517. if !user_repo::set_password(deps.pool, user.id, &hash).await? {
  518. return Err(AppError::server_error());
  519. }
  520. let enrolled = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject)
  521. .await?
  522. .is_some_and(|user| user.totp_enabled);
  523. if !enrolled {
  524. return Ok(PasswordOutcome::StillRestricted {
  525. session_id: session_id.to_owned(),
  526. });
  527. }
  528. promote_to_full(deps, session_id, &live).await
  529. }
  530. /// One-time enrollment material: the secret is already stored encrypted; the
  531. /// plaintext below is shown once and never stored.
  532. pub struct EnrollmentOffer {
  533. /// The raw secret (for display alongside the URI).
  534. pub secret: String,
  535. /// `otpauth://` URI for authenticator apps.
  536. pub otpauth_uri: String,
  537. /// Plaintext recovery codes, shown exactly once.
  538. pub recovery_codes: Vec<String>,
  539. }
  540. /// Provision (or re-provision) a TOTP secret plus recovery codes.
  541. ///
  542. /// Requires a session with a subject (never a pre-session). Overwrites any
  543. /// previous secret and code set; the session stays restricted until
  544. /// [`confirm_totp`] succeeds.
  545. ///
  546. /// # Errors
  547. ///
  548. /// Returns `invalid_request` without a subject session and a masked `5xx` for
  549. /// store/crypto/database failures.
  550. pub async fn provision_totp(
  551. deps: &mut LoginDeps<'_>,
  552. session_id: &str,
  553. ) -> Result<EnrollmentOffer, AppError> {
  554. let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
  555. return Err(AppError::invalid_request("the login session has expired"));
  556. };
  557. if live.record.subject.is_empty() {
  558. return Err(AppError::invalid_request("the login session has expired"));
  559. }
  560. let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
  561. else {
  562. return Err(AppError::invalid_request("the login session has expired"));
  563. };
  564. let secret = totp::generate_secret()?;
  565. let blob = totp::encrypt_secret(deps.keys, &secret, !deps.config.pii_dual_write)?;
  566. if !user_repo::set_totp_secret(deps.pool, user.id, &blob).await? {
  567. return Err(AppError::server_error());
  568. }
  569. let codes = totp::generate_recovery_codes()?;
  570. let params = secret_params(deps.config)?;
  571. let mut hashes = Vec::with_capacity(codes.len());
  572. for code in &codes {
  573. hashes.push(hash_secret(code, &params).map_err(AppError::internal)?);
  574. }
  575. if !user_repo::set_recovery_codes(deps.pool, user.id, &hashes).await? {
  576. return Err(AppError::server_error());
  577. }
  578. Ok(EnrollmentOffer {
  579. secret: secret.clone(),
  580. otpauth_uri: totp::otpauth_uri(&deps.config.issuer, &user.email, &secret),
  581. recovery_codes: codes,
  582. })
  583. }
  584. /// Outcome of [`confirm_totp`].
  585. pub enum ConfirmOutcome {
  586. /// Authenticator proven and bootstrap complete: full session re-issued.
  587. Completed {
  588. /// New full session id (already stored).
  589. session_id: String,
  590. /// Consent page when bound, else the success page.
  591. redirect_to: String,
  592. },
  593. /// Authenticator proven but a password change is still required.
  594. StillRestricted,
  595. /// Wrong code, no enrolled secret, or a locked-out account: the session is
  596. /// unchanged and may retry.
  597. Rejected,
  598. }
  599. /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
  600. /// codes never confirm enrollment). Failed attempts increment the shared
  601. /// login lockout counter; a successful confirmation clears it.
  602. ///
  603. /// # Errors
  604. ///
  605. /// Returns `invalid_request` without a subject session and a masked `5xx`
  606. /// for store/crypto/database failures. A wrong code, and a locked-out account
  607. /// even when the code is correct, are `Ok(Rejected)` with no distinguishing
  608. /// signal.
  609. pub async fn confirm_totp(
  610. deps: &mut LoginDeps<'_>,
  611. session_id: &str,
  612. code: &str,
  613. ) -> Result<ConfirmOutcome, AppError> {
  614. let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
  615. return Err(AppError::invalid_request("the login session has expired"));
  616. };
  617. if live.record.subject.is_empty() {
  618. return Err(AppError::invalid_request("the login session has expired"));
  619. }
  620. // Failed-confirmation lockout: the same counter and policy `attempt_login`
  621. // uses, keyed by the session subject. Checked before verification so a
  622. // locked account rejects even a correct code, indistinguishably from a
  623. // wrong one, until the window expires.
  624. let policy = user_repo::LockoutPolicy {
  625. threshold: LOCKOUT_THRESHOLD,
  626. lock_duration: LOCKOUT_WINDOW,
  627. };
  628. if user_repo::is_locked_out(deps.conn, deps.store, policy, &live.record.subject).await? {
  629. return Ok(ConfirmOutcome::Rejected);
  630. }
  631. let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
  632. else {
  633. return Err(AppError::invalid_request("the login session has expired"));
  634. };
  635. let Some(blob) = user_repo::totp_secret_blob(deps.pool, user.id).await? else {
  636. user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
  637. return Ok(ConfirmOutcome::Rejected);
  638. };
  639. let secret = totp::decrypt_secret(deps.keys, &blob)?;
  640. let now_unix = u64::try_from(deps.now.unix_timestamp().max(0)).unwrap_or(0);
  641. let key_for = |step: u64| {
  642. deps.store.totp_replay_key(&totp::replay_fingerprint(
  643. &user.id.to_string(),
  644. code.trim(),
  645. step,
  646. ))
  647. };
  648. if !totp::verify_and_claim(
  649. deps.conn,
  650. key_for,
  651. &secret,
  652. code,
  653. now_unix,
  654. totp::USED_CODE_TTL,
  655. )
  656. .await?
  657. {
  658. user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
  659. audit_failure(
  660. live.meta.pending_request_id.as_deref(),
  661. Some(&live.record.subject),
  662. "bad_confirm_code",
  663. );
  664. return Ok(ConfirmOutcome::Rejected);
  665. }
  666. user_repo::clear_login_failures(deps.conn, deps.store, &live.record.subject).await?;
  667. let fresh = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?;
  668. if fresh.is_some_and(|user| !user.must_change_password) {
  669. let promoted = promote_to_full(deps, session_id, &live).await?;
  670. match promoted {
  671. PasswordOutcome::Promoted {
  672. session_id,
  673. redirect_to,
  674. } => Ok(ConfirmOutcome::Completed {
  675. session_id,
  676. redirect_to,
  677. }),
  678. PasswordOutcome::StillRestricted { .. } => Ok(ConfirmOutcome::StillRestricted),
  679. }
  680. } else {
  681. Ok(ConfirmOutcome::StillRestricted)
  682. }
  683. }
  684. /// Destroy a session and its sidecar; missing sessions destroy to success.
  685. ///
  686. /// # Errors
  687. ///
  688. /// Returns a masked `5xx` when the store fails.
  689. pub async fn logout(conn: &mut RedisConn, store: &Store, session_id: &str) -> Result<(), AppError> {
  690. store::sessions::destroy(conn, store, session_id).await?;
  691. meta_del(conn, store, session_id).await?;
  692. Ok(())
  693. }
  694. /// Verify the second factor: TOTP first, else exactly one recovery code.
  695. ///
  696. /// Returns `false` for a wrong, replayed, or already-consumed code. Only a
  697. /// malformed stored secret or a store/database failure is an error.
  698. async fn verify_second_factor(
  699. deps: &mut LoginDeps<'_>,
  700. user_id: uuid::Uuid,
  701. code: &str,
  702. ) -> Result<bool, AppError> {
  703. let user_label = user_id.to_string();
  704. if let Some(blob) = user_repo::totp_secret_blob(deps.pool, user_id).await? {
  705. let secret = totp::decrypt_secret(deps.keys, &blob)?;
  706. let now_unix = u64::try_from(deps.now.unix_timestamp().max(0)).unwrap_or(0);
  707. let key_for = |step: u64| {
  708. deps
  709. .store
  710. .totp_replay_key(&totp::replay_fingerprint(&user_label, code.trim(), step))
  711. };
  712. if totp::verify_and_claim(
  713. deps.conn,
  714. key_for,
  715. &secret,
  716. code,
  717. now_unix,
  718. totp::USED_CODE_TTL,
  719. )
  720. .await?
  721. {
  722. return Ok(true);
  723. }
  724. }
  725. let normalized = totp::normalize_recovery_code(code);
  726. if normalized.is_empty() {
  727. return Ok(false);
  728. }
  729. user_repo::consume_recovery_code(deps.pool, user_id, &normalized).await
  730. }
  731. /// Re-issue a session as full: MFA passed, not restricted, fresh CSRF secret.
  732. /// The pending-request binding and original `auth_time` carry over.
  733. async fn promote_to_full(
  734. deps: &mut LoginDeps<'_>,
  735. session_id: &str,
  736. live: &LiveSession,
  737. ) -> Result<PasswordOutcome, AppError> {
  738. let next_id = mint_token().map_err(AppError::internal)?;
  739. let record = store::sessions::SessionRecord {
  740. subject: live.record.subject.clone(),
  741. mfa_verified: true,
  742. restricted: false,
  743. };
  744. let meta = SessionMeta {
  745. auth_time: live.meta.auth_time,
  746. csrf_secret: mint_token().map_err(AppError::internal)?,
  747. pending_request_id: live.meta.pending_request_id.clone(),
  748. post_logout_client_id: live.meta.post_logout_client_id.clone(),
  749. post_logout_uri: live.meta.post_logout_uri.clone(),
  750. };
  751. store::sessions::rotate(
  752. deps.conn,
  753. deps.store,
  754. session_id,
  755. &next_id,
  756. &record,
  757. deps.config.ttl.session,
  758. )
  759. .await?;
  760. if meta_put(deps.conn, deps.store, deps.config, &next_id, &meta)
  761. .await
  762. .is_err()
  763. {
  764. let _ = store::sessions::destroy(deps.conn, deps.store, &next_id).await;
  765. return Err(AppError::server_error());
  766. }
  767. meta_del(deps.conn, deps.store, session_id).await?;
  768. let redirect_to = match live.meta.pending_request_id.as_deref() {
  769. Some(request_id) => {
  770. if store::pending::get(deps.conn, deps.store, request_id)
  771. .await?
  772. .is_none()
  773. {
  774. return Err(AppError::invalid_request(
  775. "the authorization request is unknown or has expired",
  776. ));
  777. }
  778. CONSENT_PATH.to_owned()
  779. }
  780. None => LOGIN_SUCCESS_PATH.to_owned(),
  781. };
  782. audit_success(
  783. live.meta.pending_request_id.as_deref(),
  784. &live.record.subject,
  785. );
  786. Ok(PasswordOutcome::Promoted {
  787. session_id: next_id,
  788. redirect_to,
  789. })
  790. }
  791. /// Hash once and discard: equalizes timing between unknown and known emails.
  792. fn burn_timing(config: &Config, password: &str) -> Result<(), AppError> {
  793. let params = secret_params(config)?;
  794. hash_secret(password, &params).map_err(AppError::internal)?;
  795. Ok(())
  796. }
  797. /// Argon2id parameters from configuration for password/code hashing.
  798. fn secret_params(config: &Config) -> Result<Argon2Params, AppError> {
  799. let raw = &config.argon2;
  800. Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32).map_err(AppError::internal)
  801. }
  802. /// The active token pepper's bytes for stateless HMACs.
  803. fn active_pepper(keys: &KeyRing) -> Result<&[u8], AppError> {
  804. keys
  805. .active_pepper()
  806. .map(|(_, secret)| secret.expose_secret().as_bytes())
  807. .ok_or_else(AppError::server_error)
  808. }
  809. /// HMAC-SHA256 hex over joined parts; `server_error` only when the key is bad.
  810. fn hmac_hex(key: &[u8], first: &[u8], second: &[u8]) -> Result<String, AppError> {
  811. let mut mac = Hmac::<Sha256>::new_from_slice(key).map_err(|_| AppError::server_error())?;
  812. mac.update(first);
  813. mac.update(second);
  814. Ok(hex_bytes(&mac.finalize().into_bytes()))
  815. }
  816. /// Lowercase hex encoding of raw bytes.
  817. fn hex_bytes(bytes: &[u8]) -> String {
  818. let mut out = String::with_capacity(bytes.len() * 2);
  819. for b in bytes {
  820. out.push_str(&format!("{b:02x}"));
  821. }
  822. out
  823. }
  824. /// Audit a successful authentication (subject only; never secrets).
  825. fn audit_success(request_id: Option<&str>, subject: &str) {
  826. AuditEvent::LoginSuccess {
  827. request_id,
  828. subject,
  829. }
  830. .emit();
  831. }
  832. /// Audit a rejected attempt: a short reason, the subject only when the
  833. /// account exists, and never the presented credentials.
  834. fn audit_failure(request_id: Option<&str>, subject: Option<&str>, reason: &str) {
  835. AuditEvent::LoginFailure {
  836. request_id,
  837. subject,
  838. reason,
  839. }
  840. .emit();
  841. }
  842. /// Audit a stored-value open failure as `pii.decrypt_failure` (fail-closed).
  843. ///
  844. /// Matches [`AppError::PiiDecrypt`] specifically; any other error passes
  845. /// through silently. The event carries only the request id — never the value
  846. /// that failed to open and never any plaintext — and the error itself still
  847. /// renders 503 via [`AppError`].
  848. fn emit_pii_decrypt(request_id: &RequestId, error: &AppError) {
  849. if matches!(error, AppError::PiiDecrypt) {
  850. AuditEvent::PiiDecryptFailure {
  851. request_id: Some(request_id.as_str()),
  852. }
  853. .emit();
  854. }
  855. }
  856. /// Sidecar key for a session id (same `{state}` slot as the record).
  857. fn meta_key(store: &Store, session_id: &str) -> String {
  858. format!("{}:meta", store.session_key(session_id))
  859. }
  860. /// Write sidecar metadata with the session TTL (idempotent rewrite).
  861. async fn meta_put(
  862. conn: &mut RedisConn,
  863. store: &Store,
  864. config: &Config,
  865. session_id: &str,
  866. meta: &SessionMeta,
  867. ) -> Result<(), AppError> {
  868. let key = meta_key(store, session_id);
  869. let pairs = vec![
  870. ("auth_time".to_owned(), meta.auth_time.to_string()),
  871. ("csrf_secret".to_owned(), meta.csrf_secret.clone()),
  872. (
  873. "pending_request_id".to_owned(),
  874. meta.pending_request_id.clone().unwrap_or_default(),
  875. ),
  876. (
  877. "post_logout_client_id".to_owned(),
  878. meta.post_logout_client_id.clone().unwrap_or_default(),
  879. ),
  880. (
  881. "post_logout_uri".to_owned(),
  882. meta.post_logout_uri.clone().unwrap_or_default(),
  883. ),
  884. ];
  885. let budget = store.retry_budget();
  886. let ttl = config.ttl.session;
  887. crate::store::retry_op!(conn, budget, async {
  888. store::hset_record(conn, &key, &pairs, ttl)
  889. .await
  890. .map_err(meta_redis)
  891. })
  892. .map_err(StoreError::Redis)?;
  893. Ok(())
  894. }
  895. /// Read sidecar metadata; `Ok(None)` when absent or malformed.
  896. async fn meta_get(
  897. conn: &mut RedisConn,
  898. store: &Store,
  899. session_id: &str,
  900. ) -> Result<Option<SessionMeta>, AppError> {
  901. let key = meta_key(store, session_id);
  902. let budget = store.retry_budget();
  903. let fields = crate::store::retry_op!(conn, budget, async {
  904. store::hget_record(conn, &key).await.map_err(meta_redis)
  905. })
  906. .map_err(StoreError::Redis)?;
  907. let Some(fields) = fields else {
  908. return Ok(None);
  909. };
  910. let auth_time = fields
  911. .get("auth_time")
  912. .and_then(|value| value.parse::<i64>().ok());
  913. let csrf_secret = fields.get("csrf_secret").cloned();
  914. match (auth_time, csrf_secret) {
  915. (Some(auth_time), Some(csrf_secret)) => Ok(Some(SessionMeta {
  916. auth_time,
  917. csrf_secret,
  918. pending_request_id: fields
  919. .get("pending_request_id")
  920. .filter(|value| !value.is_empty())
  921. .cloned(),
  922. post_logout_client_id: fields
  923. .get("post_logout_client_id")
  924. .filter(|value| !value.is_empty())
  925. .cloned(),
  926. post_logout_uri: fields
  927. .get("post_logout_uri")
  928. .filter(|value| !value.is_empty())
  929. .cloned(),
  930. })),
  931. _ => Ok(None),
  932. }
  933. }
  934. /// Delete sidecar metadata; missing keys delete to success.
  935. async fn meta_del(conn: &mut RedisConn, store: &Store, session_id: &str) -> Result<(), AppError> {
  936. let key = meta_key(store, session_id);
  937. let _: () = redis::cmd("DEL")
  938. .arg(&key)
  939. .query_async(conn)
  940. .await
  941. .map_err(StoreError::Redis)?;
  942. Ok(())
  943. }
  944. /// Convert a store error into a Redis error for the retry macro.
  945. fn meta_redis(error: StoreError) -> redis::RedisError {
  946. match error {
  947. StoreError::Redis(inner) => inner,
  948. _ => redis::RedisError::from((redis::ErrorKind::Client, "store session meta failed")),
  949. }
  950. }
  951. // ---------------------------------------------------------------------------
  952. // HTTP handlers (thin wrappers; task 40 mounts them in `routes.rs`)
  953. // ---------------------------------------------------------------------------
  954. /// Query string for `GET /login`: the opaque pending-auth id, if any.
  955. #[derive(Debug, Deserialize)]
  956. pub struct LoginPageQuery {
  957. /// Pending-auth `request_id` handed off from `/authorize`.
  958. #[serde(default)]
  959. pub request_id: Option<String>,
  960. }
  961. /// Form posted to `POST /login`.
  962. #[derive(Debug, Deserialize)]
  963. pub struct LoginForm {
  964. /// Email as typed.
  965. #[serde(default)]
  966. pub email: String,
  967. /// Password as typed.
  968. #[serde(default)]
  969. pub password: String,
  970. /// TOTP or recovery code as typed.
  971. #[serde(default)]
  972. pub code: String,
  973. /// Login-CSRF token from the form.
  974. #[serde(default)]
  975. pub csrf: String,
  976. }
  977. /// Form posted to `POST /login/change-password`.
  978. #[derive(Debug, Deserialize)]
  979. pub struct PasswordForm {
  980. /// New password.
  981. #[serde(default)]
  982. pub new_password: String,
  983. /// New password, repeated.
  984. #[serde(default)]
  985. pub confirm: String,
  986. /// Session-bound form token.
  987. #[serde(default)]
  988. pub csrf: String,
  989. }
  990. /// Form posted to `POST /login/totp/confirm`.
  991. #[derive(Debug, Deserialize)]
  992. pub struct ConfirmForm {
  993. /// Authenticator code as typed.
  994. #[serde(default)]
  995. pub code: String,
  996. /// Session-bound form token.
  997. #[serde(default)]
  998. pub csrf: String,
  999. }
  1000. /// Login page.
  1001. #[derive(Template)]
  1002. #[template(
  1003. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1004. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1005. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1006. <title>Sign in</title></head><body>\
  1007. <h1>Sign in</h1>\
  1008. {% if has_client %}<p>Continue to <strong>{{ client }}</strong>.</p>{% endif %}\
  1009. {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
  1010. <form method=\"post\" action=\"/login\">\
  1011. <label for=\"email\">Email</label>\
  1012. <input id=\"email\" name=\"email\" type=\"email\" value=\"{{ email }}\" inputmode=\"email\" autocomplete=\"username\" autocapitalize=\"none\" spellcheck=\"false\">\
  1013. <label for=\"password\">Password</label>\
  1014. <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autocapitalize=\"none\" spellcheck=\"false\">\
  1015. <label for=\"code\">Authenticator code or recovery code</label>\
  1016. <input id=\"code\" name=\"code\" autocomplete=\"one-time-code\">\
  1017. <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
  1018. <button type=\"submit\">Sign in</button></form></body></html>",
  1019. ext = "html"
  1020. )]
  1021. struct LoginTemplate {
  1022. csrf: String,
  1023. email: String,
  1024. client: String,
  1025. has_client: bool,
  1026. error: String,
  1027. has_error: bool,
  1028. }
  1029. /// Password-change page.
  1030. #[derive(Template)]
  1031. #[template(
  1032. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1033. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1034. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1035. <title>Change password</title></head><body>\
  1036. <h1>Change your password</h1>\
  1037. {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
  1038. <form method=\"post\" action=\"/login/change-password\">\
  1039. <label for=\"new_password\">New password (at least 12 characters)</label>\
  1040. <input id=\"new_password\" name=\"new_password\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
  1041. <label for=\"confirm\">Confirm new password</label>\
  1042. <input id=\"confirm\" name=\"confirm\" type=\"password\" autocomplete=\"new-password\" autocapitalize=\"none\" spellcheck=\"false\">\
  1043. <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
  1044. <button type=\"submit\">Change password</button></form></body></html>",
  1045. ext = "html"
  1046. )]
  1047. struct PasswordTemplate {
  1048. csrf: String,
  1049. error: String,
  1050. has_error: bool,
  1051. }
  1052. /// TOTP enrollment page: secret, URI, and one-time recovery codes.
  1053. #[derive(Template)]
  1054. #[template(
  1055. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1056. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1057. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1058. <title>Set up authenticator</title></head><body>\
  1059. <h1>Set up your authenticator</h1>\
  1060. <p>Scan the QR code or enter the secret into your authenticator app, then confirm with a code.</p>\
  1061. <div class=\"qr\">{{ qr_svg|safe }}</div>\
  1062. <p>Secret: <strong>{{ secret }}</strong></p>\
  1063. <p>URI: {{ uri }}</p>\
  1064. <p>Recovery codes (shown once, store them safely):</p>\
  1065. <ul>{% for code in codes %}<li><strong>{{ code }}</strong></li>{% endfor %}</ul>\
  1066. {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
  1067. <form method=\"post\" action=\"/login/totp/confirm\">\
  1068. <label for=\"code\">Authenticator code</label>\
  1069. <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
  1070. <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
  1071. <button type=\"submit\">Confirm</button></form></body></html>",
  1072. ext = "html"
  1073. )]
  1074. struct SetupTemplate {
  1075. csrf: String,
  1076. secret: String,
  1077. uri: String,
  1078. qr_svg: String,
  1079. codes: Vec<String>,
  1080. error: String,
  1081. has_error: bool,
  1082. }
  1083. /// TOTP confirmation retry page (codes were shown once already).
  1084. #[derive(Template)]
  1085. #[template(
  1086. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1087. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1088. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1089. <title>Confirm authenticator</title></head><body>\
  1090. <h1>Confirm your authenticator</h1>\
  1091. {% if has_error %}<p role=\"alert\">{{ error }}</p>{% endif %}\
  1092. <form method=\"post\" action=\"/login/totp/confirm\">\
  1093. <label for=\"code\">Authenticator code</label>\
  1094. <input id=\"code\" name=\"code\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" autocapitalize=\"none\" spellcheck=\"false\">\
  1095. <input type=\"hidden\" name=\"csrf\" value=\"{{ csrf }}\">\
  1096. <button type=\"submit\">Confirm</button></form></body></html>",
  1097. ext = "html"
  1098. )]
  1099. struct ConfirmTemplate {
  1100. csrf: String,
  1101. error: String,
  1102. has_error: bool,
  1103. }
  1104. /// Signed-in landing page for logins with no bound request.
  1105. #[derive(Template)]
  1106. #[template(
  1107. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1108. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1109. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1110. <title>Signed in</title></head><body>\
  1111. <h1>Signed in</h1><p>You are signed in.</p>\
  1112. <form method=\"post\" action=\"/logout\">\
  1113. <button type=\"submit\">Sign out</button></form></body></html>",
  1114. ext = "html"
  1115. )]
  1116. struct SuccessTemplate;
  1117. /// Neutral landing page after an RP-initiated logout. Deliberately has no
  1118. /// auto-redirect and no form post: a forced navigation to `GET /logout` (for
  1119. /// example an `<img src="/logout">`) must only end the session, never bounce
  1120. /// the browser into a fresh login or an external target. This is a
  1121. /// server-specific session endpoint, not an OAuth 2.1 or OIDC defined feature.
  1122. #[derive(Template)]
  1123. #[template(
  1124. source = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1125. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1126. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1127. <title>Signed out</title></head><body>\
  1128. <h1>Signed out</h1><p>Your session has ended.</p>\
  1129. <p><a href=\"/login\">Sign in again</a>.</p></body></html>",
  1130. ext = "html"
  1131. )]
  1132. struct SignedOutTemplate;
  1133. /// `GET /login`: bind the presented `request_id` to the session — overwriting
  1134. /// any stale binding so an authenticated session serves the new flow — then
  1135. /// render the form, or redirect an already-authenticated session to its landing.
  1136. pub async fn get_login(
  1137. State(state): State<AppState>,
  1138. jar: CookieJar,
  1139. Query(query): Query<LoginPageQuery>,
  1140. ) -> Response {
  1141. let mut conn = state.redis.clone();
  1142. let store = Store::from_config(&state.config.redis);
  1143. let presented = jar
  1144. .get(SESSION_COOKIE)
  1145. .map(|cookie| cookie.value().to_owned());
  1146. let (presession_id, fresh) = {
  1147. let mut deps = deps_of(&state, &mut conn, &store);
  1148. match ensure_presession(&mut deps, presented.as_deref()).await {
  1149. Ok(pair) => pair,
  1150. Err(error) => return error.into_response(),
  1151. }
  1152. };
  1153. // Bind a fresh `?request_id=` (overwriting any stale binding) before deciding
  1154. // the landing, so an already-authenticated session serves the new
  1155. // authorization request instead of the one it carried from a prior flow.
  1156. let mut client = String::new();
  1157. if let Some(request_id) = query.request_id.as_deref() {
  1158. match store::pending::get(&mut conn, &store, request_id).await {
  1159. Ok(Some(record)) => {
  1160. let mut deps = deps_of(&state, &mut conn, &store);
  1161. if bind_pending_request(
  1162. &mut deps,
  1163. &presession_id,
  1164. request_id,
  1165. &record.client_id,
  1166. record.post_logout_uri.as_deref(),
  1167. )
  1168. .await
  1169. .is_err()
  1170. {
  1171. return AppError::server_error().into_response();
  1172. }
  1173. client = record.client_id;
  1174. }
  1175. Ok(None) => {
  1176. return AppError::invalid_request("the authorization request is unknown or has expired")
  1177. .into_response();
  1178. }
  1179. Err(error) => return AppError::from(error).into_response(),
  1180. }
  1181. }
  1182. // A live authenticated session does not need the form again.
  1183. if !fresh
  1184. && let Some(live) = load_session(&mut conn, &store, &presession_id)
  1185. .await
  1186. .unwrap_or(None)
  1187. && !live.record.subject.is_empty()
  1188. {
  1189. let target = login_landing(&live);
  1190. return Redirect::to(&target).into_response();
  1191. }
  1192. let csrf = match login_csrf_token(&state.keys, &presession_id) {
  1193. Ok(token) => token,
  1194. Err(error) => return error.into_response(),
  1195. };
  1196. let template = LoginTemplate {
  1197. csrf,
  1198. email: String::new(),
  1199. client: client.clone(),
  1200. has_client: !client.is_empty(),
  1201. error: String::new(),
  1202. has_error: false,
  1203. };
  1204. let mut response = render(template);
  1205. if fresh {
  1206. response = (
  1207. jar.add(session_cookie(&state.config, &presession_id)),
  1208. response,
  1209. )
  1210. .into_response();
  1211. }
  1212. response
  1213. }
  1214. /// `POST /login`: verify credentials, rotate the session, redirect strictly.
  1215. pub async fn post_login(
  1216. State(state): State<AppState>,
  1217. Extension(request_id): Extension<RequestId>,
  1218. jar: CookieJar,
  1219. Form(form): Form<LoginForm>,
  1220. ) -> Response {
  1221. let mut conn = state.redis.clone();
  1222. let store = Store::from_config(&state.config.redis);
  1223. let presession_id = jar
  1224. .get(SESSION_COOKIE)
  1225. .map(|cookie| cookie.value().to_owned())
  1226. .unwrap_or_default();
  1227. let attempt = LoginAttempt {
  1228. presession_id,
  1229. email: form.email.clone(),
  1230. password: form.password,
  1231. code: form.code,
  1232. csrf: form.csrf,
  1233. };
  1234. let mut deps = deps_of(&state, &mut conn, &store);
  1235. match attempt_login(&mut deps, attempt).await {
  1236. Ok(LoginOutcome::Authenticated {
  1237. session_id,
  1238. redirect_to,
  1239. })
  1240. | Ok(LoginOutcome::Restricted {
  1241. session_id,
  1242. redirect_to,
  1243. }) => (
  1244. jar.add(session_cookie(&state.config, &session_id)),
  1245. Redirect::to(&redirect_to),
  1246. )
  1247. .into_response(),
  1248. Ok(LoginOutcome::Rejected) => (
  1249. StatusCode::UNAUTHORIZED,
  1250. [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
  1251. login_rejected_page(&form.email),
  1252. )
  1253. .into_response(),
  1254. Err(error) => {
  1255. emit_pii_decrypt(&request_id, &error);
  1256. error.into_response()
  1257. }
  1258. }
  1259. }
  1260. /// `GET /login/change-password`: the password-change form (session required).
  1261. pub async fn get_change_password(State(state): State<AppState>, jar: CookieJar) -> Response {
  1262. gated_form(
  1263. &state,
  1264. &jar,
  1265. CHANGE_PASSWORD_ACTION,
  1266. BootstrapForm::Password,
  1267. )
  1268. .await
  1269. }
  1270. /// `POST /login/change-password`: set the password, promote when enrolled.
  1271. pub async fn post_change_password(
  1272. State(state): State<AppState>,
  1273. Extension(request_id): Extension<RequestId>,
  1274. jar: CookieJar,
  1275. Form(form): Form<PasswordForm>,
  1276. ) -> Response {
  1277. let mut conn = state.redis.clone();
  1278. let store = Store::from_config(&state.config.redis);
  1279. let session_id = jar
  1280. .get(SESSION_COOKIE)
  1281. .map(|cookie| cookie.value().to_owned())
  1282. .unwrap_or_default();
  1283. let live = match load_session(&mut conn, &store, &session_id).await {
  1284. Ok(Some(live)) => live,
  1285. Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
  1286. Err(error) => return error.into_response(),
  1287. };
  1288. if !verify_form_token(&live.meta.csrf_secret, CHANGE_PASSWORD_ACTION, &form.csrf) {
  1289. return AppError::invalid_request("invalid form submission").into_response();
  1290. }
  1291. let mut deps = deps_of(&state, &mut conn, &store);
  1292. match change_password(&mut deps, &session_id, &form.new_password, &form.confirm).await {
  1293. Ok(PasswordOutcome::Promoted {
  1294. session_id,
  1295. redirect_to,
  1296. }) => (
  1297. jar.add(session_cookie(&state.config, &session_id)),
  1298. Redirect::to(&redirect_to),
  1299. )
  1300. .into_response(),
  1301. Ok(PasswordOutcome::StillRestricted { .. }) => Redirect::to(TOTP_SETUP_PATH).into_response(),
  1302. Err(error) => {
  1303. emit_pii_decrypt(&request_id, &error);
  1304. error.into_response()
  1305. }
  1306. }
  1307. }
  1308. /// `GET /login/totp/setup`: provision and display enrollment material once.
  1309. pub async fn get_totp_setup(
  1310. State(state): State<AppState>,
  1311. Extension(request_id): Extension<RequestId>,
  1312. jar: CookieJar,
  1313. ) -> Response {
  1314. let mut conn = state.redis.clone();
  1315. let store = Store::from_config(&state.config.redis);
  1316. let session_id = jar
  1317. .get(SESSION_COOKIE)
  1318. .map(|cookie| cookie.value().to_owned())
  1319. .unwrap_or_default();
  1320. let live = match load_session(&mut conn, &store, &session_id).await {
  1321. Ok(Some(live)) => live,
  1322. Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
  1323. Err(error) => return error.into_response(),
  1324. };
  1325. if live.record.subject.is_empty() {
  1326. return Redirect::to(LOGIN_PATH).into_response();
  1327. }
  1328. let mut deps = deps_of(&state, &mut conn, &store);
  1329. match provision_totp(&mut deps, &session_id).await {
  1330. Ok(offer) => {
  1331. let qr_svg = match totp::qr_svg(&offer.otpauth_uri) {
  1332. Ok(svg) => svg,
  1333. Err(_) => return AppError::server_error().into_response(),
  1334. };
  1335. render(SetupTemplate {
  1336. csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
  1337. secret: offer.secret,
  1338. uri: offer.otpauth_uri,
  1339. qr_svg,
  1340. codes: offer.recovery_codes,
  1341. error: String::new(),
  1342. has_error: false,
  1343. })
  1344. }
  1345. Err(error) => {
  1346. emit_pii_decrypt(&request_id, &error);
  1347. error.into_response()
  1348. }
  1349. }
  1350. }
  1351. /// `GET /login/totp/confirm`: the confirmation retry form (session required).
  1352. pub async fn get_totp_confirm(State(state): State<AppState>, jar: CookieJar) -> Response {
  1353. gated_form(&state, &jar, TOTP_CONFIRM_ACTION, BootstrapForm::Confirm).await
  1354. }
  1355. /// `POST /login/totp/confirm`: verify the authenticator code, promote or stay.
  1356. pub async fn post_totp_confirm(
  1357. State(state): State<AppState>,
  1358. Extension(request_id): Extension<RequestId>,
  1359. jar: CookieJar,
  1360. Form(form): Form<ConfirmForm>,
  1361. ) -> Response {
  1362. let mut conn = state.redis.clone();
  1363. let store = Store::from_config(&state.config.redis);
  1364. let session_id = jar
  1365. .get(SESSION_COOKIE)
  1366. .map(|cookie| cookie.value().to_owned())
  1367. .unwrap_or_default();
  1368. let live = match load_session(&mut conn, &store, &session_id).await {
  1369. Ok(Some(live)) => live,
  1370. Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
  1371. Err(error) => return error.into_response(),
  1372. };
  1373. if !verify_form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION, &form.csrf) {
  1374. return AppError::invalid_request("invalid form submission").into_response();
  1375. }
  1376. let mut deps = deps_of(&state, &mut conn, &store);
  1377. match confirm_totp(&mut deps, &session_id, &form.code).await {
  1378. Ok(ConfirmOutcome::Completed {
  1379. session_id,
  1380. redirect_to,
  1381. }) => (
  1382. jar.add(session_cookie(&state.config, &session_id)),
  1383. Redirect::to(&redirect_to),
  1384. )
  1385. .into_response(),
  1386. Ok(ConfirmOutcome::StillRestricted) => Redirect::to(CHANGE_PASSWORD_PATH).into_response(),
  1387. Ok(ConfirmOutcome::Rejected) => render(ConfirmTemplate {
  1388. csrf: form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION),
  1389. error: "That code was not accepted. Check your authenticator and try again.".to_owned(),
  1390. has_error: true,
  1391. }),
  1392. Err(error) => {
  1393. emit_pii_decrypt(&request_id, &error);
  1394. error.into_response()
  1395. }
  1396. }
  1397. }
  1398. /// `POST /logout`: destroy the session and expire the cookie.
  1399. pub async fn post_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
  1400. let mut conn = state.redis.clone();
  1401. let store = Store::from_config(&state.config.redis);
  1402. let session_id = jar
  1403. .get(SESSION_COOKIE)
  1404. .map(|cookie| cookie.value().to_owned())
  1405. .unwrap_or_default();
  1406. if logout(&mut conn, &store, &session_id).await.is_err() {
  1407. return AppError::server_error().into_response();
  1408. }
  1409. (jar.add(expired_session_cookie()), Redirect::to(LOGIN_PATH)).into_response()
  1410. }
  1411. /// `GET /logout`: destroy the session, expire the cookie, then render a neutral
  1412. /// signed-out page or redirect to the flow's registered post-logout URI.
  1413. ///
  1414. /// No query parameter is ever honored, so the target cannot be supplied by the
  1415. /// caller. The return URI was frozen in session meta when the authorization
  1416. /// flow was bound, and it is used only if the owning client is still active and
  1417. /// still registers that exact URI. If there is no session, no bound client, or
  1418. /// the client/URI is no longer valid, the neutral page is rendered and no
  1419. /// redirect is issued. The server session is destroyed idempotently; a missing
  1420. /// session is a no-op.
  1421. pub async fn get_logout(
  1422. State(state): State<AppState>,
  1423. Extension(request_id): Extension<RequestId>,
  1424. jar: CookieJar,
  1425. ) -> Response {
  1426. let mut conn = state.redis.clone();
  1427. let store = Store::from_config(&state.config.redis);
  1428. let session_id = jar
  1429. .get(SESSION_COOKIE)
  1430. .map(|cookie| cookie.value().to_owned())
  1431. .unwrap_or_default();
  1432. let target = match load_session(&mut conn, &store, &session_id)
  1433. .await
  1434. .unwrap_or(None)
  1435. {
  1436. Some(live) => match resolve_post_logout_target(&state, &live).await {
  1437. Ok(target) => target,
  1438. Err(error) => {
  1439. emit_pii_decrypt(&request_id, &error);
  1440. if logout(&mut conn, &store, &session_id).await.is_err() {
  1441. return AppError::server_error().into_response();
  1442. }
  1443. return (jar.add(expired_session_cookie()), error.into_response()).into_response();
  1444. }
  1445. },
  1446. None => None,
  1447. };
  1448. if logout(&mut conn, &store, &session_id).await.is_err() {
  1449. return AppError::server_error().into_response();
  1450. }
  1451. let response = match target {
  1452. Some(uri) => Redirect::to(&uri).into_response(),
  1453. None => render(SignedOutTemplate),
  1454. };
  1455. (jar.add(expired_session_cookie()), response).into_response()
  1456. }
  1457. /// Resolve the session's bound post-logout target, if it is still trustworthy.
  1458. ///
  1459. /// Returns the frozen URI only when the flow recorded both a client and a URI,
  1460. /// the client is still active, and that exact URI is still in the client's
  1461. /// effective post-logout set (exact-match or RFC 8252 loopback). Any miss or
  1462. /// database failure yields `Ok(None)` (no redirect), never a guessed target.
  1463. /// A stored-value open failure is fail-closed as `Err(AppError::PiiDecrypt)`
  1464. /// (503), never downgraded to the neutral page.
  1465. async fn resolve_post_logout_target(
  1466. state: &AppState,
  1467. live: &LiveSession,
  1468. ) -> Result<Option<String>, AppError> {
  1469. let (Some(client_id), Some(bound)) = (
  1470. live.meta.post_logout_client_id.as_deref(),
  1471. live.meta.post_logout_uri.as_deref(),
  1472. ) else {
  1473. return Ok(None);
  1474. };
  1475. let client =
  1476. match client_repo::find_active_by_client_id(&state.pool, &state.keys, client_id).await {
  1477. Ok(client) => client,
  1478. Err(AppError::PiiDecrypt) => return Err(AppError::PiiDecrypt),
  1479. Err(_) => return Ok(None),
  1480. };
  1481. if client.is_none() {
  1482. return Ok(None);
  1483. }
  1484. let effective = match redirect_repo::effective_post_logout_for_client(
  1485. &state.pool,
  1486. &state.keys,
  1487. client_id,
  1488. )
  1489. .await
  1490. {
  1491. Ok(effective) => effective,
  1492. Err(AppError::PiiDecrypt) => return Err(AppError::PiiDecrypt),
  1493. Err(_) => return Ok(None),
  1494. };
  1495. let registered: Vec<&str> = effective.iter().map(String::as_str).collect();
  1496. match redirect_match::match_redirect_uri(&registered, bound) {
  1497. redirect_match::RedirectDecision::Exact | redirect_match::RedirectDecision::Loopback => {
  1498. Ok(Some(bound.to_owned()))
  1499. }
  1500. redirect_match::RedirectDecision::Reject => Ok(None),
  1501. }
  1502. }
  1503. /// `GET /login/success`: landing page for logins with no bound request.
  1504. pub async fn get_login_success(State(state): State<AppState>, jar: CookieJar) -> Response {
  1505. let mut conn = state.redis.clone();
  1506. let store = Store::from_config(&state.config.redis);
  1507. let session_id = jar
  1508. .get(SESSION_COOKIE)
  1509. .map(|cookie| cookie.value().to_owned())
  1510. .unwrap_or_default();
  1511. match load_session(&mut conn, &store, &session_id).await {
  1512. Ok(Some(live)) if !live.record.subject.is_empty() && !live.record.restricted => {
  1513. render(SuccessTemplate)
  1514. }
  1515. Ok(_) => Redirect::to(LOGIN_PATH).into_response(),
  1516. Err(error) => error.into_response(),
  1517. }
  1518. }
  1519. /// Form-token action for the password-change form.
  1520. const CHANGE_PASSWORD_ACTION: &str = "change-password";
  1521. /// Form-token action for the TOTP-confirmation form.
  1522. const TOTP_CONFIRM_ACTION: &str = "totp-confirm";
  1523. /// Which bootstrap form a gated `GET` renders.
  1524. enum BootstrapForm {
  1525. /// The password-change form.
  1526. Password,
  1527. /// The TOTP-confirmation retry form.
  1528. Confirm,
  1529. }
  1530. /// Render a session-gated bootstrap form, bouncing anonymous callers to login.
  1531. async fn gated_form(
  1532. state: &AppState,
  1533. jar: &CookieJar,
  1534. action: &str,
  1535. form: BootstrapForm,
  1536. ) -> Response {
  1537. let mut conn = state.redis.clone();
  1538. let store = Store::from_config(&state.config.redis);
  1539. let session_id = jar
  1540. .get(SESSION_COOKIE)
  1541. .map(|cookie| cookie.value().to_owned())
  1542. .unwrap_or_default();
  1543. let live = match load_session(&mut conn, &store, &session_id).await {
  1544. Ok(Some(live)) => live,
  1545. Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
  1546. Err(error) => return error.into_response(),
  1547. };
  1548. if live.record.subject.is_empty() {
  1549. return Redirect::to(LOGIN_PATH).into_response();
  1550. }
  1551. let csrf = form_token(&live.meta.csrf_secret, action);
  1552. match form {
  1553. BootstrapForm::Password => render(PasswordTemplate {
  1554. csrf,
  1555. error: String::new(),
  1556. has_error: false,
  1557. }),
  1558. BootstrapForm::Confirm => render(ConfirmTemplate {
  1559. csrf,
  1560. error: String::new(),
  1561. has_error: false,
  1562. }),
  1563. }
  1564. }
  1565. /// Where an already-authenticated `GET /login` lands.
  1566. fn login_landing(live: &LiveSession) -> String {
  1567. if live.record.restricted {
  1568. CHANGE_PASSWORD_PATH.to_owned()
  1569. } else if live.meta.pending_request_id.is_some() {
  1570. CONSENT_PATH.to_owned()
  1571. } else {
  1572. LOGIN_SUCCESS_PATH.to_owned()
  1573. }
  1574. }
  1575. /// Render an Askama template as `text/html`; a render failure is a masked 500.
  1576. fn render(template: impl Template) -> Response {
  1577. match template.render() {
  1578. Ok(body) => (
  1579. StatusCode::OK,
  1580. [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
  1581. body,
  1582. )
  1583. .into_response(),
  1584. Err(_) => AppError::server_error().into_response(),
  1585. }
  1586. }
  1587. /// Uniform 401 page: identical for every credential-failure reason.
  1588. fn login_rejected_page(email: &str) -> String {
  1589. format!(
  1590. "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
  1591. <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
  1592. <link rel=\"stylesheet\" href=\"/assets/app.css\">\
  1593. <title>Sign in</title></head><body>\
  1594. <h1>Sign in</h1>\
  1595. <p role=\"alert\">{INVALID_CREDENTIALS}</p>\
  1596. <p>Attempted sign-in as {email} was rejected.</p>\
  1597. <p><a href=\"{LOGIN_PATH}\">Try again</a>.</p></body></html>",
  1598. email = html_escape(email)
  1599. )
  1600. }
  1601. /// Minimal HTML escaping for reflected values in hand-built pages.
  1602. fn html_escape(value: &str) -> String {
  1603. let mut out = String::with_capacity(value.len());
  1604. for c in value.chars() {
  1605. match c {
  1606. '&' => out.push_str("&amp;"),
  1607. '<' => out.push_str("&lt;"),
  1608. '>' => out.push_str("&gt;"),
  1609. '"' => out.push_str("&quot;"),
  1610. _ => out.push(c),
  1611. }
  1612. }
  1613. out
  1614. }