feat: implement two-factor authentication (2FA) support with TOTP and recovery codes

This commit is contained in:
qaz741wsd856 2025-12-06 15:01:14 +00:00
parent 51676af4a1
commit 3408ccb555
16 changed files with 1286 additions and 37 deletions

7
Cargo.lock generated
View file

@ -119,6 +119,12 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "base32"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -1199,6 +1205,7 @@ name = "warden-worker"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"axum", "axum",
"base32",
"base64 0.21.7", "base64 0.21.7",
"chrono", "chrono",
"console_error_panic_hook", "console_error_panic_hook",

View file

@ -36,6 +36,7 @@ serde_json = "1.0"
# Crypto & Encoding # Crypto & Encoding
jsonwebtoken = "9.2" jsonwebtoken = "9.2"
base64 = "0.21" base64 = "0.21"
base32 = "0.5"
hex = "0.4" hex = "0.4"
rand = { version = "0.8" } rand = { version = "0.8" }
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }

View file

@ -23,14 +23,14 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
## Current Status ## 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 * Sharing
* 2FA login * 2FA login (except TOTP)
* Bitwarden Send * Bitwarden Send
* Device and session management * Device and session management
* Emergency access * Emergency access
* Admin operation * Admin operations
* Organizations * Organizations
* Other Bitwarden advanced features * 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. Controls whether the "Create Account" / registration button is displayed in the Bitwarden client UI.
* Set to `false` to show the registration button * 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. * **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) ### 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. 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.

View file

@ -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;

View file

@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS users (
kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2 kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2
kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2 kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2
security_stamp TEXT, security_stamp TEXT,
totp_recover TEXT, -- Recovery code for 2FA
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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 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 -- Folders table for organizing ciphers
CREATE TABLE IF NOT EXISTS folders ( CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,

View file

@ -22,6 +22,9 @@ pub struct Claims {
pub amr: Vec<String>, pub amr: Vec<String>,
} }
/// AuthUser extractor - provides (user_id, email) tuple
pub struct AuthUser(pub String, pub String);
impl FromRequestParts<Arc<Env>> for Claims impl FromRequestParts<Arc<Env>> for Claims
{ {
type Rejection = AppError; type Rejection = AppError;
@ -51,3 +54,13 @@ impl FromRequestParts<Arc<Env>> for Claims
Ok(token_data.claims) Ok(token_data.claims)
} }
} }
impl FromRequestParts<Arc<Env>> for AuthUser
{
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &Arc<Env>) -> Result<Self, Self::Rejection> {
let claims = Claims::from_request_parts(parts, state).await?;
Ok(AuthUser(claims.sub, claims.email))
}
}

View file

@ -128,3 +128,172 @@ pub async fn verify_password(
stored_hash.as_bytes(), 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<Vec<u8>, 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<String, AppError> {
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<Vec<u8>, 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<String, AppError> {
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<i64, AppError> {
// 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<String, AppError> {
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())
}

View file

@ -1,7 +1,7 @@
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use serde_json::json; use serde_json::{json, Value};
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -32,35 +32,47 @@ pub enum AppError {
#[error("Internal server error")] #[error("Internal server error")]
Internal, Internal,
#[error("Two factor authentication required")]
TwoFactorRequired(Value),
} }
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, error_message) = match self { match self {
AppError::Worker(e) => ( AppError::TwoFactorRequired(json_body) => {
StatusCode::INTERNAL_SERVER_ERROR, // Return 400 Bad Request with the 2FA required JSON response as expected by clients
format!("Worker error: {}", e), (StatusCode::BAD_REQUEST, Json(json_body)).into_response()
), }
AppError::Database => ( other => {
StatusCode::INTERNAL_SERVER_ERROR, let (status, error_message) = match other {
"Database error".to_string(), AppError::Worker(e) => (
), StatusCode::INTERNAL_SERVER_ERROR,
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), format!("Worker error: {}", e),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), ),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), AppError::Database => (
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), StatusCode::INTERNAL_SERVER_ERROR,
AppError::Crypto(msg) => ( "Database error".to_string(),
StatusCode::INTERNAL_SERVER_ERROR, ),
format!("Crypto error: {}", msg), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::JsonWebToken(_) => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
AppError::Internal => ( AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
StatusCode::INTERNAL_SERVER_ERROR, AppError::Crypto(msg) => (
"Internal server error".to_string(), 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 })); let body = Json(json!({ "error": error_message }));
(status, body).into_response() (status, body).into_response()
}
}
} }
} }

