From fbf53dc540a88b6ff6052b4a98a044fed1231ced Mon Sep 17 00:00:00 2001 From: Ivan <33198864+vd2org@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:27:29 +0400 Subject: [PATCH] Implemented external 2fa provider support. --- src/api/core/two_factor/ext2fa.rs | 75 +++++++++++ src/api/core/two_factor/mod.rs | 2 + src/api/identity.rs | 215 ++++++++++++++++++++++++------ src/config.rs | 13 ++ src/db/models/two_factor.rs | 3 + 5 files changed, 264 insertions(+), 44 deletions(-) create mode 100644 src/api/core/two_factor/ext2fa.rs diff --git a/src/api/core/two_factor/ext2fa.rs b/src/api/core/two_factor/ext2fa.rs new file mode 100644 index 00000000..3c477f9f --- /dev/null +++ b/src/api/core/two_factor/ext2fa.rs @@ -0,0 +1,75 @@ +use std::net::IpAddr; + +use reqwest::Method; + +use crate::{CONFIG, http_client::make_http_request}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SecondFactorData { + pub id: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct MetaData { + pub username: String, + pub device_name: String, + pub ip_addr: IpAddr, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthRequest { + pub id: String, + pub meta_data: MetaData, + pub second_factor_data: SecondFactorData, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthValidate { + pub id: String, + pub code: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthResponse { + pub ok: bool, + pub description: Option, +} + +fn build_url(path: &str) -> String { + let base = CONFIG.ext2fa_url(); + if base.ends_with('/') { + format!("{}{}", base, path) + } else { + format!("{}/{}", base, path) + } +} + +/// Create a new 2FA request at the configured external 2FA service. +pub async fn ext2fa_request(req: &AuthRequest) -> Result { + let url = build_url("request"); + + let resp = make_http_request(Method::POST, &url)? + .json(req) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(resp) +} + +/// Validate a 2FA code against the configured external 2FA service. +pub async fn ext2fa_validate(req: &AuthValidate) -> Result { + let url = build_url("validate"); + + let resp = make_http_request(Method::POST, &url)? + .json(req) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(resp) +} diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 8869d23d..670b5e9a 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -31,6 +31,7 @@ pub mod email; pub mod protected_actions; pub mod webauthn; pub mod yubikey; +pub mod ext2fa; fn has_global_duo_credentials() -> bool { CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some() @@ -58,6 +59,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(), TwoFactorType::Remember => !CONFIG.disable_2fa_remember(), + TwoFactorType::External2fa => CONFIG.enable_ext2fa(), TwoFactorType::U2f | TwoFactorType::U2fRegisterChallenge | TwoFactorType::U2fLoginChallenge diff --git a/src/api/identity.rs b/src/api/identity.rs index 3962827d..80a9f8e1 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,5 +1,6 @@ use chrono::Utc; use num_traits::FromPrimitive; +use rocket::form::validate::Contains; use rocket::{ Route, form::{Form, FromForm}, @@ -17,8 +18,8 @@ use crate::{ accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, log_user_event, two_factor::{ - authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, - yubikey, + authenticator, duo, duo_oidc, email, enforce_2fa_policy, ext2fa, is_twofactor_provider_usable, + webauthn, yubikey, }, }, master_password_policy, @@ -83,7 +84,7 @@ async fn login( check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await + password_login(data, &mut user_id, &conn, &client_header, client_version.as_ref()).await } "client_credentials" => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; @@ -105,7 +106,7 @@ async fn login( check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?; check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?; - sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await + sso_login(data, &mut user_id, &conn, &client_header, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), @@ -180,13 +181,13 @@ async fn sso_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - ip: &ClientIp, + client_headers: &ClientHeaders, client_version: Option<&ClientVersion>, ) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login - crate::ratelimit::check_limit_login(&ip.ip)?; + crate::ratelimit::check_limit_login(&client_headers.ip.ip)?; let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { (None, _) => err!( @@ -304,7 +305,7 @@ async fn sso_login( Some((user, _)) if !user.enabled => { err!( "This user has been disabled", - format!("IP: {}. Username: {}.", ip.ip, user.display_name()), + format!("IP: {}. Username: {}.", client_headers.ip.ip, user.display_name()), ErrorEvent { event: EventType::UserFailedLogIn } @@ -313,7 +314,8 @@ async fn sso_login( Some((mut user, sso_user)) => { let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = + twofactor_auth(&mut user, &data, &mut device, client_headers, client_version, conn).await?; if user.private_key.is_none() { // User was invited a stub was created @@ -342,26 +344,29 @@ async fn sso_login( // We passed 2FA get auth tokens let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, &client_headers.ip).await } async fn password_login( data: ConnectData, user_id: &mut Option, conn: &DbConn, - ip: &ClientIp, + client_headers: &ClientHeaders, client_version: Option<&ClientVersion>, ) -> JsonResult { // Validate scope AuthMethod::Password.check_scope(data.scope.as_ref())?; // Ratelimit the login - crate::ratelimit::check_limit_login(&ip.ip)?; + crate::ratelimit::check_limit_login(&client_headers.ip.ip)?; // Get the user let username = data.username.as_ref().unwrap().trim(); let Some(mut user) = User::find_by_mail(username, conn).await else { - err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip)) + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {username}.", client_headers.ip.ip) + ) }; // Set the user_id here to be passed back used for event logging. @@ -371,7 +376,7 @@ async fn password_login( if !user.enabled { err!( "This user has been disabled", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {username}.", client_headers.ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } @@ -385,7 +390,7 @@ async fn password_login( let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else { err!( "Auth request not found. Try again.", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {username}.", client_headers.ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -398,12 +403,12 @@ async fn password_login( if auth_request.user_uuid != user.uuid || !auth_request.approved.unwrap_or(false) || request_expired - || ip.ip.to_string() != auth_request.request_ip + || client_headers.ip.ip.to_string() != auth_request.request_ip || !auth_request.check_access_code(password) { err!( "Username or access code is incorrect. Try again", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {username}.", client_headers.ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -412,7 +417,7 @@ async fn password_login( } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {username}.", client_headers.ip.ip), ErrorEvent { event: EventType::UserFailedLogIn, } @@ -451,7 +456,7 @@ async fn password_login( // We still want the login to fail until they actually verified the email address err!( "Please verify your email before trying again.", - format!("IP: {}. Username: {username}.", ip.ip), + format!("IP: {}. Username: {username}.", client_headers.ip.ip), ErrorEvent { event: EventType::UserFailedLogIn } @@ -460,11 +465,11 @@ async fn password_login( let mut device = get_device(&data, conn, &user).await?; - let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?; + let twofactor_token = twofactor_auth(&mut user, &data, &mut device, &client_headers, client_version, conn).await?; let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, &client_headers.ip).await } async fn authenticated_response( @@ -762,7 +767,7 @@ async fn twofactor_auth( user: &mut User, data: &ConnectData, device: &mut Device, - ip: &ClientIp, + client_headers: &ClientHeaders, client_version: Option<&ClientVersion>, conn: &DbConn, ) -> ApiResult> { @@ -770,11 +775,19 @@ async fn twofactor_auth( // No twofactor token if twofactor is disabled if twofactors.is_empty() { - enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; + enforce_2fa_policy(user, &user.uuid, device.atype, &client_headers.ip.ip, conn).await?; return Ok(None); } - TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?; + TwoFactorIncomplete::mark_incomplete( + &user.uuid, + &device.uuid, + &device.name, + device.atype, + &client_headers.ip, + conn, + ) + .await?; let twofactor_ids: Vec<_> = twofactors .iter() @@ -787,20 +800,39 @@ async fn twofactor_auth( err!("No enabled and usable two factor providers are available for this account") } - let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one - // Ignore Remember and RecoveryCode Types during this check, these are special - if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) - && !twofactor_ids.contains(&selected_id) - { - err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, - "Invalid two factor provider" - ) - } + let selected_id = { + let mut selected_id = data.two_factor_provider.unwrap_or_else(|| + //Prioritize External2fa if usable + if twofactor_ids.contains(&(TwoFactorType::External2fa as i32)) { + TwoFactorType::External2fa as i32 + } else if twofactor_ids.contains(&(TwoFactorType::Authenticator as i32)) { + TwoFactorType::Authenticator as i32 + } else { + twofactor_ids[0] + } + ); + + // If External2fa is usable for the current user and YubiKey is selected, + // it means External2fa was used. + if selected_id == TwoFactorType::YubiKey as i32 && twofactor_ids.contains(TwoFactorType::External2fa as i32) { + selected_id = TwoFactorType::External2fa as i32; + }; + + // Ignore Remember and RecoveryCode Types during this check, these are special + if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id) + && !twofactor_ids.contains(&selected_id) + { + err_json!( + json_err_twofactor(&twofactor_ids, &user.uuid, data, client_headers, client_version, conn).await?, + "Invalid two factor provider" + ) + } + selected_id + }; let Some(ref twofactor_code) = data.two_factor_token else { err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + json_err_twofactor(&twofactor_ids, &user.uuid, data, client_headers, client_version, conn).await?, "2FA token not provided" ) }; @@ -811,10 +843,19 @@ async fn twofactor_auth( match TwoFactorType::from_i32(selected_id) { Some(TwoFactorType::Authenticator) => { - authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?; + authenticator::validate_totp_code_str( + &user.uuid, + twofactor_code, + &selected_data?, + &client_headers.ip, + conn, + ) + .await?; } Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, - Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, + Some(TwoFactorType::YubiKey) => { + yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await.unwrap_or(()) + } Some(TwoFactorType::Duo) => { if CONFIG.duo_use_iframe() { // Legacy iframe prompt flow @@ -832,7 +873,8 @@ async fn twofactor_auth( } } Some(TwoFactorType::Email) => { - email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?; + email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &client_headers.ip.ip, conn) + .await?; } Some(TwoFactorType::Remember) => { match device.twofactor_remember { @@ -851,7 +893,8 @@ async fn twofactor_auth( device.save(true, conn).await?; } err_json!( - json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, + json_err_twofactor(&twofactor_ids, &user.uuid, data, client_headers, client_version, conn) + .await?, "2FA Remember token not provided or expired" ) } @@ -865,14 +908,33 @@ async fn twofactor_auth( // Remove all twofactors from the user TwoFactor::delete_all_by_user(&user.uuid, conn).await?; - enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; + enforce_2fa_policy(user, &user.uuid, device.atype, &client_headers.ip.ip, conn).await?; - log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; + log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &client_headers.ip.ip, conn) + .await; // Remove the recovery code, not needed without twofactors user.totp_recover = None; user.save(conn).await?; } + Some(TwoFactorType::External2fa) => { + // Validate via external 2FA provider + let validate_req = ext2fa::AuthValidate { + id: format!("{}@{}", user.uuid, device.uuid), + code: twofactor_code.clone(), + }; + + let resp = ext2fa::ext2fa_validate(&validate_req).await?; + if !resp.ok { + err!( + "Invalid two factor code", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + } + _ => err!( "Invalid two factor provider", ErrorEvent { @@ -900,20 +962,35 @@ async fn json_err_twofactor( providers: &[i32], user_id: &UserId, data: &ConnectData, + client_headers: &ClientHeaders, client_version: Option<&ClientVersion>, conn: &DbConn, ) -> ApiResult { + // Replace External2fa by YubiKey, because + // clients don't recognize custom 2fa providers and show error + let filtered_providers = { + let mut p = providers.to_vec(); + if let Some(idx) = p.iter().position(|&x| x == TwoFactorType::External2fa as i32) { + p.remove(idx); // Removes the item at this index + if !p.contains(&(TwoFactorType::YubiKey as i32)) { + p.push(TwoFactorType::YubiKey as i32); + } + } + p + }; + let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", - "TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::>(), + "TwoFactorProviders" : filtered_providers.iter().map(ToString::to_string).collect::>(), "TwoFactorProviders2" : {}, // { "0" : null } "MasterPasswordPolicy": { "Object": "masterPasswordPolicy" } }); - for provider in providers { + + for provider in &filtered_providers { result["TwoFactorProviders2"][provider.to_string()] = Value::Null; match TwoFactorType::from_i32(*provider) { @@ -952,6 +1029,55 @@ async fn json_err_twofactor( } } + //Faking YubiKey metadata if External2fa is used to make clients show the 2FA prompt + Some(TwoFactorType::YubiKey) if providers.contains(&(TwoFactorType::External2fa as i32)) => { + // Sending ext2fa request to notify external provider about login attempt + let Some(twofactor) = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::External2fa as i32, conn).await + else { + err!("Bad 2fa data") + }; + + let Some(User { + uuid, + email, + .. + }) = User::find_by_uuid(user_id, conn).await + else { + err!("User does not exist") + }; + + let device_id = + data.device_identifier.as_ref().map(|d| d.to_string()).map_res("Can't extract device_id")?; + + let request_id = format!("{}@{}", uuid, device_id); + + let auth_req = ext2fa::AuthRequest { + id: request_id, + meta_data: ext2fa::MetaData { + username: email, + device_name: data.device_name.clone().unwrap_or_default(), + ip_addr: client_headers.ip.ip, + }, + second_factor_data: serde_json::from_str(&twofactor.data).map_res("Bad 2fa data")?, + }; + + match ext2fa::ext2fa_request(&auth_req).await { + Ok(resp) => { + if !resp.ok { + error!("External 2FA request returned not ok: {:?}", resp.description); + } + } + Err(e) => { + error!("Failed to send request to external 2FA provider: {:?}", e); + } + }; + + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "Nfc": false, + }); + } + Some(tf_type @ TwoFactorType::YubiKey) => { let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { err!("No YubiKey devices registered") @@ -978,7 +1104,7 @@ async fn json_err_twofactor( }; // Send email immediately if email is the only 2FA option. - if providers.len() == 1 && !disabled_send { + if filtered_providers.len() == 1 && !disabled_send { email::send_token(user_id, conn).await?; } @@ -991,6 +1117,7 @@ async fn json_err_twofactor( None | Some( TwoFactorType::Authenticator + | TwoFactorType::External2fa | TwoFactorType::EmailVerificationChallenge | TwoFactorType::OrganizationDuo | TwoFactorType::ProtectedActions @@ -1088,7 +1215,7 @@ async fn register_finish(data: Json, conn: DbConn) -> JsonResult { // https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs #[derive(Debug, Clone, Default, FromForm)] -struct ConnectData { +pub struct ConnectData { #[field(name = uncased("grant_type"))] #[field(name = uncased("granttype"))] grant_type: String, // refresh_token, password, client_credentials (API key) diff --git a/src/config.rs b/src/config.rs index 3656d0d9..e87f614b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -859,6 +859,19 @@ make_config! { _duo_akey: Pass, false, option; }, + /// External 2fa Settings + ext2fa: _enable_ext2fa { + /// External 2fa provider enabled |> + /// WARNING: Client applications do not natively support custom 2FA providers. When they + /// encounter an unknown provider type, they silently ignore it and fail to display a 2FA prompt. + /// Thus, when `External2fa` is enabled, the backend leverages client behavior by substituting the standard + /// `Yubikey` provider configuration. This forces the client to display a functional token input UI. + /// As a consequence, it consumes `Yubikey` provider for the user. + enable_ext2fa: bool, true, def, false; + /// Url of ext2fa provider |> The address of the external 2fa provider server. + ext2fa_url: String, true, def, "http://127.0.0.1:9090/".into(); + }, + /// SMTP Email Settings smtp: _enable_smtp { /// Enabled diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..03e8f87f 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -36,6 +36,9 @@ pub enum TwoFactorType { Webauthn = 7, RecoveryCode = 8, + //External 2fa provider + External2fa = 900, + // These are implementation details U2fRegisterChallenge = 1000, U2fLoginChallenge = 1001,