Compare commits

..

10 Commits

Author SHA1 Message Date
Mathijs van Veluw
cdfdc6ff4f Fix Org Import duplicate collections (#5200)
This fixes an issue with collections be duplicated same as was an issue with folders.
Also made some optimizations by using HashSet where possible and device the Vec/Hash capacity.
And instead of passing objects only use the UUID which was the only value we needed.

Also found an issue with importing a personal export via the Org import where folders are used.
Since Org's do not use folder we needed to clear those out, same as Bitwarden does.

Fixes #5193

Signed-off-by: BlackDex <black.dex@gmail.com>
2024-11-17 21:33:23 +01:00
Daniel García
2393c3f3c0 Support SSH keys on desktop 2024.12 (#5187)
* Support SSH keys on desktop 2024.12

* Document flags in .env.template

* Validate key rotation contents
2024-11-15 18:38:16 +01:00
Daniel García
0d16b38a68 Some more authrequest changes (#5188) 2024-11-15 11:25:51 +01:00
Stefan Melmuk
ff33534c07 don't infer manage permission for groups (#5190)
the web-vault v2024.6.2 currently cannot deal with manage permission so
instead of relying on the org user type this should just default to false
2024-11-13 19:19:19 +01:00
Stefan Melmuk
adb21d5c1a fix password hint check (#5189)
* fix password hint check

don't show password hints if you have disabled the hints with
PASSWORD_HINTS_ALLOWED=false or if you have not configured mail and
opted into showing password hints

* update descriptions for pw hints options
2024-11-12 21:22:25 +01:00
Mathijs van Veluw
e927b8aa5e Remove auth-request deletion (#5184)
2FA is needed to login even when using login-with-device.
If the user didn't saved the 2FA token they still need to provide this.
We deleted the auth-request after validation the request, but before 2FA was triggered.

Removing the deletion of this record from that point as it will get cleaned-up automatically anyways.

Signed-off-by: BlackDex <black.dex@gmail.com>
2024-11-12 15:48:39 +01:00
Mathijs van Veluw
ba48ca68fc fix hibp username encoding and pw hint check (#5180)
* fix hibp username encoding

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix password-hint check

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2024-11-12 11:09:28 +01:00
Mathijs van Veluw
294b429436 Add dynamic CSS support (#4940)
* Add dynamic CSS support

Together with https://github.com/dani-garcia/bw_web_builds/pull/180 this PR will add support for dynamic CSS changes.

For example, we could hide the register link if signups are not allowed.
In the future show or hide the SSO button depending on if it is enabled or not.

There also is a special `user.vaultwarden.scss` file so that users can add custom CSS without the need to modify the default (static) changes.
This will prevent future changes from not being applied and still have the custom user changes to be added.

Also added a special redirect when someone goes directly to `/index.html` as that might cause issues with loading other scripts and files.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Add versions and fallback to built-in

- Add both Vaultwarden and web-vault versions to the css_options.
- Fallback to the inner templates if rendering or compiling the scss fails.
  This ensures the basics are always working even if someone breaks the templates.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Fix fallback code to actually work

The fallback now works by using an alternative `reg!` macro.
This adds an extra template register which prefixes the template with `fallback_`.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Updated the wiki link in the user template

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2024-11-11 20:14:04 +01:00
Daniel García
37c14c3c69 More authrequest fixes (#5176) 2024-11-11 20:13:02 +01:00
Mathijs van Veluw
d0581da638 Fix if logic error (#5171)
Fixing a logical error in an if statement where we used `&&` which should have been `||`.

Signed-off-by: BlackDex <black.dex@gmail.com>
2024-11-11 11:50:33 +01:00
16 changed files with 526 additions and 130 deletions

View File

@@ -280,12 +280,13 @@
## The default for new users. If changed, it will be updated during login for existing users. ## The default for new users. If changed, it will be updated during login for existing users.
# PASSWORD_ITERATIONS=600000 # PASSWORD_ITERATIONS=600000
## Controls whether users can set password hints. This setting applies globally to all users. ## Controls whether users can set or show password hints. This setting applies globally to all users.
# PASSWORD_HINTS_ALLOWED=true # PASSWORD_HINTS_ALLOWED=true
## Controls whether a password hint should be shown directly in the web page if ## Controls whether a password hint should be shown directly in the web page if
## SMTP service is not configured. Not recommended for publicly-accessible instances ## SMTP service is not configured and password hints are allowed.
## as this provides unauthenticated access to potentially sensitive data. ## Not recommended for publicly-accessible instances because this provides
## unauthenticated access to potentially sensitive data.
# SHOW_PASSWORD_HINT=false # SHOW_PASSWORD_HINT=false
######################### #########################
@@ -349,6 +350,8 @@
## - "browser-fileless-import": Directly import credentials from other providers without a file. ## - "browser-fileless-import": Directly import credentials from other providers without a file.
## - "extension-refresh": Temporarily enable the new extension design until general availability (should be used with the beta Chrome extension) ## - "extension-refresh": Temporarily enable the new extension design until general availability (should be used with the beta Chrome extension)
## - "fido2-vault-credentials": Enable the use of FIDO2 security keys as second factor. ## - "fido2-vault-credentials": Enable the use of FIDO2 security keys as second factor.
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Needs clients >=2024.12.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials # EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.

43
Cargo.lock generated
View File

@@ -552,6 +552,12 @@ dependencies = [
"stacker", "stacker",
] ]
[[package]]
name = "codemap"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -1231,6 +1237,19 @@ dependencies = [
"spinning_top", "spinning_top",
] ]
[[package]]
name = "grass_compiler"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6"
dependencies = [
"codemap",
"indexmap",
"lasso",
"once_cell",
"phf",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.26" version = "0.3.26"
@@ -1886,6 +1905,15 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "lasso"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb"
dependencies = [
"hashbrown 0.14.5",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -2484,6 +2512,7 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [ dependencies = [
"phf_macros",
"phf_shared", "phf_shared",
] ]
@@ -2507,6 +2536,19 @@ dependencies = [
"rand", "rand",
] ]
[[package]]
name = "phf_macros"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.11.2" version = "0.11.2"
@@ -4056,6 +4098,7 @@ dependencies = [
"fern", "fern",
"futures", "futures",
"governor", "governor",
"grass_compiler",
"handlebars", "handlebars",
"hickory-resolver", "hickory-resolver",
"html5gum", "html5gum",

View File

@@ -163,6 +163,9 @@ argon2 = "0.5.3"
# 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.3.1" rpassword = "7.3.1"
# Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The symbols are the provide better panic traces # The symbols are the provide better panic traces
# Also enable fat LTO and use 1 codegen unit for optimizations # Also enable fat LTO and use 1 codegen unit for optimizations

View File

@@ -1,3 +1,5 @@
use std::collections::HashSet;
use crate::db::DbPool; use crate::db::DbPool;
use chrono::Utc; use chrono::Utc;
use rocket::serde::json::Json; use rocket::serde::json::Json;
@@ -477,6 +479,60 @@ struct KeyData {
private_key: String, private_key: String,
} }
fn validate_keydata(
data: &KeyData,
existing_ciphers: &[Cipher],
existing_folders: &[Folder],
existing_emergency_access: &[EmergencyAccess],
existing_user_orgs: &[UserOrganization],
existing_sends: &[Send],
) -> EmptyResult {
// Check that we're correctly rotating all the user's ciphers
let existing_cipher_ids = existing_ciphers.iter().map(|c| c.uuid.as_str()).collect::<HashSet<_>>();
let provided_cipher_ids = data
.ciphers
.iter()
.filter(|c| c.organization_id.is_none())
.filter_map(|c| c.id.as_deref())
.collect::<HashSet<_>>();
if !provided_cipher_ids.is_superset(&existing_cipher_ids) {
err!("All existing ciphers must be included in the rotation")
}
// Check that we're correctly rotating all the user's folders
let existing_folder_ids = existing_folders.iter().map(|f| f.uuid.as_str()).collect::<HashSet<_>>();
let provided_folder_ids = data.folders.iter().filter_map(|f| f.id.as_deref()).collect::<HashSet<_>>();
if !provided_folder_ids.is_superset(&existing_folder_ids) {
err!("All existing folders must be included in the rotation")
}
// Check that we're correctly rotating all the user's emergency access keys
let existing_emergency_access_ids =
existing_emergency_access.iter().map(|ea| ea.uuid.as_str()).collect::<HashSet<_>>();
let provided_emergency_access_ids =
data.emergency_access_keys.iter().map(|ea| ea.id.as_str()).collect::<HashSet<_>>();
if !provided_emergency_access_ids.is_superset(&existing_emergency_access_ids) {
err!("All existing emergency access keys must be included in the rotation")
}
// Check that we're correctly rotating all the user's reset password keys
let existing_reset_password_ids = existing_user_orgs.iter().map(|uo| uo.org_uuid.as_str()).collect::<HashSet<_>>();
let provided_reset_password_ids =
data.reset_password_keys.iter().map(|rp| rp.organization_id.as_str()).collect::<HashSet<_>>();
if !provided_reset_password_ids.is_superset(&existing_reset_password_ids) {
err!("All existing reset password keys must be included in the rotation")
}
// Check that we're correctly rotating all the user's sends
let existing_send_ids = existing_sends.iter().map(|s| s.uuid.as_str()).collect::<HashSet<_>>();
let provided_send_ids = data.sends.iter().filter_map(|s| s.id.as_deref()).collect::<HashSet<_>>();
if !provided_send_ids.is_superset(&existing_send_ids) {
err!("All existing sends must be included in the rotation")
}
Ok(())
}
#[post("/accounts/key", data = "<data>")] #[post("/accounts/key", data = "<data>")]
async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
// TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything. // TODO: See if we can wrap everything within a SQL Transaction. If something fails it should revert everything.
@@ -494,20 +550,35 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
let user_uuid = &headers.user.uuid; let user_uuid = &headers.user.uuid;
// TODO: Ideally we'd do everything after this point in a single transaction.
let mut existing_ciphers = Cipher::find_owned_by_user(user_uuid, &mut conn).await;
let mut existing_folders = Folder::find_by_user(user_uuid, &mut conn).await;
let mut existing_emergency_access = EmergencyAccess::find_all_by_grantor_uuid(user_uuid, &mut conn).await;
let mut existing_user_orgs = UserOrganization::find_by_user(user_uuid, &mut conn).await;
// We only rotate the reset password key if it is set.
existing_user_orgs.retain(|uo| uo.reset_password_key.is_some());
let mut existing_sends = Send::find_by_user(user_uuid, &mut conn).await;
validate_keydata(
&data,
&existing_ciphers,
&existing_folders,
&existing_emergency_access,
&existing_user_orgs,
&existing_sends,
)?;
// Update folder data // Update folder data
for folder_data in data.folders { for folder_data in data.folders {
// Skip `null` folder id entries. // Skip `null` folder id entries.
// See: https://github.com/bitwarden/clients/issues/8453 // See: https://github.com/bitwarden/clients/issues/8453
if let Some(folder_id) = folder_data.id { if let Some(folder_id) = folder_data.id {
let mut saved_folder = match Folder::find_by_uuid(&folder_id, &mut conn).await { let saved_folder = match existing_folders.iter_mut().find(|f| f.uuid == folder_id) {
Some(folder) => folder, Some(folder) => folder,
None => err!("Folder doesn't exist"), None => err!("Folder doesn't exist"),
}; };
if &saved_folder.user_uuid != user_uuid {
err!("The folder is not owned by the user")
}
saved_folder.name = folder_data.name; saved_folder.name = folder_data.name;
saved_folder.save(&mut conn).await? saved_folder.save(&mut conn).await?
} }
@@ -515,9 +586,8 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Update emergency access data // Update emergency access data
for emergency_access_data in data.emergency_access_keys { for emergency_access_data in data.emergency_access_keys {
let mut saved_emergency_access = let saved_emergency_access =
match EmergencyAccess::find_by_uuid_and_grantor_uuid(&emergency_access_data.id, user_uuid, &mut conn).await match existing_emergency_access.iter_mut().find(|ea| ea.uuid == emergency_access_data.id) {
{
Some(emergency_access) => emergency_access, Some(emergency_access) => emergency_access,
None => err!("Emergency access doesn't exist or is not owned by the user"), None => err!("Emergency access doesn't exist or is not owned by the user"),
}; };
@@ -528,13 +598,11 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Update reset password data // Update reset password data
for reset_password_data in data.reset_password_keys { for reset_password_data in data.reset_password_keys {
let mut user_org = let user_org = match existing_user_orgs.iter_mut().find(|uo| uo.org_uuid == reset_password_data.organization_id)
match UserOrganization::find_by_user_and_org(user_uuid, &reset_password_data.organization_id, &mut conn) {
.await Some(reset_password) => reset_password,
{ None => err!("Reset password doesn't exist"),
Some(reset_password) => reset_password, };
None => err!("Reset password doesn't exist"),
};
user_org.reset_password_key = Some(reset_password_data.reset_password_key); user_org.reset_password_key = Some(reset_password_data.reset_password_key);
user_org.save(&mut conn).await? user_org.save(&mut conn).await?
@@ -542,12 +610,12 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
// Update send data // Update send data
for send_data in data.sends { for send_data in data.sends {
let mut send = match Send::find_by_uuid(send_data.id.as_ref().unwrap(), &mut conn).await { let send = match existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) {
Some(send) => send, Some(send) => send,
None => err!("Send doesn't exist"), None => err!("Send doesn't exist"),
}; };
update_send_from_data(&mut send, send_data, &headers, &mut conn, &nt, UpdateType::None).await?; update_send_from_data(send, send_data, &headers, &mut conn, &nt, UpdateType::None).await?;
} }
// Update cipher data // Update cipher data
@@ -555,20 +623,15 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, mut conn: DbConn,
for cipher_data in data.ciphers { for cipher_data in data.ciphers {
if cipher_data.organization_id.is_none() { if cipher_data.organization_id.is_none() {
let mut saved_cipher = match Cipher::find_by_uuid(cipher_data.id.as_ref().unwrap(), &mut conn).await { let saved_cipher = match existing_ciphers.iter_mut().find(|c| &c.uuid == cipher_data.id.as_ref().unwrap()) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
}; };
if saved_cipher.user_uuid.as_ref().unwrap() != user_uuid {
err!("The cipher is not owned by the user")
}
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None // Prevent triggering cipher updates via WebSockets by settings UpdateType::None
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
// We force the users to logout after the user has been saved to try and prevent these issues. // We force the users to logout after the user has been saved to try and prevent these issues.
update_cipher_from_data(&mut saved_cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None) update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await?
.await?
} }
} }
@@ -842,7 +905,7 @@ struct PasswordHintData {
#[post("/accounts/password-hint", data = "<data>")] #[post("/accounts/password-hint", data = "<data>")]
async fn password_hint(data: Json<PasswordHintData>, mut conn: DbConn) -> EmptyResult { async fn password_hint(data: Json<PasswordHintData>, mut conn: DbConn) -> EmptyResult {
if !CONFIG.mail_enabled() && !CONFIG.show_password_hint() { if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
err!("This server is not configured to provide password hints."); err!("This server is not configured to provide password hints.");
} }
@@ -1136,15 +1199,15 @@ async fn post_auth_request(
#[get("/auth-requests/<uuid>")] #[get("/auth-requests/<uuid>")]
async fn get_auth_request(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult { async fn get_auth_request(uuid: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
if headers.user.uuid != uuid {
err!("AuthRequest doesn't exist", "User uuid's do not match")
}
let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await { let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await {
Some(auth_request) => auth_request, Some(auth_request) => auth_request,
None => err!("AuthRequest doesn't exist", "Record not found"), None => err!("AuthRequest doesn't exist", "Record not found"),
}; };
if headers.user.uuid != auth_request.user_uuid {
err!("AuthRequest doesn't exist", "User uuid's do not match")
}
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date));
Ok(Json(json!({ Ok(Json(json!({
@@ -1190,18 +1253,27 @@ async fn put_auth_request(
err!("AuthRequest doesn't exist", "User uuid's do not match") err!("AuthRequest doesn't exist", "User uuid's do not match")
} }
auth_request.approved = Some(data.request_approved); if auth_request.approved.is_some() {
auth_request.enc_key = Some(data.key); err!("An authentication request with the same device already exists")
auth_request.master_password_hash = data.master_password_hash;
auth_request.response_device_id = Some(data.device_identifier.clone());
auth_request.save(&mut conn).await?;
if auth_request.approved.unwrap_or(false) {
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.device_identifier, &mut conn).await;
} }
let response_date_utc = auth_request.response_date.map(|response_date| format_date(&response_date)); let response_date = Utc::now().naive_utc();
let response_date_utc = format_date(&response_date);
if data.request_approved {
auth_request.approved = Some(data.request_approved);
auth_request.enc_key = Some(data.key);
auth_request.master_password_hash = data.master_password_hash;
auth_request.response_device_id = Some(data.device_identifier.clone());
auth_request.response_date = Some(response_date);
auth_request.save(&mut conn).await?;
ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await;
nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.device_identifier, &mut conn).await;
} else {
// If denied, there's no reason to keep the request
auth_request.delete(&mut conn).await?;
}
Ok(Json(json!({ Ok(Json(json!({
"id": uuid, "id": uuid,
@@ -1231,8 +1303,8 @@ async fn get_auth_request_response(
}; };
if auth_request.device_type != client_headers.device_type if auth_request.device_type != client_headers.device_type
&& auth_request.request_ip != client_headers.ip.ip.to_string() || auth_request.request_ip != client_headers.ip.ip.to_string()
&& !auth_request.check_access_code(code) || !auth_request.check_access_code(code)
{ {
err!("AuthRequest doesn't exist", "Invalid device, IP or code") err!("AuthRequest doesn't exist", "Invalid device, IP or code")
} }

View File

@@ -10,6 +10,7 @@ use rocket::{
}; };
use serde_json::Value; use serde_json::Value;
use crate::auth::ClientVersion;
use crate::util::NumberOrString; use crate::util::NumberOrString;
use crate::{ use crate::{
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
@@ -104,11 +105,27 @@ struct SyncData {
} }
#[get("/sync?<data..>")] #[get("/sync?<data..>")]
async fn sync(data: SyncData, headers: Headers, mut conn: DbConn) -> Json<Value> { async fn sync(
data: SyncData,
headers: Headers,
client_version: Option<ClientVersion>,
mut conn: DbConn,
) -> Json<Value> {
let user_json = headers.user.to_json(&mut conn).await; let user_json = headers.user.to_json(&mut conn).await;
// Get all ciphers which are visible by the user // Get all ciphers which are visible by the user
let ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await; let mut ciphers = Cipher::find_by_user_visible(&headers.user.uuid, &mut conn).await;
// Filter out SSH keys if the client version is less than 2024.12.0
let show_ssh_keys = if let Some(client_version) = client_version {
let ver_match = semver::VersionReq::parse(">=2024.12.0").unwrap();
ver_match.matches(&client_version.0)
} else {
false
};
if !show_ssh_keys {
ciphers.retain(|c| c.atype != 5);
}
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await; let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &mut conn).await;
@@ -205,7 +222,7 @@ pub struct CipherData {
// Id is optional as it is included only in bulk share // Id is optional as it is included only in bulk share
pub id: Option<String>, pub id: Option<String>,
// Folder id is not included in import // Folder id is not included in import
folder_id: Option<String>, pub folder_id: Option<String>,
// TODO: Some of these might appear all the time, no need for Option // TODO: Some of these might appear all the time, no need for Option
#[serde(alias = "organizationID")] #[serde(alias = "organizationID")]
pub organization_id: Option<String>, pub organization_id: Option<String>,
@@ -216,7 +233,8 @@ pub struct CipherData {
Login = 1, Login = 1,
SecureNote = 2, SecureNote = 2,
Card = 3, Card = 3,
Identity = 4 Identity = 4,
SshKey = 5
*/ */
pub r#type: i32, pub r#type: i32,
pub name: String, pub name: String,
@@ -228,6 +246,7 @@ pub struct CipherData {
secure_note: Option<Value>, secure_note: Option<Value>,
card: Option<Value>, card: Option<Value>,
identity: Option<Value>, identity: Option<Value>,
ssh_key: Option<Value>,
favorite: Option<bool>, favorite: Option<bool>,
reprompt: Option<i32>, reprompt: Option<i32>,
@@ -469,6 +488,7 @@ pub async fn update_cipher_from_data(
2 => data.secure_note, 2 => data.secure_note,
3 => data.card, 3 => data.card,
4 => data.identity, 4 => data.identity,
5 => data.ssh_key,
_ => err!("Invalid type"), _ => err!("Invalid type"),
}; };
@@ -565,11 +585,11 @@ async fn post_ciphers_import(
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.ciphers)?;
// Read and create the folders // Read and create the folders
let existing_folders: Vec<String> = let existing_folders: HashSet<Option<String>> =
Folder::find_by_user(&headers.user.uuid, &mut conn).await.into_iter().map(|f| f.uuid).collect(); Folder::find_by_user(&headers.user.uuid, &mut conn).await.into_iter().map(|f| Some(f.uuid)).collect();
let mut folders: Vec<String> = Vec::with_capacity(data.folders.len()); let mut folders: Vec<String> = Vec::with_capacity(data.folders.len());
for folder in data.folders.into_iter() { for folder in data.folders.into_iter() {
let folder_uuid = if folder.id.is_some() && existing_folders.contains(folder.id.as_ref().unwrap()) { let folder_uuid = if existing_folders.contains(&folder.id) {
folder.id.unwrap() folder.id.unwrap()
} else { } else {
let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name); let mut new_folder = Folder::new(headers.user.uuid.clone(), folder.name);
@@ -581,8 +601,8 @@ async fn post_ciphers_import(
} }
// Read the relations between folders and ciphers // Read the relations between folders and ciphers
// Ciphers can only be in one folder at the same time
let mut relations_map = HashMap::with_capacity(data.folder_relationships.len()); let mut relations_map = HashMap::with_capacity(data.folder_relationships.len());
for relation in data.folder_relationships { for relation in data.folder_relationships {
relations_map.insert(relation.key, relation.value); relations_map.insert(relation.key, relation.value);
} }

View File

@@ -136,8 +136,8 @@ async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbC
#[get("/hibp/breach?<username>")] #[get("/hibp/breach?<username>")]
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult { async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
if let Some(api_key) = crate::CONFIG.hibp_api_key() { if let Some(api_key) = crate::CONFIG.hibp_api_key() {
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
let url = format!( let url = format!(
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false" "https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
); );

View File

@@ -9,9 +9,8 @@ use crate::{
core::{log_event, two_factor, CipherSyncData, CipherSyncType}, core::{log_event, two_factor, CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
}, },
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, auth::{decode_invite, AdminHeaders, ClientVersion, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
db::{models::*, DbConn}, db::{models::*, DbConn},
error::Error,
mail, mail,
util::{convert_json_key_lcase_first, NumberOrString}, util::{convert_json_key_lcase_first, NumberOrString},
CONFIG, CONFIG,
@@ -127,6 +126,7 @@ struct NewCollectionData {
name: String, name: String,
groups: Vec<NewCollectionObjectData>, groups: Vec<NewCollectionObjectData>,
users: Vec<NewCollectionObjectData>, users: Vec<NewCollectionObjectData>,
id: Option<String>,
external_id: Option<String>, external_id: Option<String>,
} }
@@ -1598,40 +1598,43 @@ async fn post_org_import(
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.ciphers)?;
let mut collections = Vec::new(); let existing_collections: HashSet<Option<String>> =
Collection::find_by_organization(&org_id, &mut conn).await.into_iter().map(|c| (Some(c.uuid))).collect();
let mut collections: Vec<String> = Vec::with_capacity(data.collections.len());
for coll in data.collections { for coll in data.collections {
let collection = Collection::new(org_id.clone(), coll.name, coll.external_id); let collection_uuid = if existing_collections.contains(&coll.id) {
if collection.save(&mut conn).await.is_err() { coll.id.unwrap()
collections.push(Err(Error::new("Failed to create Collection", "Failed to create Collection")));
} else { } else {
collections.push(Ok(collection)); let new_collection = Collection::new(org_id.clone(), coll.name, coll.external_id);
} new_collection.save(&mut conn).await?;
new_collection.uuid
};
collections.push(collection_uuid);
} }
// Read the relations between collections and ciphers // Read the relations between collections and ciphers
let mut relations = Vec::new(); // Ciphers can be in multiple collections at the same time
let mut relations = Vec::with_capacity(data.collection_relationships.len());
for relation in data.collection_relationships { for relation in data.collection_relationships {
relations.push((relation.key, relation.value)); relations.push((relation.key, relation.value));
} }
let headers: Headers = headers.into(); let headers: Headers = headers.into();
let mut ciphers = Vec::new(); let mut ciphers: Vec<String> = Vec::with_capacity(data.ciphers.len());
for cipher_data in data.ciphers { for mut cipher_data in data.ciphers {
// Always clear folder_id's via an organization import
cipher_data.folder_id = None;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await.ok(); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &mut conn, &nt, UpdateType::None).await.ok();
ciphers.push(cipher); ciphers.push(cipher.uuid);
} }
// Assign the collections // Assign the collections
for (cipher_index, coll_index) in relations { for (cipher_index, coll_index) in relations {
let cipher_id = &ciphers[cipher_index].uuid; let cipher_id = &ciphers[cipher_index];
let coll = &collections[coll_index]; let coll_id = &collections[coll_index];
let coll_id = match coll {
Ok(coll) => coll.uuid.as_str(),
Err(_) => err!("Failed to assign to collection"),
};
CollectionCipher::save(cipher_id, coll_id, &mut conn).await?; CollectionCipher::save(cipher_id, coll_id, &mut conn).await?;
} }
@@ -2305,14 +2308,14 @@ async fn _restore_organization_user(
} }
#[get("/organizations/<org_id>/groups")] #[get("/organizations/<org_id>/groups")]
async fn get_groups(org_id: &str, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult { async fn get_groups(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult {
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
// Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::<Value>() // Group::find_by_organization(&org_id, &mut conn).await.iter().map(Group::to_json).collect::<Value>()
let groups = Group::find_by_organization(org_id, &mut conn).await; let groups = Group::find_by_organization(org_id, &mut conn).await;
let mut groups_json = Vec::with_capacity(groups.len()); let mut groups_json = Vec::with_capacity(groups.len());
for g in groups { for g in groups {
groups_json.push(g.to_json_details(&headers.org_user.atype, &mut conn).await) groups_json.push(g.to_json_details(&mut conn).await)
} }
groups_json groups_json
} else { } else {
@@ -2500,7 +2503,7 @@ async fn add_update_group(
} }
#[get("/organizations/<_org_id>/groups/<group_id>/details")] #[get("/organizations/<_org_id>/groups/<group_id>/details")]
async fn get_group_details(_org_id: &str, group_id: &str, headers: AdminHeaders, mut conn: DbConn) -> JsonResult { async fn get_group_details(_org_id: &str, group_id: &str, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
err!("Group support is disabled"); err!("Group support is disabled");
} }
@@ -2510,7 +2513,7 @@ async fn get_group_details(_org_id: &str, group_id: &str, headers: AdminHeaders,
_ => err!("Group could not be found!"), _ => err!("Group could not be found!"),
}; };
Ok(Json(group.to_json_details(&(headers.org_user_type as i32), &mut conn).await)) Ok(Json(group.to_json_details(&mut conn).await))
} }
#[post("/organizations/<org_id>/groups/<group_id>/delete")] #[post("/organizations/<org_id>/groups/<group_id>/delete")]
@@ -2999,18 +3002,20 @@ async fn put_reset_password_enrollment(
// We need to convert all keys so they have the first character to be a lowercase. // We need to convert all keys so they have the first character to be a lowercase.
// Else the export will be just an empty JSON file. // Else the export will be just an empty JSON file.
#[get("/organizations/<org_id>/export")] #[get("/organizations/<org_id>/export")]
async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) -> Json<Value> { async fn get_org_export(
use semver::{Version, VersionReq}; org_id: &str,
headers: AdminHeaders,
client_version: Option<ClientVersion>,
mut conn: DbConn,
) -> Json<Value> {
// Since version v2023.1.0 the format of the export is different. // Since version v2023.1.0 the format of the export is different.
// Also, this endpoint was created since v2022.9.0. // Also, this endpoint was created since v2022.9.0.
// Therefore, we will check for any version smaller then v2023.1.0 and return a different response. // Therefore, we will check for any version smaller then v2023.1.0 and return a different response.
// If we can't determine the version, we will use the latest default v2023.1.0 and higher. // If we can't determine the version, we will use the latest default v2023.1.0 and higher.
// https://github.com/bitwarden/server/blob/9ca93381ce416454734418c3a9f99ab49747f1b6/src/Api/Controllers/OrganizationExportController.cs#L44 // https://github.com/bitwarden/server/blob/9ca93381ce416454734418c3a9f99ab49747f1b6/src/Api/Controllers/OrganizationExportController.cs#L44
let use_list_response_model = if let Some(client_version) = headers.client_version { let use_list_response_model = if let Some(client_version) = client_version {
let ver_match = VersionReq::parse("<2023.1.0").unwrap(); let ver_match = semver::VersionReq::parse("<2023.1.0").unwrap();
let client_version = Version::parse(&client_version).unwrap(); ver_match.matches(&client_version.0)
ver_match.matches(&client_version)
} else { } else {
false false
}; };

View File

@@ -211,10 +211,7 @@ impl DuoClient {
nonce, nonce,
}; };
let token = match self.encode_duo_jwt(jwt_payload) { let token = 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 authz_endpoint = format!("https://{}/oauth/v1/authorize", self.api_host);
let mut auth_url = match Url::parse(authz_endpoint.as_str()) { let mut auth_url = match Url::parse(authz_endpoint.as_str()) {

View File

@@ -165,20 +165,22 @@ async fn _password_login(
// Set the user_uuid here to be passed back used for event logging. // Set the user_uuid here to be passed back used for event logging.
*user_uuid = Some(user.uuid.clone()); *user_uuid = Some(user.uuid.clone());
// Check password // Check if the user is disabled
let password = data.password.as_ref().unwrap(); if !user.enabled {
if let Some(auth_request_uuid) = data.auth_request.clone() { err!(
if let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_uuid.as_str(), conn).await { "This user has been disabled",
if !auth_request.check_access_code(password) { format!("IP: {}. Username: {}.", ip.ip, username),
err!( ErrorEvent {
"Username or access code is incorrect. Try again", event: EventType::UserFailedLogIn
format!("IP: {}. Username: {}.", ip.ip, username),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
} }
} else { )
}
let password = data.password.as_ref().unwrap();
// If we get an auth request, we don't check the user's password, but the access code of the auth request
if let Some(ref auth_request_uuid) = data.auth_request {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_uuid.as_str(), conn).await else {
err!( err!(
"Auth request not found. Try again.", "Auth request not found. Try again.",
format!("IP: {}. Username: {}.", ip.ip, username), format!("IP: {}. Username: {}.", ip.ip, username),
@@ -186,6 +188,24 @@ async fn _password_login(
event: EventType::UserFailedLogIn, event: EventType::UserFailedLogIn,
} }
) )
};
let expiration_time = auth_request.creation_date + chrono::Duration::minutes(5);
let request_expired = Utc::now().naive_utc() >= expiration_time;
if auth_request.user_uuid != user.uuid
|| !auth_request.approved.unwrap_or(false)
|| request_expired
|| ip.ip.to_string() != auth_request.request_ip
|| !auth_request.check_access_code(password)
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {}.", ip.ip, username),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
} }
} else if !user.check_valid_password(password) { } else if !user.check_valid_password(password) {
err!( err!(
@@ -197,8 +217,8 @@ async fn _password_login(
) )
} }
// Change the KDF Iterations // Change the KDF Iterations (only when not logging in with an auth request)
if user.password_iterations != CONFIG.password_iterations() { if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() {
user.password_iterations = CONFIG.password_iterations(); user.password_iterations = CONFIG.password_iterations();
user.set_password(password, None, false, None); user.set_password(password, None, false, None);
@@ -207,17 +227,6 @@ async fn _password_login(
} }
} }
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, username),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {

View File

@@ -1,13 +1,20 @@
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use rocket::{fs::NamedFile, http::ContentType, response::content::RawHtml as Html, serde::json::Json, Catcher, Route}; use rocket::{
fs::NamedFile,
http::ContentType,
response::{content::RawCss as Css, content::RawHtml as Html, Redirect},
serde::json::Json,
Catcher, Route,
};
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
api::{core::now, ApiResult, EmptyResult}, api::{core::now, ApiResult, EmptyResult},
auth::decode_file_download, auth::decode_file_download,
error::Error, error::Error,
util::{Cached, SafeString}, util::{get_web_vault_version, Cached, SafeString},
CONFIG, CONFIG,
}; };
@@ -16,7 +23,7 @@ pub fn routes() -> Vec<Route> {
// crate::utils::LOGGED_ROUTES to make sure they appear in the log // crate::utils::LOGGED_ROUTES to make sure they appear in the log
let mut routes = routes![attachments, alive, alive_head, static_files]; let mut routes = routes![attachments, alive, alive_head, static_files];
if CONFIG.web_vault_enabled() { if CONFIG.web_vault_enabled() {
routes.append(&mut routes![web_index, web_index_head, app_id, web_files]); routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -45,11 +52,101 @@ fn not_found() -> ApiResult<Html<String>> {
Ok(Html(text)) Ok(Html(text))
} }
#[get("/css/vaultwarden.css")]
fn vaultwarden_css() -> Cached<Css<String>> {
// Configure the web-vault version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added.
static WEB_VAULT_VERSION: Lazy<u32> = Lazy::new(|| {
let re = regex::Regex::new(r"(\d{4})\.(\d{1,2})\.(\d{1,2})").unwrap();
let vault_version = get_web_vault_version();
let (major, minor, patch) = match re.captures(&vault_version) {
Some(c) if c.len() == 4 => (
c.get(1).unwrap().as_str().parse().unwrap(),
c.get(2).unwrap().as_str().parse().unwrap(),
c.get(3).unwrap().as_str().parse().unwrap(),
),
_ => (2024, 6, 2),
};
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
});
// Configure the Vaultwarden version as an integer so it can be used as a comparison smaller or greater then.
// The default is based upon the version since this feature is added.
static VW_VERSION: Lazy<u32> = Lazy::new(|| {
let re = regex::Regex::new(r"(\d{1})\.(\d{1,2})\.(\d{1,2})").unwrap();
let vw_version = crate::VERSION.unwrap_or("1.32.1");
let (major, minor, patch) = match re.captures(vw_version) {
Some(c) if c.len() == 4 => (
c.get(1).unwrap().as_str().parse().unwrap(),
c.get(2).unwrap().as_str().parse().unwrap(),
c.get(3).unwrap().as_str().parse().unwrap(),
),
_ => (1, 32, 1),
};
format!("{major}{minor:02}{patch:02}").parse::<u32>().unwrap()
});
let css_options = json!({
"web_vault_version": *WEB_VAULT_VERSION,
"vw_version": *VW_VERSION,
"signup_disabled": !CONFIG.signups_allowed() && CONFIG.signups_domains_whitelist().is_empty(),
"mail_enabled": CONFIG.mail_enabled(),
"yubico_enabled": CONFIG._enable_yubico() && (CONFIG.yubico_client_id().is_some() == CONFIG.yubico_secret_key().is_some()),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"sends_allowed": CONFIG.sends_allowed(),
"load_user_scss": true,
});
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
Ok(t) => t,
Err(e) => {
// Something went wrong loading the template. Use the fallback
warn!("Loading scss/vaultwarden.scss.hbs or scss/user.vaultwarden.scss.hbs failed. {e}");
CONFIG
.render_fallback_template("scss/vaultwarden.scss", &css_options)
.expect("Fallback scss/vaultwarden.scss.hbs to render")
}
};
let css = match grass_compiler::from_string(
scss,
&grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed),
) {
Ok(css) => css,
Err(e) => {
// Something went wrong compiling the scss. Use the fallback
warn!("Compiling the Vaultwarden SCSS styles failed. {e}");
let mut css_options = css_options;
css_options["load_user_scss"] = json!(false);
let scss = CONFIG
.render_fallback_template("scss/vaultwarden.scss", &css_options)
.expect("Fallback scss/vaultwarden.scss.hbs to render");
grass_compiler::from_string(
scss,
&grass_compiler::Options::default().style(grass_compiler::OutputStyle::Compressed),
)
.expect("SCSS to compile")
}
};
// Cache for one day should be enough and not too much
Cached::ttl(Css(css), 86_400, false)
}
#[get("/")] #[get("/")]
async fn web_index() -> Cached<Option<NamedFile>> { async fn web_index() -> Cached<Option<NamedFile>> {
Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false) Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false)
} }
// Make sure that `/index.html` redirect to actual domain path.
// If not, this might cause issues with the web-vault
#[get("/index.html")]
fn web_index_direct() -> Redirect {
Redirect::to(format!("{}/", CONFIG.domain_path()))
}
#[head("/")] #[head("/")]
fn web_index_head() -> EmptyResult { fn web_index_head() -> EmptyResult {
// Add an explicit HEAD route to prevent uptime monitoring services from // Add an explicit HEAD route to prevent uptime monitoring services from

View File

@@ -615,7 +615,6 @@ pub struct AdminHeaders {
pub device: Device, pub device: Device,
pub user: User, pub user: User,
pub org_user_type: UserOrgType, pub org_user_type: UserOrgType,
pub client_version: Option<String>,
pub ip: ClientIp, pub ip: ClientIp,
} }
@@ -625,14 +624,12 @@ impl<'r> FromRequest<'r> for AdminHeaders {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(OrgHeaders::from_request(request).await);
let client_version = request.headers().get_one("Bitwarden-Client-Version").map(String::from);
if headers.org_user_type >= UserOrgType::Admin { if headers.org_user_type >= UserOrgType::Admin {
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
user: headers.user, user: headers.user,
org_user_type: headers.org_user_type, org_user_type: headers.org_user_type,
client_version,
ip: headers.ip, ip: headers.ip,
}) })
} else { } else {
@@ -900,3 +897,24 @@ impl<'r> FromRequest<'r> for WsAccessTokenHeader {
}) })
} }
} }
pub struct ClientVersion(pub semver::Version);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientVersion {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = request.headers();
let Some(version) = headers.get_one("Bitwarden-Client-Version") else {
err_handler!("No Bitwarden-Client-Version header provided")
};
let Ok(version) = semver::Version::parse(version) else {
err_handler!("Invalid Bitwarden-Client-Version header provided")
};
Outcome::Success(ClientVersion(version))
}
}

View File

@@ -497,11 +497,11 @@ make_config! {
/// Password iterations |> Number of server-side passwords hashing iterations for the password hash. /// Password iterations |> Number of server-side passwords hashing iterations for the password hash.
/// The default for new users. If changed, it will be updated during login for existing users. /// The default for new users. If changed, it will be updated during login for existing users.
password_iterations: i32, true, def, 600_000; password_iterations: i32, true, def, 600_000;
/// Allow password hints |> Controls whether users can set password hints. This setting applies globally to all users. /// Allow password hints |> Controls whether users can set or show password hints. This setting applies globally to all users.
password_hints_allowed: bool, true, def, true; password_hints_allowed: bool, true, def, true;
/// Show password hint |> Controls whether a password hint should be shown directly in the web page /// Show password hint (Know the risks!) |> Controls whether a password hint should be shown directly in the web page
/// if SMTP service is not configured. Not recommended for publicly-accessible instances as this /// if SMTP service is not configured and password hints are allowed. Not recommended for publicly-accessible instances
/// provides unauthenticated access to potentially sensitive data. /// because this provides unauthenticated access to potentially sensitive data.
show_password_hint: bool, true, def, false; show_password_hint: bool, true, def, false;
/// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session!
@@ -811,8 +811,15 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
} }
// TODO: deal with deprecated flags so they can be removed from this list, cf. #4263 // TODO: deal with deprecated flags so they can be removed from this list, cf. #4263
const KNOWN_FLAGS: &[&str] = const KNOWN_FLAGS: &[&str] = &[
&["autofill-overlay", "autofill-v2", "browser-fileless-import", "extension-refresh", "fido2-vault-credentials"]; "autofill-overlay",
"autofill-v2",
"browser-fileless-import",
"extension-refresh",
"fido2-vault-credentials",
"ssh-key-vault-item",
"ssh-agent",
];
let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags); let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags);
let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect(); let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect();
if !invalid_flags.is_empty() { if !invalid_flags.is_empty() {
@@ -1269,11 +1276,16 @@ impl Config {
let hb = load_templates(CONFIG.templates_folder()); let hb = load_templates(CONFIG.templates_folder());
hb.render(name, data).map_err(Into::into) hb.render(name, data).map_err(Into::into)
} else { } else {
let hb = &CONFIG.inner.read().unwrap().templates; let hb = &self.inner.read().unwrap().templates;
hb.render(name, data).map_err(Into::into) hb.render(name, data).map_err(Into::into)
} }
} }
pub fn render_fallback_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
let hb = &self.inner.read().unwrap().templates;
hb.render(&format!("fallback_{name}"), data).map_err(Into::into)
}
pub fn set_rocket_shutdown_handle(&self, handle: rocket::Shutdown) { pub fn set_rocket_shutdown_handle(&self, handle: rocket::Shutdown) {
self.inner.write().unwrap().rocket_shutdown_handle = Some(handle); self.inner.write().unwrap().rocket_shutdown_handle = Some(handle);
} }
@@ -1312,6 +1324,11 @@ where
reg!($name); reg!($name);
reg!(concat!($name, $ext)); reg!(concat!($name, $ext));
}}; }};
(@withfallback $name:expr) => {{
let template = include_str!(concat!("static/templates/", $name, ".hbs"));
hb.register_template_string($name, template).unwrap();
hb.register_template_string(concat!("fallback_", $name), template).unwrap();
}};
} }
// First register default templates here // First register default templates here
@@ -1355,6 +1372,9 @@ where
reg!("404"); reg!("404");
reg!(@withfallback "scss/vaultwarden.scss");
reg!("scss/user.vaultwarden.scss");
// And then load user templates to overwrite the defaults // And then load user templates to overwrite the defaults
// Use .hbs extension for the files // Use .hbs extension for the files
// Templates get registered with their relative name // Templates get registered with their relative name

View File

@@ -30,7 +30,8 @@ db_object! {
Login = 1, Login = 1,
SecureNote = 2, SecureNote = 2,
Card = 3, Card = 3,
Identity = 4 Identity = 4,
SshKey = 5
*/ */
pub atype: i32, pub atype: i32,
pub name: String, pub name: String,
@@ -319,6 +320,7 @@ impl Cipher {
"secureNote": null, "secureNote": null,
"card": null, "card": null,
"identity": null, "identity": null,
"sshKey": null,
}); });
// These values are only needed for user/default syncs // These values are only needed for user/default syncs
@@ -347,6 +349,7 @@ impl Cipher {
2 => "secureNote", 2 => "secureNote",
3 => "card", 3 => "card",
4 => "identity", 4 => "identity",
5 => "sshKey",
_ => panic!("Wrong type"), _ => panic!("Wrong type"),
}; };

View File

@@ -1,4 +1,4 @@
use super::{User, UserOrgType, UserOrganization}; use super::{User, UserOrganization};
use crate::api::EmptyResult; use crate::api::EmptyResult;
use crate::db::DbConn; use crate::db::DbConn;
use crate::error::MapResult; use crate::error::MapResult;
@@ -73,7 +73,7 @@ impl Group {
}) })
} }
pub async fn to_json_details(&self, user_org_type: &i32, conn: &mut DbConn) -> Value { pub async fn to_json_details(&self, conn: &mut DbConn) -> Value {
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn) let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn)
.await .await
.iter() .iter()
@@ -82,7 +82,7 @@ impl Group {
"id": entry.collections_uuid, "id": entry.collections_uuid,
"readOnly": entry.read_only, "readOnly": entry.read_only,
"hidePasswords": entry.hide_passwords, "hidePasswords": entry.hide_passwords,
"manage": *user_org_type >= UserOrgType::Admin || (*user_org_type == UserOrgType::Manager && !entry.read_only && !entry.hide_passwords) "manage": false
}) })
}) })
.collect(); .collect();