View file

@ -225,14 +225,15 @@ pub async fn register(
kdf_memory, kdf_memory,
kdf_parallelism, kdf_parallelism,
security_stamp: Uuid::new_v4().to_string(), security_stamp: Uuid::new_v4().to_string(),
totp_recover: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
query!( query!(
&db, &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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
user.id, user.id,
user.name, user.name,
user.email, user.email,
@ -247,6 +248,7 @@ pub async fn register(
user.kdf_memory, user.kdf_memory,
user.kdf_parallelism, user.kdf_parallelism,
user.security_stamp, user.security_stamp,
user.totp_recover,
user.created_at, user.created_at,
user.updated_at user.updated_at
).map_err(|_|{ ).map_err(|_|{

View file

@ -1,25 +1,60 @@
use axum::{extract::State, Form, Json}; use axum::{extract::State, Form, Json};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
use worker::{query, Env}; use worker::{query, Env};
use crate::{ use crate::{
auth::Claims, 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, db,
error::AppError, error::AppError,
models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType},
models::user::User, models::user::User,
}; };
/// Deserialize an Option<i32> 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<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let opt: Option<String> = Option::deserialize(deserializer)?;
match opt {
Some(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
Ok(None)
} else {
trimmed
.parse::<i32>()
.map(Some)
.map_err(|_| D::Error::custom(format!("invalid integer: {}", s)))
}
}
None => Ok(None),
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct TokenRequest { pub struct TokenRequest {
grant_type: String, grant_type: String,
username: Option<String>, username: Option<String>,
password: Option<String>, // This is the masterPasswordHash password: Option<String>, // This is the masterPasswordHash
refresh_token: Option<String>, refresh_token: Option<String>,
// 2FA fields
#[serde(rename = "twoFactorToken")]
two_factor_token: Option<String>,
#[serde(rename = "twoFactorProvider", default, deserialize_with = "deserialize_trimmed_i32")]
two_factor_provider: Option<i32>,
#[serde(rename = "twoFactorRemember", default, deserialize_with = "deserialize_trimmed_i32")]
two_factor_remember: Option<i32>,
#[serde(rename = "deviceIdentifier")]
device_identifier: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -51,6 +86,8 @@ pub struct TokenResponse {
force_password_reset: bool, force_password_reset: bool,
#[serde(rename = "UserDecryptionOptions")] #[serde(rename = "UserDecryptionOptions")]
user_decryption_options: UserDecryptionOptions, user_decryption_options: UserDecryptionOptions,
#[serde(rename = "TwoFactorToken", skip_serializing_if = "Option::is_none")]
two_factor_token: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -63,6 +100,7 @@ pub struct UserDecryptionOptions {
fn generate_tokens_and_response( fn generate_tokens_and_response(
user: User, user: User,
env: &Arc<Env>, env: &Arc<Env>,
two_factor_token: Option<String>,
) -> Result<Json<TokenResponse>, AppError> { ) -> Result<Json<TokenResponse>, AppError> {
let now = Utc::now(); let now = Utc::now();
let expires_in = Duration::hours(1); let expires_in = Duration::hours(1);
@ -122,6 +160,7 @@ fn generate_tokens_and_response(
has_master_password: true, has_master_password: true,
object: "userDecryptionOptions".to_string(), object: "userDecryptionOptions".to_string(),
}, },
two_factor_token,
})) }))
} }
@ -168,6 +207,188 @@ pub async fn token(
return Err(AppError::Unauthorized("Invalid credentials".to_string())); return Err(AppError::Unauthorized("Invalid credentials".to_string()));
} }
// Check for 2FA
let twofactors: Vec<TwoFactor> = 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<String> = 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<i32> = 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 // Migrate legacy user to PBKDF2 if password matches and no salt exists
let user = if verification.needs_migration() { let user = if verification.needs_migration() {
// Generate new salt and hash the password // Generate new salt and hash the password
@ -200,7 +421,7 @@ pub async fn token(
user user
}; };
generate_tokens_and_response(user, &env) generate_tokens_and_response(user, &env, two_factor_remember_token)
} }
"refresh_token" => { "refresh_token" => {
let refresh_token = payload let refresh_token = payload
@ -225,8 +446,31 @@ pub async fn token(
.ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?; .ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?;
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; 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())), _ => 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::<Vec<String>>(),
"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
}

