diff --git a/.env.template b/.env.template index 2cff752c8d..a36068919b 100644 --- a/.env.template +++ b/.env.template @@ -445,6 +445,12 @@ # SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' ## Use sso only for authentication not the session lifecycle # SSO_AUTH_ONLY_NOT_SESSION=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 +## Id token path to read roles +# SSO_ROLES_TOKEN_PATH=/resource_access/${SSO_CLIENT_ID}/roles ## 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_OVERRIDE=vaultwarden::sso=debug` need to be set diff --git a/README.md b/README.md index 80fd8bd6fd..0438338d94 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,24 @@ Tagged version are based on Vaultwarden releases, Ex: `1.31.0-1` is the first re \ See [changelog](CHANGELOG.md) for more details. -## Experimental Version +## Additionnal features -Made a version which allow to run the server without storing the master password (it's still required just not sent to the server). +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 + +### 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). It´s experimental, more information in [timshel/experimental](https://github.com/Timshel/vaultwarden/tree/experimental). ## Docker diff --git a/SSO.md b/SSO.md index 4c229c6985..c041bb333d 100644 --- a/SSO.md +++ b/SSO.md @@ -25,7 +25,10 @@ The following configurations are available - `SSO_CLIENT_ID` : Client Id - `SSO_CLIENT_SECRET` : Client Secret - `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy - - `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle + - `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle. + - `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_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_OVERRIDE=vaultwarden::sso=debug` need to be set) @@ -196,6 +199,13 @@ Your configuration should look like this: * `SSO_CLIENT_ID=${Application (client) ID}` * `SSO_CLIENT_SECRET=${Secret Value}` +If you want to leverage role mapping you have to create app roles first as described here: https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-app-roles-in-apps. +Afterwards you can use these settings to derive the `admin` role from the ID token: + +* `SSO_ROLES_ENABLED=true` +* `SSO_ROLES_DEFAULT_TO_USER=true` +* `SSO_ROLES_TOKEN_PATH=/roles + ## Zitadel To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope. diff --git a/docker/keycloak/setup.dockerfile b/docker/keycloak/setup.dockerfile new file mode 100644 index 0000000000..f4cb507403 --- /dev/null +++ b/docker/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 +COPY --from=ubi-micro-build /root/jq /usr/bin/jq + +COPY keycloak_setup.sh /root/keycloak_setup.sh + +ENTRYPOINT ["/root/keycloak_setup.sh"] diff --git a/playwright/compose/keycloak_setup.sh b/playwright/compose/keycloak_setup.sh index 3246ae54e0..8667eb18c7 100755 --- a/playwright/compose/keycloak_setup.sh +++ b/playwright/compose/keycloak_setup.sh @@ -6,12 +6,12 @@ CANARY=/tmp/keycloak_setup_done if [ -f $CANARY ] then - echo "Setup should already be done. Will not run." - exit 0 + echo "Setup should already be done. Will not run." + exit 0 fi while true; do - sleep 5 + sleep 5 kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli EC=$? if [ $EC -eq 0 ]; then @@ -23,12 +23,53 @@ done set -e kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" -kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i + +## Delete default roles mapping +DEFAULT_ROLE_SCOPE_ID=$(kcadm.sh get -r "$TEST_REALM" client-scopes | jq -r '.[] | select(.name == "roles") | .id') +kcadm.sh delete -r "$TEST_REALM" "client-scopes/$DEFAULT_ROLE_SCOPE_ID" + +## Create role mapping client scope +TEST_CLIENT_ROLES_SCOPE_ID=$(kcadm.sh create -r "$TEST_REALM" client-scopes -s name=roles -s protocol=openid-connect -i) +kcadm.sh create -r "$TEST_REALM" "client-scopes/$TEST_CLIENT_ROLES_SCOPE_ID/protocol-mappers/models" \ + -s name=Roles \ + -s protocol=openid-connect \ + -s protocolMapper=oidc-usermodel-client-role-mapper \ + -s consentRequired=false \ + -s 'config."multivalued"=true' \ + -s 'config."claim.name"=resource_access.${client_id}.roles' \ + -s 'config."full.path"=false' \ + -s 'config."id.token.claim"=true' \ + -s 'config."access.token.claim"=false' \ + -s 'config."userinfo.token.claim"=true' + +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" + +## 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' + +# To list roles : kcadm.sh get-roles -r "$TEST_REALM" --cid "$TEST_CLIENT_ID" 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 users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_ID/reset-password" -s type=password -s "value=$TEST_USER_PASSWORD" -n +kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER" --cid "$TEST_CLIENT_ID" --rolename admin + TEST_USER_2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER_2" -s "firstName=$TEST_USER_2" -s "lastName=$TEST_USER_2" -s "email=$TEST_USER_2_MAIL" -s emailVerified=true -s enabled=true -i) -kcadm.sh update users/$TEST_USER_2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n +kcadm.sh update -r "$TEST_REALM" "users/$TEST_USER_2_ID/reset-password" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n +kcadm.sh add-roles -r "$TEST_REALM" --uusername "$TEST_USER_2" --cid "$TEST_CLIENT_ID" --rolename user touch $CANARY + +# TO DEBUG uncomment the following line to keep the setup container running +# sleep 3600 +# THEN in another terminal: +# docker exec -it keycloakSetup-dev /bin/bash +# export PATH=$PATH:/opt/keycloak/bin +# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli +# ENJOY +# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html diff --git a/src/api/admin.rs b/src/api/admin.rs index fdcc0ce800..be2a9ae007 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -18,7 +18,7 @@ use crate::{ core::{log_event, two_factor}, unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, }, - auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp, Secure}, + auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, config::ConfigBuilder, db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, error::{Error, MapResult}, @@ -30,8 +30,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]; } @@ -90,7 +94,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"; @@ -98,7 +102,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) } @@ -153,6 +157,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() }); @@ -168,12 +173,24 @@ 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) + .secure(CONFIG.is_https()) + .into() +} + #[post("/", data = "")] fn post_admin_login( data: Form, cookies: &CookieJar<'_>, ip: ClientIp, - secure: Secure, ) -> Result { let data = data.into_inner(); let redirect = data.redirect; @@ -191,17 +208,7 @@ fn post_admin_login( 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) - .secure(secure.https); - - 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 d53ac472d6..00e9885e02 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -2,7 +2,7 @@ use chrono::{NaiveDateTime, Utc}; use num_traits::FromPrimitive; use rocket::{ form::{Form, FromForm}, - http::Status, + http::{CookieJar, Status}, response::Redirect, serde::json::Json, Route, @@ -11,6 +11,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; @@ -73,7 +79,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 } "authorization_code" => err!("SSO sign-in is not available"), t => err!("Invalid type", t), @@ -152,7 +158,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 { +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 @@ -248,6 +260,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, diff --git a/src/auth.rs b/src/auth.rs index d6e78f66bf..913fbbcda5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -926,35 +926,6 @@ impl<'r> FromRequest<'r> for ClientIp { } } -pub struct Secure { - pub https: bool, -} - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for Secure { - type Error = (); - - async fn from_request(request: &'r Request<'_>) -> Outcome { - let headers = request.headers(); - - // Try to guess from the headers - let protocol = match headers.get_one("X-Forwarded-Proto") { - Some(proto) => proto, - None => { - if env::var("ROCKET_TLS").is_ok() { - "https" - } else { - "http" - } - } - }; - - Outcome::Success(Secure { - https: protocol == "https", - }) - } -} - pub struct WsAccessTokenHeader { pub access_token: Option, } diff --git a/src/config.rs b/src/config.rs index 64cbd478a3..a4ec024b8f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -648,6 +648,12 @@ make_config! { sso_master_password_policy: String, true, option; /// Use sso only for auth not the session lifecycle sso_auth_only_not_session: bool, true, def, false; + /// Enable the mapping of roles (user/admin) from the access_token + sso_roles_enabled: bool, false, def, false; + /// Missing invalid roles default to user + 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); /// Client cache for discovery endpoint. Duration in seconds (0 or less to disable). sso_client_cache_expiration: u64, true, def, 0; /// Log all tokens, `LOG_LEVEL=debug` or `LOG_LEVEL_OVERRIDE=vaultwarden::sso=debug` is required @@ -1327,6 +1333,11 @@ impl Config { token.is_some() && !token.unwrap().trim().is_empty() } + /// Tests whether the domain contain HTTPS. + pub fn is_https(&self) -> bool { + self.domain().starts_with("https") + } + pub fn render_template( &self, name: &str, diff --git a/src/sso.rs b/src/sso.rs index f2625f1d8b..2d5728acaf 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -1,5 +1,6 @@ use chrono::Utc; use regex::Regex; +use serde::de::DeserializeOwned; use std::borrow::Cow; use std::time::Duration; use url::Url; @@ -21,7 +22,7 @@ use crate::{ auth, auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, db::{ - models::{Device, SsoNonce, User}, + models::{Device, EventType, SsoNonce, User}, DbConn, }, CONFIG, @@ -125,15 +126,18 @@ impl BasicTokenClaims { } } -fn decode_token_claims(token_name: &str, token: &str) -> ApiResult { +// IdToken validation is handled by IdToken.claims +// This is only used to retrive additionnal claims which are configurable +// Or to try to parse access_token and refresh_tken as JWT to find exp +fn insecure_decode(token_name: &str, token: &str) -> ApiResult { let mut validation = jsonwebtoken::Validation::default(); validation.set_issuer(&[CONFIG.sso_authority()]); validation.insecure_disable_signature_validation(); validation.validate_aud = false; - match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { + match jsonwebtoken::decode::(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) { Ok(btc) => Ok(btc.claims), - Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")), + Err(err) => err_silent!(format!("Failed to decode {token_name}: {err}")), } } @@ -253,6 +257,18 @@ pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &st Ok(auth_url) } +#[derive(Debug)] +struct AdditionnalClaims { + 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 refresh_token: Option, @@ -262,6 +278,13 @@ pub struct AuthenticatedUser { pub email: String, pub email_verified: Option, 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) + } } #[derive(Clone, Debug)] @@ -273,6 +296,53 @@ pub struct UserInformation { pub user_name: Option, } +// Errors are logged but will return None +fn roles(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) => { + roles.sort(); + roles.into_iter().next() + } + Err(err) => { + debug!("Failed to parse user ({email}) roles: {err}"); + None + } + } + } else { + debug!("No roles in {email} id_token at {}", &CONFIG.sso_roles_token_path()); + None + } +} + +// 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; + + if CONFIG.sso_roles_enabled() { + 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 + } + ) + } + } + } + } + + Ok(AdditionnalClaims { + role, + }) +} + async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(String, String)> { match auth::decode_jwt::(code, SSO_JWT_ISSUER.to_string()) { Ok(code_claims) => match code_claims.code { @@ -368,8 +438,9 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult ApiResult("access_token", access_token), expires_in) { (Ok(ap), _) => (ap.nbf(), ap.exp), (Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()), _ => err!("Non jwt access_token and empty expires_in"), @@ -450,7 +522,7 @@ fn _create_auth_tokens( access_token: &str, ) -> ApiResult { let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() { - match decode_token_claims("refresh_token", rt) { + match insecure_decode::("refresh_token", rt) { Err(_) => { let time_now = Utc::now(); let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(); 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}}