Browse Source

Merge 4fffa0ead6 into 904b80115d

pull/7422/merge
Tom 1 week ago
committed by GitHub
parent
commit
428f2b1c2b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .env.template
  2. 52
      src/api/core/accounts.rs
  3. 14
      src/api/core/organizations.rs
  4. 2
      src/api/identity.rs
  5. 6
      src/config.rs
  6. 53
      src/sso.rs

4
.env.template

@ -542,6 +542,10 @@
## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover. ## Allow unknown email verification status. Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
# SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false # SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false
## Automatically add users on their first SSO sign-in as accepted members of this organization.
## An administrator must confirm them and assign collections or groups. No invitation email is sent.
# SSO_DEFAULT_ORGANIZATION_UUID=00000000-0000-0000-0000-000000000000
## Base URL of the OIDC server (auto-discovery is used) ## Base URL of the OIDC server (auto-discovery is used)
## - Should not include the `/.well-known/openid-configuration` part and no trailing `/` ## - Should not include the `/.well-known/openid-configuration` part and no trailing `/`
## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse ## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse

52
src/api/core/accounts.rs

@ -22,8 +22,8 @@ use crate::{
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, MembershipId, MembershipStatus, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User,
UserKdfType, UserId, UserKdfType,
}, },
}, },
mail, mail,
@ -445,6 +445,12 @@ pub async fn register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult { async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: SetPasswordData = data.into_inner(); let data: SetPasswordData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
let default_org_id = crate::sso::default_organization_id()?;
let enroll_in_default_organization = matches!(
(data.org_identifier.as_deref(), default_org_id.as_ref()),
(Some(identifier), Some(org_id))
if identifier == crate::sso::FAKE_SSO_IDENTIFIER || identifier == org_id.as_ref()
);
if user.private_key.is_some() || !user.password_hash.is_empty() { if user.private_key.is_some() || !user.password_hash.is_empty() {
err!("Account already initialized, cannot set password") err!("Account already initialized, cannot set password")
@ -473,6 +479,7 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
} }
if let Some(identifier) = data.org_identifier if let Some(identifier) = data.org_identifier
&& !enroll_in_default_organization
&& identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::sso::FAKE_SSO_IDENTIFIER
&& identifier != crate::api::admin::FAKE_ADMIN_UUID && identifier != crate::api::admin::FAKE_ADMIN_UUID
{ {
@ -487,21 +494,56 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
accept_org_invite(&user, membership, None, &conn).await?; accept_org_invite(&user, membership, None, &conn).await?;
} }
if CONFIG.mail_enabled() { let mail_enabled = CONFIG.mail_enabled();
if mail_enabled {
mail::send_welcome(&user.email.to_lowercase()).await?; mail::send_welcome(&user.email.to_lowercase()).await?;
} else {
Membership::accept_user_invitations(&user.uuid, &conn).await?;
} }
log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await;
user.save(&conn).await?; user.save(&conn).await?;
if enroll_in_default_organization && let Some(org_id) = default_org_id {
accept_sso_default_organization_invite(&user, &org_id, &conn).await?;
}
// Accept any remaining invitations only after the default organization policy checks above.
if !mail_enabled {
Membership::accept_user_invitations(&user.uuid, &conn).await?;
}
Ok(Json(json!({ Ok(Json(json!({
"object": "set-password", "object": "set-password",
}))) })))
} }
async fn accept_sso_default_organization_invite(user: &User, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let Some(mut membership) = Membership::find_by_user_and_org(&user.uuid, org_id, conn).await else {
err!("Failed to retrieve the default organization invitation")
};
if membership.status != MembershipStatus::Invited as i32 {
return Ok(());
}
let Some(org) = Organization::find_by_uuid(org_id, conn).await else {
err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist")
};
membership.status = MembershipStatus::Accepted as i32;
OrgPolicy::check_user_allowed(&membership, "join", conn).await?;
membership.save(conn).await?;
if CONFIG.mail_enabled() {
let address = membership.invited_by_email.unwrap_or(org.billing_email);
if let Err(e) = mail::send_invite_accepted(&user.email, &address, &org.name).await {
error!("Error sending default organization enrollment notification: {e:#?}");
}
}
info!("Added SSO user {} to default organization {} pending confirmation", user.uuid, org_id);
Ok(())
}
#[get("/accounts/profile")] #[get("/accounts/profile")]
async fn profile(headers: Headers, conn: DbConn) -> Json<Value> { async fn profile(headers: Headers, conn: DbConn) -> Json<Value> {
Json(headers.user.to_json(&conn).await) Json(headers.user.to_json(&conn).await)

14
src/api/core/organizations.rs

@ -868,18 +868,18 @@ async fn get_org_details_impl(
Ok(json!(ciphers_json)) Ok(json!(ciphers_json))
} }
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user // Return the configured default organization so its policies apply during SSO enrollment; otherwise return a dummy.
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere // In use since `v2025.6.0`; clients appear to use only the first `organizationIdentifier`.
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
#[post("/organizations/domain/sso/verified")] #[post("/organizations/domain/sso/verified")]
fn get_org_domain_sso_verified() -> JsonResult { fn get_org_domain_sso_verified() -> JsonResult {
// Always return a dummy value, no matter if SSO is enabled or not let organization_identifier = crate::sso::default_organization_id()?
.map_or_else(|| FAKE_SSO_IDENTIFIER.to_owned(), |org_id| org_id.to_string());
Ok(Json(json!({ Ok(Json(json!({
"object": "list", "object": "list",
"data": [{ "data": [{
"organizationIdentifier": FAKE_SSO_IDENTIFIER, "organizationIdentifier": organization_identifier,
// These appear to be unused "organizationName": organization_identifier,
"organizationName": FAKE_SSO_IDENTIFIER,
"domainName": CONFIG.domain() "domainName": CONFIG.domain()
}], }],
"continuationToken": null "continuationToken": null

2
src/api/identity.rs

@ -363,7 +363,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
*user_id = Some(user.uuid.clone()); *user_id = Some(user.uuid.clone());
// We passed 2FA get auth tokens // We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, &ip.ip, conn).await?;
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
} }

6
src/config.rs

@ -849,6 +849,8 @@ make_config! {
sso_signups_match_email: bool, true, def, true; 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. /// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
sso_allow_unknown_email_verification: bool, true, def, false; sso_allow_unknown_email_verification: bool, true, def, false;
/// Default organization UUID |> Automatically add users on their first SSO sign-in as accepted members of this organization. An administrator must confirm them and assign collections or groups. No invitation email is sent.
sso_default_organization_uuid: String, true, option;
/// Client ID /// Client ID
sso_client_id: String, true, def, String::new(); sso_client_id: String, true, def, String::new();
/// Client Key /// Client Key
@ -1161,6 +1163,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?;
} }
if let Some(org_uuid) = &cfg.sso_default_organization_uuid {
crate::sso::normalize_organization_uuid(org_uuid)?;
}
if cfg._enable_yubico { if cfg._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { 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") err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")

53
src/sso.rs

@ -1,4 +1,4 @@
use std::{sync::LazyLock, time::Duration}; use std::{net::IpAddr, sync::LazyLock, time::Duration};
use chrono::Utc; use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From, Into}; use derive_more::{AsRef, Deref, Display, From, Into};
@ -6,12 +6,15 @@ use url::Url;
use crate::{ use crate::{
CONFIG, CONFIG,
api::ApiResult, api::{ApiResult, core::log_event},
auth, auth,
auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper}, auth::{AuthMethod, AuthTokens, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY, TokenWrapper},
db::{ db::{
DbConn, DbConn,
models::{Device, OIDCAuthenticatedUser, SsoAuth, SsoUser, User}, models::{
Device, EventType, Membership, MembershipStatus, OIDCAuthenticatedUser, Organization, OrganizationId,
SsoAuth, SsoUser, User,
},
}, },
sso_client::Client, sso_client::Client,
}; };
@ -322,6 +325,7 @@ pub async fn exchange_code(
} }
// User has passed 2FA flow we can delete auth info from database // User has passed 2FA flow we can delete auth info from database
#[expect(clippy::too_many_arguments)]
pub async fn redeem( pub async fn redeem(
device: &Device, device: &Device,
user: &User, user: &User,
@ -329,11 +333,14 @@ pub async fn redeem(
sso_user: Option<SsoUser>, sso_user: Option<SsoUser>,
sso_auth: SsoAuth, sso_auth: SsoAuth,
auth_user: OIDCAuthenticatedUser, auth_user: OIDCAuthenticatedUser,
ip: &IpAddr,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<AuthTokens> { ) -> ApiResult<AuthTokens> {
sso_auth.delete(conn).await?; sso_auth.delete(conn).await?;
if sso_user.is_none() { if sso_user.is_none() {
invite_user_to_default_organization(user, device.atype, ip, conn).await?;
let user_sso = SsoUser { let user_sso = SsoUser {
user_uuid: user.uuid.clone(), user_uuid: user.uuid.clone(),
identifier: auth_user.identifier.clone(), identifier: auth_user.identifier.clone(),
@ -360,6 +367,46 @@ pub async fn redeem(
} }
} }
async fn invite_user_to_default_organization(
user: &User,
device_type: i32,
ip: &IpAddr,
conn: &DbConn,
) -> ApiResult<()> {
let Some(org_id) = default_organization_id()? else {
return Ok(());
};
if Membership::find_by_user_and_org(&user.uuid, &org_id, conn).await.is_some() {
return Ok(());
}
if Organization::find_by_uuid(&org_id, conn).await.is_none() {
err!("The organization configured in `SSO_DEFAULT_ORGANIZATION_UUID` does not exist")
}
let mut membership = Membership::new(user.uuid.clone(), org_id.clone(), None);
membership.status = MembershipStatus::Invited as i32;
membership.save(conn).await?;
log_event(EventType::OrganizationUserInvited, &membership.uuid, &org_id, &user.uuid, device_type, ip, conn).await;
info!("Invited SSO user {} to default organization {}", user.uuid, org_id);
Ok(())
}
pub(crate) fn default_organization_id() -> ApiResult<Option<OrganizationId>> {
CONFIG.sso_default_organization_uuid().as_deref().map(normalize_organization_uuid).transpose()
}
// Stored organization uuids are always lowercase hyphenated and compared as strings.
pub(crate) fn normalize_organization_uuid(org_uuid: &str) -> ApiResult<OrganizationId> {
let Ok(parsed) = uuid::Uuid::parse_str(org_uuid) else {
err!("`SSO_DEFAULT_ORGANIZATION_UUID` must be a valid UUID")
};
Ok(OrganizationId::from(parsed.to_string()))
}
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front). // 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 // 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( pub fn create_auth_tokens(

Loading…
Cancel
Save