635 lines
18 KiB
TypeScript
635 lines
18 KiB
TypeScript
import {computed, readonly, ref} from 'vue'
|
|
import {acceptHMRUpdate, defineStore} from 'pinia'
|
|
|
|
import {AuthenticatedHTTPFactory, HTTPFactory} from '@/helpers/fetcher'
|
|
import {getBrowserLanguage, i18n, setLanguage} from '@/i18n'
|
|
import {objectToSnakeCase} from '@/helpers/case'
|
|
import UserModel, {getDisplayName, fetchAvatarBlobUrl, invalidateAvatarCache} from '@/models/user'
|
|
import AvatarService from '@/services/avatar'
|
|
import UserSettingsService from '@/services/userSettings'
|
|
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
|
|
import {useWebSocket} from '@/composables/useWebSocket'
|
|
import {setModuleLoading} from '@/stores/helper'
|
|
import {success, error} from '@/message'
|
|
import {
|
|
getRedirectUrlFromCurrentFrontendPath,
|
|
redirectToProvider,
|
|
redirectToProviderOnLogout,
|
|
} from '@/helpers/redirectToProvider'
|
|
import {AUTH_TYPES, type IUser} from '@/modelTypes/IUser'
|
|
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
|
import router from '@/router'
|
|
import {useConfigStore} from '@/stores/config'
|
|
import UserSettingsModel from '@/models/userSettings'
|
|
import {MILLISECONDS_A_SECOND} from '@/constants/date'
|
|
import {PrefixMode} from '@/modules/quickAddMagic'
|
|
import {DATE_DISPLAY} from '@/constants/dateDisplay'
|
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
|
import {RELATION_KIND} from '@/types/IRelationKind'
|
|
import type {IProvider} from '@/types/IProvider'
|
|
|
|
// Set on explicit logout so the login page won't immediately bounce the user
|
|
// back to the OIDC provider. Lives in sessionStorage so it survives the
|
|
// round-trip to the IdP within the tab and isn't wiped by localStorage.clear().
|
|
export const JUST_LOGGED_OUT_KEY = 'justLoggedOut'
|
|
|
|
function redirectToSpecifiedProvider() {
|
|
|
|
const {auth} = useConfigStore()
|
|
const searchParams = new URLSearchParams(window.location.search)
|
|
if (searchParams.has('redirectToProvider')) {
|
|
|
|
const redirectToProviderValue = searchParams.get('redirectToProvider')
|
|
|
|
if (
|
|
auth.openidConnect.providers?.length === 1
|
|
&& (window.location.pathname.startsWith('/login') || window.location.pathname === '/') // Kinda hacky, but prevents an endless loop.
|
|
&& (redirectToProviderValue === null
|
|
|| redirectToProviderValue === 'true'
|
|
|| redirectToProviderValue === '1')
|
|
) {
|
|
redirectToProvider(auth.openidConnect.providers[0])
|
|
}
|
|
|
|
// let's try to find the provider to logon to !
|
|
const wantedProvider = auth.openidConnect.providers?.find(p => p.key === redirectToProviderValue)
|
|
if (wantedProvider) {
|
|
redirectToProvider(wantedProvider)
|
|
}
|
|
console.warn(`Could not find provider to redirect to.\nWanted: ${wantedProvider}\nAvailable: ${auth.openidConnect.providers?.map(p => p.key)}`)
|
|
}
|
|
}
|
|
|
|
// A race-loser's refresh fails but the rotated cookie is already valid, so a
|
|
// second attempt succeeds — recovering what would otherwise be a spurious
|
|
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
|
|
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
|
|
try {
|
|
await refreshToken(persist)
|
|
} catch {
|
|
await refreshToken(persist)
|
|
}
|
|
}
|
|
|
|
function getLoggedInVia(): string | null {
|
|
return localStorage.getItem('loggedInViaProvider')
|
|
}
|
|
|
|
function setLoggedInVia(provider: string | null): void {
|
|
if (provider) {
|
|
localStorage.setItem('loggedInViaProvider', provider)
|
|
} else {
|
|
localStorage.removeItem('loggedInViaProvider')
|
|
}
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const configStore = useConfigStore()
|
|
|
|
const authenticated = ref(false)
|
|
const needsTotpPasscode = ref(false)
|
|
|
|
const info = ref<IUser | null>(null)
|
|
const avatarUrl = ref('')
|
|
const settings = ref<IUserSettings>(new UserSettingsModel())
|
|
|
|
const currentSessionId = ref<string | null>(null)
|
|
const lastUserInfoRefresh = ref<Date | null>(null)
|
|
const isLoading = ref(false)
|
|
const isLoadingGeneralSettings = ref(false)
|
|
|
|
const authUser = computed(() => {
|
|
return authenticated.value && (
|
|
info.value &&
|
|
info.value.type === AUTH_TYPES.USER
|
|
)
|
|
})
|
|
|
|
const authLinkShare = computed(() => {
|
|
return authenticated.value && (
|
|
info.value &&
|
|
info.value.type === AUTH_TYPES.LINK_SHARE
|
|
)
|
|
})
|
|
|
|
const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined)
|
|
|
|
const isLinkShareAuth = computed(() => info.value?.type === AUTH_TYPES.LINK_SHARE)
|
|
|
|
function setIsLoading(newIsLoading: boolean) {
|
|
isLoading.value = newIsLoading
|
|
}
|
|
|
|
function setIsLoadingGeneralSettings(isLoading: boolean) {
|
|
isLoadingGeneralSettings.value = isLoading
|
|
}
|
|
|
|
function setUser(newUser: IUser | null, saveSettings = true) {
|
|
info.value = newUser
|
|
if (newUser !== null && !isLinkShareAuth.value) {
|
|
reloadAvatar()
|
|
|
|
if (saveSettings && newUser.settings) {
|
|
loadSettings(newUser.settings)
|
|
}
|
|
}
|
|
}
|
|
|
|
function setUserSettings(newSettings: IUserSettings) {
|
|
loadSettings(newSettings)
|
|
info.value = new UserModel({
|
|
...info.value !== null ? info.value : {},
|
|
name: newSettings.name,
|
|
})
|
|
}
|
|
|
|
function loadSettings(newSettings: IUserSettings) {
|
|
settings.value = new UserSettingsModel({
|
|
...newSettings,
|
|
frontendSettings: {
|
|
// Need to set default settings here in case the user does not have any saved in the api already
|
|
playSoundWhenDone: true,
|
|
quickAddMagicMode: PrefixMode.Default,
|
|
colorSchema: 'auto',
|
|
allowIconChanges: true,
|
|
dateDisplay: DATE_DISPLAY.RELATIVE,
|
|
timeFormat: TIME_FORMAT.HOURS_24,
|
|
defaultTaskRelationType: RELATION_KIND.RELATED,
|
|
backgroundBrightness: 100,
|
|
showLastViewed: true,
|
|
sidebarWidth: null,
|
|
commentSortOrder: 'asc',
|
|
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
|
|
...newSettings.frontendSettings,
|
|
},
|
|
})
|
|
|
|
// Sync the quick entry shortcut to the desktop app when settings are loaded
|
|
window.vikunjaDesktop?.updateQuickEntryShortcut(
|
|
settings.value.frontendSettings.desktopQuickEntryShortcut || '',
|
|
)
|
|
}
|
|
|
|
function setAuthenticated(newAuthenticated: boolean) {
|
|
authenticated.value = newAuthenticated
|
|
}
|
|
|
|
|
|
function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) {
|
|
needsTotpPasscode.value = newNeedsTotpPasscode
|
|
}
|
|
|
|
async function reloadAvatar() {
|
|
if (!info.value || !info.value.username) {
|
|
return
|
|
}
|
|
invalidateAvatarCache(info.value)
|
|
avatarUrl.value = await fetchAvatarBlobUrl(info.value, 40)
|
|
}
|
|
|
|
function updateLastUserRefresh() {
|
|
lastUserInfoRefresh.value = new Date()
|
|
}
|
|
|
|
// Logs a user in with a set of credentials.
|
|
async function login(credentials) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
|
|
// Delete an eventually preexisting old token
|
|
removeToken()
|
|
|
|
try {
|
|
const response = await HTTP.post('login', objectToSnakeCase(credentials))
|
|
// Save the token to local storage for later use
|
|
saveToken(response.data.token, true)
|
|
|
|
// Tell others the user is authenticated
|
|
await checkAuth()
|
|
} catch (e) {
|
|
if (
|
|
e.response &&
|
|
e.response.data.code === 1017 &&
|
|
!credentials.totpPasscode
|
|
) {
|
|
setNeedsTotpPasscode(true)
|
|
}
|
|
|
|
throw e
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registers a new user and logs them in.
|
|
* Not sure if this is the right place to put the logic in, maybe a separate js component would be better suited.
|
|
*/
|
|
async function register(credentials, language: string|null = null) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
|
|
if (!language) {
|
|
language = i18n.global.locale.value ?? getBrowserLanguage()
|
|
}
|
|
|
|
try {
|
|
await HTTP.post('register', {
|
|
...credentials,
|
|
language,
|
|
})
|
|
return login(credentials)
|
|
} catch (e) {
|
|
if (e.response?.data?.code === 2002 && e.response?.data?.invalid_fields[0]?.startsWith('language:')) {
|
|
return register(credentials, 'en')
|
|
}
|
|
|
|
if (e.response?.data?.message) {
|
|
throw e.response.data
|
|
}
|
|
|
|
throw e
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function openIdAuth({provider, code, totpPasscode}: {provider: string, code: string, totpPasscode?: string}) {
|
|
const HTTP = HTTPFactory()
|
|
setIsLoading(true)
|
|
setLoggedInVia(null)
|
|
|
|
const fullProvider: IProvider = configStore.auth.openidConnect.providers.find((p: IProvider) => p.key === provider)
|
|
|
|
const data: Record<string, string> = {
|
|
code: code,
|
|
redirect_url: getRedirectUrlFromCurrentFrontendPath(fullProvider),
|
|
}
|
|
if (totpPasscode) {
|
|
data.totp_passcode = totpPasscode
|
|
}
|
|
|
|
// Delete an eventually preexisting old token
|
|
removeToken()
|
|
try {
|
|
const response = await HTTP.post(`/auth/openid/${provider}/callback`, data)
|
|
// Save the token to local storage for later use
|
|
saveToken(response.data.token, true)
|
|
setLoggedInVia(provider)
|
|
|
|
// Tell others the user is authenticated
|
|
await checkAuth()
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleDesktopOAuthTokens(tokens: {access_token: string, refresh_token: string, expires_in: number}) {
|
|
setIsLoading(true)
|
|
try {
|
|
removeToken()
|
|
saveToken(tokens.access_token, true)
|
|
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
|
await checkAuth()
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
async function linkShareAuth({hash, password}) {
|
|
const HTTP = HTTPFactory()
|
|
const response = await HTTP.post('/shares/' + hash + '/auth', {
|
|
password: password,
|
|
})
|
|
saveToken(response.data.token, false)
|
|
// Reset the debounce so checkAuth() actually parses the new link share
|
|
// JWT instead of silently returning due to the 1-minute throttle.
|
|
lastUserInfoRefresh.value = null
|
|
await checkAuth()
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Populates user information from jwt token saved in local storage in store
|
|
*/
|
|
async function checkAuth() {
|
|
const now = new Date()
|
|
const oneMinuteAgo = 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.
|
|
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
|
|
if (
|
|
lastUserInfoRefresh.value !== null &&
|
|
lastUserInfoRefresh.value > oneMinuteAgo
|
|
) {
|
|
return
|
|
}
|
|
|
|
const jwt = getToken()
|
|
let isAuthenticated = false
|
|
let jwtUserType: number | undefined
|
|
if (jwt) {
|
|
try {
|
|
const base64 = jwt
|
|
.split('.')[1]
|
|
.replace(/-/g, '+')
|
|
.replace(/_/g, '/')
|
|
const payload = JSON.parse(atob(base64))
|
|
const jwtUser = new UserModel(payload)
|
|
jwtUserType = jwtUser.type
|
|
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
|
|
|
|
isAuthenticated = jwtUser.exp >= ts
|
|
currentSessionId.value = payload.sid ?? null
|
|
|
|
if (isAuthenticated) {
|
|
// Only set user from JWT if we don't already have a fully loaded
|
|
// user with the same ID *and* type. The JWT lacks fields like
|
|
// `name`, so overwriting a complete user object causes a visible
|
|
// flash where the display name briefly reverts to the username.
|
|
// Comparing on type as well is essential: regular users and link
|
|
// shares share the same numeric ID space, so a USER and a
|
|
// LINK_SHARE can have the same `id`. Without the type check, a
|
|
// logged-in user opening a link share whose id collides with
|
|
// their user id would keep the USER `info.value` and never flip
|
|
// `authLinkShare` to true, causing the router guard to bounce
|
|
// between /share/:hash/auth and the project view forever.
|
|
if (
|
|
info.value === null ||
|
|
info.value.id !== jwtUser.id ||
|
|
info.value.type !== jwtUser.type
|
|
) {
|
|
setUser(jwtUser, false)
|
|
} else {
|
|
// Always keep exp in sync so token renewal checks stay accurate
|
|
info.value.exp = jwtUser.exp
|
|
}
|
|
} else if (jwtUser.type === AUTH_TYPES.USER) {
|
|
// JWT expired but this is a user session — attempt a cookie-based
|
|
// refresh before giving up. This lets users who reopen the app
|
|
// after the short JWT TTL seamlessly resume their session.
|
|
try {
|
|
await refreshTokenWithRetry(true)
|
|
const freshJwt = getToken()
|
|
if (freshJwt) {
|
|
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
const p = JSON.parse(atob(b64))
|
|
const freshUser = new UserModel(p)
|
|
isAuthenticated = freshUser.exp >= ts
|
|
currentSessionId.value = p.sid ?? null
|
|
if (info.value === null || info.value.id !== freshUser.id) {
|
|
setUser(freshUser, false)
|
|
} else {
|
|
info.value.exp = freshUser.exp
|
|
}
|
|
}
|
|
} catch {
|
|
// Refresh failed — stay unauthenticated
|
|
}
|
|
}
|
|
} catch (_) {
|
|
logout()
|
|
}
|
|
|
|
if (isAuthenticated && jwtUserType !== AUTH_TYPES.LINK_SHARE) {
|
|
const user = await refreshUserInfo()
|
|
if (!user) {
|
|
// refreshUserInfo() did not return a user — either the
|
|
// token vanished or a 4xx triggered logout(). Bail out
|
|
// so the stale local `isAuthenticated` doesn't override
|
|
// the auth state that logout() already set.
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
setAuthenticated(isAuthenticated)
|
|
if (!isAuthenticated) {
|
|
setUser(null)
|
|
redirectToSpecifiedProvider()
|
|
}
|
|
|
|
return Promise.resolve(authenticated)
|
|
}
|
|
|
|
async function refreshUserInfo() {
|
|
const jwt = getToken()
|
|
if (!jwt) {
|
|
return
|
|
}
|
|
|
|
const HTTP = AuthenticatedHTTPFactory()
|
|
try {
|
|
const response = await HTTP.get('user')
|
|
const newUser = new UserModel({
|
|
...response.data,
|
|
...(info.value?.type && {type: info.value?.type}),
|
|
...(info.value?.exp && {exp: info.value?.exp}),
|
|
})
|
|
|
|
if (newUser.settings.language) {
|
|
await setLanguage(newUser.settings.language)
|
|
}
|
|
|
|
setUser(newUser)
|
|
updateLastUserRefresh()
|
|
|
|
return newUser
|
|
} catch (e) {
|
|
if((e?.response?.status >= 400 && e?.response?.status < 500) ||
|
|
e?.response?.data?.message === 'missing, malformed, expired or otherwise invalid token provided') {
|
|
await logout()
|
|
return
|
|
}
|
|
|
|
const cause = {e}
|
|
|
|
if (typeof e?.response?.data?.message !== 'undefined') {
|
|
cause.message = e.response.data.message
|
|
}
|
|
|
|
console.error('Error refreshing user info:', e)
|
|
|
|
throw new Error('Error while refreshing user info:', {cause})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to verify the email
|
|
*/
|
|
async function verifyEmail(): Promise<boolean> {
|
|
const emailVerifyToken = localStorage.getItem('emailConfirmToken')
|
|
if (emailVerifyToken) {
|
|
const stopLoading = setModuleLoading(setIsLoading)
|
|
try {
|
|
await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
|
|
return true
|
|
} catch(e) {
|
|
throw new Error(e.response.data.message)
|
|
} finally {
|
|
localStorage.removeItem('emailConfirmToken')
|
|
stopLoading()
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function saveUserSettings({
|
|
settings,
|
|
showMessage = true,
|
|
}: {
|
|
settings: IUserSettings,
|
|
showMessage: boolean,
|
|
}) {
|
|
const userSettingsService = new UserSettingsService()
|
|
|
|
const cancel = setModuleLoading(setIsLoadingGeneralSettings)
|
|
try {
|
|
const oldName = info.value?.name
|
|
let settingsUpdate = {...settings}
|
|
if (configStore.demoModeEnabled) {
|
|
settingsUpdate = {
|
|
...settingsUpdate,
|
|
language: null,
|
|
}
|
|
}
|
|
const updateSettingsPromise = userSettingsService.update(settingsUpdate)
|
|
setUserSettings(settingsUpdate)
|
|
await setLanguage(settings.language)
|
|
await updateSettingsPromise
|
|
if (oldName !== undefined && oldName !== settingsUpdate.name) {
|
|
const {avatarProvider} = await (new AvatarService()).get({})
|
|
if (avatarProvider === 'initials') {
|
|
await reloadAvatar()
|
|
}
|
|
}
|
|
if (showMessage) {
|
|
success({message: i18n.global.t('user.settings.general.savedSuccess')})
|
|
}
|
|
} catch (e) {
|
|
error(e)
|
|
} finally {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renews the api token and saves it to local storage
|
|
*/
|
|
async function renewToken() {
|
|
if (!authenticated.value) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (isLinkShareAuth.value) {
|
|
// Link shares renew via the dedicated link-share endpoint (JWT-based).
|
|
const HTTP = AuthenticatedHTTPFactory()
|
|
const response = await HTTP.post('user/token')
|
|
saveToken(response.data.token, false)
|
|
} else {
|
|
// User sessions renew via the refresh-token cookie.
|
|
await refreshTokenWithRetry(true)
|
|
}
|
|
await checkAuth()
|
|
} catch (e) {
|
|
// Only logout if the JWT has actually expired and we can't refresh.
|
|
// If the JWT is still valid, the proactive refresh failure is harmless
|
|
// — the 401 interceptor will handle it when the token really expires.
|
|
const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND
|
|
const isExpired = !info.value?.exp || info.value.exp < nowInSeconds
|
|
if (isExpired && (e?.cause?.request?.status || e?.cause?.response?.status)) {
|
|
await logout()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
const {disconnect} = useWebSocket()
|
|
disconnect()
|
|
|
|
// Revoke the server session so the refresh token can't be reused.
|
|
// Best-effort: if the network call fails, still clean up locally.
|
|
let oidcLogoutUrl = ''
|
|
try {
|
|
const HTTP = AuthenticatedHTTPFactory()
|
|
const {data} = await HTTP.post('user/logout')
|
|
oidcLogoutUrl = data?.oidc_logout_url ?? ''
|
|
} catch (_e) {
|
|
// Ignore — session will expire naturally
|
|
}
|
|
|
|
removeToken()
|
|
const loggedInVia = getLoggedInVia()
|
|
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
|
|
lastUserInfoRefresh.value = null
|
|
|
|
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true')
|
|
|
|
// Redirect to the OIDC provider to end its session too. Prefer the
|
|
// server-built RP-Initiated Logout URL, falling back to the static one.
|
|
// These full-page redirects return the user to the login page, so we
|
|
// must not router.push there first — that would consume
|
|
// JUST_LOGGED_OUT_KEY before the round-trip lands.
|
|
if (oidcLogoutUrl) {
|
|
window.location.href = oidcLogoutUrl
|
|
return
|
|
}
|
|
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
|
if (fullProvider && redirectToProviderOnLogout(fullProvider)) {
|
|
return
|
|
}
|
|
|
|
await router.push({name: 'user.login'})
|
|
await checkAuth()
|
|
}
|
|
|
|
return {
|
|
// state
|
|
authenticated: readonly(authenticated),
|
|
needsTotpPasscode: readonly(needsTotpPasscode),
|
|
|
|
info: readonly(info),
|
|
avatarUrl: readonly(avatarUrl),
|
|
settings: readonly(settings),
|
|
|
|
currentSessionId: readonly(currentSessionId),
|
|
lastUserInfoRefresh: readonly(lastUserInfoRefresh),
|
|
|
|
authUser,
|
|
authLinkShare,
|
|
userDisplayName,
|
|
isLinkShareAuth,
|
|
|
|
isLoading: readonly(isLoading),
|
|
setIsLoading,
|
|
|
|
isLoadingGeneralSettings: readonly(isLoadingGeneralSettings),
|
|
setIsLoadingGeneralSettings,
|
|
|
|
setUser,
|
|
setUserSettings,
|
|
setAuthenticated,
|
|
setNeedsTotpPasscode,
|
|
|
|
reloadAvatar,
|
|
updateLastUserRefresh,
|
|
|
|
login,
|
|
register,
|
|
openIdAuth,
|
|
handleDesktopOAuthTokens,
|
|
linkShareAuth,
|
|
checkAuth,
|
|
refreshUserInfo,
|
|
verifyEmail,
|
|
saveUserSettings,
|
|
renewToken,
|
|
logout,
|
|
}
|
|
})
|
|
|
|
// support hot reloading
|
|
if (import.meta.hot) {
|
|
import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot))
|
|
}
|