feat: auth store with composition api (#2602)
All checks were successful
continuous-integration/drone/push Build is passing

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: #2602
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-10-31 20:25:35 +00:00 committed by konrad
parent 839d331bf5
commit 825ba100f0

View File

@ -1,3 +1,4 @@
import {computed, readonly, ref} from 'vue'
import {defineStore, acceptHMRUpdate} from 'pinia' import {defineStore, acceptHMRUpdate} from 'pinia'
import {HTTPFactory, AuthenticatedHTTPFactory} from '@/http-common' import {HTTPFactory, AuthenticatedHTTPFactory} from '@/http-common'
@ -16,98 +17,102 @@ import {useConfigStore} from '@/stores/config'
import UserSettingsModel from '@/models/userSettings' import UserSettingsModel from '@/models/userSettings'
import {MILLISECONDS_A_SECOND} from '@/constants/date' import {MILLISECONDS_A_SECOND} from '@/constants/date'
export interface AuthState { function redirectToProviderIfNothingElseIsEnabled() {
authenticated: boolean, const {auth} = useConfigStore()
isLinkShareAuth: boolean, if (
info: IUser | null, auth.local.enabled === false &&
needsTotpPasscode: boolean, auth.openidConnect.enabled &&
avatarUrl: string, auth.openidConnect.providers?.length === 1 &&
lastUserInfoRefresh: Date | null, window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
settings: IUserSettings, ) {
isLoading: boolean, redirectToProvider(auth.openidConnect.providers[0], auth.openidConnect.redirectUrl)
isLoadingGeneralSettings: boolean }
} }
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', () => {
state: () : AuthState => ({ const authenticated = ref(false)
authenticated: false, const isLinkShareAuth = ref(false)
isLinkShareAuth: false, const needsTotpPasscode = ref(false)
needsTotpPasscode: false,
info: null, const info = ref<IUser | null>(null)
avatarUrl: '', const avatarUrl = ref('')
settings: new UserSettingsModel(), const settings = ref<IUserSettings>(new UserSettingsModel())
lastUserInfoRefresh: null, const lastUserInfoRefresh = ref<Date | null>(null)
isLoading: false, const isLoading = ref(false)
isLoadingGeneralSettings: false, const isLoadingGeneralSettings = ref(false)
}),
getters: { const authUser = computed(() => {
authUser(state) { return authenticated.value && (
return state.authenticated && ( info.value &&
state.info && info.value.type === AUTH_TYPES.USER
state.info.type === AUTH_TYPES.USER
) )
},
authLinkShare(state) {
return state.authenticated && (
state.info &&
state.info.type === AUTH_TYPES.LINK_SHARE
)
},
userDisplayName(state) {
return state.info ? getDisplayName(state.info) : undefined
},
},
actions: {
setIsLoading(isLoading: boolean) {
this.isLoading = isLoading
},
setIsLoadingGeneralSettings(isLoading: boolean) {
this.isLoadingGeneralSettings = isLoading
},
setUser(info: IUser | null) {
this.info = info
if (info !== null) {
this.reloadAvatar()
if (info.settings) {
this.settings = new UserSettingsModel(info.settings)
}
this.isLinkShareAuth = info.id < 0
}
},
setUserSettings(settings: IUserSettings) {
this.settings = new UserSettingsModel(settings)
this.info = new UserModel({
...this.info !== null ? this.info : {},
name: settings.name,
}) })
},
setAuthenticated(authenticated: boolean) { const authLinkShare = computed(() => {
this.authenticated = authenticated return authenticated.value && (
}, info.value &&
setIsLinkShareAuth(isLinkShareAuth: boolean) { info.value.type === AUTH_TYPES.LINK_SHARE
this.isLinkShareAuth = isLinkShareAuth )
}, })
setNeedsTotpPasscode(needsTotpPasscode: boolean) {
this.needsTotpPasscode = needsTotpPasscode const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined)
},
reloadAvatar() {
if (!this.info) return function setIsLoading(newIsLoading: boolean) {
this.avatarUrl = `${getAvatarUrl(this.info)}&=${+new Date()}` isLoading.value = newIsLoading
}, }
updateLastUserRefresh() {
this.lastUserInfoRefresh = new Date() function setIsLoadingGeneralSettings(isLoading: boolean) {
}, isLoadingGeneralSettings.value = isLoading
}
function setUser(newUser: IUser | null) {
info.value = newUser
if (newUser !== null) {
reloadAvatar()
if (newUser.settings) {
settings.value = new UserSettingsModel(newUser.settings)
}
isLinkShareAuth.value = newUser.id < 0
}
}
function setUserSettings(newSettings: IUserSettings) {
settings.value = new UserSettingsModel(newSettings)
info.value = new UserModel({
...info.value !== null ? info.value : {},
name: newSettings.name,
})
}
function setAuthenticated(newAuthenticated: boolean) {
authenticated.value = newAuthenticated
}
function setIsLinkShareAuth(newIsLinkShareAuth: boolean) {
isLinkShareAuth.value = newIsLinkShareAuth
}
function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) {
needsTotpPasscode.value = newNeedsTotpPasscode
}
function reloadAvatar() {
if (!info.value) return
avatarUrl.value = `${getAvatarUrl(info.value)}&=${new Date().valueOf()}`
}
function updateLastUserRefresh() {
lastUserInfoRefresh.value = new Date()
}
// Logs a user in with a set of credentials. // Logs a user in with a set of credentials.
async login(credentials) { async function login(credentials) {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
this.setIsLoading(true) setIsLoading(true)
// Delete an eventually preexisting old token // Delete an eventually preexisting old token
removeToken() removeToken()
@ -118,32 +123,32 @@ export const useAuthStore = defineStore('auth', {
saveToken(response.data.token, true) saveToken(response.data.token, true)
// Tell others the user is autheticated // Tell others the user is autheticated
await this.checkAuth() await checkAuth()
} catch (e) { } catch (e) {
if ( if (
e.response && e.response &&
e.response.data.code === 1017 && e.response.data.code === 1017 &&
!credentials.totpPasscode !credentials.totpPasscode
) { ) {
this.setNeedsTotpPasscode(true) setNeedsTotpPasscode(true)
} }
throw e throw e
} finally { } finally {
this.setIsLoading(false) setIsLoading(false)
}
} }
},
/** /**
* Registers a new user and logs them in. * Registers a new user and logs them in.
* Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited. * Not sure if this is the right place to put the logic in, maybe a seperate js component would be better suited.
*/ */
async register(credentials) { async function register(credentials) {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
this.setIsLoading(true) setIsLoading(true)
try { try {
await HTTP.post('register', credentials) await HTTP.post('register', credentials)
return this.login(credentials) return login(credentials)
} catch (e) { } catch (e) {
if (e.response?.data?.message) { if (e.response?.data?.message) {
throw e.response.data throw e.response.data
@ -151,13 +156,13 @@ export const useAuthStore = defineStore('auth', {
throw e throw e
} finally { } finally {
this.setIsLoading(false) setIsLoading(false)
}
} }
},
async openIdAuth({provider, code}) { async function openIdAuth({provider, code}) {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
this.setIsLoading(true) setIsLoading(true)
const data = { const data = {
code: code, code: code,
@ -171,39 +176,39 @@ export const useAuthStore = defineStore('auth', {
saveToken(response.data.token, true) saveToken(response.data.token, true)
// Tell others the user is autheticated // Tell others the user is autheticated
await this.checkAuth() await checkAuth()
} finally { } finally {
this.setIsLoading(false) setIsLoading(false)
}
} }
},
async linkShareAuth({hash, password}) { async function linkShareAuth({hash, password}) {
const HTTP = HTTPFactory() const HTTP = HTTPFactory()
const response = await HTTP.post('/shares/' + hash + '/auth', { const response = await HTTP.post('/shares/' + hash + '/auth', {
password: password, password: password,
}) })
saveToken(response.data.token, false) saveToken(response.data.token, false)
await this.checkAuth() await checkAuth()
return response.data return response.data
}, }
/** /**
* Populates user information from jwt token saved in local storage in store * Populates user information from jwt token saved in local storage in store
*/ */
async checkAuth() { async function checkAuth() {
const now = new Date() const now = new Date()
const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1)) const inOneMinute = new Date(new Date().setMinutes(now.getMinutes() + 1))
// This function can be called from multiple places at the same time and shortly after one another. // 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. // To prevent hitting the api too frequently or race conditions, we check at most once per minute.
if ( if (
this.lastUserInfoRefresh !== null && lastUserInfoRefresh.value !== null &&
this.lastUserInfoRefresh > inOneMinute lastUserInfoRefresh.value > inOneMinute
) { ) {
return return
} }
const jwt = getToken() const jwt = getToken()
let authenticated = false let isAuthenticated = false
if (jwt) { if (jwt) {
const base64 = jwt const base64 = jwt
.split('.')[1] .split('.')[1]
@ -211,36 +216,24 @@ export const useAuthStore = defineStore('auth', {
.replace('_', '/') .replace('_', '/')
const info = new UserModel(JSON.parse(atob(base64))) const info = new UserModel(JSON.parse(atob(base64)))
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND) const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
authenticated = info.exp >= ts isAuthenticated = info.exp >= ts
this.setUser(info) setUser(info)
if (authenticated) { if (isAuthenticated) {
await this.refreshUserInfo() await refreshUserInfo()
} }
} }
this.setAuthenticated(authenticated) setAuthenticated(isAuthenticated)
if (!authenticated) { if (!isAuthenticated) {
this.setUser(null) setUser(null)
this.redirectToProviderIfNothingElseIsEnabled() redirectToProviderIfNothingElseIsEnabled()
} }
return Promise.resolve(authenticated) return Promise.resolve(authenticated)
},
redirectToProviderIfNothingElseIsEnabled() {
const {auth} = useConfigStore()
if (
auth.local.enabled === false &&
auth.openidConnect.enabled &&
auth.openidConnect.providers?.length === 1 &&
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
) {
redirectToProvider(auth.openidConnect.providers[0], auth.openidConnect.redirectUrl)
} }
},
async refreshUserInfo() { async function refreshUserInfo() {
const jwt = getToken() const jwt = getToken()
if (!jwt) { if (!jwt) {
return return
@ -249,50 +242,50 @@ export const useAuthStore = defineStore('auth', {
const HTTP = AuthenticatedHTTPFactory() const HTTP = AuthenticatedHTTPFactory()
try { try {
const response = await HTTP.get('user') const response = await HTTP.get('user')
const info = new UserModel({ const newUser = new UserModel({
...response.data, ...response.data,
...(this.info?.type && {type: this.info?.type}), ...(info.value?.type && {type: info.value?.type}),
...(this.info?.email && {email: this.info?.email}), ...(info.value?.email && {email: info.value?.email}),
...(this.info?.exp && {exp: this.info?.exp}), ...(info.value?.exp && {exp: info.value?.exp}),
}) })
this.setUser(info) setUser(newUser)
this.updateLastUserRefresh() updateLastUserRefresh()
if ( if (
info.type === AUTH_TYPES.USER && newUser.type === AUTH_TYPES.USER &&
( (
typeof info.settings.language === 'undefined' || typeof newUser.settings.language === 'undefined' ||
info.settings.language === '' newUser.settings.language === ''
) )
) { ) {
// save current language // save current language
await this.saveUserSettings({ await saveUserSettings({
settings: { settings: {
...this.settings, ...settings.value,
language: getCurrentLanguage(), language: getCurrentLanguage(),
}, },
showMessage: false, showMessage: false,
}) })
} }
return info return newUser
} catch (e) { } catch (e) {
if(e?.response?.data?.message === 'invalid or expired jwt') { if(e?.response?.data?.message === 'invalid or expired jwt') {
this.logout() logout()
return return
} }
throw new Error('Error while refreshing user info:', {cause: e}) throw new Error('Error while refreshing user info:', {cause: e})
} }
}, }
/** /**
* Try to verify the email * Try to verify the email
*/ */
async verifyEmail(): Promise<boolean> { async function verifyEmail(): Promise<boolean> {
const emailVerifyToken = localStorage.getItem('emailConfirmToken') const emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) { if (emailVerifyToken) {
const stopLoading = setModuleLoading(this) const stopLoading = setModuleLoading(this, setIsLoading)
try { try {
await HTTPFactory().post('user/confirm', {token: emailVerifyToken}) await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
return true return true
@ -304,9 +297,9 @@ export const useAuthStore = defineStore('auth', {
} }
} }
return false return false
}, }
async saveUserSettings({ async function saveUserSettings({
settings, settings,
showMessage = true, showMessage = true,
}: { }: {
@ -315,11 +308,11 @@ export const useAuthStore = defineStore('auth', {
}) { }) {
const userSettingsService = new UserSettingsService() const userSettingsService = new UserSettingsService()
const cancel = setModuleLoading(this, this.setIsLoadingGeneralSettings) const cancel = setModuleLoading(this, setIsLoadingGeneralSettings)
try { try {
saveLanguage(settings.language) saveLanguage(settings.language)
await userSettingsService.update(settings) await userSettingsService.update(settings)
this.setUserSettings({...settings}) setUserSettings({...settings})
if (showMessage) { if (showMessage) {
success({message: i18n.global.t('user.settings.general.savedSuccess')}) success({message: i18n.global.t('user.settings.general.savedSuccess')})
} }
@ -328,40 +321,82 @@ export const useAuthStore = defineStore('auth', {
} finally { } finally {
cancel() cancel()
} }
}, }
/** /**
* Renews the api token and saves it to local storage * Renews the api token and saves it to local storage
*/ */
renewToken() { function renewToken() {
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a // 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 // 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. // the same time and one might win over the other.
setTimeout(async () => { setTimeout(async () => {
if (!this.authenticated) { if (!authenticated.value) {
return return
} }
try { try {
await refreshToken(!this.isLinkShareAuth) await refreshToken(!isLinkShareAuth.value)
await this.checkAuth() await checkAuth()
} catch (e) { } catch (e) {
// Don't logout on network errors as the user would then get logged out if they don't have // 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 // internet for a short period of time - such as when the laptop is still reconnecting
if (e?.request?.status) { if (e?.request?.status) {
await this.logout() await logout()
} }
} }
}, 5000) }, 5000)
}, }
async logout() { async function logout() {
removeToken() removeToken()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage. window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
await router.push({name: 'user.login'}) await router.push({name: 'user.login'})
await this.checkAuth() await checkAuth()
}, }
},
return {
// state
authenticated: readonly(authenticated),
isLinkShareAuth: readonly(isLinkShareAuth),
needsTotpPasscode: readonly(needsTotpPasscode),
info: readonly(info),
avatarUrl: readonly(avatarUrl),
settings: readonly(settings),
lastUserInfoRefresh: readonly(lastUserInfoRefresh),
authUser,
authLinkShare,
userDisplayName,
isLoading: readonly(isLoading),
setIsLoading,
isLoadingGeneralSettings: readonly(isLoadingGeneralSettings),
setIsLoadingGeneralSettings,
setUser,
setUserSettings,
setAuthenticated,
setIsLinkShareAuth,
setNeedsTotpPasscode,
reloadAvatar,
updateLastUserRefresh,
login,
register,
openIdAuth,
linkShareAuth,
checkAuth,
refreshUserInfo,
verifyEmail,
saveUserSettings,
renewToken,
logout,
}
}) })
// support hot reloading // support hot reloading