mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2024-09-07 21:29:02 +00:00
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 <dani-garcia@users.noreply.github.com> * remove redundant expiry check when purging Duo contexts --------- Co-authored-by: BlackDex <black.dex@gmail.com> Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
This commit is contained in:
parent
de66e56b6c
commit
b4b2701905
@ -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
|
||||
|
@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@ -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)
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@ -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)
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE twofactor_duo_ctx;
|
@ -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)
|
||||
);
|
@ -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
src/api/core/two_factor/duo_oidc.rs
Normal file
500
src/api/core/two_factor/duo_oidc.rs
Normal file
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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?;
|
||||
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) => {
|
||||
|
@ -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
|
||||
|
@ -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
src/db/models/two_factor_duo_context.rs
Normal file
84
src/db/models/two_factor_duo_context.rs
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -174,6 +174,15 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@ -174,6 +174,15 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@ -174,6 +174,15 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor_duo_ctx (state) {
|
||||
state -> Text,
|
||||
user_email -> Text,
|
||||
nonce -> Text,
|
||||
exp -> BigInt,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user