committed by
GitHub
87 changed files with 3164 additions and 1421 deletions
@ -1,40 +1,15 @@ |
|||||
# Local build artifacts |
// Ignore everything |
||||
target |
* |
||||
|
|
||||
# Data folder |
// Allow what is needed |
||||
data |
!.git |
||||
|
|
||||
# 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/** |
|
||||
!docker/healthcheck.sh |
!docker/healthcheck.sh |
||||
!docker/start.sh |
!docker/start.sh |
||||
|
!migrations |
||||
# Web vault |
!src |
||||
web-vault |
|
||||
|
!build.rs |
||||
# Vaultwarden Resources |
!Cargo.lock |
||||
resources |
!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 |
blank_issues_enabled: false |
||||
contact_links: |
contact_links: |
||||
- name: Discourse forum for vaultwarden |
- name: GitHub Discussions 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 |
|
||||
url: https://github.com/dani-garcia/vaultwarden/discussions |
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] |
[toolchain] |
||||
channel = "1.79.0" |
channel = "1.81.0" |
||||
components = [ "rustfmt", "clippy" ] |
components = [ "rustfmt", "clippy" ] |
||||
profile = "minimal" |
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. |
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}} |
* Date: {{datetime}} |
||||
* IP Address: {{ip}} |
* 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. |
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 }} |
{{> 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. |
Your account was just logged into from a new device. |
||||
|
|
||||
* Date: {{datetime}} |
* Date: {{datetime}} |
||||
* IP Address: {{ip}} |
* 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. |
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