Compare commits

...

4 Commits

Author SHA1 Message Date
Mathijs van Veluw
48836501bf Update crates (#4074)
* Remove another header for websocket connections

* Fix small bake issue

* Update crates

Updated crates and adjusted code where needed.
One major update is Rocket rc4, no need anymore (again) for crates.io patching.

The only item still pending is openssl/openssl-sys for which we need to
wait if https://github.com/sfackler/rust-openssl/pull/2094 will be
merged. If, then we can remove the pinned versions for the openssl crate.
2023-11-15 10:41:14 +01:00
Mathijs van Veluw
f863ffb89a Add Protected Actions Check (#4067)
Since the feature `Login with device` some actions done via the
web-vault need to be verified via an OTP instead of providing the MasterPassword.

This only happens if a user used the `Login with device` on a device
which uses either Biometrics login or PIN. These actions prevent the
athorizing device to send the MasterPasswordHash. When this happens, the
web-vault requests an OTP to be filled-in and this OTP is send to the
users email address which is the same as the email address to login.

The only way to bypass this is by logging in with the your password, in
those cases a password is requested instead of an OTP.

In case SMTP is not enabled, it will show an error message telling to
user to login using there password.

Fixes #4042
2023-11-12 22:15:44 +01:00
Mathijs van Veluw
03c6ed2e07 Disable autofill-v2 (#4056)
Disabled autofill-v2 as it seems to cause strange issues as reported
here: https://github.com/dani-garcia/vaultwarden/discussions/4052

Also added the Vaultwarden server version back again but at a different
location.

Fixes #4052
2023-11-09 00:16:27 +01:00
Mathijs van Veluw
efc6eb0073 Fix missing alpine tag during buildx bake (#4043)
The bake recipt was missing the single `:alpine` tag for the alpine
builds when we were releasing a `stable/latest` version of Vaultwarden.

This PR fixes this by checking for those conditions and add the
`:alpine` tag too.

We will keep the `:latest-alpine` also, which i find even nicer then just
`:alpine`

Fixes #4035
2023-11-07 10:50:58 +01:00
24 changed files with 741 additions and 489 deletions

673
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -55,32 +55,31 @@ num-traits = "0.2.17"
num-derive = "0.4.1" num-derive = "0.4.1"
# Web framework # Web framework
rocket = { version = "0.5.0-rc.3", features = ["tls", "json"], default-features = false } rocket = { version = "0.5.0-rc.4", features = ["tls", "json"], default-features = false }
# rocket_ws = { version ="0.1.0-rc.3" } rocket_ws = { version ="0.1.0-rc.4" }
rocket_ws = { git = 'https://github.com/SergioBenitez/Rocket', rev = "ce441b5f46fdf5cd99cb32b8b8638835e4c2a5fa" } # v0.5 branch
# WebSockets libraries # WebSockets libraries
tokio-tungstenite = "0.19.0" tokio-tungstenite = "0.20.1"
rmpv = "1.0.1" # MessagePack library rmpv = "1.0.1" # MessagePack library
# Concurrent HashMap used for WebSocket messaging and favicons # Concurrent HashMap used for WebSocket messaging and favicons
dashmap = "5.5.3" dashmap = "5.5.3"
# Async futures # Async futures
futures = "0.3.28" futures = "0.3.29"
tokio = { version = "1.33.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal"] } tokio = { version = "1.34.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal"] }
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.189", features = ["derive"] } serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.107" serde_json = "1.0.108"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.1.3", features = ["chrono", "r2d2"] } diesel = { version = "2.1.4", features = ["chrono", "r2d2"] }
diesel_migrations = "2.1.0" diesel_migrations = "2.1.0"
diesel_logger = { version = "0.3.0", optional = true } diesel_logger = { version = "0.3.0", optional = true }
# Bundled/Static SQLite # Bundled/Static SQLite
libsqlite3-sys = { version = "0.26.0", features = ["bundled"], optional = true } libsqlite3-sys = { version = "0.27.0", features = ["bundled"], optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = { version = "0.8.5", features = ["small_rng"] } rand = { version = "0.8.5", features = ["small_rng"] }
@@ -91,7 +90,7 @@ uuid = { version = "1.5.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.31", features = ["clock", "serde"], default-features = false } chrono = { version = "0.4.31", features = ["clock", "serde"], default-features = false }
chrono-tz = "0.8.3" chrono-tz = "0.8.4"
time = "0.3.30" time = "0.3.30"
# Job scheduler # Job scheduler
@@ -101,10 +100,10 @@ job_scheduler_ng = "2.0.4"
data-encoding = "2.4.0" data-encoding = "2.4.0"
# JWT library # JWT library
jsonwebtoken = "9.0.0" jsonwebtoken = "9.1.0"
# TOTP library # TOTP library
totp-lite = "2.0.0" totp-lite = "2.0.1"
# Yubico Library # Yubico Library
yubico = { version = "0.11.0", features = ["online-tokio"], default-features = false } yubico = { version = "0.11.0", features = ["online-tokio"], default-features = false }
@@ -116,12 +115,12 @@ webauthn-rs = "0.3.2"
url = "2.4.1" url = "2.4.1"
# Email libraries # Email libraries
lettre = { version = "0.11.0", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false } lettre = { version = "0.11.1", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "tokio1-native-tls", "hostname", "tracing", "tokio1"], default-features = false }
percent-encoding = "2.3.0" # URL encoding library used for URL's in the emails percent-encoding = "2.3.0" # URL encoding library used for URL's in the emails
email_address = "0.2.4" email_address = "0.2.4"
# HTML Template library # HTML Template library
handlebars = { version = "4.4.0", features = ["dir_source"] } handlebars = { version = "4.5.0", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.11.22", features = ["stream", "json", "deflate", "gzip", "brotli", "socks", "cookies", "trust-dns", "native-tls-alpn"] } reqwest = { version = "0.11.22", features = ["stream", "json", "deflate", "gzip", "brotli", "socks", "cookies", "trust-dns", "native-tls-alpn"] }
@@ -133,14 +132,14 @@ data-url = "0.3.0"
bytes = "1.5.0" bytes = "1.5.0"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "0.46.0", features = ["async"] } cached = { version = "0.46.1", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction # Used for custom short lived cookie jar during favicon extraction
cookie = "0.16.2" cookie = "0.16.2"
cookie_store = "0.19.1" cookie_store = "0.19.1"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.57" openssl = "=0.10.57"
# Set openssl-sys fixed to v0.9.92 to prevent building issues with musl, arm and 32bit pointer width # Set openssl-sys fixed to v0.9.92 to prevent building issues with musl, arm and 32bit pointer width
# It will force add a dynamically linked library which prevents the build from being static # It will force add a dynamically linked library which prevents the build from being static
openssl-sys = "=0.9.92" openssl-sys = "=0.9.92"
@@ -164,12 +163,7 @@ which = "5.0.0"
argon2 = "0.5.2" argon2 = "0.5.2"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.2.0" rpassword = "7.3.1"
[patch.crates-io]
rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'ce441b5f46fdf5cd99cb32b8b8638835e4c2a5fa' } # v0.5 branch
# rocket_ws = { git = 'https://github.com/SergioBenitez/Rocket', rev = 'ce441b5f46fdf5cd99cb32b8b8638835e4c2a5fa' } # v0.5 branch
# Strip debuginfo from the release builds # Strip debuginfo from the release builds

View File

@@ -88,7 +88,7 @@ target "debian" {
inherits = ["_default_attributes"] inherits = ["_default_attributes"]
dockerfile = "docker/Dockerfile.debian" dockerfile = "docker/Dockerfile.debian"
tags = generate_tags("", platform_tag()) tags = generate_tags("", platform_tag())
output = [join(",", flatten([["type=docker"], image_index_annotations()]))] output = ["type=docker"]
} }
// Multi Platform target, will build one tagged manifest with all supported architectures // Multi Platform target, will build one tagged manifest with all supported architectures
@@ -138,7 +138,7 @@ target "alpine" {
inherits = ["_default_attributes"] inherits = ["_default_attributes"]
dockerfile = "docker/Dockerfile.alpine" dockerfile = "docker/Dockerfile.alpine"
tags = generate_tags("-alpine", platform_tag()) tags = generate_tags("-alpine", platform_tag())
output = [join(",", flatten([["type=docker"], image_index_annotations()]))] output = ["type=docker"]
} }
// Multi Platform target, will build one tagged manifest with all supported architectures // Multi Platform target, will build one tagged manifest with all supported architectures
@@ -216,7 +216,13 @@ function "generate_tags" {
result = flatten([ result = flatten([
for registry in get_container_registries() : for registry in get_container_registries() :
[for base_tag in get_base_tags() : [for base_tag in get_base_tags() :
concat(["${registry}:${base_tag}${suffix}${platform}"])] concat(
# If the base_tag contains latest, and the suffix contains `-alpine` add a `:alpine` tag too
base_tag == "latest" ? suffix == "-alpine" ? ["${registry}:alpine${platform}"] : [] : [],
# The default tagging strategy
["${registry}:${base_tag}${suffix}${platform}"]
)
]
]) ])
} }

