Skip to content

Commit 9989b19

Browse files
author
yqz
committed
添加双token无感刷新设计
1 parent 3a01737 commit 9989b19

5 files changed

Lines changed: 198 additions & 42 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,25 @@ mvn -DskipTests package
172172

173173
详细放行规则见 `SecurityConfig`。
174174

175+
### 双 Token 与无感刷新(设计说明)
176+
177+
**后端(无需单独端口)**
178+
179+
- 登录成功返回 **Access Token**(短效)与 **Refresh Token**(长效),均由 `openblog.jwt.*` 配置过期时间(见上文配置表)。
180+
- 换发接口:`POST /api/v1/auth/refresh`,请求体为 `{ "refreshToken": "<refresh_jwt>" }`,成功时返回新的 **accessToken** 与 **refreshToken**(与登录接口同一 `AuthResponse` 结构)。
181+
- 与登录、文章等接口共用 **`server.port` 同一端口**;不存在「刷新专用端口」。
182+
183+
**前端(Vue,`vue/src`)**
184+
185+
- **`vue/src/auth/session.js`**:`isConsoleSessionValid()` 在 **access 已过期但 refresh 仍有效** 时也视为已登录,避免仅因短 token 过期被路由误判下线。
186+
- **`vue/src/api/http.js`**
187+
- **`refreshSessionTokens()`**:直连 `/api/v1/auth/refresh` 换发双 token,**不走** `request()`,避免递归;并发多次刷新合并为 **同一 Promise**(互斥)。
188+
- **`request(..., { withAuth: true })` 发起前**:若 refresh 仍可用,且「无 access」或 **access 剩余有效期不足约 90 秒」,则**主动刷新**,减少首包 401。
189+
- **收到 HTTP 401**:在带鉴权请求上 **自动刷新一次并重试原请求**(带 `_refreshRetried` 标记,防止死循环);刷新仍失败则 `clearAuth()`,在 `/console` 下跳转控制台登录。
190+
- **`vue/src/api/media.js`**:上传走原生 `fetch`,在 **401** 时调用 `refreshSessionTokens()` 后 **重传一次**(与 JSON 接口策略一致)。
191+
192+
**小结**:后端只提供 REST 路径;无感刷新由前端在**同一 API 基址**上组合「主动刷新 + 401 重试 + 并发合并」完成。
193+
175194
---
176195

177196
## 测试

‎vue/src/api/http.js‎

Lines changed: 126 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,15 @@
44
* - 未设置:开发环境默认连仓库里的远程后端;生产环境默认同域(与上面空字符串一致)。
55
* 注意:不要用 `||`,否则空字符串会被当成假值而误用默认 IP。
66
*/
7-
import { clearAuth, getStoredAccessToken } from '../auth/session'
7+
import {
8+
clearAuth,
9+
getStoredAccessToken,
10+
getStoredRefreshToken,
11+
hasUsableRefreshToken,
12+
isJwtExpired,
13+
isLikelyJwt,
14+
parseJwtPayload
15+
} from '../auth/session'
816

