feat(security): HttpOnly cookie sessions, token revocation, and RBAC

- Add User.role (enum Role/ADMIN) and User.tokenVersion with migration
- Login now issues short-lived access token (30m default) + 7d refresh
  token, both embedding tokenVersion and a typ discriminator
- Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped
  to /auth); refresh token never leaves the cookie
- New endpoints: POST /auth/refresh (rotation), GET /auth/me,
  POST /auth/logout (bumps tokenVersion, revoking all tokens)
- JWT strategy accepts bearer or cookie, rejects refresh tokens, and
  verifies tokenVersion + user existence on every request
- Global RolesGuard: authenticated routes require ADMIN unless widened
  via @Roles(...)
- Admin SPA: session fully cookie-based, no token in localStorage;
  router guard restores session via /auth/me; axios auto-refreshes once
  on 401; stale localStorage keys cleaned up
This commit is contained in:
yeuimu
2026-08-22 12:04:56 +08:00
parent 755b40aded
commit be0b90e68f
16 changed files with 503 additions and 85 deletions
+28 -19
View File
@@ -1,5 +1,5 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
@@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({
headers: {
'Content-Type': 'application/json',
},
// Session tokens live in HttpOnly cookies — send them along.
withCredentials: true,
})
// Request interceptor
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
(response: AxiosResponse) => {
@@ -35,15 +23,36 @@ request.interceptors.response.use(
}
return body
},
(error: AxiosError) => {
async (error: AxiosError) => {
// Access token expired: try the refresh cookie once, then retry the
// original request. A second 401 (refresh failed) logs the user out.
const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined
if (
error.response?.status === 401 &&
config &&
!config._retried &&
!config.url?.includes('/auth/login') &&
!config.url?.includes('/auth/refresh')
) {
config._retried = true
try {
await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
{},
{ withCredentials: true },
)
return request.request(config)
} catch {
// fall through to the 401 handling below
}
}
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
ElMessage.error('Unauthorized, please login')
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
break
case 403:
@@ -69,4 +78,4 @@ request.interceptors.response.use(
}
)
export default request
export default request
+23 -32
View File
@@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
// Token persisted to localStorage
const token = ref<string>(localStorage.getItem('token') || '')
// The session lives in HttpOnly cookies set by the API; nothing
// security-relevant is stored client-side. `user` is just UI state,
// restored from the server via /auth/me on app start.
const user = ref<User | null>(null)
const sessionChecked = ref(false)
// User persisted to localStorage (parsed if available)
const user = ref<User | null>(loadUser())
// Tokens moved to HttpOnly cookies; clean up any stale values from the
// previous localStorage-based session.
localStorage.removeItem('token')
localStorage.removeItem('user')
const isLoggedIn = computed(() => !!token.value)
const isLoggedIn = computed(() => !!user.value)
function loadUser(): User | null {
const raw = localStorage.getItem('user')
if (!raw) return null
// Restore the session once per app start. The router guard awaits this
// so a page refresh on a protected route does not bounce to /login.
async function ensureSessionChecked() {
if (sessionChecked.value) return
sessionChecked.value = true
try {
return JSON.parse(raw) as User
user.value = await authApi.getCurrentUser()
} catch {
return null
}
}
function setToken(newToken: string) {
token.value = newToken
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
user.value = null
}
}
function setUser(newUser: User | null) {
user.value = newUser
if (newUser) {
localStorage.setItem('user', JSON.stringify(newUser))
} else {
localStorage.removeItem('user')
}
}
async function login(payload: LoginRequest) {
const res = await authApi.login(payload) as any;
const accessToken: string = res.accessToken ?? res.data?.accessToken ?? '';
const userData: User | null = res.user ?? res.data?.user ?? null;
if (accessToken) setToken(accessToken);
if (userData) setUser(userData);
return res;
const res = await authApi.login(payload) as any
const userData: User | null = res.user ?? res.data?.user ?? null
sessionChecked.value = true
setUser(userData)
return res
}
async function fetchCurrentUser() {
@@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
} catch {
// Ignore network errors during logout
}
setToken('')
setUser(null)
}
return {
token,
user,
isLoggedIn,
ensureSessionChecked,
login,
fetchCurrentUser,
logout,