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:
Ursprung
8d2d9f8d1a
Commit
7649ce8a3c
9 geänderte Dateien mit 174 neuen und 107 gelöschten Zeilen
|
@ -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<KeysData>,
|
||||
master_password_hash: String,
|
||||
master_password_hint: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
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);
|
||||
}
|
||||
|
||||
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?;
|
||||
}
|
||||
|
|
|
@ -50,11 +50,12 @@ pub fn events_routes() -> Vec<Route> {
|
|||
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<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(())
|
||||
}
|
||||
|
|
|
@ -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<Value>
|
|||
}
|
||||
|
||||
// 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/<identifier>/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 = "<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!({
|
||||
"organizationIdentifier": "vaultwarden",
|
||||
"organizationIdentifier": identifier,
|
||||
"ssoAvailable": CONFIG.sso_enabled(),
|
||||
"verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()),
|
||||
})))
|
||||
|
@ -1283,72 +1313,37 @@ 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 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."),
|
||||
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,
|
||||
};
|
||||
// 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 {
|
||||
|
||||
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/<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 policy =
|
||||
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(),
|
||||
};
|
||||
|
||||
let policy =
|
||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data);
|
||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data)
|
||||
});
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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> {
|
||||
db_run! { conn: {
|
||||
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 {
|
||||
|
@ -1099,6 +1124,17 @@ impl Membership {
|
|||
.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 {
|
||||
|
|
12
src/mail.rs
12
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",
|
||||
|
|
|
@ -28,6 +28,8 @@ use crate::{
|
|||
CONFIG,
|
||||
};
|
||||
|
||||
pub static FAKE_IDENTIFIER: &str = "Vaultwarden";
|
||||
|
||||
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
|
||||
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
|
||||
|
||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
Laden …
Tabelle hinzufügen
In neuem Issue referenzieren