feat: implement two-factor authentication (2FA) support with TOTP and recovery codes
This commit is contained in:
parent
51676af4a1
commit
3408ccb555
16 changed files with 1286 additions and 37 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
14
README.md
14
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.
|
||||
|
|
|
|||
22
migrations/0003_add_twofactor.sql
Normal file
22
migrations/0003_add_twofactor.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
13
src/auth.rs
13
src/auth.rs
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
src/crypto.rs
169
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<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())
|
||||
}
|
||||
|
|
|
|||
64
src/error.rs
64
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(|_|{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
507
src/handlers/twofactor.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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
208
src/models/twofactor.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue