diff --git a/.env.template b/.env.template index 21e0e6e585..8cb928dc95 100644 --- a/.env.template +++ b/.env.template @@ -476,6 +476,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 9e48fcecaa..8ae8b706f8 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 e880db6e42..b0bcb85c9b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -291,6 +291,9 @@ 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 if CONFIG.sso_enabled() { + // Since the user now has a password we can confirm invitations. + UserOrganization::confirm_user_invitations(&user.uuid, &mut conn).await?; } user.save(&mut conn).await?; diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 75c63c168c..2b57ea3f7d 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 81636dfe94..e63901982f 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, NumberOrString}, @@ -310,12 +311,19 @@ 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_policy_master_password` 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) => org_id, + None => UserOrganization::find_main_user_org(&headers.user.uuid, &mut conn) + .await + .map(|uo| uo.org_uuid) + .unwrap_or_else(|| "null".to_string()), + }; + Ok(Json(json!({ - "Id": "_", + "Id": org_id, "ResetPasswordEnabled": false, // Not implemented }))) } @@ -795,13 +803,25 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut 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() }))) } @@ -868,10 +888,10 @@ async fn post_org_keys(org_id: &str, data: Json, _headers: AdminHead #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CollectionData { - id: String, - read_only: bool, - hide_passwords: bool, +pub struct CollectionData { + pub id: String, + pub read_only: bool, + pub hide_passwords: bool, } #[derive(Deserialize)] @@ -890,7 +910,7 @@ async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders let data: InviteData = data.into_inner(); let new_type = match UserOrgType::from_str(&data.r#type.into_string()) { - Some(new_type) => new_type as i32, + Some(new_type) => new_type, None => err!("Invalid type"), }; @@ -898,8 +918,14 @@ async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders err!("Only Owners can invite Managers, Admins or Owners") } + let org = match Organization::find_by_uuid(org_id, &mut conn).await { + Some(org) => org, + None => err!("Error looking up organization"), + }; + + let collections = data.collections.into_iter().flatten().collect(); + for email in data.emails.iter() { - let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(email, &mut conn).await { None => { if !CONFIG.invitations_allowed() { @@ -922,75 +948,24 @@ async fn send_invite(org_id: &str, data: Json, headers: AdminHeaders Some(user) => { if UserOrganization::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() { - user_org_status = UserOrgStatus::Accepted as i32; - } - user } + user } }; - let mut new_user = - UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone())); - let access_all = data.access_all; - new_user.access_all = access_all; - new_user.atype = new_type; - new_user.status = user_org_status; - - // 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?; - } - } - } - } - - new_user.save(&mut conn).await?; - - for group in data.groups.iter() { - let mut group_entry = GroupUser::new(String::from(group), user.uuid.clone()); - group_entry.save(&mut conn).await?; - } - - log_event( - EventType::OrganizationUserInvited as i32, - &new_user.uuid, - org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, + 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 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"), - }; - - mail::send_invite( - &user, - Some(String::from(org_id)), - Some(new_user.uuid), - &org_name, - Some(headers.user.email.clone()), - ) - .await?; - } + .await?; } Ok(()) @@ -1797,20 +1772,24 @@ async fn list_policies_invited_user(org_id: &str, userId: &str, mut conn: DbConn } // Called during the SSO enrollment. +// Return the org policy if it exists, otherwise use the default one. #[get("/organizations//policies/master-password", rank = 1)] -fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult { - let data = match CONFIG.sso_master_password_policy() { - Some(policy) => policy, - None => "null".to_string(), - }; +async fn get_policy_master_password(org_id: &str, _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 { - uuid: String::from(org_id), - org_uuid: String::from(org_id), - atype: OrgPolicyType::MasterPassword as i32, - enabled: CONFIG.sso_master_password_policy().is_some(), - data, - }; + OrgPolicy { + uuid: String::from(org_id), + org_uuid: String::from(org_id), + atype: OrgPolicyType::MasterPassword as i32, + enabled: 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 74ecc65740..9232f1206f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -258,6 +258,8 @@ async fn _sso_login( // Set the user_uuid here to be passed back used for event logging. *user_uuid = 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..f53ef811aa --- /dev/null +++ b/src/business/organization_logic.rs @@ -0,0 +1,82 @@ +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, + user_org_type: UserOrgType, + groups: &Vec, + access_all: bool, + collections: &Vec, + invited_by_email: String, + conn: &mut DbConn, +) -> ApiResult<()> { + let mut user_org_status = UserOrgStatus::Invited; + + // automatically accept existing users if mail is disabled + if !user.password_hash.is_empty() && !CONFIG.mail_enabled() { + user_org_status = UserOrgStatus::Accepted; + } + + let mut new_uo = UserOrganization::new(user.uuid.clone(), org.uuid.clone(), Some(invited_by_email.clone())); + new_uo.access_all = access_all; + new_uo.atype = user_org_type as i32; + new_uo.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_uo.save(conn).await?; + + for group in groups { + let mut group_entry = GroupUser::new(group.clone(), user.uuid.clone()); + group_entry.save(conn).await?; + } + + log_event( + EventType::OrganizationUserInvited as i32, + &new_uo.uuid, + &org.uuid, + &user.uuid, + device.atype, + &ip.ip, + conn, + ) + .await; + + if CONFIG.mail_enabled() { + match user_org_status { + UserOrgStatus::Invited => { + mail::send_invite( + user, + Some(org.uuid.clone()), + Some(new_uo.uuid), + &org.name, + new_uo.invited_by_email.clone(), + ) + .await? + } + UserOrgStatus::Accepted => mail::send_invite_accepted(&user.email, &invited_by_email, &org.name).await?, + UserOrgStatus::Revoked | UserOrgStatus::Confirmed => (), + } + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 5824574a1b..fd3aa4b78b 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::{ @@ -676,6 +677,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 @@ -1216,6 +1225,24 @@ fn parse_param_list(config: String, separator: char, kv_separator: char) -> Resu .collect() } +fn parse_as_hashmap(config: String) -> 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(), (*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 @@ -1429,6 +1456,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()) + } } use handlebars::{ diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 3cfc44daab..1bb7b8e275 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -52,6 +52,7 @@ db_object! { } // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs +#[derive(Copy, Clone)] pub enum UserOrgStatus { Revoked = -1, Invited = 0, @@ -347,11 +348,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(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .select(organizations::all_columns) + .first::(conn) + .ok().from_db() + }} + } } impl UserOrganization { @@ -964,6 +990,17 @@ impl UserOrganization { .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(UserOrgStatus::Revoked as i32)) + .order(users_organizations::atype.asc()) + .first::(conn) + .ok().from_db() + }} + } } impl OrganizationApiKey { diff --git a/src/main.rs b/src/main.rs index ca00c499fc..cbe5915d02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,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 d25ae77fdd..e30a8e6454 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -2,6 +2,7 @@ use chrono::Utc; use regex::Regex; use serde::de::DeserializeOwned; use std::borrow::Cow; +use std::collections::HashMap; use std::time::Duration; use url::Url; @@ -18,16 +19,20 @@ use openidconnect::{ }; use crate::{ + api::core::organizations::CollectionData, api::ApiResult, auth, - auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + auth::{AuthMethod, AuthMethodScope, AuthTokens, ClientIp, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, + business::organization_logic, db::{ - models::{Device, EventType, SsoNonce, User}, + models::{Device, EventType, Organization, SsoNonce, User, UserOrgType, UserOrganization}, 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()); @@ -259,6 +264,7 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st #[derive(Debug)] struct AdditionnalClaims { role: Option, + groups: Vec, } #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] @@ -278,6 +284,7 @@ pub struct AuthenticatedUser { pub email_verified: Option, pub user_name: Option, pub role: Option, + pub groups: Vec, } impl AuthenticatedUser { @@ -296,7 +303,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) => { @@ -314,24 +321,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); } } } @@ -339,6 +369,7 @@ fn additional_claims(email: &str, token: &str) -> ApiResult { Ok(AdditionnalClaims { role, + groups, }) } @@ -461,6 +492,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 = UserOrganization::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, + UserOrgType::User, + &org_groups, + CONFIG.sso_organizations_all_collections(), + &org_collections, + org.billing_email.clone(), + conn, + ) + .await?; + } + } + } + } + + Ok(()) +}