diff --git a/Cargo.lock b/Cargo.lock index 5a12a8a..c97cf0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.21.7" @@ -1199,6 +1205,7 @@ name = "warden-worker" version = "0.2.0" dependencies = [ "axum", + "base32", "base64 0.21.7", "chrono", "console_error_panic_hook", diff --git a/Cargo.toml b/Cargo.toml index 75c3786..74ce7d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ serde_json = "1.0" # Crypto & Encoding jsonwebtoken = "9.2" base64 = "0.21" +base32 = "0.5" hex = "0.4" rand = { version = "0.8" } getrandom = { version = "0.3", features = ["wasm_js"] } diff --git a/README.md b/README.md index 9cf3636..d28e04c 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,14 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem ## Current Status -**This project is not yet feature-complete**, ~~and it may never be~~. It currently supports the core functionality of a personal vault, including TOTP (storage and generation, not login). However, it does **not** support the following features: +**This project is not yet feature-complete**, ~~and it may never be~~. It currently supports the core functionality of a personal vault, including TOTP. However, it does **not** support the following features: * Sharing -* 2FA login +* 2FA login (except TOTP) * Bitwarden Send * Device and session management * Emergency access -* Admin operation +* Admin operations * Organizations * Other Bitwarden advanced features @@ -309,9 +309,15 @@ You can configure the following environment variables in `wrangler.toml` under t Controls whether the "Create Account" / registration button is displayed in the Bitwarden client UI. * Set to `false` to show the registration button - * Defaults to `true` (hide registration button) * **Note:** This setting only affects the client UI display. It does NOT affect the actual registration functionality on the server side. +* **`AUTHENTICATOR_DISABLE_TIME_DRIFT`** (Optional, Default: `false`) + + Controls whether the default ±1 time step drift for TOTP validation is disabled. + + * Set to `true` to DISABLE the time step drift + * **Note:** This setting only affects the TOTP validation. It does NOT affect the actual TOTP generation. + ### Scheduled Tasks (Cron) The worker includes a scheduled task that runs automatically to clean up soft-deleted items. By default, this task runs daily at 03:00 UTC. diff --git a/migrations/0003_add_twofactor.sql b/migrations/0003_add_twofactor.sql new file mode 100644 index 0000000..3bf9bb8 --- /dev/null +++ b/migrations/0003_add_twofactor.sql @@ -0,0 +1,22 @@ +-- Migration: Add totp_recover column and twofactor table +-- Adds recovery code storage and twofactor providers for TOTP/remember tokens. +-- +-- TwoFactor types: +-- 0 = Authenticator (TOTP) +-- 1 = Email +-- 5 = Remember (device trust token) +-- 8 = RecoveryCode + +CREATE TABLE IF NOT EXISTS twofactor ( + uuid TEXT PRIMARY KEY NOT NULL, + user_uuid TEXT NOT NULL, + atype INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + data TEXT NOT NULL, + last_used INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (user_uuid) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_uuid, atype) +); + +-- Add totp_recover column to users table for recovery codes +ALTER TABLE users ADD COLUMN totp_recover TEXT; diff --git a/sql/schema.sql b/sql/schema.sql index 60231d1..de7abcd 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS users ( kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2 kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2 security_stamp TEXT, + totp_recover TEXT, -- Recovery code for 2FA created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -35,6 +36,19 @@ CREATE TABLE IF NOT EXISTS ciphers ( FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL ); +-- TwoFactor table for two-factor authentication +-- Types: 0=Authenticator(TOTP), 1=Email, 5=Remember, 8=RecoveryCode +CREATE TABLE IF NOT EXISTS twofactor ( + uuid TEXT PRIMARY KEY NOT NULL, + user_uuid TEXT NOT NULL, + atype INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + data TEXT NOT NULL, -- JSON data specific to the 2FA type (e.g., TOTP secret) + last_used INTEGER NOT NULL DEFAULT 0, -- Unix timestamp or TOTP time step + FOREIGN KEY (user_uuid) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_uuid, atype) +); + -- Folders table for organizing ciphers CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY NOT NULL, diff --git a/src/auth.rs b/src/auth.rs index 77458ab..00d274c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -22,6 +22,9 @@ pub struct Claims { pub amr: Vec, } +/// AuthUser extractor - provides (user_id, email) tuple +pub struct AuthUser(pub String, pub String); + impl FromRequestParts> for Claims { type Rejection = AppError; @@ -51,3 +54,13 @@ impl FromRequestParts> for Claims Ok(token_data.claims) } } + +impl FromRequestParts> for AuthUser +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &Arc) -> Result { + let claims = Claims::from_request_parts(parts, state).await?; + Ok(AuthUser(claims.sub, claims.email)) + } +} diff --git a/src/crypto.rs b/src/crypto.rs index d73b438..9731e1e 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -128,3 +128,172 @@ pub async fn verify_password( stored_hash.as_bytes(), )) } + +// ============================================================================ +// TOTP Implementation using Web Crypto API +// ============================================================================ + +/// Decodes a Base32 encoded string into bytes. +/// Handles both uppercase and lowercase input. +pub fn base32_decode(input: &str) -> Result, AppError> { + base32::decode(base32::Alphabet::Rfc4648 { padding: true }, input) + .or_else(|| base32::decode(base32::Alphabet::Rfc4648 { padding: false }, input)) + .ok_or_else(|| AppError::Crypto("Invalid Base32 input".to_string())) +} + +/// Encodes bytes into a Base32 string (RFC 4648, uppercase). +pub fn base32_encode(data: &[u8]) -> String { + base32::encode(base32::Alphabet::Rfc4648 { padding: true }, data) +} + +/// Generates a random TOTP secret (20 bytes = 160 bits). +/// Returns the Base32 encoded secret. +pub fn generate_totp_secret() -> Result { + let crypto = get_crypto()?; + let secret = Uint8Array::new_with_length(20); + crypto + .get_random_values_with_array_buffer_view(&secret) + .map_err(|e| AppError::Crypto(format!("Failed to generate TOTP secret: {:?}", e)))?; + + Ok(base32_encode(&secret.to_vec())) +} + +/// Computes HMAC-SHA1 using Web Crypto API. +async fn hmac_sha1(key: &[u8], data: &[u8]) -> Result, AppError> { + let subtle = subtle_crypto()?; + + // Create algorithm object for HMAC with SHA-1 + let algorithm = js_sys::Object::new(); + js_sys::Reflect::set(&algorithm, &JsValue::from_str("name"), &JsValue::from_str("HMAC")) + .map_err(|e| AppError::Crypto(format!("Failed to set algorithm name: {:?}", e)))?; + js_sys::Reflect::set(&algorithm, &JsValue::from_str("hash"), &JsValue::from_str("SHA-1")) + .map_err(|e| AppError::Crypto(format!("Failed to set hash: {:?}", e)))?; + + // Import the key + let key_array = Uint8Array::new_from_slice(key); + let key_usages = js_sys::Array::of1(&JsValue::from_str("sign")); + + let crypto_key = JsFuture::from( + subtle + .import_key_with_object( + "raw", + &key_array, + &algorithm, + false, + &key_usages, + ) + .map_err(|e| AppError::Crypto(format!("HMAC import_key failed: {:?}", e)))?, + ) + .await + .map_err(|e| AppError::Crypto(format!("HMAC import_key await failed: {:?}", e)))?; + + // Sign the data using sign_with_str_and_buffer_source + let data_array = Uint8Array::new_from_slice(data); + let signature = JsFuture::from( + subtle + .sign_with_str_and_buffer_source( + "HMAC", + &CryptoKey::from(crypto_key), + &data_array, + ) + .map_err(|e| AppError::Crypto(format!("HMAC sign failed: {:?}", e)))?, + ) + .await + .map_err(|e| AppError::Crypto(format!("HMAC sign await failed: {:?}", e)))?; + + Ok(Uint8Array::new(&signature).to_vec()) +} + +/// Generates a TOTP code for the given secret and time. +/// +/// # Arguments +/// * `secret` - Base32 encoded secret key +/// * `time_step` - Unix timestamp divided by 30 (or custom period) +/// +/// # Returns +/// * 6-digit TOTP code as a string +pub async fn generate_totp(secret: &str, time_step: u64) -> Result { + let decoded_secret = base32_decode(secret)?; + + // Convert time_step to big-endian bytes + let counter = time_step.to_be_bytes(); + + // Compute HMAC-SHA1 + let hmac = hmac_sha1(&decoded_secret, &counter).await?; + + // Dynamic truncation (RFC 4226) + let offset = (hmac[19] & 0x0f) as usize; + let code = ((hmac[offset] & 0x7f) as u32) << 24 + | (hmac[offset + 1] as u32) << 16 + | (hmac[offset + 2] as u32) << 8 + | (hmac[offset + 3] as u32); + + // Get 6-digit code + let otp = code % 1_000_000; + Ok(format!("{:06}", otp)) +} + +/// Validates a TOTP code against a secret. +/// +/// # Arguments +/// * `code` - The TOTP code to validate +/// * `secret` - Base32 encoded secret key +/// * `last_used` - The last used time step (for replay protection) +/// * `allow_drift` - Whether to allow 1 time step drift (±30 seconds) +/// +/// # Returns +/// * `Ok(time_step)` if valid, with the time step that matched +/// * `Err` if invalid +pub async fn validate_totp( + code: &str, + secret: &str, + last_used: i64, + allow_drift: bool, +) -> Result { + // Validate code format + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) { + return Err(AppError::BadRequest("Invalid TOTP code format".to_string())); + } + + let current_time = chrono::Utc::now().timestamp(); + let current_step = current_time / 30; + + // Check drift range + let steps: i64 = if allow_drift { 1 } else { 0 }; + + for step_offset in -steps..=steps { + let time_step = current_step + step_offset; + + // Skip if this time step was already used (replay protection) + if time_step <= last_used { + continue; + } + + let expected = generate_totp(secret, time_step as u64).await?; + + if constant_time_eq(code.as_bytes(), expected.as_bytes()) { + return Ok(time_step); + } + } + + Err(AppError::Unauthorized(format!( + "Invalid TOTP code. Server time: {}", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") + ))) +} + +/// Generates a recovery code (20 characters, Base32 encoded). +pub fn generate_recovery_code() -> Result { + let crypto = get_crypto()?; + let bytes = Uint8Array::new_with_length(20); + crypto + .get_random_values_with_array_buffer_view(&bytes) + .map_err(|e| AppError::Crypto(format!("Failed to generate recovery code: {:?}", e)))?; + + Ok(base32_encode(&bytes.to_vec())) +} + +/// Constant-time string comparison wrapper. +pub fn ct_eq(a: &str, b: &str) -> bool { + constant_time_eq(a.as_bytes(), b.as_bytes()) +} diff --git a/src/error.rs b/src/error.rs index 64a8bc6..aa41958 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; -use serde_json::json; +use serde_json::{json, Value}; use thiserror::Error; #[derive(Error, Debug)] @@ -32,35 +32,47 @@ pub enum AppError { #[error("Internal server error")] Internal, + + #[error("Two factor authentication required")] + TwoFactorRequired(Value), } impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, error_message) = match self { - AppError::Worker(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Worker error: {}", e), - ), - AppError::Database => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Database error".to_string(), - ), - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), - AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), - AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), - AppError::Crypto(msg) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Crypto error: {}", msg), - ), - AppError::JsonWebToken(_) => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()), - AppError::Internal => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ), - }; + match self { + AppError::TwoFactorRequired(json_body) => { + // Return 400 Bad Request with the 2FA required JSON response as expected by clients + (StatusCode::BAD_REQUEST, Json(json_body)).into_response() + } + other => { + let (status, error_message) = match other { + AppError::Worker(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Worker error: {}", e), + ), + AppError::Database => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error".to_string(), + ), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), + AppError::Crypto(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Crypto error: {}", msg), + ), + AppError::JsonWebToken(_) => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()), + AppError::Internal => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ), + AppError::TwoFactorRequired(_) => unreachable!(), + }; - let body = Json(json!({ "error": error_message })); - (status, body).into_response() + let body = Json(json!({ "error": error_message })); + (status, body).into_response() + } + } } } diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index c3fcfb2..00d6395 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -225,14 +225,15 @@ pub async fn register( kdf_memory, kdf_parallelism, security_stamp: Uuid::new_v4().to_string(), + totp_recover: None, created_at: now.clone(), updated_at: now, }; query!( &db, - "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, totp_recover, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", user.id, user.name, user.email, @@ -247,6 +248,7 @@ pub async fn register( user.kdf_memory, user.kdf_parallelism, user.security_stamp, + user.totp_recover, user.created_at, user.updated_at ).map_err(|_|{ diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index 132b0c4..1a33b69 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -1,25 +1,60 @@ use axum::{extract::State, Form, Json}; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::sync::Arc; use worker::{query, Env}; use crate::{ auth::Claims, - crypto::{generate_salt, hash_password_for_storage}, + crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, + handlers::allow_totp_drift, db, error::AppError, + models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType}, models::user::User, }; +/// Deserialize an Option that may have trailing/leading whitespace. +/// This handles Android clients that send "0 " instead of "0". +fn deserialize_trimmed_i32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + trimmed + .parse::() + .map(Some) + .map_err(|_| D::Error::custom(format!("invalid integer: {}", s))) + } + } + None => Ok(None), + } +} + #[derive(Debug, Deserialize)] pub struct TokenRequest { grant_type: String, username: Option, password: Option, // This is the masterPasswordHash refresh_token: Option, + // 2FA fields + #[serde(rename = "twoFactorToken")] + two_factor_token: Option, + #[serde(rename = "twoFactorProvider", default, deserialize_with = "deserialize_trimmed_i32")] + two_factor_provider: Option, + #[serde(rename = "twoFactorRemember", default, deserialize_with = "deserialize_trimmed_i32")] + two_factor_remember: Option, + #[serde(rename = "deviceIdentifier")] + device_identifier: Option, } #[derive(Debug, Serialize)] @@ -51,6 +86,8 @@ pub struct TokenResponse { force_password_reset: bool, #[serde(rename = "UserDecryptionOptions")] user_decryption_options: UserDecryptionOptions, + #[serde(rename = "TwoFactorToken", skip_serializing_if = "Option::is_none")] + two_factor_token: Option, } #[derive(Debug, Serialize)] @@ -63,6 +100,7 @@ pub struct UserDecryptionOptions { fn generate_tokens_and_response( user: User, env: &Arc, + two_factor_token: Option, ) -> Result, AppError> { let now = Utc::now(); let expires_in = Duration::hours(1); @@ -122,6 +160,7 @@ fn generate_tokens_and_response( has_master_password: true, object: "userDecryptionOptions".to_string(), }, + two_factor_token, })) } @@ -168,6 +207,188 @@ pub async fn token( return Err(AppError::Unauthorized("Invalid credentials".to_string())); } + // Check for 2FA + let twofactors: Vec = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000") + .bind(&[user.id.clone().into()])? + .all() + .await + .map_err(|_| AppError::Database)? + .results() + .unwrap_or_default(); + + let mut two_factor_remember_token: Option = None; + + // Filter out Remember type - it's not a real 2FA provider, just a convenience feature + let real_twofactors: Vec<&TwoFactor> = twofactors + .iter() + .filter(|tf| tf.atype != TwoFactorType::Remember as i32) + .collect(); + + if !real_twofactors.is_empty() { + // 2FA is enabled, need to verify + // Only show real 2FA providers to client (not Remember) + let twofactor_ids: Vec = real_twofactors.iter().map(|tf| tf.atype).collect(); + let selected_id = payload.two_factor_provider.unwrap_or(twofactor_ids[0]); + + let twofactor_code = match &payload.two_factor_token { + Some(code) => code, + None => { + // Return 2FA required error + return Err(AppError::TwoFactorRequired(json_err_twofactor(&twofactor_ids))); + } + }; + + // Find the selected twofactor from real_twofactors + let selected_twofactor = real_twofactors + .iter() + .find(|tf| tf.atype == selected_id && tf.enabled) + .copied(); + + match TwoFactorType::from_i32(selected_id) { + Some(TwoFactorType::Authenticator) => { + let tf = selected_twofactor.ok_or_else(|| { + AppError::BadRequest("TOTP not configured".to_string()) + })?; + + // Validate TOTP code + let allow_drift = allow_totp_drift(&env); + let new_last_used = + validate_totp(twofactor_code, &tf.data, tf.last_used, allow_drift).await?; + + // Update last_used + query!( + &db, + "UPDATE twofactor SET last_used = ?1 WHERE uuid = ?2", + new_last_used, + &tf.uuid + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + } + Some(TwoFactorType::Remember) => { + // Remember is handled separately - client sends remember token from previous login + // Check remember token against stored value for this device + if let Some(ref device_id) = payload.device_identifier { + // Find remember token in twofactors (not real_twofactors) + let remember_tf = twofactors + .iter() + .find(|tf| tf.atype == TwoFactorType::Remember as i32); + + if let Some(tf) = remember_tf { + // Parse stored remember tokens as JSON + let mut token_data = RememberTokenData::from_json(&tf.data); + + // Remove expired tokens first + token_data.remove_expired(); + + // Validate the provided token + if !token_data.validate(device_id, twofactor_code) { + return Err(AppError::TwoFactorRequired(json_err_twofactor(&twofactor_ids))); + } + + // Update database with cleaned tokens (remove expired) + let updated_data = token_data.to_json(); + query!( + &db, + "UPDATE twofactor SET data = ?1 WHERE uuid = ?2", + &updated_data, + &tf.uuid + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + // Remember token valid, proceed with login + } else { + return Err(AppError::TwoFactorRequired(json_err_twofactor(&twofactor_ids))); + } + } else { + return Err(AppError::TwoFactorRequired(json_err_twofactor(&twofactor_ids))); + } + } + Some(TwoFactorType::RecoveryCode) => { + // Check recovery code + if let Some(ref stored_code) = user.totp_recover { + if !ct_eq(&stored_code.to_uppercase(), &twofactor_code.to_uppercase()) { + return Err(AppError::BadRequest("Recovery code is incorrect".to_string())); + } + + // Delete all 2FA and clear recovery code + query!( + &db, + "DELETE FROM twofactor WHERE user_uuid = ?1", + &user.id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + query!( + &db, + "UPDATE users SET totp_recover = NULL WHERE id = ?1", + &user.id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + } else { + return Err(AppError::BadRequest("Recovery code is incorrect".to_string())); + } + } + _ => { + return Err(AppError::BadRequest("Invalid two factor provider".to_string())); + } + } + + // Generate remember token if requested + if payload.two_factor_remember == Some(1) { + if let Some(ref device_id) = payload.device_identifier { + let remember_token = uuid::Uuid::new_v4().to_string(); + + // Load existing remember tokens or create new + let remember_tf = twofactors + .iter() + .find(|tf| tf.atype == TwoFactorType::Remember as i32); + + let mut token_data = remember_tf + .map(|tf| RememberTokenData::from_json(&tf.data)) + .unwrap_or_default(); + + // Remove expired tokens first + token_data.remove_expired(); + + // Add/update token for this device + token_data.upsert(device_id.clone(), remember_token.clone()); + + let json_data = token_data.to_json(); + + // Store or update remember token + query!( + &db, + "INSERT INTO twofactor (uuid, user_uuid, atype, enabled, data, last_used) + VALUES (?1, ?2, ?3, 1, ?4, 0) + ON CONFLICT(user_uuid, atype) DO UPDATE SET data = ?4", + uuid::Uuid::new_v4().to_string(), + &user.id, + TwoFactorType::Remember as i32, + &json_data + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + two_factor_remember_token = Some(remember_token); + } + } + } + // Migrate legacy user to PBKDF2 if password matches and no salt exists let user = if verification.needs_migration() { // Generate new salt and hash the password @@ -200,7 +421,7 @@ pub async fn token( user }; - generate_tokens_and_response(user, &env) + generate_tokens_and_response(user, &env, two_factor_remember_token) } "refresh_token" => { let refresh_token = payload @@ -225,8 +446,31 @@ pub async fn token( .ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?; let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; - generate_tokens_and_response(user, &env) + generate_tokens_and_response(user, &env, None) } _ => Err(AppError::BadRequest("Unsupported grant_type".to_string())), } } + +/// Generates the JSON error response for 2FA required +fn json_err_twofactor(providers: &[i32]) -> Value { + let mut result = serde_json::json!({ + "error": "invalid_grant", + "error_description": "Two factor required.", + "TwoFactorProviders": providers.iter().map(|p| p.to_string()).collect::>(), + "TwoFactorProviders2": {}, + "MasterPasswordPolicy": { + "Object": "masterPasswordPolicy" + } + }); + + // Add provider-specific info + for provider in providers { + result["TwoFactorProviders2"][provider.to_string()] = Value::Null; + + // TOTP doesn't need any additional info + // Other providers like Email, WebAuthn etc. would add their info here + } + + result +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e3a52fe..9d9c6d0 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -8,6 +8,7 @@ pub mod identity; pub mod import; pub mod purge; pub mod sync; +pub mod twofactor; pub mod webauth; /// Shared helper for reading an environment variable into usize. @@ -22,3 +23,13 @@ pub(crate) fn get_env_usize(env: &worker::Env, var_name: &str, default: usize) - pub(crate) fn get_batch_size(env: &worker::Env) -> usize { get_env_usize(env, "IMPORT_BATCH_SIZE", 30) } + +/// Whether TOTP validation should allow ±1 time step drift. +/// Controlled via AUTHENTICATOR_DISABLE_TIME_DRIFT (truthy -> disable drift). +pub(crate) fn allow_totp_drift(env: &worker::Env) -> bool { + env.var("AUTHENTICATOR_DISABLE_TIME_DRIFT") + .ok() + .map(|value| value.to_string().to_lowercase()) + .map(|value| !matches!(value.as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(true) +} diff --git a/src/handlers/twofactor.rs b/src/handlers/twofactor.rs new file mode 100644 index 0000000..f568f50 --- /dev/null +++ b/src/handlers/twofactor.rs @@ -0,0 +1,507 @@ +use axum::{extract::State, Json}; +use serde_json::Value; +use std::sync::Arc; +use worker::{query, Env}; + +use crate::{ + auth::AuthUser, + crypto::{base32_decode, ct_eq, generate_recovery_code, generate_totp_secret, validate_totp}, + db, + error::AppError, + handlers::allow_totp_drift, + models::twofactor::{ + DisableAuthenticatorData, DisableTwoFactorData, EnableAuthenticatorData, RecoverTwoFactor, + TwoFactor, TwoFactorType, + }, + models::user::{PasswordOrOtpData, User}, +}; + +/// GET /api/two-factor - Get all enabled 2FA providers for current user +#[worker::send] +pub async fn get_twofactor( + State(env): State>, + AuthUser(user_id, _): AuthUser, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + let twofactors: Vec = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000") + .bind(&[user_id.clone().into()])? + .all() + .await + .map_err(|_| AppError::Database)? + .results::() + .map_err(|_| AppError::Database)? + .iter() + .map(|tf| tf.to_json_provider()) + .collect(); + + Ok(Json(serde_json::json!({ + "data": twofactors, + "object": "list", + "continuationToken": null, + }))) +} + +/// POST /api/two-factor/get-authenticator - Get or generate TOTP secret +#[worker::send] +pub async fn get_authenticator( + State(env): State>, + AuthUser(user_id, _): AuthUser, + Json(data): Json, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + // Verify master password + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + validate_password_or_otp(&user, &data).await?; + + // Check if TOTP is already configured + let existing: Option = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype = ?2") + .bind(&[user_id.clone().into(), (TwoFactorType::Authenticator as i32).into()])? + .first(None) + .await + .map_err(|_| AppError::Database)?; + + let (enabled, key) = match existing { + Some(tf_value) => { + let tf: TwoFactor = serde_json::from_value(tf_value).map_err(|_| AppError::Internal)?; + (true, tf.data) + } + None => (false, generate_totp_secret()?), + }; + + Ok(Json(serde_json::json!({ + "enabled": enabled, + "key": key, + "object": "twoFactorAuthenticator" + }))) +} + +/// POST /api/two-factor/authenticator - Activate TOTP +#[worker::send] +pub async fn activate_authenticator( + State(env): State>, + AuthUser(user_id, _): AuthUser, + Json(data): Json, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + // Verify master password + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + validate_password_or_otp( + &user, + &PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + }, + ) + .await?; + + let key = data.key.to_uppercase(); + + // Validate key format (Base32, 20 bytes = 32 characters without padding) + let decoded_key = base32_decode(&key)?; + if decoded_key.len() != 20 { + return Err(AppError::BadRequest("Invalid key length".to_string())); + } + + // Check if TOTP is already configured - reuse existing record for replay protection + let existing: Option = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype = ?2") + .bind(&[user_id.clone().into(), (TwoFactorType::Authenticator as i32).into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .map(|value| serde_json::from_value(value).map_err(|_| AppError::Internal)) + .transpose()?; + + // Get last_used from existing record to prevent replay during reconfiguration + let previous_last_used = existing.as_ref().map(|tf| tf.last_used).unwrap_or(0); + + // Validate TOTP code and capture time step for replay protection + let allow_drift = allow_totp_drift(&env); + let last_used_step = validate_totp(&data.token, &key, previous_last_used, allow_drift).await?; + + // Delete existing TOTP and any remember-device tokens bound to it to avoid stale bypass + query!( + &db, + "DELETE FROM twofactor WHERE user_uuid = ?1 AND atype IN (?2, ?3)", + &user_id, + TwoFactorType::Authenticator as i32, + TwoFactorType::Remember as i32 + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + // Create new TOTP entry + let mut twofactor = TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, key.clone()); + twofactor.last_used = last_used_step; + + query!( + &db, + "INSERT INTO twofactor (uuid, user_uuid, atype, enabled, data, last_used) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + &twofactor.uuid, + &twofactor.user_uuid, + twofactor.atype, + twofactor.enabled as i32, + &twofactor.data, + twofactor.last_used + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + // Generate recovery code if not exists + generate_recovery_code_for_user(&db, &user_id).await?; + + Ok(Json(serde_json::json!({ + "enabled": true, + "key": key, + "object": "twoFactorAuthenticator" + }))) +} + +/// PUT /api/two-factor/authenticator - Same as POST +#[worker::send] +pub async fn activate_authenticator_put( + state: State>, + auth_user: AuthUser, + json: Json, +) +-> Result, AppError> { + activate_authenticator(state, auth_user, json).await +} + +/// POST /api/two-factor/disable - Disable a 2FA method +#[worker::send] +pub async fn disable_twofactor( + State(env): State>, + AuthUser(user_id, _): AuthUser, + Json(data): Json, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + // Verify master password + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + validate_password_or_otp( + &user, + &PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + }, + ) + .await?; + + let type_ = data.r#type; + + // Delete the specified 2FA type + query!( + &db, + "DELETE FROM twofactor WHERE user_uuid = ?1 AND atype = ?2", + &user_id, + type_ + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + log::info!("User {} disabled 2FA type {}", user_id, type_); + + clear_recovery_if_no_twofactor(&db, &user_id).await?; + + Ok(Json(serde_json::json!({ + "enabled": false, + "type": type_, + "object": "twoFactorProvider" + }))) +} + + +/// DELETE /api/two-factor/authenticator - Disable TOTP with key verification +#[worker::send] +pub async fn disable_authenticator( + State(env): State>, + AuthUser(user_id, _): AuthUser, + Json(data): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + if data.r#type != TwoFactorType::Authenticator as i32 { + return Err(AppError::BadRequest("Invalid two factor type".to_string())); + } + + // Verify master password (OTP not supported in this minimal implementation) + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + validate_password_or_otp( + &user, + &PasswordOrOtpData { + master_password_hash: data.master_password_hash, + otp: data.otp, + }, + ) + .await?; + + // Fetch existing TOTP and verify key matches before deleting + let existing: Option = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype = ?2") + .bind(&[user_id.clone().into(), data.r#type.into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .map(|value| serde_json::from_value(value).map_err(|_| AppError::Internal)) + .transpose()?; + + let Some(tf) = existing else { + return Err(AppError::BadRequest("TOTP not configured".to_string())); + }; + + // Compare keys case-insensitively (key is stored uppercased during activation) + if !ct_eq(&tf.data, &data.key.to_uppercase()) { + return Err(AppError::BadRequest( + "TOTP key does not match recorded value".to_string(), + )); + } + + query!( + &db, + "DELETE FROM twofactor WHERE uuid = ?1", + &tf.uuid + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + log::info!("User {} disabled authenticator (2FA type {})", user_id, data.r#type); + + clear_recovery_if_no_twofactor(&db, &user_id).await?; + + Ok(Json(serde_json::json!({ + "enabled": false, + "type": data.r#type, + "object": "twoFactorProvider" + }))) +} + +/// PUT /api/two-factor/disable - Same as POST +#[worker::send] +pub async fn disable_twofactor_put( + state: State>, + auth_user: AuthUser, + json: Json, +) +-> Result, AppError> { + disable_twofactor(state, auth_user, json).await +} + +/// POST /api/two-factor/get-recover - Get recovery code +#[worker::send] +pub async fn get_recover( + State(env): State>, + AuthUser(user_id, _): AuthUser, + Json(data): Json, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + // Verify master password + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + validate_password_or_otp(&user, &data).await?; + + Ok(Json(serde_json::json!({ + "code": user.totp_recover, + "object": "twoFactorRecover" + }))) +} + +/// POST /api/two-factor/recover - Use recovery code to disable all 2FA +#[worker::send] +pub async fn recover( + State(env): State>, + Json(data): Json, +) +-> Result, AppError> { + let db = db::get_db(&env)?; + + // Get user by email + let user_value: Value = db + .prepare("SELECT * FROM users WHERE email = ?1") + .bind(&[data.email.to_lowercase().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("Username or password is incorrect".to_string()))?; + let user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + // Verify master password + let verification = user.verify_master_password(&data.master_password_hash).await?; + if !verification.is_valid() { + return Err(AppError::Unauthorized( + "Username or password is incorrect".to_string(), + )); + } + + // Check recovery code (case-insensitive) + let is_valid = user.totp_recover.as_ref().map_or(false, |stored_code| { + ct_eq(&stored_code.to_uppercase(), &data.recovery_code.to_uppercase()) + }); + + if !is_valid { + return Err(AppError::BadRequest( + "Recovery code is incorrect. Try again.".to_string(), + )); + } + + // Delete all 2FA methods + query!( + &db, + "DELETE FROM twofactor WHERE user_uuid = ?1", + &user.id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + // Clear recovery code + query!( + &db, + "UPDATE users SET totp_recover = NULL WHERE id = ?1", + &user.id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + log::info!("User {} recovered 2FA using recovery code", user.id); + + Ok(Json(serde_json::json!({}))) +} + +// Helper functions + +async fn validate_password_or_otp(user: &User, data: &PasswordOrOtpData) -> Result<(), AppError> { + if let Some(ref password_hash) = data.master_password_hash { + let verification = user.verify_master_password(password_hash).await?; + if verification.is_valid() { + return Ok(()); + } + } + + // OTP validation would be handled here if we had protected actions support + // For now, master password is required + + Err(AppError::Unauthorized("Invalid password".to_string())) +} + +async fn generate_recovery_code_for_user( + db: &worker::D1Database, + user_id: &str, +) +-> Result<(), AppError> { + // Check if recovery code already exists + let user_value: Value = db + .prepare("SELECT totp_recover FROM users WHERE id = ?1") + .bind(&[user_id.into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::Unauthorized("User not found".to_string()))?; + + let totp_recover: Option = user_value + .get("totp_recover") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if totp_recover.is_none() { + let recovery_code = generate_recovery_code()?; + query!( + db, + "UPDATE users SET totp_recover = ?1 WHERE id = ?2", + &recovery_code, + user_id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + } + + Ok(()) +} + +/// Clear recovery code when no real 2FA providers remain. +async fn clear_recovery_if_no_twofactor(db: &worker::D1Database, user_id: &str) -> Result<(), AppError> { + let remaining: Vec = db + .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000 AND atype != ?2") + .bind(&[ + user_id.to_string().into(), + (TwoFactorType::Remember as i32).into(), + ])? + .all() + .await + .map_err(|_| AppError::Database)? + .results() + .map_err(|_| AppError::Database)?; + + if remaining.is_empty() { + query!(db, "UPDATE users SET totp_recover = NULL WHERE id = ?1", user_id) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + } + + Ok(()) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index a2bd67b..a58b386 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,3 +3,4 @@ pub mod sync; pub mod cipher; pub mod folder; pub mod import; +pub mod twofactor; diff --git a/src/models/twofactor.rs b/src/models/twofactor.rs new file mode 100644 index 0000000..519593a --- /dev/null +++ b/src/models/twofactor.rs @@ -0,0 +1,208 @@ +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; + +/// Remember token expiration in days +const REMEMBER_TOKEN_EXPIRATION_DAYS: i64 = 30; + +/// Two-factor authentication types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum TwoFactorType { + Authenticator = 0, // TOTP + Email = 1, + Duo = 2, + YubiKey = 3, + U2f = 4, + Remember = 5, + OrganizationDuo = 6, + Webauthn = 7, + RecoveryCode = 8, +} + +impl TwoFactorType { + pub fn from_i32(value: i32) -> Option { + match value { + 0 => Some(TwoFactorType::Authenticator), + 1 => Some(TwoFactorType::Email), + 2 => Some(TwoFactorType::Duo), + 3 => Some(TwoFactorType::YubiKey), + 4 => Some(TwoFactorType::U2f), + 5 => Some(TwoFactorType::Remember), + 6 => Some(TwoFactorType::OrganizationDuo), + 7 => Some(TwoFactorType::Webauthn), + 8 => Some(TwoFactorType::RecoveryCode), + _ => None, + } + } +} + +/// TwoFactor database model +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TwoFactor { + pub uuid: String, + pub user_uuid: String, + pub atype: i32, + #[serde(with = "bool_from_int")] + pub enabled: bool, + pub data: String, + pub last_used: i64, +} + +impl TwoFactor { + pub fn new(user_uuid: String, atype: TwoFactorType, data: String) -> Self { + Self { + uuid: uuid::Uuid::new_v4().to_string(), + user_uuid, + atype: atype as i32, + enabled: true, + data, + last_used: 0, + } + } + + pub fn to_json_provider(&self) -> serde_json::Value { + serde_json::json!({ + "enabled": self.enabled, + "type": self.atype, + "object": "twoFactorProvider" + }) + } +} + +mod bool_from_int { + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = i64::deserialize(deserializer)?; + match value { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(serde::de::Error::custom("expected integer 0 or 1")), + } + } + + pub fn serialize(value: &bool, serializer: S) -> Result + where + S: Serializer, + { + if *value { + serializer.serialize_i64(1) + } else { + serializer.serialize_i64(0) + } + } +} + +/// POST /api/two-factor/authenticator - Enable TOTP +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnableAuthenticatorData { + pub key: String, + pub token: String, + pub master_password_hash: Option, + pub otp: Option, +} + +/// POST /api/two-factor/disable - Disable a 2FA method +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisableTwoFactorData { + pub master_password_hash: Option, + pub otp: Option, + #[serde(rename = "type")] + pub r#type: i32, +} + +/// POST /api/two-factor/get-recover - Get recovery code +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecoverTwoFactor { + pub master_password_hash: String, + pub email: String, + pub recovery_code: String, +} + +/// DELETE /api/two-factor/authenticator - Disable TOTP with key verification +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisableAuthenticatorData { + pub key: String, + pub master_password_hash: Option, + pub otp: Option, + #[serde(rename = "type")] + pub r#type: i32, +} + +// ============================================================================ +// Remember Token Storage (supports multiple devices with expiration) +// ============================================================================ + +/// Single device remember token +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RememberTokenEntry { + pub device_id: String, + pub token: String, + pub created_at: i64, // Unix timestamp +} + +/// Container for multiple device remember tokens +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RememberTokenData { + pub tokens: Vec, +} + +impl RememberTokenData { + /// Parse from JSON string stored in database + pub fn from_json(data: &str) -> Self { + serde_json::from_str(data).unwrap_or_default() + } + + /// Serialize to JSON string for database storage + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) + } + + /// Remove expired tokens (older than 30 days) + pub fn remove_expired(&mut self) { + let now = Utc::now().timestamp(); + let expiration_seconds = Duration::days(REMEMBER_TOKEN_EXPIRATION_DAYS).num_seconds(); + self.tokens.retain(|t| now - t.created_at < expiration_seconds); + } + + /// Validate a token for a specific device + /// Returns true if valid and not expired + pub fn validate(&self, device_id: &str, token: &str) -> bool { + let now = Utc::now().timestamp(); + let expiration_seconds = Duration::days(REMEMBER_TOKEN_EXPIRATION_DAYS).num_seconds(); + + self.tokens.iter().any(|t| { + t.device_id == device_id + && t.token == token + && now - t.created_at < expiration_seconds + }) + } + + /// Add or update a token for a device + /// If the device already has a token, it will be replaced + pub fn upsert(&mut self, device_id: String, token: String) { + // Remove existing token for this device + self.tokens.retain(|t| t.device_id != device_id); + + // Add new token + self.tokens.push(RememberTokenEntry { + device_id, + token, + created_at: Utc::now().timestamp(), + }); + } + + /// Check if there are any valid (non-expired) tokens + pub fn has_valid_tokens(&self) -> bool { + let now = Utc::now().timestamp(); + let expiration_seconds = Duration::days(REMEMBER_TOKEN_EXPIRATION_DAYS).num_seconds(); + self.tokens.iter().any(|t| now - t.created_at < expiration_seconds) + } +} diff --git a/src/models/user.rs b/src/models/user.rs index 02563cc..dbb181b 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -21,6 +21,7 @@ pub struct User { pub kdf_memory: Option, // Argon2 memory parameter (15-1024 MB) pub kdf_parallelism: Option, // Argon2 parallelism parameter (1-16) pub security_stamp: String, + pub totp_recover: Option, // Recovery code for 2FA pub created_at: String, pub updated_at: String, } diff --git a/src/router.rs b/src/router.rs index 37ff8a0..8dc9a71 100644 --- a/src/router.rs +++ b/src/router.rs @@ -5,7 +5,7 @@ use axum::{ use std::sync::Arc; use worker::Env; -use crate::handlers::{accounts, ciphers, config, devices, emergency_access, folders, identity, import, sync, webauth}; +use crate::handlers::{accounts, ciphers, config, devices, emergency_access, folders, identity, import, sync, twofactor, webauth}; pub fn api_router(env: Env) -> Router { let app_state = Arc::new(env); @@ -127,5 +127,36 @@ pub fn api_router(env: Env) -> Router { "/api/webauthn", get(webauth::get_webauthn_credentials), ) + // Two-factor authentication + .route("/api/two-factor", get(twofactor::get_twofactor)) + .route( + "/api/two-factor/get-authenticator", + post(twofactor::get_authenticator), + ) + .route( + "/api/two-factor/authenticator", + post(twofactor::activate_authenticator), + ) + .route( + "/api/two-factor/authenticator", + put(twofactor::activate_authenticator_put), + ) + .route( + "/api/two-factor/authenticator", + delete(twofactor::disable_authenticator), + ) + .route( + "/api/two-factor/disable", + post(twofactor::disable_twofactor), + ) + .route( + "/api/two-factor/disable", + put(twofactor::disable_twofactor_put), + ) + .route( + "/api/two-factor/get-recover", + post(twofactor::get_recover), + ) + .route("/api/two-factor/recover", post(twofactor::recover)) .with_state(app_state) }