From 1eb8390e92c85db71736c8cee10af58a1d11908d Mon Sep 17 00:00:00 2001 From: Timshel Date: Wed, 3 Jan 2024 17:24:57 +0100 Subject: [PATCH] Allow SSO role mapping to add admin cookie --- .env.template | 6 ++ README.md | 15 ++++ SSO.md | 3 + src/api/admin.rs | 34 +++++--- src/api/identity.rs | 27 ++++-- src/config.rs | 6 ++ src/sso.rs | 125 +++++++++++++++++++++------ src/static/templates/admin/login.hbs | 28 +++--- 8 files changed, 187 insertions(+), 57 deletions(-) diff --git a/.env.template b/.env.template index 0f9d6f8ff0..b1b895a28a 100644 --- a/.env.template +++ b/.env.template @@ -380,6 +380,12 @@ # SSO_KEY_FILEPATH=%DATA_FOLDER%/sso_key.pub.pem ## Optional Master password policy (minComplexity=[0-4]) # SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' +## Enable the mapping of roles (user/admin) from the access_token +# SSO_ROLES_ENABLED=false +## Missing/Invalid roles default to user +# SSO_ROLES_DEFAULT_TO_USER=true +## Access token path to read roles +# SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles ## Set the lifetime of admin sessions to this value (in minutes). # ADMIN_SESSION_LIFETIME=20 diff --git a/README.md b/README.md index ca60dbf8cd..f620f55388 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ Based on [Timshel/sso-support](https://github.com/Timshel/vaultwarden/tree/sso-s :warning: Branch will be rebased and forced-pushed from time to time. :warning: +## 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. + +### Role mapping + +Allow to map roles from the Access token to users to grant access to `VaultWarden` `admin` console. +Support two roles: `admin` or `user`. + +This feature is controlled by the following conf: + +- `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 Access token + ## Docker Change the docker files to package both front-end from [Timshel/oidc_web_builds](https://github.com/Timshel/oidc_web_builds/releases). diff --git a/SSO.md b/SSO.md index 33b3aec925..00ffc09602 100644 --- a/SSO.md +++ b/SSO.md @@ -22,6 +22,9 @@ The following configurations are available - `SSO_AUTH_FAILURE_SILENT`: Silently redirect to the home instead of displaying a JSON error. - `SSO_KEY_FILEPATH` : Optional public key to validate the JWT token (without it signature check will not be done). - `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy + - `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 Access token The callback url is : `https://your.domain/identity/connect/oidc-signin` diff --git a/src/api/admin.rs b/src/api/admin.rs index 250115605a..a2356f6093 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -28,8 +28,12 @@ use crate::{ CONFIG, VERSION, }; +#[allow(clippy::nonminimal_bool)] pub fn routes() -> Vec { - if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() { + if !CONFIG.disable_admin_token() + && !CONFIG.is_admin_token_set() + && !(CONFIG.sso_enabled() && CONFIG.sso_roles_enabled()) + { return routes![admin_disabled]; } @@ -88,7 +92,7 @@ fn admin_disabled() -> &'static str { "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it" } -const COOKIE_NAME: &str = "VW_ADMIN"; +pub const COOKIE_NAME: &str = "VW_ADMIN"; const ADMIN_PATH: &str = "/admin"; const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z"; @@ -96,7 +100,7 @@ const BASE_TEMPLATE: &str = "admin/base"; const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000"; -fn admin_path() -> String { +pub fn admin_path() -> String { format!("{}{}", CONFIG.domain_path(), ADMIN_PATH) } @@ -151,6 +155,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option) -> ApiResult< let json = json!({ "page_content": "admin/login", "error": msg, + "sso_only": CONFIG.sso_enabled() && CONFIG.sso_roles_enabled(), "redirect": redirect, "urlpath": CONFIG.domain_path() }); @@ -166,6 +171,18 @@ struct LoginForm { redirect: Option, } +pub fn create_admin_cookie<'a>() -> Cookie<'a> { + let claims = generate_admin_claims(); + let jwt = encode_jwt(&claims); + + Cookie::build((COOKIE_NAME, jwt)) + .path(admin_path()) + .max_age(rocket::time::Duration::minutes(CONFIG.admin_session_lifetime())) + .same_site(SameSite::Strict) + .http_only(true) + .into() +} + #[post("/", data = "")] fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp) -> Result { let data = data.into_inner(); @@ -184,16 +201,7 @@ fn post_admin_login(data: Form, cookies: &CookieJar<'_>, ip: ClientIp Err(AdminResponse::Unauthorized(render_admin_login(Some("Invalid admin token, please try again."), redirect))) } else { // If the token received is valid, generate JWT and save it as a cookie - let claims = generate_admin_claims(); - let jwt = encode_jwt(&claims); - - let cookie = Cookie::build((COOKIE_NAME, jwt)) - .path(admin_path()) - .max_age(rocket::time::Duration::minutes(CONFIG.admin_session_lifetime())) - .same_site(SameSite::Strict) - .http_only(true); - - cookies.add(cookie); + cookies.add(create_admin_cookie()); if let Some(redirect) = redirect { Ok(Redirect::to(format!("{}{}", admin_path(), redirect))) } else { diff --git a/src/api/identity.rs b/src/api/identity.rs index ac2db090fb..51c127f5b1 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -10,6 +10,7 @@ use serde_json::Value; use crate::{ api::{ + admin, core::{ accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, log_user_event, @@ -31,7 +32,12 @@ pub fn routes() -> Vec { } #[post("/connect/token", data = "")] -async fn login(data: Form, client_header: ClientHeaders, mut conn: DbConn) -> JsonResult { +async fn login( + data: Form, + client_header: ClientHeaders, + cookies: &CookieJar<'_>, + mut conn: DbConn, +) -> JsonResult { let data: ConnectData = data.into_inner(); let mut user_uuid: Option = None; @@ -72,7 +78,7 @@ async fn login(data: Form, client_header: ClientHeaders, mut conn: _check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?; - _sso_login(data, &mut user_uuid, &mut conn, &client_header.ip).await + _sso_login(data, &mut user_uuid, &mut conn, cookies, &client_header.ip).await } t => err!("Invalid type", t), }; @@ -150,11 +156,13 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult { } // After exchanging the code we need to check first if 2FA is needed before continuing -async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { - if !CONFIG.sso_enabled() { - err!("SSO sign-in is disabled"); - } - +async fn _sso_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + cookies: &CookieJar<'_>, + ip: &ClientIp, +) -> JsonResult { AuthMethod::Sso.check_scope(data.scope.as_ref())?; // Ratelimit the login @@ -198,6 +206,11 @@ async fn _sso_login(data: ConnectData, user_uuid: &mut Option, conn: &mu // Set the user_uuid here to be passed back used for event logging. *user_uuid = Some(user.uuid.clone()); + if auth_user.is_admin() { + info!("User {} logged with admin cookie", user.email); + cookies.add(admin::create_admin_cookie()); + } + let auth_tokens = sso::create_auth_tokens(&device, &user, auth_user.refresh_token, &auth_user.access_token)?; authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await diff --git a/src/config.rs b/src/config.rs index 2120bccc11..46e14650ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -631,6 +631,12 @@ make_config! { sso_key_filepath: String, false, auto, |c| format!("{}/{}", c.data_folder, "sso_key.pub.pem"); /// Optional sso master password policy sso_master_password_policy: String, false, option; + /// Enable the mapping of roles (user/admin) from the access_token + sso_roles_enabled: bool, true, def, false; + /// Missing invalid roles default to user + sso_roles_default_to_user: bool, true, def, true; + /// Access token path to read roles + sso_roles_token_path: String, false, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id); }, /// Yubikey settings diff --git a/src/sso.rs b/src/sso.rs index 9f78ee540c..bd023726b6 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -9,18 +9,16 @@ use once_cell::sync::Lazy; use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims}; use openidconnect::reqwest::async_http_client; use openidconnect::{ - AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IdToken, Nonce, OAuth2TokenResponse, - RefreshToken, Scope, + AccessToken, AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IdToken, Nonce, + OAuth2TokenResponse, RefreshToken, Scope, }; use crate::{ api::ApiResult, auth, auth::AuthMethodScope, - db::{ - models::{Device, SsoNonce, User}, - DbConn, - }, + db::models::{Device, EventType, SsoNonce, User}, + db::DbConn, CONFIG, }; @@ -106,6 +104,18 @@ impl BasicTokenPayload { } } +#[derive(Debug)] +struct AccessTokenPayload { + role: Option, +} + +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + User, +} + #[derive(Clone, Debug)] pub struct AuthenticatedUser { pub nonce: String, @@ -113,6 +123,13 @@ pub struct AuthenticatedUser { pub access_token: String, pub email: String, pub user_name: Option, + pub role: Option, +} + +impl AuthenticatedUser { + pub fn is_admin(&self) -> bool { + self.role.as_ref().is_some_and(|x| x == &UserRole::Admin) + } } struct Decoding { @@ -140,7 +157,7 @@ impl Decoding { } } - pub fn decode_id_token< + pub fn id_token< AC: openidconnect::AdditionalClaims, GC: openidconnect::GenderClaim, JE: openidconnect::JweContentEncryptionAlgorithm, @@ -158,23 +175,53 @@ impl Decoding { match jsonwebtoken::decode::(id_token_str.as_str(), &self.key, &self.id_validation) { Ok(payload) => Ok(payload.claims), Err(err) => { - self.log_decode_debug("identity_token", id_token_str.as_str()); + self.log_debug("identity_token", id_token_str.as_str()); err!(format!("Could not decode id token: {err}")) } } } - pub fn decode_basic_token(&self, token_name: &str, token: &str) -> ApiResult { + fn access_token(&self, email: &str, access_token: &AccessToken) -> ApiResult { + let mut role = None; + + if CONFIG.sso_roles_enabled() { + let access_token_str = access_token.secret(); + + self.log_debug("access_token", access_token_str); + + match jsonwebtoken::decode::(access_token_str, &self.key, &self.access_validation) { + Err(err) => err!(format!("Could not decode access token: {:?}", err)), + Ok(payload) => { + role = decode_roles(email, &payload.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 + } + ) + } + } + } + } + + Ok(AccessTokenPayload { + role, + }) + } + + pub fn basic_token(&self, token_name: &str, token: &str) -> ApiResult { match jsonwebtoken::decode::(token, &self.key, &self.access_validation) { Ok(payload) => Ok(payload.claims), Err(err) => { - self.log_decode_debug(token_name, token); + self.log_debug(token_name, token); err!(format!("Could not decode {token_name}: {err}")) } } } - pub fn log_decode_debug(&self, token_name: &str, token: &str) { + pub fn log_debug(&self, token_name: &str, token: &str) { let _ = jsonwebtoken::decode::(token, &self.debug_key, &self.debug_validation) .map(|payload| debug!("Token {token_name}: {}", payload.claims)); } @@ -228,6 +275,39 @@ pub struct UserInformation { pub user_name: Option, } +// Errors are logged but will return None +fn decode_roles(email: &str, token: &serde_json::Value) -> Option { + let roles_path = CONFIG.sso_roles_token_path(); + + if let Some(json_roles) = token.pointer(&roles_path) { + match serde_json::from_value::>(json_roles.clone()) { + Ok(mut roles) => { + roles.sort(); + roles.into_iter().next() + } + Err(err) => { + debug!("Failed to parse user ({email}) roles: {err}"); + None + } + } + } else { + debug!("No roles in {email} access_token"); + None + } +} + +async fn retrieve_user_info(client: &CoreClient, access_token: AccessToken) -> ApiResult { + let endpoint = match client.user_info(access_token, None) { + Err(err) => err!(format!("No user_info endpoint: {err}")), + Ok(endpoint) => endpoint, + }; + + match endpoint.request_async(async_http_client).await { + Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), + Ok(user_info) => Ok(user_info), + } +} + // During the 2FA flow we will // - retrieve the user information and then only discover he needs 2FA. // - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged. @@ -246,17 +326,9 @@ pub async fn exchange_code(code: &String) -> ApiResult { match client.exchange_code(oidc_code).request_async(async_http_client).await { Ok(token_response) => { - let endpoint = match client.user_info(token_response.access_token().to_owned(), None) { - Err(err) => err!(format!("No user_info endpoint: {err}")), - Ok(endpoint) => endpoint, - }; - - let user_info: CoreUserInfoClaims = match endpoint.request_async(async_http_client).await { - Err(err) => err!(format!("Request to user_info endpoint failed: {err}")), - Ok(user_info) => user_info, - }; - - let id_token = SSO_JWT_VALIDATION.decode_id_token(token_response.extra_fields().id_token())?; + let id_token = SSO_JWT_VALIDATION.id_token(token_response.extra_fields().id_token())?; + let user_info = retrieve_user_info(&client, token_response.access_token().to_owned()).await?; + let user_name = user_info.preferred_username().map(|un| un.to_string()); let email = match id_token.email { Some(email) => email, @@ -266,7 +338,7 @@ pub async fn exchange_code(code: &String) -> ApiResult { }, }; - let user_name = user_info.preferred_username().map(|un| un.to_string()); + let access_token = SSO_JWT_VALIDATION.access_token(&email, token_response.access_token())?; let refresh_token = match token_response.refresh_token() { Some(token) => token.secret().to_string(), @@ -279,9 +351,10 @@ pub async fn exchange_code(code: &String) -> ApiResult { access_token: token_response.access_token().secret().to_string(), email: email.clone(), user_name: user_name.clone(), + role: access_token.role, }; - AC_CACHE.insert(code.clone(), authenticated_user.clone()); + AC_CACHE.insert(code.clone(), authenticated_user); Ok(UserInformation { email, @@ -316,8 +389,8 @@ pub fn create_auth_tokens( refresh_token: String, access_token: &str, ) -> ApiResult { - let refresh_payload = SSO_JWT_VALIDATION.decode_basic_token("refresh_token", &refresh_token)?; - let access_payload = SSO_JWT_VALIDATION.decode_basic_token("access_token", access_token)?; + let refresh_payload = SSO_JWT_VALIDATION.basic_token("refresh_token", &refresh_token)?; + let access_payload = SSO_JWT_VALIDATION.basic_token("access_token", access_token)?; debug!("Refresh_payload: {:?}", refresh_payload); debug!("Access_payload: {:?}", access_payload); diff --git a/src/static/templates/admin/login.hbs b/src/static/templates/admin/login.hbs index 3ea94aecc7..d04f446923 100644 --- a/src/static/templates/admin/login.hbs +++ b/src/static/templates/admin/login.hbs @@ -8,17 +8,23 @@ {{/if}}
-
-
Authentication key needed to continue
- Please provide it below: + {{#if sso_only}} +
+
You do not have access to the admin panel (or the admin session expired and you need to log again)
+
+ {{else}} +
+
Authentication key needed to continue
+ Please provide it below: -
- - {{#if redirect}} - - {{/if}} - -
-
+
+ + {{#if redirect}} + + {{/if}} + +
+
+ {{/if}}