mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-11-27 12:53:50 -05:00
SSO using OpenID Connect (#3899)
* Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> * Improvements and error handling * Stop rolling device token * Add playwright tests * Activate PKCE by default * Ensure result order when searching for sso_user * add SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION * Toggle SSO button in scss * Base64 encode state before sending it to providers * Prevent disabled User from SSO login * Review fixes * Remove unused UserOrganization.invited_by_email * Split SsoUser::find_by_identifier_or_email * api::Accounts::verify_password add the policy even if it's ignored * Disable signups if SSO_ONLY is activated * Add verifiedDate to organizations::get_org_domain_sso_details * Review fixes * Remove OrganizationId guard from get_master_password_policy * Add wrapper type OIDCCode OIDCState OIDCIdentifier * Membership::confirm_user_invitations fix and tests * Allow set-password only if account is unitialized * Review fixes * Prevent accepting another user invitation * Log password change event on SSO account creation * Unify master password policy resolution * Upgrade openidconnect to 4.0.0 * Revert "Remove unused UserOrganization.invited_by_email" This reverts commit 548e19995e141314af98a10d170ea7371f02fab4. * Process org enrollment in accounts::post_set_password * Improve tests * Pass the claim invited_by_email in case it was not in db * Add Slack configuration hints * Fix playwright tests * Skip broken tests * Add sso identifier in admin user panel * Remove duplicate expiration check, add a log * Augment mobile refresh_token validity * Rauthy configuration hints * Fix playwright tests * Playwright upgrade and conf improvement * Playwright tests improvements * 2FA email and device creation change * Fix and improve Playwright tests * Minor improvements * Fix enforceOnLogin org policies * Run playwright sso tests against correct db * PKCE should now work with Zitadel * Playwright upgrade maildev to use MailBuffer.expect * Upgrades playwright tests deps * Check email_verified in id_token and user_info * Add sso verified endpoint for v2025.6.0 * Fix playwright tests * Create a separate sso_client * Upgrade openidconnect to 4.0.1 * Server settings for login fields toggle * Use only css for login fields * Fix playwright test * Review fix * More review fix * Perform same checks when setting kdf --------- Co-authored-by: Felix Eckhofer <felix@eckhofer.com> Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com> Co-authored-by: Timshel <timshel@480s>
This commit is contained in:
@@ -46,6 +46,7 @@ pub fn routes() -> Vec<Route> {
|
||||
invite_user,
|
||||
logout,
|
||||
delete_user,
|
||||
delete_sso_user,
|
||||
deauth_user,
|
||||
disable_user,
|
||||
enable_user,
|
||||
@@ -239,6 +240,7 @@ struct AdminTemplateData {
|
||||
page_data: Option<Value>,
|
||||
logged_in: bool,
|
||||
urlpath: String,
|
||||
sso_enabled: bool,
|
||||
}
|
||||
|
||||
impl AdminTemplateData {
|
||||
@@ -248,6 +250,7 @@ impl AdminTemplateData {
|
||||
page_data: Some(page_data),
|
||||
logged_in: true,
|
||||
urlpath: CONFIG.domain_path(),
|
||||
sso_enabled: CONFIG.sso_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +299,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
||||
err_code!("User already exists", Status::Conflict.code)
|
||||
}
|
||||
|
||||
let mut user = User::new(data.email);
|
||||
let mut user = User::new(data.email, None);
|
||||
|
||||
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
||||
if CONFIG.mail_enabled() {
|
||||
@@ -336,7 +339,7 @@ fn logout(cookies: &CookieJar<'_>) -> Redirect {
|
||||
async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
|
||||
let users = User::get_all(&mut conn).await;
|
||||
let mut users_json = Vec::with_capacity(users.len());
|
||||
for u in users {
|
||||
for (u, _) in users {
|
||||
let mut usr = u.to_json(&mut conn).await;
|
||||
usr["userEnabled"] = json!(u.enabled);
|
||||
usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
|
||||
@@ -354,7 +357,7 @@ async fn get_users_json(_token: AdminToken, mut conn: DbConn) -> Json<Value> {
|
||||
async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<String>> {
|
||||
let users = User::get_all(&mut conn).await;
|
||||
let mut users_json = Vec::with_capacity(users.len());
|
||||
for u in users {
|
||||
for (u, sso_u) in users {
|
||||
let mut usr = u.to_json(&mut conn).await;
|
||||
usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &mut conn).await);
|
||||
usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &mut conn).await);
|
||||
@@ -365,6 +368,9 @@ async fn users_overview(_token: AdminToken, mut conn: DbConn) -> ApiResult<Html<
|
||||
Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
|
||||
None => json!("Never"),
|
||||
};
|
||||
|
||||
usr["sso_identifier"] = json!(sso_u.map(|u| u.identifier.to_string()).unwrap_or(String::new()));
|
||||
|
||||
users_json.push(usr);
|
||||
}
|
||||
|
||||
@@ -417,6 +423,27 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
|
||||
res
|
||||
}
|
||||
|
||||
#[delete("/users/<user_id>/sso", format = "application/json")]
|
||||
async fn delete_sso_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
|
||||
let memberships = Membership::find_any_state_by_user(&user_id, &mut conn).await;
|
||||
let res = SsoUser::delete(&user_id, &mut conn).await;
|
||||
|
||||
for membership in memberships {
|
||||
log_event(
|
||||
EventType::OrganizationUserUnlinkedSso as i32,
|
||||
&membership.uuid,
|
||||
&membership.org_uuid,
|
||||
&ACTING_ADMIN_USER.into(),
|
||||
14, // Use UnknownBrowser type
|
||||
&token.ip.ip,
|
||||
&mut conn,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
#[post("/users/<user_id>/deauth", format = "application/json")]
|
||||
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &mut conn).await?;
|
||||
|
||||
@@ -7,9 +7,9 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::{log_user_event, two_factor::email},
|
||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult,
|
||||
Notify, PasswordOrOtpData, UpdateType,
|
||||
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,
|
||||
},
|
||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
||||
crypto,
|
||||
@@ -34,6 +34,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
get_public_keys,
|
||||
post_keys,
|
||||
post_password,
|
||||
post_set_password,
|
||||
post_kdf,
|
||||
post_rotatekey,
|
||||
post_sstamp,
|
||||
@@ -104,6 +105,19 @@ pub struct RegisterData {
|
||||
org_invite_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetPasswordData {
|
||||
#[serde(flatten)]
|
||||
kdf: KDFData,
|
||||
|
||||
key: String,
|
||||
keys: Option<KeysData>,
|
||||
master_password_hash: String,
|
||||
master_password_hint: Option<String>,
|
||||
org_identifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeysData {
|
||||
@@ -244,10 +258,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
|
||||
err!("Registration email does not match invite email")
|
||||
}
|
||||
} else if Invitation::take(&email, &mut conn).await {
|
||||
for membership in Membership::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() {
|
||||
membership.status = MembershipStatus::Accepted as i32;
|
||||
membership.save(&mut conn).await?;
|
||||
}
|
||||
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
|
||||
user
|
||||
} else if CONFIG.is_signup_allowed(&email)
|
||||
|| (CONFIG.emergency_access_allowed()
|
||||
@@ -266,7 +277,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
|
||||
|| CONFIG.is_signup_allowed(&email)
|
||||
|| pending_emergency_access.is_some()
|
||||
{
|
||||
User::new(email.clone())
|
||||
User::new(email.clone(), None)
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
@@ -325,6 +336,68 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, mut c
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/accounts/set-password", data = "<data>")]
|
||||
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let data: SetPasswordData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
if user.private_key.is_some() {
|
||||
err!("Account already intialized cannot set password")
|
||||
}
|
||||
|
||||
// Check against the password hint setting here so if it fails, the user
|
||||
// can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hint);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
|
||||
set_kdf_data(&mut user, data.kdf)?;
|
||||
|
||||
user.set_password(
|
||||
&data.master_password_hash,
|
||||
Some(data.key),
|
||||
false,
|
||||
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
|
||||
);
|
||||
user.password_hint = password_hint;
|
||||
|
||||
if let Some(keys) = data.keys {
|
||||
user.private_key = Some(keys.encrypted_private_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() {
|
||||
mail::send_welcome(&user.email.to_lowercase()).await?;
|
||||
} else {
|
||||
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
|
||||
}
|
||||
|
||||
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &mut conn)
|
||||
.await;
|
||||
|
||||
user.save(&mut conn).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"Object": "set-password",
|
||||
"CaptchaBypassToken": "",
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/accounts/profile")]
|
||||
async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
Json(headers.user.to_json(&mut conn).await)
|
||||
@@ -1129,15 +1202,30 @@ struct SecretVerificationRequest {
|
||||
master_password_hash: String,
|
||||
}
|
||||
|
||||
// Change the KDF Iterations if necessary
|
||||
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> {
|
||||
if user.password_iterations < CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(pwd_hash, None, false, None);
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/accounts/verify-password", data = "<data>")]
|
||||
async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
async fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let data: SecretVerificationRequest = data.into_inner();
|
||||
let user = headers.user;
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
kdf_upgrade(&mut user, &data.master_password_hash, &mut conn).await?;
|
||||
|
||||
Ok(Json(master_password_policy(&user, &conn).await))
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, mu
|
||||
invitation.save(&mut conn).await?;
|
||||
}
|
||||
|
||||
let mut user = User::new(email.clone());
|
||||
let mut user = User::new(email.clone(), None);
|
||||
user.save(&mut conn).await?;
|
||||
(user, true)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -259,3 +260,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,13 +7,13 @@ 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::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders},
|
||||
db::{models::*, DbConn},
|
||||
mail,
|
||||
util::{convert_json_key_lcase_first, NumberOrString},
|
||||
util::{convert_json_key_lcase_first, get_uuid, NumberOrString},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ pub fn routes() -> Vec<Route> {
|
||||
bulk_delete_organization_collections,
|
||||
post_bulk_collections,
|
||||
get_org_details,
|
||||
get_org_domain_sso_verified,
|
||||
get_members,
|
||||
send_invite,
|
||||
reinvite_member,
|
||||
@@ -60,6 +61,7 @@ pub fn routes() -> Vec<Route> {
|
||||
post_org_import,
|
||||
list_policies,
|
||||
list_policies_token,
|
||||
get_master_password_policy,
|
||||
get_policy,
|
||||
put_policy,
|
||||
get_organization_tax,
|
||||
@@ -103,6 +105,7 @@ pub fn routes() -> Vec<Route> {
|
||||
api_key,
|
||||
rotate_api_key,
|
||||
get_billing_metadata,
|
||||
get_auto_enroll_status,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ 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());
|
||||
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
|
||||
let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
|
||||
|
||||
member.akey = data.key;
|
||||
@@ -335,6 +338,34 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
// Called during the SSO enrollment
|
||||
// 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")]
|
||||
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": id,
|
||||
"Identifier": identifier,
|
||||
"ResetPasswordEnabled": rp_auto_enroll,
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/collections")]
|
||||
async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, mut conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.membership.org_uuid {
|
||||
@@ -930,6 +961,39 @@ async fn _get_org_details(
|
||||
Ok(json!(ciphers_json))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OrgDomainDetails {
|
||||
email: String,
|
||||
}
|
||||
|
||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||
// So we either return an Org name associated to the user or a dummy value.
|
||||
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
||||
#[post("/organizations/domain/sso/verified", data = "<data>")]
|
||||
async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, mut conn: DbConn) -> JsonResult {
|
||||
let data: OrgDomainDetails = data.into_inner();
|
||||
|
||||
let identifiers = match Organization::find_org_user_email(&data.email, &mut conn)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|o| o.name)
|
||||
.collect::<Vec<String>>()
|
||||
{
|
||||
v if !v.is_empty() => v,
|
||||
_ => vec![crate::sso::FAKE_IDENTIFIER.to_string()],
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": identifiers.into_iter().map(|identifier| json!({
|
||||
"organizationName": identifier, // appear unused
|
||||
"organizationIdentifier": identifier,
|
||||
"domainName": CONFIG.domain(), // appear unused
|
||||
})).collect::<Vec<Value>>()
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct GetOrgUserData {
|
||||
#[field(name = "includeCollections")]
|
||||
@@ -1063,7 +1127,7 @@ async fn send_invite(
|
||||
Invitation::new(email).save(&mut conn).await?;
|
||||
}
|
||||
|
||||
let mut new_user = User::new(email.clone());
|
||||
let mut new_user = User::new(email.clone(), None);
|
||||
new_user.save(&mut conn).await?;
|
||||
user_created = true;
|
||||
new_user
|
||||
@@ -1081,7 +1145,7 @@ async fn send_invite(
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||
new_member.access_all = access_all;
|
||||
new_member.atype = new_type;
|
||||
new_member.status = member_status;
|
||||
@@ -1267,71 +1331,39 @@ 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(mut 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.");
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 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(())
|
||||
@@ -2025,18 +2057,36 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>")]
|
||||
// Called during the SSO enrollment.
|
||||
// Return the org policy if it exists, otherwise use the default one.
|
||||
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
||||
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(),
|
||||
};
|
||||
|
||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data)
|
||||
});
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)]
|
||||
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
|
||||
if org_id != headers.org_id {
|
||||
err!("Organization not found", "Organization id's do not match");
|
||||
}
|
||||
|
||||
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
|
||||
err!("Invalid or unsupported policy type")
|
||||
};
|
||||
|
||||
let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await {
|
||||
Some(p) => p,
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, "null".to_string()),
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_string()),
|
||||
};
|
||||
|
||||
Ok(Json(policy.to_json()))
|
||||
@@ -2147,7 +2197,7 @@ async fn put_policy(
|
||||
|
||||
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &mut conn).await {
|
||||
Some(p) => p,
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, "{}".to_string()),
|
||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_string()),
|
||||
};
|
||||
|
||||
policy.enabled = data.enabled;
|
||||
@@ -2306,7 +2356,8 @@ async fn import(org_id: OrganizationId, data: Json<OrgImportData>, headers: Head
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
let mut new_member =
|
||||
Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
||||
new_member.access_all = false;
|
||||
new_member.atype = MembershipType::User as i32;
|
||||
new_member.status = member_status;
|
||||
|
||||
@@ -89,7 +89,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
Some(user) => user, // exists in vaultwarden
|
||||
None => {
|
||||
// User does not exist yet
|
||||
let mut new_user = User::new(user_data.email.clone());
|
||||
let mut new_user = User::new(user_data.email.clone(), None);
|
||||
new_user.save(&mut conn).await?;
|
||||
|
||||
if !CONFIG.mail_enabled() {
|
||||
@@ -105,7 +105,12 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
MembershipStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone());
|
||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||
Some(org) => (org.name, org.billing_email),
|
||||
None => err!("Error looking up organization"),
|
||||
};
|
||||
|
||||
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||
new_member.set_external_id(Some(user_data.external_id.clone()));
|
||||
new_member.access_all = false;
|
||||
new_member.atype = MembershipType::User as i32;
|
||||
@@ -114,11 +119,6 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, mut conn: Db
|
||||
new_member.save(&mut conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||
Some(org) => (org.name, org.billing_email),
|
||||
None => err!("Error looking up organization"),
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
auth::Headers,
|
||||
crypto,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
models::{DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::{Error, MapResult},
|
||||
@@ -24,11 +24,15 @@ pub fn routes() -> Vec<Route> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SendEmailLoginData {
|
||||
// DeviceIdentifier: String, // Currently not used
|
||||
device_identifier: DeviceId,
|
||||
|
||||
#[allow(unused)]
|
||||
#[serde(alias = "Email")]
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
|
||||
#[allow(unused)]
|
||||
#[serde(alias = "MasterPasswordHash")]
|
||||
master_password_hash: String,
|
||||
master_password_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// User is trying to login and wants to use email 2FA.
|
||||
@@ -40,15 +44,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, mut conn: DbConn) -> E
|
||||
use crate::db::models::User;
|
||||
|
||||
// Get the user
|
||||
let Some(user) = User::find_by_mail(&data.email, &mut conn).await else {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
let Some(user) = User::find_by_device_id(&data.device_identifier, &mut conn).await else {
|
||||
err!("Cannot find user. Try again.")
|
||||
};
|
||||
|
||||
// Check password
|
||||
if !user.check_valid_password(&data.master_password_hash) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
|
||||
if !CONFIG._enable_email_2fa() {
|
||||
err!("Email 2FA is disabled")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::{
|
||||
form::{Form, FromForm},
|
||||
http::Status,
|
||||
response::Redirect,
|
||||
serde::json::Json,
|
||||
Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -10,7 +12,7 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
api::{
|
||||
core::{
|
||||
accounts::{PreloginData, RegisterData, _prelogin, _register},
|
||||
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
|
||||
log_user_event,
|
||||
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey},
|
||||
},
|
||||
@@ -18,14 +20,27 @@ use crate::{
|
||||
push::register_push_device,
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
},
|
||||
auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp, ClientVersion},
|
||||
auth,
|
||||
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
|
||||
db::{models::*, DbConn},
|
||||
error::MapResult,
|
||||
mail, util, CONFIG,
|
||||
mail, sso,
|
||||
sso::{OIDCCode, OIDCState},
|
||||
util, CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![login, prelogin, identity_register, register_verification_email, register_finish]
|
||||
routes![
|
||||
login,
|
||||
prelogin,
|
||||
identity_register,
|
||||
register_verification_email,
|
||||
register_finish,
|
||||
prevalidate,
|
||||
authorize,
|
||||
oidcsignin,
|
||||
oidcsignin_error
|
||||
]
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<data>")]
|
||||
@@ -42,8 +57,9 @@ async fn login(
|
||||
let login_result = match data.grant_type.as_ref() {
|
||||
"refresh_token" => {
|
||||
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
|
||||
_refresh_login(data, &mut conn).await
|
||||
_refresh_login(data, &mut conn, &client_header.ip).await
|
||||
}
|
||||
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||
"password" => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
_check_is_some(&data.password, "password cannot be blank")?;
|
||||
@@ -67,6 +83,17 @@ async fn login(
|
||||
|
||||
_api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await
|
||||
}
|
||||
"authorization_code" if CONFIG.sso_enabled() => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
_check_is_some(&data.code, "code cannot be blank")?;
|
||||
|
||||
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
|
||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||
|
||||
_sso_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await
|
||||
}
|
||||
"authorization_code" => err!("SSO sign-in is not available"),
|
||||
t => err!("Invalid type", t),
|
||||
};
|
||||
|
||||
@@ -100,37 +127,193 @@ async fn login(
|
||||
login_result
|
||||
}
|
||||
|
||||
async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
||||
// Return Status::Unauthorized to trigger logout
|
||||
async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Extract token
|
||||
let token = data.refresh_token.unwrap();
|
||||
let refresh_token = match data.refresh_token {
|
||||
Some(token) => token,
|
||||
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
|
||||
};
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?;
|
||||
|
||||
let scope = "api offline_access";
|
||||
let scope_vec = vec!["api".into(), "offline_access".into()];
|
||||
|
||||
// Common
|
||||
let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap();
|
||||
// ---
|
||||
// Disabled this variable, it was used to generate the JWT
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
|
||||
device.save(conn).await?;
|
||||
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
|
||||
Err(err) => {
|
||||
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
|
||||
}
|
||||
Ok((mut device, auth_tokens)) => {
|
||||
// Save to update `device.updated_at` to track usage and toggle new status
|
||||
device.save(conn).await?;
|
||||
|
||||
let result = json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": device.refresh_token,
|
||||
let result = json!({
|
||||
"refresh_token": auth_tokens.refresh_token(),
|
||||
"access_token": auth_tokens.access_token(),
|
||||
"expires_in": auth_tokens.expires_in(),
|
||||
"token_type": "Bearer",
|
||||
"scope": auth_tokens.scope(),
|
||||
});
|
||||
|
||||
"scope": scope,
|
||||
});
|
||||
Ok(Json(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(result))
|
||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||
async fn _sso_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
client_version: &Option<ClientVersion>,
|
||||
) -> JsonResult {
|
||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
let code = match data.code.as_ref() {
|
||||
None => err!(
|
||||
"Got no code in OIDC data",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
),
|
||||
Some(code) => code,
|
||||
};
|
||||
|
||||
let user_infos = sso::exchange_code(code, conn).await?;
|
||||
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
|
||||
None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
|
||||
None => None,
|
||||
Some((user, Some(_))) => {
|
||||
error!(
|
||||
"Login failure ({}), existing SSO user ({}) with same email ({})",
|
||||
user_infos.identifier, user.uuid, user.email
|
||||
);
|
||||
err_silent!(
|
||||
"Existing SSO user with same email",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
||||
error!(
|
||||
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
||||
user_infos.identifier, user.uuid, user.email
|
||||
);
|
||||
err_silent!(
|
||||
"Existing non SSO user with same email",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
Some((user, None)) => Some((user, None)),
|
||||
},
|
||||
Some((user, sso_user)) => Some((user, Some(sso_user))),
|
||||
};
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
// Will trigger 2FA flow if needed
|
||||
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
|
||||
None => {
|
||||
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
|
||||
err!(
|
||||
"Email domain not allowed",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
match user_infos.email_verified {
|
||||
None if !CONFIG.sso_allow_unknown_email_verification() => err!(
|
||||
"Your provider does not send email verification status.\n\
|
||||
You will need to change the server configuration (check `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION`) to log in.",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
),
|
||||
Some(false) => err!(
|
||||
"You need to verify your email with your provider before you can log in",
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
),
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let mut user = User::new(user_infos.email, user_infos.user_name);
|
||||
user.verified_at = Some(now);
|
||||
user.save(conn).await?;
|
||||
|
||||
let device = get_device(&data, conn, &user).await?;
|
||||
|
||||
(user, device, None, None)
|
||||
}
|
||||
Some((user, _)) if !user.enabled => {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.name),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
Some((mut user, sso_user)) => {
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?;
|
||||
|
||||
if user.private_key.is_none() {
|
||||
// User was invited a stub was created
|
||||
user.verified_at = Some(now);
|
||||
if let Some(user_name) = user_infos.user_name {
|
||||
user.name = user_name;
|
||||
}
|
||||
|
||||
user.save(conn).await?;
|
||||
}
|
||||
|
||||
if user.email != user_infos.email {
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_sso_change_email(&user_infos.email).await?;
|
||||
}
|
||||
info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email);
|
||||
}
|
||||
|
||||
(user, device, twofactor_token, sso_user)
|
||||
}
|
||||
};
|
||||
|
||||
// We passed 2FA get full user informations
|
||||
let auth_user = sso::redeem(&user_infos.state, conn).await?;
|
||||
|
||||
if sso_user.is_none() {
|
||||
let user_sso = SsoUser {
|
||||
user_uuid: user.uuid.clone(),
|
||||
identifier: user_infos.identifier,
|
||||
};
|
||||
user_sso.save(conn).await?;
|
||||
}
|
||||
|
||||
// Set the user_uuid here to be passed back used for event logging.
|
||||
*user_id = Some(user.uuid.clone());
|
||||
|
||||
let auth_tokens = sso::create_auth_tokens(
|
||||
&device,
|
||||
&user,
|
||||
data.client_id,
|
||||
auth_user.refresh_token,
|
||||
auth_user.access_token,
|
||||
auth_user.expires_in,
|
||||
)?;
|
||||
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
|
||||
}
|
||||
|
||||
async fn _password_login(
|
||||
@@ -141,11 +324,7 @@ async fn _password_login(
|
||||
client_version: &Option<ClientVersion>,
|
||||
) -> JsonResult {
|
||||
// Validate scope
|
||||
let scope = data.scope.as_ref().unwrap();
|
||||
if scope != "api offline_access" {
|
||||
err!("Scope not supported")
|
||||
}
|
||||
let scope_vec = vec!["api".into(), "offline_access".into()];
|
||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
@@ -212,13 +391,8 @@ async fn _password_login(
|
||||
}
|
||||
|
||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||
if data.auth_request.is_none() && user.password_iterations != CONFIG.password_iterations() {
|
||||
user.password_iterations = CONFIG.password_iterations();
|
||||
user.set_password(password, None, false, None);
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
if data.auth_request.is_none() {
|
||||
kdf_upgrade(&mut user, password, conn).await?;
|
||||
}
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -255,12 +429,27 @@ async fn _password_login(
|
||||
)
|
||||
}
|
||||
|
||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
|
||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
||||
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn authenticated_response(
|
||||
user: &User,
|
||||
device: &mut Device,
|
||||
auth_tokens: auth::AuthTokens,
|
||||
twofactor_token: Option<String>,
|
||||
now: &NaiveDateTime,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
if CONFIG.mail_enabled() && device.is_new() {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), now, device).await {
|
||||
error!("Error sending new device email: {e:#?}");
|
||||
|
||||
if CONFIG.require_device_email() {
|
||||
@@ -275,31 +464,21 @@ async fn _password_login(
|
||||
}
|
||||
|
||||
// register push device
|
||||
if !new_device {
|
||||
register_push_device(&mut device, conn).await?;
|
||||
if !device.is_new() {
|
||||
register_push_device(device, conn).await?;
|
||||
}
|
||||
|
||||
// Common
|
||||
// ---
|
||||
// Disabled this variable, it was used to generate the JWT
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
|
||||
// Save to update `device.updated_at` to track usage and toggle new status
|
||||
device.save(conn).await?;
|
||||
|
||||
let master_password_policy = master_password_policy(&user, conn).await;
|
||||
let master_password_policy = master_password_policy(user, conn).await;
|
||||
|
||||
let mut result = json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token": auth_tokens.access_token(),
|
||||
"expires_in": auth_tokens.expires_in(),
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": device.refresh_token,
|
||||
"Key": user.akey,
|
||||
"refresh_token": auth_tokens.refresh_token(),
|
||||
"PrivateKey": user.private_key,
|
||||
//"TwoFactorToken": "11122233333444555666777888999"
|
||||
|
||||
"Kdf": user.client_kdf_type,
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
@@ -307,19 +486,22 @@ async fn _password_login(
|
||||
"ResetMasterPassword": false, // TODO: Same as above
|
||||
"ForcePasswordReset": false,
|
||||
"MasterPasswordPolicy": master_password_policy,
|
||||
|
||||
"scope": scope,
|
||||
"scope": auth_tokens.scope(),
|
||||
"UserDecryptionOptions": {
|
||||
"HasMasterPassword": !user.password_hash.is_empty(),
|
||||
"Object": "userDecryptionOptions"
|
||||
},
|
||||
});
|
||||
|
||||
if !user.akey.is_empty() {
|
||||
result["Key"] = Value::String(user.akey.clone());
|
||||
}
|
||||
|
||||
if let Some(token) = twofactor_token {
|
||||
result["TwoFactorToken"] = Value::String(token);
|
||||
}
|
||||
|
||||
info!("User {username} logged in successfully. IP: {}", ip.ip);
|
||||
info!("User {} logged in successfully. IP: {}", &user.name, ip.ip);
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -333,9 +515,9 @@ async fn _api_key_login(
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
// Validate scope
|
||||
match data.scope.as_ref().unwrap().as_ref() {
|
||||
"api" => _user_api_key_login(data, user_id, conn, ip).await,
|
||||
"api.organization" => _organization_api_key_login(data, conn, ip).await,
|
||||
match data.scope.as_ref() {
|
||||
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_id, conn, ip).await,
|
||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
|
||||
_ => err!("Scope not supported"),
|
||||
}
|
||||
}
|
||||
@@ -382,9 +564,9 @@ async fn _user_api_key_login(
|
||||
)
|
||||
}
|
||||
|
||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if CONFIG.mail_enabled() && device.is_new() {
|
||||
let now = Utc::now().naive_utc();
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
|
||||
error!("Error sending new device email: {e:#?}");
|
||||
@@ -400,15 +582,15 @@ async fn _user_api_key_login(
|
||||
}
|
||||
}
|
||||
|
||||
// Common
|
||||
let scope_vec = vec!["api".into()];
|
||||
// ---
|
||||
// Disabled this variable, it was used to generate the JWT
|
||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
|
||||
// let orgs = Membership::find_confirmed_by_user(&user.uuid, conn).await;
|
||||
let access_claims = auth::LoginJwtClaims::default(&device, &user, &AuthMethod::UserApiKey, data.client_id);
|
||||
|
||||
// Save to update `device.updated_at` to track usage and toggle new status
|
||||
device.save(conn).await?;
|
||||
|
||||
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
|
||||
@@ -416,8 +598,8 @@ async fn _user_api_key_login(
|
||||
// Note: No refresh_token is returned. The CLI just repeats the
|
||||
// client_credentials login flow when the existing token expires.
|
||||
let result = json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token": access_claims.token(),
|
||||
"expires_in": access_claims.expires_in(),
|
||||
"token_type": "Bearer",
|
||||
"Key": user.akey,
|
||||
"PrivateKey": user.private_key,
|
||||
@@ -427,7 +609,7 @@ async fn _user_api_key_login(
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||
"scope": "api",
|
||||
"scope": AuthMethod::UserApiKey.scope(),
|
||||
});
|
||||
|
||||
Ok(Json(result))
|
||||
@@ -451,35 +633,29 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &
|
||||
}
|
||||
|
||||
let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid);
|
||||
let access_token = crate::auth::encode_jwt(&claim);
|
||||
let access_token = auth::encode_jwt(&claim);
|
||||
|
||||
Ok(Json(json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"scope": "api.organization",
|
||||
"scope": AuthMethod::OrgApiKey.scope(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Retrieves an existing device or creates a new device from ConnectData and the User
|
||||
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) {
|
||||
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> ApiResult<Device> {
|
||||
// On iOS, device_type sends "iOS", on others it sends a number
|
||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
||||
let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14);
|
||||
let device_id = data.device_identifier.clone().expect("No device id provided");
|
||||
let device_name = data.device_name.clone().expect("No device name provided");
|
||||
|
||||
let mut new_device = false;
|
||||
// Find device or create new
|
||||
let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||
Some(device) => device,
|
||||
None => {
|
||||
new_device = true;
|
||||
Device::new(device_id, user.uuid.clone(), device_name, device_type)
|
||||
}
|
||||
};
|
||||
|
||||
(device, new_device)
|
||||
match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||
Some(device) => Ok(device),
|
||||
None => Device::new(device_id, user.uuid.clone(), device_name, device_type, conn).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn twofactor_auth(
|
||||
@@ -572,12 +748,13 @@ async fn twofactor_auth(
|
||||
|
||||
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
|
||||
|
||||
if !CONFIG.disable_2fa_remember() && remember == 1 {
|
||||
Ok(Some(device.refresh_twofactor_remember()))
|
||||
let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 {
|
||||
Some(device.refresh_twofactor_remember())
|
||||
} else {
|
||||
device.delete_twofactor_remember();
|
||||
Ok(None)
|
||||
}
|
||||
None
|
||||
};
|
||||
Ok(two_factor)
|
||||
}
|
||||
|
||||
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||
@@ -727,9 +904,8 @@ async fn register_verification_email(
|
||||
|
||||
let should_send_mail = CONFIG.mail_enabled() && CONFIG.signups_verify();
|
||||
|
||||
let token_claims =
|
||||
crate::auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
|
||||
let token = crate::auth::encode_jwt(&token_claims);
|
||||
let token_claims = auth::generate_register_verify_claims(data.email.clone(), data.name.clone(), should_send_mail);
|
||||
let token = auth::encode_jwt(&token_claims);
|
||||
|
||||
if should_send_mail {
|
||||
let user = User::find_by_mail(&data.email, &mut conn).await;
|
||||
@@ -812,11 +988,131 @@ struct ConnectData {
|
||||
two_factor_remember: Option<i32>,
|
||||
#[field(name = uncased("authrequest"))]
|
||||
auth_request: Option<AuthRequestId>,
|
||||
// Needed for authorization code
|
||||
#[field(name = uncased("code"))]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||
if value.is_none() {
|
||||
err!(msg)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/sso/prevalidate")]
|
||||
fn prevalidate() -> JsonResult {
|
||||
if CONFIG.sso_enabled() {
|
||||
let sso_token = sso::encode_ssotoken_claims();
|
||||
Ok(Json(json!({
|
||||
"token": sso_token,
|
||||
})))
|
||||
} else {
|
||||
err!("SSO sign-in is not available")
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||
async fn oidcsignin(code: OIDCCode, state: String, conn: DbConn) -> ApiResult<Redirect> {
|
||||
oidcsignin_redirect(
|
||||
state,
|
||||
|decoded_state| sso::OIDCCodeWrapper::Ok {
|
||||
state: decoded_state,
|
||||
code,
|
||||
},
|
||||
&conn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Bitwarden client appear to only care for code and state so we pipe it through
|
||||
// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141
|
||||
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
|
||||
async fn oidcsignin_error(
|
||||
state: String,
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
conn: DbConn,
|
||||
) -> ApiResult<Redirect> {
|
||||
oidcsignin_redirect(
|
||||
state,
|
||||
|decoded_state| sso::OIDCCodeWrapper::Error {
|
||||
state: decoded_state,
|
||||
error,
|
||||
error_description,
|
||||
},
|
||||
&conn,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// The state was encoded using Base64 to ensure no issue with providers.
|
||||
// iss and scope parameters are needed for redirection to work on IOS.
|
||||
async fn oidcsignin_redirect(
|
||||
base64_state: String,
|
||||
wrapper: impl FnOnce(OIDCState) -> sso::OIDCCodeWrapper,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<Redirect> {
|
||||
let state = sso::deocde_state(base64_state)?;
|
||||
let code = sso::encode_code_claims(wrapper(state.clone()));
|
||||
|
||||
let nonce = match SsoNonce::find(&state, conn).await {
|
||||
Some(n) => n,
|
||||
None => err!(format!("Failed to retrive redirect_uri with {state}")),
|
||||
};
|
||||
|
||||
let mut url = match url::Url::parse(&nonce.redirect_uri) {
|
||||
Ok(url) => url,
|
||||
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)),
|
||||
};
|
||||
|
||||
url.query_pairs_mut()
|
||||
.append_pair("code", &code)
|
||||
.append_pair("state", &state)
|
||||
.append_pair("scope", &AuthMethod::Sso.scope())
|
||||
.append_pair("iss", &CONFIG.domain());
|
||||
|
||||
debug!("Redirection to {url}");
|
||||
|
||||
Ok(Redirect::temporary(String::from(url)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, FromForm)]
|
||||
struct AuthorizeData {
|
||||
#[field(name = uncased("client_id"))]
|
||||
#[field(name = uncased("clientid"))]
|
||||
client_id: String,
|
||||
#[field(name = uncased("redirect_uri"))]
|
||||
#[field(name = uncased("redirecturi"))]
|
||||
redirect_uri: String,
|
||||
#[allow(unused)]
|
||||
response_type: Option<String>,
|
||||
#[allow(unused)]
|
||||
scope: Option<String>,
|
||||
state: OIDCState,
|
||||
#[allow(unused)]
|
||||
code_challenge: Option<String>,
|
||||
#[allow(unused)]
|
||||
code_challenge_method: Option<String>,
|
||||
#[allow(unused)]
|
||||
response_mode: Option<String>,
|
||||
#[allow(unused)]
|
||||
domain_hint: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("ssoToken"))]
|
||||
sso_token: Option<String>,
|
||||
}
|
||||
|
||||
// The `redirect_uri` will change depending of the client (web, android, ios ..)
|
||||
#[get("/connect/authorize?<data..>")]
|
||||
async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
|
||||
let AuthorizeData {
|
||||
client_id,
|
||||
redirect_uri,
|
||||
state,
|
||||
..
|
||||
} = data;
|
||||
|
||||
let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?;
|
||||
|
||||
Ok(Redirect::temporary(String::from(auth_url)))
|
||||
}
|
||||
|
||||
@@ -36,9 +36,10 @@ use crate::db::{
|
||||
models::{OrgPolicy, OrgPolicyType, User},
|
||||
DbConn,
|
||||
};
|
||||
use crate::CONFIG;
|
||||
|
||||
// Type aliases for API methods results
|
||||
type ApiResult<T> = Result<T, crate::error::Error>;
|
||||
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||
pub type JsonResult = ApiResult<Json<Value>>;
|
||||
pub type EmptyResult = ApiResult<()>;
|
||||
|
||||
@@ -109,6 +110,8 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
|
||||
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
|
||||
}
|
||||
}))
|
||||
} else if let Some(policy_str) = CONFIG.sso_master_password_policy().filter(|_| CONFIG.sso_enabled()) {
|
||||
serde_json::from_str(&policy_str).unwrap_or(json!({}))
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
@@ -55,13 +55,15 @@ fn not_found() -> ApiResult<Html<String>> {
|
||||
#[get("/css/vaultwarden.css")]
|
||||
fn vaultwarden_css() -> Cached<Css<String>> {
|
||||
let css_options = json!({
|
||||
"signup_disabled": CONFIG.is_signup_disabled(),
|
||||
"mail_enabled": CONFIG.mail_enabled(),
|
||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
|
||||
"sends_allowed": CONFIG.sends_allowed(),
|
||||
"load_user_scss": true,
|
||||
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
|
||||
"mail_enabled": CONFIG.mail_enabled(),
|
||||
"sends_allowed": CONFIG.sends_allowed(),
|
||||
"signup_disabled": CONFIG.is_signup_disabled(),
|
||||
"sso_disabled": !CONFIG.sso_enabled(),
|
||||
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(),
|
||||
"yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(),
|
||||
});
|
||||
|
||||
let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) {
|
||||
|
||||
254
src/auth.rs
254
src/auth.rs
@@ -1,6 +1,5 @@
|
||||
// JWT Handling
|
||||
//
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use jsonwebtoken::{errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use num_traits::FromPrimitive;
|
||||
use once_cell::sync::{Lazy, OnceCell};
|
||||
@@ -10,17 +9,24 @@ use serde::ser::Serialize;
|
||||
use std::{env, net::IpAddr};
|
||||
|
||||
use crate::{
|
||||
api::ApiResult,
|
||||
config::PathType,
|
||||
db::models::{
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, EmergencyAccessId, MembershipId, OrgApiKeyId, OrganizationId,
|
||||
SendFileId, SendId, UserId,
|
||||
AttachmentId, CipherId, CollectionId, DeviceId, DeviceType, EmergencyAccessId, MembershipId, OrgApiKeyId,
|
||||
OrganizationId, SendFileId, SendId, UserId,
|
||||
},
|
||||
error::Error,
|
||||
sso, CONFIG,
|
||||
};
|
||||
use crate::{error::Error, CONFIG};
|
||||
|
||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||
|
||||
pub static DEFAULT_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
||||
// Limit when BitWarden consider the token as expired
|
||||
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
|
||||
|
||||
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
|
||||
pub static MOBILE_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(90).unwrap());
|
||||
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
||||
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
||||
|
||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||
@@ -85,7 +91,7 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
||||
pub fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
||||
let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM);
|
||||
validation.leeway = 30; // 30 seconds
|
||||
validation.validate_exp = true;
|
||||
@@ -99,11 +105,15 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
|
||||
ErrorKind::InvalidToken => err!("Token is invalid"),
|
||||
ErrorKind::InvalidIssuer => err!("Issuer is invalid"),
|
||||
ErrorKind::ExpiredSignature => err!("Token has expired"),
|
||||
_ => err!("Error decoding JWT"),
|
||||
_ => err!(format!("Error decoding JWT: {:?}", err)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_refresh(token: &str) -> Result<RefreshJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||
}
|
||||
@@ -186,6 +196,84 @@ pub struct LoginJwtClaims {
|
||||
pub amr: Vec<String>,
|
||||
}
|
||||
|
||||
impl LoginJwtClaims {
|
||||
pub fn new(
|
||||
device: &Device,
|
||||
user: &User,
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
scope: Vec<String>,
|
||||
client_id: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Self {
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// ---
|
||||
// fn arg: orgs: Vec<super::UserOrganization>,
|
||||
// ---
|
||||
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||
warn!("Raise access_token lifetime to more than 5min.")
|
||||
}
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
Self {
|
||||
nbf,
|
||||
exp,
|
||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||
sub: user.uuid.clone(),
|
||||
premium: true,
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
||||
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// orgowner,
|
||||
// orgadmin,
|
||||
// orguser,
|
||||
// orgmanager,
|
||||
sstamp: user.security_stamp.clone(),
|
||||
device: device.uuid.clone(),
|
||||
devicetype: DeviceType::from_i32(device.atype).to_string(),
|
||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
||||
scope,
|
||||
amr: vec!["Application".into()],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default(device: &Device, user: &User, auth_method: &AuthMethod, client_id: Option<String>) -> Self {
|
||||
let time_now = Utc::now();
|
||||
Self::new(
|
||||
device,
|
||||
user,
|
||||
time_now.timestamp(),
|
||||
(time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||
auth_method.scope_vec(),
|
||||
client_id,
|
||||
time_now,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn token(&self) -> String {
|
||||
encode_jwt(&self)
|
||||
}
|
||||
|
||||
pub fn expires_in(&self) -> i64 {
|
||||
self.exp - Utc::now().timestamp()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InviteJwtClaims {
|
||||
// Not before
|
||||
@@ -1001,3 +1089,153 @@ impl<'r> FromRequest<'r> for ClientVersion {
|
||||
Outcome::Success(ClientVersion(version))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMethod {
|
||||
OrgApiKey,
|
||||
Password,
|
||||
Sso,
|
||||
UserApiKey,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
pub fn scope(&self) -> String {
|
||||
match self {
|
||||
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
||||
AuthMethod::Password => "api offline_access".to_string(),
|
||||
AuthMethod::Sso => "api offline_access".to_string(),
|
||||
AuthMethod::UserApiKey => "api".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope_vec(&self) -> Vec<String> {
|
||||
self.scope().split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||
let method_scope = self.scope();
|
||||
match scope {
|
||||
None => err!("Missing scope"),
|
||||
Some(scope) if scope == &method_scope => Ok(method_scope),
|
||||
Some(scope) => err!(format!("Scope ({scope}) not supported")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum TokenWrapper {
|
||||
Access(String),
|
||||
Refresh(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RefreshJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: AuthMethod,
|
||||
|
||||
pub device_token: String,
|
||||
|
||||
pub token: Option<TokenWrapper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthTokens {
|
||||
pub refresh_claims: RefreshJwtClaims,
|
||||
pub access_claims: LoginJwtClaims,
|
||||
}
|
||||
|
||||
impl AuthTokens {
|
||||
pub fn refresh_token(&self) -> String {
|
||||
encode_jwt(&self.refresh_claims)
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> String {
|
||||
self.access_claims.token()
|
||||
}
|
||||
|
||||
pub fn expires_in(&self) -> i64 {
|
||||
self.access_claims.expires_in()
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> String {
|
||||
self.refresh_claims.sub.scope()
|
||||
}
|
||||
|
||||
// Create refresh_token and access_token with default validity
|
||||
pub fn new(device: &Device, user: &User, sub: AuthMethod, client_id: Option<String>) -> Self {
|
||||
let time_now = Utc::now();
|
||||
|
||||
let access_claims = LoginJwtClaims::default(device, user, &sub, client_id);
|
||||
|
||||
let validity = if DeviceType::is_mobile(&device.atype) {
|
||||
*MOBILE_REFRESH_VALIDITY
|
||||
} else {
|
||||
*DEFAULT_REFRESH_VALIDITY
|
||||
};
|
||||
|
||||
let refresh_claims = RefreshJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + validity).timestamp(),
|
||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||
sub,
|
||||
device_token: device.refresh_token.clone(),
|
||||
token: None,
|
||||
};
|
||||
|
||||
Self {
|
||||
refresh_claims,
|
||||
access_claims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_tokens(
|
||||
ip: &ClientIp,
|
||||
refresh_token: &str,
|
||||
client_id: Option<String>,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<(Device, AuthTokens)> {
|
||||
let refresh_claims = match decode_refresh(refresh_token) {
|
||||
Err(err) => {
|
||||
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
|
||||
err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
|
||||
}
|
||||
Ok(claims) => claims,
|
||||
};
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
||||
None => err!("Invalid refresh token"),
|
||||
Some(device) => device,
|
||||
};
|
||||
|
||||
// Save to update `updated_at`.
|
||||
device.save(conn).await?;
|
||||
|
||||
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||
None => err!("Impossible to find user"),
|
||||
Some(user) => user,
|
||||
};
|
||||
|
||||
let auth_tokens = match refresh_claims.sub {
|
||||
AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => {
|
||||
AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
|
||||
}
|
||||
AuthMethod::Sso if CONFIG.sso_enabled() => {
|
||||
sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await?
|
||||
}
|
||||
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
|
||||
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
|
||||
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
|
||||
_ => err!("Invalid auth method, cannot refresh token"),
|
||||
};
|
||||
|
||||
Ok((device, auth_tokens))
|
||||
}
|
||||
|
||||
102
src/config.rs
102
src/config.rs
@@ -458,6 +458,9 @@ make_config! {
|
||||
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
|
||||
/// Defaults to once every minute. Set blank to disable this job.
|
||||
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||
/// Purge incomplete SSO nonce. |> Cron schedule of the job that cleans leftover nonce in db due to incomplete SSO login.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string();
|
||||
},
|
||||
|
||||
/// General settings
|
||||
@@ -676,6 +679,42 @@ make_config! {
|
||||
enforce_single_org_with_reset_pw_policy: bool, false, def, false;
|
||||
},
|
||||
|
||||
/// OpenID Connect SSO settings
|
||||
sso {
|
||||
/// Enabled
|
||||
sso_enabled: bool, true, def, false;
|
||||
/// Only SSO login |> Disable Email+Master Password login
|
||||
sso_only: bool, true, def, false;
|
||||
/// Allow email association |> Associate existing non-SSO user based on email
|
||||
sso_signups_match_email: bool, true, def, true;
|
||||
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
|
||||
sso_allow_unknown_email_verification: bool, false, def, false;
|
||||
/// Client ID
|
||||
sso_client_id: String, true, def, String::new();
|
||||
/// Client Key
|
||||
sso_client_secret: Pass, true, def, String::new();
|
||||
/// Authority Server |> Base url of the OIDC provider discovery endpoint (without `/.well-known/openid-configuration`)
|
||||
sso_authority: String, true, def, String::new();
|
||||
/// Authorization request scopes |> List the of the needed scope (`openid` is implicit)
|
||||
sso_scopes: String, true, def, "email profile".to_string();
|
||||
/// Authorization request extra parameters
|
||||
sso_authorize_extra_params: String, true, def, String::new();
|
||||
/// Use PKCE during Authorization flow
|
||||
sso_pkce: bool, true, def, true;
|
||||
/// Regex for additional trusted Id token audience |> By default only the client_id is trusted.
|
||||
sso_audience_trusted: String, true, option;
|
||||
/// CallBack Path |> Generated from Domain.
|
||||
sso_callback_path: String, true, generated, |c| generate_sso_callback_path(&c.domain);
|
||||
/// Optional SSO master password policy |> Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
|
||||
sso_master_password_policy: String, true, option;
|
||||
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
||||
sso_auth_only_not_session: bool, true, def, false;
|
||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache
|
||||
sso_client_cache_expiration: u64, true, def, 0;
|
||||
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
||||
sso_debug_tokens: bool, true, def, false;
|
||||
},
|
||||
|
||||
/// Yubikey settings
|
||||
yubico: _enable_yubico {
|
||||
/// Enabled
|
||||
@@ -911,6 +950,16 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
err!("All Duo options need to be set for global Duo support")
|
||||
}
|
||||
|
||||
if cfg.sso_enabled {
|
||||
if cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty() {
|
||||
err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support")
|
||||
}
|
||||
|
||||
validate_internal_sso_issuer_url(&cfg.sso_authority)?;
|
||||
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
|
||||
check_master_password_policy(&cfg.sso_master_password_policy)?;
|
||||
}
|
||||
|
||||
if cfg._enable_yubico {
|
||||
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
|
||||
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")
|
||||
@@ -1088,6 +1137,28 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_internal_sso_issuer_url(sso_authority: &String) -> Result<openidconnect::IssuerUrl, Error> {
|
||||
match openidconnect::IssuerUrl::new(sso_authority.clone()) {
|
||||
Err(err) => err!(format!("Invalid sso_authority UR ({sso_authority}): {err}")),
|
||||
Ok(issuer_url) => Ok(issuer_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result<openidconnect::RedirectUrl, Error> {
|
||||
match openidconnect::RedirectUrl::new(sso_callback_path.clone()) {
|
||||
Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")),
|
||||
Ok(redirect_url) => Ok(redirect_url),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_master_password_policy(sso_master_password_policy: &Option<String>) -> Result<(), Error> {
|
||||
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
|
||||
if let Some(Err(error)) = policy {
|
||||
err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''"))
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts an RFC 6454 web origin from a URL.
|
||||
fn extract_url_origin(url: &str) -> String {
|
||||
match Url::parse(url) {
|
||||
@@ -1119,6 +1190,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_sso_callback_path(domain: &str) -> String {
|
||||
format!("{domain}/identity/connect/oidc-signin")
|
||||
}
|
||||
|
||||
/// Generate the correct URL for the icon service.
|
||||
/// This will be used within icons.rs to call the external icon service.
|
||||
fn generate_icon_service_url(icon_service: &str) -> String {
|
||||
@@ -1354,12 +1429,14 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// The registration link should be hidden if signup is not allowed and whitelist is empty
|
||||
// unless mail is disabled and invitations are allowed
|
||||
// The registration link should be hidden if
|
||||
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
|
||||
// - The SSO is activated and password login is disabled.
|
||||
pub fn is_signup_disabled(&self) -> bool {
|
||||
!self.signups_allowed()
|
||||
(!self.signups_allowed()
|
||||
&& self.signups_domains_whitelist().is_empty()
|
||||
&& (self.mail_enabled() || !self.invitations_allowed())
|
||||
&& (self.mail_enabled() || !self.invitations_allowed()))
|
||||
|| (self.sso_enabled() && self.sso_only())
|
||||
}
|
||||
|
||||
/// Tests whether the specified user is allowed to create an organization.
|
||||
@@ -1475,6 +1552,22 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sso_issuer_url(&self) -> Result<openidconnect::IssuerUrl, Error> {
|
||||
validate_internal_sso_issuer_url(&self.sso_authority())
|
||||
}
|
||||
|
||||
pub fn sso_redirect_url(&self) -> Result<openidconnect::RedirectUrl, Error> {
|
||||
validate_internal_sso_redirect_url(&self.sso_callback_path())
|
||||
}
|
||||
|
||||
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
||||
self.sso_scopes().split_whitespace().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
||||
url::form_urlencoded::parse(self.sso_authorize_extra_params().as_bytes()).into_owned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
use handlebars::{
|
||||
@@ -1540,6 +1633,7 @@ where
|
||||
reg!("email/send_org_invite", ".html");
|
||||
reg!("email/send_single_org_removed_from_org", ".html");
|
||||
reg!("email/smtp_test", ".html");
|
||||
reg!("email/sso_change_email", ".html");
|
||||
reg!("email/twofactor_email", ".html");
|
||||
reg!("email/verify_email", ".html");
|
||||
reg!("email/welcome_must_verify", ".html");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use data_encoding::{BASE64, BASE64URL};
|
||||
use derive_more::{Display, From};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -6,7 +8,6 @@ use super::{AuthRequest, UserId};
|
||||
use crate::{
|
||||
crypto,
|
||||
util::{format_date, get_uuid},
|
||||
CONFIG,
|
||||
};
|
||||
use macros::{IdFromParam, UuidFromParam};
|
||||
|
||||
@@ -34,25 +35,6 @@ db_object! {
|
||||
|
||||
/// Local methods
|
||||
impl Device {
|
||||
pub fn new(uuid: DeviceId, user_uuid: UserId, name: String, atype: i32) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Self {
|
||||
uuid,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
atype,
|
||||
|
||||
push_uuid: Some(PushId(get_uuid())),
|
||||
push_token: None,
|
||||
refresh_token: String::new(),
|
||||
twofactor_remember: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"id": self.uuid,
|
||||
@@ -66,7 +48,6 @@ impl Device {
|
||||
}
|
||||
|
||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||
use data_encoding::BASE64;
|
||||
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
|
||||
self.twofactor_remember = Some(twofactor_remember.clone());
|
||||
|
||||
@@ -77,71 +58,9 @@ impl Device {
|
||||
self.twofactor_remember = None;
|
||||
}
|
||||
|
||||
pub fn refresh_tokens(
|
||||
&mut self,
|
||||
user: &super::User,
|
||||
scope: Vec<String>,
|
||||
client_id: Option<String>,
|
||||
) -> (String, i64) {
|
||||
// If there is no refresh token, we create one
|
||||
if self.refresh_token.is_empty() {
|
||||
use data_encoding::BASE64URL;
|
||||
self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL);
|
||||
}
|
||||
|
||||
// Update the expiration of the device and the last update date
|
||||
let time_now = Utc::now();
|
||||
self.updated_at = time_now.naive_utc();
|
||||
|
||||
// Generate a random push_uuid so if it doesn't already have one
|
||||
if self.push_uuid.is_none() {
|
||||
self.push_uuid = Some(PushId(get_uuid()));
|
||||
}
|
||||
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// ---
|
||||
// fn arg: members: Vec<super::Membership>,
|
||||
// ---
|
||||
// let orgowner: Vec<_> = members.iter().filter(|m| m.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgadmin: Vec<_> = members.iter().filter(|m| m.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orguser: Vec<_> = members.iter().filter(|m| m.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||
// let orgmanager: Vec<_> = members.iter().filter(|m| m.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
|
||||
let claims = LoginJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
|
||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||
sub: user.uuid.clone(),
|
||||
|
||||
premium: true,
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
||||
|
||||
// ---
|
||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||
// ---
|
||||
// orgowner,
|
||||
// orgadmin,
|
||||
// orguser,
|
||||
// orgmanager,
|
||||
sstamp: user.security_stamp.clone(),
|
||||
device: self.uuid.clone(),
|
||||
devicetype: DeviceType::from_i32(self.atype).to_string(),
|
||||
client_id: client_id.unwrap_or("undefined".to_string()),
|
||||
scope,
|
||||
amr: vec!["Application".into()],
|
||||
};
|
||||
|
||||
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
|
||||
// This rely on the fact we only update the device after a successful login
|
||||
pub fn is_new(&self) -> bool {
|
||||
self.created_at == self.updated_at
|
||||
}
|
||||
|
||||
pub fn is_push_device(&self) -> bool {
|
||||
@@ -187,14 +106,39 @@ impl DeviceWithAuthRequest {
|
||||
}
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::api::{ApiResult, EmptyResult};
|
||||
use crate::error::MapResult;
|
||||
|
||||
/// Database methods
|
||||
impl Device {
|
||||
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
pub async fn new(
|
||||
uuid: DeviceId,
|
||||
user_uuid: UserId,
|
||||
name: String,
|
||||
atype: i32,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<Device> {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let device = Self {
|
||||
uuid,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
atype,
|
||||
|
||||
push_uuid: Some(PushId(get_uuid())),
|
||||
push_token: None,
|
||||
refresh_token: crypto::encode_random_bytes::<64>(BASE64URL),
|
||||
twofactor_remember: None,
|
||||
};
|
||||
|
||||
device.inner_save(conn).await.map(|()| device)
|
||||
}
|
||||
|
||||
async fn inner_save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
crate::util::retry(
|
||||
@@ -212,6 +156,12 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
// Should only be called after user has passed authentication
|
||||
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
self.inner_save(conn).await
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(devices::table.filter(devices::user_uuid.eq(user_uuid)))
|
||||
@@ -403,6 +353,10 @@ impl DeviceType {
|
||||
_ => DeviceType::UnknownBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_mobile(value: &i32) -> bool {
|
||||
*value == DeviceType::Android as i32 || *value == DeviceType::Ios as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
|
||||
@@ -89,7 +89,7 @@ pub enum EventType {
|
||||
OrganizationUserUpdated = 1502,
|
||||
OrganizationUserRemoved = 1503, // Organization user data was deleted
|
||||
OrganizationUserUpdatedGroups = 1504,
|
||||
// OrganizationUserUnlinkedSso = 1505, // Not supported
|
||||
OrganizationUserUnlinkedSso = 1505,
|
||||
OrganizationUserResetPasswordEnroll = 1506,
|
||||
OrganizationUserResetPasswordWithdraw = 1507,
|
||||
OrganizationUserAdminResetPassword = 1508,
|
||||
|
||||
@@ -11,6 +11,7 @@ mod group;
|
||||
mod org_policy;
|
||||
mod organization;
|
||||
mod send;
|
||||
mod sso_nonce;
|
||||
mod two_factor;
|
||||
mod two_factor_duo_context;
|
||||
mod two_factor_incomplete;
|
||||
@@ -35,7 +36,8 @@ pub use self::send::{
|
||||
id::{SendFileId, SendId},
|
||||
Send, SendType,
|
||||
};
|
||||
pub use self::sso_nonce::SsoNonce;
|
||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||
pub use self::user::{Invitation, User, UserId, UserKdfType, UserStampException};
|
||||
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
|
||||
|
||||
@@ -67,12 +67,12 @@ pub enum OrgPolicyErr {
|
||||
|
||||
/// Local methods
|
||||
impl OrgPolicy {
|
||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, data: String) -> Self {
|
||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||
Self {
|
||||
uuid: OrgPolicyId(crate::util::get_uuid()),
|
||||
org_uuid,
|
||||
atype: atype as i32,
|
||||
enabled: false,
|
||||
enabled,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ db_object! {
|
||||
pub user_uuid: UserId,
|
||||
pub org_uuid: OrganizationId,
|
||||
|
||||
pub invited_by_email: Option<String>,
|
||||
|
||||
pub access_all: bool,
|
||||
pub akey: String,
|
||||
pub status: i32,
|
||||
@@ -235,12 +237,13 @@ impl Organization {
|
||||
const ACTIVATE_REVOKE_DIFF: i32 = 128;
|
||||
|
||||
impl Membership {
|
||||
pub fn new(user_uuid: UserId, org_uuid: OrganizationId) -> Self {
|
||||
pub fn new(user_uuid: UserId, org_uuid: OrganizationId, invited_by_email: Option<String>) -> Self {
|
||||
Self {
|
||||
uuid: MembershipId(crate::util::get_uuid()),
|
||||
|
||||
user_uuid,
|
||||
org_uuid,
|
||||
invited_by_email,
|
||||
|
||||
access_all: false,
|
||||
akey: String::new(),
|
||||
@@ -389,11 +392,53 @@ 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()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_org_user_email(user_email: &str, conn: &mut DbConn) -> Vec<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)
|
||||
.load::<OrganizationDb>(conn)
|
||||
.expect("Error loading user orgs")
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
impl Membership {
|
||||
@@ -827,6 +872,19 @@ impl Membership {
|
||||
}}
|
||||
}
|
||||
|
||||
// Should be used only when email are disabled.
|
||||
// In Organizations::send_invite status is set to Accepted only if the user has a password.
|
||||
pub async fn accept_user_invitations(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::update(users_organizations::table)
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.filter(users_organizations::status.eq(MembershipStatus::Invited as i32))
|
||||
.set(users_organizations::status.eq(MembershipStatus::Accepted as i32))
|
||||
.execute(conn)
|
||||
.map_res("Error confirming invitations")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_any_state_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
users_organizations::table
|
||||
@@ -1103,6 +1161,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 {
|
||||
|
||||
89
src/db/models/sso_nonce.rs
Normal file
89
src/db/models/sso_nonce.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::db::{DbConn, DbPool};
|
||||
use crate::error::MapResult;
|
||||
use crate::sso::{OIDCState, NONCE_EXPIRATION};
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = sso_nonce)]
|
||||
#[diesel(primary_key(state))]
|
||||
pub struct SsoNonce {
|
||||
pub state: OIDCState,
|
||||
pub nonce: String,
|
||||
pub verifier: Option<String>,
|
||||
pub redirect_uri: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl SsoNonce {
|
||||
pub fn new(state: OIDCState, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
SsoNonce {
|
||||
state,
|
||||
nonce,
|
||||
verifier,
|
||||
redirect_uri,
|
||||
created_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
impl SsoNonce {
|
||||
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
diesel::replace_into(sso_nonce::table)
|
||||
.values(SsoNonceDb::to_db(self))
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO nonce")
|
||||
}
|
||||
postgresql {
|
||||
let value = SsoNonceDb::to_db(self);
|
||||
diesel::insert_into(sso_nonce::table)
|
||||
.values(&value)
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO nonce")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(state: &OIDCState, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting SSO nonce")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find(state: &OIDCState, conn: &DbConn) -> Option<Self> {
|
||||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
||||
db_run! { conn: {
|
||||
sso_nonce::table
|
||||
.filter(sso_nonce::state.eq(state))
|
||||
.filter(sso_nonce::created_at.ge(oldest))
|
||||
.first::<SsoNonceDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
|
||||
debug!("Purging expired sso_nonce");
|
||||
if let Ok(conn) = pool.get().await {
|
||||
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
||||
db_run! { conn: {
|
||||
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting expired SSO nonce")
|
||||
}}
|
||||
} else {
|
||||
err!("Failed to get DB connection while purging expired sso_nonce")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,17 @@ use super::{
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
crypto,
|
||||
db::models::DeviceId,
|
||||
db::DbConn,
|
||||
error::MapResult,
|
||||
sso::OIDCIdentifier,
|
||||
util::{format_date, get_uuid, retry},
|
||||
CONFIG,
|
||||
};
|
||||
use macros::UuidFromParam;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||
#[diesel(table_name = users)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
@@ -71,6 +73,14 @@ db_object! {
|
||||
pub struct Invitation {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Insertable, Selectable)]
|
||||
#[diesel(table_name = sso_users)]
|
||||
#[diesel(primary_key(user_uuid))]
|
||||
pub struct SsoUser {
|
||||
pub user_uuid: UserId,
|
||||
pub identifier: OIDCIdentifier,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum UserKdfType {
|
||||
@@ -96,7 +106,7 @@ impl User {
|
||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
||||
|
||||
pub fn new(email: String) -> Self {
|
||||
pub fn new(email: String, name: Option<String>) -> Self {
|
||||
let now = Utc::now().naive_utc();
|
||||
let email = email.to_lowercase();
|
||||
|
||||
@@ -108,7 +118,7 @@ impl User {
|
||||
verified_at: None,
|
||||
last_verifying_at: None,
|
||||
login_verify_count: 0,
|
||||
name: email.clone(),
|
||||
name: name.unwrap_or(email.clone()),
|
||||
email,
|
||||
akey: String::new(),
|
||||
email_new: None,
|
||||
@@ -384,9 +394,28 @@ impl User {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
|
||||
pub async fn find_by_device_id(device_uuid: &DeviceId, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
users::table
|
||||
.inner_join(devices::table.on(devices::user_uuid.eq(users::uuid)))
|
||||
.filter(devices::uuid.eq(device_uuid))
|
||||
.select(users::all_columns)
|
||||
.first::<UserDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn get_all(conn: &mut DbConn) -> Vec<(User, Option<SsoUser>)> {
|
||||
db_run! {conn: {
|
||||
users::table.load::<UserDb>(conn).expect("Error loading users").from_db()
|
||||
users::table
|
||||
.left_join(sso_users::table)
|
||||
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
|
||||
.load(conn)
|
||||
.expect("Error loading groups for user")
|
||||
.into_iter()
|
||||
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
|
||||
.collect()
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -477,3 +506,57 @@ impl Invitation {
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct UserId(String);
|
||||
|
||||
impl SsoUser {
|
||||
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
diesel::replace_into(sso_users::table)
|
||||
.values(SsoUserDb::to_db(self))
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO user")
|
||||
}
|
||||
postgresql {
|
||||
let value = SsoUserDb::to_db(self);
|
||||
diesel::insert_into(sso_users::table)
|
||||
.values(&value)
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_identifier(identifier: &str, conn: &DbConn) -> Option<(User, SsoUser)> {
|
||||
db_run! {conn: {
|
||||
users::table
|
||||
.inner_join(sso_users::table)
|
||||
.select(<(UserDb, SsoUserDb)>::as_select())
|
||||
.filter(sso_users::identifier.eq(identifier))
|
||||
.first::<(UserDb, SsoUserDb)>(conn)
|
||||
.ok()
|
||||
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_by_mail(mail: &str, conn: &DbConn) -> Option<(User, Option<SsoUser>)> {
|
||||
let lower_mail = mail.to_lowercase();
|
||||
|
||||
db_run! {conn: {
|
||||
users::table
|
||||
.left_join(sso_users::table)
|
||||
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
|
||||
.filter(users::email.eq(lower_mail))
|
||||
.first::<(UserDb, Option<SsoUserDb>)>(conn)
|
||||
.ok()
|
||||
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete(user_uuid: &UserId, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! {conn: {
|
||||
diesel::delete(sso_users::table.filter(sso_users::user_uuid.eq(user_uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting sso user")
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ table! {
|
||||
uuid -> Text,
|
||||
user_uuid -> Text,
|
||||
org_uuid -> Text,
|
||||
invited_by_email -> Nullable<Text>,
|
||||
access_all -> Bool,
|
||||
akey -> Text,
|
||||
status -> Integer,
|
||||
@@ -254,6 +255,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (state) {
|
||||
state -> Text,
|
||||
nonce -> Text,
|
||||
verifier -> Nullable<Text>,
|
||||
redirect_uri -> Text,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_users (user_uuid) {
|
||||
user_uuid -> Text,
|
||||
identifier -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
@@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||
joinable!(collections_groups -> groups (groups_uuid));
|
||||
joinable!(event -> users_organizations (uuid));
|
||||
joinable!(auth_requests -> users (user_uuid));
|
||||
joinable!(sso_users -> users (user_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
|
||||
org_policies,
|
||||
organizations,
|
||||
sends,
|
||||
sso_users,
|
||||
twofactor,
|
||||
users,
|
||||
users_collections,
|
||||
|
||||
@@ -235,6 +235,7 @@ table! {
|
||||
uuid -> Text,
|
||||
user_uuid -> Text,
|
||||
org_uuid -> Text,
|
||||
invited_by_email -> Nullable<Text>,
|
||||
access_all -> Bool,
|
||||
akey -> Text,
|
||||
status -> Integer,
|
||||
@@ -254,6 +255,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (state) {
|
||||
state -> Text,
|
||||
nonce -> Text,
|
||||
verifier -> Nullable<Text>,
|
||||
redirect_uri -> Text,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_users (user_uuid) {
|
||||
user_uuid -> Text,
|
||||
identifier -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
@@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||
joinable!(collections_groups -> groups (groups_uuid));
|
||||
joinable!(event -> users_organizations (uuid));
|
||||
joinable!(auth_requests -> users (user_uuid));
|
||||
joinable!(sso_users -> users (user_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
|
||||
org_policies,
|
||||
organizations,
|
||||
sends,
|
||||
sso_users,
|
||||
twofactor,
|
||||
users,
|
||||
users_collections,
|
||||
|
||||
@@ -235,6 +235,7 @@ table! {
|
||||
uuid -> Text,
|
||||
user_uuid -> Text,
|
||||
org_uuid -> Text,
|
||||
invited_by_email -> Nullable<Text>,
|
||||
access_all -> Bool,
|
||||
akey -> Text,
|
||||
status -> Integer,
|
||||
@@ -254,6 +255,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (state) {
|
||||
state -> Text,
|
||||
nonce -> Text,
|
||||
verifier -> Nullable<Text>,
|
||||
redirect_uri -> Text,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_users (user_uuid) {
|
||||
user_uuid -> Text,
|
||||
identifier -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
@@ -348,6 +366,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||
joinable!(collections_groups -> groups (groups_uuid));
|
||||
joinable!(event -> users_organizations (uuid));
|
||||
joinable!(auth_requests -> users (user_uuid));
|
||||
joinable!(sso_users -> users (user_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@@ -361,6 +380,7 @@ allow_tables_to_appear_in_same_query!(
|
||||
org_policies,
|
||||
organizations,
|
||||
sends,
|
||||
sso_users,
|
||||
twofactor,
|
||||
users,
|
||||
users_collections,
|
||||
|
||||
10
src/error.rs
10
src/error.rs
@@ -159,6 +159,10 @@ impl Error {
|
||||
pub fn get_event(&self) -> &Option<ErrorEvent> {
|
||||
&self.event
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
pub trait MapResult<S> {
|
||||
@@ -278,9 +282,15 @@ macro_rules! err_silent {
|
||||
($msg:expr) => {{
|
||||
return Err($crate::error::Error::new($msg, $msg));
|
||||
}};
|
||||
($msg:expr, ErrorEvent $err_event:tt) => {{
|
||||
return Err($crate::error::Error::new($msg, $msg).with_event($crate::error::ErrorEvent $err_event));
|
||||
}};
|
||||
($usr_msg:expr, $log_value:expr) => {{
|
||||
return Err($crate::error::Error::new($usr_msg, $log_value));
|
||||
}};
|
||||
($usr_msg:expr, $log_value:expr, ErrorEvent $err_event:tt) => {{
|
||||
return Err($crate::error::Error::new($usr_msg, $log_value).with_event($crate::error::ErrorEvent $err_event));
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
|
||||
18
src/mail.rs
18
src/mail.rs
@@ -301,7 +301,11 @@ pub async fn send_invite(
|
||||
.append_pair("organizationId", &org_id)
|
||||
.append_pair("organizationUserId", &member_id)
|
||||
.append_pair("token", &invite_token);
|
||||
if user.private_key.is_some() {
|
||||
|
||||
if CONFIG.sso_enabled() && CONFIG.sso_only() {
|
||||
query_params.append_pair("orgUserHasExistingUser", "false");
|
||||
query_params.append_pair("orgSsoIdentifier", org_name);
|
||||
} else if user.private_key.is_some() {
|
||||
query_params.append_pair("orgUserHasExistingUser", "true");
|
||||
}
|
||||
}
|
||||
@@ -584,6 +588,18 @@ pub async fn send_change_email_existing(address: &str, acting_address: &str) ->
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_sso_change_email(address: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/sso_change_email",
|
||||
json!({
|
||||
"url": format!("{}/#/settings/account", CONFIG.domain()),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
}),
|
||||
)?;
|
||||
|
||||
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",
|
||||
|
||||
@@ -56,6 +56,8 @@ mod db;
|
||||
mod http_client;
|
||||
mod mail;
|
||||
mod ratelimit;
|
||||
mod sso;
|
||||
mod sso_client;
|
||||
mod util;
|
||||
|
||||
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
|
||||
@@ -711,6 +713,13 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||
}));
|
||||
}
|
||||
|
||||
// Purge sso nonce from incomplete flow (default to daily at 00h20).
|
||||
if !CONFIG.purge_incomplete_sso_nonce().is_empty() {
|
||||
sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || {
|
||||
runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone()));
|
||||
}));
|
||||
}
|
||||
|
||||
// Periodically check for jobs to run. We probably won't need any
|
||||
// jobs that run more often than once a minute, so a default poll
|
||||
// interval of 30 seconds should be sufficient. Users who want to
|
||||
|
||||
462
src/sso.rs
Normal file
462
src/sso.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
use chrono::Utc;
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use regex::Regex;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
use mini_moka::sync::Cache;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::{
|
||||
api::ApiResult,
|
||||
auth,
|
||||
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
|
||||
db::{
|
||||
models::{Device, SsoNonce, User},
|
||||
DbConn,
|
||||
},
|
||||
sso_client::Client,
|
||||
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());
|
||||
|
||||
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin()));
|
||||
|
||||
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
DieselNewType,
|
||||
FromForm,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
AsRef,
|
||||
Deref,
|
||||
Display,
|
||||
From,
|
||||
)]
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct OIDCCode(String);
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
DieselNewType,
|
||||
FromForm,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
AsRef,
|
||||
Deref,
|
||||
Display,
|
||||
From,
|
||||
)]
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct OIDCState(String);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SsoTokenJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
pub fn encode_ssotoken_claims() -> String {
|
||||
let time_now = Utc::now();
|
||||
let claims = SsoTokenJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||
iss: SSO_JWT_ISSUER.to_string(),
|
||||
sub: "vaultwarden".to_string(),
|
||||
};
|
||||
|
||||
auth::encode_jwt(&claims)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum OIDCCodeWrapper {
|
||||
Ok {
|
||||
state: OIDCState,
|
||||
code: OIDCCode,
|
||||
},
|
||||
Error {
|
||||
state: OIDCState,
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct OIDCCodeClaims {
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
|
||||
pub code: OIDCCodeWrapper,
|
||||
}
|
||||
|
||||
pub fn encode_code_claims(code: OIDCCodeWrapper) -> String {
|
||||
let time_now = Utc::now();
|
||||
let claims = OIDCCodeClaims {
|
||||
exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
||||
iss: SSO_JWT_ISSUER.to_string(),
|
||||
code,
|
||||
};
|
||||
|
||||
auth::encode_jwt(&claims)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct BasicTokenClaims {
|
||||
iat: Option<i64>,
|
||||
nbf: Option<i64>,
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
impl BasicTokenClaims {
|
||||
fn nbf(&self) -> i64 {
|
||||
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp())
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
|
||||
let mut validation = jsonwebtoken::Validation::default();
|
||||
validation.set_issuer(&[CONFIG.sso_authority()]);
|
||||
validation.insecure_disable_signature_validation();
|
||||
validation.validate_aud = false;
|
||||
|
||||
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) {
|
||||
Ok(btc) => Ok(btc.claims),
|
||||
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deocde_state(base64_state: String) -> ApiResult<OIDCState> {
|
||||
let state = match data_encoding::BASE64.decode(base64_state.as_bytes()) {
|
||||
Ok(vec) => match String::from_utf8(vec) {
|
||||
Ok(valid) => OIDCState(valid),
|
||||
Err(_) => err!(format!("Invalid utf8 chars in {base64_state} after base64 decoding")),
|
||||
},
|
||||
Err(_) => err!(format!("Failed to decode {base64_state} using base64")),
|
||||
};
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
// The `nonce` allow to protect against replay attacks
|
||||
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
|
||||
pub async fn authorize_url(
|
||||
state: OIDCState,
|
||||
client_id: &str,
|
||||
raw_redirect_uri: &str,
|
||||
mut conn: DbConn,
|
||||
) -> ApiResult<Url> {
|
||||
let redirect_uri = match client_id {
|
||||
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
|
||||
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
|
||||
"cli" => {
|
||||
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap();
|
||||
match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) {
|
||||
Some(port) => format!("http://localhost:{port}"),
|
||||
None => err!("Failed to extract port number"),
|
||||
}
|
||||
}
|
||||
_ => err!(format!("Unsupported client {client_id}")),
|
||||
};
|
||||
|
||||
let (auth_url, nonce) = Client::authorize_url(state, redirect_uri).await?;
|
||||
nonce.save(&mut conn).await?;
|
||||
Ok(auth_url)
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
DieselNewType,
|
||||
FromForm,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
AsRef,
|
||||
Deref,
|
||||
Display,
|
||||
From,
|
||||
)]
|
||||
#[deref(forward)]
|
||||
#[from(forward)]
|
||||
pub struct OIDCIdentifier(String);
|
||||
|
||||
impl OIDCIdentifier {
|
||||
fn new(issuer: &str, subject: &str) -> Self {
|
||||
OIDCIdentifier(format!("{issuer}/{subject}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub refresh_token: Option<String>,
|
||||
pub access_token: String,
|
||||
pub expires_in: Option<Duration>,
|
||||
pub identifier: OIDCIdentifier,
|
||||
pub email: String,
|
||||
pub email_verified: Option<bool>,
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UserInformation {
|
||||
pub state: OIDCState,
|
||||
pub identifier: OIDCIdentifier,
|
||||
pub email: String,
|
||||
pub email_verified: Option<bool>,
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
|
||||
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
|
||||
Ok(code_claims) => match code_claims.code {
|
||||
OIDCCodeWrapper::Ok {
|
||||
state,
|
||||
code,
|
||||
} => Ok((code, state)),
|
||||
OIDCCodeWrapper::Error {
|
||||
state,
|
||||
error,
|
||||
error_description,
|
||||
} => {
|
||||
if let Err(err) = SsoNonce::delete(&state, conn).await {
|
||||
error!("Failed to delete database sso_nonce using {state}: {err}")
|
||||
}
|
||||
err!(format!(
|
||||
"SSO authorization failed: {error}, {}",
|
||||
error_description.as_ref().unwrap_or(&String::new())
|
||||
))
|
||||
}
|
||||
},
|
||||
Err(err) => err!(format!("Failed to decode code wrapper: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
// During the 2FA flow we will
|
||||
// - retrieve the user information and then only discover he needs 2FA.
|
||||
// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged.
|
||||
// The `nonce` will ensure that the user is authorized only once.
|
||||
// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`.
|
||||
pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<UserInformation> {
|
||||
use openidconnect::OAuth2TokenResponse;
|
||||
|
||||
let (code, state) = decode_code_claims(wrapped_code, conn).await?;
|
||||
|
||||
if let Some(authenticated_user) = AC_CACHE.get(&state) {
|
||||
return Ok(UserInformation {
|
||||
state,
|
||||
identifier: authenticated_user.identifier,
|
||||
email: authenticated_user.email,
|
||||
email_verified: authenticated_user.email_verified,
|
||||
user_name: authenticated_user.user_name,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce = match SsoNonce::find(&state, conn).await {
|
||||
None => err!(format!("Invalid state cannot retrieve nonce")),
|
||||
Some(nonce) => nonce,
|
||||
};
|
||||
|
||||
let client = Client::cached().await?;
|
||||
let (token_response, id_claims) = client.exchange_code(code, nonce).await?;
|
||||
|
||||
let user_info = client.user_info(token_response.access_token().to_owned()).await?;
|
||||
|
||||
let email = match id_claims.email().or(user_info.email()) {
|
||||
None => err!("Neither id token nor userinfo contained an email"),
|
||||
Some(e) => e.to_string().to_lowercase(),
|
||||
};
|
||||
|
||||
let email_verified = id_claims.email_verified().or(user_info.email_verified());
|
||||
|
||||
let user_name = id_claims.preferred_username().map(|un| un.to_string());
|
||||
|
||||
let refresh_token = token_response.refresh_token().map(|t| t.secret());
|
||||
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {
|
||||
error!("Scope offline_access is present but response contain no refresh_token");
|
||||
}
|
||||
|
||||
let identifier = OIDCIdentifier::new(id_claims.issuer(), id_claims.subject());
|
||||
|
||||
let authenticated_user = AuthenticatedUser {
|
||||
refresh_token: refresh_token.cloned(),
|
||||
access_token: token_response.access_token().secret().clone(),
|
||||
expires_in: token_response.expires_in(),
|
||||
identifier: identifier.clone(),
|
||||
email: email.clone(),
|
||||
email_verified,
|
||||
user_name: user_name.clone(),
|
||||
};
|
||||
|
||||
debug!("Authentified user {authenticated_user:?}");
|
||||
|
||||
AC_CACHE.insert(state.clone(), authenticated_user);
|
||||
|
||||
Ok(UserInformation {
|
||||
state,
|
||||
identifier,
|
||||
email,
|
||||
email_verified,
|
||||
user_name,
|
||||
})
|
||||
}
|
||||
|
||||
// User has passed 2FA flow we can delete `nonce` and clear the cache.
|
||||
pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> {
|
||||
if let Err(err) = SsoNonce::delete(state, conn).await {
|
||||
error!("Failed to delete database sso_nonce using {state}: {err}")
|
||||
}
|
||||
|
||||
if let Some(au) = AC_CACHE.get(state) {
|
||||
AC_CACHE.invalidate(state);
|
||||
Ok(au)
|
||||
} else {
|
||||
err!("Failed to retrieve user info from sso cache")
|
||||
}
|
||||
}
|
||||
|
||||
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front).
|
||||
// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity
|
||||
pub fn create_auth_tokens(
|
||||
device: &Device,
|
||||
user: &User,
|
||||
client_id: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
access_token: String,
|
||||
expires_in: Option<Duration>,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
if !CONFIG.sso_auth_only_not_session() {
|
||||
let now = Utc::now();
|
||||
|
||||
let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) {
|
||||
(Ok(ap), _) => (ap.nbf(), ap.exp),
|
||||
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()),
|
||||
_ => err!("Non jwt access_token and empty expires_in"),
|
||||
};
|
||||
|
||||
let access_claims =
|
||||
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now);
|
||||
|
||||
_create_auth_tokens(device, refresh_token, access_claims, access_token)
|
||||
} else {
|
||||
Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn _create_auth_tokens(
|
||||
device: &Device,
|
||||
refresh_token: Option<String>,
|
||||
access_claims: auth::LoginJwtClaims,
|
||||
access_token: String,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
let (nbf, exp, token) = if let Some(rt) = refresh_token {
|
||||
match decode_token_claims("refresh_token", &rt) {
|
||||
Err(_) => {
|
||||
let time_now = Utc::now();
|
||||
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp();
|
||||
debug!("Non jwt refresh_token (expiration set to {exp})");
|
||||
(time_now.timestamp(), exp, TokenWrapper::Refresh(rt))
|
||||
}
|
||||
Ok(refresh_payload) => {
|
||||
debug!("Refresh_payload: {refresh_payload:?}");
|
||||
(refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("No refresh_token present");
|
||||
(access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token))
|
||||
};
|
||||
|
||||
let refresh_claims = auth::RefreshJwtClaims {
|
||||
nbf,
|
||||
exp,
|
||||
iss: auth::JWT_LOGIN_ISSUER.to_string(),
|
||||
sub: AuthMethod::Sso,
|
||||
device_token: device.refresh_token.clone(),
|
||||
token: Some(token),
|
||||
};
|
||||
|
||||
Ok(AuthTokens {
|
||||
refresh_claims,
|
||||
access_claims,
|
||||
})
|
||||
}
|
||||
|
||||
// This endpoint is called in two case
|
||||
// - the session is close to expiration we will try to extend it
|
||||
// - the user is going to make an action and we check that the session is still valid
|
||||
pub async fn exchange_refresh_token(
|
||||
device: &Device,
|
||||
user: &User,
|
||||
client_id: Option<String>,
|
||||
refresh_claims: auth::RefreshJwtClaims,
|
||||
) -> ApiResult<AuthTokens> {
|
||||
let exp = refresh_claims.exp;
|
||||
match refresh_claims.token {
|
||||
Some(TokenWrapper::Refresh(refresh_token)) => {
|
||||
// Use new refresh_token if returned
|
||||
let (new_refresh_token, access_token, expires_in) =
|
||||
Client::exchange_refresh_token(refresh_token.clone()).await?;
|
||||
|
||||
create_auth_tokens(
|
||||
device,
|
||||
user,
|
||||
client_id,
|
||||
new_refresh_token.or(Some(refresh_token)),
|
||||
access_token,
|
||||
expires_in,
|
||||
)
|
||||
}
|
||||
Some(TokenWrapper::Access(access_token)) => {
|
||||
let now = Utc::now();
|
||||
let exp_limit = (now + *BW_EXPIRATION).timestamp();
|
||||
|
||||
if exp < exp_limit {
|
||||
err_silent!("Access token is close to expiration but we have no refresh token")
|
||||
}
|
||||
|
||||
Client::check_validaty(access_token.clone()).await?;
|
||||
|
||||
let access_claims = auth::LoginJwtClaims::new(
|
||||
device,
|
||||
user,
|
||||
now.timestamp(),
|
||||
exp,
|
||||
AuthMethod::Sso.scope_vec(),
|
||||
client_id,
|
||||
now,
|
||||
);
|
||||
|
||||
_create_auth_tokens(device, None, access_claims, access_token)
|
||||
}
|
||||
None => err!("No token present while in SSO"),
|
||||
}
|
||||
}
|
||||
264
src/sso_client.rs
Normal file
264
src/sso_client.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
use mini_moka::sync::Cache;
|
||||
use once_cell::sync::Lazy;
|
||||
use openidconnect::core::*;
|
||||
use openidconnect::reqwest;
|
||||
use openidconnect::*;
|
||||
|
||||
use crate::{
|
||||
api::{ApiResult, EmptyResult},
|
||||
db::models::SsoNonce,
|
||||
sso::{OIDCCode, OIDCState},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
static CLIENT_CACHE_KEY: Lazy<String> = Lazy::new(|| "sso-client".to_string());
|
||||
static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| {
|
||||
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
|
||||
});
|
||||
|
||||
/// OpenID Connect Core client.
|
||||
pub type CustomClient = openidconnect::Client<
|
||||
EmptyAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
CoreTokenResponse,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointSet,
|
||||
EndpointSet,
|
||||
>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
pub http_client: reqwest::Client,
|
||||
pub core_client: CustomClient,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
// Call the OpenId discovery endpoint to retrieve configuration
|
||||
async fn _get_client() -> ApiResult<Self> {
|
||||
let client_id = ClientId::new(CONFIG.sso_client_id());
|
||||
let client_secret = ClientSecret::new(CONFIG.sso_client_secret());
|
||||
|
||||
let issuer_url = CONFIG.sso_issuer_url()?;
|
||||
|
||||
let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() {
|
||||
Err(err) => err!(format!("Failed to build http client: {err}")),
|
||||
Ok(client) => client,
|
||||
};
|
||||
|
||||
let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, &http_client).await {
|
||||
Err(err) => err!(format!("Failed to discover OpenID provider: {err}")),
|
||||
Ok(metadata) => metadata,
|
||||
};
|
||||
|
||||
let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
|
||||
|
||||
let token_uri = match base_client.token_uri() {
|
||||
Some(uri) => uri.clone(),
|
||||
None => err!("Failed to discover token_url, cannot proceed"),
|
||||
};
|
||||
|
||||
let user_info_url = match base_client.user_info_url() {
|
||||
Some(url) => url.clone(),
|
||||
None => err!("Failed to discover user_info url, cannot proceed"),
|
||||
};
|
||||
|
||||
let core_client = base_client
|
||||
.set_redirect_uri(CONFIG.sso_redirect_url()?)
|
||||
.set_token_uri(token_uri)
|
||||
.set_user_info_url(user_info_url);
|
||||
|
||||
Ok(Client {
|
||||
http_client,
|
||||
core_client,
|
||||
})
|
||||
}
|
||||
|
||||
// Simple cache to prevent recalling the discovery endpoint each time
|
||||
pub async fn cached() -> ApiResult<Self> {
|
||||
if CONFIG.sso_client_cache_expiration() > 0 {
|
||||
match CLIENT_CACHE.get(&*CLIENT_CACHE_KEY) {
|
||||
Some(client) => Ok(client),
|
||||
None => Self::_get_client().await.inspect(|client| {
|
||||
debug!("Inserting new client in cache");
|
||||
CLIENT_CACHE.insert(CLIENT_CACHE_KEY.clone(), client.clone());
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Self::_get_client().await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate() {
|
||||
if CONFIG.sso_client_cache_expiration() > 0 {
|
||||
CLIENT_CACHE.invalidate(&*CLIENT_CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// The `state` is encoded using base64 to ensure no issue with providers (It contains the Organization identifier).
|
||||
pub async fn authorize_url(state: OIDCState, redirect_uri: String) -> ApiResult<(Url, SsoNonce)> {
|
||||
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
|
||||
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
|
||||
|
||||
let client = Self::cached().await?;
|
||||
let mut auth_req = client
|
||||
.core_client
|
||||
.authorize_url(
|
||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||
|| CsrfToken::new(base64_state),
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scopes(scopes)
|
||||
.add_extra_params(CONFIG.sso_authorize_extra_params_vec());
|
||||
|
||||
let verifier = if CONFIG.sso_pkce() {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
auth_req = auth_req.set_pkce_challenge(pkce_challenge);
|
||||
Some(pkce_verifier.into_secret())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (auth_url, _, nonce) = auth_req.url();
|
||||
Ok((auth_url, SsoNonce::new(state, nonce.secret().clone(), verifier, redirect_uri)))
|
||||
}
|
||||
|
||||
pub async fn exchange_code(
|
||||
&self,
|
||||
code: OIDCCode,
|
||||
nonce: SsoNonce,
|
||||
) -> ApiResult<(
|
||||
StandardTokenResponse<
|
||||
IdTokenFields<
|
||||
EmptyAdditionalClaims,
|
||||
EmptyExtraTokenFields,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJwsSigningAlgorithm,
|
||||
>,
|
||||
CoreTokenType,
|
||||
>,
|
||||
IdTokenClaims<EmptyAdditionalClaims, CoreGenderClaim>,
|
||||
)> {
|
||||
let oidc_code = AuthorizationCode::new(code.to_string());
|
||||
|
||||
let mut exchange = self.core_client.exchange_code(oidc_code);
|
||||
|
||||
if CONFIG.sso_pkce() {
|
||||
match nonce.verifier {
|
||||
None => err!(format!("Missing verifier in the DB nonce table")),
|
||||
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
match exchange.request_async(&self.http_client).await {
|
||||
Err(err) => err!(format!("Failed to contact token endpoint: {:?}", err)),
|
||||
Ok(token_response) => {
|
||||
let oidc_nonce = Nonce::new(nonce.nonce);
|
||||
|
||||
let id_token = match token_response.extra_fields().id_token() {
|
||||
None => err!("Token response did not contain an id_token"),
|
||||
Some(token) => token,
|
||||
};
|
||||
|
||||
if CONFIG.sso_debug_tokens() {
|
||||
debug!("Id token: {}", id_token.to_string());
|
||||
debug!("Access token: {}", token_response.access_token().secret());
|
||||
debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret()));
|
||||
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||
}
|
||||
|
||||
let id_claims = match id_token.claims(&self.vw_id_token_verifier(), &oidc_nonce) {
|
||||
Ok(claims) => claims.clone(),
|
||||
Err(err) => {
|
||||
Self::invalidate();
|
||||
err!(format!("Could not read id_token claims, {err}"));
|
||||
}
|
||||
};
|
||||
|
||||
Ok((token_response, id_claims))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_info(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> {
|
||||
match self.core_client.user_info(access_token, None).request_async(&self.http_client).await {
|
||||
Err(err) => err!(format!("Request to user_info endpoint failed: {err}")),
|
||||
Ok(user_info) => Ok(user_info),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_validaty(access_token: String) -> EmptyResult {
|
||||
let client = Client::cached().await?;
|
||||
match client.user_info(AccessToken::new(access_token)).await {
|
||||
Err(err) => {
|
||||
err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}"))
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> {
|
||||
let mut verifier = self.core_client.id_token_verifier();
|
||||
if let Some(regex_str) = CONFIG.sso_audience_trusted() {
|
||||
match Regex::new(®ex_str) {
|
||||
Ok(regex) => {
|
||||
verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud));
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
verifier
|
||||
}
|
||||
|
||||
pub async fn exchange_refresh_token(
|
||||
refresh_token: String,
|
||||
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
|
||||
let rt = RefreshToken::new(refresh_token);
|
||||
|
||||
let client = Client::cached().await?;
|
||||
let token_response =
|
||||
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await {
|
||||
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
|
||||
Ok(token_response) => token_response,
|
||||
};
|
||||
|
||||
Ok((
|
||||
token_response.refresh_token().map(|token| token.secret().clone()),
|
||||
token_response.access_token().secret().clone(),
|
||||
token_response.expires_in(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
trait AuthorizationRequestExt<'a> {
|
||||
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(self, params: Vec<(N, V)>) -> Self;
|
||||
}
|
||||
|
||||
impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a>
|
||||
for AuthorizationRequest<'a, AD, P, RT>
|
||||
{
|
||||
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(mut self, params: Vec<(N, V)>) -> Self {
|
||||
for (key, value) in params {
|
||||
self = self.add_extra_param(key, value);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
14
src/static/scripts/admin.js
vendored
14
src/static/scripts/admin.js
vendored
@@ -28,11 +28,11 @@ function msg(text, reload_page = true) {
|
||||
reload_page && reload();
|
||||
}
|
||||
|
||||
function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
function _fetch(method, url, successMsg, errMsg, body, reload_page = true) {
|
||||
let respStatus;
|
||||
let respStatusText;
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
method: method,
|
||||
body: body,
|
||||
mode: "same-origin",
|
||||
credentials: "same-origin",
|
||||
@@ -65,6 +65,14 @@ function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
});
|
||||
}
|
||||
|
||||
function _post(url, successMsg, errMsg, body, reload_page = true) {
|
||||
return _fetch("POST", url, successMsg, errMsg, body, reload_page);
|
||||
}
|
||||
|
||||
function _delete(url, successMsg, errMsg, body, reload_page = true) {
|
||||
return _fetch("DELETE", url, successMsg, errMsg, body, reload_page);
|
||||
}
|
||||
|
||||
// Bootstrap Theme Selector
|
||||
const getStoredTheme = () => localStorage.getItem("theme");
|
||||
const setStoredTheme = theme => localStorage.setItem("theme", theme);
|
||||
@@ -146,4 +154,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||
navItem[0].className = navItem[0].className + " active";
|
||||
navItem[0].setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
33
src/static/scripts/admin_users.js
vendored
33
src/static/scripts/admin_users.js
vendored
@@ -24,6 +24,28 @@ function deleteUser(event) {
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSSOUser(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const id = event.target.parentNode.dataset.vwUserUuid;
|
||||
const email = event.target.parentNode.dataset.vwUserEmail;
|
||||
if (!id || !email) {
|
||||
alert("Required parameters not found!");
|
||||
return false;
|
||||
}
|
||||
const input_email = prompt(`To delete user "${email}", please type the email below`);
|
||||
if (input_email != null) {
|
||||
if (input_email == email) {
|
||||
_delete(`${BASE_URL}/admin/users/${id}/sso`,
|
||||
"User SSO Associtation deleted correctly",
|
||||
"Error deleting user SSO association"
|
||||
);
|
||||
} else {
|
||||
alert("Wrong email, please try again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remove2fa(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -246,6 +268,9 @@ function initUserTable() {
|
||||
document.querySelectorAll("button[vw-delete-user]").forEach(btn => {
|
||||
btn.addEventListener("click", deleteUser);
|
||||
});
|
||||
document.querySelectorAll("button[vw-delete-sso-user]").forEach(btn => {
|
||||
btn.addEventListener("click", deleteSSOUser);
|
||||
});
|
||||
document.querySelectorAll("button[vw-disable-user]").forEach(btn => {
|
||||
btn.addEventListener("click", disableUser);
|
||||
});
|
||||
@@ -263,6 +288,8 @@ function initUserTable() {
|
||||
|
||||
// onLoad events
|
||||
document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||
const size = jQuery("#users-table > thead th").length;
|
||||
const ssoOffset = size-7;
|
||||
jQuery("#users-table").DataTable({
|
||||
"drawCallback": function() {
|
||||
initUserTable();
|
||||
@@ -275,10 +302,10 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||
],
|
||||
"pageLength": -1, // Default show all
|
||||
"columnDefs": [{
|
||||
"targets": [1, 2],
|
||||
"targets": [1 + ssoOffset, 2 + ssoOffset],
|
||||
"type": "date-iso"
|
||||
}, {
|
||||
"targets": 6,
|
||||
"targets": size-1,
|
||||
"searchable": false,
|
||||
"orderable": false
|
||||
}]
|
||||
@@ -303,4 +330,4 @@ document.addEventListener("DOMContentLoaded", (/*event*/) => {
|
||||
if (btnInviteUserForm) {
|
||||
btnInviteUserForm.addEventListener("submit", inviteUser);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="vw-account-details">User</th>
|
||||
{{#if sso_enabled}}
|
||||
<th class="vw-sso-identifier">SSO Identifier</th>
|
||||
{{/if}}
|
||||
<th class="vw-created-at">Created at</th>
|
||||
<th class="vw-last-active">Last Active</th>
|
||||
<th class="vw-entries">Entries</th>
|
||||
@@ -38,6 +41,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{{#if ../sso_enabled}}
|
||||
<td>
|
||||
<span class="d-block">{{sso_identifier}}</span>
|
||||
</td>
|
||||
{{/if}}
|
||||
<td>
|
||||
<span class="d-block">{{created_at}}</span>
|
||||
</td>
|
||||
@@ -67,6 +75,9 @@
|
||||
{{/if}}
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-deauth-user>Deauthorize sessions</button><br>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-user>Delete User</button><br>
|
||||
{{#if ../sso_enabled}}
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-delete-sso-user>Delete SSO Association</button><br>
|
||||
{{/if}}
|
||||
{{#if user_enabled}}
|
||||
<button type="button" class="btn btn-sm btn-link p-0 border-0 float-right" vw-disable-user>Disable User</button><br>
|
||||
{{else}}
|
||||
|
||||
@@ -9,7 +9,7 @@ Join {{{org_name}}}
|
||||
</tr>
|
||||
<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; text-align: center;" valign="top" align="center">
|
||||
<a href="{{{url}}}"
|
||||
<a data-testid="invite" href="{{{url}}}"
|
||||
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Join Organization Now
|
||||
</a>
|
||||
|
||||
4
src/static/templates/email/sso_change_email.hbs
Normal file
4
src/static/templates/email/sso_change_email.hbs
Normal file
@@ -0,0 +1,4 @@
|
||||
Your Email Changed
|
||||
<!---------------->
|
||||
Your email was changed in your SSO Provider. Please update your email in Account Settings ({{url}}).
|
||||
{{> email/email_footer_text }}
|
||||
11
src/static/templates/email/sso_change_email.html.hbs
Normal file
11
src/static/templates/email/sso_change_email.html.hbs
Normal file
@@ -0,0 +1,11 @@
|
||||
Your Email 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; text-align: center;" valign="top" align="center">
|
||||
Your email was changed in your SSO Provider. Please update your email in <a href="{{url}}/">Account Settings</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
||||
@@ -4,7 +4,7 @@ Vaultwarden Login Verification Code
|
||||
<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">
|
||||
Your two-step verification code is: <b>{{token}}</b>
|
||||
Your two-step verification code is: <b data-testid="2fa">{{token}}</b>
|
||||
</td>
|
||||
</tr>
|
||||
<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;">
|
||||
|
||||
@@ -9,7 +9,7 @@ Verify Your Email
|
||||
</tr>
|
||||
<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; text-align: center;" valign="top" align="center">
|
||||
<a href="{{url}}/#/verify-email/?userId={{user_id}}&token={{token}}"
|
||||
<a data-testid="verify" href="{{url}}/#/verify-email/?userId={{user_id}}&token={{token}}"
|
||||
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
Verify Email Address Now
|
||||
</a>
|
||||
|
||||
@@ -21,15 +21,33 @@ a[href$="/settings/sponsored-families"] {
|
||||
}
|
||||
|
||||
/* Hide the sso `Email` input field */
|
||||
{{#if sso_disabled}}
|
||||
.vw-email-sso {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the default/continue `Email` input field */
|
||||
{{#if (not sso_disabled)}}
|
||||
.vw-email-continue {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Continue` button on the login page */
|
||||
{{#if (not sso_disabled)}}
|
||||
.vw-continue-login {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Enterprise Single Sign-On` button on the login page */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
{{#if sso_disabled}}
|
||||
.vw-sso-login {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(4) {
|
||||
@extend %vw-hide;
|
||||
@@ -53,9 +71,11 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
|
||||
|
||||
/* Hide the or text followed by the two buttons hidden above */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
{{#if (or sso_disabled sso_only)}}
|
||||
.vw-or-text {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
|
||||
@extend %vw-hide;
|
||||
@@ -63,9 +83,11 @@ app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-chi
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Other` button on the login page */
|
||||
{{#if (or sso_disabled sso_only)}}
|
||||
.vw-other-login {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide Two-Factor menu in Organization settings */
|
||||
bit-nav-item[route="settings/two-factor"],
|
||||
|
||||
@@ -151,9 +151,12 @@ impl Cors {
|
||||
// If a match exists, return it. Otherwise, return None.
|
||||
fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> {
|
||||
let origin = Cors::get_header(headers, "Origin");
|
||||
let domain_origin = CONFIG.domain_origin();
|
||||
let safari_extension_origin = "file://";
|
||||
if origin == domain_origin || origin == safari_extension_origin {
|
||||
|
||||
if origin == CONFIG.domain_origin()
|
||||
|| origin == safari_extension_origin
|
||||
|| (CONFIG.sso_enabled() && origin == CONFIG.sso_authority())
|
||||
{
|
||||
Some(origin)
|
||||
} else {
|
||||
None
|
||||
|
||||
Reference in New Issue
Block a user