1
0
Fork 1
Spiegel von https://github.com/dani-garcia/vaultwarden.git synchronisiert 2025-03-13 16:57:01 +01:00

Process org enrollment in accounts::post_set_password

Dieser Commit ist enthalten in:
Timshel 2025-02-11 19:45:40 +01:00
Ursprung 8d2d9f8d1a
Commit 7649ce8a3c
9 geänderte Dateien mit 174 neuen und 107 gelöschten Zeilen

Datei anzeigen

@ -7,7 +7,7 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ 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, master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
JsonResult, Notify, PasswordOrOtpData, UpdateType, JsonResult, Notify, PasswordOrOtpData, UpdateType,
}, },
@ -96,7 +96,6 @@ pub struct SetPasswordData {
keys: Option<KeysData>, keys: Option<KeysData>,
master_password_hash: String, master_password_hash: String,
master_password_hint: Option<String>, master_password_hint: Option<String>,
#[allow(dead_code)]
org_identifier: Option<String>, org_identifier: Option<String>,
} }
@ -297,8 +296,24 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, mut co
user.public_key = Some(keys.public_key); 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() { if CONFIG.mail_enabled() {
mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; mail::send_welcome(&user.email.to_lowercase()).await?;
} else { } else {
Membership::accept_user_invitations(&user.uuid, &mut conn).await?; Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
} }

Datei anzeigen

@ -50,11 +50,12 @@ pub fn events_routes() -> Vec<Route> {
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route}; use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use crate::{ use crate::{
api::{JsonResult, Notify, UpdateType}, api::{EmptyResult, JsonResult, Notify, UpdateType},
auth::Headers, auth::Headers,
db::DbConn, db::{models::*, DbConn},
error::Error, error::Error,
http_client::make_http_request, http_client::make_http_request,
mail,
util::parse_experimental_client_feature_flags, util::parse_experimental_client_feature_flags,
}; };
@ -246,3 +247,49 @@ fn api_not_found() -> Json<Value> {
} }
})) }))
} }
async fn accept_org_invite(
user: &User,
mut member: Membership,
reset_password_key: Option<String>,
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(())
}

Datei anzeigen

@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet};
use crate::api::admin::FAKE_ADMIN_UUID; use crate::api::admin::FAKE_ADMIN_UUID;
use crate::{ use crate::{
api::{ api::{
core::{log_event, two_factor, CipherSyncData, CipherSyncType}, core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
}, },
auth::{ auth::{
@ -342,13 +342,30 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
} }
// Called during the SSO enrollment // 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 // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
#[get("/organizations/<_identifier>/auto-enroll-status")] #[get("/organizations/<identifier>/auto-enroll-status")]
fn get_auto_enroll_status(_identifier: &str) -> JsonResult { 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!({ Ok(Json(json!({
"Id": get_uuid(), "Id": id,
"ResetPasswordEnabled": false, // Not implemented "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) 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 // 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. // So we either return an Org name associated to the user or a dummy value.
#[post("/organizations/domain/sso/details")] // The `verifiedDate` is required but the value ATM is ignored.
fn get_org_domain_sso_details() -> JsonResult { #[post("/organizations/domain/sso/details", data = "<data>")]
async fn get_org_domain_sso_details(data: Json<OrgDomainDetails>, 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!({ Ok(Json(json!({
"organizationIdentifier": "vaultwarden", "organizationIdentifier": identifier,
"ssoAvailable": CONFIG.sso_enabled(), "ssoAvailable": CONFIG.sso_enabled(),
"verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()), "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") 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 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) { if !claims.member_id.eq(&member_id) {
err!("Error accepting the invitation", "Claim does not match the member_id") err!("Error accepting the invitation", "Claim does not match the member_id")
} }
let member = &claims.member_id; let member_id = &claims.member_id;
let org = &claims.org_id;
Invitation::take(&claims.email, &mut conn).await; Invitation::take(&claims.email, &mut conn).await;
// skip invitation logic when we were invited via the /admin panel // skip invitation logic when we were invited via the /admin panel
if **member != FAKE_ADMIN_UUID { if **member_id != FAKE_ADMIN_UUID {
let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else { let Some(member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else {
err!("Error accepting the invitation") err!("Error accepting the invitation")
}; };
if member.status != MembershipStatus::Invited as i32 { let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await {
err!("User already accepted the invitation") 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; accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?;
if data.reset_password_key.is_none() && master_password_required { } else if CONFIG.mail_enabled() {
err!("Reset password key is required, but not provided."); // 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?;
// 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?;
}
} }
Ok(()) Ok(())
@ -2012,16 +2007,18 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo
} }
// Called during the SSO enrollment. // 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/<org_id>/policies/master-password", rank = 1)] #[get("/organizations/<org_id>/policies/master-password", rank = 1)]
fn get_master_password_policy(org_id: OrganizationId, _headers: Headers) -> JsonResult { async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult {
let data = match CONFIG.sso_master_password_policy() {
Some(policy) => policy,
None => "null".to_string(),
};
let policy = 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())) Ok(Json(policy.to_json()))
} }

Datei anzeigen

@ -1509,7 +1509,6 @@ where
reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_emergency_access_invite", ".html");
reg!("email/send_org_invite", ".html"); reg!("email/send_org_invite", ".html");
reg!("email/send_single_org_removed_from_org", ".html"); reg!("email/send_single_org_removed_from_org", ".html");
reg!("email/set_password", ".html");
reg!("email/smtp_test", ".html"); reg!("email/smtp_test", ".html");
reg!("email/sso_change_email", ".html"); reg!("email/sso_change_email", ".html");
reg!("email/twofactor_email", ".html"); reg!("email/twofactor_email", ".html");

Datei anzeigen

@ -391,11 +391,36 @@ impl Organization {
}} }}
} }
pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option<Self> {
db_run! { conn: {
organizations::table
.filter(organizations::name.eq(name))
.first::<OrganizationDb>(conn)
.ok().from_db()
}}
}
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> { pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
db_run! { conn: { db_run! { conn: {
organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db() organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db()
}} }}
} }
pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option<Organization> {
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::<OrganizationDb>(conn)
.ok().from_db()
}}
}
} }
impl Membership { impl Membership {
@ -1099,6 +1124,17 @@ impl Membership {
.first::<MembershipDb>(conn).ok().from_db() .first::<MembershipDb>(conn).ok().from_db()
}} }}
} }
pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
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::<MembershipDb>(conn)
.ok().from_db()
}}
}
} }
impl OrganizationApiKey { impl OrganizationApiKey {

Datei anzeigen

@ -565,18 +565,6 @@ pub async fn send_sso_change_email(address: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await 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 { pub async fn send_test(address: &str) -> EmptyResult {
let (subject, body_html, body_text) = get_text( let (subject, body_html, body_text) = get_text(
"email/smtp_test", "email/smtp_test",

Datei anzeigen

@ -28,6 +28,8 @@ use crate::{
CONFIG, CONFIG,
}; };
pub static FAKE_IDENTIFIER: &str = "Vaultwarden";
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> = static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());

Datei anzeigen

@ -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 }}

Datei anzeigen

@ -1,11 +0,0 @@
Master Password Has Been Changed
<!---------------->
{{> 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">
The master password for <b 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;">{{user_name}}</b> has been changed. If you did not initiate this request, please reach out to your administrator immediately.
</td>
</tr>
</table>
{{> email/email_footer }}