//! Pages: self-service registration (`/register`), email verification (`/verify/{token}`) and //! the resend button on the security settings page. With `registration = "open"` anyone can //! register. With `"invite"` a valid invite link is needed. With `"closed"` the page is a 404. use std::time::Instant; use forge_core::names::validate_username; use forge_core::store::validate_email; use forge_core::{AuditAction, Registration, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::{page, path_param_segment, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{ check_pre_csrf, clear_pre_csrf, current_user, hash_password_async, login, pre_csrf_token, require_csrf, }; use crate::client_ip::client_ip; use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t}; use crate::mail::account; use crate::state::AppState; use crate::throttle; use crate::views::{alert, shell, user_facing_message}; const MIN_PASSWORD_CHARS: usize = 12; const MAX_PASSWORD_BYTES: usize = 1024; /// Registration attempts allowed per client IP inside the throttle window. const REGISTER_IP_LIMIT: usize = 10; /// Checks a new password and its confirmation. Returns the message to show, or `None` when the /// password is acceptable. Shared with the password reset page. pub(crate) fn check_new_password(lang: Lang, password: &str, confirm: &str) -> Option { if password.chars().count() < MIN_PASSWORD_CHARS { return Some(password_too_short(lang, MIN_PASSWORD_CHARS)); } if password.len() > MAX_PASSWORD_BYTES { return Some(password_too_long(lang, MAX_PASSWORD_BYTES)); } if password != confirm { return Some(t(lang, Key::ErrPasswordsDiffer).to_owned()); } None } #[query_params(error = bad_request)] struct RegisterQuery { invite: Option, } #[derive(Deserialize)] struct RegisterForm { #[serde(default)] username: String, #[serde(default)] email: String, #[serde(default)] password: String, #[serde(default)] confirm: String, #[serde(default)] invite: String, #[serde(default)] precsrf: String, } #[derive(Deserialize)] struct VerifyForm { #[serde(default)] precsrf: String, } #[derive(Deserialize)] struct ResendForm { #[serde(default)] csrf: String, } /// Which state the register page is in. #[derive(Clone, Copy, PartialEq, Eq)] enum Gate { /// The form is shown. Form, /// Invite-only registration, and no invite was given. NeedsInvite, /// An invite was given, but it is unknown, used or expired. BadInvite, } /// Which state the verification page is in. #[derive(Clone, Copy, PartialEq, Eq)] enum VerifyMode { /// The confirm button is shown. Nothing is consumed until it is pressed, so a mail scanner /// that opens the link does not spend it. Confirm, Done, Invalid, } fn gate_for(mode: &Registration, invite: &str, invite_valid: bool) -> Gate { if invite.trim().is_empty() { return if *mode == Registration::Invite { Gate::NeedsInvite } else { Gate::Form }; } if invite_valid { Gate::Form } else { Gate::BadInvite } } #[component] async fn register_form( lang: Lang, gate: Gate, invite: &str, invited: bool, username: &str, email: &str, precsrf: &str, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::RegisterTitle))

