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