滑动续期与自动刷新:
Web:访问令牌 10 分钟;活跃时自动轮换刷新令牌;连续无操作 30 分钟后清除登录并跳转登录页。 App:会话窗口 3 天;打开 App、恢复前台或请求接口时自动刷新并重新顺延 3 天。 普通登录和 SSO 使用同一策略。 刷新令牌只以 SHA-256 摘要入库,每次刷新都会轮换,旧令牌无法再次使用;退出登录会吊销刷新令牌。 网络临时故障不会误清登录状态,多标签页同时刷新也做了竞争处理。
This commit is contained in:
+34
-5
@@ -1,27 +1,56 @@
|
||||
import axios from 'axios'
|
||||
import { goLogin } from '../utils/navigate'
|
||||
import {
|
||||
authStorageKeys,
|
||||
clearAuthSession,
|
||||
markActivity,
|
||||
refreshAuthSession,
|
||||
refreshIfNeeded,
|
||||
} from '../auth/session'
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('jiaowu_token')
|
||||
http.interceptors.request.use(async (config) => {
|
||||
const isAuthenticationRequest =
|
||||
config.url?.endsWith('/auth/login') ||
|
||||
config.url?.endsWith('/auth/refresh') ||
|
||||
config.url?.endsWith('/auth/logout') ||
|
||||
config.url?.endsWith('/auth/sso/exchange') ||
|
||||
config.url?.endsWith('/auth/sso/bind')
|
||||
if (!isAuthenticationRequest) {
|
||||
const activeToken = await refreshIfNeeded(true)
|
||||
if (activeToken) markActivity()
|
||||
}
|
||||
const token = localStorage.getItem(authStorageKeys.token)
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const isAuthenticationRequest =
|
||||
error.config?.url?.endsWith('/auth/login') ||
|
||||
error.config?.url?.endsWith('/auth/refresh') ||
|
||||
error.config?.url?.endsWith('/auth/logout') ||
|
||||
error.config?.url?.endsWith('/auth/sso/exchange') ||
|
||||
error.config?.url?.endsWith('/auth/sso/bind')
|
||||
const retryableConfig = error.config as
|
||||
(typeof error.config & { _jiaowuRetried?: boolean }) | undefined
|
||||
if (error.response?.status === 401 && !isAuthenticationRequest &&
|
||||
!retryableConfig?._jiaowuRetried) {
|
||||
const token = await refreshAuthSession()
|
||||
if (token && retryableConfig) {
|
||||
retryableConfig._jiaowuRetried = true
|
||||
retryableConfig.headers.Authorization = `Bearer ${token}`
|
||||
return http.request(retryableConfig)
|
||||
}
|
||||
}
|
||||
if (error.response?.status === 401 && !isAuthenticationRequest) {
|
||||
localStorage.removeItem('jiaowu_token')
|
||||
localStorage.removeItem('jiaowu_user')
|
||||
clearAuthSession()
|
||||
goLogin(location.pathname + location.search + location.hash)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import axios from 'axios'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
const TOKEN_KEY = 'jiaowu_token'
|
||||
const REFRESH_TOKEN_KEY = 'jiaowu_refresh_token'
|
||||
const ACCESS_EXPIRES_KEY = 'jiaowu_access_expires_at'
|
||||
const SESSION_EXPIRES_KEY = 'jiaowu_session_expires_at'
|
||||
const USER_KEY = 'jiaowu_user'
|
||||
const LAST_ACTIVITY_KEY = 'jiaowu_last_activity_at'
|
||||
const WEB_IDLE_MILLISECONDS = 30 * 60 * 1000
|
||||
const REFRESH_AHEAD_MILLISECONDS = 60 * 1000
|
||||
const WEB_SLIDING_TOUCH_MILLISECONDS = 60 * 1000
|
||||
|
||||
export interface AuthSessionPayload {
|
||||
token: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshToken: string
|
||||
sessionExpiresAt: string
|
||||
user: unknown
|
||||
}
|
||||
|
||||
export const isNativeApp = () => Capacitor.isNativePlatform()
|
||||
|
||||
export function saveAuthSession(payload: AuthSessionPayload, recordActivity = true) {
|
||||
localStorage.setItem(TOKEN_KEY, payload.token)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
|
||||
localStorage.setItem(ACCESS_EXPIRES_KEY, payload.accessTokenExpiresAt)
|
||||
localStorage.setItem(SESSION_EXPIRES_KEY, payload.sessionExpiresAt)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(payload.user))
|
||||
if (recordActivity) markActivity()
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
export function clearAuthSession(notifyExpired = false) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(ACCESS_EXPIRES_KEY)
|
||||
localStorage.removeItem(SESSION_EXPIRES_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(LAST_ACTIVITY_KEY)
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
if (notifyExpired) window.dispatchEvent(new Event('mingxu-session-expired'))
|
||||
}
|
||||
|
||||
export function markActivity() {
|
||||
localStorage.setItem(LAST_ACTIVITY_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function hasExceededWebIdleTimeout(now = Date.now()) {
|
||||
if (isNativeApp()) return false
|
||||
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||
return lastActivity > 0 && now - lastActivity >= WEB_IDLE_MILLISECONDS
|
||||
}
|
||||
|
||||
export function hasLocallyExpired(now = Date.now()) {
|
||||
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||
return hasExceededWebIdleTimeout(now) ||
|
||||
(Number.isFinite(sessionExpiresAt) && sessionExpiresAt <= now)
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
export function refreshAuthSession(): Promise<string | null> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
if (!refreshToken || hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
refreshPromise = axios.post<AuthSessionPayload>(
|
||||
`${import.meta.env.VITE_API_BASE_URL ?? '/api'}/auth/refresh`,
|
||||
{ refreshToken },
|
||||
{ timeout: 15000 },
|
||||
).then(({ data }) => {
|
||||
saveAuthSession(data, false)
|
||||
return data.token
|
||||
}).catch((error: unknown) => {
|
||||
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
if (currentRefreshToken && currentRefreshToken !== refreshToken) {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
if (axios.isAxiosError(error) &&
|
||||
error.response &&
|
||||
[400, 401, 403].includes(error.response.status)) {
|
||||
clearAuthSession(true)
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}).finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function refreshIfNeeded(isCurrentRequestActivity = false) {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!token) return null
|
||||
if (hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return null
|
||||
}
|
||||
const expiresAt = Date.parse(localStorage.getItem(ACCESS_EXPIRES_KEY) ?? '')
|
||||
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||
const now = Date.now()
|
||||
const hasRecentWebActivity = !isNativeApp() &&
|
||||
(isCurrentRequestActivity || now - lastActivity <= WEB_SLIDING_TOUCH_MILLISECONDS)
|
||||
const webSessionNeedsSlidingTouch = hasRecentWebActivity &&
|
||||
Number.isFinite(sessionExpiresAt) &&
|
||||
sessionExpiresAt - now <= WEB_IDLE_MILLISECONDS - WEB_SLIDING_TOUCH_MILLISECONDS
|
||||
if (!Number.isFinite(expiresAt) ||
|
||||
expiresAt - now <= REFRESH_AHEAD_MILLISECONDS ||
|
||||
webSessionNeedsSlidingTouch) {
|
||||
return refreshAuthSession()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export function initializeAuthSession() {
|
||||
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||
if (hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return
|
||||
}
|
||||
|
||||
let lastActivityWrite = 0
|
||||
const recordActivity = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastActivityWrite < 5000) return
|
||||
lastActivityWrite = now
|
||||
markActivity()
|
||||
}
|
||||
const activityEvents: Array<keyof WindowEventMap> = [
|
||||
'pointerdown',
|
||||
'keydown',
|
||||
'touchstart',
|
||||
'scroll',
|
||||
]
|
||||
activityEvents.forEach(event =>
|
||||
window.addEventListener(event, recordActivity, { passive: true }))
|
||||
|
||||
window.setInterval(() => {
|
||||
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||
if (hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshIfNeeded().catch(() => undefined)
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshIfNeeded(true).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
window.addEventListener('online', () =>
|
||||
void refreshIfNeeded(true).catch(() => undefined))
|
||||
}
|
||||
|
||||
export const authStorageKeys = {
|
||||
token: TOKEN_KEY,
|
||||
refreshToken: REFRESH_TOKEN_KEY,
|
||||
user: USER_KEY,
|
||||
}
|
||||
@@ -6,11 +6,17 @@ import router from './router'
|
||||
import { initializeAppUpdates } from './services/appUpdates'
|
||||
import { initializeNativeHome } from './services/nativeHome'
|
||||
import { setRouter } from './utils/navigate'
|
||||
import { initializeAuthSession } from './auth/session'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
setRouter(router)
|
||||
window.addEventListener('mingxu-session-expired', () => {
|
||||
const returnUrl = location.pathname + location.search + location.hash
|
||||
void router.push({ name: 'login', query: { redirect: returnUrl } })
|
||||
})
|
||||
initializeAuthSession()
|
||||
app.mount('#app')
|
||||
void initializeAppUpdates()
|
||||
initializeNativeHome(router)
|
||||
|
||||
+36
-18
@@ -1,6 +1,12 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import http from '../api/http'
|
||||
import {
|
||||
authStorageKeys,
|
||||
clearAuthSession,
|
||||
isNativeApp,
|
||||
saveAuthSession,
|
||||
} from '../auth/session'
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string
|
||||
@@ -12,54 +18,66 @@ export interface CurrentUser {
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('jiaowu_token') ?? '')
|
||||
const saved = localStorage.getItem('jiaowu_user')
|
||||
const token = ref(localStorage.getItem(authStorageKeys.token) ?? '')
|
||||
const saved = localStorage.getItem(authStorageKeys.user)
|
||||
const user = ref<CurrentUser | null>(saved ? JSON.parse(saved) : null)
|
||||
const isLoggedIn = computed(() => Boolean(token.value))
|
||||
const isSuperAdmin = computed(() => user.value?.roles.includes('SuperAdmin') ?? false)
|
||||
|
||||
async function login(userName: string, password: string) {
|
||||
const { data } = await http.post('/auth/login', { userName, password })
|
||||
const { data } = await http.post('/auth/login', {
|
||||
userName,
|
||||
password,
|
||||
isNativeApp: isNativeApp(),
|
||||
})
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
localStorage.setItem('jiaowu_token', data.token)
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
saveAuthSession(data)
|
||||
}
|
||||
|
||||
async function exchangeSso(code: string) {
|
||||
const { data } = await http.post('/auth/sso/exchange', { code })
|
||||
const { data } = await http.post('/auth/sso/exchange', {
|
||||
code,
|
||||
isNativeApp: isNativeApp(),
|
||||
})
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
localStorage.setItem('jiaowu_token', data.token)
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
saveAuthSession(data)
|
||||
}
|
||||
|
||||
async function bindSso(code: string, userName: string, password: string) {
|
||||
const { data } = await http.post('/auth/sso/bind', { code, userName, password })
|
||||
const { data } = await http.post('/auth/sso/bind', {
|
||||
code,
|
||||
userName,
|
||||
password,
|
||||
isNativeApp: isNativeApp(),
|
||||
})
|
||||
token.value = data.token
|
||||
user.value = data.user
|
||||
localStorage.setItem('jiaowu_token', data.token)
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
saveAuthSession(data)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!token.value) return
|
||||
const { data } = await http.get('/auth/me')
|
||||
user.value = data
|
||||
localStorage.setItem('jiaowu_user', JSON.stringify(data))
|
||||
localStorage.setItem(authStorageKeys.user, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
const refreshToken = localStorage.getItem(authStorageKeys.refreshToken)
|
||||
if (refreshToken) void http.post('/auth/logout', { refreshToken }).catch(() => undefined)
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('jiaowu_token')
|
||||
localStorage.removeItem('jiaowu_user')
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
clearAuthSession()
|
||||
}
|
||||
|
||||
window.addEventListener('mingxu-auth-changed', () => {
|
||||
token.value = localStorage.getItem(authStorageKeys.token) ?? ''
|
||||
const currentUser = localStorage.getItem(authStorageKeys.user)
|
||||
user.value = currentUser ? JSON.parse(currentUser) : null
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
|
||||
Reference in New Issue
Block a user