if gate == Gate::NeedsInvite {

(t(lang, Key::RegisterNeedsInvite))

} if gate == Gate::BadInvite {

(t(lang, Key::RegisterInvalidInvite))

} if gate == Gate::Form { if invited {

(t(lang, Key::RegisterInviteSub))

} if !invited {

(t(lang, Key::RegisterSub))

} if let Some(message) = error { alert(message: message.as_str()) }
if invited { } if !invited { }
}
}) } #[page("/register")] async fn register_page(cx: &Cx) -> Result { let state = app_context::(cx); if state.config.registration == Registration::Closed { return Err(not_found().into()); } if current_user(cx).await?.is_some() { return Err(see_other("/").into()); } let invite = query_params::(cx)? .invite .clone() .unwrap_or_default(); let invited_email = match invite.trim() { "" => None, token => state.store.invite_email(token).await?, }; let gate = gate_for(&state.config.registration, &invite, invited_email.is_some()); let invited = invited_email.is_some(); let email = invited_email.unwrap_or_default(); let lang = i18n::lang(cx, None); let precsrf = pre_csrf_token(cx); Ok(view! { shell(title: t(lang, Key::RegisterTitle), viewer: None, register_form(lang: lang, gate: gate, invite: invite.as_str(), invited: invited, username: "", email: email.as_str(), precsrf: precsrf.as_str(), error: None)) }) } /// Validates the form and creates the account. The inner `Err` is a message for the user. With /// an invite, the account takes the invite's address and is verified from the start. async fn create_account( state: &AppState, lang: Lang, form: &RegisterForm, invite: Option<(&str, &str)>, ) -> Result> { let username = match validate_username(&form.username) { Ok((name, _)) => name, Err(e) => { return Ok(Err( user_facing_message(&e).unwrap_or_else(|| "invalid username".into()) )); } }; if let Some(message) = check_new_password(lang, &form.password, &form.confirm) { return Ok(Err(message)); } let address = match invite { Some((_, address)) => address.to_owned(), None => { let address = form.email.trim().to_owned(); if let Err(e) = validate_email(&address) { return Ok(Err( user_facing_message(&e).unwrap_or_else(|| "invalid email address".into()) )); } address } }; let hash = hash_password_async(form.password.clone()).await?; let created = match invite { Some((token, _)) => match state.store.accept_invite(token, &username, &hash).await { Ok(Some(user)) => Ok(user), Ok(None) => return Ok(Err(t(lang, Key::RegisterInvalidInvite).to_owned())), Err(e) => Err(e), }, None => { state .store .create_user(&username, Some(&address), &hash, false) .await } }; match created { Ok(user) => Ok(Ok(user)), Err(e) => match user_facing_message(&e) { Some(message) => Ok(Err(message)), None => Err(e.into()), }, } } #[page(POST "/register")] async fn register_submit(cx: &Cx, Form(form): Form) -> Result { let state = app_context::(cx); if state.config.registration == Registration::Closed { return Err(not_found().into()); } // Login CSRF: the pre-session token must match the cookie before anything else runs. if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let lang = i18n::lang(cx, None); let now = Instant::now(); let ip_key = throttle::ip_key("register-ip", client_ip(cx)); let limited = state .throttle .check(&ip_key, REGISTER_IP_LIMIT, now) .is_some(); // Every attempt counts, so guessing invite tokens stays limited. state.throttle.record_failure(&ip_key, now); let invite = form.invite.trim().to_owned(); let invited_email = match invite.as_str() { "" => None, token => state.store.invite_email(token).await?, }; let gate = gate_for(&state.config.registration, &invite, invited_email.is_some()); let mut error = None; if limited && gate == Gate::Form { error = Some(t(lang, Key::ErrThrottled).to_owned()); } else if gate == Gate::Form { let invite_pair = invited_email .as_deref() .map(|address| (invite.as_str(), address)); match create_account(state, lang, &form, invite_pair).await? { Ok(user) => { let invited = invite_pair.is_some(); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::UserCreated, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::json!({ "register": true, "invite": invited }), ) .await; if invited { audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::InviteAccepted, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::Value::Null, ) .await; } else if let Some(address) = user.email.clone() { account::send_verify(state, &user, &address).await?; } login(cx, &user).await?; clear_pre_csrf(cx); return Err(see_other("/").into()); } Err(message) => error = Some(message), } } let precsrf = form.precsrf.clone(); let invited = invited_email.is_some(); let email = invited_email.unwrap_or_else(|| form.email.clone()); Ok(view! { shell(title: t(lang, Key::RegisterTitle), viewer: None, register_form(lang: lang, gate: gate, invite: invite.as_str(), invited: invited, username: form.username.as_str(), email: email.as_str(), precsrf: precsrf.as_str(), error: error)) }) } #[component] async fn verify_body( lang: Lang, token: &str, precsrf: &str, mode: VerifyMode, ) -> Result { Ok(view! {

(t(lang, Key::VerifyTitle))

