🔀 Merge pull request #397 from leocov-dev/FEATURE/keycloak-user-data

 Adds Keycloak group and role based visibility
Credit to @leocov-dev
Closes #342
This commit is contained in:
Alicia Sykes
2022-01-02 03:19:27 +00:00
committed by GitHub
11 changed files with 255 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div>
<!-- If auth configured, show status text -->
<span class="user-type-note">{{ makeText() }}</span>
<span class="user-type-note">{{ makeUserGreeting() }}</span>
<div class="display-options">
<!-- If user logged in, show logout button -->
<IconLogout
@@ -17,6 +17,13 @@
v-tooltip="tooltip($t('settings.sign-in-tooltip'))"
class="layout-icon" tabindex="-2"
/>
<!-- If user logged in via keycloak, show keycloak logout button -->
<IconLogout
v-if="userType == userStateEnum.keycloakEnabled"
@click="keycloakLogout()"
v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2"
/>
</div>
</div>
</template>
@@ -24,6 +31,7 @@
<script>
import router from '@/router';
import { logout as registerLogout } from '@/utils/Auth';
import { getKeycloakAuth } from '@/utils/KeycloakAuth';
import { localStorageKeys, userStateEnum } from '@/utils/defaults';
import IconLogout from '@/assets/interface-icons/user-logout.svg';
@@ -48,14 +56,22 @@ export default {
router.push({ path: '/login' });
}, 500);
},
keycloakLogout() {
const keycloak = getKeycloakAuth();
this.$toasted.show(this.$t('login.logout-message'));
setTimeout(() => {
keycloak.logout();
}, 500);
},
goToLogin() {
router.push({ path: '/login' });
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
makeText() {
if (this.userType === userStateEnum.loggedIn) {
makeUserGreeting() {
if (this.userType === userStateEnum.loggedIn
|| this.userType === userStateEnum.keycloakEnabled) {
const username = localStorage[localStorageKeys.USERNAME];
return username ? this.$t('settings.sign-in-welcome', { username }) : '';
}
@@ -73,7 +89,6 @@ export default {
span.user-type-note {
color: var(--settings-text-color);
text-transform: capitalize;
margin-right: 0.5rem;
}

View File

@@ -10,7 +10,7 @@
<LayoutSelector :displayLayout="displayLayout" />
<ItemSizeSelector :iconSize="iconSize" />
<ConfigLauncher />
<AuthButtons v-if="userState != 'noone'" :userType="userState" />
<AuthButtons v-if="userState !== 0" :userType="userState" />
</div>
<div :class="`show-hide-container ${settingsVisible? 'hide-btn' : 'show-btn'}`">
<button @click="toggleSettingsVisibility()"
@@ -80,7 +80,7 @@ export default {
/**
* Determines which button should display, based on the user type
* 0 = Auth not configured, don't show anything
* 1 = Auth condifured, and user logged in, show logout button
* 1 = Auth configured, and user logged in, show logout button
* 2 = Auth configured, guest access enabled, and not logged in, show login
* Note that if auth is enabled, but not guest access, and user not logged in,
* then they will never be able to view the homepage, so no button needed

View File

@@ -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 { initKeycloakAuth, isKeycloakEnabled } from '@/utils/KeycloakAuth';
// Initialize global Vue components
Vue.use(VueI18n);
@@ -63,18 +62,7 @@ const mount = () => new Vue({
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();
}
});
initKeycloakAuth()
.then(() => mount())
.catch(() => window.location.reload());
}

View File

@@ -2,6 +2,7 @@ import sha256 from 'crypto-js/sha256';
import ConfigAccumulator from '@/utils/ConfigAccumalator';
import ErrorHandler from '@/utils/ErrorHandler';
import { cookieKeys, localStorageKeys, userStateEnum } from '@/utils/defaults';
import { isKeycloakEnabled } from '@/utils/KeycloakAuth';
/* Uses config accumulator to get and return app config */
const getAppConfig = () => {
@@ -19,26 +20,6 @@ const printWarning = () => {
ErrorHandler('From V 1.6.5 onwards, the structure of the users object has changed.');
};
/* Returns true if keycloak is enabled */
export const isKeycloakEnabled = () => {
const appConfig = getAppConfig();
if (!appConfig.auth) return false;
return appConfig.auth.enableKeycloak || false;
};
/* Returns the users keycloak config */
export const getKeycloakConfig = () => {
const appConfig = getAppConfig();
if (!isKeycloakEnabled()) return false;
const { keycloak } = appConfig.auth;
const { serverUrl, realm, clientId } = keycloak;
if (!serverUrl || !realm || !clientId) {
ErrorHandler('Keycloak config missing- please ensure you specify: serverUrl, realm, clientId');
return false;
}
return keycloak;
};
/* Returns array of users from appConfig.auth, if available, else an empty array */
const getUsers = () => {
const appConfig = getAppConfig();
@@ -65,7 +46,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 +75,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 +88,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 +127,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 +145,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 +163,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 = () => {
@@ -212,7 +192,13 @@ export const isUserAdmin = () => {
* then they will never be able to view the homepage, so no button needed
*/
export const getUserState = () => {
const { notConfigured, loggedIn, guestAccess } = userStateEnum; // Numeric enum options
const {
notConfigured,
loggedIn,
guestAccess,
keycloakEnabled,
} = userStateEnum; // Numeric enum options
if (isKeycloakEnabled()) return keycloakEnabled; // Keycloak auth configured
if (!isAuthEnabled()) return notConfigured; // No auth enabled
if (isLoggedIn()) return loggedIn; // User is logged in
if (isGuestAccessEnabled()) return guestAccess; // Guest is viewing

View File

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

View File

@@ -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"
}
}
}
}
}
},

92
src/utils/KeycloakAuth.js Normal file
View File

@@ -0,0 +1,92 @@
import Keycloak from 'keycloak-js';
import ConfigAccumulator from '@/utils/ConfigAccumalator';
import { localStorageKeys } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
const getAppConfig = () => {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
return config.appConfig || {};
};
class KeycloakAuth {
constructor() {
const { auth } = getAppConfig();
const { serverUrl, realm, clientId } = auth.keycloak;
const initOptions = {
url: `${serverUrl}/auth`, realm, clientId, onLoad: 'login-required',
};
this.keycloakClient = Keycloak(initOptions);
}
login() {
return new Promise((resolve, reject) => {
this.keycloakClient.init({ onLoad: 'login-required' })
.then((auth) => {
if (auth) {
this.storeKeycloakInfo();
return resolve();
} else {
return reject(new Error('Not authenticated'));
}
})
.catch((reason) => reject(reason));
});
}
logout() {
localStorage.removeItem(localStorageKeys.USERNAME);
localStorage.removeItem(localStorageKeys.KEYCLOAK_INFO);
this.keycloakClient.logout();
}
storeKeycloakInfo() {
if (this.keycloakClient.tokenParsed && typeof this.keycloakClient.tokenParsed === 'object') {
const {
groups,
realm_access: realmAccess,
resource_access: resourceAccess,
azp: clientId,
preferred_username: preferredUsername,
} = this.keycloakClient.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));
localStorage.setItem(localStorageKeys.USERNAME, preferredUsername);
}
}
}
export const isKeycloakEnabled = () => {
const { auth } = getAppConfig();
if (!auth) return false;
return auth.enableKeycloak || false;
};
let keycloak;
export const initKeycloakAuth = () => {
keycloak = new KeycloakAuth();
return keycloak.login();
};
export const getKeycloakAuth = () => {
if (!keycloak) {
ErrorHandler("Keycloak not initialized, can't get instance of class");
}
return keycloak;
};

View File

@@ -124,6 +124,7 @@ module.exports = {
USERNAME: 'username',
MOST_USED: 'mostUsed',
LAST_USED: 'lastUsed',
KEYCLOAK_INFO: 'keycloakInfo',
},
/* Key names for cookie identifiers */
cookieKeys: {
@@ -278,6 +279,7 @@ module.exports = {
loggedIn: 1,
guestAccess: 2,
notLoggedIn: 3,
keycloakEnabled: 4,
},
/* Progressive Web App settings, used by Vue Config */
pwa: {