Skip to main content

rust_web_server/auth_flows/
mod.rs

1//! Sign-up, login, logout, password reset and email confirmation (18.35.0,
2//! #92): the account pages and API every app needs, as one middleware.
3//! Features `crypto`, `mailer` and `serde` (all in the `web` preset).
4//!
5//! ```rust
6//! use std::sync::Arc;
7//! use rust_web_server::app::App;
8//! use rust_web_server::auth_flows::{AuthFlows, CurrentUser, MemoryUserStore};
9//! use rust_web_server::extract::FromRequest;
10//! use rust_web_server::mailer::TestMailbox;
11//! use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
12//!
13//! let auth = AuthFlows::new(b"a secret of at least 32 bytes, from config!", "https://example.com", MemoryUserStore::new(), TestMailbox::new());
14//! let app = App::with_state(())
15//!     .group("/account", |g| {
16//!         g.guard(rust_web_server::auth_flows::signed_in)
17//!             .get("/", |request, _, _, _| {
18//!                 let user = CurrentUser::from_request(request).unwrap();
19//!                 Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, &user.email)
20//!             })
21//!     })
22//!     .wrap(auth);
23//! ```
24//!
25//! | Request | JSON (`Content-Type: application/json`) | Form (HTML) |
26//! |---|---|---|
27//! | `POST /auth/signup` `{email, password}` | `201 {user}` and signed in (or `confirmation_required`) | redirect + flash |
28//! | `POST /auth/login` `{email, password}` | `200 {user}`, `401`, `403` unconfirmed | redirect to `return_to` or the after-login path |
29//! | `POST /auth/logout` | `204` | redirect |
30//! | `GET /auth/me` | `200 {user}` or `401` | — |
31//! | `POST /auth/password/forgot` `{email}` | `202`, whether or not the account exists | redirect + flash |
32//! | `POST /auth/password/reset` `{token, password}` | `200 {user}` and signed in, `400` bad link | redirect + flash |
33//! | `GET /auth/confirm?token=` / `POST /auth/confirm` `{token}` | `200 {user}`, `400` | redirect + flash |
34//! | `POST /auth/confirm/resend` `{email}` | `202` | redirect + flash |
35//! | `GET /auth/login`, `/auth/signup`, `/auth/password/forgot`, `/auth/password/reset?token=` | — | the forms |
36//!
37//! **Sessions** are a signed cookie (`rws_auth`, `HttpOnly`, `SameSite=Lax`,
38//! `Secure` when the base URL is `https`) holding the user id, an expiry
39//! and a fingerprint of the password hash — no session store, any replica
40//! can check it, and changing the password signs every session out.
41//! **Links** in the emails carry signed tokens the same way: a reset link
42//! stops working once the password changes, and expires after an hour; a
43//! confirmation link after three days. Logins, sign-ups and the email
44//! requests are rate limited per client IP (10 a minute by default): the
45//! address a proxy in `trusted_proxies` (private ranges by default)
46//! forwarded, else the peer's.
47//!
48//! For every other request the middleware checks the session cookie and
49//! passes the account to the app as [`CurrentUser`] (a handler argument);
50//! [`signed_in`] is a route guard. Headers a client sends in its place are
51//! removed.
52
53#[cfg(test)]
54mod tests;
55mod pages;
56mod store;
57
58pub use store::{AuthUser, MemoryUserStore, UserStore};
59#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
60pub use store::DbUserStore;
61
62use std::collections::HashMap;
63use std::sync::{Arc, OnceLock};
64use std::time::{SystemTime, UNIX_EPOCH};
65
66use sha2::{Digest, Sha256};
67
68use crate::application::Application;
69use crate::cookie::{signed_cookie, verify_signed_cookie, CookieJar, SetCookie};
70use crate::error::IntoResponse;
71use crate::extract::FromRequest;
72use crate::flash::Redirect;
73use crate::header::Header;
74use crate::mailer::{Deliver, Email, Mail};
75use crate::middleware::Middleware;
76use crate::rate_limit::RateLimiter;
77use crate::request::Request;
78use crate::response::{Response, StatusCodeReasonPhrase, STATUS_CODE_REASON_PHRASE};
79use crate::server::ConnectionInfo;
80
81const USER_ID_HEADER: &str = "X-Rws-Auth-User-Id";
82const USER_EMAIL_HEADER: &str = "X-Rws-Auth-User-Email";
83const USER_CONFIRMED_HEADER: &str = "X-Rws-Auth-User-Confirmed";
84/// Carries a per-process random value, so [`CurrentUser`] only believes
85/// headers this middleware set.
86const MARK_HEADER: &str = "X-Rws-Auth-Mark";
87
88const RESET_TTL: u64 = 3600;
89const CONFIRM_TTL: u64 = 3 * 86_400;
90
91fn mark() -> &'static str {
92    static MARK: OnceLock<String> = OnceLock::new();
93    MARK.get_or_init(|| crate::crypto::generate_token(16))
94}
95
96fn now() -> u64 {
97    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
98}
99
100fn hex(bytes: &[u8]) -> String {
101    bytes.iter().map(|b| format!("{b:02x}")).collect()
102}
103
104fn unhex(text: &str) -> Option<Vec<u8>> {
105    if text.len() % 2 != 0 {
106        return None;
107    }
108    (0..text.len()).step_by(2).map(|i| u8::from_str_radix(text.get(i..i + 2)?, 16).ok()).collect()
109}
110
111/// 16 hex digits of SHA-256: ties a session or link to a value that changes
112/// (the password hash, the email).
113fn fingerprint(value: &str) -> String {
114    hex(&Sha256::digest(value.as_bytes())[..8])
115}
116
117/// The signed-in account, as [`AuthFlows`] passed it on: a handler argument
118/// that answers `401` without a session.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CurrentUser {
121    pub id: String,
122    pub email: String,
123    pub confirmed: bool,
124}
125
126impl CurrentUser {
127    /// The account the request is signed in as, if any.
128    pub fn of(request: &Request) -> Option<CurrentUser> {
129        if request.header(MARK_HEADER).map(|h| h.value.as_ref()) != Some(mark()) {
130            return None;
131        }
132        Some(CurrentUser {
133            id: request.header(USER_ID_HEADER)?.value.to_string(),
134            email: request.header(USER_EMAIL_HEADER)?.value.to_string(),
135            confirmed: request.header(USER_CONFIRMED_HEADER).map(|h| h.value == "true").unwrap_or(false),
136        })
137    }
138}
139
140impl FromRequest for CurrentUser {
141    fn from_request(request: &Request) -> Result<Self, Response> {
142        CurrentUser::of(request).ok_or_else(|| crate::error::AppError::Unauthorized.into_response())
143    }
144}
145
146/// A route guard: `401` without a session (`group(…).guard(signed_in)`).
147pub fn signed_in<S>(request: &Request, _connection: &ConnectionInfo, _state: &S) -> Result<(), Response> {
148    CurrentUser::from_request(request).map(|_| ())
149}
150
151/// A route guard for pages: without a session, a redirect to `login`
152/// (with `return_to` set to the page asked for).
153pub fn signed_in_or_redirect<S>(login: &str) -> impl Fn(&Request, &ConnectionInfo, &S) -> Result<(), Response> + Send + Sync + 'static {
154    let login = login.to_string();
155    move |request, _, _| match CurrentUser::of(request) {
156        Some(_) => Ok(()),
157        None => {
158            let back = crate::router::build_path("/", &[("return_to", &request.request_uri)]).unwrap_or_default();
159            Err(Redirect::to(&format!("{login}{}", back.trim_start_matches('/'))).alert("Please sign in.").into_response())
160        }
161    }
162}
163
164struct Inner {
165    secret: Vec<u8>,
166    base_url: String,
167    prefix: String,
168    store: Arc<dyn UserStore>,
169    mailer: Arc<dyn Deliver>,
170    app_name: String,
171    require_confirmation: bool,
172    min_password: usize,
173    session_ttl: u64,
174    cookie_name: String,
175    secure: bool,
176    after_login: String,
177    after_logout: String,
178    limiter: Option<Arc<RateLimiter>>,
179    on_signup: Vec<Arc<dyn Fn(&AuthUser) + Send + Sync>>,
180    #[cfg(feature = "tera")]
181    templates: Option<Arc<crate::template::TeraEngine>>,
182}
183
184/// The auth flows. A middleware: `app.wrap(auth)`, after (inside) `FlashLayer`
185/// and `CsrfLayer`, which must see the pages it draws. See the [module docs](self).
186#[derive(Clone)]
187pub struct AuthFlows {
188    inner: Arc<Inner>,
189}
190
191impl AuthFlows {
192    /// `secret` signs sessions and links: at least 32 random bytes, the same
193    /// on every replica, kept out of the code. `base_url` (`https://example.com`)
194    /// starts the links in emails — never taken from the request's `Host`.
195    ///
196    /// # Panics
197    ///
198    /// When `secret` is shorter than 32 bytes.
199    pub fn new(secret: &[u8], base_url: &str, store: impl UserStore + 'static, mailer: impl Deliver + 'static) -> Self {
200        Self::with_arcs(secret, base_url, Arc::new(store), Arc::new(mailer))
201    }
202
203    /// [`new`](Self::new) with shared store and mailer.
204    pub fn with_arcs(secret: &[u8], base_url: &str, store: Arc<dyn UserStore>, mailer: Arc<dyn Deliver>) -> Self {
205        assert!(secret.len() >= 32, "AuthFlows: the secret must be at least 32 bytes");
206        let base_url = base_url.trim_end_matches('/').to_string();
207        AuthFlows {
208            inner: Arc::new(Inner {
209                secret: secret.to_vec(),
210                secure: base_url.starts_with("https://"),
211                base_url,
212                prefix: "/auth".into(),
213                store,
214                mailer,
215                app_name: "the app".into(),
216                require_confirmation: false,
217                min_password: 8,
218                session_ttl: 30 * 86_400,
219                cookie_name: "rws_auth".into(),
220                after_login: "/".into(),
221                after_logout: "/".into(),
222                limiter: Some(Arc::new(RateLimiter::new(10, 60))),
223                on_signup: Vec::new(),
224                #[cfg(feature = "tera")]
225                templates: None,
226            }),
227        }
228    }
229
230    fn edit(mut self, f: impl FnOnce(&mut Inner)) -> Self {
231        f(Arc::get_mut(&mut self.inner).expect("configure AuthFlows before cloning it"));
232        self
233    }
234
235    /// Where the flows live (default `/auth`).
236    pub fn prefix(self, prefix: &str) -> Self {
237        let prefix = format!("/{}", prefix.trim_matches('/'));
238        self.edit(|i| i.prefix = prefix)
239    }
240
241    /// The app's name in emails (default "the app").
242    pub fn app_name(self, name: &str) -> Self {
243        self.edit(|i| i.app_name = name.to_string())
244    }
245
246    /// Refuse to sign in an account until its email is confirmed (default:
247    /// sign in at once; the confirmation email is sent either way).
248    pub fn require_confirmation(self, require: bool) -> Self {
249        self.edit(|i| i.require_confirmation = require)
250    }
251
252    /// The shortest password accepted (default 8).
253    pub fn min_password_length(self, n: usize) -> Self {
254        self.edit(|i| i.min_password = n.max(1))
255    }
256
257    /// How long a session lasts (default 30 days).
258    pub fn session_ttl(self, ttl: std::time::Duration) -> Self {
259        self.edit(|i| i.session_ttl = ttl.as_secs().max(60))
260    }
261
262    /// The session cookie's name (default `rws_auth`).
263    pub fn cookie_name(self, name: &str) -> Self {
264        self.edit(|i| i.cookie_name = name.to_string())
265    }
266
267    /// Mark the session cookie `Secure` (default: when the base URL is `https`).
268    pub fn secure_cookie(self, secure: bool) -> Self {
269        self.edit(|i| i.secure = secure)
270    }
271
272    /// Where a form sign-in, sign-up or reset lands (default `/`).
273    pub fn after_login(self, path: &str) -> Self {
274        self.edit(|i| i.after_login = path.to_string())
275    }
276
277    /// Where a form sign-out lands (default `/`).
278    pub fn after_logout(self, path: &str) -> Self {
279        self.edit(|i| i.after_logout = path.to_string())
280    }
281
282    /// At most `max` sign-ins, sign-ups and email requests per client IP per
283    /// `window_secs` (default 10 a minute); `max = 0` turns the limit off.
284    pub fn rate_limit(self, max: u32, window_secs: u64) -> Self {
285        let limiter = (max > 0).then(|| Arc::new(RateLimiter::new(max, window_secs)));
286        self.edit(|i| i.limiter = limiter)
287    }
288
289    /// Call `hook` with each new account, after it's created (a welcome
290    /// email, a default workspace). Runs on the request's thread: enqueue
291    /// anything slow.
292    pub fn on_signup(self, hook: impl Fn(&AuthUser) + Send + Sync + 'static) -> Self {
293        self.edit(|i| i.on_signup.push(Arc::new(hook)))
294    }
295
296    /// Render the pages from `engine`'s `auth/login.html`, `auth/signup.html`,
297    /// `auth/forgot.html`, `auth/reset.html` and the emails from
298    /// `auth/mail/confirm.{txt,html}`, `auth/mail/reset.{txt,html}` — each
299    /// that exists; the built-in ones otherwise (`tera` feature).
300    #[cfg(feature = "tera")]
301    pub fn templates(self, engine: Arc<crate::template::TeraEngine>) -> Self {
302        self.edit(|i| i.templates = Some(engine))
303    }
304
305    /// A session cookie for `user` (`Set-Cookie` value): sign someone in
306    /// from your own handler.
307    pub fn session_cookie(&self, user: &AuthUser) -> String {
308        let i = &self.inner;
309        let value = format!("s.{}.{}.{}", hex(user.id.as_bytes()), now() + i.session_ttl, fingerprint(&user.password_hash));
310        let mut cookie = SetCookie::new(&i.cookie_name, signed_cookie(&value, &i.secret)).path("/").http_only().same_site("Lax").max_age(i.session_ttl as i64);
311        if i.secure {
312            cookie = cookie.secure();
313        }
314        cookie.build()
315    }
316
317    fn clear_cookie(&self) -> String {
318        let mut cookie = SetCookie::new(&self.inner.cookie_name, "").path("/").http_only().same_site("Lax").max_age(0);
319        if self.inner.secure {
320            cookie = cookie.secure();
321        }
322        cookie.build()
323    }
324
325    /// The account `request`'s session cookie belongs to — valid signature,
326    /// not expired, password unchanged since.
327    pub fn session_user(&self, request: &Request) -> Option<AuthUser> {
328        let header = request.header("cookie")?;
329        let jar = CookieJar::parse(&header.value);
330        let value = verify_signed_cookie(&jar.get(&self.inner.cookie_name)?.value, &self.inner.secret)?;
331        let mut parts = value.split('.');
332        let (Some("s"), Some(id), Some(expires), Some(fp), None) = (parts.next(), parts.next(), parts.next(), parts.next(), parts.next()) else {
333            return None;
334        };
335        if expires.parse::<u64>().ok()? < now() {
336            return None;
337        }
338        let id = String::from_utf8(unhex(id)?).ok()?;
339        let user = self.inner.store.find_by_id(&id).ok()??;
340        (fingerprint(&user.password_hash) == fp).then_some(user)
341    }
342
343    /// A signed link token: `purpose`, the user, an expiry and a fingerprint.
344    fn token(&self, purpose: &str, user: &AuthUser, ttl: u64) -> String {
345        let bound = if purpose == "reset" { &user.password_hash } else { &user.email };
346        let value = format!("{purpose}.{}.{}.{}", hex(user.id.as_bytes()), now() + ttl, fingerprint(bound));
347        signed_cookie(&hex(value.as_bytes()), &self.inner.secret)
348    }
349
350    /// The account a valid, unexpired `purpose` token names.
351    fn check_token(&self, purpose: &str, token: &str) -> Option<AuthUser> {
352        let value = verify_signed_cookie(token.trim(), &self.inner.secret)?;
353        let value = String::from_utf8(unhex(&value)?).ok()?;
354        let mut parts = value.split('.');
355        let (Some(p), Some(id), Some(expires), Some(fp), None) = (parts.next(), parts.next(), parts.next(), parts.next(), parts.next()) else {
356            return None;
357        };
358        if p != purpose || expires.parse::<u64>().ok()? < now() {
359            return None;
360        }
361        let user = self.inner.store.find_by_id(&String::from_utf8(unhex(id)?).ok()?).ok()??;
362        let bound = if purpose == "reset" { &user.password_hash } else { &user.email };
363        (fingerprint(bound) == fp).then_some(user)
364    }
365
366    /// A link to `path` with the token for `purpose`.
367    fn link(&self, path: &str, purpose: &str, user: &AuthUser, ttl: u64) -> String {
368        let token = self.token(purpose, user, ttl);
369        format!("{}{}{}?token={token}", self.inner.base_url, self.inner.prefix, path)
370    }
371
372    fn send(&self, user: &AuthUser, kind: &str, link: &str) {
373        let i = &self.inner;
374        let (subject, text) = match kind {
375            "confirm" => (
376                format!("Confirm your email for {}", i.app_name),
377                format!("Confirm your email address for {}:\n\n{link}\n\nThe link works for 3 days.", i.app_name),
378            ),
379            _ => (
380                format!("Reset your password for {}", i.app_name),
381                format!("Choose a new password for {}:\n\n{link}\n\nThe link works for an hour. If you didn't ask for it, ignore this email.", i.app_name),
382            ),
383        };
384        let builder = Email::builder().to(&user.email).subject(&subject);
385        #[cfg(feature = "tera")]
386        let builder = match &i.templates {
387            Some(engine) if engine.has_template(&format!("auth/mail/{kind}.txt")) || engine.has_template(&format!("auth/mail/{kind}.html")) => {
388                let mut ctx = crate::template::Context::new();
389                ctx.insert("link", link);
390                ctx.insert("app", &i.app_name);
391                ctx.insert("email", &user.email);
392                match builder.render_with(engine, &format!("auth/mail/{kind}"), &ctx) {
393                    Ok(b) => b,
394                    Err(e) => {
395                        eprintln!("[auth] {kind} email template: {e}");
396                        return;
397                    }
398                }
399            }
400            _ => builder.text(&text),
401        };
402        #[cfg(not(feature = "tera"))]
403        let builder = builder.text(&text);
404        let result = builder.build().and_then(|email| i.mailer.deliver(&Mail::new(email)));
405        if let Err(e) = result {
406            eprintln!("[auth] the {kind} email to {} failed: {e}", user.email);
407        }
408    }
409
410    /// Over the rate limit for `action`, per client address: the peer, or
411    /// behind a proxy listed in `trusted_proxies` the address it forwarded
412    /// ([`ip_filter::client_ip`](crate::ip_filter::client_ip)).
413    fn limited(&self, request: &Request, connection: &ConnectionInfo, action: &str) -> bool {
414        match &self.inner.limiter {
415            Some(limiter) => !limiter.check(&format!("{}|{action}", crate::ip_filter::client_ip(request, &connection.client.ip))),
416            None => false,
417        }
418    }
419
420    fn validate_email(email: &str) -> Result<String, String> {
421        let email = email.trim().to_lowercase();
422        let ok = email.len() <= 254
423            && !email.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | ',' | ';' | '"'))
424            && email.split_once('@').is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'));
425        if ok { Ok(email) } else { Err("that doesn't look like an email address".into()) }
426    }
427
428    fn validate_password(&self, password: &str) -> Result<(), String> {
429        let n = password.chars().count();
430        if n < self.inner.min_password {
431            Err(format!("the password needs at least {} characters", self.inner.min_password))
432        } else if password.len() > 1024 {
433            Err("the password is too long".into())
434        } else {
435            Ok(())
436        }
437    }
438
439    /// The request with the session's account (or none) in the headers the
440    /// app reads, and any the client sent itself removed.
441    fn with_identity(&self, request: &Request) -> Request {
442        let mut request = request.clone();
443        request.headers.retain(|h| !h.name.to_ascii_lowercase().starts_with("x-rws-auth-"));
444        if let Some(user) = self.session_user(&request) {
445            request.headers.push(Header::new(USER_ID_HEADER, user.id));
446            request.headers.push(Header::new(USER_EMAIL_HEADER, user.email));
447            request.headers.push(Header::new(USER_CONFIRMED_HEADER, if user.confirmed { "true" } else { "false" }));
448            request.headers.push(Header::new(MARK_HEADER, mark()));
449        }
450        request
451    }
452
453    fn route(&self, request: &Request, connection: &ConnectionInfo) -> Option<Response> {
454        let path = request.request_uri.split('?').next().unwrap_or("");
455        let rest = path.strip_prefix(&self.inner.prefix)?;
456        let rest = rest.trim_end_matches('/');
457        let get = request.method == "GET" || request.method == "HEAD";
458        let post = request.method == "POST";
459        let input = Input::read(request);
460        let ctx = Ctx { flows: self, request, json: wants_json(request), input: &input };
461        Some(match (rest, get, post) {
462            ("/signup", _, true) => ctx.signup(connection),
463            ("/login", _, true) => ctx.login(connection),
464            ("/logout", _, true) => ctx.logout(),
465            ("/me", true, _) => ctx.me(),
466            ("/password/forgot", _, true) => ctx.forgot(connection),
467            ("/password/reset", _, true) => ctx.reset(),
468            ("/confirm", true, _) | ("/confirm", _, true) => ctx.confirm(),
469            ("/confirm/resend", _, true) => ctx.resend(connection),
470            ("/login", true, _) | ("/signup", true, _) | ("/password/forgot", true, _) | ("/password/reset", true, _) => pages::page(self, request, rest),
471            _ => return None,
472        })
473    }
474}
475
476/// Fields of a JSON object or an urlencoded form.
477struct Input(HashMap<String, String>);
478
479impl Input {
480    fn read(request: &Request) -> Input {
481        let mut fields = HashMap::new();
482        if let Some((_, query)) = request.request_uri.split_once('?') {
483            fields.extend(parse_form(query));
484        }
485        let content_type = request.header("content-type").map(|h| h.value.to_ascii_lowercase()).unwrap_or_default();
486        if content_type.starts_with("application/json") {
487            if let Ok(serde_json::Value::Object(map)) = serde_json::from_slice::<serde_json::Value>(&request.body) {
488                for (k, v) in map {
489                    if let serde_json::Value::String(s) = v {
490                        fields.insert(k, s);
491                    }
492                }
493            }
494        } else if content_type.starts_with("application/x-www-form-urlencoded") {
495            fields.extend(parse_form(&String::from_utf8_lossy(&request.body)));
496        }
497        Input(fields)
498    }
499
500    fn get(&self, name: &str) -> &str {
501        self.0.get(name).map(String::as_str).unwrap_or("")
502    }
503}
504
505fn parse_form(text: &str) -> Vec<(String, String)> {
506    text.split('&')
507        .filter_map(|pair| {
508            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
509            Some((form_decode(k)?, form_decode(v)?))
510        })
511        .collect()
512}
513
514/// `application/x-www-form-urlencoded` decoding: `+` is a space, `%XX` a byte.
515fn form_decode(text: &str) -> Option<String> {
516    let bytes = text.as_bytes();
517    let mut out = Vec::with_capacity(bytes.len());
518    let mut i = 0;
519    while i < bytes.len() {
520        match bytes[i] {
521            b'+' => {
522                out.push(b' ');
523                i += 1;
524            }
525            b'%' => {
526                out.push(u8::from_str_radix(text.get(i + 1..i + 3)?, 16).ok()?);
527                i += 3;
528            }
529            b => {
530                out.push(b);
531                i += 1;
532            }
533        }
534    }
535    String::from_utf8(out).ok()
536}
537
538fn wants_json(request: &Request) -> bool {
539    let content_type = request.header("content-type").map(|h| h.value.to_ascii_lowercase()).unwrap_or_default();
540    if content_type.starts_with("application/json") {
541        return true;
542    }
543    if content_type.starts_with("application/x-www-form-urlencoded") || content_type.starts_with("multipart/form-data") {
544        return false;
545    }
546    let accept = request.header("accept").map(|h| h.value.to_ascii_lowercase()).unwrap_or_default();
547    accept.contains("application/json") && !accept.contains("text/html")
548}
549
550fn user_json(user: &AuthUser) -> serde_json::Value {
551    serde_json::json!({ "id": user.id, "email": user.email, "confirmed": user.confirmed })
552}
553
554fn json(status: &StatusCodeReasonPhrase, value: serde_json::Value) -> Response {
555    Response::json(status, value.to_string().into_bytes())
556}
557
558fn problem(status: &StatusCodeReasonPhrase, detail: &str) -> Response {
559    crate::error::problem::problem(status, detail)
560}
561
562/// A local path to go back to (not `//host`, not a URL).
563fn local_path(path: &str) -> Option<&str> {
564    (path.starts_with('/') && !path.starts_with("//") && !path.contains(['\\', '\r', '\n'])).then_some(path)
565}
566
567/// One request through a flow.
568struct Ctx<'a> {
569    flows: &'a AuthFlows,
570    request: &'a Request,
571    json: bool,
572    input: &'a Input,
573}
574
575impl Ctx<'_> {
576    fn i(&self) -> &Inner {
577        &self.flows.inner
578    }
579
580    fn page(&self, page: &str) -> String {
581        format!("{}{page}", self.i().prefix)
582    }
583
584    /// An error: problem details for JSON, a redirect to `back` with the
585    /// message for forms.
586    fn fail(&self, status: &StatusCodeReasonPhrase, message: &str, back: &str) -> Response {
587        if self.json {
588            problem(status, message)
589        } else {
590            let mut location = self.page(back);
591            let return_to = self.input.get("return_to");
592            if let Some(path) = local_path(return_to) {
593                location = crate::router::build_path(&location, &[("return_to", path)]).unwrap_or(location);
594            }
595            Redirect::to(&location).alert(message).into_response()
596        }
597    }
598
599    fn internal(&self, e: String) -> Response {
600        eprintln!("[auth] {e}");
601        problem(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, "something went wrong")
602    }
603
604    fn signed_in(&self, user: &AuthUser, status: &StatusCodeReasonPhrase, notice: &str) -> Response {
605        let mut response = if self.json {
606            json(status, serde_json::json!({ "user": user_json(user) }))
607        } else {
608            let target = local_path(self.input.get("return_to")).unwrap_or(&self.i().after_login);
609            Redirect::to(target).notice(notice).into_response()
610        };
611        response.headers.push(Header::new(Header::_SET_COOKIE, self.flows.session_cookie(user)));
612        response
613    }
614
615    fn too_many(&self) -> Response {
616        let mut response = if self.json {
617            problem(STATUS_CODE_REASON_PHRASE.n429_too_many_requests, "too many attempts; try again in a minute")
618        } else {
619            Response::text(STATUS_CODE_REASON_PHRASE.n429_too_many_requests, "Too many attempts; try again in a minute.")
620        };
621        response.headers.push(Header::new("Retry-After", "60"));
622        response
623    }
624
625    fn signup(&self, connection: &ConnectionInfo) -> Response {
626        if self.flows.limited(self.request, connection, "signup") {
627            return self.too_many();
628        }
629        let email = match AuthFlows::validate_email(self.input.get("email")) {
630            Ok(email) => email,
631            Err(e) => return self.fail(STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity, &e, "/signup"),
632        };
633        let password = self.input.get("password");
634        if let Err(e) = self.flows.validate_password(password) {
635            return self.fail(STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity, &e, "/signup");
636        }
637        let hash = match crate::crypto::hash_password(password) {
638            Ok(hash) => hash,
639            Err(e) => return self.internal(e.to_string()),
640        };
641        let user = match self.i().store.create(&email, &hash) {
642            Ok(Some(user)) => user,
643            Ok(None) => return self.fail(STATUS_CODE_REASON_PHRASE.n409_conflict, "an account with that email already exists", "/signup"),
644            Err(e) => return self.internal(e),
645        };
646        self.flows.send(&user, "confirm", &self.flows.link("/confirm", "confirm", &user, CONFIRM_TTL));
647        for hook in &self.i().on_signup {
648            hook(&user);
649        }
650        if self.i().require_confirmation {
651            if self.json {
652                json(STATUS_CODE_REASON_PHRASE.n201_created, serde_json::json!({ "user": user_json(&user), "confirmation_required": true }))
653            } else {
654                Redirect::to(&self.page("/login")).notice("Check your email to confirm your account.").into_response()
655            }
656        } else {
657            self.signed_in(&user, STATUS_CODE_REASON_PHRASE.n201_created, "Welcome! We sent you an email to confirm your address.")
658        }
659    }
660
661    fn login(&self, connection: &ConnectionInfo) -> Response {
662        if self.flows.limited(self.request, connection, "login") {
663            return self.too_many();
664        }
665        let email = self.input.get("email").trim().to_lowercase();
666        let password = self.input.get("password");
667        let user = match self.i().store.find_by_email(&email) {
668            Ok(user) => user,
669            Err(e) => return self.internal(e),
670        };
671        let valid = match &user {
672            Some(user) => crate::crypto::verify_password(password, &user.password_hash).unwrap_or(false),
673            None => {
674                // The same work as a real check, so timing doesn't tell
675                // which emails have accounts.
676                let _ = crate::crypto::verify_password(password, dummy_hash());
677                false
678            }
679        };
680        let Some(user) = user.filter(|_| valid) else {
681            return self.fail(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "wrong email or password", "/login");
682        };
683        if self.i().require_confirmation && !user.confirmed {
684            return self.fail(STATUS_CODE_REASON_PHRASE.n403_forbidden, "please confirm your email first (we can send the link again)", "/login");
685        }
686        self.signed_in(&user, STATUS_CODE_REASON_PHRASE.n200_ok, "Signed in.")
687    }
688
689    fn logout(&self) -> Response {
690        let mut response = if self.json {
691            Response::get_response(STATUS_CODE_REASON_PHRASE.n204_no_content, None, None)
692        } else {
693            Redirect::to(&self.i().after_logout).notice("Signed out.").into_response()
694        };
695        response.headers.push(Header::new(Header::_SET_COOKIE, self.flows.clear_cookie()));
696        response
697    }
698
699    fn me(&self) -> Response {
700        match self.flows.session_user(self.request) {
701            Some(user) => json(STATUS_CODE_REASON_PHRASE.n200_ok, serde_json::json!({ "user": user_json(&user) })),
702            None => problem(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "not signed in"),
703        }
704    }
705
706    fn accepted(&self, notice: &str, back: &str) -> Response {
707        if self.json {
708            json(STATUS_CODE_REASON_PHRASE.n202_accepted, serde_json::json!({ "status": "accepted" }))
709        } else {
710            Redirect::to(&self.page(back)).notice(notice).into_response()
711        }
712    }
713
714    fn forgot(&self, connection: &ConnectionInfo) -> Response {
715        if self.flows.limited(self.request, connection, "email") {
716            return self.too_many();
717        }
718        let email = self.input.get("email").trim().to_lowercase();
719        match self.i().store.find_by_email(&email) {
720            Ok(Some(user)) => self.flows.send(&user, "reset", &self.flows.link("/password/reset", "reset", &user, RESET_TTL)),
721            Ok(None) => {}
722            Err(e) => return self.internal(e),
723        }
724        // The same answer either way: it doesn't tell who has an account.
725        self.accepted("If that email has an account, we sent it a link to reset the password.", "/login")
726    }
727
728    fn reset(&self) -> Response {
729        let Some(user) = self.flows.check_token("reset", self.input.get("token")) else {
730            return self.fail(STATUS_CODE_REASON_PHRASE.n400_bad_request, "that link has expired or was already used; ask for a new one", "/password/forgot");
731        };
732        let password = self.input.get("password");
733        if let Err(e) = self.flows.validate_password(password) {
734            if self.json {
735                return problem(STATUS_CODE_REASON_PHRASE.n422_unprocessable_entity, &e);
736            }
737            let back = format!("{}?token={}", self.page("/password/reset"), self.input.get("token"));
738            return Redirect::to(&back).alert(&e).into_response();
739        }
740        let hash = match crate::crypto::hash_password(password) {
741            Ok(hash) => hash,
742            Err(e) => return self.internal(e.to_string()),
743        };
744        if let Err(e) = self.i().store.set_password_hash(&user.id, &hash) {
745            return self.internal(e);
746        }
747        // The link reached their inbox: the address is theirs.
748        if !user.confirmed {
749            let _ = self.i().store.confirm(&user.id);
750        }
751        let user = AuthUser { password_hash: hash, confirmed: true, ..user };
752        self.signed_in(&user, STATUS_CODE_REASON_PHRASE.n200_ok, "Your password was changed.")
753    }
754
755    fn confirm(&self) -> Response {
756        let Some(user) = self.flows.check_token("confirm", self.input.get("token")) else {
757            return self.fail(STATUS_CODE_REASON_PHRASE.n400_bad_request, "that confirmation link has expired; ask for a new one", "/login");
758        };
759        if let Err(e) = self.i().store.confirm(&user.id) {
760            return self.internal(e);
761        }
762        let user = AuthUser { confirmed: true, ..user };
763        if self.json {
764            json(STATUS_CODE_REASON_PHRASE.n200_ok, serde_json::json!({ "user": user_json(&user) }))
765        } else {
766            let target = if self.flows.session_user(self.request).is_some() { self.i().after_login.clone() } else { self.page("/login") };
767            Redirect::to(&target).notice("Your email is confirmed.").into_response()
768        }
769    }
770
771    fn resend(&self, connection: &ConnectionInfo) -> Response {
772        if self.flows.limited(self.request, connection, "email") {
773            return self.too_many();
774        }
775        let email = self.input.get("email").trim().to_lowercase();
776        match self.i().store.find_by_email(&email) {
777            Ok(Some(user)) if !user.confirmed => self.flows.send(&user, "confirm", &self.flows.link("/confirm", "confirm", &user, CONFIRM_TTL)),
778            Ok(_) => {}
779            Err(e) => return self.internal(e),
780        }
781        self.accepted("If that email has an unconfirmed account, we sent it a new link.", "/login")
782    }
783}
784
785/// An Argon2id hash of nothing anyone types, for timing-equal failed logins.
786fn dummy_hash() -> &'static str {
787    static HASH: OnceLock<String> = OnceLock::new();
788    HASH.get_or_init(|| crate::crypto::hash_password(&crate::crypto::generate_token(16)).unwrap_or_default())
789}
790
791impl Middleware for AuthFlows {
792    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
793        if let Some(response) = self.route(request, connection) {
794            return Ok(response);
795        }
796        next.execute(&self.with_identity(request), connection)
797    }
798
799    /// Nothing to check or strip: no cookie of ours, no identity headers,
800    /// not a flow path. Async handlers then stay async.
801    fn is_passthrough(&self, request: &Request, _connection: &ConnectionInfo) -> bool {
802        let ours = request.request_uri.starts_with(&self.inner.prefix);
803        let spoofed = request.headers.iter().any(|h| h.name.to_ascii_lowercase().starts_with("x-rws-auth-"));
804        let cookie = request.header("cookie").is_some_and(|h| CookieJar::parse(&h.value).get(&self.inner.cookie_name).is_some());
805        !(ours || spoofed || cookie)
806    }
807
808    fn layer_name(&self) -> &'static str {
809        "AuthFlows"
810    }
811}