fix: No token refresh or revocation mechanism~

This commit is contained in:
voltsrage
2026-06-25 14:14:20 +08:00
parent a8964381a2
commit fdcc646fae
26 changed files with 3453 additions and 55 deletions
+96 -1
View File
@@ -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>
+82 -4
View File
@@ -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)
}
}
},
},
})
})