From a29c22009e3d8db68ff85b440a2061f272991c16 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 6 Aug 2026 16:06:37 +0200 Subject: [PATCH 1/5] 2FA using userVerificationToken --- playwright/tests/login.spec.ts | 37 +++- playwright/tests/setups/2fa.ts | 19 +- playwright/tests/setups/user.ts | 18 +- src/api/core/two_factor/authenticator.rs | 52 ++---- src/api/core/two_factor/duo.rs | 121 +++++-------- src/api/core/two_factor/email.rs | 89 +++++---- src/api/core/two_factor/mod.rs | 65 +------ src/api/core/two_factor/webauthn.rs | 120 ++++++------ src/api/core/two_factor/yubikey.rs | 80 ++++---- src/api/mod.rs | 2 +- src/auth.rs | 3 + src/auth/two_factor.rs | 221 +++++++++++++++++++++++ 12 files changed, 514 insertions(+), 313 deletions(-) create mode 100644 src/auth/two_factor.rs diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index 194976ea..bc229c43 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth"; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP } from './setups/2fa'; +import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa'; let users = utils.loadEnv(); let totp; @@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - await test.step('login', async () => { - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + await logUser(test, page, users.user1, { totp }); + + await disableTOTP(test, page, users.user1); +}); + +test('Recovery codes', async ({ context, page }) => { + await logUser(test, page, users.user1); + + await activateTOTP(test, page, users.user1); + let recovery = await recoveryCodes(test, page, users.user1); + + await utils.logout(test, page, users.user1); + await test.step('login', async () => { await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); - await page.getByRole('button', { name: 'Continue' }).click(); await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - await disableTOTP(test, page, users.user1); + const newPagePromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Use your recovery code' }).click(); + const newPage = await newPagePromise; + + const tabs = context.pages(); + await tabs[1].bringToFront(); + await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible(); + await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email); + await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password); + await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery); + await tabs[1].getByRole('button', { name: 'Submit' }).click(); + + await expect(tabs[1]).toHaveTitle(/Two-step login/); + }); }); + diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index d430d053..0b1711a1 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; +export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string { + return await test.step('Recovery code', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + + await page.getByRole('button', { name: 'View recovery code' }).click(); + await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const recovery = await page.getByRole('code').innerText(); + await page.getByLabel('Close').click(); + + return recovery; + }) +} + export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { return await test.step('Activate TOTP 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); @@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); - await page.getByLabel('Close').click(); return totp; }) diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 3d3990e9..aa5d1e7e 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; +import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; @@ -43,6 +44,7 @@ export async function logUser( mailBuffer ?: MailBuffer, mail2fa?: boolean, notNewDevice?: boolean, + totp?: OTPAuth.TOTP, } = {} ) { await test.step(`Log user ${user.email}`, async () => { @@ -55,11 +57,23 @@ export async function logUser( await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); - if( options.mail2fa ){ + if( options.mail2fa || options.totp ){ + let code; + await test.step('2FA check', async () => { await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - let code = await retrieveEmailCode(test, page, options.mailBuffer); + + if( options.totp ) { + const totp = options.totp; + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + code = totp.generate({timestamp}); + } else if( options.mail2fa ){ + code = await retrieveEmailCode(test, page, mailBuffer); + } + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); }); } diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 692e8248..deacd9d8 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -3,7 +3,7 @@ use rocket::{Route, serde::json::Json}; use crate::{ api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, - auth::{ClientIp, Headers}, + auth::{ClientIp, Headers, two_factor}, crypto, db::{ DbConn, @@ -20,7 +20,6 @@ pub fn routes() -> Vec { #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; @@ -33,14 +32,12 @@ async fn generate_authenticator(data: Json, headers: Headers, _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), }; - // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. - // It should help prevent TOTP disclosure if someone keeps their vault unlocked. - // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. - // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ - "enabled": enabled, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": enabled, + "key": key, + }), + "userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled), }))) } @@ -49,8 +46,7 @@ async fn generate_authenticator(data: Json, headers: Headers, struct EnableAuthenticatorData { key: String, token: NumberOrString, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[post("/two-factor/authenticator", data = "")] @@ -61,12 +57,7 @@ async fn activate_authenticator(data: Json, headers: He let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { @@ -87,9 +78,10 @@ async fn activate_authenticator(data: Json, headers: He log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": true, + "key": key, + }), }))) } @@ -184,20 +176,18 @@ pub async fn validate_totp_code( #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, - master_password_hash: String, - r#type: NumberOrString, + user_verification_token: String, } #[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - let type_ = data.r#type.into_i32()?; - if !user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(twofactor) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator as i32, &conn).await + { if twofactor.data == data.key { twofactor.delete(&conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) @@ -211,9 +201,5 @@ async fn disable_authenticator(data: Json, headers: He super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; } - Ok(Json(json!({ - "enabled": false, - "keys": type_, - "object": "twoFactorProvider" - }))) + Ok(()) } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index ed112eb9..e168eae1 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -5,10 +5,11 @@ use rocket::{Route, serde::json::Json}; use crate::{ CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, - core::two_factor::generate_recover_code, + ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, + core::log_user_event, + core::two_factor::{VerificationTokenData, generate_recover_code}, }, - auth::Headers, + auth::{Headers, two_factor, two_factor::DuoData}, crypto, db::{ DbConn, @@ -19,55 +20,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_duo, activate_duo, activate_duo_put,] -} - -#[derive(Serialize, Deserialize)] -struct DuoData { - host: String, // Duo API hostname - ik: String, // client id - sk: String, // client secret -} - -impl DuoData { - fn global() -> Option { - match (CONFIG._enable_duo(), CONFIG.duo_host()) { - (true, Some(host)) => Some(Self { - host, - ik: CONFIG.duo_ikey().unwrap(), - sk: CONFIG.duo_skey().unwrap(), - }), - _ => None, - } - } - fn msg(s: &str) -> Self { - Self { - host: s.into(), - ik: s.into(), - sk: s.into(), - } - } - fn secret() -> Self { - Self::msg("") - } - fn obscure(self) -> Self { - let mut host = self.host; - let mut ik = self.ik; - let mut sk = self.sk; - - let digits = 4; - let replaced = "************"; - - host.replace_range(digits.., replaced); - ik.replace_range(digits.., replaced); - sk.replace_range(digits.., replaced); - - Self { - host, - ik, - sk, - } - } + routes![get_duo, activate_duo, activate_duo_put, disable_duo,] } enum DuoStatus { @@ -96,22 +49,19 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) data.validate(&user, false, &conn).await?; - let data = get_user_duo_data(&user.uuid, &conn).await; - - let (enabled, data) = match data { + let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(false) => (false, None), }; - let json = if let Some(data) = data { + let duo_json = if let Some(data) = duo.as_ref() { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, - "object": "twoFactorDuo" }) } else { json!({ @@ -119,11 +69,13 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) "host": null, "clientSecret": null, "clientId": null, - "object": "twoFactorDuo" }) }; - Ok(Json(json)) + Ok(Json(rocket::serde::json::json!({ + "duo": duo_json, + "userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled), + }))) } #[derive(Deserialize)] @@ -132,8 +84,7 @@ struct EnableDuoData { host: String, client_secret: String, client_id: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } impl From for DuoData { @@ -160,12 +111,7 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let data: EnableDuoData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); @@ -185,11 +131,12 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, - "object": "twoFactorDuo" + "duo": json!({ + "enabled": true, + "host": data.host, + "clientSecret": data.sk, + "clientId": data.ik, + }), }))) } @@ -198,6 +145,32 @@ async fn activate_duo_put(data: Json, headers: Headers, conn: DbC activate_duo(data, headers, conn).await } +#[delete("/two-factor/duo", data = "")] +async fn disable_duo(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo as i32, &conn).await { + // Apply the same transformation than in `get_duo` to check we are disabling the correct one + let duo = match to_user_duo_data(&twofactor) { + DuoStatus::Global(_) => Some(DuoData::secret()), + DuoStatus::User(data) => Some(data.obscure()), + DuoStatus::Disabled(_) => None, + }; + + two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; + } + + if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + } + + Ok(()) +} + async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { use reqwest::{Method, header}; use std::str::FromStr; @@ -237,6 +210,10 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { return DuoStatus::Disabled(DuoData::global().is_some()); }; + to_user_duo_data(&twofactor) +} + +fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { // If the user has the required values, we use those if let Ok(data) = serde_json::from_str(&twofactor.data) { return DuoStatus::User(data); diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 44ba2e7f..3472830c 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -5,9 +5,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::{ClientHeaders, Headers}, + auth::{ClientHeaders, Headers, two_factor}, crypto, db::{ DbConn, @@ -18,7 +21,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email,] + routes![get_email, send_email_login, send_email, email, disable_email] } #[derive(Deserialize)] @@ -131,18 +134,19 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { - Some(x) => { - let twofactor_data = EmailTokenData::from_json(&x.data)?; - (true, json!(twofactor_data.email)) - } - _ => (false, serde_json::value::Value::Null), + if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + let twofactor_data = EmailTokenData::from_json(&x.data)?; + (true, Some(twofactor_data.email)) + } else { + (false, None) }; - Ok(Json(json!({ - "email": mfa_email, - "enabled": enabled, - "object": "twoFactorEmail" + Ok(Json(rocket::serde::json::json!({ + "email": rocket::serde::json::json!({ + "enabled": enabled, + "email": mfa_email, + }), + "userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled), }))) } @@ -151,22 +155,16 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn struct SendEmailData { /// Email where 2FA codes will be sent to, can be different than user email account. email: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] -async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn send_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SendEmailData = data.into_inner(); let user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, false, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") @@ -182,12 +180,13 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - let twofactor_data = EmailTokenData::new(data.email, generated_token); // Uses EmailVerificationChallenge as type to show that it's not verified yet. - let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); + let twofactor = + TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); twofactor.save(&conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; - Ok(()) + Ok(Json(json!({}))) } #[derive(Deserialize, Serialize)] @@ -195,8 +194,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - struct EmailData { email: String, token: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -205,17 +203,12 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes let data: EmailData = data.into_inner(); let mut user = headers.user; - // This is the last step in the verification process, delete the otp directly afterwards - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; - let type_ = TwoFactorType::EmailVerificationChallenge as i32; let mut twofactor = - TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge as i32, &conn) + .await + .map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -236,11 +229,27 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - Ok(Json(json!({ - "email": email_data.email, - "enabled": "true", - "object": "twoFactorEmail" - }))) + Ok(Json(json!({}))) +} + +#[delete("/two-factor/email", data = "")] +async fn disable_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + let twofactor_data = EmailTokenData::from_json(&twofactor.data)?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; + } + + if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + } + + Ok(()) } /// Validate the email code when used as TwoFactor token mechanism diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index c95fb297..9370929d 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -7,10 +7,7 @@ use serde_json::Value; use crate::{ CONFIG, - api::{ - EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_event, log_user_event}, - }, + api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, auth::Headers, crypto, db::{ @@ -21,7 +18,6 @@ use crate::{ }, }, mail, - util::NumberOrString, }; pub mod authenticator; @@ -69,13 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![ - get_twofactor, - get_recover, - disable_twofactor, - disable_twofactor_put, - get_device_verification_settings, - ]; + let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings,]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -87,6 +77,12 @@ pub fn routes() -> Vec { routes } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerificationTokenData { + user_verification_token: String, +} + #[get("/two-factor")] async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; @@ -126,51 +122,6 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DisableTwoFactorData { - master_password_hash: Option, - otp: Option, - r#type: NumberOrString, -} - -#[post("/two-factor/disable", data = "")] -async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: DisableTwoFactorData = data.into_inner(); - let user = headers.user; - - // Delete directly after a valid token has been provided - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; - - let type_ = data.r#type.into_i32()?; - - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { - twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; - } - - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; - } - - Ok(Json(json!({ - "enabled": false, - "type": type_, - "object": "twoFactorProvider" - }))) -} - -#[put("/two-factor/disable", data = "")] -async fn disable_twofactor_put(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - disable_twofactor(data, headers, conn).await -} - pub async fn enforce_2fa_policy( user: &User, act_user_id: &UserId, diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 07b964e5..6124fd96 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -18,9 +18,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, crypto::ct_eq, db::{ DbConn, @@ -119,34 +122,36 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "enabled": enabled, - "keys": registrations_json, - "object": "twoFactorWebAuthn" + "webAuthn": json!({ + "enabled": enabled, + "keys": registrations_json, + }), + "userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled), }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); +async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; - data.validate(&user, false, &conn).await?; - - let registrations = get_webauthn_registrations(&user.uuid, &conn) - .await? - .1 + let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + let creds = registrations .into_iter() .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?; + let (mut challenge, state) = WEBAUTHN.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), - Some(registrations), + Some(creds), )?; let mut state = serde_json::to_value(&state)?; @@ -166,7 +171,10 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); - Ok(Json(challenge_value)) + + Ok(Json(json!({ + "options": challenge_value + }))) } #[derive(Debug, Deserialize)] @@ -175,8 +183,7 @@ struct EnableWebauthnData { id: NumberOrString, // 1..5 name: String, device_response: RegisterPublicKeyCredentialCopy, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Debug, Deserialize)] @@ -257,12 +264,9 @@ async fn activate_webauthn(data: Json, headers: Headers, con let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, false)?; // Retrieve and delete the saved challenge state let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; @@ -277,7 +281,6 @@ async fn activate_webauthn(data: Json, headers: Headers, con // Verify the credentials with the saved state let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; - let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { id: data.id.into_i32()?, @@ -296,10 +299,12 @@ async fn activate_webauthn(data: Json, headers: Headers, con log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); + Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" + "webAuthn": json!({ + "enabled": true, + "keys": keys_json, + }), }))) } @@ -308,60 +313,47 @@ async fn activate_webauthn_put(data: Json, headers: Headers, activate_webauthn(data, headers, conn).await } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeleteU2FData { - id: NumberOrString, - master_password_hash: String, -} - -#[delete("/two-factor/webauthn", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let id = data.id.into_i32()?; - if !headers.user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } +#[delete("/two-factor/webauthn/all", data = "")] +async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; - let Some(mut tf) = - TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await - else { + let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn as i32, &conn).await else { err!("Webauthn data not found!") }; - let mut data: Vec = serde_json::from_str(&tf.data)?; + let removed: Vec = serde_json::from_str(&tf.data)?; + let keys: Vec = removed.iter().map(|r| r.id).collect(); - let Some(item_pos) = data.iter().position(|r| r.id == id) else { - err!("Webauthn entry not found") - }; + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, true)?; + tf.delete(&conn).await?; - let removed_item = data.remove(item_pos); - tf.data = serde_json::to_string(&data)?; - tf.save(&conn).await?; - drop(tf); + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + let migrated: Vec = removed.into_iter().filter(|r| r.migrated).collect(); // If entry is migrated from u2f, delete the u2f entry as well - if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await + if !migrated.is_empty() + && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f as i32, &conn).await { - let mut data: Vec = if let Ok(d) = serde_json::from_str(&u2f.data) { - d - } else { + let Ok(mut data) = serde_json::from_str::>(&u2f.data) else { err!("Error parsing U2F data") }; - data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); - let new_data_str = serde_json::to_string(&data)?; + data.retain(|old| migrated.iter().all(|m| old.reg.key_handle != m.credential.cred_id().as_slice())); - u2f.data = new_data_str; - u2f.save(&conn).await?; + if data.is_empty() { + u2f.delete(&conn).await?; + } else { + let new_data_str = serde_json::to_string(&data)?; + u2f.data = new_data_str; + u2f.save(&conn).await?; + } } - let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); + if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + } - Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" - }))) + Ok(()) } pub async fn get_webauthn_registrations( diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index eb3d6dfd..bd36de2a 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -10,9 +10,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, db::{ DbConn, models::{EventType, TwoFactor, TwoFactorType}, @@ -22,7 +25,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] + routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,] } struct HttpClientTransport { @@ -60,8 +63,7 @@ struct EnableYubikeyData { key4: Option, key5: Option, nfc: bool, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Deserialize, Serialize, Debug)] @@ -127,37 +129,30 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: let user_id = &user.uuid; let yubikey_type = TwoFactorType::YubiKey as i32; - let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; - - if let Some(r) = r { - let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; - - let mut result = jsonify_yubikeys(yubikey_metadata.keys); - - result["enabled"] = Value::Bool(true); - result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) - } else { - Ok(Json(json!({ - "enabled": false, - "object": "twoFactorU2f", - }))) - } + let (enabled, keys, yubikey_json) = + if let Some(r) = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + let enabled = !yubikey_metadata.keys.is_empty(); + let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone()); + result["enabled"] = Value::Bool(enabled); + result["nfc"] = Value::Bool(yubikey_metadata.nfc); + (enabled, yubikey_metadata.keys, result) + } else { + (false, Vec::new(), json!({"enabled": false})) + }; + Ok(Json(json!({ + "yubiKey": yubikey_json, + "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled), + }))) } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); + let yubikeys = parse_yubikeys(&data); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikeys, yubikeys.is_empty())?; // Check if we already have some data let mut yubikey_data = @@ -166,8 +161,6 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), }; - let yubikeys = parse_yubikeys(&data); - if yubikeys.is_empty() { // Return an error to prevent saving empty keys which would cause users not being able to login anymore. // To remove all keys users should click the `Deactivate all keys` button @@ -198,12 +191,9 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); - result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) + Ok(Json(json!({"yubiKey": result}))) } #[put("/two-factor/yubikey", data = "")] @@ -211,6 +201,26 @@ async fn activate_yubikey_put(data: Json, headers: Headers, c activate_yubikey(data, headers, conn).await } +#[delete("/two-factor/yubikey", data = "")] +async fn delete_yubikeys(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; + + r.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await; + } + + if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + } + + Ok(()) +} + pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { if response.len() != 44 { err!("Invalid Yubikey OTP length"); diff --git a/src/api/mod.rs b/src/api/mod.rs index 9a79ce95..a61048f4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -46,7 +46,7 @@ pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] diff --git a/src/auth.rs b/src/auth.rs index 762088e5..57f689d5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,6 @@ +#[path = "auth/two_factor.rs"] +pub mod two_factor; + #[path = "auth/send.rs"] pub mod send; pub type SendTokens = send::SendTokens; diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs new file mode 100644 index 00000000..2d02417a --- /dev/null +++ b/src/auth/two_factor.rs @@ -0,0 +1,221 @@ +use chrono::{TimeDelta, Utc}; +use serde::{de::DeserializeOwned, ser::Serialize}; +use std::sync::LazyLock; + +use crate::{ + CONFIG, + api::{ApiResult, EmptyResult}, + auth::{decode_jwt, encode_jwt}, + db::models::UserId, +}; + +static JWT_2FA_AUTH_ISSUER: LazyLock = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin())); + +#[derive(Serialize, Deserialize)] +pub struct TwopFactorClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: UserId, + + pub enabled: bool, + + pub claims: T, +} + +#[derive(Serialize, Deserialize)] +pub struct AuthenticatorClaims { + pub key: String, +} + +#[derive(Serialize, Deserialize)] +pub struct DuoClaims { + data: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct WebauthnClaims { + pub keys: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct YubikeyClaims { + pub keys: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq)] +pub struct DuoData { + pub host: String, // Duo API hostname + pub ik: String, // client id + pub sk: String, // client secret +} + +impl DuoData { + pub fn global() -> Option { + match (CONFIG._enable_duo(), CONFIG.duo_host()) { + (true, Some(host)) => Some(Self { + host, + ik: CONFIG.duo_ikey().unwrap(), + sk: CONFIG.duo_skey().unwrap(), + }), + _ => None, + } + } + pub fn msg(s: &str) -> Self { + Self { + host: s.into(), + ik: s.into(), + sk: s.into(), + } + } + pub fn secret() -> Self { + Self::msg("") + } + pub fn obscure(self) -> Self { + let mut host = self.host; + let mut ik = self.ik; + let mut sk = self.sk; + + let digits = 4; + let replaced = "************"; + + host.replace_range(digits.., replaced); + ik.replace_range(digits.., replaced); + sk.replace_range(digits.., replaced); + + Self { + host, + ik, + sk, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct EmailClaims { + pub email: Option, +} + +fn token(user_id: UserId, enabled: bool, claims: T) -> String { + let time_now = Utc::now(); + let claims = TwopFactorClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), + iss: JWT_2FA_AUTH_ISSUER.to_string(), + sub: user_id, + enabled, + claims, + }; + encode_jwt(&claims) +} + +fn validate(token: &str, user_id: &UserId, enabled: bool) -> ApiResult { + match decode_jwt::>(token, JWT_2FA_AUTH_ISSUER.to_string()) { + Ok(claims) => { + if claims.sub != *user_id { + err!("Invalid verification token: Invalid user"); + } + if claims.enabled != enabled { + err!("Invalid verification token: Invalid state"); + } + Ok(claims.claims) + } + Err(err) => err!(format!("Failed to decode verification token: {err}")), + } +} + +pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String { + token( + user_id, + enabled, + AuthenticatorClaims { + key, + }, + ) +} + +pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if claims.key != key { + err!("Invalid verification token: Invalid key"); + } + Ok(()) +} + +pub fn duo_token(user_id: UserId, data: Option, enabled: bool) -> String { + token( + user_id, + enabled, + DuoClaims { + data, + }, + ) +} + +// When disabling we check that it's the correct data +pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.data.as_ref() != data { + err!("Invalid verification token: Invalid duo data"); + } + Ok(()) +} + +pub fn email_token(user_id: UserId, email: Option, enabled: bool) -> String { + token( + user_id, + enabled, + EmailClaims { + email, + }, + ) +} + +// When disabling we check that it's the correct `email` +pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.email != Some(email) { + err!("Invalid verification token: Invalid email"); + } + Ok(()) +} + +pub fn webauthn_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + WebauthnClaims { + keys, + }, + ) +} + +pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} + +pub fn yubikey_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + YubikeyClaims { + keys, + }, + ) +} + +pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if *keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} From 829af9e4ff377bfde6be60f4009e6d91dd021846 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 25 Aug 2026 16:45:41 +0200 Subject: [PATCH 2/5] Fix 2FA Yubikeys --- src/api/core/two_factor/yubikey.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index bd36de2a..bcf43349 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -152,21 +152,23 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: let yubikeys = parse_yubikeys(&data); let mut user = headers.user; - two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikeys, yubikeys.is_empty())?; - - // Check if we already have some data - let mut yubikey_data = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { - Some(data) => data, - None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), - }; - if yubikeys.is_empty() { // Return an error to prevent saving empty keys which would cause users not being able to login anymore. // To remove all keys users should click the `Deactivate all keys` button err!("A key is required."); } + // Check if we already have some data + let mut yubikey_data = + if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { + let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?; + yd + } else { + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?; + TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()) + }; + // Ensure they are valid OTPs for yubikey in &yubikeys { if yubikey.is_empty() || yubikey.len() == 12 { From 4845b071bbdff821a8e71d9308cc8dbaa4565846 Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 26 Aug 2026 09:26:31 +0200 Subject: [PATCH 3/5] TwoFactor.find_by_user_and_type take enum parameter not i32 --- src/api/core/two_factor/authenticator.rs | 10 +++----- src/api/core/two_factor/duo.rs | 6 ++--- src/api/core/two_factor/email.rs | 24 ++++++++------------ src/api/core/two_factor/protected_actions.rs | 5 ++-- src/api/core/two_factor/webauthn.rs | 17 +++++++------- src/api/core/two_factor/yubikey.rs | 7 +++--- src/api/identity.rs | 4 ++-- src/db/models/two_factor.rs | 4 ++-- 8 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index deacd9d8..78e791f0 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -24,8 +24,7 @@ async fn generate_authenticator(data: Json, headers: Headers, data.validate(&user, false, &conn).await?; - let type_ = TwoFactorType::Authenticator as i32; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), @@ -117,8 +116,7 @@ pub async fn validate_totp_code( err!("Invalid TOTP secret") }; - let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await - { + let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await { Some(tf) => tf, _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), }; @@ -185,9 +183,7 @@ async fn disable_authenticator(data: Json, headers: He two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; - if let Some(twofactor) = - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator as i32, &conn).await - { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index e168eae1..a731a29c 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -149,7 +149,7 @@ async fn activate_duo_put(data: Json, headers: Headers, conn: DbC async fn disable_duo(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo as i32, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await { // Apply the same transformation than in `get_duo` to check we are disabling the correct one let duo = match to_user_duo_data(&twofactor) { DuoStatus::Global(_) => Some(DuoData::secret()), @@ -203,10 +203,8 @@ const DUO_PREFIX: &str = "TX"; const APP_PREFIX: &str = "APP"; async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { - let type_ = TwoFactorType::Duo as i32; - // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else { return DuoStatus::Disabled(DuoData::global().is_some()); }; diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 3472830c..e63bc392 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -110,8 +110,8 @@ async fn send_email_login(data: Json, client_headers: Client /// Generate the token, save the data for later verification and send email to user pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::Email as i32; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -134,7 +134,7 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { let twofactor_data = EmailTokenData::from_json(&x.data)?; (true, Some(twofactor_data.email)) } else { @@ -170,9 +170,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - err!("Email 2FA is disabled") } - let type_ = TwoFactorType::Email as i32; - - if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { tf.delete(&conn).await?; } @@ -205,10 +203,9 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; - let mut twofactor = - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge as i32, &conn) - .await - .map_res("Two factor not found")?; + let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn) + .await + .map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -236,7 +233,7 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes async fn disable_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { let twofactor_data = EmailTokenData::from_json(&twofactor.data)?; two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?; @@ -261,9 +258,8 @@ pub async fn validate_email_code_str( conn: &DbConn, ) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) - .await - .map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index c0c1b5e8..1cafede0 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one - if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await - { + if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; @@ -125,7 +124,7 @@ pub async fn validate_protected_action_otp( delete_if_valid: bool, conn: &DbConn, ) -> EmptyResult { - let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) + let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn) .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 6124fd96..7385dc87 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -269,8 +269,9 @@ async fn activate_webauthn(data: Json, headers: Headers, con two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, false)?; // Retrieve and delete the saved challenge state - let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; - let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + let state = if let Some(tf) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await + { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; state @@ -317,7 +318,7 @@ async fn activate_webauthn_put(data: Json, headers: Headers, async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn as i32, &conn).await else { + let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, &conn).await else { err!("Webauthn data not found!") }; @@ -332,7 +333,7 @@ async fn delete_webauthn(data: Json, headers: Headers, co let migrated: Vec = removed.into_iter().filter(|r| r.migrated).collect(); // If entry is migrated from u2f, delete the u2f entry as well if !migrated.is_empty() - && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f as i32, &conn).await + && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, &conn).await { let Ok(mut data) = serde_json::from_str::>(&u2f.data) else { err!("Error parsing U2F data") @@ -360,8 +361,7 @@ pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { - let type_ = TwoFactorType::Webauthn as i32; - match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } @@ -408,8 +408,9 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes } pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::WebauthnLoginChallenge as i32; - let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + let mut state = if let Some(tf) = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await + { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index bcf43349..8cb701ee 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -127,10 +127,9 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: data.validate(&user, false, &conn).await?; let user_id = &user.uuid; - let yubikey_type = TwoFactorType::YubiKey as i32; let (enabled, keys, yubikey_json) = - if let Some(r) = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await { + if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; let enabled = !yubikey_metadata.keys.is_empty(); let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone()); @@ -160,7 +159,7 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: // Check if we already have some data let mut yubikey_data = - if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { + if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?; two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?; yd @@ -207,7 +206,7 @@ async fn activate_yubikey_put(data: Json, headers: Headers, c async fn delete_yubikeys(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { + if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; diff --git a/src/api/identity.rs b/src/api/identity.rs index 2b1ddfb1..6308f413 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -993,7 +993,7 @@ async fn json_err_twofactor( } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No YubiKey devices registered") }; @@ -1005,7 +1005,7 @@ async fn json_err_twofactor( } Some(tf_type @ TwoFactorType::Email) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No twofactor email registered") }; diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..051ea4df 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -137,11 +137,11 @@ impl TwoFactor { .await } - pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) - .filter(twofactor::atype.eq(atype)) + .filter(twofactor::atype.eq(atype as i32)) .first::(conn) .ok() }) From 7ab6d91a1f94540df424c7b2d6e769361c2c9849 Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 26 Aug 2026 09:31:14 +0200 Subject: [PATCH 4/5] log_user_event take enum parameter not i32 --- src/api/core/accounts.rs | 12 +++++------- src/api/core/events.rs | 4 ++-- src/api/core/two_factor/authenticator.rs | 5 ++--- src/api/core/two_factor/duo.rs | 5 ++--- src/api/core/two_factor/email.rs | 5 ++--- src/api/core/two_factor/webauthn.rs | 4 ++-- src/api/core/two_factor/yubikey.rs | 5 ++--- src/api/identity.rs | 7 +++---- 8 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 626f22bb..2cfb4df4 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -487,8 +487,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: Membership::accept_user_invitations(&user.uuid, &conn).await?; } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; user.save(&conn).await?; @@ -613,8 +612,7 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("Invalid password") } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let (new_master_password_hash, new_key) = if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { @@ -1620,7 +1618,7 @@ async fn post_auth_request( nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( - EventType::UserRequestedDeviceApproval as i32, + EventType::UserRequestedDeviceApproval, &user.uuid, client_headers.device_type, &client_headers.ip.ip, @@ -1714,7 +1712,7 @@ async fn put_auth_request( nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; log_user_event( - EventType::OrganizationUserApprovedAuthRequest as i32, + EventType::OrganizationUserApprovedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1725,7 +1723,7 @@ async fn put_auth_request( // If denied, there's no reason to keep the request auth_request.delete(&conn).await?; log_user_event( - EventType::OrganizationUserRejectedAuthRequest as i32, + EventType::OrganizationUserRejectedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 2c437a36..1074dee6 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -225,11 +225,11 @@ async fn post_events_collect(data: Json>, headers: Headers, Ok(()) } -pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } - log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await; + log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await; } async fn log_user_event_impl( diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 78e791f0..a020dc74 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -74,7 +74,7 @@ async fn activate_authenticator(data: Json, headers: He generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ "authenticator": json!({ @@ -186,8 +186,7 @@ async fn disable_authenticator(data: Json, headers: He if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { if twofactor.data == data.key { twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } else { err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index a731a29c..8e7616f3 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -128,7 +128,7 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ "duo": json!({ @@ -160,8 +160,7 @@ async fn disable_duo(data: Json, headers: Headers, conn: two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?; twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index e63bc392..b37061a1 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -224,7 +224,7 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({}))) } @@ -238,8 +238,7 @@ async fn disable_email(data: Json, headers: Headers, conn two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?; twofactor.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index 7385dc87..f710cf61 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -297,7 +297,7 @@ async fn activate_webauthn(data: Json, headers: Headers, con .await?; generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); @@ -328,7 +328,7 @@ async fn delete_webauthn(data: Json, headers: Headers, co two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, true)?; tf.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let migrated: Vec = removed.into_iter().filter(|r| r.migrated).collect(); // If entry is migrated from u2f, delete the u2f entry as well diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 8cb701ee..2f688238 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -189,7 +189,7 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); result["enabled"] = Value::Bool(true); @@ -211,8 +211,7 @@ async fn delete_yubikeys(data: Json, headers: Headers, co two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; r.delete(&conn).await?; - log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { diff --git a/src/api/identity.rs b/src/api/identity.rs index 6308f413..41b7825e 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -129,7 +129,7 @@ async fn login( match &login_result { Ok(_) => { log_user_event( - EventType::UserLoggedIn as i32, + EventType::UserLoggedIn, &user_id, client_header.device_type, &client_header.ip.ip, @@ -139,8 +139,7 @@ async fn login( } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) - .await; + log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await; } } } @@ -907,7 +906,7 @@ async fn twofactor_auth( TwoFactor::delete_all_by_user(&user.uuid, conn).await?; enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; - log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; + log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await; // Remove the recovery code, not needed without twofactors user.totp_recover = None; From 598d01b51efc83a1cdd22c5467f70696c95e9097 Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 3 Sep 2026 11:39:06 +0200 Subject: [PATCH 5/5] Fix 2fa webauthn --- src/api/core/two_factor/webauthn.rs | 90 ++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index f710cf61..7e8d6bde 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, sync::LazyLock, time::Duration}; +use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -30,7 +30,6 @@ use crate::{ models::{EventType, TwoFactor, TwoFactorType, UserId}, }, error::Error, - util::NumberOrString, }; static WEBAUTHN: LazyLock = LazyLock::new(|| { @@ -48,7 +47,14 @@ static WEBAUTHN: LazyLock = LazyLock::new(|| { }); pub fn routes() -> Vec { - routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] + routes![ + get_webauthn, + generate_webauthn_challenge, + activate_webauthn, + activate_webauthn_put, + delete_webauthn, + delete_webauthns + ] } // Some old u2f structs still needed for migrating from u2f to WebAuthn @@ -180,7 +186,7 @@ async fn generate_webauthn_challenge(data: Json, headers: #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { - id: NumberOrString, // 1..5 + id: i32, name: String, device_response: RegisterPublicKeyCredentialCopy, user_verification_token: String, @@ -266,7 +272,7 @@ async fn activate_webauthn(data: Json, headers: Headers, con let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; let keys: Vec = registrations.iter().map(|r| r.id).collect(); - two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, false)?; + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?; // Retrieve and delete the saved challenge state let state = if let Some(tf) = @@ -284,7 +290,7 @@ async fn activate_webauthn(data: Json, headers: Headers, con // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { - id: data.id.into_i32()?, + id: data.id, name: data.name, migrated: false, @@ -314,44 +320,84 @@ async fn activate_webauthn_put(data: Json, headers: Headers, activate_webauthn(data, headers, conn).await } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteWebauthnData { + id: i32, + user_verification_token: String, +} + +#[delete("/two-factor/webauthn", data = "")] +async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await +} + #[delete("/two-factor/webauthn/all", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn delete_webauthns(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await +} + +async fn inner_delete_webauthns( + token: &str, + retain: impl Fn(&WebauthnRegistration) -> bool, + headers: Headers, + conn: &DbConn, +) -> EmptyResult { let user = headers.user; - let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, &conn).await else { + let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else { err!("Webauthn data not found!") }; - let removed: Vec = serde_json::from_str(&tf.data)?; - let keys: Vec = removed.iter().map(|r| r.id).collect(); + let mut keys: Vec = serde_json::from_str(&tf.data)?; + let keys_id: Vec = keys.iter().map(|r| r.id).collect(); - two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, true)?; - tf.delete(&conn).await?; + two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?; - log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + let mut removed: HashSet> = HashSet::new(); + let mut migrated = false; + + keys.retain(|key| { + let retained = retain(key); + if !retained { + removed.insert(key.credential.cred_id().to_vec()); + migrated = migrated || key.migrated; + } + retained + }); + + if removed.is_empty() { + err!("Webauthn entry not found") + } + + if keys.is_empty() { + tf.delete(conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await; + } else { + tf.data = serde_json::to_string(&keys)?; + tf.save(conn).await?; + drop(tf); + } - let migrated: Vec = removed.into_iter().filter(|r| r.migrated).collect(); // If entry is migrated from u2f, delete the u2f entry as well - if !migrated.is_empty() - && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, &conn).await - { + if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await { let Ok(mut data) = serde_json::from_str::>(&u2f.data) else { err!("Error parsing U2F data") }; - data.retain(|old| migrated.iter().all(|m| old.reg.key_handle != m.credential.cred_id().as_slice())); + data.retain(|old| !removed.contains(&old.reg.key_handle)); if data.is_empty() { - u2f.delete(&conn).await?; + u2f.delete(conn).await?; } else { let new_data_str = serde_json::to_string(&data)?; u2f.data = new_data_str; - u2f.save(&conn).await?; + u2f.save(conn).await?; } } - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; + if keys.is_empty() && TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() { + super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await?; } Ok(())