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",
]
[[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",

View file

@ -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"] }

View file

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

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_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,

View file

@ -22,6 +22,9 @@ pub struct Claims {
pub amr: Vec<String>,
}
/// AuthUser extractor - provides (user_id, email) tuple
pub struct AuthUser(pub String, pub String);
impl FromRequestParts<Arc<Env>> for Claims
{
type Rejection = AppError;
@ -51,3 +54,13 @@ impl FromRequestParts<Arc<Env>> for 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(),
))
}
// ============================================================================
// 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::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()
}
}
}
}

View file

@ -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(|_|{

View file

@ -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<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)]
pub struct TokenRequest {
grant_type: String,
username: Option<String>,
password: Option<String>, // This is the masterPasswordHash
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)]
@ -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<String>,
}
#[derive(Debug, Serialize)]
@ -63,6 +100,7 @@ pub struct UserDecryptionOptions {
fn generate_tokens_and_response(
user: User,
env: &Arc<Env>,
two_factor_token: Option<String>,
) -> Result<Json<TokenResponse>, 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<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
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::<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 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)
}

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 folder;
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_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
pub security_stamp: String,
pub totp_recover: Option<String>, // Recovery code for 2FA
pub created_at: String,
pub updated_at: String,
}

View file

@ -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)
}