From e706b8ed68d7e3f2d37d7dbe82bd815b194f9740 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 13:43:40 +0000 Subject: [PATCH] feat(auth): optional two-factor sign-in with TOTP and recovery codes (#105) The master password derives both the login key and the vault key, so a phished or reused password was the whole account. An account can now turn on a second factor the server checks before it hands out a session or the wrapped secrets. Login for such an account answers 428 TWO_FACTOR_REQUIRED with a five-minute pending token, and POST /v1/auth/login/2fa trades it plus a code or a recovery code for the usual response. Enrolment is setup (seed + otpauth URI, needs the auth key) then enable (first code), which returns ten recovery codes and ends every earlier session. Disabling, regenerating the codes and changing the master password all take a code. Seeds are sealed with TOTP_ENC_KEY. Without the key the server reports two_factor: false in /v1/meta and refuses enrolment, but an account already enrolled is still stopped at the second step and passes it only with a recovery code. Five wrong codes lock that step for fifteen minutes, counted in Postgres, and a code is accepted once. POST /v1/admin/users/:id/2fa/clear is the way back for someone who lost the authenticator and the codes; it is audited and ends their sessions. update_email and update_password shared a fetch-and-verify of the auth key with the new handlers, now require_auth_key, and the AuthResponse built in four places comes from one auth_response. --- .env.example | 12 + Cargo.lock | 87 ++++- Cargo.toml | 3 + compose.prod.yml | 1 + compose.yml | 1 + docs/runbooks/bootstrap-host.md | 5 +- migrations/059_user_two_factor.sql | 13 + src/auth/jwt.rs | 38 +- src/main.rs | 16 + src/routes/admin.rs | 32 +- src/routes/auth.rs | 261 ++++++++----- src/routes/meta.rs | 24 +- src/routes/mod.rs | 3 +- src/routes/two_factor.rs | 568 +++++++++++++++++++++++++++++ src/two_factor.rs | 413 +++++++++++++++++++++ 15 files changed, 1371 insertions(+), 106 deletions(-) create mode 100644 migrations/059_user_two_factor.sql create mode 100644 src/routes/two_factor.rs create mode 100644 src/two_factor.rs diff --git a/.env.example b/.env.example index 2a6ea88..6ea100c 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,18 @@ JWT_SECRET= # generate with: openssl rand -hex 32 # SYNC_BLOB_RETENTION_DAYS=90 +# Two-factor authentication (optional) ------------------------------------ +# Encrypts the authenticator seeds of accounts that turn on two-factor +# sign-in. Unset, the server does not offer it and clients hide the option. +# Generate with: openssl rand -base64 32 +# +# Back this key up with JWT_SECRET and never change it. Without it the +# server can no longer check anyone's codes: enrolled accounts are refused, +# not let through, until they use a recovery code or an admin clears their +# two-factor from the dashboard. +# TOTP_ENC_KEY= + + # Account deletion (optional) --------------------------------------------- # Days an admin soft-deleted account stays restorable. After that the daily # purge hard-deletes it and its email can be registered again. An account diff --git a/Cargo.lock b/Cargo.lock index 5ee69d6..412ccfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -219,6 +229,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -230,6 +251,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -244,6 +278,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -341,6 +386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1093,6 +1139,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1400,6 +1455,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "p256" version = "0.13.2" @@ -1518,6 +1579,17 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -1689,7 +1761,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -2745,6 +2817,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2812,8 +2894,10 @@ dependencies = [ "argon2", "axum", "base64", + "chacha20poly1305", "chrono", "dashmap", + "data-encoding", "dotenvy", "futures-util", "hex", @@ -2825,6 +2909,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1", "sha2", "sqlx", "tokio", diff --git a/Cargo.toml b/Cargo.toml index fd8bb95..be554b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ tokio-stream = { version = "0.1.18", features = ["sync"] } hmac = "0.12" sha2 = "0.10" hex = "0.4" +sha1 = "0.10" +chacha20poly1305 = "0.10" +data-encoding = "2" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } dashmap = "6" metrics = "0.24" diff --git a/compose.prod.yml b/compose.prod.yml index ca56480..fb7575a 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -26,6 +26,7 @@ services: INVITE_RATE_LIMIT: ${INVITE_RATE_LIMIT:-20} WAITLIST_RATE_LIMIT: ${WAITLIST_RATE_LIMIT:-10} ADMIN_SECRET: ${ADMIN_SECRET} + TOTP_ENC_KEY: ${TOTP_ENC_KEY:-} CORS_ORIGINS: ${CORS_ORIGINS:-https://voltius.app,https://www.voltius.app,https://app.voltius.app,tauri://localhost,https://tauri.localhost,http://localhost:1420,http://localhost:1421} LEMONSQUEEZY_SIGNING_SECRET: ${LEMONSQUEEZY_SIGNING_SECRET} LEMONSQUEEZY_STORE_ID: ${LEMONSQUEEZY_STORE_ID} diff --git a/compose.yml b/compose.yml index 8e17b88..4c1f876 100644 --- a/compose.yml +++ b/compose.yml @@ -39,6 +39,7 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:-} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} TRUSTED_PROXY_IP: ${TRUSTED_PROXY_IP:-} + TOTP_ENC_KEY: ${TOTP_ENC_KEY:-} AUTH_RATE_LIMIT: ${AUTH_RATE_LIMIT:-} SYNC_RATE_LIMIT: ${SYNC_RATE_LIMIT:-} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-} diff --git a/docs/runbooks/bootstrap-host.md b/docs/runbooks/bootstrap-host.md index a6809a1..1779755 100644 --- a/docs/runbooks/bootstrap-host.md +++ b/docs/runbooks/bootstrap-host.md @@ -21,7 +21,10 @@ The server image itself comes from GHCR, so nothing is compiled on the host. `.env.dockhand`, `.env.db`, `cloudflared/.env`, `cloudflared-api/.env`, `voltius-tofu/.env.tofu` and `voltius-tofu/oci_api_key.pem` exist nowhere else. No database backup contains -them, and losing `JWT_SECRET` alone signs every user out permanently. `scripts/secrets-bundle.sh` +them, and losing `JWT_SECRET` alone signs every user out permanently. Losing `TOTP_ENC_KEY` locks +every account with two-factor sign-in out of its authenticator codes: the server refuses them +rather than skip the step, and each one then needs a recovery code or an admin clear. +`scripts/secrets-bundle.sh` tars them, encrypts them to an age recipient, and uploads them to `s3:///voltius-prod/secrets/`, keeping `secrets-latest.tar.age` as the pointer. diff --git a/migrations/059_user_two_factor.sql b/migrations/059_user_two_factor.sql new file mode 100644 index 0000000..6287875 --- /dev/null +++ b/migrations/059_user_two_factor.sql @@ -0,0 +1,13 @@ +ALTER TABLE users + ADD COLUMN totp_secret TEXT NULL, + ADD COLUMN totp_enabled_at TIMESTAMPTZ NULL, + ADD COLUMN totp_last_step BIGINT NULL, + ADD COLUMN totp_failed_attempts INTEGER NOT NULL DEFAULT 0, + ADD COLUMN totp_locked_until TIMESTAMPTZ NULL; + +CREATE TABLE user_recovery_codes ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TIMESTAMPTZ NULL, + PRIMARY KEY (user_id, code_hash) +); diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index ae25aa5..e216265 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -66,17 +66,21 @@ pub fn create_access_token( ) } -pub fn create_refresh_token( +pub const PENDING_SECOND_FACTOR: &str = "2fa_pending"; + +// Refresh and pending tokens carry no tier: it is re-read from the database when they are used. +fn bare_token( user_id: Uuid, + kind: &str, + lifetime: Duration, token_version: i32, ) -> Result { let now = Utc::now(); - // Refresh tokens carry no tier — tier is re-read from DB on each refresh. let claims = Claims { sub: user_id, iat: now.timestamp(), - exp: (now + Duration::days(90)).timestamp(), - kind: "refresh".to_string(), + exp: (now + lifetime).timestamp(), + kind: kind.to_string(), tier: "free".to_string(), trial_ends_at: None, trial_used: false, @@ -92,6 +96,20 @@ pub fn create_refresh_token( ) } +pub fn create_refresh_token( + user_id: Uuid, + token_version: i32, +) -> Result { + bare_token(user_id, "refresh", Duration::days(90), token_version) +} + +pub fn create_pending_token( + user_id: Uuid, + token_version: i32, +) -> Result { + bare_token(user_id, PENDING_SECOND_FACTOR, Duration::minutes(5), token_version) +} + pub fn validate_token( token: &str, expected_kind: &str, @@ -175,6 +193,18 @@ pub(crate) mod tests { assert_eq!(claims.sub, user_id); } + #[test] + fn a_pending_token_passes_for_nothing_else() { + let _guard = crate::test_support::env_lock(); + std::env::set_var("JWT_SECRET", "test-secret"); + + let token = create_pending_token(Uuid::new_v4(), 3).unwrap(); + + assert_eq!(validate_token(&token, PENDING_SECOND_FACTOR).unwrap().ver, 3); + assert!(validate_token(&token, "access").is_err()); + assert!(validate_token(&token, "refresh").is_err()); + } + #[test] fn refresh_token_carries_the_token_version() { let _guard = crate::test_support::env_lock(); diff --git a/src/main.rs b/src/main.rs index b92699f..04f1b39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod team_plan; mod vault_key_exposure; mod sync_notifier; mod terminal_manager; +mod two_factor; mod user_purge; #[cfg(test)] mod test_support; @@ -271,6 +272,8 @@ async fn main() { handles_from_email = features.handles_from_email, "Configured features" ); + let seed_cipher = two_factor::SeedCipher::from_env(); + tracing::info!(available = seed_cipher.available(), "Configured two-factor authentication"); // Register — stricter limit: 5/day per IP on top of the general auth 10/min let register_route = Router::new() @@ -286,6 +289,7 @@ async fn main() { let public = Router::new() .route("/v1/auth/challenge", get(routes::auth::challenge)) .route("/v1/auth/login", post(routes::auth::login)) + .route("/v1/auth/login/2fa", post(routes::auth::login_second_factor)) .route("/v1/auth/verify-email", post(routes::auth::verify_email)) .route( "/v1/invitations/:token", @@ -369,6 +373,13 @@ async fn main() { .route("/v1/auth/email", put(routes::auth::update_email)) .route("/v1/auth/password", put(routes::auth::update_password)) .route("/v1/auth/logout-all", post(routes::auth::logout_all)) + .route("/v1/auth/2fa/setup", post(routes::two_factor::setup)) + .route("/v1/auth/2fa/enable", post(routes::two_factor::enable)) + .route("/v1/auth/2fa/disable", post(routes::two_factor::disable)) + .route( + "/v1/auth/2fa/recovery-codes", + post(routes::two_factor::regenerate_recovery_codes), + ) .route( "/v1/auth/wrapped-user-secrets", put(routes::auth::upload_wrapped_user_secrets), @@ -726,6 +737,10 @@ async fn main() { .route("/v1/admin/users/:id/handle", put(routes::admin::set_user_handle)) .route("/v1/admin/handles/derive", post(routes::admin::derive_handles)) .route("/v1/admin/users/:id/unban", post(routes::admin::unban_user)) + .route( + "/v1/admin/users/:id/2fa/clear", + post(routes::admin::clear_two_factor), + ) .route( "/v1/admin/users/:id/extend-trial", post(routes::admin::extend_trial), @@ -810,6 +825,7 @@ async fn main() { .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) .layer(middleware::from_fn(observability::track_requests)) + .layer(Extension(seed_cipher)) .with_state(pool); let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); diff --git a/src/routes/admin.rs b/src/routes/admin.rs index e0d548b..7d721c0 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -554,6 +554,7 @@ pub struct UserDetail { deleted_at: Option>, deletion_reason: Option, deleted_by: Option, + two_factor_enabled: bool, } pub async fn get_user( @@ -565,7 +566,8 @@ pub async fn get_user( SELECT id, email, handle, account_id, subscription_tier, trial_ends_at, trial_used, is_banned, is_admin, ban_reason, banned_at, admin_notes, discount_pct, ls_customer_id, ls_subscription_id, admin_override, created_at, seat_count, - last_seen_on, deleted_at, deletion_reason, deleted_by + last_seen_on, deleted_at, deletion_reason, deleted_by, + totp_enabled_at IS NOT NULL AS two_factor_enabled FROM users WHERE id = $1 "#, ) @@ -769,6 +771,34 @@ pub async fn unban_user( Ok(StatusCode::NO_CONTENT) } +// ─── Two-factor reset ───────────────────────────────────────────────────────── + +/// The only way back in for someone who lost both the authenticator and the recovery codes. +pub async fn clear_two_factor( + State(pool): State, + Extension(AdminEmail(admin_email)): Extension, + Extension(notifier): Extension, + Path(user_id): Path, +) -> Result { + let failed = |e: sqlx::Error| { + error!(error = %e, "Failed to clear two-factor authentication"); + StatusCode::INTERNAL_SERVER_ERROR + }; + let mut tx = pool.begin().await.map_err(failed)?; + let cleared = crate::two_factor::clear(&mut tx, user_id).await.map_err(failed)?; + tx.commit().await.map_err(failed)?; + + if !cleared { + return Err(StatusCode::NOT_FOUND); + } + + write_audit(&pool, &admin_email, Some(user_id), "clear_two_factor", json!({})).await; + + notifier.notify(user_id, "token_invalidated".to_string()); + info!(admin = %admin_email, user = %user_id, "Two-factor authentication cleared"); + Ok(StatusCode::NO_CONTENT) +} + // ─── Handles ────────────────────────────────────────────────────────────────── #[derive(Deserialize)] diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 67b3e28..64abdb7 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -11,7 +11,7 @@ use tracing::{error, info, warn}; use uuid::Uuid; use crate::auth::{ - jwt::{create_access_token, create_refresh_token, validate_token}, + jwt::{create_access_token, create_pending_token, create_refresh_token, validate_token, PENDING_SECOND_FACTOR}, password::{hash_auth_key, verify_auth_key}, AuthUser, }; @@ -20,6 +20,7 @@ use crate::routes::email_undeliverable_response; use crate::routes::invitations::{accept_pending_invitation, PendingInvitation, PENDING_INVITATION_COLUMNS}; use crate::self_host; use crate::sync_notifier::SyncNotifier; +use crate::two_factor::{self, Refusal, SecondFactorProof, SeedCipher}; // ─── Tier helper ───────────────────────────────────────────────────────────── @@ -69,6 +70,55 @@ async fn bump_token_version(pool: &PgPool, user_id: Uuid) -> Result<(), StatusCo Ok(()) } +fn auth_response( + user_id: Uuid, + tier: TierInfo, + wrapped_user_secrets: Option, +) -> Result { + let (jwt_token, refresh_token) = issue_session(user_id, &tier)?; + Ok(AuthResponse { + user_id, + jwt_token, + refresh_token, + tier: tier.tier, + trial_ends_at: tier.trial_ends_at, + wrapped_user_secrets, + }) +} + +#[derive(Serialize)] +pub struct SessionTokens { + pub jwt_token: String, + pub refresh_token: String, +} + +/// A pair at the user's current token version, for the device that just moved it. +pub(crate) async fn fresh_session(pool: &PgPool, user_id: Uuid) -> Result { + let tier = fetch_tier(pool, user_id).await?; + let (jwt_token, refresh_token) = issue_session(user_id, &tier)?; + Ok(SessionTokens { jwt_token, refresh_token }) +} + +pub(crate) async fn require_auth_key(pool: &PgPool, user_id: Uuid, auth_key: &str) -> Result<(), StatusCode> { + let auth_hash = sqlx::query_scalar::<_, String>("SELECT auth_hash FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, user_id = %user_id, "Failed to fetch auth_hash"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let valid = verify_auth_key(auth_key, &auth_hash).map_err(|e| { + error!(error = %e, user_id = %user_id, "Failed to verify auth key"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + if valid { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + async fn fetch_tier(pool: &PgPool, user_id: Uuid) -> Result { let row = sqlx::query_as::<_, (String, Option>, bool, bool, bool, bool, bool, Option, Option>, i32)>( "SELECT subscription_tier, trial_ends_at, trial_used, is_admin, is_banned, email_verified, admin_override, ls_subscription_id, deleted_at, token_version FROM users WHERE id = $1", @@ -376,9 +426,9 @@ async fn register_with( } let trial_ends_ts = trial_ends_at.map(|t| t.timestamp()); - let (jwt_token, refresh_token) = issue_session( + let session = auth_response( user_id, - &TierInfo { + TierInfo { tier: initial_tier.to_string(), trial_ends_at: trial_ends_ts, trial_used: false, @@ -387,6 +437,7 @@ async fn register_with( email_verified, token_version: 0, }, + body.wrapped_user_secrets, )?; // `account_id` is deliberately absent: despite the name it is the KDF salt @@ -399,17 +450,7 @@ async fn register_with( info!(user_id = %user_id, "User registered with 14-day trial"); } - Ok(( - StatusCode::CREATED, - Json(AuthResponse { - user_id, - jwt_token, - refresh_token, - tier: initial_tier.to_string(), - trial_ends_at: trial_ends_ts, - wrapped_user_secrets: body.wrapped_user_secrets, - }), - )) + Ok((StatusCode::CREATED, Json(session))) } // ─── Login ─────────────────────────────────────────────────────────────────── @@ -420,12 +461,37 @@ pub struct LoginRequest { pub auth_key: String, } +#[derive(Serialize)] +pub struct SecondFactorChallenge { + pub error: &'static str, + pub pending_token: String, + pub methods: Vec<&'static str>, +} + +pub enum LoginOutcome { + Session(AuthResponse), + SecondFactorRequired(SecondFactorChallenge), +} + +impl IntoResponse for LoginOutcome { + fn into_response(self) -> Response { + match self { + LoginOutcome::Session(session) => Json(session).into_response(), + // A status released clients report as a server error, never as a wrong password. + LoginOutcome::SecondFactorRequired(challenge) => { + (StatusCode::PRECONDITION_REQUIRED, Json(challenge)).into_response() + } + } + } +} + pub async fn login( State(pool): State, + axum::Extension(cipher): axum::Extension, Json(body): Json, -) -> Result, StatusCode> { - let user = sqlx::query_as::<_, (Uuid, String, bool, Option)>( - "SELECT id, auth_hash, is_banned, wrapped_user_secrets FROM users WHERE account_id = $1", +) -> Result { + let user = sqlx::query_as::<_, (Uuid, String, bool, Option, bool)>( + "SELECT id, auth_hash, is_banned, wrapped_user_secrets, totp_enabled_at IS NOT NULL FROM users WHERE account_id = $1", ) .bind(body.account_id) .fetch_optional(&pool) @@ -439,7 +505,7 @@ pub async fn login( StatusCode::UNAUTHORIZED })?; - let (user_id, auth_hash, is_banned, wrapped_user_secrets) = user; + let (user_id, auth_hash, is_banned, wrapped_user_secrets, two_factor_enabled) = user; if is_banned { warn!(user_id = %user_id, "Login attempt by banned user"); @@ -456,18 +522,63 @@ pub async fn login( } let tier = fetch_tier(&pool, user_id).await?; - let (jwt_token, refresh_token) = issue_session(user_id, &tier)?; + + if two_factor_enabled { + let pending_token = create_pending_token(user_id, tier.token_version).map_err(|err| { + error!(error = %err, user_id = %user_id, "Failed to create pending token"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + info!(user_id = %user_id, "Login awaiting second factor"); + return Ok(LoginOutcome::SecondFactorRequired(SecondFactorChallenge { + error: "TWO_FACTOR_REQUIRED", + pending_token, + methods: two_factor::methods(&cipher), + })); + } info!(user_id = %user_id, tier = %tier.tier, "User logged in"); - Ok(Json(AuthResponse { - user_id, - jwt_token, - refresh_token, - tier: tier.tier, - trial_ends_at: tier.trial_ends_at, - wrapped_user_secrets, - })) + Ok(LoginOutcome::Session(auth_response(user_id, tier, wrapped_user_secrets)?)) +} + +#[derive(Deserialize)] +pub struct SecondFactorLoginRequest { + pub pending_token: String, + #[serde(flatten)] + pub proof: SecondFactorProof, +} + +pub async fn login_second_factor( + State(pool): State, + axum::Extension(cipher): axum::Extension, + Json(body): Json, +) -> Result, Refusal> { + let claims = validate_token(&body.pending_token, PENDING_SECOND_FACTOR).map_err(|_| { + warn!("Second factor login failed: invalid pending token"); + StatusCode::UNAUTHORIZED + })?; + let user_id = claims.sub; + + let (two_factor_enabled, wrapped_user_secrets): (bool, Option) = + sqlx::query_as("SELECT totp_enabled_at IS NOT NULL, wrapped_user_secrets FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(&pool) + .await + .map_err(two_factor::db_failure)? + .ok_or(StatusCode::UNAUTHORIZED)?; + let tier = fetch_tier(&pool, user_id).await?; + + if tier.is_banned { + return Err(StatusCode::FORBIDDEN.into()); + } + if !two_factor_enabled || claims.ver != tier.token_version { + return Err(StatusCode::UNAUTHORIZED.into()); + } + two_factor::verify(&pool, &cipher, user_id, &body.proof).await?; + + info!(user_id = %user_id, tier = %tier.tier, "User logged in with second factor"); + + Ok(Json(auth_response(user_id, tier, wrapped_user_secrets)?)) } // ─── Refresh ───────────────────────────────────────────────────────────────── @@ -681,6 +792,7 @@ pub struct MeResponse { pub handle_is_custom: bool, pub allow_stranger_invites: bool, pub handle_managed: bool, + pub two_factor_enabled: bool, } pub(crate) async fn fetch_me_inner( @@ -688,8 +800,8 @@ pub(crate) async fn fetch_me_inner( user_id: Uuid, handle_managed: bool, ) -> Result { - let row = sqlx::query_as::<_, (String, String, Uuid, Option, String, bool, bool, bool)>( - "SELECT email, handle AS display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites, email_undeliverable_at IS NOT NULL FROM users WHERE id = $1", + let row = sqlx::query_as::<_, (String, String, Uuid, Option, String, bool, bool, bool, bool)>( + "SELECT email, handle AS display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites, email_undeliverable_at IS NOT NULL, totp_enabled_at IS NOT NULL FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(pool) @@ -714,6 +826,7 @@ pub(crate) async fn fetch_me_inner( handle_is_custom: row.5, allow_stranger_invites: row.6, handle_managed, + two_factor_enabled: row.8, }) } @@ -738,22 +851,7 @@ pub async fn update_email( axum::Extension(auth): axum::Extension, Json(body): Json, ) -> Result { - let auth_hash = sqlx::query_scalar::<_, String>("SELECT auth_hash FROM users WHERE id = $1") - .bind(auth.0) - .fetch_one(&pool) - .await - .map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to fetch auth_hash in update_email"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let valid = verify_auth_key(&body.auth_key, &auth_hash).map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to verify auth key in update_email"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - if !valid { - return Err(StatusCode::UNAUTHORIZED); - } + require_auth_key(&pool, auth.0, &body.auth_key).await?; let new_email = crate::email::normalize(&body.new_email); @@ -800,30 +898,19 @@ pub struct UpdatePasswordRequest { pub old_auth_key: String, pub new_auth_key: String, pub new_wrapped_user_secrets: String, + #[serde(flatten)] + pub proof: SecondFactorProof, } pub async fn update_password( State(pool): State, axum::Extension(auth): axum::Extension, axum::Extension(notifier): axum::Extension, + axum::Extension(cipher): axum::Extension, Json(body): Json, -) -> Result, StatusCode> { - let auth_hash = sqlx::query_scalar::<_, String>("SELECT auth_hash FROM users WHERE id = $1") - .bind(auth.0) - .fetch_one(&pool) - .await - .map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to fetch auth_hash in update_password"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let valid = verify_auth_key(&body.old_auth_key, &auth_hash).map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to verify old auth key in update_password"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - if !valid { - return Err(StatusCode::UNAUTHORIZED); - } +) -> Result, Refusal> { + require_auth_key(&pool, auth.0, &body.old_auth_key).await?; + two_factor::verify_if_enabled(&pool, &cipher, auth.0, &body.proof).await?; let new_auth_hash = hash_auth_key(&body.new_auth_key).map_err(|e| { error!(error = %e, user_id = %auth.0, "Failed to hash new auth key"); @@ -844,42 +931,27 @@ pub async fn update_password( })?; let tier = fetch_tier(&pool, auth.0).await?; - let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; notifier.notify(auth.0, "token_invalidated".to_string()); info!(user_id = %auth.0, "User password updated"); - Ok(Json(AuthResponse { - user_id: auth.0, - jwt_token, - refresh_token, - tier: tier.tier, - trial_ends_at: tier.trial_ends_at, - wrapped_user_secrets: Some(body.new_wrapped_user_secrets), - })) + Ok(Json(auth_response(auth.0, tier, Some(body.new_wrapped_user_secrets))?)) } // ─── Log out everywhere ─────────────────────────────────────────────────────── -#[derive(Serialize)] -pub struct SessionTokens { - pub jwt_token: String, - pub refresh_token: String, -} - pub async fn logout_all( State(pool): State, axum::Extension(auth): axum::Extension, axum::Extension(notifier): axum::Extension, ) -> Result, StatusCode> { bump_token_version(&pool, auth.0).await?; - let tier = fetch_tier(&pool, auth.0).await?; - let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; + let session = fresh_session(&pool, auth.0).await?; notifier.notify(auth.0, "token_invalidated".to_string()); info!(user_id = %auth.0, "User signed out every other session"); - Ok(Json(SessionTokens { jwt_token, refresh_token })) + Ok(Json(session)) } // ─── Upload wrapped user secrets (migration) ────────────────────────────────── @@ -1226,16 +1298,24 @@ mod handler_tests { // ── login: effective tier in the issued session ─────────────────────────── - async fn login_of(pool: &PgPool, account_id: Uuid) -> Result { - login( + async fn login_with(pool: &PgPool, account_id: Uuid, auth_key: &str) -> Result { + let outcome = login( State(pool.clone()), + Extension(SeedCipher::for_tests()), Json(LoginRequest { account_id, - auth_key: "auth-key-secret".to_string(), + auth_key: auth_key.to_string(), }), ) - .await - .map(|Json(r)| r) + .await?; + match outcome { + LoginOutcome::Session(session) => Ok(session), + LoginOutcome::SecondFactorRequired(_) => panic!("account has no second factor"), + } + } + + async fn login_of(pool: &PgPool, account_id: Uuid) -> Result { + login_with(pool, account_id, "auth-key-secret").await } #[tokio::test] @@ -1366,14 +1446,7 @@ mod handler_tests { let account_id = Uuid::new_v4(); seed_user_with_credentials(&pool, account_id, "auth-key-secret").await; - let res = login( - State(pool.clone()), - Json(LoginRequest { - account_id, - auth_key: "wrong-key".to_string(), - }), - ) - .await; + let res = login_with(&pool, account_id, "wrong-key").await; match res { Err(s) => assert_eq!(s, StatusCode::UNAUTHORIZED), @@ -1503,10 +1576,12 @@ mod handler_tests { State(pool.clone()), Extension(AuthUser(uid)), Extension(SyncNotifier::new()), + Extension(SeedCipher::for_tests()), Json(UpdatePasswordRequest { old_auth_key: "auth-key-secret".to_string(), new_auth_key: "new-auth-key".to_string(), new_wrapped_user_secrets: "wrapped".to_string(), + proof: SecondFactorProof::default(), }), ) .await diff --git a/src/routes/meta.rs b/src/routes/meta.rs index 3120bd3..f30d71f 100644 --- a/src/routes/meta.rs +++ b/src/routes/meta.rs @@ -7,7 +7,7 @@ use axum::{Extension, Json}; use serde::Serialize; -use crate::{features::Features, routes::team_objects, self_host}; +use crate::{features::Features, routes::team_objects, self_host, two_factor::SeedCipher}; #[derive(Serialize)] pub struct MetaResponse { @@ -19,9 +19,14 @@ pub struct MetaResponse { pub handles_from_email: bool, /// A client must not upload a type missing from this list; a server that omits the field predates it. pub team_secret_types: Vec<&'static str>, + /// Whether accounts can enrol a second factor here; absent on servers that predate it. + pub two_factor: bool, } -pub async fn get_meta(Extension(features): Extension) -> Json { +pub async fn get_meta( + Extension(features): Extension, + Extension(cipher): Extension, +) -> Json { let self_hosted = self_host::is_self_hosted(); Json(MetaResponse { self_hosted, @@ -31,6 +36,7 @@ pub async fn get_meta(Extension(features): Extension) -> Json Response { coded_error(StatusCode::UNPROCESSABLE_ENTITY, "EMAIL_UNDELIVERABLE") } -fn coded_error(status: StatusCode, code: &'static str) -> Response { +pub(crate) fn coded_error(status: StatusCode, code: &'static str) -> Response { (status, Json(serde_json::json!({ "error": code }))).into_response() } diff --git a/src/routes/two_factor.rs b/src/routes/two_factor.rs new file mode 100644 index 0000000..5bb8737 --- /dev/null +++ b/src/routes/two_factor.rs @@ -0,0 +1,568 @@ +use axum::{extract::State, Extension, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tracing::info; + +use crate::auth::AuthUser; +use crate::routes::auth::{fresh_session, require_auth_key, SessionTokens}; +use crate::sync_notifier::SyncNotifier; +use crate::two_factor::{self, db_failure, Refusal, SecondFactorProof, SeedCipher}; + +#[derive(Deserialize)] +pub struct SetupRequest { + pub auth_key: String, +} + +#[derive(Serialize)] +pub struct SetupResponse { + pub secret: String, + pub otpauth_uri: String, +} + +pub async fn setup( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Json(body): Json, +) -> Result, Refusal> { + let seed = two_factor::generate_seed(); + let sealed = cipher.seal(&seed).ok_or(Refusal::Unavailable)?; + require_auth_key(&pool, auth.0, &body.auth_key).await?; + + let email: Option = sqlx::query_scalar( + "UPDATE users SET totp_secret = $1, totp_last_step = NULL + WHERE id = $2 AND totp_enabled_at IS NULL RETURNING email", + ) + .bind(&sealed) + .bind(auth.0) + .fetch_optional(&pool) + .await + .map_err(db_failure)?; + let email = email.ok_or(Refusal::AlreadyEnabled)?; + + Ok(Json(SetupResponse { + secret: two_factor::seed_base32(&seed), + otpauth_uri: two_factor::otpauth_uri(&seed, &email), + })) +} + +#[derive(Serialize)] +pub struct EnableResponse { + pub recovery_codes: Vec, + #[serde(flatten)] + pub session: SessionTokens, +} + +pub async fn enable( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Extension(notifier): Extension, + Json(proof): Json, +) -> Result, Refusal> { + if two_factor::is_enabled(&pool, auth.0).await? { + return Err(Refusal::AlreadyEnabled); + } + let code_only = SecondFactorProof { code: proof.code, recovery_code: None }; + two_factor::verify(&pool, &cipher, auth.0, &code_only).await?; + + let mut tx = pool.begin().await.map_err(db_failure)?; + let enabled = sqlx::query( + "UPDATE users SET totp_enabled_at = now(), token_version = token_version + 1 + WHERE id = $1 AND totp_enabled_at IS NULL AND totp_secret IS NOT NULL", + ) + .bind(auth.0) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + if enabled.rows_affected() == 0 { + return Err(Refusal::AlreadyEnabled); + } + let recovery_codes = two_factor::replace_recovery_codes(&mut tx, auth.0).await.map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + + let session = fresh_session(&pool, auth.0).await?; + notifier.notify(auth.0, "token_invalidated".to_string()); + info!(user_id = %auth.0, "Two-factor authentication enabled"); + + Ok(Json(EnableResponse { recovery_codes, session })) +} + +#[derive(Deserialize)] +pub struct DisableRequest { + pub auth_key: String, + #[serde(flatten)] + pub proof: SecondFactorProof, +} + +pub async fn disable( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Extension(notifier): Extension, + Json(body): Json, +) -> Result, Refusal> { + if !two_factor::is_enabled(&pool, auth.0).await? { + return Err(Refusal::NotEnabled); + } + require_auth_key(&pool, auth.0, &body.auth_key).await?; + two_factor::verify(&pool, &cipher, auth.0, &body.proof).await?; + + let mut tx = pool.begin().await.map_err(db_failure)?; + two_factor::clear(&mut tx, auth.0).await.map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + + let session = fresh_session(&pool, auth.0).await?; + notifier.notify(auth.0, "token_invalidated".to_string()); + info!(user_id = %auth.0, "Two-factor authentication disabled"); + + Ok(Json(session)) +} + +#[derive(Serialize)] +pub struct RecoveryCodesResponse { + pub recovery_codes: Vec, +} + +pub async fn regenerate_recovery_codes( + State(pool): State, + Extension(auth): Extension, + Extension(cipher): Extension, + Json(proof): Json, +) -> Result, Refusal> { + if !two_factor::is_enabled(&pool, auth.0).await? { + return Err(Refusal::NotEnabled); + } + two_factor::verify(&pool, &cipher, auth.0, &proof).await?; + + let mut tx = pool.begin().await.map_err(db_failure)?; + let recovery_codes = two_factor::replace_recovery_codes(&mut tx, auth.0).await.map_err(db_failure)?; + tx.commit().await.map_err(db_failure)?; + info!(user_id = %auth.0, "Recovery codes regenerated"); + + Ok(Json(RecoveryCodesResponse { recovery_codes })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::jwt::{create_refresh_token, validate_token}; + use crate::routes::auth::{ + login, login_second_factor, refresh, update_password, LoginOutcome, LoginRequest, RefreshRequest, + SecondFactorChallenge, SecondFactorLoginRequest, UpdatePasswordRequest, + }; + use crate::test_pool_or_skip; + use crate::test_support::{env_lock, seed_user_with_credentials, EnvLockGuard}; + use axum::http::StatusCode; + use uuid::Uuid; + + const AUTH_KEY: &str = "auth-key-secret"; + + struct Enrolled { + uid: Uuid, + account_id: Uuid, + seed: Vec, + recovery_codes: Vec, + session: SessionTokens, + } + + fn jwt_env() -> EnvLockGuard { + let guard = env_lock(); + std::env::set_var("JWT_SECRET", "ci-test-secret"); + EnvLockGuard(guard) + } + + fn code(value: &str) -> SecondFactorProof { + SecondFactorProof { code: Some(value.to_string()), recovery_code: None } + } + + fn recovery(value: &str) -> SecondFactorProof { + SecondFactorProof { code: None, recovery_code: Some(value.to_string()) } + } + + /// A code the server has not seen yet: replay protection would refuse the one used a moment ago. + async fn unused_code(pool: &PgPool, user: &Enrolled) -> String { + sqlx::query("UPDATE users SET totp_last_step = NULL WHERE id = $1") + .bind(user.uid) + .execute(pool) + .await + .unwrap(); + two_factor::totp_at(&user.seed, two_factor::current_step()) + } + + async fn begin_setup(pool: &PgPool, cipher: &SeedCipher, uid: Uuid) -> Result, Refusal> { + let Json(pending) = setup( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(cipher.clone()), + Json(SetupRequest { auth_key: AUTH_KEY.to_string() }), + ) + .await?; + assert!(pending.otpauth_uri.contains(&pending.secret)); + Ok(data_encoding::BASE32_NOPAD.decode(pending.secret.as_bytes()).unwrap()) + } + + async fn confirm(pool: &PgPool, uid: Uuid, proof: SecondFactorProof) -> Result { + enable( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SeedCipher::for_tests()), + Extension(SyncNotifier::new()), + Json(proof), + ) + .await + .map(|Json(r)| r) + } + + async fn enrol(pool: &PgPool) -> Enrolled { + let account_id = Uuid::new_v4(); + let uid = seed_user_with_credentials(pool, account_id, AUTH_KEY).await; + let seed = begin_setup(pool, &SeedCipher::for_tests(), uid).await.expect("setup ok"); + let enabled = confirm(pool, uid, code(&two_factor::totp_at(&seed, two_factor::current_step()))) + .await + .expect("enable ok"); + Enrolled { uid, account_id, seed, recovery_codes: enabled.recovery_codes, session: enabled.session } + } + + async fn challenge_with(pool: &PgPool, cipher: SeedCipher, account_id: Uuid) -> SecondFactorChallenge { + let outcome = login( + State(pool.clone()), + Extension(cipher), + Json(LoginRequest { account_id, auth_key: AUTH_KEY.to_string() }), + ) + .await + .expect("password accepted"); + match outcome { + LoginOutcome::SecondFactorRequired(challenge) => challenge, + LoginOutcome::Session(_) => panic!("a session was issued without the second factor"), + } + } + + async fn finish_with( + pool: &PgPool, + cipher: SeedCipher, + pending_token: &str, + proof: SecondFactorProof, + ) -> Result { + login_second_factor( + State(pool.clone()), + Extension(cipher), + Json(SecondFactorLoginRequest { pending_token: pending_token.to_string(), proof }), + ) + .await + .map(|Json(r)| r) + } + + async fn finish( + pool: &PgPool, + pending_token: &str, + proof: SecondFactorProof, + ) -> Result { + finish_with(pool, SeedCipher::for_tests(), pending_token, proof).await + } + + async fn refreshes(pool: &PgPool, refresh_token: &str) -> bool { + refresh(State(pool.clone()), Json(RefreshRequest { refresh_token: refresh_token.to_string() })) + .await + .is_ok() + } + + #[tokio::test] + async fn sign_in_stops_at_the_password_until_a_code_arrives() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + assert_eq!(challenge.error, "TWO_FACTOR_REQUIRED"); + assert_eq!(challenge.methods, ["totp", "recovery"]); + let body = serde_json::to_value(&challenge).unwrap(); + assert!(body.get("jwt_token").is_none() && body.get("wrapped_user_secrets").is_none()); + assert!(validate_token(&challenge.pending_token, "access").is_err()); + assert!(!refreshes(&pool, &challenge.pending_token).await); + + assert_eq!(finish(&pool, &challenge.pending_token, code("000000")).await.err(), Some(Refusal::Invalid)); + assert_eq!( + finish(&pool, &challenge.pending_token, SecondFactorProof::default()).await.err(), + Some(Refusal::Required) + ); + + let good = unused_code(&pool, &user).await; + let session = finish(&pool, &challenge.pending_token, code(&good)).await.expect("code accepted"); + assert_eq!(session.user_id, user.uid); + assert!(refreshes(&pool, &session.refresh_token).await); + + assert_eq!( + finish(&pool, &challenge.pending_token, code(&good)).await.err(), + Some(Refusal::Invalid), + "a code must not open a second session" + ); + } + + #[tokio::test] + async fn a_recovery_code_signs_in_once() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + let typed = user.recovery_codes[0].replace('-', " ").to_uppercase(); + + assert!(finish(&pool, &challenge.pending_token, recovery(&typed)).await.is_ok()); + assert_eq!( + finish(&pool, &challenge.pending_token, recovery(&user.recovery_codes[0])).await.err(), + Some(Refusal::Invalid) + ); + assert!(finish(&pool, &challenge.pending_token, recovery(&user.recovery_codes[1])).await.is_ok()); + } + + #[tokio::test] + async fn five_wrong_codes_lock_the_second_step() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + + for _ in 0..5 { + assert_eq!( + finish(&pool, &challenge.pending_token, code("000000")).await.err(), + Some(Refusal::Invalid) + ); + } + let good = unused_code(&pool, &user).await; + assert_eq!(finish(&pool, &challenge.pending_token, code(&good)).await.err(), Some(Refusal::Locked)); + assert_eq!( + finish(&pool, &challenge.pending_token, recovery(&user.recovery_codes[0])).await.err(), + Some(Refusal::Locked) + ); + + sqlx::query("UPDATE users SET totp_locked_until = now() - interval '1 second' WHERE id = $1") + .bind(user.uid) + .execute(&pool) + .await + .unwrap(); + assert!(finish(&pool, &challenge.pending_token, code(&good)).await.is_ok()); + } + + #[tokio::test] + async fn enabling_needs_the_first_code_and_ends_earlier_sessions() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let uid = seed_user_with_credentials(&pool, Uuid::new_v4(), AUTH_KEY).await; + let before = create_refresh_token(uid, 0).unwrap(); + let seed = begin_setup(&pool, &SeedCipher::for_tests(), uid).await.expect("setup ok"); + + assert_eq!(confirm(&pool, uid, code("000000")).await.err(), Some(Refusal::Invalid)); + assert_eq!( + confirm(&pool, uid, recovery("anything")).await.err(), + Some(Refusal::Required), + "only a code from the new seed proves the authenticator works" + ); + assert!(refreshes(&pool, &before).await, "a failed attempt changes nothing"); + + let enabled = confirm(&pool, uid, code(&two_factor::totp_at(&seed, two_factor::current_step()))) + .await + .expect("enable ok"); + assert_eq!(enabled.recovery_codes.len(), two_factor::RECOVERY_CODE_COUNT); + assert!(!refreshes(&pool, &before).await); + assert!(refreshes(&pool, &enabled.session.refresh_token).await); + + assert_eq!( + begin_setup(&pool, &SeedCipher::for_tests(), uid).await.err(), + Some(Refusal::AlreadyEnabled) + ); + assert_eq!(confirm(&pool, uid, code("000000")).await.err(), Some(Refusal::AlreadyEnabled)); + } + + #[tokio::test] + async fn setup_needs_the_password_and_the_seed_key() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let uid = seed_user_with_credentials(&pool, Uuid::new_v4(), "another-key").await; + + assert_eq!( + begin_setup(&pool, &SeedCipher::for_tests(), uid).await.err(), + Some(Refusal::Status(StatusCode::UNAUTHORIZED)) + ); + assert_eq!(begin_setup(&pool, &SeedCipher::absent(), uid).await.err(), Some(Refusal::Unavailable)); + } + + #[tokio::test] + async fn disabling_needs_password_and_code_then_sign_in_is_plain_again() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let turn_off = |auth_key: &'static str, proof: SecondFactorProof| { + disable( + State(pool.clone()), + Extension(AuthUser(user.uid)), + Extension(SeedCipher::for_tests()), + Extension(SyncNotifier::new()), + Json(DisableRequest { auth_key: auth_key.to_string(), proof }), + ) + }; + + let good = unused_code(&pool, &user).await; + assert_eq!( + turn_off("wrong-key", code(&good)).await.err(), + Some(Refusal::Status(StatusCode::UNAUTHORIZED)) + ); + assert_eq!(turn_off(AUTH_KEY, code("000000")).await.err(), Some(Refusal::Invalid)); + + let Json(session) = turn_off(AUTH_KEY, code(&good)).await.expect("disable ok"); + assert!(!refreshes(&pool, &user.session.refresh_token).await); + assert!(refreshes(&pool, &session.refresh_token).await); + assert_eq!(turn_off(AUTH_KEY, code(&good)).await.err(), Some(Refusal::NotEnabled)); + + let outcome = login( + State(pool.clone()), + Extension(SeedCipher::for_tests()), + Json(LoginRequest { account_id: user.account_id, auth_key: AUTH_KEY.to_string() }), + ) + .await + .expect("login ok"); + assert!(matches!(outcome, LoginOutcome::Session(_))); + let leftover: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_recovery_codes WHERE user_id = $1") + .bind(user.uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(leftover, 0); + } + + #[tokio::test] + async fn regenerating_recovery_codes_retires_the_old_set() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let regenerate = |proof: SecondFactorProof| { + regenerate_recovery_codes( + State(pool.clone()), + Extension(AuthUser(user.uid)), + Extension(SeedCipher::for_tests()), + Json(proof), + ) + }; + + assert_eq!(regenerate(code("000000")).await.err(), Some(Refusal::Invalid)); + let good = unused_code(&pool, &user).await; + let Json(fresh) = regenerate(code(&good)).await.expect("regenerate ok"); + + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + assert_eq!( + finish(&pool, &challenge.pending_token, recovery(&user.recovery_codes[0])).await.err(), + Some(Refusal::Invalid) + ); + assert!(finish(&pool, &challenge.pending_token, recovery(&fresh.recovery_codes[0])).await.is_ok()); + } + + #[tokio::test] + async fn a_server_without_the_seed_key_refuses_codes_but_never_skips_the_step() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + + let challenge = challenge_with(&pool, SeedCipher::absent(), user.account_id).await; + assert_eq!(challenge.methods, ["recovery"]); + + let good = unused_code(&pool, &user).await; + assert_eq!( + finish_with(&pool, SeedCipher::absent(), &challenge.pending_token, code(&good)).await.err(), + Some(Refusal::Unavailable) + ); + assert!(finish_with( + &pool, + SeedCipher::absent(), + &challenge.pending_token, + recovery(&user.recovery_codes[0]) + ) + .await + .is_ok()); + } + + #[tokio::test] + async fn a_pending_token_dies_with_the_token_version() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let challenge = challenge_with(&pool, SeedCipher::for_tests(), user.account_id).await; + + sqlx::query("UPDATE users SET token_version = token_version + 1 WHERE id = $1") + .bind(user.uid) + .execute(&pool) + .await + .unwrap(); + + let good = unused_code(&pool, &user).await; + assert_eq!( + finish(&pool, &challenge.pending_token, code(&good)).await.err(), + Some(Refusal::Status(StatusCode::UNAUTHORIZED)) + ); + assert_eq!( + finish(&pool, "not-a-token", code(&good)).await.err(), + Some(Refusal::Status(StatusCode::UNAUTHORIZED)) + ); + } + + #[tokio::test] + async fn changing_the_password_takes_a_code_once_two_factor_is_on() { + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let change = |proof: SecondFactorProof| { + update_password( + State(pool.clone()), + Extension(AuthUser(user.uid)), + Extension(SyncNotifier::new()), + Extension(SeedCipher::for_tests()), + Json(UpdatePasswordRequest { + old_auth_key: AUTH_KEY.to_string(), + new_auth_key: "new-auth-key".to_string(), + new_wrapped_user_secrets: "wrapped".to_string(), + proof, + }), + ) + }; + + assert_eq!(change(SecondFactorProof::default()).await.err(), Some(Refusal::Required)); + let good = unused_code(&pool, &user).await; + assert!(change(code(&good)).await.is_ok()); + } + + #[tokio::test] + async fn an_admin_clear_lets_the_password_alone_sign_in_and_is_audited() { + use crate::auth::AdminEmail; + use axum::extract::Path; + let _env = jwt_env(); + let pool = test_pool_or_skip!(); + let user = enrol(&pool).await; + let clear = || { + crate::routes::admin::clear_two_factor( + State(pool.clone()), + Extension(AdminEmail("admin@test.local".to_string())), + Extension(SyncNotifier::new()), + Path(user.uid), + ) + }; + + assert_eq!(clear().await, Ok(StatusCode::NO_CONTENT)); + assert_eq!(clear().await, Err(StatusCode::NOT_FOUND)); + + assert!(!refreshes(&pool, &user.session.refresh_token).await); + let outcome = login( + State(pool.clone()), + Extension(SeedCipher::for_tests()), + Json(LoginRequest { account_id: user.account_id, auth_key: AUTH_KEY.to_string() }), + ) + .await + .expect("login ok"); + assert!(matches!(outcome, LoginOutcome::Session(_))); + let audited: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM admin_audit_log WHERE target_id = $1 AND action = 'clear_two_factor'", + ) + .bind(user.uid) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(audited, 1); + } +} diff --git a/src/two_factor.rs b/src/two_factor.rs new file mode 100644 index 0000000..5a960a1 --- /dev/null +++ b/src/two_factor.rs @@ -0,0 +1,413 @@ +//! Optional TOTP second factor: seed encryption, code checks, recovery codes. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit, OsRng}, + XChaCha20Poly1305, XNonce, +}; +use chrono::Utc; +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::Deserialize; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::routes::coded_error; + +const STEP_SECONDS: i64 = 30; +const NONCE_LEN: usize = 24; +const MAX_FAILED_ATTEMPTS: i32 = 5; +const LOCK_MINUTES: i32 = 15; +pub const RECOVERY_CODE_COUNT: usize = 10; +const RECOVERY_ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + +/// Seals TOTP seeds with `TOTP_ENC_KEY`. Without the key the server offers no enrolment. +#[derive(Clone)] +pub struct SeedCipher(Option>); + +impl SeedCipher { + pub fn from_env() -> Self { + Self::parse(std::env::var("TOTP_ENC_KEY").ok().as_deref()) + .unwrap_or_else(|why| panic!("TOTP_ENC_KEY {why} (expected 32 bytes, base64)")) + } + + fn parse(value: Option<&str>) -> Result { + let Some(encoded) = value.map(str::trim).filter(|v| !v.is_empty()) else { + return Ok(Self(None)); + }; + let key = B64.decode(encoded).map_err(|_| "is not valid base64")?; + let cipher = XChaCha20Poly1305::new_from_slice(&key).map_err(|_| "is not 32 bytes long")?; + Ok(Self(Some(Arc::new(cipher)))) + } + + pub fn available(&self) -> bool { + self.0.is_some() + } + + pub fn seal(&self, seed: &[u8]) -> Option { + let cipher = self.0.as_ref()?; + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let mut sealed = nonce.to_vec(); + sealed.extend(cipher.encrypt(&nonce, seed).ok()?); + Some(B64.encode(sealed)) + } + + fn open(&self, sealed: &str) -> Option> { + let cipher = self.0.as_ref()?; + let bytes = B64.decode(sealed).ok()?; + if bytes.len() <= NONCE_LEN { + return None; + } + let (nonce, ciphertext) = bytes.split_at(NONCE_LEN); + cipher.decrypt(XNonce::from_slice(nonce), ciphertext).ok() + } + + #[cfg(test)] + pub fn for_tests() -> Self { + Self::parse(Some(&B64.encode([7u8; 32]))).unwrap() + } + + #[cfg(test)] + pub fn absent() -> Self { + Self(None) + } +} + +pub fn generate_seed() -> [u8; 20] { + let mut seed = [0u8; 20]; + rand::rngs::OsRng.fill_bytes(&mut seed); + seed +} + +pub fn seed_base32(seed: &[u8]) -> String { + BASE32_NOPAD.encode(seed) +} + +pub fn otpauth_uri(seed: &[u8], email: &str) -> String { + let label: String = email + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(), + other => format!("%{other:02X}"), + }) + .collect(); + format!( + "otpauth://totp/Voltius:{label}?secret={}&issuer=Voltius&algorithm=SHA1&digits=6&period={STEP_SECONDS}", + seed_base32(seed) + ) +} + +pub fn current_step() -> i64 { + Utc::now().timestamp() / STEP_SECONDS +} + +pub fn totp_at(seed: &[u8], step: i64) -> String { + let mut mac = as Mac>::new_from_slice(seed).expect("hmac accepts any key length"); + mac.update(&(step as u64).to_be_bytes()); + let digest = mac.finalize().into_bytes(); + let offset = (digest[19] & 0x0f) as usize; + let binary = u32::from_be_bytes([digest[offset], digest[offset + 1], digest[offset + 2], digest[offset + 3]]) + & 0x7fff_ffff; + format!("{:06}", binary % 1_000_000) +} + +fn same_bytes(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +fn matching_step(seed: &[u8], code: &str, now_step: i64) -> Option { + let code: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + (now_step - 1..=now_step + 1).find(|step| same_bytes(totp_at(seed, *step).as_bytes(), code.as_bytes())) +} + +pub fn generate_recovery_codes() -> Vec { + let mut rng = rand::rngs::OsRng; + (0..RECOVERY_CODE_COUNT) + .map(|_| { + let chars: Vec = (0..16) + .map(|_| RECOVERY_ALPHABET[(rng.next_u32() as usize) % RECOVERY_ALPHABET.len()] as char) + .collect(); + chars.chunks(4).map(|group| group.iter().collect::()).collect::>().join("-") + }) + .collect() +} + +fn hash_recovery_code(code: &str) -> String { + let normalized: String = code + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + hex::encode(Sha256::digest(normalized.as_bytes())) +} + +#[derive(Deserialize, Default)] +pub struct SecondFactorProof { + #[serde(default)] + pub code: Option, + #[serde(default)] + pub recovery_code: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Refusal { + Required, + Invalid, + Locked, + Unavailable, + AlreadyEnabled, + NotEnabled, + Status(StatusCode), +} + +impl From for Refusal { + fn from(status: StatusCode) -> Self { + Refusal::Status(status) + } +} + +impl IntoResponse for Refusal { + fn into_response(self) -> Response { + match self { + Refusal::Required => coded_error(StatusCode::PRECONDITION_REQUIRED, "TWO_FACTOR_REQUIRED"), + Refusal::Invalid => coded_error(StatusCode::UNAUTHORIZED, "TWO_FACTOR_INVALID"), + Refusal::Locked => coded_error(StatusCode::TOO_MANY_REQUESTS, "TWO_FACTOR_LOCKED"), + Refusal::Unavailable => coded_error(StatusCode::SERVICE_UNAVAILABLE, "TWO_FACTOR_UNAVAILABLE"), + Refusal::AlreadyEnabled => coded_error(StatusCode::CONFLICT, "TWO_FACTOR_ALREADY_ENABLED"), + Refusal::NotEnabled => coded_error(StatusCode::CONFLICT, "TWO_FACTOR_NOT_ENABLED"), + Refusal::Status(status) => status.into_response(), + } + } +} + +pub fn db_failure(err: sqlx::Error) -> Refusal { + error!(error = %err, "Two-factor query failed"); + Refusal::Status(StatusCode::INTERNAL_SERVER_ERROR) +} + +/// What a sign-in challenge may offer: a code needs the seed key, a recovery code never does. +pub fn methods(cipher: &SeedCipher) -> Vec<&'static str> { + if cipher.available() { + vec!["totp", "recovery"] + } else { + vec!["recovery"] + } +} + +pub async fn is_enabled(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_scalar("SELECT totp_enabled_at IS NOT NULL FROM users WHERE id = $1") + .bind(user_id) + .fetch_one(pool) + .await + .map_err(db_failure) +} + +/// The step every sensitive change takes once the account has a second factor. +pub async fn verify_if_enabled( + pool: &PgPool, + cipher: &SeedCipher, + user_id: Uuid, + proof: &SecondFactorProof, +) -> Result<(), Refusal> { + if is_enabled(pool, user_id).await? { + verify(pool, cipher, user_id, proof).await?; + } + Ok(()) +} + +pub async fn verify( + pool: &PgPool, + cipher: &SeedCipher, + user_id: Uuid, + proof: &SecondFactorProof, +) -> Result<(), Refusal> { + verify_at(pool, cipher, user_id, proof, current_step()).await +} + +/// Checks the stored seed whether or not it is enabled yet, so enrolment confirms through the same path. +pub async fn verify_at( + pool: &PgPool, + cipher: &SeedCipher, + user_id: Uuid, + proof: &SecondFactorProof, + now_step: i64, +) -> Result<(), Refusal> { + // A client that predates the feature sends nothing: that is not a wrong guess. + if proof.code.is_none() && proof.recovery_code.is_none() { + return Err(Refusal::Required); + } + let mut tx = pool.begin().await.map_err(db_failure)?; + let (sealed, last_step, locked): (Option, Option, bool) = sqlx::query_as( + "SELECT totp_secret, totp_last_step, COALESCE(totp_locked_until > now(), FALSE) + FROM users WHERE id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await + .map_err(db_failure)?; + + if locked { + return Err(Refusal::Locked); + } + + let accepted_step = if let Some(recovery_code) = &proof.recovery_code { + let spent = sqlx::query( + "UPDATE user_recovery_codes SET used_at = now() + WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL", + ) + .bind(user_id) + .bind(hash_recovery_code(recovery_code)) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + (spent.rows_affected() == 1).then_some(last_step) + } else if let Some(code) = &proof.code { + match sealed { + Some(sealed) => { + let seed = cipher.open(&sealed).ok_or(Refusal::Unavailable)?; + matching_step(&seed, code, now_step) + .filter(|step| last_step.is_none_or(|last| *step > last)) + .map(Some) + } + None => None, + } + } else { + None + }; + + let outcome = match accepted_step { + Some(step) => { + sqlx::query("UPDATE users SET totp_failed_attempts = 0, totp_last_step = $2 WHERE id = $1") + .bind(user_id) + .bind(step) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + Ok(()) + } + None => { + sqlx::query( + "UPDATE users SET + totp_locked_until = CASE WHEN totp_failed_attempts + 1 >= $2 + THEN now() + make_interval(mins => $3) ELSE totp_locked_until END, + totp_failed_attempts = CASE WHEN totp_failed_attempts + 1 >= $2 + THEN 0 ELSE totp_failed_attempts + 1 END + WHERE id = $1", + ) + .bind(user_id) + .bind(MAX_FAILED_ATTEMPTS) + .bind(LOCK_MINUTES) + .execute(&mut *tx) + .await + .map_err(db_failure)?; + warn!(user_id = %user_id, "Second factor rejected"); + Err(Refusal::Invalid) + } + }; + tx.commit().await.map_err(db_failure)?; + outcome +} + +pub async fn replace_recovery_codes( + tx: &mut sqlx::PgConnection, + user_id: Uuid, +) -> Result, sqlx::Error> { + let codes = generate_recovery_codes(); + sqlx::query("DELETE FROM user_recovery_codes WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + let hashes: Vec = codes.iter().map(|code| hash_recovery_code(code)).collect(); + sqlx::query("INSERT INTO user_recovery_codes (user_id, code_hash) SELECT $1, unnest($2::text[])") + .bind(user_id) + .bind(&hashes) + .execute(&mut *tx) + .await?; + Ok(codes) +} + +pub async fn clear(tx: &mut sqlx::PgConnection, user_id: Uuid) -> Result { + let cleared = sqlx::query( + "UPDATE users SET totp_secret = NULL, totp_enabled_at = NULL, totp_last_step = NULL, + totp_failed_attempts = 0, totp_locked_until = NULL, token_version = token_version + 1 + WHERE id = $1 AND totp_enabled_at IS NOT NULL", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM user_recovery_codes WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(cleared.rows_affected() == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + // RFC 6238 appendix B, SHA-1 rows, truncated to six digits. + #[test] + fn totp_matches_the_rfc_vectors() { + let seed = b"12345678901234567890"; + for (time, code) in [(59, "287082"), (1_111_111_109, "081804"), (1_234_567_890, "005924"), (20_000_000_000, "353130")] { + assert_eq!(totp_at(seed, time / STEP_SECONDS), code); + } + } + + #[test] + fn a_code_matches_only_within_one_step() { + let seed = generate_seed(); + for (offset, accepted) in [(-2, false), (-1, true), (0, true), (1, true), (2, false)] { + let code = totp_at(&seed, 1000 + offset); + assert_eq!(matching_step(&seed, &code, 1000).is_some(), accepted, "offset {offset}"); + } + assert_eq!(matching_step(&seed, &format!(" {} ", totp_at(&seed, 1000)), 1000), Some(1000)); + } + + #[test] + fn a_sealed_seed_opens_only_under_its_key() { + let cipher = SeedCipher::for_tests(); + let seed = generate_seed(); + let sealed = cipher.seal(&seed).unwrap(); + + assert_eq!(cipher.open(&sealed).as_deref(), Some(&seed[..])); + assert_ne!(cipher.seal(&seed).unwrap(), sealed); + let other = SeedCipher::parse(Some(&B64.encode([8u8; 32]))).unwrap(); + assert_eq!(other.open(&sealed), None); + assert_eq!(SeedCipher::absent().seal(&seed), None); + } + + #[test] + fn a_malformed_key_is_refused_and_an_empty_one_means_off() { + assert!(!SeedCipher::parse(None).unwrap().available()); + assert!(!SeedCipher::parse(Some(" ")).unwrap().available()); + assert!(SeedCipher::parse(Some("not base64!")).is_err()); + assert!(SeedCipher::parse(Some(&B64.encode([1u8; 16]))).is_err()); + } + + #[test] + fn recovery_codes_hash_the_same_however_they_are_typed() { + let codes = generate_recovery_codes(); + assert_eq!(codes.len(), RECOVERY_CODE_COUNT); + let code = &codes[0]; + assert_eq!(code.len(), 19); + assert_eq!(hash_recovery_code(code), hash_recovery_code(&code.replace('-', " ").to_uppercase())); + assert_ne!(hash_recovery_code(code), hash_recovery_code(&codes[1])); + } + + #[test] + fn the_otpauth_uri_escapes_the_address() { + let uri = otpauth_uri(b"12345678901234567890", "a+b@example.com"); + assert!(uri.starts_with("otpauth://totp/Voltius:a%2Bb%40example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Voltius")); + } +}