917
function resolveApiBase() {
1018
const v = import.meta.env.VITE_API_BASE
@@ -16,21 +24,107 @@ function resolveApiBase() {
1624

1725
export const API_BASE = resolveApiBase()
1826

19-
const baseUrl = API_BASE
20-
2127
function buildUrl(path) {
2228
// 后端统一是 /api/v1 前缀
2329
if (path.startsWith('http://') || path.startsWith('https://')) return path
24-
return `${baseUrl}${path}`
30+
return `${API_BASE}${path}`
31+
}
32+
33+
/** 并发刷新合并为同一 Promise */
34+
let refreshInflight = null
35+
36+
/**
37+
* 使用 refreshToken 换发双 token(不经过 request,避免环)。
38+
* 失败时会 clearAuth。
39+
*/
40+
export function refreshSessionTokens() {
41+
if (refreshInflight) return refreshInflight
42+
43+
refreshInflight = (async () => {
44+
const rt = getStoredRefreshToken()
45+
if (!rt || !isLikelyJwt(rt) || isJwtExpired(rt)) {
46+
clearAuth()
47+
const err = new Error('登录已失效')
48+
err.httpStatus = 401
49+
throw err
50+
}
51+
52+
const res = await fetch(buildUrl('/api/v1/auth/refresh'), {
53+
method: 'POST',
54+
headers: { 'Content-Type': 'application/json' },
55+
body: JSON.stringify({ refreshToken: rt })
56+
})
57+
58+
const text = await res.text()
59+
let json = null
60+
try {
61+
json = text ? JSON.parse(text) : null
62+
} catch {
63+
// ignore
64+
}
65+
66+
if (!res.ok || (json && typeof json.code === 'number' && json.code !== 0)) {
67+
clearAuth()
68+
const err = new Error(json?.message || '登录已失效')
69+
err.httpStatus = res.status || 401
70+
err.code = json?.code
71+
throw err
72+
}
73+
74+
const data = json?.data
75+
if (!data?.accessToken || !data?.refreshToken) {
76+
clearAuth()
77+
const err = new Error('刷新返回异常')
78+
err.httpStatus = 401
79+
throw err
80+
}
81+
82+
localStorage.setItem('accessToken', data.accessToken)
83+
localStorage.setItem('refreshToken', data.refreshToken)
84+
return data
85+
})()
86+
87+
return refreshInflight.finally(() => {
88+
refreshInflight = null
89+
})
90+
}
91+
92+
function accessExpiresWithinSeconds(seconds) {
93+
const t = getStoredAccessToken()
94+
if (!t || !isLikelyJwt(t)) return true
95+
const p = parseJwtPayload(t)
96+
if (typeof p.exp !== 'number') return false
97+
return p.exp * 1000 - Date.now() < seconds * 1000
98+
}
99+
100+
function redirectConsoleLoginIfNeeded() {
101+
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/console')) {
102+
const redirect = window.location.pathname + window.location.search
103+
window.location.assign('/console/login?redirect=' + encodeURIComponent(redirect))
104+
}
25105
}
26106

27107
export async function request(path, options = {}) {
108+
const isRetry = Boolean(options._refreshRetried)
109+
const extraHeaders = options.headers && typeof options.headers === 'object' ? options.headers : {}
28110
const headers = {
29111
'Content-Type': 'application/json',
30-
...(options.headers || {})
112+
...extraHeaders
31113
}
32114

33115
if (options.withAuth) {
116+
if (
117+
!isRetry &&
118+
hasUsableRefreshToken() &&
119+
(!getStoredAccessToken() || accessExpiresWithinSeconds(90))
120+
) {
121+
try {
122+
await refreshSessionTokens()
123+
} catch {
124+
// 无 access 时交给后续分支;将过期 access 时可能仍失败,再由 401 重试
125+
}
126+
}
127+
34128
const token = getStoredAccessToken()
35129
if (!token) {
36130
const err = new Error('未登录')
@@ -40,31 +134,46 @@ export async function request(path, options = {}) {
40134
headers.Authorization = `Bearer ${token}`
41135
}
42136

137+
const fetchOpts = { ...options }
138+
delete fetchOpts.withAuth
139+
delete fetchOpts._refreshRetried
140+
delete fetchOpts.headers
141+
43142
const res = await fetch(buildUrl(path), {
44-
headers,
45-
...options
143+
...fetchOpts,
144+
headers
46145
})
47146

48147
const text = await res.text()
49148
let json = null
50-
try {
51-
json = text ? JSON.parse(text) : null
52-
} catch {
53-
// ignore
149+
if (!_skipJson) {
150+
try {
151+
json = text ? JSON.parse(text) : null
152+
} catch {
153+
// ignore
154+
}
54155
}
55156

56-
if (res.status === 401 && options.withAuth) {
57-
clearAuth()
58-
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/console')) {
59-
const redirect = window.location.pathname + window.location.search
60-
window.location.assign('/console/login?redirect=' + encodeURIComponent(redirect))
157+
if (res.status === 401 && options.withAuth && !isRetry) {
158+
try {
159+
await refreshSessionTokens()
160+
return request(path, { ...options, _refreshRetried: true })
161+
} catch {
162+
redirectConsoleLoginIfNeeded()
163+
const err = new Error('登录已失效')
164+
err.httpStatus = 401
165+
throw err
61166
}
167+
}
168+
169+
if (res.status === 401 && options.withAuth && isRetry) {
170+
clearAuth()
171+
redirectConsoleLoginIfNeeded()
62172
const err = new Error('登录已失效')
63173
err.httpStatus = 401
64174
throw err
65175
}
66176

67-
// API 统一返回 ApiResponse,HTTP 层可能仍为 200
68177
if (json && typeof json.code === 'number' && json.code !== 0) {
69178
const msg = json.message || '请求失败'
70179
const err = new Error(msg)
@@ -83,4 +192,3 @@ export async function request(path, options = {}) {
83192

84193
return json?.data ?? json
85194
}
86-

‎vue/src/api/media.js‎

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getStoredAccessToken } from '../auth/session'
2-
import { API_BASE } from './http'
2+
import { API_BASE, refreshSessionTokens } from './http'
33

44
function getAuthHeader() {
55
const token = getStoredAccessToken()
@@ -8,23 +8,17 @@ function getAuthHeader() {
88
}
99

1010
export async function uploadMedia(file) {
11-
const fd = new FormData()
12-
fd.append('file', file)
13-
14-
const res = await fetch(`${API_BASE}/api/v1/media/upload`, {
15-
method: 'POST',
16-
headers: {
17-
...getAuthHeader()
18-
},
19-
body: fd
20-
})
21-
22-
const text = await res.text()
23-
let json = null
24-
try {
25-
json = text ? JSON.parse(text) : null
26-
} catch {
27-
json = null
11+
let { res, json } = await doUploadWithFile(file)
12+
13+
if (res.status === 401) {
14+
try {
15+
await refreshSessionTokens()
16+
;({ res, json } = await doUploadWithFile(file))
17+
} catch {
18+
const err = new Error('登录已失效')
19+
err.httpStatus = 401
20+
throw err
21+
}
2822
}
2923

3024
if (!res.ok) {
@@ -41,3 +35,25 @@ export async function uploadMedia(file) {
4135
return json?.data ?? json
4236
}
4337

38+
function doUploadWithFile(file) {
39+
const fd = new FormData()
40+
fd.append('file', file)
41+
42+
return fetch(`${API_BASE}/api/v1/media/upload`, {
43+
method: 'POST',
44+
headers: {
45+
...getAuthHeader()
46+
},
47+
body: fd
48+
}).then(async (res) => {
49+
const text = await res.text()
50+
let json = null
51+
try {
52+
json = text ? JSON.parse(text) : null
53+
} catch {
54+
json = null
55+
}
56+
return { res, json }
57+
})
58+
}
59+

‎vue/src/auth/session.js‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ export function getStoredAccessToken() {
1515
return s.length > 0 ? s : null
1616
}
1717

18+
export function getStoredRefreshToken() {
19+
const t = localStorage.getItem('refreshToken')
20+
if (typeof t !== 'string') return null
21+
const s = t.trim()
22+
return s.length > 0 ? s : null
23+
}
24+
25+
/** refresh JWT 仍有效且未过期 */
26+
export function hasUsableRefreshToken() {
27+
const rt = getStoredRefreshToken()
28+
return Boolean(rt && isLikelyJwt(rt) && !isJwtExpired(rt))
29+
}
30+
1831
/** 是否为标准 JWT 外形(header.payload.sig) */
1932
export function isLikelyJwt(token) {
2033
return typeof token === 'string' && token.split('.').length === 3
@@ -42,10 +55,11 @@ export function clearAuth() {
4255
localStorage.removeItem('refreshToken')
4356
}
4457

58+
/** 控制台/带鉴权前台:access 有效,或仍可用 refresh 换发(无感刷新入口) */
4559
export function isConsoleSessionValid() {
46-
const token = getStoredAccessToken()
47-
if (!token || !isLikelyJwt(token) || isJwtExpired(token)) return false
48-
return true
60+
const access = getStoredAccessToken()
61+
if (access && isLikelyJwt(access) && !isJwtExpired(access)) return true
62+
return hasUsableRefreshToken()
4963
}
5064

5165
/** JWT payload 中的 role(ADMIN / AUTHOR / READER) */

‎vue/src/components/BlogHeader.vue‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ import { computed, onMounted, ref, watch } from 'vue'
8282
import { useRoute, useRouter } from 'vue-router'
8383
import { toggleTheme as applyToggle } from '../theme'
8484
import { fetchMe } from '../api/admin'
85-
import { clearAuth, getStoredAccessToken, isJwtExpired, isLikelyJwt } from '../auth/session'
85+
import { clearAuth, isConsoleSessionValid } from '../auth/session'
8686
8787
defineEmits(['toggle-widgets'])
8888
@@ -98,8 +98,7 @@ const displayName = computed(() => {
9898
})
9999
100100
function tokenLooksValid() {
101-
const t = getStoredAccessToken()
102-
return Boolean(t && isLikelyJwt(t) && !isJwtExpired(t))
101+
return isConsoleSessionValid()
103102
}
104103
105104
async function loadMe() {

0 commit comments

Comments
 (0)