7 changed files with 563 additions and 19 deletions
@ -0,0 +1,473 @@ |
|||
use chrono::{TimeDelta, Utc}; |
|||
use jsonwebtoken::{decode_header, Algorithm, DecodingKey, EncodingKey, Header, Validation}; |
|||
use reqwest::{header, StatusCode}; |
|||
use serde::Serialize; |
|||
use std::collections::HashMap; |
|||
use url::Url; |
|||
use crate::{ |
|||
api::{core::two_factor::duo::get_duo_keys_email, EmptyResult}, |
|||
auth::ClientType, |
|||
crypto, |
|||
db::{models::EventType, DbConn}, |
|||
error::Error, |
|||
util::get_reqwest_client, |
|||
CONFIG, |
|||
}; |
|||
|
|||
// Pool of characters for state and nonce generation
|
|||
// 0-9 -> 0x30-0x39
|
|||
// A-Z -> 0x41-0x5A
|
|||
// a-z -> 0x61-0x7A
|
|||
const STATE_CHAR_POOL: [u8; 62] = [ |
|||
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, |
|||
0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x61, 0x62, |
|||
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, |
|||
0x76, 0x77, 0x78, 0x79, 0x7A, |
|||
]; |
|||
|
|||
const MIN_STATE_SIZE: usize = 16; |
|||
const MAX_STATE_SIZE: usize = 1024; |
|||
const STATE_LENGTH: usize = 36; // Default size of state for generate_state_default()
|
|||
|
|||
// Client URL constants. Defined as macros, so they can be passed into format!()
|
|||
#[allow(non_snake_case)] |
|||
macro_rules! HEALTH_ENDPOINT { |
|||
() => { |
|||
"https://{}/oauth/v1/health_check" |
|||
}; |
|||
} |
|||
#[allow(non_snake_case)] |
|||
macro_rules! AUTHZ_ENDPOINT { |
|||
() => { |
|||
"https://{}/oauth/v1/authorize" |
|||
}; |
|||
} |
|||
#[allow(non_snake_case)] |
|||
macro_rules! API_HOST_FMT { |
|||
() => { |
|||
"https://{}" |
|||
}; |
|||
} |
|||
#[allow(non_snake_case)] |
|||
macro_rules! TOKEN_ENDPOINT { |
|||
() => { |
|||
"https://{}/oauth/v1/token" |
|||
}; |
|||
} |
|||
|
|||
// Default JWT validity time
|
|||
const JWT_VALIDITY_SECS: i64 = 300; |
|||
|
|||
// Generate a new Duo WebSDKv4 state string with a given size.
|
|||
// This can also be used to generate the optional OpenID Connect nonce.
|
|||
// Size must be between 16 and 1024 (inclusive).
|
|||
pub fn generate_state_len(size: usize) -> String { |
|||
if (size < MIN_STATE_SIZE) || (MAX_STATE_SIZE < size) { |
|||
panic!("Illegal Duo state size: {size}. Size must be 15 < size < 1025") |
|||
} |
|||
|
|||
return crypto::get_random_string(&STATE_CHAR_POOL, size); |
|||
} |
|||
|
|||
pub fn generate_state_default() -> String { |
|||
return generate_state_len(STATE_LENGTH); |
|||
} |
|||
|
|||
// Structs for serializing calls to Duo
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct ClientAssertionJwt { |
|||
pub iss: String, |
|||
pub sub: String, |
|||
pub aud: String, |
|||
pub exp: i64, |
|||
pub jti: String, |
|||
pub iat: i64, |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct AuthUrlJwt { |
|||
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, |
|||
#[serde(skip_serializing_if = "Option::is_none")] |
|||
pub iss: Option<String>, |
|||
#[serde(skip_serializing_if = "Option::is_none")] |
|||
pub aud: Option<String>, |
|||
#[serde(skip_serializing_if = "Option::is_none")] |
|||
pub nonce: Option<String>, |
|||
#[serde(skip_serializing_if = "Option::is_none")] |
|||
pub use_duo_code_attribute: Option<bool>, |
|||
} |
|||
|
|||
/* |
|||
Structs for deserializing responses from Duo's API |
|||
*/ |
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct HealthOKTS { |
|||
timestamp: i64, |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
#[serde(untagged)] |
|||
enum HealthCheckResponse { |
|||
HealthOK { |
|||
stat: String, |
|||
response: HealthOKTS, |
|||
}, |
|||
HealthFail { |
|||
stat: String, |
|||
code: i32, |
|||
timestamp: i64, |
|||
message: String, |
|||
message_detail: String, |
|||
}, |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct IdTokenResponse { |
|||
id_token: String, |
|||
access_token: String, |
|||
expires_in: i64, |
|||
token_type: String, |
|||
} |
|||
|
|||
#[derive(Debug, Serialize, Deserialize)] |
|||
struct IdTokenClaims { |
|||
aud: String, |
|||
iss: String, |
|||
preferred_username: String, |
|||
#[serde(skip_serializing_if = "Option::is_none")] |
|||
nonce: Option<String>, |
|||
} |
|||
|
|||
// Duo WebSDK 4 Client
|
|||
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
|
|||
jwt_exp_seconds: i64, // Number of seconds that JWTs we create should be valid for
|
|||
} |
|||
// TODO: Cert pinning for calls to Duo?
|
|||
|
|||
// See https://duo.com/docs/oauthapi
|
|||
impl DuoClient { |
|||
fn new(client_id: String, client_secret: String, api_host: String, redirect_uri: String) -> DuoClient { |
|||
return DuoClient { |
|||
client_id, |
|||
client_secret, |
|||
api_host, |
|||
redirect_uri, |
|||
jwt_exp_seconds: JWT_VALIDITY_SECS, |
|||
}; |
|||
} |
|||
|
|||
// 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(Algorithm::HS512), |
|||
&jwt_payload, |
|||
&EncodingKey::from_secret(&self.client_secret.as_bytes()), |
|||
) { |
|||
Ok(token) => Ok(token), |
|||
Err(e) => err!(format!("{}", 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!(HEALTH_ENDPOINT!(), self.api_host); |
|||
|
|||
let now = Utc::now(); |
|||
let jwt_id = generate_state_default(); |
|||
let jwt_payload = ClientAssertionJwt { |
|||
iss: self.client_id.clone(), |
|||
sub: self.client_id.clone(), |
|||
aud: health_check_url.clone(), |
|||
exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), |
|||
jti: jwt_id, |
|||
iat: now.timestamp(), |
|||
}; |
|||
|
|||
let token = match self.encode_duo_jwt(jwt_payload) { |
|||
Ok(token) => token, |
|||
Err(e) => err!(format!("{}", 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 get_reqwest_client() |
|||
.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, |
|||
response: _, |
|||
} => stat, |
|||
HealthCheckResponse::HealthFail { |
|||
stat: _, |
|||
code: _, |
|||
timestamp: _, |
|||
message, |
|||
message_detail, |
|||
} => err!(format!("Duo health check FAIL response msg: {}, detail: {}", message, message_detail)), |
|||
}; |
|||
|
|||
if health_stat != "OK" { |
|||
err!("Duo health check returned OK-like body but did not contain an OK 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: Option<String>) -> Result<String, Error> { |
|||
let now = Utc::now(); |
|||
|
|||
let jwt_payload = AuthUrlJwt { |
|||
response_type: String::from("code"), |
|||
scope: String::from("openid"), |
|||
exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), |
|||
client_id: self.client_id.clone(), |
|||
redirect_uri: self.redirect_uri.clone(), |
|||
state, |
|||
duo_uname: String::from(duo_username), |
|||
iss: Some(self.client_id.clone()), |
|||
aud: Some(format!(API_HOST_FMT!(), self.api_host)), |
|||
nonce, |
|||
use_duo_code_attribute: Some(false), |
|||
}; |
|||
|
|||
let token = match self.encode_duo_jwt(jwt_payload) { |
|||
Ok(token) => token, |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
let authz_endpoint = format!(AUTHZ_ENDPOINT!(), self.api_host); |
|||
let mut auth_url = match Url::parse(authz_endpoint.as_str()) { |
|||
Ok(url) => url, |
|||
Err(e) => err!(format!("{}", 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(); |
|||
return Ok(final_auth_url); |
|||
} |
|||
|
|||
async fn exchange_authz_code_for_result( |
|||
&self, |
|||
duo_code: &str, |
|||
duo_username: &str, |
|||
nonce: Option<&str>, |
|||
) -> Result<(), Error> { |
|||
if duo_code == "" { |
|||
err!("Invalid Duo Code") |
|||
} |
|||
|
|||
let now = Utc::now(); |
|||
|
|||
let token_url = format!(TOKEN_ENDPOINT!(), self.api_host); |
|||
let jwt_id = generate_state_default(); |
|||
|
|||
let jwt_payload = ClientAssertionJwt { |
|||
iss: self.client_id.clone(), |
|||
sub: self.client_id.clone(), |
|||
aud: token_url.clone(), |
|||
exp: (now + TimeDelta::try_seconds(self.jwt_exp_seconds).unwrap()).timestamp(), |
|||
jti: jwt_id, |
|||
iat: now.timestamp(), |
|||
}; |
|||
|
|||
let token = match self.encode_duo_jwt(jwt_payload) { |
|||
Ok(token) => token, |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
let mut post_body = HashMap::new(); |
|||
post_body.insert("grant_type", String::from("authorization_code")); |
|||
post_body.insert("code", String::from(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 get_reqwest_client() |
|||
.post(token_url.clone()) |
|||
.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 header = decode_header(&response.id_token).unwrap(); |
|||
|
|||
let mut validation = Validation::new(header.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)), |
|||
}; |
|||
|
|||
if !crypto::ct_eq(&duo_username, &token_data.claims.preferred_username) { |
|||
err!(format!( |
|||
"Error validating Duo user, expected {}, got {}", |
|||
duo_username, token_data.claims.preferred_username |
|||
)) |
|||
}; |
|||
|
|||
match nonce { |
|||
Some(nonce) => { |
|||
_ = nonce; // FIXME: Add Nonce support
|
|||
Ok(()) |
|||
} |
|||
None => Ok(()), |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Construct the url that Duo should redirect users to.
|
|||
// The actual location is a bridge built in to the clients.
|
|||
// See: /clients/apps/web/src/connectors/duo-redirect.ts
|
|||
fn make_callback_url(client_name: &str) -> Result<String, Error> { |
|||
const DUO_REDIRECT_LOCATION: &str = "duo-redirect-connector.html"; |
|||
|
|||
// 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!("{}", e)), |
|||
}; |
|||
|
|||
// Add the client redirect bridge location
|
|||
let mut callback = match base.join(DUO_REDIRECT_LOCATION) { |
|||
Ok(url) => url, |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
// Add the 'client' string. This is sent by clients in the 'Bitwarden-Client-Name'
|
|||
// HTTP header of the request to /identity/connect/token
|
|||
{ |
|||
let mut query_params = callback.query_pairs_mut(); |
|||
query_params.append_pair("client", client_name); |
|||
} |
|||
return Ok(callback.to_string()); |
|||
} |
|||
|
|||
// Initiates the first stage of the Duo WebSDKv4 authentication flow.
|
|||
// Returns the "AuthUrl" that should be passed to clients for MFA.
|
|||
pub async fn get_duo_auth_url(email: &str, client_type: &ClientType, 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_type.as_str()) { |
|||
Ok(url) => url, |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
let client = DuoClient::new(ik, sk, host, callback_url); |
|||
|
|||
match client.health_check().await { |
|||
Ok(()) => {} |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
// Generate a random Duo state and OIDC Nonce
|
|||
let state = generate_state_default(); |
|||
|
|||
return client.make_authz_req_url(email, state, None); |
|||
} |
|||
|
|||
pub async fn validate_duo_login( |
|||
email: &str, |
|||
two_factor_token: &str, |
|||
client_type: &ClientType, |
|||
conn: &mut DbConn, |
|||
) -> EmptyResult { |
|||
let email = &email.to_lowercase(); |
|||
|
|||
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?; |
|||
|
|||
let callback_url = match make_callback_url(client_type.as_str()) { |
|||
Ok(url) => url, |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
let client = DuoClient::new(ik, sk, host, callback_url); |
|||
|
|||
match client.health_check().await { |
|||
Ok(()) => {} |
|||
Err(e) => err!(format!("{}", e)), |
|||
}; |
|||
|
|||
match client.exchange_authz_code_for_result(code, email, None).await { |
|||
Ok(_r) => Ok(()), |
|||
Err(_e) => { |
|||
err!( |
|||
"Error validating duo authentication", |
|||
ErrorEvent { |
|||
event: EventType::UserFailedLogIn2fa |
|||
} |
|||
) |
|||
} |
|||
} |
|||
} |
Loading…
Reference in new issue