View file

@ -8,6 +8,7 @@ pub mod identity;
pub mod import; pub mod import;
pub mod purge; pub mod purge;
pub mod sync; pub mod sync;
pub mod twofactor;
pub mod webauth; pub mod webauth;
/// Shared helper for reading an environment variable into usize. /// 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 { pub(crate) fn get_batch_size(env: &worker::Env) -> usize {
get_env_usize(env, "IMPORT_BATCH_SIZE", 30) 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)
}

507
src/handlers/twofactor.rs Normal file
View file

@ -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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
)
-> Result<Json<Value>, AppError> {
let db = db::get_db(&env)?;
let twofactors: Vec<Value> = db
.prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000")
.bind(&[user_id.clone().into()])?
.all()
.await
.map_err(|_| AppError::Database)?
.results::<TwoFactor>()
.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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
Json(data): Json<PasswordOrOtpData>,
)
-> Result<Json<Value>, 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<Value> = 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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
Json(data): Json<EnableAuthenticatorData>,
)
-> Result<Json<Value>, 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<TwoFactor> = 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<Arc<Env>>,
auth_user: AuthUser,
json: Json<EnableAuthenticatorData>,
)
-> Result<Json<Value>, 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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
Json(data): Json<DisableTwoFactorData>,
)
-> Result<Json<Value>, 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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
Json(data): Json<DisableAuthenticatorData>,
) -> Result<Json<Value>, 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<TwoFactor> = 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<Arc<Env>>,
auth_user: AuthUser,
json: Json<DisableTwoFactorData>,
)
-> Result<Json<Value>, 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<Arc<Env>>,
AuthUser(user_id, _): AuthUser,
Json(data): Json<PasswordOrOtpData>,
)
-> Result<Json<Value>, 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<Arc<Env>>,
Json(data): Json<RecoverTwoFactor>,
)
-> Result<Json<Value>, 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<String> = 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<TwoFactor> = 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(())
}

View file

@ -3,3 +3,4 @@ pub mod sync;
pub mod cipher; pub mod cipher;
pub mod folder; pub mod folder;
pub mod import; pub mod import;
pub mod twofactor;

208
src/models/twofactor.rs Normal file
View file

@ -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<Self> {
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<bool, D::Error>
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<S>(value: &bool, serializer: S) -> Result<S::Ok, S::Error>
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<String>,
pub otp: Option<String>,
}
/// POST /api/two-factor/disable - Disable a 2FA method
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DisableTwoFactorData {
pub master_password_hash: Option<String>,
pub otp: Option<String>,
#[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<String>,
pub otp: Option<String>,
#[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<RememberTokenEntry>,
}
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)
}
}

View file

@ -21,6 +21,7 @@ pub struct User {
pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB) pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB)
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16) pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
pub security_stamp: String, pub security_stamp: String,
pub totp_recover: Option<String>, // Recovery code for 2FA
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }

View file

@ -5,7 +5,7 @@ use axum::{
use std::sync::Arc; use std::sync::Arc;
use worker::Env; 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 { pub fn api_router(env: Env) -> Router {
let app_state = Arc::new(env); let app_state = Arc::new(env);
@ -127,5 +127,36 @@ pub fn api_router(env: Env) -> Router {
"/api/webauthn", "/api/webauthn",
get(webauth::get_webauthn_credentials), 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) .with_state(app_state)
} }