diff --git a/frontend/src/composables/useRenewTokenOnFocus.ts b/frontend/src/composables/useRenewTokenOnFocus.ts index adbcfe20b..b6a5e3e03 100644 --- a/frontend/src/composables/useRenewTokenOnFocus.ts +++ b/frontend/src/composables/useRenewTokenOnFocus.ts @@ -1,11 +1,12 @@ -import {computed} from 'vue' +import {computed, ref, watch} from 'vue' import {useRouter} from 'vue-router' import {useEventListener} from '@vueuse/core' import {useAuthStore} from '@/stores/auth' -import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date' +import {MILLISECONDS_A_SECOND} from '@/constants/date' -const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR +// Refresh the token 60 seconds before it expires to avoid API calls hitting 401. +const REFRESH_BUFFER_SECONDS = 60 export function useRenewTokenOnFocus() { const router = useRouter() @@ -13,34 +14,78 @@ export function useRenewTokenOnFocus() { const userInfo = computed(() => authStore.info) const authenticated = computed(() => authStore.authenticated) + const refreshTimer = ref | null>(null) + + function clearRefreshTimer() { + if (refreshTimer.value !== null) { + clearTimeout(refreshTimer.value) + refreshTimer.value = null + } + } + + // Schedule a proactive refresh based on the JWT's exp claim. + // Called after every successful auth check or token refresh. + function scheduleProactiveRefresh() { + clearRefreshTimer() + + if (!authenticated.value || !userInfo.value?.exp) { + return + } + + const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND + const expiresIn = userInfo.value.exp - nowInSeconds + const refreshIn = Math.max(expiresIn - REFRESH_BUFFER_SECONDS, 0) + + refreshTimer.value = setTimeout(() => { + authStore.renewToken() + }, refreshIn * MILLISECONDS_A_SECOND) + } + + // Re-schedule whenever the user info (and thus exp) changes. + watch( + () => userInfo.value?.exp, + () => scheduleProactiveRefresh(), + ) + + // Also re-schedule when authentication state changes (e.g. logout clears it). + watch(authenticated, (isAuth) => { + if (!isAuth) { + clearRefreshTimer() + } + }) // Try renewing the token every time vikunja is loaded initially // (When opening the browser the focus event is not fired) authStore.renewToken() - // Check if the token is still valid if the window gets focus again to maybe renew it + // Check if the token is still valid if the window gets focus again to maybe renew it. + // This handles the case where the laptop was suspended and the timer didn't fire. useEventListener('focus', async () => { if (!authenticated.value) { return } - const nowInSeconds = new Date().getTime() / MILLISECONDS_A_SECOND + const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND const expiresIn = userInfo.value ? userInfo.value.exp - nowInSeconds : 0 - // 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 the token is already expired, try to refresh immediately. + // The 401 interceptor would also handle this, but refreshing here + // avoids a brief error flash on the first API call after focus. if (expiresIn <= 0) { - await authStore.checkAuth() - await router.push({name: 'user.login'}) + try { + await authStore.renewToken() + } catch { + await authStore.checkAuth() + await 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 < SECONDS_TOKEN_VALID) { + // If the token expires within the buffer window, refresh now. + if (expiresIn < REFRESH_BUFFER_SECONDS) { authStore.renewToken() - console.debug('renewed token') } }) } diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index 669c3a772..7a9620140 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -1,5 +1,4 @@ -import {AuthenticatedHTTPFactory} from '@/helpers/fetcher' -import type {AxiosResponse} from 'axios' +import {HTTPFactory} from '@/helpers/fetcher' let savedToken: string | null = null @@ -36,16 +35,42 @@ export const removeToken = () => { /** * Refreshes an auth token while ensuring it is updated everywhere. + * The refresh token is sent automatically as an HttpOnly cookie. + * The server rotates the cookie on every call. + * + * Uses the Web Locks API to coordinate across browser tabs. Only one tab + * performs the actual refresh; other tabs waiting for the lock detect that + * the token in localStorage was already updated and adopt it directly. */ -export async function refreshToken(persist: boolean): Promise { - const HTTP = AuthenticatedHTTPFactory() - try { - const response = await HTTP.post('user/token') - saveToken(response.data.token, persist) - return response +export async function refreshToken(persist: boolean): Promise { + // Capture the token before waiting for the lock so we can detect + // if another tab refreshed while we were queued. + const tokenBeforeLock = localStorage.getItem('token') - } catch(e) { - throw new Error('Error renewing token: ', { cause: e }) + const doRefresh = async () => { + // If the token in localStorage changed while waiting for the lock, + // another tab already refreshed. Just adopt the new token. + const currentToken = localStorage.getItem('token') + if (currentToken && currentToken !== tokenBeforeLock) { + savedToken = currentToken + return + } + + // We hold the lock and no one else refreshed — make the API call. + const HTTP = HTTPFactory() + try { + const response = await HTTP.post('user/token/refresh') + saveToken(response.data.token, persist) + } catch (e) { + throw new Error('Error renewing token: ', {cause: e}) + } + } + + if (navigator.locks) { + await navigator.locks.request('vikunja-token-refresh', doRefresh) + } else { + // Fallback for environments without Web Locks (e.g. insecure HTTP) + await doRefresh() } } diff --git a/frontend/src/helpers/fetcher.ts b/frontend/src/helpers/fetcher.ts index 16dee8efb..f0fa4f9c2 100644 --- a/frontend/src/helpers/fetcher.ts +++ b/frontend/src/helpers/fetcher.ts @@ -1,8 +1,15 @@ import axios from 'axios' -import {getToken} from '@/helpers/auth' +import type {AxiosRequestConfig} from 'axios' +import {getToken, refreshToken} from '@/helpers/auth' +import {AUTH_TYPES} from '@/modelTypes/IUser' export function HTTPFactory() { - const instance = axios.create({baseURL: window.API_URL}) + const instance = axios.create({ + baseURL: window.API_URL, + // Ensure the browser sends and accepts cookies (e.g. the HttpOnly + // refresh token) even when the API is on a different origin. + withCredentials: true, + }) instance.interceptors.request.use((config) => { // by setting the baseURL fresh for every request @@ -15,7 +22,38 @@ export function HTTPFactory() { return instance } -export function AuthenticatedHTTPFactory() { +// Shared state for the 401 interceptor so that concurrent requests that all +// fail with 401 only trigger a single refresh, then all retry with the new token. +let refreshPromise: Promise | null = null + +async function doRefresh(): Promise { + try { + await refreshToken(true) + return getToken() + } catch { + // Refresh failed. Don't remove the token here — in a multi-tab scenario, + // another tab may have successfully rotated the refresh token, and clearing + // localStorage would log out that tab too. Let the caller decide. + return null + } +} + +/** + * Returns the `type` claim from a JWT without verifying the signature. + * Returns null if the token is missing or malformed. + */ +function getTokenType(token: string | null): number | null { + if (!token) return null + try { + const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') + const payload = JSON.parse(atob(base64)) + return typeof payload.type === 'number' ? payload.type : null + } catch { + return null + } +} + +export function AuthenticatedHTTPFactory() { const instance = HTTPFactory() instance.interceptors.request.use((config) => { @@ -32,5 +70,52 @@ export function AuthenticatedHTTPFactory() { return config }) + // Response interceptor: on expired JWT 401, attempt a refresh and retry once. + instance.interceptors.response.use(undefined, async (error) => { + const originalRequest: AxiosRequestConfig & { _retried?: boolean } = error.config + + // Only intercept 401s, and don't retry a request that already retried. + if (error.response?.status !== 401 || originalRequest._retried) { + return Promise.reject(error) + } + + // Only retry when the 401 is from an expired/invalid JWT. The backend + // returns error code 11 for this case. Other 401s (disabled account, + // wrong API token, etc.) are genuine auth failures — retrying would loop. + const ERROR_CODE_INVALID_TOKEN = 11 + if (error.response?.data?.code !== ERROR_CODE_INVALID_TOKEN) { + return Promise.reject(error) + } + + // Don't try to refresh if we don't have a token at all (not logged in), + // or if the token is a link share JWT (they don't use cookie-based refresh). + const currentToken = getToken() + if (!currentToken || getTokenType(currentToken) !== AUTH_TYPES.USER) { + return Promise.reject(error) + } + + originalRequest._retried = true + + // Coalesce concurrent refresh attempts into a single request. + if (!refreshPromise) { + refreshPromise = doRefresh().finally(() => { + refreshPromise = null + }) + } + + const newToken = await refreshPromise + if (!newToken) { + // Refresh failed — reject so the UI can redirect to login. + return Promise.reject(error) + } + + // Retry the original request with the new token. + originalRequest.headers = { + ...originalRequest.headers, + Authorization: `Bearer ${newToken}`, + } + return instance.request(originalRequest) + }) + return instance } diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 93d63561c..e10219f9b 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -212,6 +212,20 @@ "expiresAt": "Expires at", "permissions": "Permissions" } + }, + "sessions": { + "title": "Sessions", + "description": "These are all the devices currently logged into your account. You can revoke any session to log that device out. It may take up to 10 minutes for the revocation to take full effect.", + "deviceInfo": "Device", + "ipAddress": "IP Address", + "lastActive": "Last Active", + "current": "Current session", + "delete": { + "header": "Revoke session", + "text": "Are you sure you want to revoke this session? The device will be logged out. It may take up to 10 minutes for the session to fully expire." + }, + "deleteSuccess": "Session revoked successfully. It may take up to 10 minutes for the session to fully expire.", + "noOtherSessions": "No other active sessions." } }, "deletion": { diff --git a/frontend/src/modelTypes/ISession.ts b/frontend/src/modelTypes/ISession.ts new file mode 100644 index 000000000..7dbb477fe --- /dev/null +++ b/frontend/src/modelTypes/ISession.ts @@ -0,0 +1,10 @@ +import type {IAbstract} from '@/modelTypes/IAbstract' + +export interface ISession extends IAbstract { + id: string + deviceInfo: string + ipAddress: string + isCurrent: boolean + lastActive: Date + created: Date +} diff --git a/frontend/src/models/session.ts b/frontend/src/models/session.ts new file mode 100644 index 000000000..7cf9751f2 --- /dev/null +++ b/frontend/src/models/session.ts @@ -0,0 +1,24 @@ +import AbstractModel from '@/models/abstractModel' +import type {ISession} from '@/modelTypes/ISession' + +export default class SessionModel extends AbstractModel implements ISession { + id = '' + deviceInfo = '' + ipAddress = '' + isCurrent = false + lastActive: Date = null + created: Date = null + + constructor(data: Partial = {}) { + super() + + this.assignData(data) + + if (this.lastActive) { + this.lastActive = new Date(this.lastActive) + } + if (this.created) { + this.created = new Date(this.created) + } + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3bc9ec7f0..ceacc0b74 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -139,6 +139,11 @@ const router = createRouter({ name: 'user.settings.apiTokens', component: () => import('@/views/user/settings/ApiTokens.vue'), }, + { + path: '/user/settings/sessions', + name: 'user.settings.sessions', + component: () => import('@/views/user/settings/Sessions.vue'), + }, { path: '/user/settings/migrate', name: 'migrate.start', diff --git a/frontend/src/services/session.ts b/frontend/src/services/session.ts new file mode 100644 index 000000000..e3ac60711 --- /dev/null +++ b/frontend/src/services/session.ts @@ -0,0 +1,16 @@ +import AbstractService from '@/services/abstractService' +import SessionModel from '@/models/session' +import type {ISession} from '@/modelTypes/ISession' + +export default class SessionService extends AbstractService { + constructor() { + super({ + getAll: '/user/sessions', + delete: '/user/sessions/{id}', + }) + } + + modelFactory(data: Partial) { + return new SessionModel(data) + } +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index d602e9f2a..b471a729a 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -76,6 +76,7 @@ export const useAuthStore = defineStore('auth', () => { const avatarUrl = ref('') const settings = ref(new UserSettingsModel()) + const currentSessionId = ref(null) const lastUserInfoRefresh = ref(null) const isLoading = ref(false) const isLoadingGeneralSettings = ref(false) @@ -290,19 +291,46 @@ export const useAuthStore = defineStore('auth', () => { .split('.')[1] .replace(/-/g, '+') .replace(/_/g, '/') - const jwtUser = new UserModel(JSON.parse(atob(base64))) + const payload = JSON.parse(atob(base64)) + const jwtUser = new UserModel(payload) const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND) isAuthenticated = jwtUser.exp >= ts - // Only set user from JWT if we don't already have a fully loaded - // user with the same ID. 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. - if (info.value === null || info.value.id !== jwtUser.id) { - setUser(jwtUser, false) - } else { - // Always keep exp in sync so token renewal checks stay accurate - info.value.exp = jwtUser.exp + 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. 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. + if (info.value === null || info.value.id !== jwtUser.id) { + 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 refreshToken(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() @@ -426,29 +454,44 @@ export const useAuthStore = defineStore('auth', () => { /** * Renews the api token and saves it to local storage */ - function renewToken() { - // 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 (!authenticated.value) { - return - } + async function renewToken() { + if (!authenticated.value) { + return + } - try { - await refreshToken(!isLinkShareAuth.value) - await 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) { - await logout() - } + 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 refreshToken(true) } - }, 5000) + 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() { + // Revoke the server session so the refresh token can't be reused. + // Best-effort: if the network call fails, still clean up locally. + try { + const HTTP = AuthenticatedHTTPFactory() + await HTTP.post('user/logout') + } 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. @@ -472,6 +515,7 @@ export const useAuthStore = defineStore('auth', () => { avatarUrl: readonly(avatarUrl), settings: readonly(settings), + currentSessionId: readonly(currentSessionId), lastUserInfoRefresh: readonly(lastUserInfoRefresh), authUser, diff --git a/frontend/src/views/user/Settings.vue b/frontend/src/views/user/Settings.vue index 7f6870aaf..81e21409d 100644 --- a/frontend/src/views/user/Settings.vue +++ b/frontend/src/views/user/Settings.vue @@ -108,6 +108,10 @@ const navigationItems = computed(() => { title: t('user.settings.apiTokens.title'), routeName: 'user.settings.apiTokens', }, + { + title: t('user.settings.sessions.title'), + routeName: 'user.settings.sessions', + }, { title: t('user.deletion.title'), routeName: 'user.settings.deletion', diff --git a/frontend/src/views/user/settings/Sessions.vue b/frontend/src/views/user/settings/Sessions.vue new file mode 100644 index 000000000..6c0bc6c35 --- /dev/null +++ b/frontend/src/views/user/settings/Sessions.vue @@ -0,0 +1,111 @@ + + +