Browse Source

Merge 25ab8a1454 into 8e7eeab293

pull/6158/merge
Timshel 1 day ago
committed by GitHub
parent
commit
ec61517232
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      .env.template
  2. 1
      Cargo.lock
  3. 1
      Cargo.toml
  4. 2
      playwright/compose/keycloak/Dockerfile
  5. 45
      playwright/compose/keycloak/setup.sh
  6. 5
      playwright/docker-compose.yml
  7. 2
      playwright/test.env
  8. 56
      playwright/tests/sso_roles.spec.ts
  9. 43
      src/api/admin.rs
  10. 56
      src/api/identity.rs
  11. 8
      src/auth.rs
  12. 6
      src/config.rs
  13. 157
      src/sso.rs
  14. 78
      src/sso_client.rs
  15. 28
      src/static/templates/admin/login.hbs
  16. 14
      src/static/templates/admin/settings.hbs

9
.env.template

@ -512,6 +512,15 @@
## Log all the tokens, LOG_LEVEL=debug is required
# SSO_DEBUG_TOKENS=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
########################
### MFA/2FA settings ###
########################

1
Cargo.lock

@ -5722,6 +5722,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"serde_with",
"subtle",
"svg-hush",
"syslog",

1
Cargo.toml

@ -83,6 +83,7 @@ tokio-util = { version = "0.7.16", features = ["compat"]}
# A generic serialization/deserialization framework
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
serde_with = "3.14.0"
# A safe, extensible ORM and Query builder
diesel = { version = "2.2.12", features = ["chrono", "r2d2", "numeric"] }

2
playwright/compose/keycloak/Dockerfile

@ -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

45
playwright/compose/keycloak/setup.sh

