diff --git a/.env.template b/.env.template index 9fc29989..5f6f374c 100644 --- a/.env.template +++ b/.env.template @@ -518,6 +518,9 @@ ## Prevent users from logging in directly without going through SSO # SSO_ONLY=false +## Allow SSO flow to create account. You probably want to disable it when using a public provider. +# SSO_SIGNUPS_ALLOWED=true + ## On SSO Signup if a user with a matching email already exists make the association # SSO_SIGNUPS_MATCH_EMAIL=true diff --git a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql index 9e5e46df..8d1eb178 100644 --- a/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql +++ b/migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql @@ -1,15 +1,31 @@ --- Dynamically create DROP FOREIGN KEY --- Some versions of MySQL or MariaDB might fail if the key doesn't exists --- This checks if the key exists, and if so, will drop it. -SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE - CONSTRAINT_SCHEMA = DATABASE() AND - TABLE_NAME = 'sso_users' AND - CONSTRAINT_NAME = 'sso_users_ibfk_1' AND - CONSTRAINT_TYPE = 'FOREIGN KEY') = true, - 'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1', - 'SELECT 1'); -PREPARE stmt FROM @drop_sso_fk; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sso_users' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = 'sso_users_ibfk_1' + ) + ,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`' + ,'SELECT "info: FK sso_users_ibfk_1 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +SELECT if ( + EXISTS( + SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sso_users' + AND CONSTRAINT_TYPE = 'FOREIGN KEY' + AND CONSTRAINT_NAME = '1' + ) + ,'ALTER TABLE sso_users DROP FOREIGN KEY `1`' + ,'SELECT "info: FK sso_users 1 does not exist."' +) INTO @drop_stmt; +PREPARE drop_stmt FROM @drop_stmt; +EXECUTE drop_stmt; + +DEALLOCATE PREPARE drop_stmt; ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 5dd04ff4..5bfc47a5 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -61,7 +61,7 @@ services: Mariadb: profiles: ["playwright"] container_name: playwright_mariadb - image: mariadb:11.2.4 + image: mariadb:12.2.2 env_file: test.env healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] diff --git a/src/api/admin.rs b/src/api/admin.rs index 48f36afd..eaa681dd 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -425,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR for membership in memberships { log_event( - EventType::OrganizationUserDeleted as i32, + EventType::OrganizationUserDeleted, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), @@ -446,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em for membership in memberships { log_event( - EventType::OrganizationUserUnlinkedSso as i32, + EventType::OrganizationUserUnlinkedSso, &membership.uuid, &membership.org_uuid, &ACTING_ADMIN_USER.into(), @@ -571,7 +571,7 @@ async fn update_membership_type(data: Json, token: AdminToke OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; log_event( - EventType::OrganizationUserUpdated as i32, + EventType::OrganizationUserUpdated, &member_to_edit.uuid, &data.org_uuid, &ACTING_ADMIN_USER.into(), diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 2b51fd0c..13021ca3 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -167,7 +167,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option EventType::CipherUpdated, }; - log_event( - event_type as i32, - &cipher.uuid, - org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - conn, - ) - .await; + log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) + .await; } nt.send_cipher_update( ut, @@ -850,7 +842,7 @@ async fn post_collections_update( .await; log_event( - EventType::CipherUpdatedCollections as i32, + EventType::CipherUpdatedCollections, &cipher.uuid, org_uuid, &headers.user.uuid, @@ -930,7 +922,7 @@ async fn post_collections_admin( .await; log_event( - EventType::CipherUpdatedCollections as i32, + EventType::CipherUpdatedCollections, &cipher.uuid, org_uuid, &headers.user.uuid, @@ -1335,7 +1327,7 @@ async fn save_attachment( if let Some(org_id) = &cipher.organization_uuid { log_event( - EventType::CipherAttachmentCreated as i32, + EventType::CipherAttachmentCreated, &cipher.uuid, org_id, &headers.user.uuid, @@ -1696,7 +1688,7 @@ async fn purge_org_vault( nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await; log_event( - EventType::OrganizationPurgedVault as i32, + EventType::OrganizationPurgedVault, &organization.org_id, &organization.org_id, &user.uuid, @@ -1824,9 +1816,9 @@ async fn delete_cipher_by_uuid( let event_type = if *delete_options == CipherDeleteOptions::SoftSingle || *delete_options == CipherDeleteOptions::SoftMulti { - EventType::CipherSoftDeleted as i32 + EventType::CipherSoftDeleted } else { - EventType::CipherDeleted as i32 + EventType::CipherDeleted }; log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn) @@ -1895,7 +1887,7 @@ async fn restore_cipher_by_uuid( if let Some(org_id) = &cipher.organization_uuid { log_event( - EventType::CipherRestored as i32, + EventType::CipherRestored, &cipher.uuid.clone(), org_id, &headers.user.uuid, @@ -1972,7 +1964,7 @@ async fn delete_cipher_attachment_by_id( if let Some(ref org_id) = cipher.organization_uuid { log_event( - EventType::CipherAttachmentDeleted as i32, + EventType::CipherAttachmentDeleted, &cipher.uuid, org_id, &headers.user.uuid, diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 5518fa3c..2c437a36 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -10,7 +10,7 @@ use crate::{ auth::{AdminHeaders, Headers}, db::{ DbConn, DbPool, - models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, + models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, }, util::parse_date, }; @@ -267,7 +267,7 @@ async fn log_user_event_impl( } pub async fn log_event( - event_type: i32, + event_type: EventType, source_uuid: &str, org_id: &OrganizationId, act_user_id: &UserId, @@ -278,7 +278,7 @@ pub async fn log_event( if !CONFIG.org_events_enabled() { return; } - log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; + log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; } #[expect(clippy::too_many_arguments)] diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..9082297f 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -269,7 +269,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c } log_event( - EventType::OrganizationUserLeft as i32, + EventType::OrganizationUserLeft, &membership.uuid, &org_id, &headers.user.uuid, @@ -327,7 +327,7 @@ async fn post_organization( org.save(&conn).await?; log_event( - EventType::OrganizationUpdated as i32, + EventType::OrganizationUpdated, org_id.as_ref(), &org_id, &headers.user.uuid, @@ -514,7 +514,7 @@ async fn post_organization_collections( collection.save(&conn).await?; log_event( - EventType::CollectionCreated as i32, + EventType::CollectionCreated, &collection.uuid, &org_id, &headers.user.uuid, @@ -597,7 +597,7 @@ async fn post_bulk_access_collections( collection.save(&conn).await?; log_event( - EventType::CollectionUpdated as i32, + EventType::CollectionUpdated, &collection.uuid, &org_id, &headers.user.uuid, @@ -674,7 +674,7 @@ async fn post_organization_collection_update( collection.save(&conn).await?; log_event( - EventType::CollectionUpdated as i32, + EventType::CollectionUpdated, &collection.uuid, &org_id, &headers.user.uuid, @@ -723,7 +723,7 @@ async fn delete_organization_collection_impl( err!("Collection not found", "Collection does not exist or does not belong to this organization") }; log_event( - EventType::CollectionDeleted as i32, + EventType::CollectionDeleted, &collection.uuid, org_id, &headers.user.uuid, @@ -1148,7 +1148,7 @@ async fn send_invite( } log_event( - EventType::OrganizationUserInvited as i32, + EventType::OrganizationUserInvited, &new_member.uuid, &org_id, &headers.user.uuid, @@ -1447,7 +1447,7 @@ async fn confirm_invite_impl( OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; log_event( - EventType::OrganizationUserConfirmed as i32, + EventType::OrganizationUserConfirmed, &member_to_confirm.uuid, org_id, &headers.user.uuid, @@ -1637,7 +1637,7 @@ async fn edit_member( } log_event( - EventType::OrganizationUserUpdated as i32, + EventType::OrganizationUserUpdated, &member_to_edit.uuid, &org_id, &headers.user.uuid, @@ -1724,7 +1724,7 @@ async fn delete_member_impl( } log_event( - EventType::OrganizationUserRemoved as i32, + EventType::OrganizationUserRemoved, &member_to_delete.uuid, org_id, &headers.user.uuid, @@ -2144,7 +2144,7 @@ async fn put_policy( } log_event( - EventType::OrganizationUserRemoved as i32, + EventType::OrganizationUserRemoved, &member.uuid, &org_id, &headers.user.uuid, @@ -2170,7 +2170,7 @@ async fn put_policy( policy.save(&conn).await?; log_event( - EventType::PolicyUpdated as i32, + EventType::PolicyUpdated, policy.uuid.as_ref(), &org_id, &headers.user.uuid, @@ -2339,7 +2339,7 @@ async fn revoke_member_impl( member.save(conn).await?; log_event( - EventType::OrganizationUserRevoked as i32, + EventType::OrganizationUserRevoked, &member.uuid, org_id, &headers.user.uuid, @@ -2437,7 +2437,7 @@ async fn restore_member_impl( member.save(conn).await?; log_event( - EventType::OrganizationUserRestored as i32, + EventType::OrganizationUserRestored, &member.uuid, org_id, &headers.user.uuid, @@ -2605,7 +2605,7 @@ async fn post_groups( let group = group_request.to_group(&org_id); log_event( - EventType::GroupCreated as i32, + EventType::GroupCreated, &group.uuid, &org_id, &headers.user.uuid, @@ -2646,7 +2646,7 @@ async fn put_group( GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; log_event( - EventType::GroupUpdated as i32, + EventType::GroupUpdated, &updated_group.uuid, &org_id, &headers.user.uuid, @@ -2679,7 +2679,7 @@ async fn add_update_group( user_entry.save(conn).await?; log_event( - EventType::OrganizationUserUpdatedGroups as i32, + EventType::OrganizationUserUpdatedGroups, &assigned_member, &org_id, &headers.user.uuid, @@ -2754,7 +2754,7 @@ async fn delete_group_impl( }; log_event( - EventType::GroupDeleted as i32, + EventType::GroupDeleted, &group.uuid, org_id, &headers.user.uuid, @@ -2865,7 +2865,7 @@ async fn put_group_members( user_entry.save(&conn).await?; log_event( - EventType::OrganizationUserUpdatedGroups as i32, + EventType::OrganizationUserUpdatedGroups, &assigned_member, &org_id, &headers.user.uuid, @@ -2903,7 +2903,7 @@ async fn post_delete_group_member( } log_event( - EventType::OrganizationUserUpdatedGroups as i32, + EventType::OrganizationUserUpdatedGroups, &member_id, &org_id, &headers.user.uuid, @@ -3039,7 +3039,7 @@ async fn recover_account( nt.send_logout(&user, None, &conn).await; log_event( - EventType::OrganizationUserAdminResetPassword as i32, + EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &headers.user.uuid, @@ -3166,9 +3166,9 @@ async fn put_reset_password_enrollment( membership.save(&conn).await?; let event_type = if membership.reset_password_key.is_some() { - EventType::OrganizationUserResetPasswordEnroll as i32 + EventType::OrganizationUserResetPasswordEnroll } else { - EventType::OrganizationUserResetPasswordWithdraw as i32 + EventType::OrganizationUserResetPasswordWithdraw }; log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 8869d23d..c95fb297 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -190,7 +190,7 @@ pub async fn enforce_2fa_policy( member.save(conn).await?; log_event( - EventType::OrganizationUserRevoked as i32, + EventType::OrganizationUserRevoked, &member.uuid, &member.org_uuid, act_user_id, @@ -224,16 +224,8 @@ pub async fn enforce_2fa_policy_for_org( member.revoke(); member.save(conn).await?; - log_event( - EventType::OrganizationUserRevoked as i32, - &member.uuid, - org_id, - act_user_id, - device_type, - ip, - conn, - ) - .await; + log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn) + .await; } } diff --git a/src/api/identity.rs b/src/api/identity.rs index 23411dc7..2b1ddfb1 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -234,6 +234,24 @@ async fn sso_login( } ) } + Some((user, None)) + if user.private_key.is_none() + && !CONFIG.sso_signups_allowed() + && !CONFIG.is_email_domain_allowed(&user.email) + && !CONFIG.mail_enabled() + && Invitation::find_by_mail(&user.email, conn).await.is_none() => + { + error!( + "Login failure ({}), no invitation with email ({}) was found", + user_infos.identifier, user.email + ); + err_silent!( + "Missing invitation", + 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", @@ -281,7 +299,15 @@ async fn sso_login( // 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) { + if !CONFIG.is_sso_signup_allowed(&user_infos.email) { + if CONFIG.signups_domains_whitelist().is_empty() { + err!( + "Signups are disabled. You will need an invitation", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ); + } err!( "Email domain not allowed", ErrorEvent { diff --git a/src/config.rs b/src/config.rs index 72b58252..2502dd02 100644 --- a/src/config.rs +++ b/src/config.rs @@ -817,6 +817,8 @@ make_config! { sso_enabled: bool, true, def, false; /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; + /// Allow SSO flow to create account |> You probably want to disable it when using a public provider + sso_signups_allowed: bool, true, def, true; /// 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. @@ -1544,6 +1546,17 @@ impl Config { } } + /// Tests whether SSO signup is allowed for an email address, taking into + /// account the sso_signups_allowed and signups_domains_whitelist settings. + pub fn is_sso_signup_allowed(&self, email: &str) -> bool { + if self.signups_domains_whitelist().is_empty() { + self.sso_signups_allowed() + } else { + // The whitelist setting overrides the signups_allowed setting. + self.is_email_domain_allowed(email) + } + } + // 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. diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index d501f8b9..2b45cd86 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -318,6 +318,13 @@ impl OrgPolicy { } pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool { + // Account recovery depends on outbound mail. When SMTP is disabled, treat the + // auto-enroll policy as inactive so invites/registration are not forced to + // supply a reset-password key (see check_reset_password_applicable). + if !CONFIG.mail_enabled() { + return false; + } + match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await { Some(policy) => match serde_json::from_str::(&policy.data) { Ok(opts) => {