summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author0x0fbc <[email protected]>2024-07-24 09:50:35 -0500
committerGitHub <[email protected]>2024-07-24 16:50:35 +0200
commitb4b2701905752f90080dd46ba10a90c5c584a38e (patch)
tree5e24bde1efd339e8401451e49a945319238be60b
parentde66e56b6c951192bcb47987b34ac21e2bcea43a (diff)
downloadvaultwarden-b4b2701905752f90080dd46ba10a90c5c584a38e.tar.gz
vaultwarden-b4b2701905752f90080dd46ba10a90c5c584a38e.zip
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]>
-rw-r--r--.env.template16
-rw-r--r--migrations/mysql/2024-06-05-131359_add_2fa_duo_store/down.sql1
-rw-r--r--migrations/mysql/2024-06-05-131359_add_2fa_duo_store/up.sql8
-rw-r--r--migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/down.sql1
-rw-r--r--migrations/postgresql/2024-06-05-131359_add_2fa_duo_store/up.sql8
-rw-r--r--migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/down.sql1
-rw-r--r--migrations/sqlite/2024-06-05-131359_add_2fa_duo_store/up.sql8
-rw-r--r--src/api/core/two_factor/duo.rs2
-rw-r--r--src/api/core/two_factor/duo_oidc.rs500
-rw-r--r--src/api/core/two_factor/mod.rs1
-rw-r--r--src/api/identity.rs63
-rw-r--r--src/config.rs6
-rw-r--r--src/db/models/mod.rs2
-rw-r--r--src/db/models/two_factor_duo_context.rs84
-rw-r--r--src/db/schemas/mysql/schema.rs9
-rw-r--r--src/db/schemas/postgresql/schema.rs9
-rw-r--r--src/db/schemas/sqlite/schema.rs9
-rw-r--r--src/main.rs8
18 files changed, 720 insertions, 16 deletions
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=<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
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<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
+ }
+ )
+ }
+ }
+}
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<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) => {
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<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();
+ }
+ }
+}
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
@@ -175,6 +175,15 @@ table! {
}
table! {
+ twofactor_duo_ctx (state) {
+ state -> Text,
+ user_email -> Text,
+ nonce -> Text,
+ exp -> BigInt,
+ }
+}
+
+table! {
users (uuid) {
uuid -> Text,
enabled -> Bool,
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
@@ -175,6 +175,15 @@ table! {
}
table! {
+ twofactor_duo_ctx (state) {
+ state -> Text,
+ user_email -> Text,
+ nonce -> Text,
+ exp -> BigInt,
+ }
+}
+
+table! {
users (uuid) {
uuid -> Text,
enabled -> Bool,
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
@@ -175,6 +175,15 @@ table! {
}
table! {
+ twofactor_duo_ctx (state) {
+ state -> Text,
+ user_email -> Text,
+ nonce -> Text,
+ exp -> BigInt,
+ }
+}
+
+table! {
users (uuid) {
uuid -> Text,
enabled -> Bool,
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()