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>pull/4772/head
parent
de66e56b6c
commit
b4b2701905
@ -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)
|
||||||
|
);
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue