diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 491f6c6a..2ce150ed 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -7,7 +7,7 @@ use serde_json::Value; use crate::{ api::{ - core::{log_user_event, two_factor::email}, + core::{accept_org_invite, log_user_event, two_factor::email}, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, @@ -96,7 +96,6 @@ pub struct SetPasswordData { keys: Option, master_password_hash: String, master_password_hint: Option, - #[allow(dead_code)] org_identifier: Option, } @@ -297,8 +296,24 @@ async fn post_set_password(data: Json, headers: Headers, mut co user.public_key = Some(keys.public_key); } + if let Some(identifier) = data.org_identifier { + if identifier != crate::sso::FAKE_IDENTIFIER { + let org = match Organization::find_by_name(&identifier, &mut conn).await { + None => err!("Failed to retrieve the associated organization"), + Some(org) => org, + }; + + let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &mut conn).await { + None => err!("Failed to retrieve the invitation"), + Some(org) => org, + }; + + accept_org_invite(&user, membership, None, &mut conn).await?; + } + } + if CONFIG.mail_enabled() { - mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; + mail::send_welcome(&user.email.to_lowercase()).await?; } else { Membership::accept_user_invitations(&user.uuid, &mut conn).await?; } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index b4238725..896297fa 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -50,11 +50,12 @@ pub fn events_routes() -> Vec { use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use crate::{ - api::{JsonResult, Notify, UpdateType}, + api::{EmptyResult, JsonResult, Notify, UpdateType}, auth::Headers, - db::DbConn, + db::{models::*, DbConn}, error::Error, http_client::make_http_request, + mail, util::parse_experimental_client_feature_flags, }; @@ -246,3 +247,49 @@ fn api_not_found() -> Json { } })) } + +async fn accept_org_invite( + user: &User, + mut member: Membership, + reset_password_key: Option, + conn: &mut DbConn, +) -> EmptyResult { + if member.status != MembershipStatus::Invited as i32 { + err!("User already accepted the invitation"); + } + + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type + // It returns different error messages per function. + if member.atype < MembershipType::Admin { + match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await { + Ok(_) => {} + Err(OrgPolicyErr::TwoFactorMissing) => { + if crate::CONFIG.email_2fa_auto_fallback() { + two_factor::email::activate_email_2fa(user, conn).await?; + } else { + err!("You cannot join this organization until you enable two-step login on your user account"); + } + } + Err(OrgPolicyErr::SingleOrgEnforced) => { + err!("You cannot join this organization because you are a member of an organization which forbids it"); + } + } + } + + member.status = MembershipStatus::Accepted as i32; + member.reset_password_key = reset_password_key; + + member.save(conn).await?; + + if crate::CONFIG.mail_enabled() { + let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { + Some(org) => org, + None => err!("Organization not found."), + }; + // User was invited to an organization, so they must be confirmed manually after acceptance + mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name) + .await?; + } + + Ok(()) +} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index d901e01f..5f88a14e 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet}; use crate::api::admin::FAKE_ADMIN_UUID; use crate::{ api::{ - core::{log_event, two_factor, CipherSyncData, CipherSyncType}, + core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType}, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{ @@ -342,13 +342,30 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json } // Called during the SSO enrollment -// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details` +// The `identifier` should be the value returned by `get_org_domain_sso_details` // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it -#[get("/organizations/<_identifier>/auto-enroll-status")] -fn get_auto_enroll_status(_identifier: &str) -> JsonResult { +#[get("/organizations//auto-enroll-status")] +async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let org = if identifier == crate::sso::FAKE_IDENTIFIER { + match Membership::find_main_user_org(&headers.user.uuid, &mut conn).await { + Some(member) => Organization::find_by_uuid(&member.org_uuid, &mut conn).await, + None => None, + } + } else { + Organization::find_by_name(identifier, &mut conn).await + }; + + let (id, identifier, rp_auto_enroll) = match org { + None => (get_uuid(), identifier.to_string(), false), + Some(org) => { + (org.uuid.to_string(), org.name, OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &mut conn).await) + } + }; + Ok(Json(json!({ - "Id": get_uuid(), - "ResetPasswordEnabled": false, // Not implemented + "Id": id, + "Identifier": identifier, + "ResetPasswordEnabled": rp_auto_enroll, }))) } @@ -932,13 +949,26 @@ async fn _get_org_details(org_id: &OrganizationId, host: &str, user_id: &UserId, json!(ciphers_json) } -// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrgDomainDetails { + email: String, +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// VaultWarden sso login is not linked to Org so we set a dummy value. -#[post("/organizations/domain/sso/details")] -fn get_org_domain_sso_details() -> JsonResult { +// So we either return an Org name associated to the user or a dummy value. +// The `verifiedDate` is required but the value ATM is ignored. +#[post("/organizations/domain/sso/details", data = "")] +async fn get_org_domain_sso_details(data: Json, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner(); + + let identifier = match Organization::find_main_org_user_email(&data.email, &mut conn).await { + Some(org) => org.name, + None => crate::sso::FAKE_IDENTIFIER.to_string(), + }; + Ok(Json(json!({ - "organizationIdentifier": "vaultwarden", + "organizationIdentifier": identifier, "ssoAvailable": CONFIG.sso_enabled(), "verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()), }))) @@ -1283,71 +1313,36 @@ async fn accept_invite( err!("Invitation was issued to a different account", "Claim does not match user_id") } + // If a claim org_id does not match the one in from the URI, something is wrong. + if !claims.org_id.eq(&org_id) { + err!("Error accepting the invitation", "Claim does not match the org_id") + } + // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. if !claims.member_id.eq(&member_id) { err!("Error accepting the invitation", "Claim does not match the member_id") } - let member = &claims.member_id; - let org = &claims.org_id; - + let member_id = &claims.member_id; Invitation::take(&claims.email, &mut conn).await; // skip invitation logic when we were invited via the /admin panel - if **member != FAKE_ADMIN_UUID { - let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { + if **member_id != FAKE_ADMIN_UUID { + let Some(member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else { err!("Error accepting the invitation") }; - if member.status != MembershipStatus::Invited as i32 { - err!("User already accepted the invitation") - } + let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await { + true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), + true => data.reset_password_key, + false => None, + }; - let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await; - if data.reset_password_key.is_none() && master_password_required { - err!("Reset password key is required, but not provided."); - } - - // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type - // It returns different error messages per function. - if member.atype < MembershipType::Admin { - match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await { - Ok(_) => {} - Err(OrgPolicyErr::TwoFactorMissing) => { - if CONFIG.email_2fa_auto_fallback() { - two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?; - } else { - err!("You cannot join this organization until you enable two-step login on your user account"); - } - } - Err(OrgPolicyErr::SingleOrgEnforced) => { - err!("You cannot join this organization because you are a member of an organization which forbids it"); - } - } - } - - member.status = MembershipStatus::Accepted as i32; - - if master_password_required { - member.reset_password_key = data.reset_password_key; - } - - member.save(&mut conn).await?; - } - - if CONFIG.mail_enabled() { - if let Some(invited_by_email) = &claims.invited_by_email { - let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await { - Some(org) => org.name, - None => err!("Organization not found."), - }; - // User was invited to an organization, so they must be confirmed manually after acceptance - mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?; - } else { - // User was invited from /admin, so they are automatically confirmed - let org_name = CONFIG.invitation_org_name(); - mail::send_invite_confirmed(&claims.email, &org_name).await?; - } + accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?; + } else if CONFIG.mail_enabled() { + // User was invited from /admin, so they are automatically confirmed + let org_name = CONFIG.invitation_org_name(); + mail::send_invite_confirmed(&claims.email, &org_name).await?; } Ok(()) @@ -2012,16 +2007,18 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo } // Called during the SSO enrollment. -// Cannot use the OrganizationId guard since the Org does not exists. +// Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] -fn get_master_password_policy(org_id: OrganizationId, _headers: Headers) -> JsonResult { - let data = match CONFIG.sso_master_password_policy() { - Some(policy) => policy, - None => "null".to_string(), - }; - +async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult { let policy = - OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data); + OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| { + let data = match CONFIG.sso_master_password_policy() { + Some(policy) => policy, + None => "null".to_string(), + }; + + OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data) + }); Ok(Json(policy.to_json())) } diff --git a/src/config.rs b/src/config.rs index cc771616..9ce45d20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1509,7 +1509,6 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); - reg!("email/set_password", ".html"); reg!("email/smtp_test", ".html"); reg!("email/sso_change_email", ".html"); reg!("email/twofactor_email", ".html"); diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 783afe41..a7b099ec 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -391,11 +391,36 @@ impl Organization { }} } + pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } + + pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } } impl Membership { @@ -1099,6 +1124,17 @@ impl Membership { .first::(conn).ok().from_db() }} } + + pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/mail.rs b/src/mail.rs index 03613cfa..a707ec54 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -565,18 +565,6 @@ pub async fn send_sso_change_email(address: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult { - let (subject, body_html, body_text) = get_text( - "email/set_password", - json!({ - "url": CONFIG.domain(), - "img_src": CONFIG._smtp_img_src(), - "user_name": user_name, - }), - )?; - send_email(address, &subject, body_html, body_text).await -} - pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", diff --git a/src/sso.rs b/src/sso.rs index ff30a13c..94747d72 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -28,6 +28,8 @@ use crate::{ CONFIG, }; +pub static FAKE_IDENTIFIER: &str = "Vaultwarden"; + static AC_CACHE: Lazy> = Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); diff --git a/src/static/templates/email/set_password.hbs b/src/static/templates/email/set_password.hbs deleted file mode 100644 index 923c80f2..00000000 --- a/src/static/templates/email/set_password.hbs +++ /dev/null @@ -1,6 +0,0 @@ -Master Password Has Been Changed - -The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. - -=== -{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/set_password.html.hbs b/src/static/templates/email/set_password.html.hbs deleted file mode 100644 index ede5da0c..00000000 --- a/src/static/templates/email/set_password.html.hbs +++ /dev/null @@ -1,11 +0,0 @@ -Master Password Has Been Changed - -{{> email/email_header }} - - - - -
- The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. -
-{{> email/email_footer }} \ No newline at end of file