@ -21,16 +21,57 @@ set -e
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
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/*\", \"http://localhost:$ROCKET_PORT/*\"]" -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_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 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)
kcadm.sh update users/$TEST_USER3_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER3_PASSWORD" -n
# Dummy realm to mark end of setup
kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLifespan=600"
# 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

5
playwright/docker-compose.yml

@ -30,9 +30,12 @@ services:
- SMTP_FROM
- SMTP_DEBUG
- SSO_DEBUG_TOKENS
- SSO_FRONTEND
- SSO_ENABLED
- SSO_FRONTEND
- SSO_ONLY
- SSO_ROLES_DEFAULT_TO_USER
- SSO_ROLES_ENABLED
- SSO_SCOPES
restart: "no"
depends_on:
- VaultwardenPrebuild

2
playwright/test.env

@ -52,7 +52,7 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config #
######################
ROCKET_PORT=8003
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
DOMAIN=http://localhost:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100

56
playwright/tests/sso_roles.spec.ts

@ -0,0 +1,56 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils";
import { logNewUser, logUser } from './setups/sso';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: true,
SSO_ROLES_ENABLED: true,
SSO_ROLES_DEFAULT_TO_USER: false,
SSO_SCOPES: "email profile roles",
});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('admin have access to vault/admin page', async ({ page }) => {
await logNewUser(test, page, users.user1);
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});
test('user have access to vault', async ({ page }) => {
await logNewUser(test, page, users.user2);
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'You do not have access' })).toBeVisible();
});
test('No role cannot log', async ({ page }) => {
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.getByLabel(/Email address/).fill(users.user3.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user3.name);
await page.getByLabel('Password', { exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Auth failed', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await utils.checkNotification(page, 'Invalid user role');
});
});

43
src/api/admin.rs

@ -31,8 +31,12 @@ use crate::{
CONFIG, VERSION,
};
#[allow(clippy::nonminimal_bool)]
pub fn routes() -> Vec<Route> {
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];
}
@ -157,6 +161,7 @@ fn render_admin_login(msg: Option<&str>, redirect: Option<String>) -> 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()
});
@ -172,6 +177,24 @@ struct LoginForm {
redirect: Option<String>,
}
pub fn add_admin_cookie(cookies: &CookieJar<'_>, is_secure: bool) {
let claims = generate_admin_claims();
let jwt = encode_jwt(&claims);
let cookie = Cookie::build((COOKIE_NAME, jwt))
.path(admin_path())
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(is_secure);
cookies.add(cookie);
}
pub fn remove_admin_cookie(cookies: &CookieJar<'_>) {
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
}
#[post("/", format = "application/x-www-form-urlencoded", data = "<data>")]
fn post_admin_login(
data: Form<LoginForm>,
@ -195,17 +218,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(time::Duration::minutes(CONFIG.admin_session_lifetime()))
.same_site(SameSite::Strict)
.http_only(true)
.secure(secure.https);
cookies.add(cookie);
add_admin_cookie(cookies, secure.https);
if let Some(redirect) = redirect {
Ok(Redirect::to(format!("{}{redirect}", admin_path())))
} else {
@ -263,6 +276,7 @@ fn render_admin_page() -> ApiResult<Html<String>> {
let settings_json = json!({
"config": CONFIG.prepare_json(),
"can_backup": *CAN_BACKUP,
"sso_only": CONFIG.sso_enabled() && CONFIG.sso_roles_enabled(),
});
let text = AdminTemplateData::new("admin/settings", settings_json).render()?;
Ok(Html(text))
@ -331,7 +345,7 @@ async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
#[get("/logout")]
fn logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
remove_admin_cookie(cookies);
Redirect::to(admin_path())
}
@ -839,8 +853,7 @@ impl<'r> FromRequest<'r> for AdminToken {
};
if decode_admin(access_token).is_err() {
// Remove admin cookie
cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
remove_admin_cookie(cookies);
error!("Invalid or expired admin JWT. IP: {}.", &ip.ip);
return Outcome::Error((Status::Unauthorized, "Session expired"));
}

56
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,
@ -12,6 +12,7 @@ use serde_json::Value;
use crate::api::core::two_factor::webauthn::Webauthn2FaConfig;
use crate::{
api::{
admin,
core::{
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
log_user_event,
@ -22,7 +23,7 @@ use crate::{
ApiResult, EmptyResult, JsonResult,
},
auth,
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure},
db::{models::*, DbConn},
error::MapResult,
mail, sso,
@ -50,6 +51,8 @@ async fn login(
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
cookies: &CookieJar<'_>,
secure: Secure,
mut conn: DbConn,
) -> JsonResult {
let data: ConnectData = data.into_inner();
@ -59,7 +62,7 @@ async fn login(
let login_result = match data.grant_type.as_ref() {
"refresh_token" => {
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
_refresh_login(data, &mut conn, &client_header.ip).await
_refresh_login(data, &mut conn, cookies, &client_header.ip, secure).await
}
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"password" => {
@ -93,7 +96,8 @@ async fn login(
_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_id, &mut conn, &client_header.ip, &client_version, webauthn).await
_sso_login(data, &mut user_id, &mut conn, cookies, &client_header.ip, secure, &client_version, webauthn)
.await
}
"authorization_code" => err!("SSO sign-in is not available"),
t => err!("Invalid type", t),
@ -130,7 +134,13 @@ async fn login(
}
// Return Status::Unauthorized to trigger logout
async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
async fn _refresh_login(
data: ConnectData,
conn: &mut DbConn,
cookies: &CookieJar<'_>,
ip: &ClientIp,
secure: Secure,
) -> JsonResult {
// Extract token
let refresh_token = match data.refresh_token {
Some(token) => token,
@ -147,10 +157,17 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) ->
Err(err) => {
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code)
}
Ok((mut device, auth_tokens)) => {
Ok((user, mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status
device.save(conn).await?;
if auth_tokens.is_admin {
debug!("Refreshed {} admin cookie", user.email);
admin::add_admin_cookie(cookies, secure.https);
} else {
admin::remove_admin_cookie(cookies);
}
let result = json!({
"refresh_token": auth_tokens.refresh_token(),
"access_token": auth_tokens.access_token(),
@ -165,11 +182,14 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) ->
}
// After exchanging the code we need to check first if 2FA is needed before continuing
#[allow(clippy::too_many_arguments)]
async fn _sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &mut DbConn,
cookies: &CookieJar<'_>,
ip: &ClientIp,
secure: Secure,
client_version: &Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
) -> JsonResult {
@ -293,28 +313,16 @@ async fn _sso_login(
}
};
// We passed 2FA get full user information
let auth_user = sso::redeem(&user_infos.state, conn).await?;
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: user_infos.identifier,
};
user_sso.save(conn).await?;
}
// We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&user, &device, data.client_id, sso_user, &user_infos.state, conn).await?;
// Set the user_uuid here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
let auth_tokens = sso::create_auth_tokens(
&device,
&user,
data.client_id,
auth_user.refresh_token,
auth_user.access_token,
auth_user.expires_in,
)?;
if auth_tokens.is_admin {
info!("User {} logged with admin cookie", user.email);
admin::add_admin_cookie(cookies, secure.https);
}
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, &now, conn, ip).await
}

8
src/auth.rs

@ -1149,6 +1149,7 @@ pub struct RefreshJwtClaims {
pub struct AuthTokens {
pub refresh_claims: RefreshJwtClaims,
pub access_claims: LoginJwtClaims,
pub is_admin: bool,
}
impl AuthTokens {
@ -1192,6 +1193,7 @@ impl AuthTokens {
Self {
refresh_claims,
access_claims,
is_admin: false,
}
}
}
@ -1201,7 +1203,7 @@ pub async fn refresh_tokens(
refresh_token: &str,
client_id: Option<String>,
conn: &mut DbConn,
) -> ApiResult<(Device, AuthTokens)> {
) -> ApiResult<(User, Device, AuthTokens)> {
let refresh_claims = match decode_refresh(refresh_token) {
Err(err) => {
debug!("Failed to decode {} refresh_token: {refresh_token}", ip.ip);
@ -1229,7 +1231,7 @@ pub async fn refresh_tokens(
AuthTokens::new(&device, &user, refresh_claims.sub, client_id)
}
AuthMethod::Sso if CONFIG.sso_enabled() => {
sso::exchange_refresh_token(&device, &user, client_id, refresh_claims).await?
sso::exchange_refresh_token(&user, &device, client_id, refresh_claims).await?
}
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
@ -1237,5 +1239,5 @@ pub async fn refresh_tokens(
_ => err!("Invalid auth method, cannot refresh token"),
};
Ok((device, auth_tokens))
Ok((user, device, auth_tokens))
}

6
src/config.rs

@ -716,6 +716,12 @@ make_config! {
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
/// Roles mapping |> Enable the mapping of roles (user/admin) from the access_token
sso_roles_enabled: bool, true, def, false;
/// Missing roles default to user |> If `false` user with no role won't be able to log
sso_roles_default_to_user: bool, true, def, true;
/// Path to read roles in IDToken or User Info
sso_roles_token_path: String, true, auto, |c| format!("/resource_access/{}/roles", c.sso_client_id);
},
/// Yubikey settings

157
src/sso.rs

@ -1,6 +1,8 @@
use chrono::Utc;
use derive_more::{AsRef, Deref, Display, From};
use regex::Regex;
use serde::de::DeserializeOwned;
use serde_with::{serde_as, DefaultOnError};
use std::time::Duration;
use url::Url;
@ -12,10 +14,10 @@ use crate::{
auth,
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
db::{
models::{Device, SsoNonce, User},
models::{Device, EventType, SsoNonce, SsoUser, User},
DbConn,
},
sso_client::Client,
sso_client::{AllAdditionalClaims, Client},
CONFIG,
};
@ -139,15 +141,18 @@ impl BasicTokenClaims {
}
}
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
// 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<T: DeserializeOwned>(token_name: &str, token: &str) -> ApiResult<T> {
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::<T>(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}")),
}
}
@ -215,6 +220,28 @@ impl OIDCIdentifier {
}
}
#[derive(Debug)]
struct AdditionnalClaims {
role: Option<UserRole>,
}
impl AdditionnalClaims {
pub fn is_admin(&self) -> bool {
self.role.as_ref().is_some_and(|x| x == &UserRole::Admin)
}
}
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserRole {
Admin,
User,
}
#[serde_as]
#[derive(Deserialize)]
struct UserRoles<T: DeserializeOwned>(#[serde_as(as = "Vec<DefaultOnError>")] Vec<Option<T>>);
#[derive(Clone, Debug)]
pub struct AuthenticatedUser {
pub refresh_token: Option<String>,
@ -224,6 +251,13 @@ pub struct AuthenticatedUser {
pub email: String,
pub email_verified: Option<bool>,
pub user_name: Option<String>,
pub role: Option<UserRole>,
}
impl AuthenticatedUser {
pub fn is_admin(&self) -> bool {
self.role.as_ref().is_some_and(|x| x == &UserRole::Admin)
}
}
#[derive(Clone, Debug)]
@ -235,6 +269,44 @@ pub struct UserInformation {
pub user_name: Option<String>,
}
// Errors are logged but will return None
// Return the top most defined Role (https://doc.rust-lang.org/std/cmp/trait.PartialOrd.html#derivable)
fn role_claim<T: DeserializeOwned + Ord>(email: &str, token: &serde_json::Value, source: &str) -> Option<T> {
use crate::serde::Deserialize;
if let Some(json_roles) = token.pointer(&CONFIG.sso_roles_token_path()) {
match UserRoles::<T>::deserialize(json_roles) {
Ok(UserRoles(mut roles)) => {
roles.sort();
roles.into_iter().find(|r| r.is_some()).flatten()
}
Err(err) => {
debug!("Failed to parse {email} roles from {source}: {err}");
None
}
}
} else {
debug!("No roles in {email} {source} at {}", &CONFIG.sso_roles_token_path());
None
}
}
// All claims are read as Value.
fn additional_claims(email: &str, sources: Vec<(&AllAdditionalClaims, &str)>) -> ApiResult<AdditionnalClaims> {
let mut role: Option<UserRole> = None;
if CONFIG.sso_roles_enabled() {
for (ac, source) in sources {
if CONFIG.sso_roles_enabled() {
role = role.or_else(|| role_claim(email, &ac.claims, source))
}
}
}
Ok(AdditionnalClaims {
role,
})
}
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(OIDCCode, OIDCState)> {
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
Ok(code_claims) => match code_claims.code {
@ -299,6 +371,21 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<U
let user_name = id_claims.preferred_username().map(|un| un.to_string());
let additional_claims = additional_claims(
&email,
vec![(id_claims.additional_claims(), "id_token"), (user_info.additional_claims(), "user_info")],
)?;
if CONFIG.sso_roles_enabled() && !CONFIG.sso_roles_default_to_user() && additional_claims.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
}
)
}
let refresh_token = token_response.refresh_token().map(|t| t.secret());
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {
error!("Scope offline_access is present but response contain no refresh_token");
@ -314,6 +401,7 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<U
email: email.clone(),
email_verified,
user_name: user_name.clone(),
role: additional_claims.role,
};
debug!("Authenticated user {authenticated_user:?}");
@ -330,17 +418,43 @@ pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<U
}
// User has passed 2FA flow we can delete `nonce` and clear the cache.
pub async fn redeem(state: &OIDCState, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> {
pub async fn redeem(
user: &User,
device: &Device,
client_id: Option<String>,
sso_user: Option<SsoUser>,
state: &OIDCState,
conn: &mut DbConn,
) -> ApiResult<AuthTokens> {
if let Err(err) = SsoNonce::delete(state, conn).await {
error!("Failed to delete database sso_nonce using {state}: {err}")
}
if let Some(au) = AC_CACHE.get(state) {
let auth_user = if let Some(au) = AC_CACHE.get(state) {
AC_CACHE.invalidate(state);
Ok(au)
au
} else {
err!("Failed to retrieve user info from sso cache")
};
if sso_user.is_none() {
let user_sso = SsoUser {
user_uuid: user.uuid.clone(),
identifier: auth_user.identifier.clone(),
};
user_sso.save(conn).await?;
}
let is_admin = auth_user.is_admin();
create_auth_tokens(
device,
user,
client_id,
auth_user.refresh_token,
auth_user.access_token,
auth_user.expires_in,
is_admin,
)
}
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front).
@ -352,11 +466,12 @@ pub fn create_auth_tokens(
refresh_token: Option<String>,
access_token: String,
expires_in: Option<Duration>,
is_admin: bool,
) -> ApiResult<AuthTokens> {
if !CONFIG.sso_auth_only_not_session() {
let now = Utc::now();
let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", &access_token), expires_in) {
let (ap_nbf, ap_exp) = match (insecure_decode::<BasicTokenClaims>("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"),
@ -365,7 +480,7 @@ pub fn create_auth_tokens(
let access_claims =
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, AuthMethod::Sso.scope_vec(), client_id, now);
_create_auth_tokens(device, refresh_token, access_claims, access_token)
_create_auth_tokens(device, refresh_token, access_claims, access_token, is_admin)
} else {
Ok(AuthTokens::new(device, user, AuthMethod::Sso, client_id))
}
@ -376,9 +491,10 @@ fn _create_auth_tokens(
refresh_token: Option<String>,
access_claims: auth::LoginJwtClaims,
access_token: String,
is_admin: bool,
) -> ApiResult<AuthTokens> {
let (nbf, exp, token) = if let Some(rt) = refresh_token {
match decode_token_claims("refresh_token", &rt) {
match insecure_decode::<BasicTokenClaims>("refresh_token", &rt) {
Err(_) => {
let time_now = Utc::now();
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp();
@ -407,6 +523,7 @@ fn _create_auth_tokens(
Ok(AuthTokens {
refresh_claims,
access_claims,
is_admin,
})
}
@ -414,25 +531,35 @@ fn _create_auth_tokens(
// - the session is close to expiration we will try to extend it
// - the user is going to make an action and we check that the session is still valid
pub async fn exchange_refresh_token(
device: &Device,
user: &User,
device: &Device,
client_id: Option<String>,
refresh_claims: auth::RefreshJwtClaims,
) -> ApiResult<AuthTokens> {
let exp = refresh_claims.exp;
match refresh_claims.token {
Some(TokenWrapper::Refresh(refresh_token)) => {
let client = Client::cached().await?;
let mut is_admin = false;
// Use new refresh_token if returned
let (new_refresh_token, access_token, expires_in) =
Client::exchange_refresh_token(refresh_token.clone()).await?;
client.exchange_refresh_token(refresh_token.clone()).await?;
if CONFIG.sso_roles_enabled() {
let user_info = client.user_info(access_token.clone()).await?;
let ac = additional_claims(&user.email, vec![(user_info.additional_claims(), "user_info")])?;
is_admin = ac.is_admin();
}
create_auth_tokens(
device,
user,
client_id,
new_refresh_token.or(Some(refresh_token)),
access_token,
access_token.into_secret(),
expires_in,
is_admin,
)
}
Some(TokenWrapper::Access(access_token)) => {
@ -455,7 +582,7 @@ pub async fn exchange_refresh_token(
now,
);
_create_auth_tokens(device, None, access_claims, access_token)
_create_auth_tokens(device, None, access_claims, access_token, false)
}
None => err!("No token present while in SSO"),
}

78
src/sso_client.rs

@ -8,6 +8,7 @@ use once_cell::sync::Lazy;
use openidconnect::core::*;
use openidconnect::reqwest;
use openidconnect::*;
use serde_json::Value;
use crate::{
api::{ApiResult, EmptyResult},
@ -21,16 +22,61 @@ static CLIENT_CACHE: Lazy<Cache<String, Client>> = Lazy::new(|| {
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build()
});
/// OpenID Connect Core client.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct AllAdditionalClaims {
#[serde(flatten)]
pub claims: Value,
}
impl AdditionalClaims for AllAdditionalClaims {}
pub type MetadataClient = openidconnect::Client<
AllAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
StandardTokenResponse<
IdTokenFields<
AllAdditionalClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
>,
CoreTokenType,
>,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
pub type CustomClient = openidconnect::Client<
EmptyAdditionalClaims,
AllAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
CoreTokenResponse,
StandardTokenResponse<
IdTokenFields<
AllAdditionalClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJwsSigningAlgorithm,
>,
CoreTokenType,
>,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
@ -66,7 +112,7 @@ impl Client {
Ok(metadata) => metadata,
};
let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
let base_client = MetadataClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
let token_uri = match base_client.token_uri() {
Some(uri) => uri.clone(),
@ -145,7 +191,7 @@ impl Client {
) -> ApiResult<(
StandardTokenResponse<
IdTokenFields<
EmptyAdditionalClaims,
AllAdditionalClaims,
EmptyExtraTokenFields,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
@ -153,7 +199,7 @@ impl Client {
>,
CoreTokenType,
>,
IdTokenClaims<EmptyAdditionalClaims, CoreGenderClaim>,
IdTokenClaims<AllAdditionalClaims, CoreGenderClaim>,
)> {
let oidc_code = AuthorizationCode::new(code.to_string());
@ -196,7 +242,10 @@ impl Client {
}
}
pub async fn user_info(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> {
pub async fn user_info(
&self,
access_token: AccessToken,
) -> ApiResult<UserInfoClaims<AllAdditionalClaims, CoreGenderClaim>> {
match self.core_client.user_info(access_token, None).request_async(&self.http_client).await {
Err(err) => err!(format!("Request to user_info endpoint failed: {err}")),
Ok(user_info) => Ok(user_info),
@ -229,20 +278,19 @@ impl Client {
}
pub async fn exchange_refresh_token(
&self,
refresh_token: String,
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
) -> ApiResult<(Option<String>, AccessToken, Option<Duration>)> {
let rt = RefreshToken::new(refresh_token);
let client = Client::cached().await?;
let token_response =
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await {
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
Ok(token_response) => token_response,
};
let token_response = match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await {
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
Ok(token_response) => token_response,
};
Ok((
token_response.refresh_token().map(|token| token.secret().clone()),
token_response.access_token().secret().clone(),
token_response.access_token().clone(),
token_response.expires_in(),
))
}

28
src/static/templates/admin/login.hbs

@ -8,17 +8,23 @@
{{/if}}
<div class="align-items-center p-3 mb-3 text-opacity-75 text-light bg-danger rounded shadow">
<div>
<h6 class="mb-0 text-light">Authentication key needed to continue</h6>
<small>Please provide it below:</small>
{{#if sso_only}}
<div>
<h6 class="mb-0 text-light">You do not have access to the admin panel (or the admin session expired and you need to log again)</h6>
</div>
{{else}}
<div>
<h6 class="mb-0 text-light">Authentication key needed to continue</h6>
<small>Please provide it below:</small>
<form class="form-inline" method="post" action="{{urlpath}}/admin">
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
{{#if redirect}}
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
{{/if}}
<button type="submit" class="btn btn-primary mt-2">Enter</button>
</form>
</div>
<form class="form-inline" method="post" action="{{urlpath}}/admin">
<input type="password" autocomplete="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token" autofocus="autofocus">
{{#if redirect}}
<input type="hidden" id="redirect" name="redirect" value="/{{redirect}}">
{{/if}}
<button type="submit" class="btn btn-primary mt-2">Enter</button>
</form>
</div>
{{/if}}
</div>
</main>

14
src/static/templates/admin/settings.hbs

@ -1,10 +1,12 @@
<main class="container-xl">
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
You are using a plain text `ADMIN_TOKEN` which is insecure.<br>
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.<br>
See: <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token" target="_blank" rel="noopener noreferrer">Enabling admin page - Secure the `ADMIN_TOKEN`</a>
</div>
{{#unless page_data.sso_only}}
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
You are using a plain text `ADMIN_TOKEN` which is insecure.<br>
Please generate a secure Argon2 PHC string by using `vaultwarden hash` or `argon2`.<br>
See: <a href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page#secure-the-admin_token" target="_blank" rel="noopener noreferrer">Enabling admin page - Secure the `ADMIN_TOKEN`</a>
</div>
{{/unless}}
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
<div>
<h6 class="text-white mb-3">Configuration</h6>

Loading…
Cancel
Save