From 89ff5ebee13060ab263afad9f3e40ab3269f3414 Mon Sep 17 00:00:00 2001 From: Dominik Pschenitschni Date: Mon, 18 Oct 2021 11:23:43 +0200 Subject: [PATCH 1/2] wip: feat: use navigation guards --- src/App.vue | 82 +++++++++++------------- src/components/home/contentAuth.vue | 23 +------ src/components/home/contentNoAuth.vue | 35 +--------- src/i18n/index.js | 2 +- src/router/index.js | 30 +++++++++ src/store/modules/auth.js | 92 +++++++++++++++++++-------- src/views/user/Login.vue | 56 ++++++++-------- 7 files changed, 164 insertions(+), 156 deletions(-) diff --git a/src/App.vue b/src/App.vue index d2690c260..3e97d4d6b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,6 +36,39 @@ import ContentLinkShare from './components/home/contentLinkShare' import ContentNoAuth from './components/home/contentNoAuth' import {setLanguage} from './i18n' import AccountDeleteService from '@/services/accountDelete' +import {store} from '@/store' +import {i18n} from '@/i18n' +import {success} from '@/message' + +function setupOnlineStatus() { + store.commit(ONLINE, navigator.onLine) + window.addEventListener('online', () => store.commit(ONLINE, navigator.onLine)) + window.addEventListener('offline', () => store.commit(ONLINE, navigator.onLine)) +} + +function setupRedirects(route) { + if (typeof route.query.userPasswordReset !== 'undefined') { + localStorage.setItem('passwordResetToken', route.query.userPasswordReset) + return {name: 'user.password-reset.reset'} + } else if (typeof route.query.userEmailConfirm !== 'undefined') { + localStorage.setItem('emailConfirmToken', route.query.userEmailConfirm) + return {name: 'user.login'} + } else if (route.fullPath.endsWith('frontend/index.html')) { + // Make sure to always load the home route when running with electron + return {name: 'home'} + } +} + +async function setupAccountDeletionVerification(route) { + if (typeof route.query.accountDeletionConfirm === 'undefined') { + return + } + + const accountDeletionService = new AccountDeleteService() + await accountDeletionService.confirm(route.query.accountDeletionConfirm) + success({message: i18n.global.t('user.deletion.confirmSuccess')}) + await store.dispatch('auth/refreshUserInfo') +} export default defineComponent({ name: 'app', @@ -47,11 +80,11 @@ export default defineComponent({ KeyboardShortcuts, Notification, }, - beforeMount() { - this.setupOnlineStatus() - this.setupPasswortResetRedirect() - this.setupEmailVerificationRedirect() - this.setupAccountDeletionVerification() + beforeRouteEnter(to) { + setupOnlineStatus() + const redirect = setupRedirects(to) + if (redirect) return redirect + setupAccountDeletionVerification(to) }, beforeCreate() { // FIXME: async action in beforeCreate, might be not finished when component mounts @@ -63,12 +96,6 @@ export default defineComponent({ setLanguage() }, - created() { - // Make sure to always load the home route when running with electron - if (this.$route.fullPath.endsWith('frontend/index.html')) { - this.$router.push({name: 'home'}) - } - }, computed: { isTouch() { return isTouchDevice() @@ -82,38 +109,5 @@ export default defineComponent({ 'authLinkShare', ]), }, - methods: { - setupOnlineStatus() { - this.$store.commit(ONLINE, navigator.onLine) - window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine)) - window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine)) - }, - setupPasswortResetRedirect() { - if (typeof this.$route.query.userPasswordReset === 'undefined') { - return - } - - localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset) - this.$router.push({name: 'user.password-reset.reset'}) - }, - setupEmailVerificationRedirect() { - if (typeof this.$route.query.userEmailConfirm === 'undefined') { - return - } - - localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm) - this.$router.push({name: 'user.login'}) - }, - async setupAccountDeletionVerification() { - if (typeof this.$route.query.accountDeletionConfirm === 'undefined') { - return - } - - const accountDeletionService = new AccountDeleteService() - await accountDeletionService.confirm(this.$route.query.accountDeletionConfirm) - this.$message.success({message: this.$t('user.deletion.confirmSuccess')}) - this.$store.dispatch('auth/refreshUserInfo') - }, - }, }) diff --git a/src/components/home/contentAuth.vue b/src/components/home/contentAuth.vue index b4b6c5271..e1d7e3fd2 100644 --- a/src/components/home/contentAuth.vue +++ b/src/components/home/contentAuth.vue @@ -95,28 +95,7 @@ export default { this.$store.dispatch('auth/renewToken') // Check if the token is still valid if the window gets focus again to maybe renew it - window.addEventListener('focus', () => { - - if (!this.authenticated) { - return - } - - const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000 - - // If the token expiry is negative, it is already expired and we have no choice but to redirect - // the user to the login page - if (expiresIn < 0) { - this.$store.dispatch('auth/checkAuth') - this.$router.push({name: 'user.login'}) - return - } - - // Check if the token is valid for less than 60 hours and renew if thats the case - if (expiresIn < 60 * 3600) { - this.$store.dispatch('auth/renewToken') - console.debug('renewed token') - } - }) + window.addEventListener('focus', this.$store.dispatch('auth/checkToken')) }, hideMenuOnMobile() { if (window.innerWidth < 769) { diff --git a/src/components/home/contentNoAuth.vue b/src/components/home/contentNoAuth.vue index c60a070bf..63a369b4c 100644 --- a/src/components/home/contentNoAuth.vue +++ b/src/components/home/contentNoAuth.vue @@ -19,7 +19,6 @@ import {mapState} from 'vuex' import logoUrl from '@/assets/logo-full.svg' -import { saveLastVisited } from '@/helpers/saveLastVisited' export default { name: 'contentNoAuth', @@ -29,39 +28,7 @@ export default { } }, computed: { - routeName() { - return this.$route.name - }, - ...mapState({ - motd: state => state.config.motd, - }), - }, - watch: { - routeName: { - handler(routeName) { - if (!routeName) return - this.redirectToHome() - }, - immediate: true, - }, - }, - methods: { - redirectToHome() { - // Check if the user is already logged in and redirect them to the home page if not - if ( - this.$route.name !== 'user.login' && - this.$route.name !== 'user.password-reset.request' && - this.$route.name !== 'user.password-reset.reset' && - this.$route.name !== 'user.register' && - this.$route.name !== 'link-share.auth' && - this.$route.name !== 'openid.auth' && - localStorage.getItem('passwordResetToken') === null && - localStorage.getItem('emailConfirmToken') === null - ) { - saveLastVisited(this.$route.name, this.$route.params) - this.$router.push({name: 'user.login'}) - } - }, + ...mapState('config', ['motd']), }, } diff --git a/src/i18n/index.js b/src/i18n/index.js index 0f787d7d1..9c0d92d3e 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -52,7 +52,7 @@ export const getCurrentLanguage = () => { return savedLanguage } - let browserLanguage = navigator.language || navigator.userLanguage + const browserLanguage = navigator.language || navigator.userLanguage for (let k in availableLanguages) { if (browserLanguage[k] === browserLanguage || k.startsWith(browserLanguage + '-')) { diff --git a/src/router/index.js b/src/router/index.js index 215e29640..7ff3d34ba 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,4 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' +import {store} from '@/store' +import { saveLastVisited } from '@/helpers/saveLastVisited' import HomeComponent from '../views/Home' import NotFoundComponent from '../views/404' @@ -520,4 +522,32 @@ router.resolve({ params: { pathMatch: ['not', 'found'] }, }).href // '/not/found' +function canUserAccess(to) { + // Check if the user is already logged in and redirect them to the home page if not + if ( + to.name !== 'user.login' && + to.name !== 'user.password-reset.request' && + to.name !== 'user.password-reset.reset' && + to.name !== 'user.register' && + to.name !== 'link-share.auth' && + to.name !== 'openid.auth' && + localStorage.getItem('passwordResetToken') === null && + localStorage.getItem('emailConfirmToken') === null + ) { + saveLastVisited(to.name, to.params) + return false + } + return true +} + +router.beforeEach(async (to, from) => { + if (store.getters['auth/authUser']) { + return + } + + // canUserAccess() returns `true` or `false` + const canAccess = canUserAccess(to) + if (!canAccess) return {name: 'user.login'} + }) + export default router \ No newline at end of file diff --git a/src/store/modules/auth.js b/src/store/modules/auth.js index 19d4ba11a..0cc459e6e 100644 --- a/src/store/modules/auth.js +++ b/src/store/modules/auth.js @@ -2,6 +2,11 @@ import {HTTPFactory} from '@/http-common' import {LOADING} from '../mutation-types' import UserModel from '../../models/user' import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth' +import { saveLastVisited } from '@/helpers/saveLastVisited' +import { useStorage } from '@vueuse/core' + +const lastUserInfoRefresh = useStorage('lastUserInfoRefresh', 0) // returns Ref + const AUTH_TYPES = { 'UNKNOWN': 0, @@ -16,6 +21,24 @@ const defaultSettings = settings => { return settings } +function canUserAccess(to) { + // Check if the user is already logged in and redirect them to the home page if not + if ( + to.name !== 'user.login' && + to.name !== 'user.password-reset.request' && + to.name !== 'user.password-reset.reset' && + to.name !== 'user.register' && + to.name !== 'link-share.auth' && + to.name !== 'openid.auth' && + localStorage.getItem('passwordResetToken') === null && + localStorage.getItem('emailConfirmToken') === null + ) { + saveLastVisited(to.name, to.params) + return false + } + return true +} + export default { namespaced: true, state: () => ({ @@ -24,7 +47,6 @@ export default { info: null, needsTotpPasscode: false, avatarUrl: '', - lastUserInfoRefresh: null, settings: {}, }), getters: { @@ -142,14 +164,10 @@ export default { const HTTP = HTTPFactory() ctx.commit(LOADING, true, {root: true}) - const data = { - code: code, - } - // Delete an eventually preexisting old token removeToken() try { - const response = await HTTP.post(`/auth/openid/${provider}/callback`, data) + const response = await HTTP.post(`/auth/openid/${provider}/callback`, { code }) // Save the token to local storage for later use saveToken(response.data.token, true) @@ -175,7 +193,12 @@ export default { // This function can be called from multiple places at the same time and shortly after one another. // To prevent hitting the api too frequently or race conditions, we check at most once per minute. - if (ctx.state.lastUserInfoRefresh !== null && ctx.state.lastUserInfoRefresh > (new Date()).setMinutes((new Date()).getMinutes() + 1)) { + const now = new Date() + const inOneMinute = now.setMinutes(now.getMinutes() + 1) + if ( + ctx.state.lastUserInfoRefresh !== null && + ctx.state.lastUserInfoRefresh > inOneMinute + ) { return } @@ -230,27 +253,44 @@ export default { } }, - // Renews the api token and saves it to local storage - renewToken(ctx) { - // FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a - // link share in another tab. Without the timeout both the token renew and link share auth are executed at - // the same time and one might win over the other. - setTimeout(async () => { - if (!ctx.state.authenticated) { - return - } + async checkToken(ctx) { + if (!ctx.state.authenticated) { + return + } - try { - await refreshToken(!ctx.state.isLinkShareAuth) - ctx.dispatch('checkAuth') - } catch(e) { - // Don't logout on network errors as the user would then get logged out if they don't have - // internet for a short period of time - such as when the laptop is still reconnecting - if (e.request.status) { - ctx.dispatch('logout') - } + const expiresIn = (ctx.state.info !== null ? ctx.state.info.exp : 0) - +new Date() / 1000 + + // If the token expiry is negative, it is already expired and we have no choice but to redirect + // the user to the login page + if (expiresIn < 0) { + ctx.dispatch('auth/checkAuth') + router.push({name: 'user.login'}) + return + } + + // Check if the token is valid for less than 60 hours and renew if thats the case + if (expiresIn < 60 * 3600) { + ctx.dispatch('auth/renewToken') + console.debug('renewed token') + } + }, + + // Renews the api token and saves it to local storage + async renewToken(ctx) { + if (!ctx.state.authenticated) { + return + } + + try { + await refreshToken(!ctx.state.isLinkShareAuth) + ctx.dispatch('checkAuth') + } catch(e) { + // Don't logout on network errors as the user would then get logged out if they don't have + // internet for a short period of time - such as when the laptop is still reconnecting + if (e.request.status) { + ctx.dispatch('logout') } - }, 5000) + } }, logout(ctx) { removeToken() diff --git a/src/views/user/Login.vue b/src/views/user/Login.vue index 0ad4dac0a..711740cc3 100644 --- a/src/views/user/Login.vue +++ b/src/views/user/Login.vue @@ -124,40 +124,38 @@ export default { errorMessage: '', } }, - beforeMount() { - const HTTP = HTTPFactory() - // Try to verify the email - // FIXME: Why is this here? Can we find a better place for this? - let emailVerifyToken = localStorage.getItem('emailConfirmToken') - if (emailVerifyToken) { - const cancel = this.setLoading() - HTTP.post('user/confirm', {token: emailVerifyToken}) - .then(() => { - localStorage.removeItem('emailConfirmToken') - this.confirmedEmailSuccess = true - cancel() - }) - .catch(e => { - cancel() - this.errorMessage = e.response.data.message - }) + beforeRouteEnter(to, from, next) { + // Check if the user is already logged in, if so, redirect them to the homepage + if (!this.authenticated) { + return } - // Check if the user is already logged in, if so, redirect them to the homepage - if (this.authenticated) { - const last = getLastVisited() - if (last !== null) { - this.$router.push({ - name: last.name, - params: last.params, - }) - clearLastVisited() - } else { - this.$router.push({name: 'home'}) - } + const last = getLastVisited() + if (last !== null) { + clearLastVisited() + next(last) + } else { + next({name: 'home'}) } }, created() { + const HTTP = HTTPFactory() + // Try to verify the email + // FIXME: Why is this here? Can we find a better place for this? + const emailVerifyToken = localStorage.getItem('emailConfirmToken') + if (!emailVerifyToken) { + return + } + const cancel = this.setLoading() + HTTP.post('user/confirm', {token: emailVerifyToken}) + .then(() => { + localStorage.removeItem('emailConfirmToken') + this.confirmedEmailSuccess = true + }) + .catch(e => { + this.errorMessage = e.response.data.message + }) + .finally(() => cancel()) this.hasApiUrl = window.API_URL !== '' this.setTitle(this.$t('user.auth.login')) }, -- 2.40.1 From 0c7df1a927549170d761cbaef515e88d28d97a34 Mon Sep 17 00:00:00 2001 From: Dominik Pschenitschni Date: Mon, 1 Nov 2021 18:40:48 +0100 Subject: [PATCH 2/2] feat: add auth meta check --- src/router/index.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/router/index.js b/src/router/index.js index 7ff3d34ba..852652eb3 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -97,6 +97,9 @@ const router = createRouter({ path: '/login', name: 'user.login', component: LoginComponent, + meta: { + accessibleWithoutAuth: true, + }, }, { path: '/get-password-reset', @@ -522,9 +525,27 @@ router.resolve({ params: { pathMatch: ['not', 'found'] }, }).href // '/not/found' + +router.beforeEach((to) => { + if ( + to.matched.some(record => record.meta.requiresAuth) && + // this route requires auth, check if logged in + // if not, redirect to login page. + !auth.loggedIn() + ) { + return { + path: '/login', + query: { redirect: to.fullPath }, + } + } + }) + + + function canUserAccess(to) { // Check if the user is already logged in and redirect them to the home page if not if ( + (to.matched.some(record => record.meta.accessibleWithoutAuth)) to.name !== 'user.login' && to.name !== 'user.password-reset.request' && to.name !== 'user.password-reset.reset' && @@ -537,7 +558,6 @@ function canUserAccess(to) { saveLastVisited(to.name, to.params) return false } - return true } router.beforeEach(async (to, from) => { -- 2.40.1