Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2025-01-22 07:09:00 +01:00
improve admin invite (#5403)
* check for admin invite * refactor the invitation logic * cleanup check for undefined token * prevent wrong user from accepting invitation
Dieser Commit ist enthalten in:
Ursprung
29f2b433f0
Commit
ef2695de0c
5 geänderte Dateien mit 77 neuen und 96 gelöschten Zeilen
|
@ -99,6 +99,7 @@ const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z";
|
||||||
const BASE_TEMPLATE: &str = "admin/base";
|
const BASE_TEMPLATE: &str = "admin/base";
|
||||||
|
|
||||||
const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000";
|
const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000";
|
||||||
|
pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
fn admin_path() -> String {
|
fn admin_path() -> String {
|
||||||
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
|
format!("{}{}", CONFIG.domain_path(), ADMIN_PATH)
|
||||||
|
@ -299,7 +300,9 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
||||||
|
|
||||||
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_invite(user, None, None, &CONFIG.invitation_org_name(), None).await
|
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
|
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
invitation.save(conn).await
|
invitation.save(conn).await
|
||||||
|
@ -475,7 +478,9 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, mut conn: DbCon
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_invite(&user, None, None, &CONFIG.invitation_org_name(), None).await
|
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
|
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
|
||||||
|
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ use rocket::Route;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::api::admin::FAKE_ADMIN_UUID;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{log_event, two_factor, CipherSyncData, CipherSyncType},
|
core::{log_event, two_factor, CipherSyncData, CipherSyncType},
|
||||||
|
@ -971,8 +972,8 @@ async fn send_invite(
|
||||||
|
|
||||||
if let Err(e) = mail::send_invite(
|
if let Err(e) = mail::send_invite(
|
||||||
&user,
|
&user,
|
||||||
Some(org_id.clone()),
|
org_id.clone(),
|
||||||
Some(new_member.uuid.clone()),
|
new_member.uuid.clone(),
|
||||||
&org_name,
|
&org_name,
|
||||||
Some(headers.user.email.clone()),
|
Some(headers.user.email.clone()),
|
||||||
)
|
)
|
||||||
|
@ -1098,14 +1099,7 @@ async fn _reinvite_member(
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_invite(
|
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_string())).await?;
|
||||||
&user,
|
|
||||||
Some(org_id.clone()),
|
|
||||||
Some(member.uuid),
|
|
||||||
&org_name,
|
|
||||||
Some(invited_by_email.to_string()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
} else if user.password_hash.is_empty() {
|
} else if user.password_hash.is_empty() {
|
||||||
let invitation = Invitation::new(&user.email);
|
let invitation = Invitation::new(&user.email);
|
||||||
invitation.save(conn).await?;
|
invitation.save(conn).await?;
|
||||||
|
@ -1131,23 +1125,30 @@ async fn accept_invite(
|
||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
data: Json<AcceptData>,
|
data: Json<AcceptData>,
|
||||||
|
headers: Headers,
|
||||||
mut conn: DbConn,
|
mut conn: DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
// The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead
|
// The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead
|
||||||
let data: AcceptData = data.into_inner();
|
let data: AcceptData = data.into_inner();
|
||||||
let claims = decode_invite(&data.token)?;
|
let claims = decode_invite(&data.token)?;
|
||||||
|
|
||||||
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
// Don't allow other users from accepting an invitation.
|
||||||
match &claims.member_id {
|
if !claims.email.eq(&headers.user.email) {
|
||||||
Some(ou_id) if ou_id.eq(&member_id) => {}
|
err!("Invitation was issued to a different account", "Claim does not match user_id")
|
||||||
_ => err!("Error accepting the invitation", "Claim does not match the member_id"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match User::find_by_mail(&claims.email, &mut conn).await {
|
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
||||||
Some(user) => {
|
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;
|
||||||
|
|
||||||
Invitation::take(&claims.email, &mut conn).await;
|
Invitation::take(&claims.email, &mut conn).await;
|
||||||
|
|
||||||
if let (Some(member), Some(org)) = (&claims.member_id, &claims.org_id) {
|
// 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 {
|
let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else {
|
||||||
err!("Error accepting the invitation")
|
err!("Error accepting the invitation")
|
||||||
};
|
};
|
||||||
|
@ -1168,7 +1169,7 @@ async fn accept_invite(
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||||
if CONFIG.email_2fa_auto_fallback() {
|
if CONFIG.email_2fa_auto_fallback() {
|
||||||
two_factor::email::activate_email_2fa(&user, &mut conn).await?;
|
two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?;
|
||||||
} else {
|
} else {
|
||||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
err!("You cannot join this organization until you enable two-step login on your user account");
|
||||||
}
|
}
|
||||||
|
@ -1187,23 +1188,18 @@ async fn accept_invite(
|
||||||
|
|
||||||
member.save(&mut conn).await?;
|
member.save(&mut conn).await?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None => err!("Invited user not found"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let mut org_name = CONFIG.invitation_org_name();
|
if let Some(invited_by_email) = &claims.invited_by_email {
|
||||||
if let Some(org_id) = &claims.org_id {
|
let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await {
|
||||||
org_name = match Organization::find_by_uuid(org_id, &mut conn).await {
|
|
||||||
Some(org) => org.name,
|
Some(org) => org.name,
|
||||||
None => err!("Organization not found."),
|
None => err!("Organization not found."),
|
||||||
};
|
};
|
||||||
};
|
|
||||||
if let Some(invited_by_email) = &claims.invited_by_email {
|
|
||||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
// 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?;
|
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?;
|
||||||
} else {
|
} else {
|
||||||
// User was invited from /admin, so they are automatically confirmed
|
// 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?;
|
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1825,23 +1821,17 @@ async fn list_policies(org_id: OrganizationId, _headers: AdminHeaders, mut conn:
|
||||||
|
|
||||||
#[get("/organizations/<org_id>/policies/token?<token>")]
|
#[get("/organizations/<org_id>/policies/token?<token>")]
|
||||||
async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbConn) -> JsonResult {
|
async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbConn) -> JsonResult {
|
||||||
// web-vault 2024.6.2 seems to send these values and cause logs to output errors
|
|
||||||
// Catch this and prevent errors in the logs
|
|
||||||
// TODO: CleanUp after 2024.6.x is not used anymore.
|
|
||||||
if org_id.as_ref() == "undefined" && token == "undefined" {
|
|
||||||
return Ok(Json(json!({})));
|
|
||||||
}
|
|
||||||
|
|
||||||
let invite = decode_invite(token)?;
|
let invite = decode_invite(token)?;
|
||||||
|
|
||||||
let Some(invite_org_id) = invite.org_id else {
|
if invite.org_id != org_id {
|
||||||
err!("Invalid token")
|
|
||||||
};
|
|
||||||
|
|
||||||
if invite_org_id != org_id {
|
|
||||||
err!("Token doesn't match request organization");
|
err!("Token doesn't match request organization");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exit early when we have been invited via /admin panel
|
||||||
|
if org_id.as_ref() == FAKE_ADMIN_UUID {
|
||||||
|
return Ok(Json(json!({})));
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: We receive the invite token as ?token=<>, validate it contains the org id
|
// TODO: We receive the invite token as ?token=<>, validate it contains the org id
|
||||||
let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await;
|
let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await;
|
||||||
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
||||||
|
@ -2141,8 +2131,8 @@ async fn import(org_id: OrganizationId, data: Json<OrgImportData>, headers: Head
|
||||||
|
|
||||||
mail::send_invite(
|
mail::send_invite(
|
||||||
&user,
|
&user,
|
||||||
Some(org_id.clone()),
|
org_id.clone(),
|
||||||
Some(new_member.uuid.clone()),
|
new_member.uuid.clone(),
|
||||||
&org_name,
|
&org_name,
|
||||||
Some(headers.user.email.clone()),
|
Some(headers.user.email.clone()),
|
||||||
)
|
)
|
||||||
|
|
|
@ -119,14 +119,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||||
None => err!("Error looking up organization"),
|
None => err!("Error looking up organization"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = mail::send_invite(
|
if let Err(e) =
|
||||||
&user,
|
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||||
Some(org_id.clone()),
|
|
||||||
Some(new_member.uuid.clone()),
|
|
||||||
&org_name,
|
|
||||||
Some(org_email),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
// Upon error delete the user, invite and org member records when needed
|
// Upon error delete the user, invite and org member records when needed
|
||||||
if user_created {
|
if user_created {
|
||||||
|
|
|
@ -194,16 +194,16 @@ pub struct InviteJwtClaims {
|
||||||
pub sub: UserId,
|
pub sub: UserId,
|
||||||
|
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub org_id: Option<OrganizationId>,
|
pub org_id: OrganizationId,
|
||||||
pub member_id: Option<MembershipId>,
|
pub member_id: MembershipId,
|
||||||
pub invited_by_email: Option<String>,
|
pub invited_by_email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_invite_claims(
|
pub fn generate_invite_claims(
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
email: String,
|
email: String,
|
||||||
org_id: Option<OrganizationId>,
|
org_id: OrganizationId,
|
||||||
member_id: Option<MembershipId>,
|
member_id: MembershipId,
|
||||||
invited_by_email: Option<String>,
|
invited_by_email: Option<String>,
|
||||||
) -> InviteJwtClaims {
|
) -> InviteJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
|
|
16
src/mail.rs
16
src/mail.rs
|
@ -259,8 +259,8 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) ->
|
||||||
|
|
||||||
pub async fn send_invite(
|
pub async fn send_invite(
|
||||||
user: &User,
|
user: &User,
|
||||||
org_id: Option<OrganizationId>,
|
org_id: OrganizationId,
|
||||||
member_id: Option<MembershipId>,
|
member_id: MembershipId,
|
||||||
org_name: &str,
|
org_name: &str,
|
||||||
invited_by_email: Option<String>,
|
invited_by_email: Option<String>,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
|
@ -272,22 +272,14 @@ pub async fn send_invite(
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
);
|
);
|
||||||
let invite_token = encode_jwt(&claims);
|
let invite_token = encode_jwt(&claims);
|
||||||
let org_id = match org_id {
|
|
||||||
Some(ref org_id) => org_id.as_ref(),
|
|
||||||
None => "_",
|
|
||||||
};
|
|
||||||
let member_id = match member_id {
|
|
||||||
Some(ref member_id) => member_id.as_ref(),
|
|
||||||
None => "_",
|
|
||||||
};
|
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
{
|
{
|
||||||
let mut query_params = query.query_pairs_mut();
|
let mut query_params = query.query_pairs_mut();
|
||||||
query_params
|
query_params
|
||||||
.append_pair("email", &user.email)
|
.append_pair("email", &user.email)
|
||||||
.append_pair("organizationName", org_name)
|
.append_pair("organizationName", org_name)
|
||||||
.append_pair("organizationId", org_id)
|
.append_pair("organizationId", &org_id)
|
||||||
.append_pair("organizationUserId", member_id)
|
.append_pair("organizationUserId", &member_id)
|
||||||
.append_pair("token", &invite_token);
|
.append_pair("token", &invite_token);
|
||||||
if user.private_key.is_some() {
|
if user.private_key.is_some() {
|
||||||
query_params.append_pair("orgUserHasExistingUser", "true");
|
query_params.append_pair("orgUserHasExistingUser", "true");
|
||||||
|
|
Laden …
In neuem Issue referenzieren