🔀 Merge pull request #167 from Lissy93/FEATURE/guest-access

[FEATURE] Adds support for guest access
Fixes #56
This commit is contained in:
Alicia Sykes 2021-08-18 22:33:33 +01:00 committed by GitHub
commit a49181760b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 311 additions and 98 deletions

View File

@ -1,5 +1,9 @@
# Changelog
## ✨ 1.6.2 - Support for Guest Access [PR #167](https://github.com/Lissy93/dashy/pull/167)
- Adds functionality for optional read-only guest access to dashboards with authentication
- Can be enabled by setting `appConfig.enableGuestAccess: true`
## 💄 1.6.1 - Adds new Theme [PR #166](https://github.com/Lissy93/dashy/issues/166)
- Adds Dashy theme, for use in the dev dashboard
## ✨ 1.5.9 - New Minimal/ Startpage View [PR #155](https://github.com/Lissy93/dashy/issues/155)

View File

@ -251,7 +251,10 @@ appConfig:
- user: alicia
hash: 4D1E58C90B3B94BCAD9848ECCACD6D2A8C9FBC5CA913304BBA5CDEAB36FEEFA3
```
At present, access control is handled on the frontend, and therefore in security-critical situations, it is recommended to use an alternate method for authentication, such as [Authelia](https://www.authelia.com/), a VPN or web server and firewall rules.
By default, when authentication is configured no user can access your dashboard without first logging in. If you would like to allow for read-only access by unauthenticated users, then you can enable guest mode, by setting `appConfig.enableGuestAccess: true`.
**Note**: At present, access control is handled on the frontend, and therefore in security-critical situations, it is recommended to use an alternate method for authentication, such as [Authelia](https://www.authelia.com/), a VPN or web server and firewall rules. Instructions for setting this up can be found [in the docs](docs/authentication.md#alternative-authentication-methods).
<p align="center">
<img

View File

@ -36,6 +36,9 @@ A hash is a one-way cryptographic function, meaning that it is easy to generate
## Logging In and Out
Once authentication is enabled, so long as there is no valid token in cookie storage, the application will redirect the user to the login page. When the user enters credentials in the login page, they will be checked, and if valid, then a token will be generated, and they can be redirected to the home page. If credentials are invalid, then an error message will be shown, and they will remain on the login page. Once in the application, to log out the user can click the logout button (in the top-right), which will clear cookie storage, causing them to be redirected back to the login page.
## Enabling Guest Access
With authentication setup, by default no access is allowed to your dashboard without first logging in with valid credentials. Guest mode can be enabled to allow for read-only access to a secured dashboard by any user, without the need to log in. A guest user cannot write any changes to the config file, but can apply modifications locally (stored in their browser). You can enable guest access, by setting `appConfig.enableGuestAccess: true`.
## Security
Since all authentication is happening entirely on the client-side, it is vulnerable to manipulation by an adversary. An attacker could look at the source code, find the function used generate the auth token, then decode the minified JavaScript to find the hash, and manually generate a token using it, then just insert that value as a cookie using the console, and become a logged in user. Therefore, if you need secure authentication for your app, it is strongly recommended to implement this using your web server, or use a VPN to control access to Dashy. The purpose of the login page is merely to prevent immediate unauthorized access to your homepage.

View File

@ -65,6 +65,7 @@ To disallow any changes from being written to disk via the UI config editor, set
**`fontAwesomeKey`** | `string` | _Optional_ | If you have a font-awesome key, then you can use it here and make use of premium icons. It is a 10-digit alpha-numeric string from you're FA kit URL (e.g. `13014ae648`)
**`faviconApi`** | `enum` | _Optional_ | Only applicable if you are using favicons for item icons. Specifies which service to use to resolve favicons. Set to `local` to do this locally, without using an API. Services running locally will use this option always. Available options are: `local`, `faviconkit`, `google`, `clearbit`, `webmasterapi` and `allesedv`. Defaults to `faviconkit`. See [Icons](/docs/icons.md#favicons) for more info
**`auth`** | `array` | _Optional_ | An array of objects containing usernames and hashed passwords. If this is not provided, then authentication will be off by default, and you will not need any credentials to access the app. Note authentication is done on the client side, and so if your instance of Dashy is exposed to the internet, it is recommend to configure your web server to handle this. See [`auth`](#appconfigauth-optional)
**`enableGuestAccess`** | `boolean` | _Optional_ | When set to `true`, an unauthenticated user will be able to access the dashboard, with read-only access, without having to login. Requires `auth` to be configured. Defaults to `false`.
**`layout`** | `enum` | _Optional_ | App layout, either `horizontal`, `vertical`, `auto` or `sidebar`. Defaults to `auto`. This specifies the layout and direction of how sections are positioned on the home screen. This can also be modified from the UI.
**`iconSize`** | `enum` | _Optional_ | The size of link items / icons. Can be either `small`, `medium,` or `large`. Defaults to `medium`. This can also be set directly from the UI.
**`theme`** | `string` | _Optional_ | The default theme for first load (you can change this later from the UI)

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.6.1",
"version": "1.6.2",
"license": "MIT",
"main": "server",
"scripts": {

View File

@ -23,7 +23,12 @@
"error-incorrect-username": "User not found",
"error-incorrect-password": "Incorrect Password",
"success-message": "Logging in...",
"logout-message": "Logged Out"
"logout-message": "Logged Out",
"already-logged-in-title": "Already Logged In",
"already-logged-in-text": "You're logged in as",
"proceed-to-dashboard": "Proceed to Dashboard",
"log-out-button": "Logout",
"proceed-guest-button": "Proceed as Guest"
},
"config": {
"main-tab": "Main Menu",
@ -67,7 +72,9 @@
"item-size-large": "Large",
"config-launcher-label": "Config",
"config-launcher-tooltip": "Update Configuration",
"sign-out-tooltip": "Sign Out"
"sign-out-tooltip": "Sign Out",
"sign-in-tooltip": "Log In",
"sign-in-welcome": "Hello {username}!"
},
"updates": {
"app-version-note": "Dashy version",
@ -109,7 +116,8 @@
"error-msg-save-mode": "Please select a Save Mode: Local or File",
"error-msg-cannot-save": "An error occurred saving config",
"error-msg-bad-json": "Error in JSON, possibly malformed",
"warning-msg-validation": "Validation Warning"
"warning-msg-validation": "Validation Warning",
"not-admin-note": "You cannot write changed to disk, because you are not logged in as an admin"
},
"app-rebuild": {
"title": "Rebuild Application",
@ -154,4 +162,4 @@
"modal": "Open in Pop-Up Modal",
"workspace": "Open in Workspace View"
}
}
}

View File

@ -43,6 +43,9 @@
{{saveSuccess
? $t('config-editor.status-success-msg') : $t('config-editor.status-fail-msg') }}
</p>
<p v-if="!allowWriteToDisk" class="no-permission-note">
{{ $t('config-editor.not-admin-note') }}
</p>
<p class="response-output">{{ responseText }}</p>
<p v-if="saveSuccess" class="response-output">
{{ $t('config-editor.success-note-l1') }}
@ -243,6 +246,10 @@ p.response-output {
}
}
p.no-permission-note {
color: var(--config-settings-color);
}
button.save-button {
padding: 0.5rem 1rem;
margin: 0.25rem auto;

View File

@ -1,62 +0,0 @@
<template>
<div>
<div class="display-options">
<IconLogout @click="logout()" v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2" />
</div>
</div>
</template>
<script>
import { logout as registerLogout } from '@/utils/Auth';
import IconLogout from '@/assets/interface-icons/user-logout.svg';
export default {
name: 'AppButtons',
components: {
IconLogout,
},
methods: {
logout() {
registerLogout();
this.$toasted.show(this.$t('login.logout-message'));
setTimeout(() => {
location.reload(true); // eslint-disable-line no-restricted-globals
}, 500);
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
},
};
</script>
<style scoped lang="scss">
span.options-label {
color: var(--settings-text-color);
}
.display-options {
color: var(--settings-text-color);
svg {
path {
fill: var(--settings-text-color);
}
width: 1rem;
height: 1rem;
margin: 0.2rem;
padding: 0.2rem;
text-align: center;
background: var(--background);
border: 1px solid currentColor;
border-radius: var(--curve-factor);
cursor: pointer;
&:hover, &.selected {
background: var(--settings-text-color);
path { fill: var(--background); }
}
}
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div>
<!-- If auth configured, show status text -->
<span class="user-type-note">{{ makeText() }}</span>
<div class="display-options">
<!-- If user logged in, show logout button -->
<IconLogout
v-if="userType == userStateEnum.loggedIn"
@click="logout()"
v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2"
/>
<!-- If not logged in, and gues mode enabled, show login button -->
<IconLogout
v-if="userType == userStateEnum.guestAccess"
@click="goToLogin()"
v-tooltip="tooltip($t('settings.sign-in-tooltip'))"
class="layout-icon" tabindex="-2"
/>
</div>
</div>
</template>
<script>
import router from '@/router';
import { logout as registerLogout } from '@/utils/Auth';
import { localStorageKeys, userStateEnum } from '@/utils/defaults';
import IconLogout from '@/assets/interface-icons/user-logout.svg';
export default {
name: 'AuthButtons',
components: {
IconLogout,
},
props: {
userType: Number,
},
data() {
return {
userStateEnum,
};
},
methods: {
logout() {
registerLogout();
this.$toasted.show(this.$t('login.logout-message'));
setTimeout(() => {
router.push({ path: '/login' });
}, 500);
},
goToLogin() {
router.push({ path: '/login' });
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
makeText() {
if (this.userType === userStateEnum.loggedIn) {
const username = localStorage[localStorageKeys.USERNAME];
return this.$t('settings.sign-in-welcome', { username });
}
if (this.userType === userStateEnum.guestAccess) {
return this.$t('settings.sign-in-tooltip');
}
return '';
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/style-helpers.scss';
span.user-type-note {
color: var(--settings-text-color);
text-transform: capitalize;
margin-right: 0.5rem;
}
.display-options {
@extend .svg-button;
color: var(--settings-text-color);
}
</style>

View File

@ -13,7 +13,7 @@
<ItemSizeSelector :iconSize="iconSize" @iconSizeUpdated="updateIconSize" />
<ConfigLauncher :sections="sections" :pageInfo="pageInfo" :appConfig="appConfig"
@modalChanged="modalChanged" />
<AppButtons v-if="isUserLoggedIn()" />
<AuthButtons v-if="userState != 'noone'" :userType="userState" />
</div>
<div :class="`show-hide-container ${settingsVisible? 'hide-btn' : 'show-btn'}`">
<button @click="toggleSettingsVisibility()"
@ -34,7 +34,7 @@ import ConfigLauncher from '@/components/Settings/ConfigLauncher';
import ThemeSelector from '@/components/Settings/ThemeSelector';
import LayoutSelector from '@/components/Settings/LayoutSelector';
import ItemSizeSelector from '@/components/Settings/ItemSizeSelector';
import AppButtons from '@/components/Settings/AppButtons';
import AuthButtons from '@/components/Settings/AuthButtons';
import KeyboardShortcutInfo from '@/components/Settings/KeyboardShortcutInfo';
import AppInfoModal from '@/components/Configuration/AppInfoModal';
import IconOpen from '@/assets/interface-icons/config-open-settings.svg';
@ -44,6 +44,8 @@ import {
visibleComponents as defaultVisibleComponents,
} from '@/utils/defaults';
import { getUserState } from '@/utils/Auth';
export default {
name: 'SettingsContainer',
props: {
@ -61,7 +63,7 @@ export default {
ThemeSelector,
LayoutSelector,
ItemSizeSelector,
AppButtons,
AuthButtons,
KeyboardShortcutInfo,
AppInfoModal,
IconOpen,
@ -87,9 +89,6 @@ export default {
getInitialTheme() {
return this.appConfig.theme || '';
},
isUserLoggedIn() {
return !!localStorage[localStorageKeys.USERNAME];
},
/* Gets user themes if available */
getUserThemes() {
const userThemes = this.appConfig.cssThemes || [];
@ -105,6 +104,19 @@ export default {
|| (this.visibleComponents || defaultVisibleComponents).settings);
},
},
computed: {
/**
* 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
* 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
*/
userState() {
return getUserState(this.appConfig || {});
},
},
data() {
return {
settingsVisible: this.getSettingsVisibility(),

View File

@ -22,14 +22,16 @@ import { metaTagData, startingView, routePaths } from '@/utils/defaults';
Vue.use(Router);
/**
* Checks if the current user is either authenticated,
* or if authentication is not enabled
* @returns true if user logged in, or user management not enabled
*/
/* Checks if guest mode is enabled in appConfig */
const isGuestEnabled = () => {
if (!config || !config.appConfig) return false;
return config.appConfig.enableGuestAccess || false;
};
/* Returns true if user is already authenticated, or if auth is not enabled */
const isAuthenticated = () => {
const users = config.appConfig.auth;
return (!users || users.length === 0 || isLoggedIn(users));
return (!users || users.length === 0 || isLoggedIn(users) || isGuestEnabled());
};
/* Get the users chosen starting view from app config, or return default */
@ -94,13 +96,14 @@ const router = new Router({
appConfig: config.appConfig,
},
beforeEnter: (to, from, next) => {
if (isAuthenticated()) router.push({ path: '/' });
// If the user already logged in + guest mode not enabled, then redirect home
if (isAuthenticated() && !isGuestEnabled()) router.push({ path: '/' });
next();
},
},
{ // The about app page
path: routePaths.about,
name: 'about',
name: 'about', // We lazy load the About page so as to not slow down the app
component: () => import(/* webpackChunkName: "about" */ './views/About.vue'),
meta: makeMetaTags('About Dashy'),
},
@ -115,9 +118,9 @@ const router = new Router({
});
/**
* Before loading a route, check if the user has authentication enabled *
* if so, then ensure that they are correctly logged in as a valid user *
* If not logged in, prevent access and redirect them to the login page *
* Before loading a route, check if the user has authentication enabled
* if so, then ensure that they are correctly logged in as a valid user
* If not logged in, prevent all access and redirect them to login page
* */
router.beforeEach((to, from, next) => {
if (to.name !== 'login' && !isAuthenticated()) next({ name: 'login' });
@ -131,5 +134,5 @@ router.afterEach((to) => {
});
});
// Export the now configured router
// All done - export the now configured router
export default router;

View File

@ -17,8 +17,6 @@
.svg-button {
color: var(--primary);
width: 1.5rem;
height: 1.5rem;
svg {
path {
fill: var(--settings-text-color);

View File

@ -1,5 +1,5 @@
import sha256 from 'crypto-js/sha256';
import { cookieKeys, localStorageKeys } from './defaults';
import { cookieKeys, localStorageKeys, userStateEnum } from './defaults';
/**
* Generates a 1-way hash, in order to be stored in local storage for authentication
@ -34,6 +34,12 @@ export const isLoggedIn = (users) => {
return userAuthenticated;
};
/* Returns true if authentication is enabled */
export const isAuthEnabled = (users) => (users && users.length > 0);
/* Returns true if guest access is enabled */
export const isGuestAccessEnabled = (appConfig) => appConfig.enableGuestAccess || false;
/**
* Checks credentials entered by the user against those in the config
* Returns an object containing a boolean indicating success/ failure
@ -107,3 +113,20 @@ export const isUserAdmin = (users) => {
});
return isAdmin;
};
/**
* Determines which button should display, based on the user type
* 0 = Auth not configured (don't show anything)
* 1 = Auth configured, and user logged in (show logout button)
* 2 = Auth configured, guest access enabled, 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
*/
export const getUserState = (appConfig) => {
const { notConfigured, loggedIn, guestAccess } = userStateEnum; // Numeric enum options
const users = appConfig.auth || []; // Get auth object
if (!isAuthEnabled(users)) return notConfigured; // No auth enabled
if (isLoggedIn(users)) return loggedIn; // User is logged in
if (isGuestAccessEnabled(appConfig)) return guestAccess; // Guest is viewing
return notConfigured;
};

View File

@ -248,6 +248,11 @@
}
}
},
"enableGuestAccess": {
"type": "boolean",
"default": false,
"description": "If set to true, an unauthenticated user will be able to have read-only access to dashboard, without needing to login. Requires auth to be configured."
},
"enableMultiTasking": {
"type": "boolean",
"default": false,

View File

@ -166,4 +166,11 @@ module.exports = {
],
/* Use your own self-hosted Sentry instance. Only used if error reporting is turned on */
sentryDsn: 'https://3138ea85f15a4fa883a5b27a4dc8ee28@o937511.ingest.sentry.io/5887934',
/* A JS enum for indicating the user state, when guest mode + authentication is enabled */
userStateEnum: {
notConfigured: 0,
loggedIn: 1,
guestAccess: 2,
notLoggedIn: 3,
},
};

View File

@ -1,6 +1,25 @@
<template>
<div class="login-page">
<form class="login-form">
<!-- User is already logged in -->
<div v-if="isUserAlreadyLoggedIn" class="already-logged-in">
<h2>{{ $t('login.already-logged-in-title') }}</h2>
<p class="already-logged-in">
{{ $t('login.already-logged-in-text') }}
<span class="username">{{ existingUsername }}</span>
</p>
<Button class="login-button" :click="stayLoggedIn">
{{ $t('login.proceed-to-dashboard') }}
</Button>
<Button class="login-button" :click="getOut">{{ $t('login.log-out-button') }}</Button>
<span class="already-logged-in-note">
You need to log out, in order to proceed as a different user.
</span>
<transition name="bounce">
<p :class="`login-error-message ${status}`" v-show="message">{{ message }}</p>
</transition>
</div>
<!-- Main login form -->
<form class="login-form" v-if="(!isUserAlreadyLoggedIn) && isAuthenticationEnabled">
<h2 class="login-title">{{ $t('login.title') }}</h2>
<Input
v-model="username"
@ -28,6 +47,27 @@
<p :class="`login-error-message ${status}`" v-show="message">{{ message }}</p>
</transition>
</form>
<!-- Guest login form -->
<form class="guest-form"
v-if="appConfig.enableGuestAccess && !isUserAlreadyLoggedIn && isAuthenticationEnabled">
<h2 class="login-title">Guest Access</h2>
<Button class="login-button" :click="guestLogin">
{{ $t('login.proceed-guest-button') }}
</Button>
<p class="guest-intro">
This instance has guest access enabled.<br>
Guests have view-only access to dashboards,
so cannot write any changes to disk.
</p>
</form>
<!-- Edge case - guest mode enabled, but no users configured -->
<div class="not-configured" v-if="!isAuthenticationEnabled">
<h2>Error</h2>
<p>Authentication is not enabled, or no users have been configured</p>
<Button class="login-button" :click="guestLogin">
Go Home
</Button>
</div>
</div>
</template>
@ -36,7 +76,12 @@ import router from '@/router';
import Button from '@/components/FormElements/Button';
import Input from '@/components/FormElements/Input';
import Defaults, { localStorageKeys } from '@/utils/defaults';
import { checkCredentials, login } from '@/utils/Auth';
import {
checkCredentials,
login,
isLoggedIn,
logout,
} from '@/utils/Auth';
export default {
name: 'login',
@ -76,6 +121,21 @@ export default {
successMsg: this.$t('login.success-message'),
};
},
existingUsername() {
return localStorage[localStorageKeys.USERNAME];
},
isUserAlreadyLoggedIn() {
const users = this.appConfig.auth;
const loggedIn = (!users || users.length === 0 || isLoggedIn(users));
return (loggedIn && this.existingUsername);
},
isGuestAccessEnabled() {
if (!this.appConfig || !this.appConfig.enableGuestAccess) return false;
return this.appConfig.enableGuestAccess;
},
isAuthenticationEnabled() {
return (this.appConfig && this.appConfig.auth && this.appConfig.auth.length > 0);
},
},
methods: {
/* Checks form is filled in, then initiates the login, and redirects to /home */
@ -93,11 +153,42 @@ export default {
this.status = response.correct ? 'success' : 'error';
if (response.correct) { // Yay, credentials were correct :)
login(this.username, this.password, timeout); // Login, to set the cookie
setTimeout(() => { // Wait a short while, then redirect back home
router.push({ path: '/' });
}, 250);
this.goHome();
}
},
/* Calls function to double-check guest access enabled, then log in as guest */
guestLogin() {
const isAllowed = this.isGuestAccessEnabled;
if (isAllowed) {
this.$toasted.show('Logged in as Guest, Redirecting...', { className: 'toast-success' });
this.goHome();
} else {
this.$toasted.show('Guest access not allowed', { className: 'toast-error' });
}
},
/* Calls logout, shows status message, and refreshed page */
getOut() {
logout();
this.status = 'success';
this.message = 'Logging out...';
this.refreshPage();
},
/* Logged in user redirects to home page */
stayLoggedIn() {
this.status = 'success';
this.message = 'Redirecting...';
this.goHome();
},
/* Refreshes the page */
refreshPage() {
setTimeout(() => { location.reload(); }, 250); // eslint-disable-line no-restricted-globals
},
/* Redirects to the homepage */
goHome() {
setTimeout(() => { // Wait a short while, then redirect back home
router.push({ path: '/' });
}, 250);
},
/* Since Theme setter isn't loaded at this point, we must manually get and apply users theme */
setTheme() {
const theme = localStorage[localStorageKeys.THEME] || Defaults.theme;
@ -119,23 +210,43 @@ export default {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-evenly;
min-height: calc(100vh - var(--footer-height));
/* User is already logged in note */
div.already-logged-in {
margin: 0 auto 0.5rem;
p.already-logged-in {
margin: 0 auto 0.5rem;
text-align: center;
}
span.username {
font-weight: bold;
text-transform: capitalize;
}
span.already-logged-in-note {
font-size: 0.8rem;
opacity: var(--dimming-factor);
text-align: left;
}
}
/* Login form container */
form.login-form {
form.login-form, form.guest-form, div.already-logged-in, div.not-configured {
background: var(--login-form-background);
color: var(--login-form-color);
border: 1px solid var(--login-form-color);
border-radius: var(--curve-factor);
font-size: 1.4rem;
padding: 2rem;
margin: 2rem auto;
margin: 2rem;
max-width: 22rem;
display: flex;
flex-direction: column;
/* Login form title */
h2.login-title {
font-size: 3rem;
h2 {
font-size: 2rem;
margin: 0 0 1rem 0;
text-align: center;
cursor: default;
@ -177,6 +288,11 @@ export default {
&.success { color: var(--success); }
&.error { color: var(--warning); }
}
p.guest-intro {
font-size: 0.8rem;
opacity: var(--dimming-factor);
text-align: left;
}
}
}