if mode == VerifyMode::Confirm {

(t(lang, Key::VerifyConfirmSub))

} if mode == VerifyMode::Done {

(t(lang, Key::VerifyDone))

} if mode == VerifyMode::Invalid {

(t(lang, Key::VerifyInvalid))

}
}) } #[page("/verify/{token}")] async fn verify_page(cx: &Cx) -> Result { let lang = i18n::lang(cx, None); let token = path_param_segment(cx, "token").to_owned(); let precsrf = pre_csrf_token(cx); Ok(view! { shell(title: t(lang, Key::VerifyTitle), viewer: None, verify_body(lang: lang, token: token.as_str(), precsrf: precsrf.as_str(), mode: VerifyMode::Confirm)) }) } #[page(POST "/verify/{token}")] async fn verify_submit(cx: &Cx, Form(form): Form) -> Result { if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, None); let token = path_param_segment(cx, "token").to_owned(); let mode = match state.store.redeem_verify_token(&token).await? { Some(user_id) => { if let Some(user) = state.store.user_by_id(&user_id).await? { audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::EmailVerified, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::Value::Null, ) .await; } VerifyMode::Done } None => VerifyMode::Invalid, }; Ok(view! { shell(title: t(lang, Key::VerifyTitle), viewer: None, verify_body(lang: lang, token: token.as_str(), precsrf: form.precsrf.as_str(), mode: mode)) }) } /// Sends a fresh verification link for the signed-in user's address, unless it is verified. #[route(POST "/settings/security/email/resend")] async fn verify_resend(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); if let Some(address) = viewer.0.email.clone() && !state.store.email_is_verified(&viewer.0.id).await? { account::send_verify(state, &viewer.0, &address).await?; } Ok(see_other("/settings/security?saved=verify_sent")) } #[cfg(test)] pub(crate) mod test_helpers { use http::HeaderMap; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; /// The pre-session CSRF cookie name. pub const PRECSRF_COOKIE: &str = "__Host-forge_precsrf"; /// Sends one request and returns the status, headers and body. pub async fn send( router: &Router, method: &str, uri: &str, cookie: Option<&str>, form: Option<&str>, ) -> (u16, HeaderMap, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(c) = cookie { builder = builder.header("cookie", c); } let body = match form { Some(f) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(f.to_owned()) } None => Body::empty(), }; let res = router.handle(builder.body(body).unwrap()).await; let status = res.status().as_u16(); let headers = res.headers().clone(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); ( status, headers, String::from_utf8_lossy(&bytes).into_owned(), ) } /// The `name=value` pair of a named Set-Cookie header, if present. pub fn cookie_pair(headers: &HeaderMap, name: &str) -> Option { headers .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .find(|raw| raw.starts_with(name)) .map(|raw| raw.split(';').next().unwrap().to_owned()) } /// The value of a hidden `` field. pub fn field_from(html: &str, name: &str) -> String { let marker = format!("name=\"{name}\" value=\""); let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len(); let end = html[start..].find('"').unwrap() + start; html[start..end].to_owned() } /// The first URL after `marker` in a mail body, up to whitespace. pub fn link_in(body: &str, marker: &str) -> String { let start = body .find(marker) .unwrap_or_else(|| panic!("{marker} in mail")); body[start..].split_whitespace().next().unwrap().to_owned() } /// Fetches a page and returns its pre-session cookie pair and the CSRF value. The value is /// the cookie's own value, so this works on pages whose form is not shown. pub async fn precsrf_for(router: &Router, uri: &str) -> (String, String) { let (status, headers, _) = send(router, "GET", uri, None, None).await; assert_eq!(status, 200, "GET {uri}"); let cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("precsrf cookie"); let value = cookie.split_once('=').expect("cookie value").1.to_owned(); (cookie, value) } } #[cfg(test)] mod tests { use std::path::Path; use forge_core::{Config, Registration}; use std::sync::Arc; use super::test_helpers::{field_from, link_in, precsrf_for, send}; use crate::state::AppState; async fn app(dir: &Path, mode: Registration) -> (topcoat::router::Router, AppState) { let mut state = crate::test_support::test_state(dir).await; state.config = Arc::new(Config { registration: mode, ..crate::test_support::test_config(dir) }); (crate::test_support::app_router(state.clone()), state) } #[tokio::test] async fn closed_registration_is_not_found() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, _state) = app(tmp.path(), Registration::Closed).await; let (status, _, _) = send(&router, "GET", "/register", None, None).await; assert_eq!(status, 404); let (status, _, _) = send( &router, "POST", "/register", None, Some("username=bob&password=x&confirm=x"), ) .await; assert_eq!(status, 404); } #[tokio::test] async fn invite_mode_needs_an_invite() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, _state) = app(tmp.path(), Registration::Invite).await; let (status, _, html) = send(&router, "GET", "/register", None, None).await; assert_eq!(status, 200); assert!(html.contains("Registration is by invitation only")); assert!( !html.contains("name=\"username\""), "no form without an invite" ); let (status, _, html) = send(&router, "GET", "/register?invite=forge_nope", None, None).await; assert_eq!(status, 200); assert!(html.contains("This invite is invalid")); } #[tokio::test] async fn invite_creates_one_verified_account_and_the_link_is_single_use() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path(), Registration::Invite).await; let admin = state .store .create_user("admin", None, "hash", true) .await .unwrap(); let token = state .store .create_invite("bob@example.com", &admin.id) .await .unwrap(); let uri = format!("/register?invite={token}"); let (cookie, precsrf) = precsrf_for(&router, &uri).await; let (status, _, html) = send(&router, "GET", &uri, Some(&cookie), None).await; assert_eq!(status, 200); assert!(html.contains("bob@example.com"), "invited address is shown"); assert_eq!(field_from(&html, "invite"), token); let form = format!( "precsrf={precsrf}&invite={token}&username=bob&email=mallory%40example.com\ &password=correct-horse-battery&confirm=correct-horse-battery" ); let (status, headers, _) = send(&router, "POST", "/register", Some(&cookie), Some(&form)).await; assert_eq!(status, 303); assert_eq!(headers["location"].to_str().unwrap(), "/"); let bob = state .store .user_by_username("bob") .await .unwrap() .expect("account"); assert_eq!( bob.email.as_deref(), Some("bob@example.com"), "the invite's address wins" ); assert!(state.store.email_is_verified(&bob.id).await.unwrap()); // The same link now answers as spent, and creates nothing. let (cookie, precsrf) = precsrf_for(&router, &uri).await; let form = format!( "precsrf={precsrf}&invite={token}&username=carol&email=\ &password=correct-horse-battery&confirm=correct-horse-battery" ); let (status, _, html) = send(&router, "POST", "/register", Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!(html.contains("This invite is invalid")); assert!( state .store .user_by_username("carol") .await .unwrap() .is_none() ); } #[tokio::test] async fn open_registration_queues_a_verification_mail_and_no_verified_mark() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path(), Registration::Open).await; let (cookie, precsrf) = precsrf_for(&router, "/register").await; let form = format!( "precsrf={precsrf}&invite=&username=dave&email=dave%40example.com\ &password=correct-horse-battery&confirm=correct-horse-battery" ); let (status, _, _) = send(&router, "POST", "/register", Some(&cookie), Some(&form)).await; assert_eq!(status, 303); let dave = state .store .user_by_username("dave") .await .unwrap() .expect("account"); assert!(!state.store.email_is_verified(&dave.id).await.unwrap()); let mail = state.store.recent_mail(10).await.unwrap(); assert!( mail.iter() .any(|m| m.to_addr == "dave@example.com" && m.body.contains("/verify/")), "a verification link is queued" ); } #[tokio::test] async fn verify_link_marks_the_address_verified_once() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path(), Registration::Open).await; let user = state .store .create_user("erin", Some("erin@example.com"), "hash", false) .await .unwrap(); crate::mail::account::send_verify(&state, &user, "erin@example.com") .await .unwrap(); let body = state.store.recent_mail(10).await.unwrap()[0].body.clone(); let link = link_in(&body, "/verify/"); let path = link.trim_start_matches("https://forge.test"); assert!(!state.store.email_is_verified(&user.id).await.unwrap()); // A GET only shows the confirm button; it spends nothing. let (status, _, html) = send(&router, "GET", path, None, None).await; assert_eq!(status, 200); assert!(html.contains("Confirm address")); assert!(!state.store.email_is_verified(&user.id).await.unwrap()); let (cookie, precsrf) = precsrf_for(&router, path).await; let form = format!("precsrf={precsrf}"); let (status, _, html) = send(&router, "POST", path, Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!(html.contains("Your email address is verified")); assert!(state.store.email_is_verified(&user.id).await.unwrap()); let (status, _, html) = send(&router, "POST", path, Some(&cookie), Some(&form)).await; assert_eq!(status, 200); assert!(html.contains("This verification link is invalid")); } }