View File

@@ -0,0 +1 @@
/* See the wiki for examples and details: https://github.com/dani-garcia/vaultwarden/wiki/Customize-Vaultwarden-CSS */

View File

@@ -0,0 +1,105 @@
/**** START Static Vaultwarden changes ****/
/* This combines all selectors extending it into one */
%vw-hide {
display: none !important;
}
/* This allows searching for the combined style in the browsers dev-tools (look into the head tag) */
.vw-hide,
head {
@extend %vw-hide;
}
/* Hide the Subscription Page tab */
bit-nav-item[route="settings/subscription"] {
@extend %vw-hide;
}
/* Hide any link pointing to Free Bitwarden Families */
a[href$="/settings/sponsored-families"] {
@extend %vw-hide;
}
/* Hide the `Enterprise Single Sign-On` button on the login page */
a[routerlink="/sso"] {
@extend %vw-hide;
}
/* Hide Two-Factor menu in Organization settings */
bit-nav-item[route="settings/two-factor"],
a[href$="/settings/two-factor"] {
@extend %vw-hide;
}
/* Hide Business Owned checkbox */
app-org-info > form:nth-child(1) > div:nth-child(3) {
@extend %vw-hide;
}
/* Hide the `This account is owned by a business` checkbox and label */
#ownedBusiness,
label[for^="ownedBusiness"] {
@extend %vw-hide;
}
/* Hide the radio button and label for the `Custom` org user type */
#userTypeCustom,
label[for^="userTypeCustom"] {
@extend %vw-hide;
}
/* Hide Business Name */
app-org-account form div bit-form-field.tw-block:nth-child(3) {
@extend %vw-hide;
}
/* Hide organization plans */
app-organization-plans > form > bit-section:nth-child(2) {
@extend %vw-hide;
}
/* Hide Device Verification form at the Two Step Login screen */
app-security > app-two-factor-setup > form {
@extend %vw-hide;
}
/**** END Static Vaultwarden Changes ****/
/**** START Dynamic Vaultwarden Changes ****/
{{#if signup_disabled}}
/* Hide the register link on the login screen */
app-frontend-layout > app-login > form > div > div > div > p {
@extend %vw-hide;
}
{{/if}}
/* Hide `Email` 2FA if mail is not enabled */
{{#unless mail_enabled}}
app-two-factor-setup ul.list-group.list-group-2fa li.list-group-item:nth-child(5) {
@extend %vw-hide;
}
{{/unless}}
/* Hide `YubiKey OTP security key` 2FA if it is not enabled */
{{#unless yubico_enabled}}
app-two-factor-setup ul.list-group.list-group-2fa li.list-group-item:nth-child(2) {
@extend %vw-hide;
}
{{/unless}}
/* Hide Emergency Access if not allowed */
{{#unless emergency_access_allowed}}
bit-nav-item[route="settings/emergency-access"] {
@extend %vw-hide;
}
{{/unless}}
/* Hide Sends if not allowed */
{{#unless sends_allowed}}
bit-nav-item[route="sends"] {
@extend %vw-hide;
}
{{/unless}}
/**** End Dynamic Vaultwarden Changes ****/
/**** Include a special user stylesheet for custom changes ****/
{{#if load_user_scss}}
{{> scss/user.vaultwarden.scss }}
{{/if}}