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
+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,