From f472af14509eb7830919adbe612df6480b218654 Mon Sep 17 00:00:00 2001 From: Felix Eckhofer Date: Wed, 30 Aug 2023 16:13:09 +0200 Subject: [PATCH] Add SSO functionality using OpenID Connect Co-authored-by: Pablo Ovelleiro Corral Co-authored-by: Stuart Heap Co-authored-by: Alex Moore Co-authored-by: Brian Munro Co-authored-by: Jacques B. --- .env.template | 14 + Cargo.toml | 3 + .../mysql/2023-02-01-133000_add_sso/down.sql | 1 + .../mysql/2023-02-01-133000_add_sso/up.sql | 3 + .../2023-02-01-133000_add_sso/down.sql | 1 + .../2023-02-01-133000_add_sso/up.sql | 3 + .../sqlite/2023-02-01-133000_add_sso/down.sql | 1 + .../sqlite/2023-02-01-133000_add_sso/up.sql | 3 + src/api/core/accounts.rs | 73 +++- src/api/core/organizations.rs | 37 ++ src/api/identity.rs | 351 +++++++++++++++++- src/auth.rs | 23 ++ src/config.rs | 29 ++ src/db/models/mod.rs | 2 + src/db/models/org_policy.rs | 2 +- src/db/models/organization.rs | 8 +- src/db/models/sso_nonce.rs | 60 +++ src/db/schemas/mysql/schema.rs | 6 + src/db/schemas/postgresql/schema.rs | 6 + src/db/schemas/sqlite/schema.rs | 6 + src/mail.rs | 12 + src/static/templates/email/set_password.hbs | 6 + .../templates/email/set_password.html.hbs | 11 + src/util.rs | 58 ++- 24 files changed, 704 insertions(+), 15 deletions(-) create mode 100644 migrations/mysql/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/mysql/2023-02-01-133000_add_sso/up.sql create mode 100644 migrations/postgresql/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/postgresql/2023-02-01-133000_add_sso/up.sql create mode 100644 migrations/sqlite/2023-02-01-133000_add_sso/down.sql create mode 100644 migrations/sqlite/2023-02-01-133000_add_sso/up.sql create mode 100644 src/db/models/sso_nonce.rs create mode 100644 src/static/templates/email/set_password.hbs create mode 100644 src/static/templates/email/set_password.html.hbs diff --git a/.env.template b/.env.template index 07d7dbc0..a1462638 100644 --- a/.env.template +++ b/.env.template @@ -409,6 +409,20 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +##################################### +### SSO settings (OpenID Connect) ### +##################################### + +## Controls whether users can login using an OpenID Connect identity provider +# SSO_ENABLED=true +## Prevent users from logging in directly without going through SSO +# SSO_ONLY=false +## Base URL of the OIDC server (auto-discovery is used) +# SSO_AUTHORITY=https://auth.example.com +## Set your Client ID and Client Key +# SSO_CLIENT_ID=11111 +# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA + ######################## ### MFA/2FA settings ### ######################## diff --git a/Cargo.toml b/Cargo.toml index 9094801b..10f96bb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,9 @@ pico-args = "0.5.0" paste = "1.0.15" governor = "0.6.3" +# OIDC for SSO +openidconnect = "3.4.0" + # Check client versions for specific features. semver = "1.0.23" diff --git a/migrations/mysql/2023-02-01-133000_add_sso/down.sql b/migrations/mysql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/mysql/2023-02-01-133000_add_sso/up.sql b/migrations/mysql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..c10ab5cf --- /dev/null +++ b/migrations/mysql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/down.sql b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/postgresql/2023-02-01-133000_add_sso/up.sql b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..57f976c1 --- /dev/null +++ b/migrations/postgresql/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); \ No newline at end of file diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/down.sql b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql new file mode 100644 index 00000000..2c946dc5 --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/down.sql @@ -0,0 +1 @@ +DROP TABLE sso_nonce; diff --git a/migrations/sqlite/2023-02-01-133000_add_sso/up.sql b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql new file mode 100644 index 00000000..c10ab5cf --- /dev/null +++ b/migrations/sqlite/2023-02-01-133000_add_sso/up.sql @@ -0,0 +1,3 @@ +CREATE TABLE sso_nonce ( + nonce CHAR(36) NOT NULL PRIMARY KEY +); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index da787ac7..b1abb114 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -31,6 +31,7 @@ pub fn routes() -> Vec { get_public_keys, post_keys, post_password, + post_set_password, post_kdf, post_rotatekey, post_sstamp, @@ -80,6 +81,21 @@ pub struct RegisterData { organization_user_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPasswordData { + kdf: Option, + kdf_iterations: Option, + kdf_memory: Option, + kdf_parallelism: Option, + key: String, + keys: Option, + master_password_hash: String, + master_password_hint: Option, + #[allow(dead_code)] + org_identifier: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KeysData { @@ -87,6 +103,13 @@ struct KeysData { public_key: String, } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: String, + nonce: String, +} + /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: &Option) -> Option { match password_hint { @@ -242,6 +265,50 @@ pub async fn _register(data: Json, mut conn: DbConn) -> JsonResult }))) } +#[post("/accounts/set-password", data = "")] +async fn post_set_password(data: Json, headers: Headers, mut conn: DbConn) -> JsonResult { + let data: SetPasswordData = data.into_inner(); + let mut user = headers.user; + + // Check against the password hint setting here so if it fails, the user + // can retry without losing their invitation below. + let password_hint = clean_password_hint(&data.master_password_hash); + enforce_password_hint_setting(&password_hint)?; + + if let Some(client_kdf_iter) = data.kdf_iterations { + user.client_kdf_iter = client_kdf_iter; + } + + if let Some(client_kdf_type) = data.kdf { + user.client_kdf_type = client_kdf_type; + } + + // We need to allow revision-date to use the old security_timestamp + let routes = ["revision_date"]; + let routes: Option> = Some(routes.iter().map(ToString::to_string).collect()); + + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.set_password(&data.master_password_hash, Some(data.key), false, routes); + user.password_hint = password_hint; + + if let Some(keys) = data.keys { + user.private_key = Some(keys.encrypted_private_key); + user.public_key = Some(keys.public_key); + } + + if CONFIG.mail_enabled() { + mail::send_set_password(&user.email.to_lowercase(), &user.name).await?; + } + + user.save(&mut conn).await?; + Ok(Json(json!({ + "Object": "set-password", + "CaptchaBypassToken": "", + }))) +} + #[get("/accounts/profile")] async fn profile(headers: Headers, mut conn: DbConn) -> Json { Json(headers.user.to_json(&mut conn).await) @@ -922,7 +989,7 @@ struct SecretVerificationRequest { } #[post("/accounts/verify-password", data = "")] -fn verify_password(data: Json, headers: Headers) -> EmptyResult { +fn verify_password(data: Json, headers: Headers) -> JsonResult { let data: SecretVerificationRequest = data.into_inner(); let user = headers.user; @@ -930,7 +997,9 @@ fn verify_password(data: Json, headers: Headers) -> E err!("Invalid password") } - Ok(()) + Ok(Json(json!({ + "MasterPasswordPolicy": {}, // Required for SSO login with mobile apps + }))) } async fn _api_key(data: Json, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 204dd56f..0cff0497 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -40,6 +40,7 @@ pub fn routes() -> Vec { post_organization_collection_delete, bulk_delete_organization_collections, get_org_details, + get_org_domain_sso_details, get_org_users, send_invite, reinvite_user, @@ -56,6 +57,7 @@ pub fn routes() -> Vec { post_org_import, list_policies, list_policies_token, + list_policies_invited_user, get_policy, put_policy, get_organization_tax, @@ -96,6 +98,7 @@ pub fn routes() -> Vec { get_org_export, api_key, rotate_api_key, + get_auto_enroll_status, ] } @@ -302,6 +305,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json })) } +#[get("/organizations/<_identifier>/auto-enroll-status")] +fn get_auto_enroll_status(_identifier: String) -> JsonResult { + Ok(Json(json!({ + "ResetPasswordEnabled": false, // Not implemented + }))) +} + #[get("/organizations//collections")] async fn get_org_collections(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json { Json(json!({ @@ -769,6 +779,14 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut json!(ciphers_json) } +#[post("/organizations/domain/sso/details")] +fn get_org_domain_sso_details() -> JsonResult { + Ok(Json(json!({ + "organizationIdentifier": "vaultwarden", + "ssoAvailable": CONFIG.sso_enabled() + }))) +} + #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] @@ -1664,6 +1682,25 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso }))) } +#[allow(non_snake_case)] +#[get("/organizations//policies/invited-user?")] +async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult { + // We should confirm the user is part of the organization, but unique domain_hints must be supported first. + + if userId.is_empty() { + err!("userId must not be empty"); + } + + let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await; + let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + Ok(Json(json!({ + "Data": policies_json, + "Object": "list", + "ContinuationToken": null + }))) +} + #[get("/organizations//policies/")] async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult { let pol_type_enum = match OrgPolicyType::from_i32(pol_type) { diff --git a/src/api/identity.rs b/src/api/identity.rs index fbf8d506..5e1fb328 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1,8 +1,10 @@ use chrono::Utc; +use jsonwebtoken::DecodingKey; use num_traits::FromPrimitive; use rocket::serde::json::Json; use rocket::{ form::{Form, FromForm}, + http::CookieJar, Route, }; use serde_json::Value; @@ -17,14 +19,16 @@ use crate::{ push::register_push_device, ApiResult, EmptyResult, JsonResult, }, - auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, + auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, - mail, util, CONFIG, + mail, util, + util::{CookieManager, CustomRedirect}, + CONFIG, }; pub fn routes() -> Vec { - routes![login, prelogin, identity_register] + routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin] } #[post("/connect/token", data = "")] @@ -61,6 +65,15 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await } + "authorization_code" => { + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.code, "code cannot be blank")?; + + _check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; + _check_is_some(&data.device_name, "device_name cannot be blank")?; + _check_is_some(&data.device_type, "device_type cannot be blank")?; + _authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + } t => err!("Invalid type", t), }; @@ -135,6 +148,141 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { Ok(Json(result)) } +#[derive(Debug, Serialize, Deserialize)] +struct TokenPayload { + exp: i64, + email: Option, + nonce: String, +} + +async fn _authorization_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { + let scope = match data.scope.as_ref() { + None => err!("Got no scope in OIDC data"), + Some(scope) => scope, + }; + if scope != "api offline_access" { + err!("Scope not supported") + } + + let scope_vec = vec!["api".into(), "offline_access".into()]; + let code = match data.code.as_ref() { + None => err!("Got no code in OIDC data"), + Some(code) => code, + }; + + let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await { + Ok((refresh_token, id_token, user_info)) => (refresh_token, id_token, user_info), + Err(_err) => err!("Could not retrieve access token"), + }; + + let mut validation = jsonwebtoken::Validation::default(); + validation.insecure_disable_signature_validation(); + + let token = + match jsonwebtoken::decode::(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) { + Err(_err) => err!("Could not decode id token"), + Ok(payload) => payload.claims, + }; + + // let expiry = token.exp; + let nonce = token.nonce; + let mut new_user = false; + + match SsoNonce::find(&nonce, conn).await { + Some(sso_nonce) => { + match sso_nonce.delete(conn).await { + Ok(_) => { + let user_email = match token.email { + Some(email) => email, + None => match user_info.email() { + None => err!("Neither id token nor userinfo contained an email"), + Some(email) => email.to_owned().to_string(), + }, + }; + let now = Utc::now().naive_utc(); + + let mut user = match User::find_by_mail(&user_email, conn).await { + Some(user) => user, + None => { + new_user = true; + User::new(user_email.clone()) + } + }; + + if new_user { + user.verified_at = Some(Utc::now().naive_utc()); + user.save(conn).await?; + } + + // Set the user_uuid here to be passed back used for event logging. + *user_uuid = Some(user.uuid.clone()); + + let (mut device, new_device) = get_device(&data, conn, &user).await; + + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, true, conn).await?; + + if CONFIG.mail_enabled() && new_device { + if let Err(e) = + mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await + { + error!("Error sending new device email: {:#?}", e); + + if CONFIG.require_device_email() { + err!("Could not send login notification email. Please contact your administrator.") + } + } + } + + if CONFIG.sso_acceptall_invites() { + for user_org in UserOrganization::find_invited_by_user(&user.uuid, conn).await.iter_mut() { + user_org.status = UserOrgStatus::Accepted as i32; + user_org.save(conn).await?; + } + } + + device.refresh_token = refresh_token.clone(); + device.save(conn).await?; + + let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec); + device.save(conn).await?; + + let mut result = json!({ + "access_token": access_token, + "token_type": "Bearer", + "refresh_token": device.refresh_token, + "expires_in": expires_in, + "Key": user.akey, + "PrivateKey": user.private_key, + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "KdfMemory": user.client_kdf_memory, + "KdfParallelism": user.client_kdf_parallelism, + "ResetMasterPassword": user.password_hash.is_empty(), + "scope": scope, + "unofficialServer": true, + }); + + if let Some(token) = twofactor_token { + result["TwoFactorToken"] = Value::String(token); + } + + info!("User {} logged in successfully. IP: {}", user.email, ip.ip); + Ok(Json(result)) + } + Err(_) => err!("Failed to delete nonce"), + } + } + None => { + err!("Invalid nonce") + } + } +} + async fn _password_login( data: ConnectData, user_uuid: &mut Option, @@ -151,6 +299,10 @@ async fn _password_login( // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; + if CONFIG.sso_enabled() && CONFIG.sso_only() { + err!("SSO sign-in is required"); + } + // Get the user let username = data.username.as_ref().unwrap().trim(); let mut user = match User::find_by_mail(username, conn).await { @@ -250,7 +402,7 @@ async fn _password_login( let (mut device, new_device) = get_device(&data, conn, &user).await; - let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?; + let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, false, conn).await?; if CONFIG.mail_enabled() && new_device { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await { @@ -485,6 +637,7 @@ async fn twofactor_auth( data: &ConnectData, device: &mut Device, ip: &ClientIp, + is_sso: bool, conn: &mut DbConn, ) -> ApiResult> { let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; @@ -502,7 +655,17 @@ async fn twofactor_auth( let twofactor_code = match data.two_factor_token { Some(ref code) => code, - None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"), + None => { + if is_sso { + if CONFIG.sso_only() { + err!("2FA not supported with SSO login, contact your administrator"); + } else { + err!("2FA not supported with SSO login, log in directly using email and master password"); + } + } else { + err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"); + } + } }; let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled); @@ -696,11 +859,187 @@ struct ConnectData { two_factor_remember: Option, #[field(name = uncased("authrequest"))] auth_request: Option, + // Needed for authorization code + #[form(field = uncased("code"))] + code: Option, } - fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { if value.is_none() { err!(msg) } Ok(()) } + +#[get("/account/prevalidate")] +#[allow(non_snake_case)] +fn prevalidate() -> JsonResult { + let claims = generate_ssotoken_claims(); + let ssotoken = encode_jwt(&claims); + Ok(Json(json!({ + "token": ssotoken, + }))) +} + +use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims}; +use openidconnect::reqwest::async_http_client; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse, + RedirectUrl, Scope, +}; + +async fn get_client_from_sso_config() -> ApiResult { + let redirect = CONFIG.sso_callback_path(); + let client_id = ClientId::new(CONFIG.sso_client_id()); + let client_secret = ClientSecret::new(CONFIG.sso_client_secret()); + let issuer_url = match IssuerUrl::new(CONFIG.sso_authority()) { + Ok(issuer) => issuer, + Err(_err) => err!("invalid issuer URL"), + }; + + let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await { + Ok(metadata) => metadata, + Err(_err) => { + err!("Failed to discover OpenID provider") + } + }; + + let redirect_uri = match RedirectUrl::new(redirect) { + Ok(uri) => uri, + Err(err) => err!("Invalid redirection url: {}", err.to_string()), + }; + let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)) + .set_redirect_uri(redirect_uri); + + Ok(client) +} + +#[get("/connect/oidc-signin?")] +fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + + let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) { + None => err!("No redirect_uri in cookie"), + Some(uri) => uri, + }; + let orig_state = match cookiemanager.get_cookie("state".to_string()) { + None => err!("No state in cookie"), + Some(state) => state, + }; + + cookiemanager.delete_cookie("redirect_uri".to_string()); + cookiemanager.delete_cookie("state".to_string()); + + let redirect = CustomRedirect { + url: format!("{redirect_uri}?code={code}&state={orig_state}"), + headers: vec![], + }; + + Ok(redirect) +} + +#[derive(FromForm)] +#[allow(non_snake_case)] +struct AuthorizeData { + #[allow(unused)] + #[field(name = uncased("client_id"))] + #[field(name = uncased("clientid"))] + client_id: Option, + #[field(name = uncased("redirect_uri"))] + #[field(name = uncased("redirecturi"))] + redirect_uri: Option, + #[allow(unused)] + #[field(name = uncased("response_type"))] + #[field(name = uncased("responsetype"))] + response_type: Option, + #[allow(unused)] + #[field(name = uncased("scope"))] + scope: Option, + #[field(name = uncased("state"))] + state: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge"))] + code_challenge: Option, + #[allow(unused)] + #[field(name = uncased("code_challenge_method"))] + code_challenge_method: Option, + #[allow(unused)] + #[field(name = uncased("response_mode"))] + response_mode: Option, + #[allow(unused)] + #[field(name = uncased("domain_hint"))] + domain_hint: Option, + #[allow(unused)] + #[field(name = uncased("ssoToken"))] + ssoToken: Option, +} + +#[get("/connect/authorize?")] +async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult { + let cookiemanager = CookieManager::new(jar); + match get_client_from_sso_config().await { + Ok(client) => { + let (auth_url, _csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + let sso_nonce = SsoNonce::new(nonce.secret().to_string()); + sso_nonce.save(&mut conn).await?; + + let redirect_uri = match data.redirect_uri { + None => err!("No redirect_uri in data"), + Some(uri) => uri, + }; + cookiemanager.set_cookie("redirect_uri".to_string(), redirect_uri); + let state = match data.state { + None => err!("No state in data"), + Some(state) => state, + }; + cookiemanager.set_cookie("state".to_string(), state); + + let redirect = CustomRedirect { + url: format!("{}", auth_url), + headers: vec![], + }; + + Ok(redirect) + } + Err(_err) => err!("Unable to find client from identifier"), + } +} + +async fn get_auth_code_access_token(code: &str) -> ApiResult<(String, String, CoreUserInfoClaims)> { + let oidc_code = AuthorizationCode::new(String::from(code)); + match get_client_from_sso_config().await { + Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await { + Ok(token_response) => { + let refresh_token = match token_response.refresh_token() { + Some(token) => token.secret().to_string(), + None => String::new(), + }; + let id_token = match token_response.extra_fields().id_token() { + None => err!("Token response did not contain an id_token"), + Some(token) => token.to_string(), + }; + + let user_info: CoreUserInfoClaims = + match client.user_info(token_response.access_token().to_owned(), None) { + Err(_err) => err!("Token response did not contain user_info"), + Ok(info) => match info.request_async(async_http_client).await { + Err(_err) => err!("Request to user_info endpoint failed"), + Ok(claim) => claim, + }, + }; + + Ok((refresh_token, id_token, user_info)) + } + Err(err) => err!("Failed to contact token endpoint: {}", err.to_string()), + }, + Err(_err) => err!("Unable to find client"), + } +} diff --git a/src/auth.rs b/src/auth.rs index c8060a28..d684249d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,6 +20,7 @@ pub static JWT_LOGIN_ISSUER: Lazy = Lazy::new(|| format!("{}|login", CON static JWT_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin())); static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy = Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin())); +static JWT_SSOTOKEN_ISSUER: Lazy = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin())); static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin())); static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); @@ -317,6 +318,28 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct SsoTokenJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, +} + +pub fn generate_ssotoken_claims() -> SsoTokenJwtClaims { + let time_now = Utc::now().naive_utc(); + SsoTokenJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::minutes(2)).timestamp(), + iss: JWT_SSOTOKEN_ISSUER.to_string(), + sub: "vaultwarden".to_string(), + } +} + pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/config.rs b/src/config.rs index 489a229d..6b55a9b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -606,6 +606,24 @@ make_config! { org_groups_enabled: bool, false, def, false; }, + /// OpenID Connect SSO settings + sso { + /// Enabled + sso_enabled: bool, true, def, false; + /// Force SSO login + sso_only: bool, true, def, false; + /// Client ID + sso_client_id: String, true, def, String::new(); + /// Client Key + sso_client_secret: Pass, true, def, String::new(); + /// Authority Server + sso_authority: String, true, def, String::new(); + /// CallBack Path + sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain); + /// Allow workaround so SSO logins accept all invites + sso_acceptall_invites: bool, true, def, false; + }, + /// Yubikey settings yubico: _enable_yubico { /// Enabled @@ -815,6 +833,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("All Duo options need to be set for global Duo support") } + if cfg.sso_enabled + && (cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty()) + { + err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support") + } + if cfg._enable_yubico { 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") @@ -1018,6 +1042,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { } } +fn generate_sso_callback_path(domain: &str) -> String { + format!("{domain}/identity/connect/oidc-signin") +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { @@ -1305,6 +1333,7 @@ where reg!("email/send_emergency_access_invite", ".html"); reg!("email/send_org_invite", ".html"); reg!("email/send_single_org_removed_from_org", ".html"); + reg!("email/set_password", ".html"); reg!("email/smtp_test", ".html"); reg!("email/twofactor_email", ".html"); reg!("email/verify_email", ".html"); diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0379141a..9a4e7585 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -11,6 +11,7 @@ mod group; mod org_policy; mod organization; mod send; +mod sso_nonce; mod two_factor; mod two_factor_incomplete; mod user; @@ -28,6 +29,7 @@ pub use self::group::{CollectionGroup, Group, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; +pub use self::sso_nonce::SsoNonce; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::user::{Invitation, User, UserKdfType, UserStampException}; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index d1e8aa0f..e5a845f6 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -27,7 +27,7 @@ pub enum OrgPolicyType { MasterPassword = 1, PasswordGenerator = 2, SingleOrg = 3, - // RequireSso = 4, // Not supported + RequireSso = 4, PersonalOwnership = 5, DisableSend = 6, SendOptions = 7, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index fce9f9c9..fd952955 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -166,9 +166,9 @@ impl Organization { "useGroups": CONFIG.org_groups_enabled(), "useTotp": true, "usePolicies": true, - // "useScim": false, // Not supported (Not AGPLv3 Licensed) - "useSso": false, // Not supported - // "useKeyConnector": false, // Not supported + // "UseScim": false, // Not supported (Not AGPLv3 Licensed) + "useSso": CONFIG.sso_enabled(), + // "UseKeyConnector": false, // Not supported "selfHost": true, "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), @@ -385,7 +385,7 @@ impl UserOrganization { "resetPasswordEnrolled": self.reset_password_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported - "useSso": false, // Not supported + "useSso": CONFIG.sso_enabled(), "useKeyConnector": false, "useSecretsManager": false, "usePasswordManager": true, diff --git a/src/db/models/sso_nonce.rs b/src/db/models/sso_nonce.rs new file mode 100644 index 00000000..0a9533e0 --- /dev/null +++ b/src/db/models/sso_nonce.rs @@ -0,0 +1,60 @@ +use crate::api::EmptyResult; +use crate::db::DbConn; +use crate::error::MapResult; + +db_object! { + #[derive(Identifiable, Queryable, Insertable)] + #[diesel(table_name = sso_nonce)] + #[diesel(primary_key(nonce))] + pub struct SsoNonce { + pub nonce: String, + } +} + +/// Local methods +impl SsoNonce { + pub fn new(nonce: String) -> Self { + Self { + nonce, + } + } +} + +/// Database methods +impl SsoNonce { + pub async fn save(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + diesel::replace_into(sso_nonce::table) + .values(SsoNonceDb::to_db(self)) + .execute(conn) + .map_res("Error saving SSO device") + } + postgresql { + let value = SsoNonceDb::to_db(self); + diesel::insert_into(sso_nonce::table) + .values(&value) + .execute(conn) + .map_res("Error saving SSO nonce") + } + } + } + + pub async fn delete(self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce))) + .execute(conn) + .map_res("Error deleting SSO nonce") + }} + } + + pub async fn find(nonce: &str, conn: &mut DbConn) -> Option { + db_run! { conn: { + sso_nonce::table + .filter(sso_nonce::nonce.eq(nonce)) + .first::(conn) + .ok() + .from_db() + }} + } +} diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 0fb286a4..91392524 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -243,6 +243,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 26bf4b68..fad549d8 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -243,6 +243,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 26bf4b68..fad549d8 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -243,6 +243,12 @@ table! { } } +table! { + sso_nonce (nonce) { + nonce -> Text, + } +} + table! { emergency_access (uuid) { uuid -> Text, diff --git a/src/mail.rs b/src/mail.rs index 151554a1..4ff6725a 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -492,6 +492,18 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } +pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult { + let (subject, body_html, body_text) = get_text( + "email/set_password", + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "user_name": user_name, + }), + )?; + send_email(address, &subject, body_html, body_text).await +} + pub async fn send_test(address: &str) -> EmptyResult { let (subject, body_html, body_text) = get_text( "email/smtp_test", diff --git a/src/static/templates/email/set_password.hbs b/src/static/templates/email/set_password.hbs new file mode 100644 index 00000000..923c80f2 --- /dev/null +++ b/src/static/templates/email/set_password.hbs @@ -0,0 +1,6 @@ +Master Password Has Been Changed + +The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. + +=== +{{> email/email_footer_text }} \ No newline at end of file diff --git a/src/static/templates/email/set_password.html.hbs b/src/static/templates/email/set_password.html.hbs new file mode 100644 index 00000000..ede5da0c --- /dev/null +++ b/src/static/templates/email/set_password.html.hbs @@ -0,0 +1,11 @@ +Master Password Has Been Changed + +{{> email/email_header }} + + + + +
+ The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately. +
+{{> email/email_footer }} \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index 29df7bbc..01e04adc 100644 --- a/src/util.rs +++ b/src/util.rs @@ -7,7 +7,7 @@ use num_traits::ToPrimitive; use once_cell::sync::Lazy; use rocket::{ fairing::{Fairing, Info, Kind}, - http::{ContentType, Header, HeaderMap, Method, Status}, + http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status}, request::FromParam, response::{self, Responder}, Data, Orbit, Request, Response, Rocket, @@ -131,8 +131,9 @@ impl Cors { fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option { let origin = Cors::get_header(headers, "Origin"); let domain_origin = CONFIG.domain_origin(); + let sso_origin = CONFIG.sso_authority(); let safari_extension_origin = "file://"; - if origin == domain_origin || origin == safari_extension_origin { + if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin { Some(origin) } else { None @@ -257,6 +258,33 @@ impl<'r> FromParam<'r> for SafeString { } } +pub struct CustomRedirect { + pub url: String, + pub headers: Vec<(String, String)>, +} + +impl<'r> rocket::response::Responder<'r, 'static> for CustomRedirect { + fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> { + let mut response = Response::build() + .status(rocket::http::Status { + code: 307, + }) + .raw_header("Location", self.url) + .header(ContentType::HTML) + .finalize(); + + // Normal headers + response.set_raw_header("Referrer-Policy", "same-origin"); + response.set_raw_header("X-XSS-Protection", "0"); + + for header in &self.headers { + response.set_raw_header(header.0.clone(), header.1.clone()); + } + + Ok(response) + } +} + // Log all the routes from the main paths list, and the attachments endpoint // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; @@ -994,3 +1022,29 @@ mod tests { }); } } + +pub struct CookieManager<'a> { + jar: &'a CookieJar<'a>, +} + +impl<'a> CookieManager<'a> { + pub fn new(jar: &'a CookieJar<'a>) -> Self { + Self { + jar, + } + } + + pub fn set_cookie(&self, name: String, value: String) { + let cookie = Cookie::build((name, value)).same_site(SameSite::Lax); + + self.jar.add(cookie) + } + + pub fn get_cookie(&self, name: String) -> Option { + self.jar.get(&name).map(|c| c.value().to_string()) + } + + pub fn delete_cookie(&self, name: String) { + self.jar.remove(Cookie::from(name)); + } +}