From b4b2701905752f90080dd46ba10a90c5c584a38e Mon Sep 17 00:00:00 2001 From: 0x0fbc <10455804+0x0fbc@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:50:35 -0500 Subject: [PATCH] Add support for MFA with Duo's Universal Prompt (#4637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add initial working Duo Universal Prompt support. * Add db schema and models for Duo 2FA state storage * store duo states in the database and validate during authentication * cleanup & comments * bump state/nonce length * replace stray use of TimeDelta * more cleanup * bind Duo oauth flow to device id, drop redundant device type handling * drop redundant alphanum string generation code * error handling cleanup * directly use JWT_VALIDITY_SECS constant instead of copying it to DuoClient instances * remove redundant explicit returns, rustfmt * rearrange constants, update comments, error message * override charset on duo state column to ascii for mysql * Reduce twofactor_duo_ctx state/nonce column size in postgres and maria * Add fixes suggested by clippy * rustfmt * Update to use the make_http_request * Don't handle OrganizationDuo * move Duo API endpoint fmt strings out of macros and into format! calls * Add missing indentation Co-authored-by: Daniel García * remove redundant expiry check when purging Duo contexts --------- Co-authored-by: BlackDex Co-authored-by: Daniel García --- .env.template | 16 +- .../down.sql | 1 + .../up.sql | 8 + .../down.sql | 1 + .../up.sql | 8 + .../down.sql | 1 + .../up.sql | 8 + src/api/core/two_factor/duo.rs | 2 +- src/api/core/two_factor/duo_oidc.rs | 500 ++++++++++++++++++ src/api/core/two_factor/mod.rs | 1 + src/api/identity.rs | 61 ++- src/config.rs | 6 +- src/db/models/mod.rs | 2 + src/db/models/two_factor_duo_context.rs | 84 +++ src/db/schemas/mysql/schema.rs | 9 + src/db/schemas/postgresql/schema.rs | 9 + src/db/schemas/sqlite/schema.rs | 9 + src/main.rs | 8 + 18 files changed, 719 insertions(+), 15 deletions(-) create mode 100644 migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql create mode 100644 migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql create mode 100644 migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql create mode 100644 migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql create mode 100644 migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql create mode 100644 migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql create mode 100644 src/api/core/two_factor/duo_oidc.rs create mode 100644 src/db/models/two_factor_duo_context.rs diff --git a/.env.template b/.env.template index 9e36a51a..b6605910 100644 --- a/.env.template +++ b/.env.template @@ -152,6 +152,10 @@ ## Cron schedule of the job that cleans old auth requests from the auth request. ## Defaults to every minute. Set blank to disable this job. # AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *" +## +## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. +## Defaults to every minute. Set blank to disable this job. +# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *" ######################## ### General settings ### @@ -423,15 +427,21 @@ # YUBICO_SERVER=http://yourdomain.com/wsapi/2.0/verify ## Duo Settings -## You need to configure all options to enable global Duo support, otherwise users would need to configure it themselves +## You need to configure the DUO_IKEY, DUO_SKEY, and DUO_HOST options to enable global Duo support. +## Otherwise users will need to configure it themselves. ## Create an account and protect an application as mentioned in this link (only the first step, not the rest): ## https://help.bitwarden.com/article/setup-two-step-login-duo/#create-a-duo-security-account ## Then set the following options, based on the values obtained from the last step: -# DUO_IKEY= -# DUO_SKEY= +# DUO_IKEY= +# DUO_SKEY= # DUO_HOST= ## After that, you should be able to follow the rest of the guide linked above, ## ignoring the fields that ask for the values that you already configured beforehand. +## +## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'. +## Duo no longer supports this, but it still works for some integrations. +## If you aren't sure, leave this alone. +# DUO_USE_IFRAME=false ## Email 2FA settings ## Email token size diff --git a/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql b/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql new file mode 100644 index 00000000..7af867a2 --- /dev/null +++ b/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_duo_ctx; \ No newline at end of file diff --git a/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql b/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql new file mode 100644 index 00000000..29091791 --- /dev/null +++ b/migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE twofactor_duo_ctx ( + state VARCHAR(64) NOT NULL, + user_email VARCHAR(255) NOT NULL, + nonce VARCHAR(64) NOT NULL, + exp BIGINT NOT NULL, + + PRIMARY KEY (state) +); \ No newline at end of file diff --git a/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql b/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql new file mode 100644 index 00000000..0b5d4cd8 --- /dev/null +++ b/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_duo_ctx; diff --git a/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql b/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql new file mode 100644 index 00000000..ebc8be1b --- /dev/null +++ b/migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE twofactor_duo_ctx ( + state VARCHAR(64) NOT NULL, + user_email VARCHAR(255) NOT NULL, + nonce VARCHAR(64) NOT NULL, + exp BIGINT NOT NULL, + + PRIMARY KEY (state) +); \ No newline at end of file diff --git a/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql b/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql new file mode 100644 index 00000000..7af867a2 --- /dev/null +++ b/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql @@ -0,0 +1 @@ +DROP TABLE twofactor_duo_ctx; \ No newline at end of file diff --git a/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql b/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql new file mode 100644 index 00000000..40d8e52f --- /dev/null +++ b/migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE twofactor_duo_ctx ( + state TEXT NOT NULL, + user_email TEXT NOT NULL, + nonce TEXT NOT NULL, + exp INTEGER NOT NULL, + + PRIMARY KEY (state) +); diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 8554999c..3993397c 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -252,7 +252,7 @@ async fn get_user_duo_data(uuid: &str, conn: &mut DbConn) -> DuoStatus { } // let (ik, sk, ak, host) = get_duo_keys(); -async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiResult<(String, String, String, String)> { +pub(crate) async fn get_duo_keys_email(email: &str, conn: &mut DbConn) -> ApiResult<(String, String, String, String)> { let data = match User::find_by_mail(email, conn).await { Some(u) => get_user_duo_data(&u.uuid, conn).await.data(), _ => DuoData::global(), diff --git a/src/api/core/two_factor/duo_oidc.rs b/src/api/core/two_factor/duo_oidc.rs new file mode 100644 index 00000000..a0ce709c --- /dev/null +++ b/src/api/core/two_factor/duo_oidc.rs @@ -0,0 +1,500 @@ +use chrono::Utc; +use data_encoding::HEXLOWER; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use reqwest::{header, StatusCode}; +use ring::digest::{digest, Digest, SHA512_256}; +use serde::Serialize; +use std::collections::HashMap; + +use crate::{ + api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, + crypto, + db::{ + models::{EventType, TwoFactorDuoContext}, + DbConn, DbPool, + }, + error::Error, + http_client::make_http_request, + CONFIG, +}; +use url::Url; + +// The location on this service that Duo should redirect users to. For us, this is a bridge +// built in to the Bitwarden clients. +// See: https://github.com/bitwarden/clients/blob/main/apps/web/src/connectors/duo-redirect.ts +const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; + +// Number of seconds that a JWT we generate for Duo should be valid for. +const JWT_VALIDITY_SECS: i64 = 300; + +// Number of seconds that a Duo context stored in the database should be valid for. +const CTX_VALIDITY_SECS: i64 = 300; + +// Expected algorithm used by Duo to sign JWTs. +const DUO_RESP_SIGNATURE_ALG: Algorithm = Algorithm::HS512; + +// Signature algorithm we're using to sign JWTs for Duo. Must be either HS512 or HS256. +const JWT_SIGNATURE_ALG: Algorithm = Algorithm::HS512; + +// Size of random strings for state and nonce. Must be at least 16 characters and at most 1024 characters. +// If increasing this above 64, also increase the size of the twofactor_duo_ctx.state and +// twofactor_duo_ctx.nonce database columns for postgres and mariadb. +const STATE_LENGTH: usize = 64; + +// client_assertion payload for health checks and obtaining MFA results. +#[derive(Debug, Serialize, Deserialize)] +struct ClientAssertion { + pub iss: String, + pub sub: String, + pub aud: String, + pub exp: i64, + pub jti: String, + pub iat: i64, +} + +// authorization request payload sent with clients to Duo for MFA +#[derive(Debug, Serialize, Deserialize)] +struct AuthorizationRequest { + pub response_type: String, + pub scope: String, + pub exp: i64, + pub client_id: String, + pub redirect_uri: String, + pub state: String, + pub duo_uname: String, + pub iss: String, + pub aud: String, + pub nonce: String, +} + +// Duo service health check responses +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum HealthCheckResponse { + HealthOK { + stat: String, + }, + HealthFail { + message: String, + message_detail: String, + }, +} + +// Outer structure of response when exchanging authz code for MFA results +#[derive(Debug, Serialize, Deserialize)] +struct IdTokenResponse { + id_token: String, // IdTokenClaims + access_token: String, + expires_in: i64, + token_type: String, +} + +// Inner structure of IdTokenResponse.id_token +#[derive(Debug, Serialize, Deserialize)] +struct IdTokenClaims { + preferred_username: String, + nonce: String, +} + +// Duo OIDC Authorization Client +// See https://duo.com/docs/oauthapi +struct DuoClient { + client_id: String, // Duo Client ID (DuoData.ik) + client_secret: String, // Duo Client Secret (DuoData.sk) + api_host: String, // Duo API hostname (DuoData.host) + redirect_uri: String, // URL in this application clients should call for MFA verification +} + +impl DuoClient { + // Construct a new DuoClient + fn new(client_id: String, client_secret: String, api_host: String, redirect_uri: String) -> DuoClient { + DuoClient { + client_id, + client_secret, + api_host, + redirect_uri, + } + } + + // Generate a client assertion for health checks and authorization code exchange. + fn new_client_assertion(&self, url: &str) -> ClientAssertion { + let now = Utc::now().timestamp(); + let jwt_id = crypto::get_random_string_alphanum(STATE_LENGTH); + + ClientAssertion { + iss: self.client_id.clone(), + sub: self.client_id.clone(), + aud: url.to_string(), + exp: now + JWT_VALIDITY_SECS, + jti: jwt_id, + iat: now, + } + } + + // Given a serde-serializable struct, attempt to encode it as a JWT + fn encode_duo_jwt(&self, jwt_payload: T) -> Result { + match jsonwebtoken::encode( + &Header::new(JWT_SIGNATURE_ALG), + &jwt_payload, + &EncodingKey::from_secret(self.client_secret.as_bytes()), + ) { + Ok(token) => Ok(token), + Err(e) => err!(format!("Error encoding Duo JWT: {e:?}")), + } + } + + // "required" health check to verify the integration is configured and Duo's services + // are up. + // https://duo.com/docs/oauthapi#health-check + async fn health_check(&self) -> Result<(), Error> { + let health_check_url: String = format!("https://{}/oauth/v1/health_check", self.api_host); + + let jwt_payload = self.new_client_assertion(&health_check_url); + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => return Err(e), + }; + + let mut post_body = HashMap::new(); + post_body.insert("client_assertion", token); + post_body.insert("client_id", self.client_id.clone()); + + let res = match make_http_request(reqwest::Method::POST, &health_check_url)? + .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") + .form(&post_body) + .send() + .await + { + Ok(r) => r, + Err(e) => err!(format!("Error requesting Duo health check: {e:?}")), + }; + + let response: HealthCheckResponse = match res.json::().await { + Ok(r) => r, + Err(e) => err!(format!("Duo health check response decode error: {e:?}")), + }; + + let health_stat: String = match response { + HealthCheckResponse::HealthOK { + stat, + } => stat, + HealthCheckResponse::HealthFail { + message, + message_detail, + } => err!(format!("Duo health check FAIL response, msg: {}, detail: {}", message, message_detail)), + }; + + if health_stat != "OK" { + err!(format!("Duo health check failed, got OK-like body with stat {health_stat}")); + } + + Ok(()) + } + + // Constructs the URL for the authorization request endpoint on Duo's service. + // Clients are sent here to continue authentication. + // https://duo.com/docs/oauthapi#authorization-request + fn make_authz_req_url(&self, duo_username: &str, state: String, nonce: String) -> Result { + let now = Utc::now().timestamp(); + + let jwt_payload = AuthorizationRequest { + response_type: String::from("code"), + scope: String::from("openid"), + exp: now + JWT_VALIDITY_SECS, + client_id: self.client_id.clone(), + redirect_uri: self.redirect_uri.clone(), + state, + duo_uname: String::from(duo_username), + iss: self.client_id.clone(), + aud: format!("https://{}", self.api_host), + nonce, + }; + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => return Err(e), + }; + + let authz_endpoint = format!("https://{}/oauth/v1/authorize", self.api_host); + let mut auth_url = match Url::parse(authz_endpoint.as_str()) { + Ok(url) => url, + Err(e) => err!(format!("Error parsing Duo authorization URL: {e:?}")), + }; + + { + let mut query_params = auth_url.query_pairs_mut(); + query_params.append_pair("response_type", "code"); + query_params.append_pair("client_id", self.client_id.as_str()); + query_params.append_pair("request", token.as_str()); + } + + let final_auth_url = auth_url.to_string(); + Ok(final_auth_url) + } + + // Exchange the authorization code obtained from an access token provided by the user + // for the result of the MFA and validate. + // See: https://duo.com/docs/oauthapi#access-token (under Response Format) + async fn exchange_authz_code_for_result( + &self, + duo_code: &str, + duo_username: &str, + nonce: &str, + ) -> Result<(), Error> { + if duo_code.is_empty() { + err!("Empty Duo authorization code") + } + + let token_url = format!("https://{}/oauth/v1/token", self.api_host); + + let jwt_payload = self.new_client_assertion(&token_url); + + let token = match self.encode_duo_jwt(jwt_payload) { + Ok(token) => token, + Err(e) => return Err(e), + }; + + let mut post_body = HashMap::new(); + post_body.insert("grant_type", String::from("authorization_code")); + post_body.insert("code", String::from(duo_code)); + + // Must be the same URL that was supplied in the authorization request for the supplied duo_code + post_body.insert("redirect_uri", self.redirect_uri.clone()); + + post_body + .insert("client_assertion_type", String::from("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); + post_body.insert("client_assertion", token); + + let res = match make_http_request(reqwest::Method::POST, &token_url)? + .header(header::USER_AGENT, "vaultwarden:Duo/2.0 (Rust)") + .form(&post_body) + .send() + .await + { + Ok(r) => r, + Err(e) => err!(format!("Error exchanging Duo code: {e:?}")), + }; + + let status_code = res.status(); + if status_code != StatusCode::OK { + err!(format!("Failure response from Duo: {}", status_code)) + } + + let response: IdTokenResponse = match res.json::().await { + Ok(r) => r, + Err(e) => err!(format!("Error decoding ID token response: {e:?}")), + }; + + let mut validation = Validation::new(DUO_RESP_SIGNATURE_ALG); + validation.set_required_spec_claims(&["exp", "aud", "iss"]); + validation.set_audience(&[&self.client_id]); + validation.set_issuer(&[token_url.as_str()]); + + let token_data = match jsonwebtoken::decode::( + &response.id_token, + &DecodingKey::from_secret(self.client_secret.as_bytes()), + &validation, + ) { + Ok(c) => c, + Err(e) => err!(format!("Failed to decode Duo token {e:?}")), + }; + + let matching_nonces = crypto::ct_eq(nonce, &token_data.claims.nonce); + let matching_usernames = crypto::ct_eq(duo_username, &token_data.claims.preferred_username); + + if !(matching_nonces && matching_usernames) { + err!("Error validating Duo authorization, nonce or username mismatch.") + }; + + Ok(()) + } +} + +struct DuoAuthContext { + pub state: String, + pub user_email: String, + pub nonce: String, + pub exp: i64, +} + +// Given a state string, retrieve the associated Duo auth context and +// delete the retrieved state from the database. +async fn extract_context(state: &str, conn: &mut DbConn) -> Option { + let ctx: TwoFactorDuoContext = match TwoFactorDuoContext::find_by_state(state, conn).await { + Some(c) => c, + None => return None, + }; + + if ctx.exp < Utc::now().timestamp() { + ctx.delete(conn).await.ok(); + return None; + } + + // Copy the context data, so that we can delete the context from + // the database before returning. + let ret_ctx = DuoAuthContext { + state: ctx.state.clone(), + user_email: ctx.user_email.clone(), + nonce: ctx.nonce.clone(), + exp: ctx.exp, + }; + + ctx.delete(conn).await.ok(); + Some(ret_ctx) +} + +// Task to clean up expired Duo authentication contexts that may have accumulated in the database. +pub async fn purge_duo_contexts(pool: DbPool) { + debug!("Purging Duo authentication contexts"); + if let Ok(mut conn) = pool.get().await { + TwoFactorDuoContext::purge_expired_duo_contexts(&mut conn).await; + } else { + error!("Failed to get DB connection while purging expired Duo authentications") + } +} + +// Construct the url that Duo should redirect users to. +fn make_callback_url(client_name: &str) -> Result { + // Get the location of this application as defined in the config. + let base = match Url::parse(CONFIG.domain().as_str()) { + Ok(url) => url, + Err(e) => err!(format!("Error parsing configured domain URL (check your domain configuration): {e:?}")), + }; + + // Add the client redirect bridge location + let mut callback = match base.join(DUO_REDIRECT_LOCATION) { + Ok(url) => url, + Err(e) => err!(format!("Error constructing Duo redirect URL (check your domain configuration): {e:?}")), + }; + + // Add the 'client' string with the authenticating device type. The callback connector uses this + // information to figure out how it should handle certain clients. + { + let mut query_params = callback.query_pairs_mut(); + query_params.append_pair("client", client_name); + } + Ok(callback.to_string()) +} + +// Pre-redirect first stage of the Duo OIDC authentication flow. +// Returns the "AuthUrl" that should be returned to clients for MFA. +pub async fn get_duo_auth_url( + email: &str, + client_id: &str, + device_identifier: &String, + conn: &mut DbConn, +) -> Result { + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + + let callback_url = match make_callback_url(client_id) { + Ok(url) => url, + Err(e) => return Err(e), + }; + + let client = DuoClient::new(ik, sk, host, callback_url); + + match client.health_check().await { + Ok(()) => {} + Err(e) => return Err(e), + }; + + // Generate random OAuth2 state and OIDC Nonce + let state: String = crypto::get_random_string_alphanum(STATE_LENGTH); + let nonce: String = crypto::get_random_string_alphanum(STATE_LENGTH); + + // Bind the nonce to the device that's currently authing by hashing the nonce and device id + // and sending the result as the OIDC nonce. + let d: Digest = digest(&SHA512_256, format!("{nonce}{device_identifier}").as_bytes()); + let hash: String = HEXLOWER.encode(d.as_ref()); + + match TwoFactorDuoContext::save(state.as_str(), email, nonce.as_str(), CTX_VALIDITY_SECS, conn).await { + Ok(()) => client.make_authz_req_url(email, state, hash), + Err(e) => err!(format!("Error saving Duo authentication context: {e:?}")), + } +} + +// Post-redirect second stage of the Duo OIDC authentication flow. +// Exchanges an authorization code for the MFA result with Duo's API and validates the result. +pub async fn validate_duo_login( + email: &str, + two_factor_token: &str, + client_id: &str, + device_identifier: &str, + conn: &mut DbConn, +) -> EmptyResult { + let email = &email.to_lowercase(); + + // Result supplied to us by clients in the form "|" + let split: Vec<&str> = two_factor_token.split('|').collect(); + if split.len() != 2 { + err!( + "Invalid response length", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ); + } + + let code = split[0]; + let state = split[1]; + + let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?; + + // Get the context by the state reported by the client. If we don't have one, + // it means the context is either missing or expired. + let ctx = match extract_context(state, conn).await { + Some(c) => c, + None => { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + }; + + // Context validation steps + let matching_usernames = crypto::ct_eq(email, &ctx.user_email); + + // Probably redundant, but we're double-checking them anyway. + let matching_states = crypto::ct_eq(state, &ctx.state); + let unexpired_context = ctx.exp > Utc::now().timestamp(); + + if !(matching_usernames && matching_states && unexpired_context) { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + + let callback_url = match make_callback_url(client_id) { + Ok(url) => url, + Err(e) => return Err(e), + }; + + let client = DuoClient::new(ik, sk, host, callback_url); + + match client.health_check().await { + Ok(()) => {} + Err(e) => return Err(e), + }; + + let d: Digest = digest(&SHA512_256, format!("{}{}", ctx.nonce, device_identifier).as_bytes()); + let hash: String = HEXLOWER.encode(d.as_ref()); + + match client.exchange_authz_code_for_result(code, email, hash.as_str()).await { + Ok(_) => Ok(()), + Err(_) => { + err!( + "Error validating duo authentication", + ErrorEvent { + event: EventType::UserFailedLogIn2fa + } + ) + } + } +} diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 2fbcfb3b..2dd88a64 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -19,6 +19,7 @@ use crate::{ pub mod authenticator; pub mod duo; +pub mod duo_oidc; pub mod email; pub mod protected_actions; pub mod webauthn; diff --git a/src/api/identity.rs b/src/api/identity.rs index fbf8d506..b6621ce3 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -12,7 +12,7 @@ use crate::{ core::{ accounts::{PreloginData, RegisterData, _prelogin, _register}, log_user_event, - two_factor::{authenticator, duo, email, enforce_2fa_policy, webauthn, yubikey}, + two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, }, push::register_push_device, ApiResult, EmptyResult, JsonResult, @@ -502,7 +502,9 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"), + None => { + err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA token not provided") + } }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -519,7 +521,23 @@ async fn twofactor_auth( 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::Duo) => { - duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await? + match CONFIG.duo_use_iframe() { + true => { + // Legacy iframe prompt flow + duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await? + } + false => { + // OIDC based flow + duo_oidc::validate_duo_login( + data.username.as_ref().unwrap().trim(), + twofactor_code, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await? + } + } } Some(TwoFactorType::Email) => { email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await? @@ -532,7 +550,7 @@ async fn twofactor_auth( } _ => { err_json!( - _json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, + _json_err_twofactor(&twofactor_ids, &user.uuid, data, conn).await?, "2FA Remember token not provided" ) } @@ -560,7 +578,12 @@ fn _selected_data(tf: Option) -> ApiResult { tf.map(|t| t.data).map_res("Two factor doesn't exist") } -async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbConn) -> ApiResult { +async fn _json_err_twofactor( + providers: &[i32], + user_uuid: &str, + data: &ConnectData, + conn: &mut DbConn, +) -> ApiResult { let mut result = json!({ "error" : "invalid_grant", "error_description" : "Two factor required.", @@ -588,12 +611,30 @@ async fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &mut DbCo None => err!("User does not exist"), }; - let (signature, host) = duo::generate_duo_signature(&email, conn).await?; + match CONFIG.duo_use_iframe() { + true => { + // Legacy iframe prompt flow + let (signature, host) = duo::generate_duo_signature(&email, conn).await?; + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "Host": host, + "Signature": signature, + }) + } + false => { + // OIDC based flow + let auth_url = duo_oidc::get_duo_auth_url( + &email, + data.client_id.as_ref().unwrap(), + data.device_identifier.as_ref().unwrap(), + conn, + ) + .await?; - result["TwoFactorProviders2"][provider.to_string()] = json!({ - "Host": host, - "Signature": signature, - }); + result["TwoFactorProviders2"][provider.to_string()] = json!({ + "AuthUrl": auth_url, + }) + } + } } Some(tf_type @ TwoFactorType::YubiKey) => { diff --git a/src/config.rs b/src/config.rs index 5579918e..58e52155 100644 --- a/src/config.rs +++ b/src/config.rs @@ -415,7 +415,9 @@ make_config! { /// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. /// Defaults to every minute. Set blank to disable this job. auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string(); - + /// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt. + /// Defaults to once every minute. Set blank to disable this job. + duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string(); }, /// General settings @@ -635,6 +637,8 @@ make_config! { duo: _enable_duo { /// Enabled _enable_duo: bool, true, def, true; + /// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2) + duo_use_iframe: bool, false, def, false; /// Integration Key duo_ikey: String, true, option; /// Secret Key diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0379141a..c336cb1a 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -12,6 +12,7 @@ mod org_policy; mod organization; mod send; mod two_factor; +mod two_factor_duo_context; mod two_factor_incomplete; mod user; @@ -29,5 +30,6 @@ pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; pub use self::two_factor::{TwoFactor, TwoFactorType}; +pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserKdfType, UserStampException}; diff --git a/src/db/models/two_factor_duo_context.rs b/src/db/models/two_factor_duo_context.rs new file mode 100644 index 00000000..3e742d35 --- /dev/null +++ b/src/db/models/two_factor_duo_context.rs @@ -0,0 +1,84 @@ +use chrono::Utc; + +use crate::{api::EmptyResult, db::DbConn, error::MapResult}; + +db_object! { + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = twofactor_duo_ctx)] + #[diesel(primary_key(state))] + pub struct TwoFactorDuoContext { + pub state: String, + pub user_email: String, + pub nonce: String, + pub exp: i64, + } +} + +impl TwoFactorDuoContext { + pub async fn find_by_state(state: &str, conn: &mut DbConn) -> Option { + db_run! { + conn: { + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(state)) + .first::(conn) + .ok() + .from_db() + } + } + } + + pub async fn save(state: &str, user_email: &str, nonce: &str, ttl: i64, conn: &mut DbConn) -> EmptyResult { + // A saved context should never be changed, only created or deleted. + let exists = Self::find_by_state(state, conn).await; + if exists.is_some() { + return Ok(()); + }; + + let exp = Utc::now().timestamp() + ttl; + + db_run! { + conn: { + diesel::insert_into(twofactor_duo_ctx::table) + .values(( + twofactor_duo_ctx::state.eq(state), + twofactor_duo_ctx::user_email.eq(user_email), + twofactor_duo_ctx::nonce.eq(nonce), + twofactor_duo_ctx::exp.eq(exp) + )) + .execute(conn) + .map_res("Error saving context to twofactor_duo_ctx") + } + } + } + + pub async fn find_expired(conn: &mut DbConn) -> Vec { + let now = Utc::now().timestamp(); + db_run! { + conn: { + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::exp.lt(now)) + .load::(conn) + .expect("Error finding expired contexts in twofactor_duo_ctx") + .from_db() + } + } + } + + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { + conn: { + diesel::delete( + twofactor_duo_ctx::table + .filter(twofactor_duo_ctx::state.eq(&self.state))) + .execute(conn) + .map_res("Error deleting from twofactor_duo_ctx") + } + } + } + + pub async fn purge_expired_duo_contexts(conn: &mut DbConn) { + for context in Self::find_expired(conn).await { + context.delete(conn).await.ok(); + } + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 0fb286a4..58ec55a2 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -174,6 +174,15 @@ table! { } } +table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> BigInt, + } +} + table! { users (uuid) { uuid -> Text, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 26bf4b68..10b5313e 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -174,6 +174,15 @@ table! { } } +table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> BigInt, + } +} + table! { users (uuid) { uuid -> Text, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 26bf4b68..10b5313e 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -174,6 +174,15 @@ table! { } } +table! { + twofactor_duo_ctx (state) { + state -> Text, + user_email -> Text, + nonce -> Text, + exp -> BigInt, + } +} + table! { users (uuid) { uuid -> Text, diff --git a/src/main.rs b/src/main.rs index ca39c502..9f96dc60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,7 @@ mod mail; mod ratelimit; mod util; +use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::purge_auth_requests; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; pub use config::CONFIG; @@ -626,6 +627,13 @@ fn schedule_jobs(pool: db::DbPool) { })); } + // Clean unused, expired Duo authentication contexts. + if !CONFIG.duo_context_purge_schedule().is_empty() && CONFIG._enable_duo() && !CONFIG.duo_use_iframe() { + sched.add(Job::new(CONFIG.duo_context_purge_schedule().parse().unwrap(), || { + runtime.spawn(purge_duo_contexts(pool.clone())); + })); + } + // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty()