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