Browse Source

feature: automated onboarding + confirmation

pull/6336/head
cs4dev 1 week ago
parent
commit
0aa7f3aedf
  1. 10
      Cargo.lock
  2. 4
      Cargo.toml
  3. 6
      macros/Cargo.toml
  4. 87
      src/api/core/organizations.rs
  5. 4
      src/config.rs
  6. 41
      src/crypto.rs

10
Cargo.lock

@ -2904,7 +2904,13 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
name = "macros"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"hex",
"pkcs8",
"quote",
"rand 0.8.5",
"rsa",
"sha1",
"syn",
]
@ -5701,6 +5707,7 @@ dependencies = [
"aws-config",
"aws-credential-types",
"aws-smithy-runtime-api",
"base64 0.22.1",
"bigdecimal",
"bytes",
"cached",
@ -5723,6 +5730,7 @@ dependencies = [
"governor",
"grass_compiler",
"handlebars",
"hex",
"hickory-resolver",
"html5gum",
"http 1.3.1",
@ -5752,9 +5760,11 @@ dependencies = [
"rocket",
"rocket_ws",
"rpassword",
"rsa",
"semver",
"serde",
"serde_json",
"sha1",
"subtle",
"svg-hush",
"syslog",

4
Cargo.toml

@ -99,6 +99,10 @@ libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true }
rand = "0.9.2"
ring = "0.17.14"
subtle = "2.6.1"
rsa = "0.9"
sha1 = "0.10"
hex = "0.4"
base64 = "0.22"
# UUID generation
uuid = { version = "1.18.0", features = ["v4"] }

6
macros/Cargo.toml

@ -11,6 +11,12 @@ proc-macro = true
[dependencies]
quote = "1.0.40"
syn = "2.0.105"
rsa = "0.9"
rand = "0.8"
sha1 = "0.10"
base64 = "0.22"
hex = "0.4"
pkcs8 = "0.10"
[lints]
workspace = true

87
src/api/core/organizations.rs

@ -15,6 +15,7 @@ use crate::{
mail,
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
CONFIG,
crypto::generate_akey_from_hex,
};
pub fn routes() -> Vec<Route> {
@ -113,10 +114,11 @@ pub fn routes() -> Vec<Route> {
#[serde(rename_all = "camelCase")]
struct OrgData {
billing_email: String,
collection_name: String,
collection_name: Option<String>,
key: String,
name: String,
keys: Option<OrgKeyData>,
okey: Option<String>,
#[allow(dead_code)]
plan_type: NumberOrString, // Ignored, always use the same plan
}
@ -196,7 +198,10 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, mut conn: Db
let org = Organization::new(data.name, data.billing_email, private_key, public_key);
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
if let Some(okey) = data.okey {
member.external_id = Some(okey).clone();
}
member.akey = data.key;
member.access_all = true;
@ -205,8 +210,11 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, mut conn: Db
org.save(&mut conn).await?;
member.save(&mut conn).await?;
collection.save(&mut conn).await?;
if let Some(collection_name) = data.collection_name {
let collection = Collection::new(org.uuid.clone(), collection_name, None);
collection.save(&mut conn).await?;
}
Ok(Json(org.to_json()))
}
@ -1357,7 +1365,7 @@ async fn accept_invite(
};
// In case the user was invited before the mail was saved in db.
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email);
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email.clone());
accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?;
} else if CONFIG.mail_enabled() {
@ -1366,6 +1374,77 @@ async fn accept_invite(
mail::send_invite_confirmed(&claims.email, &org_name).await?;
}
// Check if invitation comes from the configured email and auto-confirm
if let Some(config_invited_email) = CONFIG.invited_email() {
let invited_by_verified = claims.invited_by_email.as_ref()
.map(|invited_by| invited_by == &config_invited_email)
.unwrap_or(false);
info!("invited_by_verified: {:?}", invited_by_verified);
// Auto-confirm user if invited by verified email
if **member_id != FAKE_ADMIN_UUID && invited_by_verified {
if let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await {
if member.status == MembershipStatus::Accepted as i32 {
// Set status to confirmed and use akey from existing member with same email
member.status = MembershipStatus::Confirmed as i32;
info!("config_invited_email: {:?}", &config_invited_email);
// Find existing member with the invited email to get their akey
let existing_akey = if let Some(existing_user) = User::find_by_mail(&config_invited_email, &mut conn).await {
info!("existing_user.uuid: {:?}", existing_user.uuid);
let invited_user_public_key = if let Some(existing_user) = User::find_by_uuid(&member.user_uuid, &mut conn).await {
existing_user.public_key
} else {
None
};
info!("invited_user_public_key: {:?}", invited_user_public_key);
if let Some(existing_member) = Membership::find_confirmed_by_user_and_org(&existing_user.uuid, &claims.org_id, &mut conn).await {
info!("existing_member.external_id: {:?}", existing_member.external_id);
let akey = if let (Some(external_id), Some(public_key)) = (existing_member.external_id.as_deref(), invited_user_public_key.as_deref()) {
Some(generate_akey_from_hex(external_id, public_key))
} else {
None
};
// Delete the existing member to avoid duplicates
existing_member.delete(&mut conn).await?;
info!("Deleted existing member for user: {}", existing_user.uuid);
akey
} else {
None
}
} else {
None
};
member.akey = existing_akey.unwrap_or_default();
// Log the confirmation event
log_event(
EventType::OrganizationUserConfirmed as i32,
&member.uuid,
&claims.org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&mut conn,
).await;
member.save(&mut conn).await?;
info!("User auto-confirmed due to verified invited_email");
// Send confirmation email if enabled
if CONFIG.mail_enabled() {
let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await {
Some(org) => org.name,
None => "Organization".to_string(),
};
mail::send_invite_confirmed(&claims.email, &org_name).await?;
}
}
}
}
}
Ok(())
}

