From da98ea5cf27d0cb40db77a263b77b3fc2b3bf320 Mon Sep 17 00:00:00 2001 From: Leonardo Covarrubias Date: Sat, 1 Jan 2022 15:33:46 -0500 Subject: [PATCH] :sparkle: add keycloak group and role based visibility --- docs/configuring.md | 11 +++++ package.json | 2 +- src/main.js | 22 +++------- src/utils/Auth.js | 68 ++++++++++++++++++++++++++--- src/utils/CheckSectionVisibility.js | 48 ++++++++++++++++---- src/utils/ConfigSchema.json | 54 +++++++++++++++++++++++ src/utils/defaults.js | 1 + 7 files changed, 175 insertions(+), 31 deletions(-) diff --git a/docs/configuring.md b/docs/configuring.md index 23f8d186..71fb1ceb 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -215,6 +215,8 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **`hideForUsers`** | `string[]` | _Optional_ | Current section will be visible to all users, except for those specified in this list **`showForUsers`** | `string[]` | _Optional_ | Current section will be hidden from all users, except for those specified in this list **`hideForGuests`** | `boolean` | _Optional_ | Current section will be visible for logged in users, but not for guests (see `appConfig.enableGuestAccess`). Defaults to `false` +**`hideForKeycloakUsers`** | `object` | _Optional_ | Current section will be visible to all keycloak users, except for those configured via these groups and roles. See `hideForKeycloakUsers` +**`showForKeycloakUsers`** | `object` | _Optional_ | Current section will be hidden from all keyclaok users, except for those configured via these groups and roles. See `showForKeycloakUsers` **[⬆️ Back to Top](#configuring)** @@ -226,6 +228,15 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)** **[⬆️ Back to Top](#configuring)** +### `section.displayData.hideForKeycloakUsers` and `section.displayData.showForKeycloakUsers` + +**Field** | **Type** | **Required**| **Description** +--- |------------| --- | --- +**`groups`** | `string[]` | _Optional_ | Current Section will be hidden or shown based on the user having any of the groups in this list +**`roles`** | `string[]` | _Optional_ | Current Section will be hidden or shown based on the user having any of the roles in this list + +**[⬆️ Back to Top](#configuring)** + --- ## Notes diff --git a/package.json b/package.json index ca3a3a6b..0a26e47c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Dashy", - "version": "1.9.5", + "version": "1.9.6", "license": "MIT", "main": "server", "author": "Alicia Sykes (https://aliciasykes.com)", diff --git a/src/main.js b/src/main.js index 86fc9863..d71d46ee 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,6 @@ // Import core framework and essential utils import Vue from 'vue'; import VueI18n from 'vue-i18n'; // i18n for localization -import Keycloak from 'keycloak-js'; // Import component Vue plugins, used throughout the app import VTooltip from 'v-tooltip'; // A Vue directive for Popper.js, tooltip component @@ -21,7 +20,7 @@ import clickOutside from '@/utils/ClickOutside'; // Directive for closing p import { messages } from '@/utils/languages'; // Language texts import ErrorReporting from '@/utils/ErrorReporting'; // Error reporting initializer (off) import { toastedOptions, tooltipOptions, language as defaultLanguage } from '@/utils/defaults'; -import { isKeycloakEnabled, getKeycloakConfig } from '@/utils/Auth'; // Keycloak auth config +import { isKeycloakEnabled, cleanupKeycloakInfo, initKeycloak } from '@/utils/Auth'; // Keycloak auth config // Initialize global Vue components Vue.use(VueI18n); @@ -59,22 +58,13 @@ const mount = () => new Vue({ store, router, render, i18n, }).$mount('#app'); +// every page reload removes keycloak user data +cleanupKeycloakInfo(); // If Keycloak not enabled, then proceed straight to the app if (!isKeycloakEnabled()) { mount(); } else { // Keycloak is enabled, redirect to KC login page - const { serverUrl, realm, clientId } = getKeycloakConfig(); - const initOptions = { - url: `${serverUrl}/auth`, realm, clientId, onLoad: 'login-required', - }; - const keycloak = Keycloak(initOptions); - keycloak.init({ onLoad: initOptions.onLoad }).then((auth) => { - if (!auth) { - // Not authenticated, reload to Keycloak login page - window.location.reload(); - } else { - // Yay - user successfully authenticated with Keycloak, render the app! - mount(); - } - }); + initKeycloak() + .then(() => mount()) + .catch(() => window.location.reload()); } diff --git a/src/utils/Auth.js b/src/utils/Auth.js index a1ac8c90..7e49f2b5 100644 --- a/src/utils/Auth.js +++ b/src/utils/Auth.js @@ -1,4 +1,5 @@ import sha256 from 'crypto-js/sha256'; +import Keycloak from 'keycloak-js'; import ConfigAccumulator from '@/utils/ConfigAccumalator'; import ErrorHandler from '@/utils/ErrorHandler'; import { cookieKeys, localStorageKeys, userStateEnum } from '@/utils/defaults'; @@ -39,6 +40,62 @@ export const getKeycloakConfig = () => { return keycloak; }; +/** + * helper that persists keycloak user data in browser local storage + * @param {Keycloak.KeycloakInstance} keycloak The username of user + */ +const storeKeycloakInfo = (keycloak) => { + if (keycloak.tokenParsed && typeof keycloak.tokenParsed === 'object') { + const { + groups, + realm_access: realmAccess, + resource_access: resourceAccess, + azp: clientId, + } = keycloak.tokenParsed; + + const realmRoles = realmAccess.roles || []; + + let clientRoles = []; + if (Object.hasOwn(resourceAccess, clientId)) { + clientRoles = resourceAccess[clientId].roles || []; + } + + const roles = [...realmRoles, ...clientRoles]; + + const info = { + groups, + roles, + }; + + localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info)); + } +}; + +/* remove keycloak local storage */ +export const cleanupKeycloakInfo = () => localStorage.removeItem(localStorageKeys.KEYCLOAK_INFO); + +/* starts the keycloak login process and gathers user data */ +export const initKeycloak = () => { + const { serverUrl, realm, clientId } = getKeycloakConfig(); + const initOptions = { + url: `${serverUrl}/auth`, realm, clientId, onLoad: 'login-required', + }; + const keycloak = Keycloak(initOptions); + + return new Promise((resolve, reject) => { + keycloak.init({ onLoad: initOptions.onLoad }) + .then((auth) => { + if (auth) { + storeKeycloakInfo(keycloak); + return resolve(); + } else { + return reject(new Error('Not authenticated')); + } + }) + .catch((reason) => reject(reason)); + }); +}; + /* Returns array of users from appConfig.auth, if available, else an empty array */ const getUsers = () => { const appConfig = getAppConfig(); @@ -65,7 +122,6 @@ const generateUserToken = (user) => { /** * Checks if the user is currently authenticated - * @param {Array[Object]} users An array of user objects pulled from the config * @returns {Boolean} Will return true if the user is logged in, else false */ export const isLoggedIn = () => { @@ -95,7 +151,7 @@ export const isAuthEnabled = () => { /* Returns true if guest access is enabled */ export const isGuestAccessEnabled = () => { const appConfig = getAppConfig(); - if (appConfig.auth && typeof appConfig.auth === 'object') { + if (appConfig.auth && typeof appConfig.auth === 'object' && !isKeycloakEnabled()) { return appConfig.auth.enableGuestAccess || false; } return false; @@ -108,6 +164,7 @@ export const isGuestAccessEnabled = () => { * @param {String} username The username entered by the user * @param {String} pass The password entered by the user * @param {String[]} users An array of valid user objects + * @param {Object} messages A static message template object * @returns {Object} An object containing a boolean result and a message */ export const checkCredentials = (username, pass, users, messages) => { @@ -146,7 +203,7 @@ export const login = (username, pass, timeout) => { }; /** - * Removed the browsers cookie, causing user to be logged out + * Removed the browsers' cookie, causing user to be logged out */ export const logout = () => { document.cookie = 'authenticationToken=null'; @@ -164,7 +221,7 @@ export const getCurrentUser = () => { if (!username) return false; // No username let foundUserObject = false; // Value to return getUsers().forEach((user) => { - // If current logged in user found, then return that user + // If current logged-in user found, then return that user if (user.user === username) foundUserObject = user; }); return foundUserObject; @@ -182,11 +239,10 @@ export const isLoggedInAsGuest = () => { /** * Checks if the current user has admin privileges. - * If no users are setup, then function will always return true + * If no users are set up, then function will always return true * But if auth is configured, then will verify user is correctly * logged in and then check weather they are of type admin, and * return false if any conditions fail - * @param {String[]} - Array of users * @returns {Boolean} - True if admin privileges */ export const isUserAdmin = () => { diff --git a/src/utils/CheckSectionVisibility.js b/src/utils/CheckSectionVisibility.js index b5b65f4e..555fadcd 100644 --- a/src/utils/CheckSectionVisibility.js +++ b/src/utils/CheckSectionVisibility.js @@ -6,24 +6,32 @@ // Import helper functions from auth, to get current user, and check if guest import { getCurrentUser, isLoggedInAsGuest } from '@/utils/Auth'; +import { localStorageKeys } from '@/utils/defaults'; -/* Helper function, checks if a given username appears in a user array */ -const determineVisibility = (visibilityList, cUsername) => { +/* Helper function, checks if a given testValue is found in the visibility list */ +const determineVisibility = (visibilityList, testValue) => { let isFound = false; - visibilityList.forEach((userInList) => { - if (userInList.toLowerCase() === cUsername) isFound = true; + visibilityList.forEach((visibilityItem) => { + if (visibilityItem.toLowerCase() === testValue.toLowerCase()) isFound = true; }); return isFound; }; +/* Helper function, determines if two arrays have any intersecting elements + (one or more items that are the same) */ +const determineIntersection = (source = [], target = []) => { + const intersections = source.filter(item => target.indexOf(item) !== -1); + return intersections.length > 0; +}; + /* Returns false if this section should not be rendered for the current user/ guest */ const isSectionVisibleToUser = (displayData, currentUser, isGuest) => { // Checks if user explicitly has access to a certain section - const checkVisiblity = () => { + const checkVisibility = () => { if (!currentUser) return true; - const hideFor = displayData.hideForUsers || []; + const hideForUsers = displayData.hideForUsers || []; const cUsername = currentUser.user.toLowerCase(); - return !determineVisibility(hideFor, cUsername); + return !determineVisibility(hideForUsers, cUsername); }; // Checks if user is explicitly prevented from viewing a certain section const checkHiddenability = () => { @@ -33,12 +41,36 @@ const isSectionVisibleToUser = (displayData, currentUser, isGuest) => { if (showForUsers.length < 1) return true; return determineVisibility(showForUsers, cUsername); }; + const checkKeycloakVisibility = () => { + if (!displayData.hideForKeycloakUsers) return true; + + const { groups, roles } = JSON.parse(localStorage.getItem(localStorageKeys.KEYCLOAK_INFO) || '{}'); + const hideForGroups = displayData.hideForKeycloakUsers.groups || []; + const hideForRoles = displayData.hideForKeycloakUsers.roles || []; + + return !(determineIntersection(hideForRoles, roles) + || determineIntersection(hideForGroups, groups)); + }; + const checkKeycloakHiddenability = () => { + if (!displayData.showForKeycloakUsers) return true; + + const { groups, roles } = JSON.parse(localStorage.getItem(localStorageKeys.KEYCLOAK_INFO) || '{}'); + const showForGroups = displayData.showForKeycloakUsers.groups || []; + const showForRoles = displayData.showForKeycloakUsers.roles || []; + + return determineIntersection(showForRoles, roles) + || determineIntersection(showForGroups, groups); + }; // Checks if the current user is a guest, and if section allows for guests const checkIfHideForGuest = () => { const hideForGuest = displayData.hideForGuests; return !(hideForGuest && isGuest); }; - return checkVisiblity() && checkHiddenability() && checkIfHideForGuest(); + return checkVisibility() + && checkHiddenability() + && checkIfHideForGuest() + && checkKeycloakVisibility() + && checkKeycloakHiddenability(); }; /* Putting it all together, the function to export */ diff --git a/src/utils/ConfigSchema.json b/src/utils/ConfigSchema.json index 908bed3d..90c6e49c 100644 --- a/src/utils/ConfigSchema.json +++ b/src/utils/ConfigSchema.json @@ -613,6 +613,60 @@ "type": "boolean", "default": false, "description": "If set to true, section will be visible for logged in users, but not for guests" + }, + "showForKeycloakUsers": { + "title": "Show for select Keycloak groups or roles", + "type": "object", + "description": "Configure the Keycloak groups or roles that will have access to this section", + "additionalProperties": false, + "properties": { + "groups": { + "title": "Show for Groups", + "type": "array", + "description": "Section will be hidden from all users except those with one or more of these groups", + + "items": { + "type": "string", + "description": "Name of the group that will be able to view this section" + } + }, + "roles": { + "title": "Show for Roles", + "type": "array", + "description": "Section will be hidden from all users except those with one or more of these roles", + "items": { + "type": "string", + "description": "Name of the role that will be able to view this section" + } + } + } + }, + "hideForKeycloakUsers": { + "title": "Hide for select Keycloak groups or roles", + "type": "object", + "description": "Configure the Keycloak groups or roles that will not have access to this section", + "additionalProperties": false, + "properties": { + "groups": { + "title": "Hide for Groups", + "type": "array", + "description": "Section will be hidden from users with any of these groups", + + "items": { + "type": "string", + "description": "name of the group that will not be able to view this section" + } + }, + "roles": { + "title": "Hide for Roles", + "type": "array", + "description": "Section will be hidden from users with any of roles", + "items": { + "type": "string", + "description": "name of the role that will not be able to view this section" + } + } + } } } }, diff --git a/src/utils/defaults.js b/src/utils/defaults.js index d952f23c..7a05d047 100644 --- a/src/utils/defaults.js +++ b/src/utils/defaults.js @@ -124,6 +124,7 @@ module.exports = { USERNAME: 'username', MOST_USED: 'mostUsed', LAST_USED: 'lastUsed', + KEYCLOAK_INFO: 'keycloakInfo', }, /* Key names for cookie identifiers */ cookieKeys: {