Prechádzať zdrojové kódy

Add support for MFA with Duo's Universal Prompt (#4637)

* 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 <[email protected]>

* remove redundant expiry check when purging Duo contexts

---------

Co-authored-by: BlackDex <[email protected]>
Co-authored-by: Daniel García <[email protected]>
0x0fbc 1 rok pred
rodič
commit
b4b2701905

+ 13 - 3
.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=<Integration Key>
-# DUO_SKEY=<Secret Key>
+# DUO_IKEY=<Client ID>
+# DUO_SKEY=<Client Secret>
 # DUO_HOST=<API Hostname>
 ## 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

+ 1 - 0
migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql

@@ -0,0 +1 @@
+DROP TABLE twofactor_duo_ctx;

+ 8 - 0
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)
+);

+ 1 - 0
migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql

@@ -0,0 +1 @@
+DROP TABLE twofactor_duo_ctx;

+ 8 - 0
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)
+);

+ 1 - 0
migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql

@@ -0,0 +1 @@
+DROP TABLE twofactor_duo_ctx;

+ 8 - 0
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)
+);

+ 1 - 1
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(),

+ 500 - 0
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<T: Serialize>(&self, jwt_payload: T) -> Result<String, Error> {
+        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::<HealthCheckResponse>().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<String, Error> {
+        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::<IdTokenResponse>().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::<IdTokenClaims>(
+            &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<DuoAuthContext> {
+    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<String, Error> {
+    // 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<String, Error> {
+    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 "<authz code>|<state>"
+    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
+                }
+            )
+        }
+    }
+}

+ 1 - 0
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;

+ 52 - 11
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<TwoFactor>) -> ApiResult<String> {
     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<Value> {
+async fn _json_err_twofactor(
+    providers: &[i32],
+    user_uuid: &str,
+    data: &ConnectData,
+    conn: &mut DbConn,
+) -> ApiResult<Value> {
     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?;
-
-                result["TwoFactorProviders2"][provider.to_string()] = json!({
-                    "Host": host,
-                    "Signature": signature,
-                });
+                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!({
+                            "AuthUrl": auth_url,
+                        })
+                    }
+                }
             }
 
             Some(tf_type @ TwoFactorType::YubiKey) => {

+ 5 - 1
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

+ 2 - 0
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};

+ 84 - 0
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<Self> {
+        db_run! {
+            conn: {
+                twofactor_duo_ctx::table
+                    .filter(twofactor_duo_ctx::state.eq(state))
+                    .first::<TwoFactorDuoContextDb>(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<Self> {
+        let now = Utc::now().timestamp();
+        db_run! {
+            conn: {
+                twofactor_duo_ctx::table
+                    .filter(twofactor_duo_ctx::exp.lt(now))
+                    .load::<TwoFactorDuoContextDb>(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();
+        }
+    }
+}

+ 9 - 0
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,

+ 9 - 0
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,

+ 9 - 0
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,

+ 8 - 0
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()