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")); + } +}