mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-31 10:18:19 +02: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) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user