committed by
GitHub
87 changed files with 3164 additions and 1421 deletions
@ -1,40 +1,15 @@ |
|||
# Local build artifacts |
|||
target |
|||
// Ignore everything |
|||
* |
|||
|
|||
# Data folder |
|||
data |
|||
|
|||
# Misc |
|||
.env |
|||
.env.template |
|||
.gitattributes |
|||
.gitignore |
|||
rustfmt.toml |
|||
|
|||
# IDE files |
|||
.vscode |
|||
.idea |
|||
.editorconfig |
|||
*.iml |
|||
|
|||
# Documentation |
|||
.github |
|||
*.md |
|||
*.txt |
|||
*.yml |
|||
*.yaml |
|||
|
|||
# Docker |
|||
hooks |
|||
tools |
|||
Dockerfile |
|||
.dockerignore |
|||
docker/** |
|||
// Allow what is needed |
|||
!.git |
|||
!docker/healthcheck.sh |
|||
!docker/start.sh |
|||
|
|||
# Web vault |
|||
web-vault |
|||
|
|||
# Vaultwarden Resources |
|||
resources |
|||
!migrations |
|||
!src |
|||
|
|||
!build.rs |
|||
!Cargo.lock |
|||
!Cargo.toml |
|||
!rustfmt.toml |
|||
!rust-toolchain.toml |
|||
|
@ -1,66 +0,0 @@ |
|||
--- |
|||
name: Bug report |
|||
about: Use this ONLY for bugs in vaultwarden itself. Use the Discourse forum (link below) to request features or get help with usage/configuration. If in doubt, use the forum. |
|||
title: '' |
|||
labels: '' |
|||
assignees: '' |
|||
|
|||
--- |
|||
<!-- |
|||
# ### |
|||
NOTE: Please update to the latest version of vaultwarden before reporting an issue! |
|||
This saves you and us a lot of time and troubleshooting. |
|||
See: |
|||
* https://github.com/dani-garcia/vaultwarden/issues/1180 |
|||
* https://github.com/dani-garcia/vaultwarden/wiki/Updating-the-vaultwarden-image |
|||
# ### |
|||
--> |
|||
|
|||
<!-- |
|||
Please fill out the following template to make solving your problem easier and faster for us. |
|||
This is only a guideline. If you think that parts are unnecessary for your issue, feel free to remove them. |
|||
|
|||
Remember to hide/redact personal or confidential information, |
|||
such as passwords, IP addresses, and DNS names as appropriate. |
|||
--> |
|||
|
|||
### Subject of the issue |
|||
<!-- Describe your issue here. --> |
|||
|
|||
### Deployment environment |
|||
|
|||
<!-- |
|||
========================================================================================= |
|||
Preferably, use the `Generate Support String` button on the admin page's Diagnostics tab. |
|||
That will auto-generate most of the info requested in this section. |
|||
========================================================================================= |
|||
--> |
|||
|
|||
<!-- The version number, obtained from the logs (at startup) or the admin diagnostics page --> |
|||
<!-- This is NOT the version number shown on the web vault, which is versioned separately from vaultwarden --> |
|||
<!-- Remember to check if your issue exists on the latest version first! --> |
|||
* vaultwarden version: |
|||
|
|||
<!-- How the server was installed: Docker image, OS package, built from source, etc. --> |
|||
* Install method: |
|||
|
|||
* Clients used: <!-- web vault, desktop, Android, iOS, etc. (if applicable) --> |
|||
|
|||
* Reverse proxy and version: <!-- if applicable --> |
|||
|
|||
* MySQL/MariaDB or PostgreSQL version: <!-- if applicable --> |
|||
|
|||
* Other relevant details: |
|||
|
|||
### Steps to reproduce |
|||
<!-- Tell us how to reproduce this issue. What parameters did you set (differently from the defaults) |
|||
and how did you start vaultwarden? --> |
|||
|
|||
### Expected behaviour |
|||
<!-- Tell us what you expected to happen --> |
|||
|
|||
### Actual behaviour |
|||
<!-- Tell us what actually happened --> |
|||
|
|||
### Troubleshooting data |
|||
<!-- Share any log files, screenshots, or other relevant troubleshooting data --> |
@ -0,0 +1,167 @@ |
|||
name: Bug Report |
|||
description: File a bug report |
|||
labels: ["bug"] |
|||
body: |
|||
# |
|||
- type: markdown |
|||
attributes: |
|||
value: | |
|||
Thanks for taking the time to fill out this bug report! |
|||
|
|||
Please *do not* submit feature requests or ask for help on how to configure Vaultwarden here. |
|||
|
|||
The [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions/) has sections for Questions and Ideas. |
|||
|
|||
Also, make sure you are running [](https://github.com/dani-garcia/vaultwarden/releases/latest) of Vaultwarden! |
|||
And search for existing open or closed issues or discussions regarding your topic before posting. |
|||
|
|||
Be sure to check and validate the Vaultwarden Admin Diagnostics (`/admin/diagnostics`) page for any errors! |
|||
See here [how to enable the admin page](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page). |
|||
# |
|||
- id: support-string |
|||
type: textarea |
|||
attributes: |
|||
label: Vaultwarden Support String |
|||
description: Output of the **Generate Support String** from the `/admin/diagnostics` page. |
|||
placeholder: | |
|||
1. Go to the Vaultwarden Admin of your instance https://example.domain.tld/admin/diagnostics |
|||
2. Click on `Generate Support String` |
|||
3. Click on `Copy To Clipboard` |
|||
4. Replace this text by pasting it into this textarea without any modifications |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: version |
|||
type: input |
|||
attributes: |
|||
label: Vaultwarden Build Version |
|||
description: What version of Vaultwarden are you running? |
|||
placeholder: ex. v1.31.0 or v1.32.0-3466a804 |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: deployment |
|||
type: dropdown |
|||
attributes: |
|||
label: Deployment method |
|||
description: How did you deploy Vaultwarden? |
|||
multiple: false |
|||
options: |
|||
- Official Container Image |
|||
- Build from source |
|||
- OS Package (apt, yum/dnf, pacman, apk, nix, ...) |
|||
- Manually Extracted from Container Image |
|||
- Downloaded from GitHub Actions Release Workflow |
|||
- Other method |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: deployment-other |
|||
type: textarea |
|||
attributes: |
|||
label: Custom deployment method |
|||
description: If you deployed Vaultwarden via any other method, please describe how. |
|||
# |
|||
- id: reverse-proxy |
|||
type: input |
|||
attributes: |
|||
label: Reverse Proxy |
|||
description: Are you using a reverse proxy, if so which and what version? |
|||
placeholder: ex. nginx 1.26.2, caddy 2.8.4, traefik 3.1.2, haproxy 3.0 |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: os |
|||
type: dropdown |
|||
attributes: |
|||
label: Host/Server Operating System |
|||
description: On what operating system are you running the Vaultwarden server? |
|||
multiple: false |
|||
options: |
|||
- Linux |
|||
- NAS/SAN |
|||
- Cloud |
|||
- Windows |
|||
- macOS |
|||
- Other |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: os-version |
|||
type: input |
|||
attributes: |
|||
label: Operating System Version |
|||
description: What version of the operating system(s) are you seeing the problem on? |
|||
placeholder: ex. Arch Linux, Ubuntu 24.04, Kubernetes, Synology DSM 7.x, Windows 11 |
|||
# |
|||
- id: clients |
|||
type: dropdown |
|||
attributes: |
|||
label: Clients |
|||
description: What client(s) are you seeing the problem on? |
|||
multiple: true |
|||
options: |
|||
- Web Vault |
|||
- Browser Extension |
|||
- CLI |
|||
- Desktop |
|||
- Android |
|||
- iOS |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: client-version |
|||
type: input |
|||
attributes: |
|||
label: Client Version |
|||
description: What version(s) of the client(s) are you seeing the problem on? |
|||
placeholder: ex. CLI v2024.7.2, Firefox 130 - v2024.7.0 |
|||
# |
|||
- id: reproduce |
|||
type: textarea |
|||
attributes: |
|||
label: Steps To Reproduce |
|||
description: How can we reproduce the behavior. |
|||
value: | |
|||
1. Go to '...' |
|||
2. Click on '....' |
|||
3. Scroll down to '....' |
|||
4. Click on '...' |
|||
5. Etc '...' |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: expected |
|||
type: textarea |
|||
attributes: |
|||
label: Expected Result |
|||
description: A clear and concise description of what you expected to happen. |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: actual |
|||
type: textarea |
|||
attributes: |
|||
label: Actual Result |
|||
description: A clear and concise description of what is happening. |
|||
validations: |
|||
required: true |
|||
# |
|||
- id: logs |
|||
type: textarea |
|||
attributes: |
|||
label: Logs |
|||
description: Provide the logs generated by Vaultwarden during the time this issue occurs. |
|||
render: text |
|||
# |
|||
- id: screenshots |
|||
type: textarea |
|||
attributes: |
|||
label: Screenshots or Videos |
|||
description: If applicable, add screenshots and/or a short video to help explain your problem. |
|||
# |
|||
- id: additional-context |
|||
type: textarea |
|||
attributes: |
|||
label: Additional Context |
|||
description: Add any other context about the problem here. |
@ -1,8 +1,8 @@ |
|||
blank_issues_enabled: false |
|||
contact_links: |
|||
- name: Discourse forum for vaultwarden |
|||
url: https://vaultwarden.discourse.group/ |
|||
about: Use this forum to request features or get help with usage/configuration. |
|||
- name: GitHub Discussions for vaultwarden |
|||
- name: GitHub Discussions for Vaultwarden |
|||
url: https://github.com/dani-garcia/vaultwarden/discussions |
|||
about: An alternative to the Discourse forum, if this is easier for you. |
|||
about: Use the discussions to request features or get help with usage/configuration. |
|||
- name: Discourse forum for Vaultwarden |
|||
url: https://vaultwarden.discourse.group/ |
|||
about: An alternative to the GitHub Discussions, if this is easier for you. |
|||
|
File diff suppressed because it is too large
@ -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 @@ |
|||
ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`; |
@ -0,0 +1 @@ |
|||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser |
@ -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 @@ |
|||
ALTER TABLE twofactor_incomplete DROP COLUMN device_type; |
@ -0,0 +1 @@ |
|||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser |
@ -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 @@ |
|||
ALTER TABLE `twofactor_incomplete` DROP COLUMN `device_type`; |
@ -0,0 +1 @@ |
|||
ALTER TABLE twofactor_incomplete ADD COLUMN device_type INTEGER NOT NULL DEFAULT 14; -- 14 = Unknown Browser |
@ -1,4 +1,4 @@ |
|||
[toolchain] |
|||
channel = "1.79.0" |
|||
channel = "1.81.0" |
|||
components = [ "rustfmt", "clippy" ] |
|||
profile = "minimal" |
|||
|
@ -0,0 +1,498 @@ |
|||
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(&format!("{}/", CONFIG.domain())) { |
|||
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 { |
|||
// 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(); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,246 @@ |
|||
use std::{ |
|||
fmt, |
|||
net::{IpAddr, SocketAddr}, |
|||
str::FromStr, |
|||
sync::{Arc, Mutex}, |
|||
time::Duration, |
|||
}; |
|||
|
|||
use hickory_resolver::{system_conf::read_system_conf, TokioAsyncResolver}; |
|||
use once_cell::sync::Lazy; |
|||
use regex::Regex; |
|||
use reqwest::{ |
|||
dns::{Name, Resolve, Resolving}, |
|||
header, Client, ClientBuilder, |
|||
}; |
|||
use url::Host; |
|||
|
|||
use crate::{util::is_global, CONFIG}; |
|||
|
|||
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> { |
|||
let Ok(url) = url::Url::parse(url) else { |
|||
err!("Invalid URL"); |
|||
}; |
|||
let Some(host) = url.host() else { |
|||
err!("Invalid host"); |
|||
}; |
|||
|
|||
should_block_host(host)?; |
|||
|
|||
static INSTANCE: Lazy<Client> = Lazy::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); |
|||
|
|||
Ok(INSTANCE.request(method, url)) |
|||
} |
|||
|
|||
pub fn get_reqwest_client_builder() -> ClientBuilder { |
|||
let mut headers = header::HeaderMap::new(); |
|||
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); |
|||
|
|||
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { |
|||
if attempt.previous().len() >= 5 { |
|||
return attempt.error("Too many redirects"); |
|||
} |
|||
|
|||
let Some(host) = attempt.url().host() else { |
|||
return attempt.error("Invalid host"); |
|||
}; |
|||
|
|||
if let Err(e) = should_block_host(host) { |
|||
return attempt.error(e); |
|||
} |
|||
|
|||
attempt.follow() |
|||
}); |
|||
|
|||
Client::builder() |
|||
.default_headers(headers) |
|||
.redirect(redirect_policy) |
|||
.dns_resolver(CustomDnsResolver::instance()) |
|||
.timeout(Duration::from_secs(10)) |
|||
} |
|||
|
|||
pub fn should_block_address(domain_or_ip: &str) -> bool { |
|||
if let Ok(ip) = IpAddr::from_str(domain_or_ip) { |
|||
if should_block_ip(ip) { |
|||
return true; |
|||
} |
|||
} |
|||
|
|||
should_block_address_regex(domain_or_ip) |
|||
} |
|||
|
|||
fn should_block_ip(ip: IpAddr) -> bool { |
|||
if !CONFIG.http_request_block_non_global_ips() { |
|||
return false; |
|||
} |
|||
|
|||
!is_global(ip) |
|||
} |
|||
|
|||
fn should_block_address_regex(domain_or_ip: &str) -> bool { |
|||
let Some(block_regex) = CONFIG.http_request_block_regex() else { |
|||
return false; |
|||
}; |
|||
|
|||
static COMPILED_REGEX: Mutex<Option<(String, Regex)>> = Mutex::new(None); |
|||
let mut guard = COMPILED_REGEX.lock().unwrap(); |
|||
|
|||
// If the stored regex is up to date, use it
|
|||
if let Some((value, regex)) = &*guard { |
|||
if value == &block_regex { |
|||
return regex.is_match(domain_or_ip); |
|||
} |
|||
} |
|||
|
|||
// If we don't have a regex stored, or it's not up to date, recreate it
|
|||
let regex = Regex::new(&block_regex).unwrap(); |
|||
let is_match = regex.is_match(domain_or_ip); |
|||
*guard = Some((block_regex, regex)); |
|||
|
|||
is_match |
|||
} |
|||
|
|||
fn should_block_host(host: Host<&str>) -> Result<(), CustomHttpClientError> { |
|||
let (ip, host_str): (Option<IpAddr>, String) = match host { |
|||
Host::Ipv4(ip) => (Some(ip.into()), ip.to_string()), |
|||
Host::Ipv6(ip) => (Some(ip.into()), ip.to_string()), |
|||
Host::Domain(d) => (None, d.to_string()), |
|||
}; |
|||
|
|||
if let Some(ip) = ip { |
|||
if should_block_ip(ip) { |
|||
return Err(CustomHttpClientError::NonGlobalIp { |
|||
domain: None, |
|||
ip, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
if should_block_address_regex(&host_str) { |
|||
return Err(CustomHttpClientError::Blocked { |
|||
domain: host_str, |
|||
}); |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
#[derive(Debug, Clone)] |
|||
pub enum CustomHttpClientError { |
|||
Blocked { |
|||
domain: String, |
|||
}, |
|||
NonGlobalIp { |
|||
domain: Option<String>, |
|||
ip: IpAddr, |
|||
}, |
|||
} |
|||
|
|||
impl CustomHttpClientError { |
|||
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { |
|||
let mut source = e.source(); |
|||
|
|||
while let Some(err) = source { |
|||
source = err.source(); |
|||
if let Some(err) = err.downcast_ref::<CustomHttpClientError>() { |
|||
return Some(err); |
|||
} |
|||
} |
|||
None |
|||
} |
|||
} |
|||
|
|||
impl fmt::Display for CustomHttpClientError { |
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|||
match self { |
|||
Self::Blocked { |
|||
domain, |
|||
} => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), |
|||
Self::NonGlobalIp { |
|||
domain: Some(domain), |
|||
ip, |
|||
} => write!(f, "IP {ip} for domain '{domain}' is not a global IP!"), |
|||
Self::NonGlobalIp { |
|||
domain: None, |
|||
ip, |
|||
} => write!(f, "IP {ip} is not a global IP!"), |
|||
} |
|||
} |
|||
} |
|||
|
|||
impl std::error::Error for CustomHttpClientError {} |
|||
|
|||
#[derive(Debug, Clone)] |
|||
enum CustomDnsResolver { |
|||
Default(), |
|||
Hickory(Arc<TokioAsyncResolver>), |
|||
} |
|||
type BoxError = Box<dyn std::error::Error + Send + Sync>; |
|||
|
|||
impl CustomDnsResolver { |
|||
fn instance() -> Arc<Self> { |
|||
static INSTANCE: Lazy<Arc<CustomDnsResolver>> = Lazy::new(CustomDnsResolver::new); |
|||
Arc::clone(&*INSTANCE) |
|||
} |
|||
|
|||
fn new() -> Arc<Self> { |
|||
match read_system_conf() { |
|||
Ok((config, opts)) => { |
|||
let resolver = TokioAsyncResolver::tokio(config.clone(), opts.clone()); |
|||
Arc::new(Self::Hickory(Arc::new(resolver))) |
|||
} |
|||
Err(e) => { |
|||
warn!("Error creating Hickory resolver, falling back to default: {e:?}"); |
|||
Arc::new(Self::Default()) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Note that we get an iterator of addresses, but we only grab the first one for convenience
|
|||
async fn resolve_domain(&self, name: &str) -> Result<Option<SocketAddr>, BoxError> { |
|||
pre_resolve(name)?; |
|||
|
|||
let result = match self { |
|||
Self::Default() => tokio::net::lookup_host(name).await?.next(), |
|||
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), |
|||
}; |
|||
|
|||
if let Some(addr) = &result { |
|||
post_resolve(name, addr.ip())?; |
|||
} |
|||
|
|||
Ok(result) |
|||
} |
|||
} |
|||
|
|||
fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { |
|||
if should_block_address(name) { |
|||
return Err(CustomHttpClientError::Blocked { |
|||
domain: name.to_string(), |
|||
}); |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { |
|||
if should_block_ip(ip) { |
|||
Err(CustomHttpClientError::NonGlobalIp { |
|||
domain: Some(name.to_string()), |
|||
ip, |
|||
}) |
|||
} else { |
|||
Ok(()) |
|||
} |
|||
} |
|||
|
|||
impl Resolve for CustomDnsResolver { |
|||
fn resolve(&self, name: Name) -> Resolving { |
|||
let this = self.clone(); |
|||
Box::pin(async move { |
|||
let name = name.as_str(); |
|||
let result = this.resolve_domain(name).await?; |
|||
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter())) |
|||
}) |
|||
} |
|||
} |
@ -1,10 +1,11 @@ |
|||
Incomplete Two-Step Login From {{{device}}} |
|||
Incomplete Two-Step Login From {{{device_name}}} |
|||
<!----------------> |
|||
Someone attempted to log into your account with the correct master password, but did not provide the correct token or action required to complete the two-step login process within {{time_limit}} minutes of the initial login attempt. |
|||
|
|||
* Date: {{datetime}} |
|||
* IP Address: {{ip}} |
|||
* Device Type: {{device}} |
|||
* Device Name: {{device_name}} |
|||
* Device Type: {{device_type}} |
|||
|
|||
If this was not you or someone you authorized, then you should change your master password as soon as possible, as it is likely to be compromised. |
|||
{{> email/email_footer_text }} |
|||
|
@ -1,10 +1,11 @@ |
|||
New Device Logged In From {{{device}}} |
|||
New Device Logged In From {{{device_name}}} |
|||
<!----------------> |
|||
Your account was just logged into from a new device. |
|||
|
|||
* Date: {{datetime}} |
|||
* IP Address: {{ip}} |
|||
* Device Type: {{device}} |
|||
* Device Name: {{device_name}} |
|||
* Device Type: {{device_type}} |
|||
|
|||
You can deauthorize all devices that have access to your account from the web vault ( {{url}} ) under Settings > My Account > Deauthorize Sessions. |
|||
{{> email/email_footer_text }} |
|||
{{> email/email_footer_text }} |
|||
|
Loading…
Reference in new issue