//! Page: account security (`/settings/security`). Password change, email change and //! two-factor authentication (enrollment, disabling, recovery codes). Every POST checks the //! CSRF token first. Changing the password signs out the other sessions. use std::time::{Duration, Instant}; use forge_core::names::normalize_name; use forge_core::totp; use forge_core::{AuditAction, Session, User, secret}; use http::header::{HeaderValue, RETRY_AFTER}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::{HeaderMap, StatusCode, page, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{ hash_password_async, logout, require_csrf, require_user, two_factor_enabled, unix_now, verify_password_async, verify_second_factor, }; use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t, two_factor_on_line}; use crate::state::AppState; use crate::throttle::{self, LOGIN_USER_LIMIT, TWO_FACTOR_USER_LIMIT}; use crate::views::{alert, shell, user_facing_message}; /// Shortest and longest new passwords, in characters. const MIN_PASSWORD_CHARS: usize = 10; const MAX_PASSWORD_CHARS: usize = 1024; /// Recovery codes issued by each enrollment or regeneration. const RECOVERY_CODE_COUNT: usize = 10; #[query_params(error = bad_request)] struct SecurityQuery { saved: Option, } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct PasswordForm { #[serde(default)] csrf: String, #[serde(default)] current_password: String, #[serde(default)] new_password: String, #[serde(default)] confirm_password: String, } #[derive(Deserialize)] struct EmailForm { #[serde(default)] csrf: String, #[serde(default)] email: String, #[serde(default)] current_password: String, } #[derive(Deserialize)] struct CodeForm { #[serde(default)] csrf: String, #[serde(default)] code: String, } #[derive(Deserialize)] struct DisableForm { #[serde(default)] csrf: String, #[serde(default)] current_password: String, #[serde(default)] code: String, } #[derive(Deserialize)] struct DeleteAccountForm { #[serde(default)] csrf: String, #[serde(default)] current_password: String, #[serde(default)] code: String, #[serde(default)] confirm_username: String, } /// Messages and one-time output for one render of the page. #[derive(Default)] struct Shown { notice: Option<&'static str>, delete_error: Option, password_error: Option, email_error: Option, email_draft: Option, two_factor_error: Option, /// Recovery codes, shown once in the response that created them. codes: Option>, /// Set when a check was refused by the throttle. The response then answers 429. throttled: Option, } /// What the page needs to draw itself. struct SecurityPage { lang: Lang, csrf: String, email: String, /// Whether the stored address has been verified. email_verified: bool, notice: Option<&'static str>, password_error: Option, email_error: Option, two_factor_error: Option, codes: Option>, /// Secret (base32) and `otpauth://` URI while enrollment is waiting for a code. pending: Option<(String, String)>, /// Unused recovery codes, when two-factor is on. enabled_unused_codes: Option, delete_error: Option, /// Repositories still owned by the user. Deletion is refused while there are any. owned_repos: Vec, two_factor_on: bool, } #[component] async fn security_body(page: SecurityPage) -> Result { let SecurityPage { lang, csrf, email, email_verified, notice, password_error, email_error, two_factor_error, codes, pending, enabled_unused_codes, delete_error, owned_repos, two_factor_on, } = page; Ok(view! {

(t(lang, Key::Security))

(t(lang, Key::SecurityLog))(t(lang, Key::SecurityLogRest))

if let Some(message) = notice {

(message)

} if let Some(list) = codes {

(t(lang, Key::RecoveryCodes))

(t(lang, Key::RecoveryCodesSave))

    for code in list.into_iter() {
  • (code)
  • }
}

(t(lang, Key::Password))

if let Some(message) = password_error { alert(message: message.as_str()) }

(t(lang, Key::Email))

if let Some(message) = email_error { alert(message: message.as_str()) }
if !email.is_empty() { if email_verified {

(t(lang, Key::VerifyVerified))

} if !email_verified {

(t(lang, Key::VerifyUnverified))

} }

(t(lang, Key::TwoFactorAuth))

if let Some(message) = two_factor_error { alert(message: message.as_str()) } if let Some(unused) = enabled_unused_codes {

(two_factor_on_line(lang, unused))

(t(lang, Key::RegenerateCodes))

(t(lang, Key::RegenerateCodesHint))

(t(lang, Key::TurnOffTwoFactor))

} else if let Some((secret, uri)) = pending {

(t(lang, Key::TwoFactorPendingIntro))

(t(lang, Key::SecretPrefix)) (secret)

(t(lang, Key::LinkPrefix)) (uri)

} else {

(t(lang, Key::TwoFactorOffIntro))

}

(t(lang, Key::DeleteAccount))

if let Some(message) = delete_error { alert(message: message.as_str()) } if owned_repos.is_empty() {

(t(lang, Key::DeleteAccountIntro))

if two_factor_on { }
} else {

(t(lang, Key::TransferOrDeleteRepos))

    for name in owned_repos.into_iter() {
  • (name)
  • }
}
}) } fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> { match saved { Some("password") => Some(t(lang, Key::OkPasswordChanged)), Some("email") => Some(t(lang, Key::OkEmailSaved)), Some("verify_sent") => Some(t(lang, Key::OkVerifySent)), Some("2fa-off") => Some(t(lang, Key::OkTwoFactorOff)), _ => None, } } /// Builds the page data from the stored state and the message set for this render. async fn build_page( state: &AppState, lang: Lang, viewer: &(User, Session), shown: Shown, ) -> forge_core::Result { let totp_state = state.store.totp_state(&viewer.0.id).await?; let (pending, enabled_unused_codes) = match totp_state { Some(t) if t.enabled => ( None, Some(state.store.unused_recovery_codes(&viewer.0.id).await?), ), Some(t) => match t.sealed_secret { Some(sealed) => { let shared = secret::open(&state.key, &sealed)?; let pending = ( totp::base32_encode(&shared), totp::otpauth_uri(&viewer.0.username, &shared), ); (Some(pending), None) } None => (None, None), }, None => (None, None), }; let owned_repos = state .store .list_repos_by_owner(&viewer.0.id) .await? .into_iter() .map(|repo| format!("{}/{}", repo.owner_name, repo.name)) .collect(); let two_factor_on = enabled_unused_codes.is_some(); Ok(SecurityPage { lang, csrf: viewer.1.csrf_token.clone(), delete_error: shown.delete_error, owned_repos, two_factor_on, email_verified: state.store.email_is_verified(&viewer.0.id).await?, email: shown .email_draft .unwrap_or_else(|| viewer.0.email.clone().unwrap_or_default()), notice: shown.notice, password_error: shown.password_error, email_error: shown.email_error, two_factor_error: shown.two_factor_error, codes: shown.codes, pending, enabled_unused_codes, }) } /// Renders the page. A throttled check answers 429 with `Retry-After`. async fn respond(cx: &Cx, viewer: (User, Session), shown: Shown) -> Result { let state = app_context::(cx); // `view!` expects the `__cx` binding that `#[page]` and `#[component]` provide. This plain // helper has to bind it itself. let __cx = cx; let mut headers = HeaderMap::new(); let status = match shown.throttled { Some(retry) => { headers.insert(RETRY_AFTER, HeaderValue::from(retry.as_secs().max(1))); StatusCode::TOO_MANY_REQUESTS } None => StatusCode::OK, }; let lang = i18n::lang(cx, Some(&viewer.0)); let page = build_page(state, lang, &viewer, shown).await?; Ok(view! { (status) (headers) shell(title: t(lang, Key::Security), viewer: Some(&viewer), security_body(page: page)) }) } /// Why a sensitive action was refused at the password step. struct Refused { message: &'static str, retry: Option, } /// Checks the current password for a sensitive change. A wrong answer counts against the /// user's login throttle, so guessing here also locks logins. A right answer clears the count. async fn require_password( lang: Lang, state: &AppState, user: &User, password: &str, ) -> forge_core::Result> { let key = throttle::user_key(&normalize_name(&user.username)); let now = Instant::now(); if let Some(retry) = state.throttle.check(&key, LOGIN_USER_LIMIT, now) { return Ok(Err(Refused { message: t(lang, Key::ErrThrottled), retry: Some(retry), })); } if verify_password_async(password.to_owned(), user.password_hash.clone()).await? { state.throttle.clear(&key); Ok(Ok(())) } else { state.throttle.record_failure(&key, now); Ok(Err(Refused { message: t(lang, Key::ErrWrongPassword), retry: None, })) } } /// The retry delay when second-factor checks for this user are throttled. fn second_factor_blocked(state: &AppState, user_id: &str) -> Option { state.throttle.check( &throttle::two_factor_key(user_id), TWO_FACTOR_USER_LIMIT, Instant::now(), ) } /// Fresh recovery codes and their hashes. The plain codes are for display only. fn new_recovery_codes() -> (Vec, Vec) { let codes: Vec = (0..RECOVERY_CODE_COUNT) .map(|_| totp::new_recovery_code()) .collect(); let hashes = codes.iter().map(|c| totp::recovery_code_hash(c)).collect(); (codes, hashes) } /// Why a new password is refused, or `None` when it is acceptable. fn check_new_password(lang: Lang, new: &str, confirm: &str) -> Option { let len = new.chars().count(); if len < MIN_PASSWORD_CHARS { return Some(password_too_short(lang, MIN_PASSWORD_CHARS)); } if len > MAX_PASSWORD_CHARS { return Some(password_too_long(lang, MAX_PASSWORD_CHARS)); } if new != confirm { return Some(t(lang, Key::ErrPasswordsDiffer).into()); } None } #[page("/settings/security")] async fn security_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let saved = query_params::(cx)?.saved.clone(); let shown = Shown { notice: notice_for(lang, saved.as_deref()), ..Shown::default() }; respond(cx, viewer, shown).await } #[page(POST "/settings/security/password")] async fn password_change(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let mut shown = Shown::default(); if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? { shown.password_error = Some(refused.message.into()); shown.throttled = refused.retry; return respond(cx, viewer, shown).await; } if let Some(message) = check_new_password(lang, &form.new_password, &form.confirm_password) { shown.password_error = Some(message); return respond(cx, viewer, shown).await; } let hash = hash_password_async(form.new_password).await?; state.store.set_password_hash(&viewer.0.id, &hash).await?; // Every other browser loses its session. The current one stays signed in. state .store .delete_sessions_except(&viewer.0.id, &viewer.1.id) .await?; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::PasswordChanged, Target::default(), serde_json::Value::Null, ) .await; Err(see_other("/settings/security?saved=password").into()) } #[page(POST "/settings/security/email")] async fn email_change(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let email = form.email.trim().to_owned(); let mut shown = Shown { email_draft: Some(email.clone()), ..Shown::default() }; if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? { shown.email_error = Some(refused.message.into()); shown.throttled = refused.retry; return respond(cx, viewer, shown).await; } match state.store.set_email(&viewer.0.id, &email).await { Ok(()) => { // A new address is unverified until its link is opened. state.store.clear_email_verified(&viewer.0.id).await?; crate::mail::account::send_verify(state, &viewer.0, &email).await?; // The allowed-signers lines and cached verdicts carry the old address. crate::signatures::on_keys_changed(state, &viewer.0.id).await; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::EmailChanged, Target::default(), serde_json::Value::Null, ) .await; Err(see_other("/settings/security?saved=email").into()) } Err(e) => match user_facing_message(&e) { Some(message) => { shown.email_error = Some(message); respond(cx, viewer, shown).await } None => Err(e.into()), }, } } #[route(POST "/settings/security/2fa/enable")] async fn two_factor_enable(cx: &Cx, Form(form): Form) -> Result { let (user, _session) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); if !two_factor_enabled(&state.store, &user.id).await? { // A new enrollment replaces any unconfirmed secret. let sealed = secret::seal(&state.key, &totp::new_secret())?; state.store.set_totp_secret(&user.id, &sealed).await?; } Ok(see_other("/settings/security")) } #[page(POST "/settings/security/2fa/confirm")] async fn two_factor_confirm(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let user_id = viewer.0.id.clone(); let Some(totp_state) = state.store.totp_state(&user_id).await? else { return Err(see_other("/settings/security").into()); }; let sealed = match (&totp_state.sealed_secret, totp_state.enabled) { (Some(sealed), false) => sealed.clone(), _ => return Err(see_other("/settings/security").into()), }; let mut shown = Shown::default(); if let Some(retry) = second_factor_blocked(state, &user_id) { shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into()); shown.throttled = Some(retry); return respond(cx, viewer, shown).await; } let shared = secret::open(&state.key, &sealed)?; let key = throttle::two_factor_key(&user_id); match totp::verify( &shared, form.code.trim(), totp::step_for(unix_now()), totp_state.last_step, ) { Some(step) => { state.throttle.clear(&key); let (codes, hashes) = new_recovery_codes(); state.store.enable_totp(&user_id, step, &hashes).await?; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::TotpEnabled, Target::default(), serde_json::Value::Null, ) .await; shown.notice = Some(t(lang, Key::OkTwoFactorOn)); shown.codes = Some(codes); respond(cx, viewer, shown).await } None => { state.throttle.record_failure(&key, Instant::now()); shown.two_factor_error = Some(t(lang, Key::ErrWrongConfirmCode).into()); respond(cx, viewer, shown).await } } } #[page(POST "/settings/security/2fa/disable")] async fn two_factor_disable(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let user_id = viewer.0.id.clone(); if !two_factor_enabled(&state.store, &user_id).await? { return Err(see_other("/settings/security").into()); } let mut shown = Shown::default(); if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? { shown.two_factor_error = Some(refused.message.into()); shown.throttled = refused.retry; return respond(cx, viewer, shown).await; } if let Some(retry) = second_factor_blocked(state, &user_id) { shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into()); shown.throttled = Some(retry); return respond(cx, viewer, shown).await; } let key = throttle::two_factor_key(&user_id); if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? { state.throttle.record_failure(&key, Instant::now()); shown.two_factor_error = Some(t(lang, Key::ErrWrongCode).into()); return respond(cx, viewer, shown).await; } state.throttle.clear(&key); state.store.disable_totp(&user_id).await?; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::TotpDisabled, Target::default(), serde_json::Value::Null, ) .await; Err(see_other("/settings/security?saved=2fa-off").into()) } #[page(POST "/settings/security/delete")] async fn account_delete(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let user_id = viewer.0.id.clone(); let mut shown = Shown::default(); if form.confirm_username != viewer.0.username { shown.delete_error = Some(t(lang, Key::ErrConfirmUsername).into()); return respond(cx, viewer, shown).await; } if let Err(refused) = require_password(lang, state, &viewer.0, &form.current_password).await? { shown.delete_error = Some(refused.message.into()); shown.throttled = refused.retry; return respond(cx, viewer, shown).await; } if two_factor_enabled(&state.store, &user_id).await? { if let Some(retry) = second_factor_blocked(state, &user_id) { shown.delete_error = Some(t(lang, Key::ErrThrottled).into()); shown.throttled = Some(retry); return respond(cx, viewer, shown).await; } let key = throttle::two_factor_key(&user_id); if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? { state.throttle.record_failure(&key, Instant::now()); shown.delete_error = Some(t(lang, Key::ErrWrongCode).into()); return respond(cx, viewer, shown).await; } state.throttle.clear(&key); } let ghost = state.store.ensure_ghost_user().await?; match state.store.delete_user(&user_id, &ghost.id).await { Ok(_) => { // The account row is gone, so the entry names no actor; the username is kept. audit::record( &state.store, Some(cx), Actor::Anonymous, AuditAction::UserDeleted, Target { kind: "user", id: &user_id, name: &viewer.0.username, }, serde_json::json!({ "self_service": true }), ) .await; logout(cx, &viewer.1).await?; Err(see_other("/").into()) } Err(e) => match user_facing_message(&e) { Some(message) => { shown.delete_error = Some(message); respond(cx, viewer, shown).await } None => Err(e.into()), }, } } #[page(POST "/settings/security/2fa/recovery")] async fn two_factor_recovery(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let user_id = viewer.0.id.clone(); if !two_factor_enabled(&state.store, &user_id).await? { return Err(see_other("/settings/security").into()); } let mut shown = Shown::default(); if let Some(retry) = second_factor_blocked(state, &user_id) { shown.two_factor_error = Some(t(lang, Key::ErrThrottled).into()); shown.throttled = Some(retry); return respond(cx, viewer, shown).await; } let key = throttle::two_factor_key(&user_id); if !verify_second_factor(&state.store, &state.key, &user_id, &form.code).await? { state.throttle.record_failure(&key, Instant::now()); shown.two_factor_error = Some(t(lang, Key::ErrWrongCode).into()); return respond(cx, viewer, shown).await; } state.throttle.clear(&key); let (codes, hashes) = new_recovery_codes(); state .store .replace_recovery_codes(&user_id, &hashes) .await?; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::RecoveryCodesRegenerated, Target::default(), serde_json::Value::Null, ) .await; shown.notice = Some(t(lang, Key::OkRecoveryCodesNew)); shown.codes = Some(codes); respond(cx, viewer, shown).await } #[cfg(test)] mod tests { use std::sync::Arc; use forge_core::time::stamp_after; use forge_core::token::hash_token; use forge_core::{Config, Registration, Store, User, secret, totp}; use forge_git::GitEnv; use topcoat::cookie::RouterBuilderCookieExt; use topcoat::router::request::Request; use topcoat::router::{Body, Router, RouterBuilderDiscoverExt, to_bytes}; use crate::auth::{SESSION_COOKIE, hash_password_async}; use crate::state::AppState; use crate::throttle::{LOGIN_USER_LIMIT, Throttle, user_key}; const TOKEN: &str = "security-test-session"; const OTHER_TOKEN: &str = "security-test-other-session"; const CSRF: &str = "security-test-csrf"; const PASSWORD: &str = "current password 1"; const KEY: [u8; 32] = [7u8; 32]; struct Fixture { router: Router, state: AppState, user: User, _dir: tempfile::TempDir, } async fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); let path = dir.path().to_path_buf(); let store = Store::open(&path.join("forge.db")).await.unwrap(); let config = Config { database: path.join("forge.db"), data_dir: path.clone(), listen: "127.0.0.1:7660".parse().unwrap(), base_url: "https://forge.test/".into(), user_content_base_url: "https://forge.test".into(), ssh_listen: "127.0.0.1:2222".parse().unwrap(), user_content_listen: "127.0.0.1:7661".parse().unwrap(), ssh_public_host: "forge.test".into(), ssh_public_port: 22, registration: Registration::Closed, trusted_proxies: Vec::new(), webhooks: Default::default(), backup: Default::default(), releases: Default::default(), replication: Default::default(), lfs: Default::default(), ci: Default::default(), advisories: Default::default(), mail: Default::default(), orgs: Default::default(), self_update: Default::default(), }; let state = AppState { config: Arc::new(config), store: store.clone(), git: Arc::new(GitEnv { git_bin: "git".into(), data_dir: path.clone(), }), key: Arc::new(KEY), internal_token: Arc::from("test-token"), admin_token: Arc::from("test-admin-token"), jobs: Arc::new(crate::backup::Jobs::default()), internal_socket: path.join("internal.sock"), throttle: Arc::new(Throttle::default()), webhook_waker: Arc::new(tokio::sync::Notify::new()), mirror_waker: Arc::new(tokio::sync::Notify::new()), blobs: crate::blobs::BlobStore::open(&path.join("blobs")).expect("blob store"), replication: Arc::new(crate::replication::ReplicationQueue::default()), ci_waker: Arc::new(tokio::sync::Notify::new()), mailer: Arc::new(crate::mail::FakeMailer::default()), mail_waker: Arc::new(tokio::sync::Notify::new()), notify_hub: Arc::new(crate::notify_hub::NotifyHub::new()), ci_live: Arc::new(crate::ci::CiLive::default()), }; let hash = hash_password_async(PASSWORD.into()).await.unwrap(); let user = store .create_user("tahinli", None, &hash, false) .await .unwrap(); let expiry = stamp_after(std::time::Duration::from_secs(3600)); store .create_session(&user.id, &hash_token(TOKEN), CSRF, &expiry, None) .await .unwrap(); let router = Router::builder() .discover() .cookies() .app_context(state.clone()) .build(); Fixture { router, state, user, _dir: dir, } } struct Reply { status: u16, location: Option, body: String, } async fn send(router: &Router, method: &str, uri: &str, signed_in: bool, form: &str) -> Reply { let mut builder = Request::builder().method(method).uri(uri); if signed_in { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={TOKEN}")); } if method == "POST" { builder = builder.header("content-type", "application/x-www-form-urlencoded"); } let res = router .handle(builder.body(Body::from(form.to_owned())).unwrap()) .await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); Reply { status, location, body: String::from_utf8_lossy(&bytes).into_owned(), } } fn now_code(shared: &[u8]) -> String { totp::code_at(shared, totp::step_for(crate::auth::unix_now())) } /// Turns two-factor on directly in the store. Returns the shared secret. async fn enable_totp_directly(fx: &Fixture) -> Vec { let shared = totp::new_secret(); let sealed = secret::seal(&KEY, &shared).unwrap(); fx.state .store .set_totp_secret(&fx.user.id, &sealed) .await .unwrap(); fx.state .store .enable_totp(&fx.user.id, 0, &[totp::recovery_code_hash("aaaa-bbbb")]) .await .unwrap(); shared.to_vec() } #[tokio::test] async fn security_page_requires_a_session() { let fx = fixture().await; let reply = send(&fx.router, "GET", "/settings/security", false, "").await; assert_eq!(reply.status, 303); assert!(reply.location.unwrap().starts_with("/login?next=")); } #[tokio::test] async fn password_change_revokes_other_sessions_and_keeps_this_one() { let fx = fixture().await; let expiry = stamp_after(std::time::Duration::from_secs(3600)); fx.state .store .create_session( &fx.user.id, &hash_token(OTHER_TOKEN), "other", &expiry, None, ) .await .unwrap(); let form = format!( "csrf={CSRF}¤t_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password" ); let reply = send( &fx.router, "POST", "/settings/security/password", true, &form, ) .await; assert_eq!(reply.status, 303); assert_eq!( reply.location.as_deref(), Some("/settings/security?saved=password") ); let store = &fx.state.store; assert!( store .session_by_token_hash(&hash_token(TOKEN)) .await .unwrap() .is_some() ); assert!( store .session_by_token_hash(&hash_token(OTHER_TOKEN)) .await .unwrap() .is_none() ); let fresh = store.user_by_id(&fx.user.id).await.unwrap().unwrap(); assert!( crate::auth::verify_password_async("a brand new password".into(), fresh.password_hash) .await .unwrap() ); } #[tokio::test] async fn wrong_current_password_fails_and_changes_nothing() { let fx = fixture().await; let form = format!( "csrf={CSRF}¤t_password=wrong+one&new_password=a+brand+new+password&confirm_password=a+brand+new+password" ); let reply = send( &fx.router, "POST", "/settings/security/password", true, &form, ) .await; assert_eq!(reply.status, 200); assert!(reply.body.contains("Current password is incorrect.")); let fresh = fx .state .store .user_by_id(&fx.user.id) .await .unwrap() .unwrap(); assert!( crate::auth::verify_password_async(PASSWORD.into(), fresh.password_hash) .await .unwrap() ); } #[tokio::test] async fn wrong_current_passwords_count_toward_the_login_throttle() { let fx = fixture().await; let bad = format!( "csrf={CSRF}¤t_password=wrong&new_password=a+brand+new+password&confirm_password=a+brand+new+password" ); for _ in 0..LOGIN_USER_LIMIT { let reply = send( &fx.router, "POST", "/settings/security/password", true, &bad, ) .await; assert_eq!(reply.status, 200); } assert!( fx.state .throttle .check( &user_key("tahinli"), LOGIN_USER_LIMIT, std::time::Instant::now() ) .is_some() ); let blocked = send( &fx.router, "POST", "/settings/security/password", true, &bad, ) .await; assert_eq!(blocked.status, 429); assert!(blocked.body.contains("Too many attempts")); } #[tokio::test] async fn new_password_length_and_confirmation_are_checked() { let fx = fixture().await; let short = format!( "csrf={CSRF}¤t_password={PASSWORD}&new_password=short&confirm_password=short" ); let reply = send( &fx.router, "POST", "/settings/security/password", true, &short, ) .await; assert!(reply.body.contains("at least 10 characters")); let mismatch = format!( "csrf={CSRF}¤t_password={PASSWORD}&new_password=a+brand+new+one&confirm_password=a+brand+new+two" ); let reply = send( &fx.router, "POST", "/settings/security/password", true, &mismatch, ) .await; assert!(reply.body.contains("do not match")); } #[tokio::test] async fn email_change_needs_the_password_and_a_valid_address() { let fx = fixture().await; // An SSH key puts the user's address into the allowed-signers file. fx.state .store .add_ssh_key( &fx.user.id, "laptop", "ssh-ed25519", &[1, 2, 3], "SHA256:test", ) .await .unwrap(); crate::signatures::rebuild_allowed_signers(&fx.state) .await .unwrap(); let signers = fx.state.config.data_dir.join("run").join("allowed_signers"); let before = std::fs::read_to_string(&signers).unwrap(); // The fixture user has no email yet, so the username is the principal. assert!(before.starts_with("tahinli "), "{before}"); let wrong = format!("csrf={CSRF}&email=me%40example.com¤t_password=nope"); let reply = send(&fx.router, "POST", "/settings/security/email", true, &wrong).await; assert!(reply.body.contains("Current password is incorrect.")); let invalid = format!("csrf={CSRF}&email=not+an+email¤t_password={PASSWORD}"); let reply = send( &fx.router, "POST", "/settings/security/email", true, &invalid, ) .await; assert!(reply.body.contains("Enter an email address")); let ok = format!("csrf={CSRF}&email=me%40example.com¤t_password={PASSWORD}"); let reply = send(&fx.router, "POST", "/settings/security/email", true, &ok).await; assert_eq!( reply.location.as_deref(), Some("/settings/security?saved=email") ); let fresh = fx .state .store .user_by_id(&fx.user.id) .await .unwrap() .unwrap(); assert_eq!(fresh.email.as_deref(), Some("me@example.com")); let after = std::fs::read_to_string(&signers).unwrap(); assert!( after.starts_with("me@example.com "), "allowed_signers still has the old address: {after}" ); } #[tokio::test] async fn enroll_confirm_then_disable_two_factor() { let fx = fixture().await; let enable = format!("csrf={CSRF}"); let reply = send( &fx.router, "POST", "/settings/security/2fa/enable", true, &enable, ) .await; assert_eq!(reply.status, 303); let pending = fx .state .store .totp_state(&fx.user.id) .await .unwrap() .unwrap(); assert!(!pending.enabled); let sealed = pending.sealed_secret.expect("secret stored"); let shared = secret::open(&KEY, &sealed).unwrap(); // The enrollment page shows the secret and the URI, not the codes. let page = send(&fx.router, "GET", "/settings/security", true, "").await; assert!(page.body.contains(&totp::base32_encode(&shared))); assert!(page.body.contains("otpauth://totp/forge:tahinli?secret=")); let confirm = format!("csrf={CSRF}&code={}", now_code(&shared)); let reply = send( &fx.router, "POST", "/settings/security/2fa/confirm", true, &confirm, ) .await; assert_eq!(reply.status, 200); assert!(reply.body.contains("Recovery codes")); let codes = reply .body .split("") .skip(1) .filter_map(|chunk| chunk.split("").next()) .filter(|code| code.len() == 9 && code.as_bytes()[4] == b'-') .count(); assert_eq!(codes, super::RECOVERY_CODE_COUNT); assert!( fx.state .store .totp_state(&fx.user.id) .await .unwrap() .unwrap() .enabled ); assert_eq!( fx.state .store .unused_recovery_codes(&fx.user.id) .await .unwrap(), super::RECOVERY_CODE_COUNT as u64 ); // Disabling needs the password and a code. A wrong password is refused. let no_password = format!( "csrf={CSRF}¤t_password=nope&code={}", now_code(&shared) ); let reply = send( &fx.router, "POST", "/settings/security/2fa/disable", true, &no_password, ) .await; assert!(reply.body.contains("Current password is incorrect.")); assert!( fx.state .store .totp_state(&fx.user.id) .await .unwrap() .unwrap() .enabled ); // The confirm step already used the current code, so disabling takes the next step's // code. Reusing the current one would be refused as a replay. let next = totp::code_at(&shared, totp::step_for(crate::auth::unix_now()) + 1); let disable = format!("csrf={CSRF}¤t_password={PASSWORD}&code={next}"); let reply = send( &fx.router, "POST", "/settings/security/2fa/disable", true, &disable, ) .await; assert_eq!( reply.location.as_deref(), Some("/settings/security?saved=2fa-off") ); let off = fx .state .store .totp_state(&fx.user.id) .await .unwrap() .unwrap(); assert!(!off.enabled); assert_eq!(off.sealed_secret, None); } #[tokio::test] async fn wrong_confirm_code_does_not_enable() { let fx = fixture().await; send( &fx.router, "POST", "/settings/security/2fa/enable", true, &format!("csrf={CSRF}"), ) .await; let reply = send( &fx.router, "POST", "/settings/security/2fa/confirm", true, &format!("csrf={CSRF}&code=000000"), ) .await; // The chance that 000000 is the live code is about 1 in a million per window. if reply.status == 200 && !reply.body.contains("Recovery codes") { assert!(reply.body.contains("That code is not right")); } let state = fx .state .store .totp_state(&fx.user.id) .await .unwrap() .unwrap(); if !state.enabled { assert!(state.sealed_secret.is_some()); } } #[tokio::test] async fn regenerating_recovery_codes_requires_a_code_and_replaces_them() { let fx = fixture().await; let shared = enable_totp_directly(&fx).await; let bad = format!("csrf={CSRF}&code=123456x"); let reply = send( &fx.router, "POST", "/settings/security/2fa/recovery", true, &bad, ) .await; assert!(reply.body.contains("That code is not right")); assert_eq!( fx.state .store .unused_recovery_codes(&fx.user.id) .await .unwrap(), 1 ); let good = format!("csrf={CSRF}&code={}", now_code(&shared)); let reply = send( &fx.router, "POST", "/settings/security/2fa/recovery", true, &good, ) .await; assert!(reply.body.contains("Recovery codes")); assert_eq!( fx.state .store .unused_recovery_codes(&fx.user.id) .await .unwrap(), super::RECOVERY_CODE_COUNT as u64 ); assert!( !fx.state .store .consume_recovery_code(&fx.user.id, &totp::recovery_code_hash("aaaa-bbbb")) .await .unwrap() ); } #[tokio::test] async fn post_without_csrf_is_forbidden() { let fx = fixture().await; let form = format!( "csrf=wrong¤t_password={PASSWORD}&new_password=a+brand+new+password&confirm_password=a+brand+new+password" ); let reply = send( &fx.router, "POST", "/settings/security/password", true, &form, ) .await; assert_eq!(reply.status, 403); } }