From 2c1ca9ca539fe7f86f131648f823e7c1dcdf6baf Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 3 Jan 2024 17:26:06 +0100 Subject: [PATCH] Allow Group/Organization mapping to trigger invitation --- .env.template | 8 + README.md | 32 +++- SSO.md | 4 + playwright/compose/keycloak/Dockerfile | 4 +- playwright/compose/keycloak/setup.sh | 22 ++- playwright/compose/keycloak_setup.dockerfile | 10 ++ playwright/docker-compose.yml | 1 + playwright/tests/sso_groups.spec.ts | 54 ++++++ src/api/core/accounts.rs | 1 + src/api/core/mod.rs | 2 +- src/api/core/organizations.rs | 171 ++++++++----------- src/api/identity.rs | 2 + src/business/mod.rs | 1 + src/business/organization_logic.rs | 86 ++++++++++ src/config.rs | 32 ++++ src/db/models/organization.rs | 37 ++++ src/db/models/user.rs | 2 +- src/main.rs | 1 + src/sso.rs | 111 ++++++++++-- 19 files changed, 461 insertions(+), 120 deletions(-) create mode 100644 playwright/compose/keycloak_setup.dockerfile create mode 100644 playwright/tests/sso_groups.spec.ts create mode 100644 src/business/mod.rs create mode 100644 src/business/organization_logic.rs diff --git a/.env.template b/.env.template index 4c0ab82543..64cf4a66d6 100644 --- a/.env.template +++ b/.env.template @@ -485,6 +485,14 @@ # SSO_ROLES_DEFAULT_TO_USER=true ## Id token path to read roles # SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles +## Controls whether to add users to organization +# SSO_ORGANIZATIONS_INVITE=false +## Id token path to read groups +# SSO_ORGANIZATIONS_TOKEN_PATH=/groups +## Organization ID mapping +# SSO_ORGANIZATIONS_ID_MAPPING="ProviderId:VaultwardenId;" +## Grant access to all the organization collections +# SSO_ORGANIZATIONS_ALL_COLLECTIONS=true ## Client cache for discovery endpoint. Duration in seconds (0 to disable). # SSO_CLIENT_CACHE_EXPIRATION=0 ## Log all the tokens, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set diff --git a/README.md b/README.md index 0438338d94..13600407f4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ See [changelog](CHANGELOG.md) for more details. ## Additionnal features -This branch now contain additionnal features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review. +This branch now contain features not added to the SSO [PR](https://github.com/dani-garcia/vaultwarden/pull/3899) since it would slow even more it's review. ### Role mapping @@ -26,6 +26,34 @@ This feature is controlled by the following conf: - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Access token +### Group/Organization invitation mapping + +Allow to invite user to existing Oganization if they are listed in the Access token. +If activated it will check if the token contain a list of potential Orgnaization. +If an Oganization with a matching name (case sensitive) is found it will the start the invitation process for this user. +It will use the email associated with the Organization to send further notifications (admin side). + +The flow look like this: + +- Decode the JWT Access token and check if a list of organization is present (default path is `/groups`). +- Check if an Organization with a matching name exist and the user is not part of it (Use group name mapping if `SSO_ORGANIZATIONS_ID_MAPPING` is defined). +- if mail are activated invite the user to the Orgnization + - The user will need to click on the link in the mail he received + - A notification is sent tto he `email` associated with the Organization that a new user is ready to join + - An admin will have to validate the user to finalize the user joining the org. +- Otherwise just add the user to the Organization + - An admin will have to validate the user to confirm the user joining the org. + +One of the bonus of invitation is that if an organization define a specific password policy then it will apply to new user when they set their new master password. +If a user is part of two organizations then it will order them using the role of the user (`Owner`, `Admin`, `User` or `Manager` for now manager is last :() and return the password policy of the first one. + +This feature is controlled with the following conf: + +- `SSO_SCOPES`: Optional scope override if additionnal scopes are needed, default is `"email profile"` +- `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false` +- `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Access token, default is `/groups` +- `SSO_ORGANIZATIONS_ID_MAPPING`: Optional, allow to map provider group to a Vaultwarden organization `uuid` (default `""`, format: `"ProviderId:VaultwardenId;"`) + ### Experimental Version Made a version which additionnaly allow to run the server without storing the master password (it's still required just not sent to the server). @@ -37,7 +65,7 @@ Change the docker files to package both front-end from [Timshel/oidc_web_builds] \ By default it will use the release which only make the `sso` button visible. -If you want to use the version which additionally change the default redirection to `/sso` and fix organization invitation to persist. +If you want to use the version with the additional features mentionned, default redirection to `/sso` and fix organization invitation. You need to pass an env variable: `-e SSO_FRONTEND='override'` (cf [start.sh](docker/start.sh)). Docker images available at: diff --git a/SSO.md b/SSO.md index 1d7dc1af4f..a3aa87ca3a 100644 --- a/SSO.md +++ b/SSO.md @@ -30,6 +30,10 @@ The following configurations are available - `SSO_ROLES_ENABLED`: control if the mapping is done, default is `false` - `SSO_ROLES_DEFAULT_TO_USER`: do not block login in case of missing or invalid roles, default is `true`. - `SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles`: path to read roles in the Id token + - `SSO_ORGANIZATIONS_INVITE`: control if the mapping is done, default is `false` + - `SSO_ORGANIZATIONS_TOKEN_PATH`: path to read groups/organization in the Id token + - `SSO_ORGANIZATIONS_ID_MAPPING`: Optional, allow to map provider group to a Vaultwarden organization `uuid` (default `""`, format: `"ProviderId:VaultwardenId;"`) + - `SSO_ORGANIZATIONS_ALL_COLLECTIONS`: Grant access to all collections, default is `true` - `SSO_CLIENT_CACHE_EXPIRATION`: Cache calls to the discovery endpoint, duration in seconds, `0` to disable (default `0`); - `SSO_DEBUG_TOKENS`: Log all tokens for easier debugging (default `false`, `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` need to be set) diff --git a/playwright/compose/keycloak/Dockerfile b/playwright/compose/keycloak/Dockerfile index 3588895016..cf2581e986 100644 --- a/playwright/compose/keycloak/Dockerfile +++ b/playwright/compose/keycloak/Dockerfile @@ -6,7 +6,7 @@ ARG KEYCLOAK_VERSION SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update \ - && apt-get install -y ca-certificates curl wget \ + && apt-get install -y ca-certificates curl wget jq \ && rm -rf /var/lib/apt/lists/* WORKDIR / @@ -21,7 +21,7 @@ ARG KEYCLOAK_VERSION SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update \ - && apt-get install -y ca-certificates curl wget \ + && apt-get install -y ca-certificates curl wget jq \ && rm -rf /var/lib/apt/lists/* ARG JAVA_URL diff --git a/playwright/compose/keycloak/setup.sh b/playwright/compose/keycloak/setup.sh index e94d954e74..8b23564d7e 100755 --- a/playwright/compose/keycloak/setup.sh +++ b/playwright/compose/keycloak/setup.sh @@ -40,12 +40,31 @@ kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID/prot -s 'config."access.token.claim"=false' \ -s 'config."userinfo.token.claim"=true' +## Create group mapping client scope +TEST_GROUPS_CLIENT_SCOPE_ID=$(kcadm.sh create -r "$TEST_REALM" client-scopes -s name=groups -s protocol=openid-connect -i) +kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID/protocol-mappers/models" \ + -s name=Groups \ + -s protocol=openid-connect \ + -s protocolMapper=oidc-group-membership-mapper \ + -s consentRequired=false \ + -s 'config."claim.name"=groups' \ + -s 'config."full.path"=false' \ + -s 'config."id.token.claim"=true' \ + -s 'config."access.token.claim"=true' \ + -s 'config."userinfo.token.claim"=true' + +TEST_GROUP_ID=$(kcadm.sh create -r "$TEST_REALM" groups -s name=Test -i) + TEST_CLIENT_ID=$(kcadm.sh create -r "$TEST_REALM" clients -s "name=VaultWarden" -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i) ## ADD Role mapping scope kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_CLIENT_ROLES_SCOPE_ID\"]}" kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID" +## ADD Group mapping scope +kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID" --body "{\"optionalClientScopes\": [\"$TEST_GROUPS_CLIENT_SCOPE_ID\"]}" +kcadm.sh update -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/optional-client-scopes/$TEST_GROUPS_CLIENT_SCOPE_ID" + ## CREATE TEST ROLES kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=admin -s 'description=Admin role' kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s 'description=Admin role' @@ -54,11 +73,12 @@ kcadm.sh create -r "$TEST_REALM" "clients/$TEST_CLIENT_ID/roles" -s name=user -s TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "firstName=$TEST_USER" -s "lastName=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/reset-password" -s type=password -s "value=$TEST_USER_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/groups/$TEST_GROUP_ID" kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER" --cid "$TEST_CLIENT_ID" --rolename admin - TEST_USER2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER2" -s "firstName=$TEST_USER2" -s "lastName=$TEST_USER2" -s "email=$TEST_USER2_MAIL" -s emailVerified=true -s enabled=true -i) kcadm.sh update users/$TEST_USER2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER2_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER2_ID/groups/$TEST_GROUP_ID" kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER2" --cid "$TEST_CLIENT_ID" --rolename user TEST_USER3_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER3" -s "firstName=$TEST_USER3" -s "lastName=$TEST_USER3" -s "email=$TEST_USER3_MAIL" -s emailVerified=true -s enabled=true -i) diff --git a/playwright/compose/keycloak_setup.dockerfile b/playwright/compose/keycloak_setup.dockerfile new file mode 100644 index 0000000000..7a98028364 --- /dev/null +++ b/playwright/compose/keycloak_setup.dockerfile @@ -0,0 +1,10 @@ +FROM registry.access.redhat.com/ubi9 AS ubi-micro-build + +RUN dnf install -y wget && wget -O /root/jq https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 && chmod +x /root/jq + +FROM quay.io/keycloak/keycloak:25.0.1 +COPY --from=ubi-micro-build /root/jq /usr/bin/jq + +COPY keycloak_setup.sh /keycloak_setup.sh + +ENTRYPOINT [ "bash", "-c", "/keycloak_setup.sh"] diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index e30a2a91a6..a33930f2e1 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -30,6 +30,7 @@ services: - SSO_ENABLED - SSO_FRONTEND - SSO_ONLY + - SSO_ORGANIZATIONS_INVITE - SSO_ROLES_DEFAULT_TO_USER - SSO_ROLES_ENABLED - SSO_SCOPES diff --git a/playwright/tests/sso_groups.spec.ts b/playwright/tests/sso_groups.spec.ts new file mode 100644 index 0000000000..d600ef38e8 --- /dev/null +++ b/playwright/tests/sso_groups.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type TestInfo } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { logNewUser, logUser } from './setups/sso'; + +let users = utils.loadEnv(); + +let mailServer; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailServer = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailServer.listen(); + + await utils.startVaultwarden(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: true, + SSO_ORGANIZATIONS_INVITE: true, + SSO_SCOPES: "email profile groups", + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.VAULTWARDEN_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVaultwarden(); + mailServer?.close(); +}); + +test('User auto invite', async ({ context, page }) => { + let mail2Buffer = mailServer.buffer(users.user2.email); + try { + await logNewUser(test, page, users.user1); + + await test.step('Create Org', async () => { + await page.getByRole('link', { name: 'New organisation' }).click(); + await page.getByLabel('Organisation name (required)').fill('Test'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); + }); + + await test.step('Log user2 and receive invite', async () => { + await context.clearCookies(); + await logNewUser(test, page, users.user2, { mailBuffer: mail2Buffer }); + await expect(mail2Buffer.next((m) => m.subject === "Join Test")).resolves.toBeDefined(); + }); + } finally { + mail2Buffer.close(); + } +}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index fef3323c74..d4de330bad 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -298,6 +298,7 @@ async fn post_set_password(data: Json, headers: Headers, mut co if CONFIG.mail_enabled() { mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; } else { + // Since the user now has a password we can confirm invitations. Membership::accept_user_invitations(&user.uuid, &mut conn).await?; } diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 9a9276a06a..7fb7109a95 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -3,7 +3,7 @@ mod ciphers; mod emergency_access; mod events; mod folders; -mod organizations; +pub mod organizations; mod public; mod sends; pub mod two_factor; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index f3bbd9059c..69cda9c0a6 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -10,6 +10,7 @@ use crate::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, }, auth::{decode_invite, AdminHeaders, ClientVersion, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders}, + business::organization_logic, db::{models::*, DbConn}, mail, util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, @@ -325,12 +326,16 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json } // Called during the SSO enrollment -// The `_identifier` should be the harcoded 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")] -fn get_auto_enroll_status(_identifier: &str) -> JsonResult { +// We return the org_id if it exists ortherwise we return the first associated with the user +#[get("/organizations//auto-enroll-status")] +async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult { + let org_id = match Organization::find_by_name(identifier, &mut conn).await.map(|o| o.uuid) { + Some(org_id) => Some(org_id), + None => Membership::find_main_user_org(&headers.user.uuid, &mut conn).await.map(|uo| uo.org_uuid), + }; + Ok(Json(json!({ - "Id": get_uuid(), + "Id": org_id.map(|oi| oi.to_string()).unwrap_or_else(get_uuid), "ResetPasswordEnabled": false, // Not implemented }))) } @@ -808,13 +813,25 @@ async fn _get_org_details(org_id: &OrganizationId, host: &str, user_id: &UserId, json!(ciphers_json) } -// Endpoint called when the user select SSO login (body: `{ "email": "" }`). +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrgDomainDetails { + email: String, +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user -// VaultWarden sso login is not linked to Org so we set a dummy value. -#[post("/organizations/domain/sso/details")] -fn get_org_domain_sso_details() -> JsonResult { +// So we either return an Org name associated to the user or a dummy value. +#[post("/organizations/domain/sso/details", data = "")] +async fn get_org_domain_sso_details(data: Json, mut conn: DbConn) -> JsonResult { + let data: OrgDomainDetails = data.into_inner(); + + let identifier = match Organization::find_main_org_user_email(&data.email, &mut conn).await { + Some(org) => org.name, + None => crate::sso::FAKE_IDENTIFIER.to_string(), + }; + Ok(Json(json!({ - "organizationIdentifier": "vaultwarden", + "organizationIdentifier": identifier, "ssoAvailable": CONFIG.sso_enabled(), "verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()), }))) @@ -887,18 +904,18 @@ async fn post_org_keys( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CollectionData { - id: CollectionId, - read_only: bool, - hide_passwords: bool, +pub struct CollectionData { + pub id: CollectionId, + pub read_only: bool, + pub hide_passwords: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct MembershipData { - id: MembershipId, - read_only: bool, - hide_passwords: bool, +pub struct MembershipData { + pub id: MembershipId, + pub read_only: bool, + pub hide_passwords: bool, } #[derive(Deserialize)] @@ -928,7 +945,7 @@ async fn send_invite( let raw_type = &data.r#type.into_string(); // Membership::from_str will convert custom (4) to manager (3) let new_type = match MembershipType::from_str(raw_type) { - Some(new_type) => new_type as i32, + Some(new_type) => new_type, None => err!("Invalid type"), }; @@ -948,8 +965,14 @@ async fn send_invite( } let mut user_created: bool = false; + let collections = data.collections.into_iter().flatten().collect(); + + let org = match Organization::find_by_uuid(&org_id, &mut conn).await { + Some(org) => org, + None => err!("Error looking up organization"), + }; + for email in data.emails.iter() { - let mut member_status = MembershipStatus::Invited as i32; let user = match User::find_by_mail(email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { @@ -972,83 +995,32 @@ async fn send_invite( Some(user) => { if Membership::find_by_user_and_org(&user.uuid, &org_id, &mut conn).await.is_some() { err!(format!("User already in organization: {email}")) - } else { - // automatically accept existing users if mail is disabled - if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - member_status = MembershipStatus::Accepted as i32; - } - user } + user } }; - let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); - let access_all = data.access_all; - new_member.access_all = access_all; - new_member.atype = new_type; - new_member.status = member_status; - new_member.save(&mut conn).await?; - - if CONFIG.mail_enabled() { - let org_name = match Organization::find_by_uuid(&org_id, &mut conn).await { - Some(org) => org.name, - None => err!("Error looking up organization"), - }; - - if let Err(e) = mail::send_invite( - &user, - Some(org_id.clone()), - Some(new_member.uuid.clone()), - &org_name, - Some(headers.user.email.clone()), - ) - .await - { - // Upon error delete the user, invite and org member records when needed - if user_created { - user.delete(&mut conn).await?; - } else { - new_member.delete(&mut conn).await?; - } - - err!(format!("Error sending invite: {e:?} ")); - } - } - - log_event( - EventType::OrganizationUserInvited as i32, - &new_member.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, + if let Err(e) = organization_logic::invite( + &user, + &headers.device, + &headers.ip, + &org, + new_type, + &data.groups, + data.access_all, + &collections, + headers.user.email.clone(), &mut conn, ) - .await; - - // If no accessAll, add the collections received - if !access_all { - for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &mut conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - CollectionUser::save( - &user.uuid, - &collection.uuid, - col.read_only, - col.hide_passwords, - &mut conn, - ) - .await?; - } - } + .await + { + // Upon error delete the user, invite and org member records when needed + if user_created { + user.delete(&mut conn).await?; } - } - for group_id in data.groups.iter() { - let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); - group_entry.save(&mut conn).await?; - } + return Err(e); + }; } Ok(()) @@ -1881,19 +1853,18 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo // Called during the SSO enrollment. // Cannot use the OrganizationId guard since the Org does not exists. +// Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] -fn get_master_password_policy(org_id: OrganizationId, _headers: Headers) -> JsonResult { - let data = match CONFIG.sso_master_password_policy() { - Some(policy) => policy, - None => "null".to_string(), - }; +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(), + }; - let policy = OrgPolicy::new( - org_id, - OrgPolicyType::MasterPassword, - CONFIG.sso_master_password_policy().is_some(), - data, - ); + OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data) + }); Ok(Json(policy.to_json())) } diff --git a/src/api/identity.rs b/src/api/identity.rs index 1b3a4facca..162d57649d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -294,6 +294,8 @@ async fn _sso_login( // Set the user_uuid here to be passed back used for event logging. *user_id = Some(user.uuid.clone()); + sso::sync_groups(&user, &device, ip, &auth_user.groups, conn).await?; + if auth_user.is_admin() { info!("User {} logged with admin cookie", user.email); cookies.add(admin::create_admin_cookie()); diff --git a/src/business/mod.rs b/src/business/mod.rs new file mode 100644 index 0000000000..ee5cb8e469 --- /dev/null +++ b/src/business/mod.rs @@ -0,0 +1 @@ +pub mod organization_logic; diff --git a/src/business/organization_logic.rs b/src/business/organization_logic.rs new file mode 100644 index 0000000000..e4b39dbccb --- /dev/null +++ b/src/business/organization_logic.rs @@ -0,0 +1,86 @@ +use crate::{ + api::{core::log_event, core::organizations::CollectionData, ApiResult}, + auth::ClientIp, + db::models::*, + db::DbConn, + mail, CONFIG, +}; + +#[allow(clippy::too_many_arguments)] +pub async fn invite( + user: &User, + device: &Device, + ip: &ClientIp, + org: &Organization, + membership_type: MembershipType, + groups: &Vec, + access_all: bool, + collections: &Vec, + invited_by_email: String, + conn: &mut DbConn, +) -> ApiResult<()> { + let mut user_org_status = MembershipStatus::Invited; + + // automatically accept existing users if mail is disabled + if !user.password_hash.is_empty() && !CONFIG.mail_enabled() { + user_org_status = MembershipStatus::Accepted; + } + + let mut new_member = Membership::new(user.uuid.clone(), org.uuid.clone(), Some(invited_by_email.clone())); + new_member.access_all = access_all; + new_member.atype = membership_type as i32; + new_member.status = user_org_status as i32; + + // If no accessAll, add the collections received + if !access_all { + for col in collections { + match Collection::find_by_uuid_and_org(&col.id, &org.uuid, conn).await { + None => err!("Collection not found in Organization"), + Some(collection) => { + CollectionUser::save(&user.uuid, &collection.uuid, col.read_only, col.hide_passwords, conn).await?; + } + } + } + } + + new_member.save(conn).await?; + + for group in groups { + let mut group_entry = GroupUser::new(group.clone(), new_member.uuid.clone()); + group_entry.save(conn).await?; + } + + log_event( + EventType::OrganizationUserInvited as i32, + &new_member.uuid, + &org.uuid, + &user.uuid, + device.atype, + &ip.ip, + conn, + ) + .await; + + if CONFIG.mail_enabled() { + match user_org_status { + MembershipStatus::Invited => { + if let Err(e) = mail::send_invite( + user, + Some(org.uuid.clone()), + Some(new_member.uuid.clone()), + &org.name, + new_member.invited_by_email.clone(), + ) + .await + { + new_member.delete(conn).await?; + err!(format!("Error sending invite: {e:?} ")); + } + } + MembershipStatus::Accepted => mail::send_invite_accepted(&user.email, &invited_by_email, &org.name).await?, + MembershipStatus::Revoked | MembershipStatus::Confirmed => (), + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 182ab336c3..f10541888c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::env::consts::EXE_SUFFIX; use std::process::exit; use std::sync::{ @@ -10,6 +11,7 @@ use once_cell::sync::Lazy; use reqwest::Url; use crate::{ + db::models::OrganizationId, db::DbConnType, error::Error, util::{get_env, get_env_bool, get_web_vault_version, parse_experimental_client_feature_flags}, @@ -681,6 +683,14 @@ make_config! { sso_roles_default_to_user: bool, false, def, true; /// Id token path to read roles sso_roles_token_path: String, false, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id); + /// Invite users to Organizations + sso_organizations_invite: bool, false, def, false; + /// Id token path to read Organization/Groups + sso_organizations_token_path: String, false, def, "/groups".to_string(); + /// Organization Id mapping |> "ProviderId:VaultwardenId;" + sso_organizations_id_mapping: String, true, def, String::new(); + /// Grant acceess to all collections + sso_organizations_all_collections: bool, true, def, true; /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/blob/sso-support/SSO.md#client-cache sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required @@ -1229,6 +1239,24 @@ fn parse_param_list(config: String, separator: char, kv_separator: char) -> Resu .collect() } +fn parse_as_hashmap V>(config: String, f: F) -> HashMap { + config + .split(';') + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .filter_map(|l| { + let split = l.split(':').collect::>(); + match &split[..] { + [key, value] => Some(((*key).to_string(), f((*value).to_string()))), + _ => { + println!("[WARNING] Failed to parse ({l}). Expected key:value;"); + None + } + } + }) + .collect() +} + impl Config { pub fn load() -> Result { // Loading from env and file @@ -1445,6 +1473,10 @@ impl Config { pub fn sso_authorize_extra_params_vec(&self) -> Result, Error> { internal_sso_authorize_extra_params_vec(&self.sso_authorize_extra_params()) } + + pub fn sso_organizations_id_mapping_map(&self) -> HashMap { + parse_as_hashmap(self.sso_organizations_id_mapping(), |str| str.into()) + } } use handlebars::{ diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 7e46e2e2ea..5d7f169850 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -57,6 +57,7 @@ db_object! { } // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs +#[derive(Copy, Clone)] pub enum MembershipStatus { Revoked = -1, Invited = 0, @@ -376,11 +377,36 @@ impl Organization { }} } + pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + organizations::table + .filter(organizations::name.eq(name)) + .first::(conn) + .ok().from_db() + }} + } + pub async fn get_all(conn: &mut DbConn) -> Vec { db_run! { conn: { organizations::table.load::(conn).expect("Error loading organizations").from_db() }} } + + pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option { + let lower_mail = user_email.to_lowercase(); + + db_run! { conn: { + organizations::table + .inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid))) + .inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid))) + .filter(users::email.eq(lower_mail)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } } impl Membership { @@ -1082,6 +1108,17 @@ impl Membership { .first::(conn).ok().from_db() }} } + + pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + users_organizations::table + .filter(users_organizations::user_uuid.eq(user_uuid)) + .filter(users_organizations::status.ne(MembershipStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 4a3b25fb2f..d68f3af292 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -17,7 +17,7 @@ use crate::{ use macros::UuidFromParam; db_object! { - #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] + #[derive(Clone, Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[diesel(table_name = users)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] diff --git a/src/main.rs b/src/main.rs index 9efda57c8b..3199094234 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ mod auth; mod config; mod crypto; #[macro_use] +mod business; mod db; mod http_client; mod mail; diff --git a/src/sso.rs b/src/sso.rs index 1887a59497..5b26563b88 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -3,6 +3,7 @@ use derive_more::{AsRef, Deref, Display, From}; use regex::Regex; use serde::de::DeserializeOwned; use std::borrow::Cow; +use std::collections::HashMap; use std::time::Duration; use url::Url; @@ -19,16 +20,20 @@ use openidconnect::{ }; use crate::{ + api::core::organizations::CollectionData, api::ApiResult, auth, - auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + auth::{AuthMethod, AuthTokens, ClientIp, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + business::organization_logic, db::{ - models::{Device, EventType, SsoNonce, User}, + models::{Device, EventType, GroupId, Membership, MembershipType, Organization, SsoNonce, User}, DbConn, }, CONFIG, }; +pub static FAKE_IDENTIFIER: &str = "VaultWarden"; + static AC_CACHE: Lazy> = Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build()); @@ -345,6 +350,7 @@ impl OIDCIdentifier { #[derive(Debug)] struct AdditionnalClaims { role: Option, + groups: Vec, } #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] @@ -364,6 +370,7 @@ pub struct AuthenticatedUser { pub email_verified: Option, pub user_name: Option, pub role: Option, + pub groups: Vec, } impl AuthenticatedUser { @@ -382,7 +389,7 @@ pub struct UserInformation { } // Errors are logged but will return None -fn roles(email: &str, token: &serde_json::Value) -> Option { +fn roles_claim(email: &str, token: &serde_json::Value) -> Option { if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) { match serde_json::from_value::>(json_roles.clone()) { Ok(mut roles) => { @@ -400,24 +407,47 @@ fn roles(email: &str, token: &serde_json::Value) -> Option { } } +// Errors are logged but will return an empty Vec +fn groups_claim(email: &str, token: &serde_json::Value) -> Vec { + if let Some(json_groups) = token.pointer(&CONFIG.sso_organizations_token_path()) { + match serde_json::from_value::>(json_groups.clone()) { + Ok(groups) => groups, + Err(err) => { + error!("Failed to parse user ({email}) groups: {err}"); + Vec::new() + } + } + } else { + debug!("No groups in {email} id_token at {}", &CONFIG.sso_organizations_token_path()); + Vec::new() + } +} + // Trying to conditionnally read additionnal configurable claims using openidconnect appear nightmarish // So we just decode the token again as a JsValue fn additional_claims(email: &str, token: &str) -> ApiResult { let mut role = None; + let mut groups = Vec::new(); - if CONFIG.sso_roles_enabled() { + if CONFIG.sso_roles_enabled() || CONFIG.sso_organizations_invite() { match insecure_decode::("id_token", token) { Err(err) => err!(format!("Could not decode access token: {:?}", err)), Ok(claims) => { - role = roles(email, &claims); - if !CONFIG.sso_roles_default_to_user() && role.is_none() { - info!("User {email} failed to login due to missing/invalid role"); - err!( - "Invalid user role. Contact your administrator", - ErrorEvent { - event: EventType::UserFailedLogIn - } - ) + if CONFIG.sso_roles_enabled() { + role = roles_claim(email, &claims); + if !CONFIG.sso_roles_default_to_user() && role.is_none() { + info!("User {email} failed to login due to missing/invalid role"); + err!( + "Invalid user role. Contact your administrator", + ErrorEvent { + event: EventType::UserFailedLogIn + } + ) + } + } + + if CONFIG.sso_organizations_invite() { + groups = groups_claim(email, &claims); } } } @@ -425,6 +455,7 @@ fn additional_claims(email: &str, token: &str) -> ApiResult { Ok(AdditionnalClaims { role, + groups, }) } @@ -547,6 +578,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult err!("No token present while in SSO"), } } + +pub async fn sync_groups( + user: &User, + device: &Device, + ip: &ClientIp, + groups: &Vec, + conn: &mut DbConn, +) -> ApiResult<()> { + if CONFIG.sso_organizations_invite() { + let id_mapping = CONFIG.sso_organizations_id_mapping_map(); + let db_user_orgs = Membership::find_any_state_by_user(&user.uuid, conn).await; + let user_orgs = db_user_orgs.iter().map(|uo| (uo.org_uuid.clone(), uo)).collect::>(); + + let org_groups: Vec = vec![]; + let org_collections: Vec = vec![]; + + for group in groups { + let db_org = if id_mapping.is_empty() { + Organization::find_by_name(group, conn).await + } else { + match id_mapping.get(group) { + Some(uuid) if user_orgs.contains_key(uuid) => continue, + Some(uuid) => Organization::find_by_uuid(uuid, conn).await, + None => { + warn!("Missing organization mapping for {group}"); + None + } + } + }; + + if let Some(org) = db_org { + if !user_orgs.contains_key(&org.uuid) { + info!("Invitation to {} organization sent to {}", group, user.email); + organization_logic::invite( + user, + device, + ip, + &org, + MembershipType::User, + &org_groups, + CONFIG.sso_organizations_all_collections(), + &org_collections, + org.billing_email.clone(), + conn, + ) + .await?; + } + } + } + } + + Ok(()) +}