wip: feat: use navigation guards #899

Closed
dpschen wants to merge 2 commits from dpschen/frontend:feature/use-navigation-guard into main
7 changed files with 184 additions and 156 deletions

View File

@ -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')
},
},
})
</script>

View File

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

View File

@ -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']),
},
}
</script>

View File

@ -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 + '-')) {

View File

@ -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'
@ -95,6 +97,9 @@ const router = createRouter({
path: '/login',
name: 'user.login',
component: LoginComponent,
meta: {
accessibleWithoutAuth: true,
},
},
{
path: '/get-password-reset',
@ -520,4 +525,49 @@ 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' &&
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
}
}
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

View File

@ -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<number>
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()

View File

@ -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'))
},