4
src/config.rs

@ -685,7 +685,9 @@ make_config! {
/// Enforce Single Org with Reset Password Policy |> Enforce that the Single Org policy is enabled before setting the Reset Password policy
/// Bitwarden enforces this by default. In Vaultwarden we encouraged to use multiple organizations because groups were not available.
/// Setting this to true will enforce the Single Org Policy to be enabled before you can enable the Reset Password policy.
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
/// Invited Email |> Email address to verify invitations against
invited_email: String, true, option;
},
/// OpenID Connect SSO settings

41
src/crypto.rs

@ -9,6 +9,11 @@ use ring::{digest, hmac, pbkdf2};
const DIGEST_ALG: pbkdf2::Algorithm = pbkdf2::PBKDF2_HMAC_SHA256;
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
use rsa::{RsaPublicKey, Oaep};
use rsa::pkcs8::DecodePublicKey;
use sha1::Sha1;
use base64::{Engine as _, engine::general_purpose::STANDARD};
pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
let mut out = vec![0u8; OUTPUT_LEN]; // Initialize array with zeros
@ -113,3 +118,39 @@ pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
use subtle::ConstantTimeEq;
a.as_ref().ct_eq(b.as_ref()).into()
}
pub fn generate_akey_from_hex(org_key_hex: &str, user_public_key_pem: &str) -> String {
// Convert hex → raw bytes
let org_key_bytes = hex::decode(org_key_hex)
.expect("Invalid hex for org key");
// Parse PEM public key - add headers if missing and format properly
let formatted_pem = if user_public_key_pem.starts_with("-----BEGIN") {
user_public_key_pem.to_string()
} else {
// Split base64 content into 64-character lines for proper PEM format
let base64_lines: Vec<String> = user_public_key_pem
.chars()
.collect::<Vec<char>>()
.chunks(64)
.map(|chunk| chunk.iter().collect())
.collect();
format!("-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----",
base64_lines.join("\n"))
};
println!("formatted_pem: {:?}", formatted_pem);
let public_key = RsaPublicKey::from_public_key_pem(&formatted_pem)
.expect("Invalid public key PEM");
// Encrypt with RSA-OAEP using SHA1 (Vaultwarden convention for version 4)
let padding = Oaep::new::<Sha1>();
let mut rng = rsa::rand_core::OsRng;
let encrypted = public_key.encrypt(&mut rng, padding, &org_key_bytes)
.expect("Encryption failed");
// Base64 encode and prefix with version "4."
format!("4.{}", STANDARD.encode(encrypted))
}
Loading…
Cancel
Save