fix: No token refresh or revocation mechanism~
This commit is contained in:
@@ -1,23 +1,90 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270'
|
||||
|
||||
let authToken = null
|
||||
let refreshToken = null
|
||||
let refreshPromise = null
|
||||
let onSessionExpired = null
|
||||
|
||||
export function setAuthToken(token) {
|
||||
authToken = token
|
||||
}
|
||||
|
||||
export function setRefreshToken(token) {
|
||||
refreshToken = token
|
||||
}
|
||||
|
||||
export function onSessionExpiredCallback(callback) {
|
||||
onSessionExpired = callback
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...extra }
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
|
||||
return headers
|
||||
}
|
||||
|
||||
async function attemptRefresh() {
|
||||
if (!refreshToken) return false
|
||||
if (refreshPromise) return refreshPromise
|
||||
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const envelope = await res.json()
|
||||
if (!envelope.success) return false
|
||||
|
||||
authToken = envelope.data.accessToken
|
||||
refreshToken = envelope.data.refreshToken
|
||||
if (onSessionExpired) {
|
||||
onSessionExpired({
|
||||
type: 'refreshed',
|
||||
accessToken: envelope.data.accessToken,
|
||||
refreshToken: envelope.data.refreshToken,
|
||||
expiresAt: envelope.data.expiresAt,
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
function handleSessionExpired() {
|
||||
if (onSessionExpired) {
|
||||
onSessionExpired({ type: 'expired' })
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: authHeaders(options.headers),
|
||||
...options,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const refreshed = await attemptRefresh()
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: authHeaders(options.headers),
|
||||
...options,
|
||||
})
|
||||
const retryEnvelope = await retry.json()
|
||||
if (!retry.ok || !retryEnvelope.success) {
|
||||
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return retryEnvelope.data
|
||||
}
|
||||
handleSessionExpired()
|
||||
throw new Error('Session expired — please log in again.')
|
||||
}
|
||||
const envelope = await res.json()
|
||||
@@ -28,13 +95,26 @@ async function request(path, options = {}) {
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
/** Returns null when the resource does not exist yet (HTTP 404 or empty optional payload). */
|
||||
async function requestOptional(path) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (res.status === 404) return null
|
||||
if (res.status === 401) {
|
||||
const refreshed = await attemptRefresh()
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (retry.status === 404) return null
|
||||
const retryEnvelope = await retry.json()
|
||||
if (!retry.ok || !retryEnvelope.success) {
|
||||
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return retryEnvelope.data ?? null
|
||||
}
|
||||
handleSessionExpired()
|
||||
throw new Error('Session expired — please log in again.')
|
||||
}
|
||||
const envelope = await res.json()
|
||||
@@ -66,6 +146,21 @@ export const api = {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (res.status === 401) {
|
||||
const refreshed = await attemptRefresh()
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE_URL}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
})
|
||||
if (retry.status === 204) return null
|
||||
const retryEnvelope = await retry.json()
|
||||
if (!retry.ok || !retryEnvelope.success) {
|
||||
const msg = retryEnvelope.error?.message ?? `API ${retry.status}: ${path}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return retryEnvelope.data ?? null
|
||||
}
|
||||
handleSessionExpired()
|
||||
throw new Error('Session expired — please log in again.')
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
|
||||
const route = useRoute()
|
||||
const wardStore = useWardStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const authStore = useAuthStore()
|
||||
const { department } = storeToRefs(wardStore)
|
||||
const { alertSoundMuted } = storeToRefs(settingsStore)
|
||||
const { darkMode, toggle } = useDarkMode()
|
||||
@@ -135,6 +137,22 @@ function onDepartmentChange(event) {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="hidden items-center gap-3 border-l border-gray-200 pl-4 dark:border-gray-700 sm:flex">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ authStore.displayName }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:text-gray-300 dark:hover:bg-red-950/50 dark:hover:text-red-400"
|
||||
aria-label="Sign out"
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoleAccess } from '@/composables/roleAccess'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess()
|
||||
|
||||
function linkClasses(path) {
|
||||
@@ -219,5 +221,25 @@ function linkClasses(path) {
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="border-t border-gray-200 px-4 py-4 dark:border-gray-800">
|
||||
<div class="mb-2 px-4">
|
||||
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ authStore.displayName }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ authStore.role }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition duration-200 hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:text-gray-300 dark:hover:bg-red-950/50 dark:hover:text-red-400"
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoleAccess } from '@/composables/roleAccess'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { mobileNavLinks } = useRoleAccess()
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
@@ -86,6 +88,18 @@ const activePath = computed(() => route.path)
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li class="flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400"
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api, setAuthToken } from '@/api/client'
|
||||
import { api, setAuthToken, setRefreshToken, onSessionExpiredCallback } from '@/api/client'
|
||||
import router from '@/router'
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
function scheduleRefresh(expiresAt, store) {
|
||||
clearTimeout(refreshTimer)
|
||||
const expiresMs = new Date(expiresAt).getTime()
|
||||
const now = Date.now()
|
||||
const delay = Math.max((expiresMs - now) - 60_000, 5_000)
|
||||
refreshTimer = setTimeout(() => store.silentRefresh(), delay)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('vigilcare_token') ?? null,
|
||||
_refreshToken: localStorage.getItem('vigilcare_refresh_token') ?? null,
|
||||
user: JSON.parse(localStorage.getItem('vigilcare_user') ?? 'null'),
|
||||
expiresAt: localStorage.getItem('vigilcare_expires_at') ?? null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -22,6 +35,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
async login(username, password) {
|
||||
const data = await api.post('/api/v1/auth/login', { username, password })
|
||||
this.token = data.accessToken
|
||||
this._refreshToken = data.refreshToken
|
||||
this.expiresAt = data.expiresAt
|
||||
this.user = {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
@@ -29,20 +44,83 @@ export const useAuthStore = defineStore('auth', {
|
||||
role: data.role,
|
||||
}
|
||||
localStorage.setItem('vigilcare_token', this.token)
|
||||
localStorage.setItem('vigilcare_refresh_token', this._refreshToken)
|
||||
localStorage.setItem('vigilcare_expires_at', this.expiresAt)
|
||||
localStorage.setItem('vigilcare_user', JSON.stringify(this.user))
|
||||
setAuthToken(this.token)
|
||||
setRefreshToken(this._refreshToken)
|
||||
scheduleRefresh(this.expiresAt, this)
|
||||
},
|
||||
|
||||
logout() {
|
||||
async logout() {
|
||||
try {
|
||||
if (this._refreshToken) {
|
||||
await api.post('/api/v1/auth/logout', { refreshToken: this._refreshToken })
|
||||
}
|
||||
} catch {
|
||||
// Best-effort server-side revocation
|
||||
}
|
||||
this._clearSession()
|
||||
router.push('/login')
|
||||
},
|
||||
|
||||
_clearSession() {
|
||||
clearTimeout(refreshTimer)
|
||||
this.token = null
|
||||
this._refreshToken = null
|
||||
this.expiresAt = null
|
||||
this.user = null
|
||||
localStorage.removeItem('vigilcare_token')
|
||||
localStorage.removeItem('vigilcare_refresh_token')
|
||||
localStorage.removeItem('vigilcare_expires_at')
|
||||
localStorage.removeItem('vigilcare_user')
|
||||
setAuthToken(null)
|
||||
setRefreshToken(null)
|
||||
},
|
||||
|
||||
async silentRefresh() {
|
||||
if (!this._refreshToken) return
|
||||
try {
|
||||
const data = await api.post('/api/v1/auth/refresh', {
|
||||
refreshToken: this._refreshToken,
|
||||
})
|
||||
this._applyTokens(data.accessToken, data.refreshToken, data.expiresAt)
|
||||
} catch {
|
||||
this._clearSession()
|
||||
router.push('/login')
|
||||
}
|
||||
},
|
||||
|
||||
_applyTokens(accessToken, newRefreshToken, expiresAt) {
|
||||
this.token = accessToken
|
||||
this._refreshToken = newRefreshToken
|
||||
this.expiresAt = expiresAt
|
||||
localStorage.setItem('vigilcare_token', accessToken)
|
||||
localStorage.setItem('vigilcare_refresh_token', newRefreshToken)
|
||||
localStorage.setItem('vigilcare_expires_at', expiresAt)
|
||||
setAuthToken(accessToken)
|
||||
setRefreshToken(newRefreshToken)
|
||||
scheduleRefresh(expiresAt, this)
|
||||
},
|
||||
|
||||
hydrate() {
|
||||
if (this.token) setAuthToken(this.token)
|
||||
if (this.token) {
|
||||
setAuthToken(this.token)
|
||||
setRefreshToken(this._refreshToken)
|
||||
|
||||
onSessionExpiredCallback((event) => {
|
||||
if (event.type === 'refreshed') {
|
||||
this._applyTokens(event.accessToken, event.refreshToken, event.expiresAt)
|
||||
} else {
|
||||
this._clearSession()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
|
||||
if (this.expiresAt) {
|
||||
scheduleRefresh(this.expiresAt, this)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user