View File

@@ -184,12 +184,11 @@ fn post_admin_login(data: Form<LoginForm>, cookies: &CookieJar<'_>, ip: ClientIp
let claims = generate_admin_claims(); let claims = generate_admin_claims();
let jwt = encode_jwt(&claims); let jwt = encode_jwt(&claims);
let cookie = Cookie::build(COOKIE_NAME, jwt) let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path()) .path(admin_path())
.max_age(rocket::time::Duration::minutes(CONFIG.admin_session_lifetime())) .max_age(rocket::time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.http_only(true) .http_only(true);
.finish();
cookies.add(cookie); cookies.add(cookie);
if let Some(redirect) = redirect { if let Some(redirect) = redirect {
@@ -313,7 +312,7 @@ async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
#[get("/logout")] #[get("/logout")]
fn logout(cookies: &CookieJar<'_>) -> Redirect { fn logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
Redirect::to(admin_path()) Redirect::to(admin_path())
} }
@@ -786,16 +785,16 @@ impl<'r> FromRequest<'r> for AdminToken {
if requested_page.is_empty() { if requested_page.is_empty() {
return Outcome::Forward(Status::Unauthorized); return Outcome::Forward(Status::Unauthorized);
} else { } else {
return Outcome::Failure((Status::Unauthorized, "Unauthorized")); return Outcome::Error((Status::Unauthorized, "Unauthorized"));
} }
} }
}; };
if decode_admin(access_token).is_err() { if decode_admin(access_token).is_err() {
// Remove admin cookie // Remove admin cookie
cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip); error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
return Outcome::Failure((Status::Unauthorized, "Session expired")); return Outcome::Error((Status::Unauthorized, "Session expired"));
} }
Outcome::Success(Self { Outcome::Success(Self {

View File

@@ -6,7 +6,7 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ api::{
core::log_user_event, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, core::log_user_event, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult,
JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType, JsonUpcase, Notify, NumberOrString, PasswordOrOtpData, UpdateType,
}, },
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
crypto, crypto,
@@ -503,17 +503,15 @@ async fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, mut conn: D
#[post("/accounts/security-stamp", data = "<data>")] #[post("/accounts/security-stamp", data = "<data>")]
async fn post_sstamp( async fn post_sstamp(
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
headers: Headers, headers: Headers,
mut conn: DbConn, mut conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password")
}
Device::delete_all_by_user(&user.uuid, &mut conn).await?; Device::delete_all_by_user(&user.uuid, &mut conn).await?;
user.reset_security_stamp(); user.reset_security_stamp();
@@ -736,18 +734,16 @@ async fn post_delete_recover_token(data: JsonUpcase<DeleteRecoverTokenData>, mut
} }
#[post("/accounts/delete", data = "<data>")] #[post("/accounts/delete", data = "<data>")]
async fn post_delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn post_delete_account(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
delete_account(data, headers, conn).await delete_account(data, headers, conn).await
} }
#[delete("/accounts", data = "<data>")] #[delete("/accounts", data = "<data>")]
async fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> EmptyResult { async fn delete_account(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> EmptyResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password")
}
user.delete(&mut conn).await user.delete(&mut conn).await
} }
@@ -854,20 +850,13 @@ fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers
Ok(()) Ok(())
} }
async fn _api_key( async fn _api_key(data: JsonUpcase<PasswordOrOtpData>, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult {
data: JsonUpcase<SecretVerificationRequest>,
rotate: bool,
headers: Headers,
mut conn: DbConn,
) -> JsonResult {
use crate::util::format_date; use crate::util::format_date;
let data: SecretVerificationRequest = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password")
}
if rotate || user.api_key.is_none() { if rotate || user.api_key.is_none() {
user.api_key = Some(crypto::generate_api_key()); user.api_key = Some(crypto::generate_api_key());
@@ -882,12 +871,12 @@ async fn _api_key(
} }
#[post("/accounts/api-key", data = "<data>")] #[post("/accounts/api-key", data = "<data>")]
async fn api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult { async fn api_key(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
_api_key(data, false, headers, conn).await _api_key(data, false, headers, conn).await
} }
#[post("/accounts/rotate-api-key", data = "<data>")] #[post("/accounts/rotate-api-key", data = "<data>")]
async fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult { async fn rotate_api_key(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
_api_key(data, true, headers, conn).await _api_key(data, true, headers, conn).await
} }
@@ -921,26 +910,23 @@ impl<'r> FromRequest<'r> for KnownDevice {
let email_bytes = match data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) { let email_bytes = match data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(_) => { Err(_) => {
return Outcome::Failure(( return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
Status::BadRequest,
"X-Request-Email value failed to decode as base64url",
));
} }
}; };
match String::from_utf8(email_bytes) { match String::from_utf8(email_bytes) {
Ok(email) => email, Ok(email) => email,
Err(_) => { Err(_) => {
return Outcome::Failure((Status::BadRequest, "X-Request-Email value failed to decode as UTF-8")); return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as UTF-8"));
} }
} }
} else { } else {
return Outcome::Failure((Status::BadRequest, "X-Request-Email value is required")); return Outcome::Error((Status::BadRequest, "X-Request-Email value is required"));
}; };
let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") { let uuid = if let Some(uuid) = req.headers().get_one("X-Device-Identifier") {
uuid.to_string() uuid.to_string()
} else { } else {
return Outcome::Failure((Status::BadRequest, "X-Device-Identifier value is required")); return Outcome::Error((Status::BadRequest, "X-Device-Identifier value is required"));
}; };
Outcome::Success(KnownDevice { Outcome::Success(KnownDevice {

View File

@@ -10,7 +10,7 @@ use rocket::{
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::{self, core::log_event, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordData, UpdateType}, api::{self, core::log_event, EmptyResult, JsonResult, JsonUpcase, Notify, PasswordOrOtpData, UpdateType},
auth::Headers, auth::Headers,
crypto, crypto,
db::{models::*, DbConn, DbPool}, db::{models::*, DbConn, DbPool},
@@ -1457,19 +1457,15 @@ struct OrganizationId {
#[post("/ciphers/purge?<organization..>", data = "<data>")] #[post("/ciphers/purge?<organization..>", data = "<data>")]
async fn delete_all( async fn delete_all(
organization: Option<OrganizationId>, organization: Option<OrganizationId>,
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
headers: Headers, headers: Headers,
mut conn: DbConn, mut conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&password_hash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password")
}
match organization { match organization {
Some(org_data) => { Some(org_data) => {

View File

@@ -203,7 +203,8 @@ fn config() -> Json<Value> {
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden" "url": "https://github.com/dani-garcia/vaultwarden",
"version": crate::VERSION
}, },
"environment": { "environment": {
"vault": domain, "vault": domain,
@@ -216,8 +217,8 @@ fn config() -> Json<Value> {
// Any feature flags that we want the clients to use // Any feature flags that we want the clients to use
// Can check the enabled ones at: // Can check the enabled ones at:
// https://vault.bitwarden.com/api/config // https://vault.bitwarden.com/api/config
"autofill-v2": true, "fido2-vault-credentials": true, // Passkey support
"fido2-vault-credentials": true "autofill-v2": false, // Disabled because it is causing issues https://github.com/dani-garcia/vaultwarden/discussions/4052
}, },
"object": "config", "object": "config",
})) }))

View File

@@ -6,7 +6,8 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ api::{
core::{log_event, CipherSyncData, CipherSyncType}, core::{log_event, CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, PasswordData, UpdateType, EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, PasswordOrOtpData,
UpdateType,
}, },
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
db::{models::*, DbConn}, db::{models::*, DbConn},
@@ -186,16 +187,13 @@ async fn create_organization(headers: Headers, data: JsonUpcase<OrgData>, mut co
#[delete("/organizations/<org_id>", data = "<data>")] #[delete("/organizations/<org_id>", data = "<data>")]
async fn delete_organization( async fn delete_organization(
org_id: &str, org_id: &str,
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
headers: OwnerHeaders, headers: OwnerHeaders,
mut conn: DbConn, mut conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
if !headers.user.check_valid_password(&password_hash) { data.validate(&headers.user, true, &mut conn).await?;
err!("Invalid password")
}
match Organization::find_by_uuid(org_id, &mut conn).await { match Organization::find_by_uuid(org_id, &mut conn).await {
None => err!("Organization not found"), None => err!("Organization not found"),
@@ -206,7 +204,7 @@ async fn delete_organization(
#[post("/organizations/<org_id>/delete", data = "<data>")] #[post("/organizations/<org_id>/delete", data = "<data>")]
async fn post_delete_organization( async fn post_delete_organization(
org_id: &str, org_id: &str,
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
headers: OwnerHeaders, headers: OwnerHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
@@ -2945,18 +2943,16 @@ async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) -
async fn _api_key( async fn _api_key(
org_id: &str, org_id: &str,
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
rotate: bool, rotate: bool,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
// Validate the admin users password // Validate the admin users password/otp
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password")
}
let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
Some(mut org_api_key) => { Some(mut org_api_key) => {
@@ -2983,14 +2979,14 @@ async fn _api_key(
} }
#[post("/organizations/<org_id>/api-key", data = "<data>")] #[post("/organizations/<org_id>/api-key", data = "<data>")]
async fn api_key(org_id: &str, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult { async fn api_key(org_id: &str, data: JsonUpcase<PasswordOrOtpData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
_api_key(org_id, data, false, headers, conn).await _api_key(org_id, data, false, headers, conn).await
} }
#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")] #[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
async fn rotate_api_key( async fn rotate_api_key(
org_id: &str, org_id: &str,
data: JsonUpcase<PasswordData>, data: JsonUpcase<PasswordOrOtpData>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {

View File

@@ -5,7 +5,7 @@ use rocket::Route;
use crate::{ use crate::{
api::{ api::{
core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, core::log_user_event, core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase,
NumberOrString, PasswordData, NumberOrString, PasswordOrOtpData,
}, },
auth::{ClientIp, Headers}, auth::{ClientIp, Headers},
crypto, crypto,
@@ -22,13 +22,11 @@ pub fn routes() -> Vec<Route> {
} }
#[post("/two-factor/get-authenticator", data = "<data>")] #[post("/two-factor/get-authenticator", data = "<data>")]
async fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn generate_authenticator(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, false, &mut conn).await?;
err!("Invalid password");
}
let type_ = TwoFactorType::Authenticator as i32; let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await; let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await;
@@ -48,9 +46,10 @@ async fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct EnableAuthenticatorData { struct EnableAuthenticatorData {
MasterPasswordHash: String,
Key: String, Key: String,
Token: NumberOrString, Token: NumberOrString,
MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
#[post("/two-factor/authenticator", data = "<data>")] #[post("/two-factor/authenticator", data = "<data>")]
@@ -60,15 +59,17 @@ async fn activate_authenticator(
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
let data: EnableAuthenticatorData = data.into_inner().data; let data: EnableAuthenticatorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let key = data.Key; let key = data.Key;
let token = data.Token.into_string(); let token = data.Token.into_string();
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&password_hash) { PasswordOrOtpData {
err!("Invalid password"); MasterPasswordHash: data.MasterPasswordHash,
Otp: data.Otp,
} }
.validate(&user, true, &mut conn)
.await?;
// Validate key as base32 and 20 bytes length // Validate key as base32 and 20 bytes length
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) { let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {

View File

@@ -6,7 +6,7 @@ use rocket::Route;
use crate::{ use crate::{
api::{ api::{
core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase, core::log_user_event, core::two_factor::_generate_recover_code, ApiResult, EmptyResult, JsonResult, JsonUpcase,
PasswordData, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
crypto, crypto,
@@ -92,14 +92,13 @@ impl DuoStatus {
const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>"; const DISABLED_MESSAGE_DEFAULT: &str = "<To use the global Duo keys, please leave these fields untouched>";
#[post("/two-factor/get-duo", data = "<data>")] #[post("/two-factor/get-duo", data = "<data>")]
async fn get_duo(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn get_duo(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, false, &mut conn).await?;
err!("Invalid password");
}
let data = get_user_duo_data(&headers.user.uuid, &mut conn).await; let data = get_user_duo_data(&user.uuid, &mut conn).await;
let (enabled, data) = match data { let (enabled, data) = match data {
DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::Global(_) => (true, Some(DuoData::secret())),
@@ -129,10 +128,11 @@ async fn get_duo(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbC
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(non_snake_case, dead_code)] #[allow(non_snake_case, dead_code)]
struct EnableDuoData { struct EnableDuoData {
MasterPasswordHash: String,
Host: String, Host: String,
SecretKey: String, SecretKey: String,
IntegrationKey: String, IntegrationKey: String,
MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
impl From<EnableDuoData> for DuoData { impl From<EnableDuoData> for DuoData {
@@ -159,9 +159,12 @@ async fn activate_duo(data: JsonUpcase<EnableDuoData>, headers: Headers, mut con
let data: EnableDuoData = data.into_inner().data; let data: EnableDuoData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { PasswordOrOtpData {
err!("Invalid password"); MasterPasswordHash: data.MasterPasswordHash.clone(),
Otp: data.Otp.clone(),
} }
.validate(&user, true, &mut conn)
.await?;
let (data, data_str) = if check_duo_fields_custom(&data) { let (data, data_str) = if check_duo_fields_custom(&data) {
let data_req: DuoData = data.into(); let data_req: DuoData = data.into();

View File

@@ -5,7 +5,7 @@ use rocket::Route;
use crate::{ use crate::{
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code}, core::{log_user_event, two_factor::_generate_recover_code},
EmptyResult, JsonResult, JsonUpcase, PasswordData, EmptyResult, JsonResult, JsonUpcase, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
crypto, crypto,
@@ -76,13 +76,11 @@ pub async fn send_token(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
/// When user clicks on Manage email 2FA show the user the related information /// When user clicks on Manage email 2FA show the user the related information
#[post("/two-factor/get-email", data = "<data>")] #[post("/two-factor/get-email", data = "<data>")]
async fn get_email(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn get_email(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, false, &mut conn).await?;
err!("Invalid password");
}
let (enabled, mfa_email) = let (enabled, mfa_email) =
match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await { match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &mut conn).await {
@@ -105,7 +103,8 @@ async fn get_email(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: D
struct SendEmailData { struct SendEmailData {
/// Email where 2FA codes will be sent to, can be different than user email account. /// Email where 2FA codes will be sent to, can be different than user email account.
Email: String, Email: String,
MasterPasswordHash: String, MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
/// Send a verification email to the specified email address to check whether it exists/belongs to user. /// Send a verification email to the specified email address to check whether it exists/belongs to user.
@@ -114,9 +113,12 @@ async fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, mut conn:
let data: SendEmailData = data.into_inner().data; let data: SendEmailData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { PasswordOrOtpData {
err!("Invalid password"); MasterPasswordHash: data.MasterPasswordHash,
Otp: data.Otp,
} }
.validate(&user, false, &mut conn)
.await?;
if !CONFIG._enable_email_2fa() { if !CONFIG._enable_email_2fa() {
err!("Email 2FA is disabled") err!("Email 2FA is disabled")
@@ -144,8 +146,9 @@ async fn send_email(data: JsonUpcase<SendEmailData>, headers: Headers, mut conn:
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct EmailData { struct EmailData {
Email: String, Email: String,
MasterPasswordHash: String,
Token: String, Token: String,
MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
/// Verify email belongs to user and can be used for 2FA email codes. /// Verify email belongs to user and can be used for 2FA email codes.
@@ -154,9 +157,13 @@ async fn email(data: JsonUpcase<EmailData>, headers: Headers, mut conn: DbConn)
let data: EmailData = data.into_inner().data; let data: EmailData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { // This is the last step in the verification process, delete the otp directly afterwards
err!("Invalid password"); PasswordOrOtpData {
MasterPasswordHash: data.MasterPasswordHash,
Otp: data.Otp,
} }
.validate(&user, true, &mut conn)
.await?;
let type_ = TwoFactorType::EmailVerificationChallenge as i32; let type_ = TwoFactorType::EmailVerificationChallenge as i32;
let mut twofactor = let mut twofactor =

View File

@@ -5,7 +5,7 @@ use rocket::Route;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::{core::log_user_event, JsonResult, JsonUpcase, NumberOrString, PasswordData}, api::{core::log_user_event, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData},
auth::{ClientHeaders, Headers}, auth::{ClientHeaders, Headers},
crypto, crypto,
db::{models::*, DbConn, DbPool}, db::{models::*, DbConn, DbPool},
@@ -15,6 +15,7 @@ use crate::{
pub mod authenticator; pub mod authenticator;
pub mod duo; pub mod duo;
pub mod email; pub mod email;
pub mod protected_actions;
pub mod webauthn; pub mod webauthn;
pub mod yubikey; pub mod yubikey;
@@ -33,6 +34,7 @@ pub fn routes() -> Vec<Route> {
routes.append(&mut email::routes()); routes.append(&mut email::routes());
routes.append(&mut webauthn::routes()); routes.append(&mut webauthn::routes());
routes.append(&mut yubikey::routes()); routes.append(&mut yubikey::routes());
routes.append(&mut protected_actions::routes());
routes routes
} }
@@ -50,13 +52,11 @@ async fn get_twofactor(headers: Headers, mut conn: DbConn) -> Json<Value> {
} }
#[post("/two-factor/get-recover", data = "<data>")] #[post("/two-factor/get-recover", data = "<data>")]
fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult { async fn get_recover(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, true, &mut conn).await?;
err!("Invalid password");
}
Ok(Json(json!({ Ok(Json(json!({
"Code": user.totp_recover, "Code": user.totp_recover,
@@ -123,19 +123,23 @@ async fn _generate_recover_code(user: &mut User, conn: &mut DbConn) {
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct DisableTwoFactorData { struct DisableTwoFactorData {
MasterPasswordHash: String, MasterPasswordHash: Option<String>,
Otp: Option<String>,
Type: NumberOrString, Type: NumberOrString,
} }
#[post("/two-factor/disable", data = "<data>")] #[post("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner().data; let data: DisableTwoFactorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&password_hash) { // Delete directly after a valid token has been provided
err!("Invalid password"); PasswordOrOtpData {
MasterPasswordHash: data.MasterPasswordHash,
Otp: data.Otp,
} }
.validate(&user, true, &mut conn)
.await?;
let type_ = data.Type.into_i32()?; let type_ = data.Type.into_i32()?;

View File

@@ -0,0 +1,142 @@
use chrono::{Duration, NaiveDateTime, Utc};
use rocket::Route;
use crate::{
api::{EmptyResult, JsonUpcase},
auth::Headers,
crypto,
db::{
models::{TwoFactor, TwoFactorType},
DbConn,
},
error::{Error, MapResult},
mail, CONFIG,
};
pub fn routes() -> Vec<Route> {
routes![request_otp, verify_otp]
}
/// Data stored in the TwoFactor table in the db
#[derive(Serialize, Deserialize, Debug)]
pub struct ProtectedActionData {
/// Token issued to validate the protected action
pub token: String,
/// UNIX timestamp of token issue.
pub token_sent: i64,
// The total amount of attempts
pub attempts: u8,
}
impl ProtectedActionData {
pub fn new(token: String) -> Self {
Self {
token,
token_sent: Utc::now().naive_utc().timestamp(),
attempts: 0,
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(&self).unwrap()
}
pub fn from_json(string: &str) -> Result<Self, Error> {
let res: Result<Self, crate::serde_json::Error> = serde_json::from_str(string);
match res {
Ok(x) => Ok(x),
Err(_) => err!("Could not decode ProtectedActionData from string"),
}
}
pub fn add_attempt(&mut self) {
self.attempts += 1;
}
}
#[post("/accounts/request-otp")]
async fn request_otp(headers: Headers, mut conn: DbConn) -> EmptyResult {
if !CONFIG.mail_enabled() {
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
}
let user = headers.user;
// Only one Protected Action per user is allowed to take place, delete the previous one
if let Some(pa) =
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &mut conn).await
{
pa.delete(&mut conn).await?;
}
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
let pa_data = ProtectedActionData::new(generated_token);
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::ProtectedActions, pa_data.to_json());
twofactor.save(&mut conn).await?;
mail::send_protected_action_token(&user.email, &pa_data.token).await?;
Ok(())
}
#[derive(Deserialize, Serialize, Debug)]
#[allow(non_snake_case)]
struct ProtectedActionVerify {
OTP: String,
}
#[post("/accounts/verify-otp", data = "<data>")]
async fn verify_otp(data: JsonUpcase<ProtectedActionVerify>, headers: Headers, mut conn: DbConn) -> EmptyResult {
if !CONFIG.mail_enabled() {
err!("Email is disabled for this server. Either enable email or login using your master password instead of login via device.");
}
let user = headers.user;
let data: ProtectedActionVerify = data.into_inner().data;
// Delete the token after one validation attempt
// This endpoint only gets called for the vault export, and doesn't need a second attempt
validate_protected_action_otp(&data.OTP, &user.uuid, true, &mut conn).await
}
pub async fn validate_protected_action_otp(
otp: &str,
user_uuid: &str,
delete_if_valid: bool,
conn: &mut DbConn,
) -> EmptyResult {
let pa = TwoFactor::find_by_user_and_type(user_uuid, TwoFactorType::ProtectedActions as i32, conn)
.await
.map_res("Protected action token not found, try sending the code again or restart the process")?;
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
pa_data.add_attempt();
// Delete the token after x attempts if it has been used too many times
// We use the 6, which should be more then enough for invalid attempts and multiple valid checks
if pa_data.attempts > 6 {
pa.delete(conn).await?;
err!("Token has expired")
}
// Check if the token has expired (Using the email 2fa expiration time)
let date =
NaiveDateTime::from_timestamp_opt(pa_data.token_sent, 0).expect("Protected Action token timestamp invalid.");
let max_time = CONFIG.email_expiration_time() as i64;
if date + Duration::seconds(max_time) < Utc::now().naive_utc() {
pa.delete(conn).await?;
err!("Token has expired")
}
if !crypto::ct_eq(&pa_data.token, otp) {
pa.save(conn).await?;
err!("Token is invalid")
}
if delete_if_valid {
pa.delete(conn).await?;
}
Ok(())
}

View File

@@ -7,7 +7,7 @@ use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState,
use crate::{ use crate::{
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code}, core::{log_user_event, two_factor::_generate_recover_code},
EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
db::{ db::{
@@ -103,16 +103,17 @@ impl WebauthnRegistration {
} }
#[post("/two-factor/get-webauthn", data = "<data>")] #[post("/two-factor/get-webauthn", data = "<data>")]
async fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn get_webauthn(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
if !CONFIG.domain_set() { if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled") err!("`DOMAIN` environment variable is not set. Webauthn disabled")
} }
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { let data: PasswordOrOtpData = data.into_inner().data;
err!("Invalid password"); let user = headers.user;
}
let (enabled, registrations) = get_webauthn_registrations(&headers.user.uuid, &mut conn).await?; data.validate(&user, false, &mut conn).await?;
let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &mut conn).await?;
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect(); let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect();
Ok(Json(json!({ Ok(Json(json!({
@@ -123,12 +124,17 @@ async fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, mut conn
} }
#[post("/two-factor/get-webauthn-challenge", data = "<data>")] #[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn generate_webauthn_challenge(
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { data: JsonUpcase<PasswordOrOtpData>,
err!("Invalid password"); headers: Headers,
} mut conn: DbConn,
) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user;
let registrations = get_webauthn_registrations(&headers.user.uuid, &mut conn) data.validate(&user, false, &mut conn).await?;
let registrations = get_webauthn_registrations(&user.uuid, &mut conn)
.await? .await?
.1 .1
.into_iter() .into_iter()
@@ -136,16 +142,16 @@ async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: He
.collect(); .collect();
let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options(
headers.user.uuid.as_bytes().to_vec(), user.uuid.as_bytes().to_vec(),
headers.user.email, user.email,
headers.user.name, user.name,
Some(registrations), Some(registrations),
None, None,
None, None,
)?; )?;
let type_ = TwoFactorType::WebauthnRegisterChallenge; let type_ = TwoFactorType::WebauthnRegisterChallenge;
TwoFactor::new(headers.user.uuid, type_, serde_json::to_string(&state)?).save(&mut conn).await?; TwoFactor::new(user.uuid, type_, serde_json::to_string(&state)?).save(&mut conn).await?;
let mut challenge_value = serde_json::to_value(challenge.public_key)?; let mut challenge_value = serde_json::to_value(challenge.public_key)?;
challenge_value["status"] = "ok".into(); challenge_value["status"] = "ok".into();
@@ -158,8 +164,9 @@ async fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: He
struct EnableWebauthnData { struct EnableWebauthnData {
Id: NumberOrString, // 1..5 Id: NumberOrString, // 1..5
Name: String, Name: String,
MasterPasswordHash: String,
DeviceResponse: RegisterPublicKeyCredentialCopy, DeviceResponse: RegisterPublicKeyCredentialCopy,
MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
// This is copied from RegisterPublicKeyCredential to change the Response objects casing // This is copied from RegisterPublicKeyCredential to change the Response objects casing
@@ -246,9 +253,12 @@ async fn activate_webauthn(data: JsonUpcase<EnableWebauthnData>, headers: Header
let data: EnableWebauthnData = data.into_inner().data; let data: EnableWebauthnData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { PasswordOrOtpData {
err!("Invalid password"); MasterPasswordHash: data.MasterPasswordHash,
Otp: data.Otp,
} }
.validate(&user, true, &mut conn)
.await?;
// Retrieve and delete the saved challenge state // Retrieve and delete the saved challenge state
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;

View File

@@ -6,7 +6,7 @@ use yubico::{config::Config, verify};
use crate::{ use crate::{
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code}, core::{log_user_event, two_factor::_generate_recover_code},
EmptyResult, JsonResult, JsonUpcase, PasswordData, EmptyResult, JsonResult, JsonUpcase, PasswordOrOtpData,
}, },
auth::Headers, auth::Headers,
db::{ db::{
@@ -24,13 +24,14 @@ pub fn routes() -> Vec<Route> {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct EnableYubikeyData { struct EnableYubikeyData {
MasterPasswordHash: String,
Key1: Option<String>, Key1: Option<String>,
Key2: Option<String>, Key2: Option<String>,
Key3: Option<String>, Key3: Option<String>,
Key4: Option<String>, Key4: Option<String>,
Key5: Option<String>, Key5: Option<String>,
Nfc: bool, Nfc: bool,
MasterPasswordHash: Option<String>,
Otp: Option<String>,
} }
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
@@ -83,16 +84,14 @@ async fn verify_yubikey_otp(otp: String) -> EmptyResult {
} }
#[post("/two-factor/get-yubikey", data = "<data>")] #[post("/two-factor/get-yubikey", data = "<data>")]
async fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn generate_yubikey(data: JsonUpcase<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
// Make sure the credentials are set // Make sure the credentials are set
get_yubico_credentials()?; get_yubico_credentials()?;
let data: PasswordData = data.into_inner().data; let data: PasswordOrOtpData = data.into_inner().data;
let user = headers.user; let user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { data.validate(&user, false, &mut conn).await?;
err!("Invalid password");
}
let user_uuid = &user.uuid; let user_uuid = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32; let yubikey_type = TwoFactorType::YubiKey as i32;
@@ -122,9 +121,12 @@ async fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers,
let data: EnableYubikeyData = data.into_inner().data; let data: EnableYubikeyData = data.into_inner().data;
let mut user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&data.MasterPasswordHash) { PasswordOrOtpData {
err!("Invalid password"); MasterPasswordHash: data.MasterPasswordHash.clone(),
Otp: data.Otp.clone(),
} }
.validate(&user, true, &mut conn)
.await?;
// Check if we already have some data // Check if we already have some data
let mut yubikey_data = let mut yubikey_data =

View File

@@ -32,6 +32,7 @@ pub use crate::api::{
web::routes as web_routes, web::routes as web_routes,
web::static_files, web::static_files,
}; };
use crate::db::{models::User, DbConn};
use crate::util; use crate::util;
// Type aliases for API methods results // Type aliases for API methods results
@@ -46,8 +47,31 @@ type JsonVec<T> = Json<Vec<T>>;
// Common structs representing JSON data received // Common structs representing JSON data received
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct PasswordData { struct PasswordOrOtpData {
MasterPasswordHash: String, MasterPasswordHash: Option<String>,
Otp: Option<String>,
}
impl PasswordOrOtpData {
/// Tokens used via this struct can be used multiple times during the process
/// First for the validation to continue, after that to enable or validate the following actions
/// This is different per caller, so it can be adjusted to delete the token or not
pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &mut DbConn) -> EmptyResult {
use crate::api::core::two_factor::protected_actions::validate_protected_action_otp;
match (self.MasterPasswordHash.as_deref(), self.Otp.as_deref()) {
(Some(pw_hash), None) => {
if !user.check_valid_password(pw_hash) {
err!("Invalid password");
}
}
(None, Some(otp)) => {
validate_protected_action_otp(otp, &user.uuid, delete_if_valid, conn).await?;
}
_ => err!("No validation provided"),
}
Ok(())
}
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]

View File

@@ -1243,17 +1243,18 @@ where
reg!("email/invite_accepted", ".html"); reg!("email/invite_accepted", ".html");
reg!("email/invite_confirmed", ".html"); reg!("email/invite_confirmed", ".html");
reg!("email/new_device_logged_in", ".html"); reg!("email/new_device_logged_in", ".html");
reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html"); reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html"); reg!("email/pw_hint_some", ".html");
reg!("email/send_2fa_removed_from_org", ".html"); reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/send_org_invite", ".html");
reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_emergency_access_invite", ".html");
reg!("email/send_org_invite", ".html");
reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/smtp_test", ".html");
reg!("email/twofactor_email", ".html"); reg!("email/twofactor_email", ".html");
reg!("email/verify_email", ".html"); reg!("email/verify_email", ".html");
reg!("email/welcome", ".html");
reg!("email/welcome_must_verify", ".html"); reg!("email/welcome_must_verify", ".html");
reg!("email/smtp_test", ".html"); reg!("email/welcome", ".html");
reg!("admin/base"); reg!("admin/base");
reg!("admin/login"); reg!("admin/login");

View File

@@ -7,7 +7,6 @@ use diesel::{
use rocket::{ use rocket::{
http::Status, http::Status,
outcome::IntoOutcome,
request::{FromRequest, Outcome}, request::{FromRequest, Outcome},
Request, Request,
}; };
@@ -413,8 +412,11 @@ impl<'r> FromRequest<'r> for DbConn {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.rocket().state::<DbPool>() { match request.rocket().state::<DbPool>() {
Some(p) => p.get().await.map_err(|_| ()).into_outcome(Status::ServiceUnavailable), Some(p) => match p.get().await {
None => Outcome::Failure((Status::InternalServerError, ())), Ok(dbconn) => Outcome::Success(dbconn),
_ => Outcome::Error((Status::ServiceUnavailable, ())),
},
None => Outcome::Error((Status::InternalServerError, ())),
} }
} }
} }

View File

@@ -34,6 +34,9 @@ pub enum TwoFactorType {
EmailVerificationChallenge = 1002, EmailVerificationChallenge = 1002,
WebauthnRegisterChallenge = 1003, WebauthnRegisterChallenge = 1003,
WebauthnLoginChallenge = 1004, WebauthnLoginChallenge = 1004,
// Special type for Protected Actions verification via email
ProtectedActions = 2000,
} }
/// Local methods /// Local methods

View File

@@ -291,10 +291,10 @@ macro_rules! err_json {
macro_rules! err_handler { macro_rules! err_handler {
($expr:expr) => {{ ($expr:expr) => {{
error!(target: "auth", "Unauthorized Error: {}", $expr); error!(target: "auth", "Unauthorized Error: {}", $expr);
return ::rocket::request::Outcome::Failure((rocket::http::Status::Unauthorized, $expr)); return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
}}; }};
($usr_msg:expr, $log_value:expr) => {{ ($usr_msg:expr, $log_value:expr) => {{
error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value); error!(target: "auth", "Unauthorized Error: {}. {}", $usr_msg, $log_value);
return ::rocket::request::Outcome::Failure((rocket::http::Status::Unauthorized, $usr_msg)); return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $usr_msg));
}}; }};
} }

View File

@@ -517,6 +517,19 @@ pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name:
send_email(address, &subject, body_html, body_text).await send_email(address, &subject, body_html, body_text).await
} }
pub async fn send_protected_action_token(address: &str, token: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/protected_action",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"token": token,
}),
)?;
send_email(address, &subject, body_html, body_text).await
}
async fn send_with_selected_transport(email: Message) -> EmptyResult { async fn send_with_selected_transport(email: Message) -> EmptyResult {
if CONFIG.use_sendmail() { if CONFIG.use_sendmail() {
match sendmail_transport().send(email).await { match sendmail_transport().send(email).await {

View File

@@ -0,0 +1,6 @@
Your Vaultwarden Verification Code
<!---------------->
Your email verification code is: {{token}}
Use this code to complete the protected action in Vaultwarden.
{{> email/email_footer_text }}

View File

@@ -0,0 +1,16 @@
Your Vaultwarden Verification Code
<!---------------->
{{> email/email_header }}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Your email verification code is: <b>{{token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
Use this code to complete the protected action in Vaultwarden.
</td>
</tr>
</table>
{{> email/email_footer }}

View File

@@ -46,6 +46,7 @@ impl Fairing for AppHeaders {
// Remove headers which could cause websocket connection issues // Remove headers which could cause websocket connection issues
res.remove_header("X-Frame-Options"); res.remove_header("X-Frame-Options");
res.remove_header("X-Content-Type-Options"); res.remove_header("X-Content-Type-Options");
res.remove_header("Permissions-Policy");
return; return;
} }
(_, _) => (), (_, _) => (),