From 2c338490e5b6ad027115f0acfccb9cceb0a3462f Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 Nov 2026 22:53:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BB=A4=E7=89=8C=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E8=87=AA=E5=8A=A8=E7=BB=AD=E4=B8=8A=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=94=A8=E5=8D=8A=E5=B0=8F=E6=97=B6=E5=B0=B1=E8=A2=AB?= =?UTF-8?q?=E8=B8=A2=E5=9B=9E=E7=99=BB=E5=BD=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端一直签 access(30 分钟)和 refresh(14 天)两个令牌,但两个客户端拿到 401 都是直接清 令牌跳登录页,refresh 从来没被用过——"14 天有效"只是纸面参数,真实寿命就是 30 分钟。 - web/client.ts:请求前剩不到 90 秒先换;撞 401 先换再重放一次原请求;并发共用一次刷新; 上传附件同一条路 - android/Api.kt:call 拆 call/callOnce,2002 时用 Mutex 串行刷新后重放 - 后端 /auth/refresh 看一眼账号状态:停用的账号不能靠 refresh 一直续(2012) - 新增 session-refresh.mjs:假 access + 真 refresh 要能留住人,两个都假才回登录页 --- 2.Design/API.md | 9 +- 2.Design/ROADMAP-M0-M1.md | 27 ++++ .../src/main/java/com/dadao/task/data/Api.kt | 47 ++++++- backend/app/api/auth.py | 8 +- backend/tests/test_auth.py | 16 +++ web/e2e/session-refresh.mjs | 115 ++++++++++++++++ web/src/lib/client.ts | 128 +++++++++++++++--- 7 files changed, 329 insertions(+), 21 deletions(-) create mode 100644 web/e2e/session-refresh.mjs diff --git a/2.Design/API.md b/2.Design/API.md index c85675e..03f8805 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -56,6 +56,10 @@ `Authorization: Bearer `。`access` 30 分钟,`refresh` 14 天。 `refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。 +**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对, +撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。 +每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。 + ### 0.4 幂等 所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: `。 @@ -146,7 +150,10 @@ { "refresh_token": "…" } ``` -返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。 +返回同形状的 `tokens`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**—— +token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。 + +换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。 ### `POST /auth/login/password` diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 1032403..66d5f4d 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -712,3 +712,30 @@ - `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿, 截图 `2.Design/screens/prod-knowledge-read-page.png`。 - 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。 + +## 12. 登录态续期(2026-11-03) + +用户反馈:"web端登陆后好像没多久就退出登陆了,现在系统对登陆时限是怎么设计的?" + +### 12.1 原来的设计(也就是问题所在) + +- access 30 分钟、refresh 14 天,服务端两个都签好、都发给了客户端。 +- 但**没有任何客户端用过 refresh**:web 与安卓拿到 `401/2002` 都是直接清令牌、回登录页。 +- 结果"14 天有效"只是纸面参数:真实登录寿命就是 30 分钟,到点下一次请求就被扔出去, + 正在写的工单、正在编辑的知识都不会留下。 + +### 12.2 改法 + +- `web/src/lib/client.ts`:新增 `refreshSession()`(并发只发一次),请求前若 access 剩不到 90 秒先换; + 收到 401 先换再重放一次原请求,换不动才清令牌发 `authExpired`。`uploadFile` 走同一条路。 +- `android/app/src/main/java/com/dadao/task/data/Api.kt`:`call()` 拆成 `call` + `callOnce`, + 2002 时 `refreshTokens(stale)` 用 `Mutex` 串行刷新(等锁期间别人换好了就直接用新的),重放一次。 +- 后端 `/auth/refresh` 顺手补一条:账号被停用 → `2012`,别让停用账号靠 refresh 一直续。 + +### 12.3 验证 + +- `backend/tests/test_auth.py::test_disabled_account_cannot_refresh`(停用账号换令牌 → 403/2012), + 全量 **140 passed**、`ruff check` / `format --check` 全绿。 +- 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、 + 拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。 +- 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。 diff --git a/android/app/src/main/java/com/dadao/task/data/Api.kt b/android/app/src/main/java/com/dadao/task/data/Api.kt index c88df31..3869910 100644 --- a/android/app/src/main/java/com/dadao/task/data/Api.kt +++ b/android/app/src/main/java/com/dadao/task/data/Api.kt @@ -2,6 +2,8 @@ package com.dadao.task.data import com.dadao.task.BuildConfig import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -789,11 +791,55 @@ class Api(private val tokens: TokenStore) { method = "PATCH", ) + /** + * 访问令牌只有 30 分钟,refresh 有 14 天。 + * + * 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次; + * 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。 + * 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。 + */ private suspend inline fun call( path: String, body: String? = null, auth: Boolean = false, method: String? = null, + ): T { + val stale = tokens.accessToken + return try { + callOnce(path, body, auth, method) + } catch (e: ApiException) { + if (!auth || e.code != 2002 || !refreshTokens(stale)) throw e + callOnce(path, body, auth, method) + } + } + + private val refreshLock = Mutex() + + private suspend fun refreshTokens(stale: String?): Boolean { + val refresh = tokens.refreshToken ?: return false + return refreshLock.withLock { + // 等锁期间别人可能已经换完了:本地令牌已经不是失败的那一个,直接用新的 + val current = tokens.accessToken + if (current != null && current != stale) return@withLock true + try { + val fresh: Tokens = call( + path = "/auth/refresh", + body = json.encodeToString(RefreshRequest(refresh)), + ) + tokens.save(fresh.accessToken, fresh.refreshToken) + true + } catch (e: ApiException) { + tokens.clear() + false + } + } + } + + private suspend inline fun callOnce( + path: String, + body: String? = null, + auth: Boolean = false, + method: String? = null, ): T = withContext(Dispatchers.IO) { val builder = Request.Builder().url(base + path) val verb = method ?: if (body == null) "GET" else "POST" @@ -817,7 +863,6 @@ class Api(private val tokens: TokenStore) { throw ApiException(1500, "响应格式异常(HTTP ${response.code})") } if (envelope.code != 0) { - if (envelope.code == 2002 && auth) tokens.clear() throw ApiException(envelope.code, envelope.message, envelope.requestId) } envelope.data ?: throw ApiException(1500, "响应缺少 data") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 9d00c57..3809e34 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -240,8 +240,14 @@ async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict: @router.post("/refresh", summary="刷新访问令牌") -async def refresh(body: RefreshIn) -> dict: +async def refresh(body: RefreshIn, session: SessionDep) -> dict: user_id = decode_token(body.refresh_token, "refresh") + # 换令牌时也看一眼账号状态:停用的账号不该靠 refresh 一直续下去 + user = await session.get(AppUser, user_id) + if user is None: + raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在") + if user.status != "active": + raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用,请联系管理员") tokens = create_token_pair(user_id) # 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取 return ok(_tokens_out(tokens)) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 3644eed..14513ee 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -70,6 +70,22 @@ async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None: assert me.status_code == 200 +async def test_disabled_account_cannot_refresh(client: AsyncClient) -> None: + # 停用的账号不能靠 refresh 一直续下去:令牌在有效期内也要挡住 + user = await login(client, "13800000009") + from app.db import SessionFactory + from app.models import AppUser + + async with SessionFactory() as session, session.begin(): + row = await session.get(AppUser, user["user_id"]) + assert row is not None + row.status = "disabled" + + resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}) + assert resp.status_code == 403 + assert resp.json()["code"] == 2012 + + async def test_requires_token(client: AsyncClient) -> None: resp = await client.get("/api/v1/auth/me") assert resp.status_code == 401 diff --git a/web/e2e/session-refresh.mjs b/web/e2e/session-refresh.mjs new file mode 100644 index 0000000..0b423c0 --- /dev/null +++ b/web/e2e/session-refresh.mjs @@ -0,0 +1,115 @@ +/** + * 登录态续期的验收:访问令牌过期时,界面不该把人踢回登录页。 + * + * 背景:access 只有 30 分钟,refresh 有 14 天,但以前前端拿到 401 就直接清令牌跳登录页, + * refresh 存着没人用——用户的感觉就是"用一会儿就被踢出去"。 + * 现在前端会先用 refresh 换一对令牌、把原请求重放一次,换不动才清。 + * + * 这里不能造一个"真的过期"的签名令牌,所以把 access 换成一串假的: + * 服务端照样回 401,走的还是同一条续期分支。 + * + * 用法:node web/e2e/session-refresh.mjs + */ +import { chromium } from "playwright-core"; +import { readFileSync } from "node:fs"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const PHONE = process.env.PHONE ?? "13900000001"; +const CHROME = + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`; +const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url); + +let pass = 0; +let fail = 0; +function check(name, ok, extra = "") { + if (ok) { + pass += 1; + console.log(`PASS ${name}`); + } else { + fail += 1; + console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`); + } +} + +const password = (() => { + if (process.env.PASSWORD) return process.env.PASSWORD; + try { + return readFileSync(PWD_FILE, "utf8").trim(); + } catch { + return ""; + } +})(); +if (!password) throw new Error("没有密码:设 PASSWORD,或先跑一遍 account.mjs 生成 .local/e2e-account-password.txt"); + +async function login() { + const r = await fetch(`${API}/auth/login/password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: PHONE, password }), + }); + const body = await r.json(); + if (body.code !== 0) throw new Error(`登录失败:${body.code} ${body.message}`); + return body.data.tokens; +} + +const tokens = await login(); + +const browser = await chromium.launch({ executablePath: CHROME }); +const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); +const page = await context.newPage(); + +const errors = []; +page.on("pageerror", (e) => errors.push(String(e))); + +// ① 假 access + 真 refresh:应该自己续上,留在首页 +await page.addInitScript( + ([access, refresh]) => { + localStorage.setItem("dadao.access", access); + localStorage.setItem("dadao.refresh", refresh); + }, + ["bogus.access.token", tokens.refresh_token], +); +await page.goto(`${WEB}/`, { waitUntil: "networkidle" }); +await page.waitForTimeout(800); + +check("令牌过期也不会被踢回登录页", !page.url().includes("/login"), page.url()); +check( + "首页照常渲染(能看到工作区列表或新建入口)", + (await page.getByText(/我的工作区|新建工作区/).first().isVisible().catch(() => false)) === true, +); + +const afterRefresh = await page.evaluate(() => localStorage.getItem("dadao.access")); +check("拿到的是一对新的访问令牌", Boolean(afterRefresh) && afterRefresh !== "bogus.access.token"); + +// 新令牌要真能用:再请求一次业务接口 +const probe = await page.evaluate(async () => { + const token = localStorage.getItem("dadao.access"); + const r = await fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${token}` } }); + return r.status; +}); +check("新令牌能直接调业务接口", probe === 200, `HTTP ${probe}`); + +// ② access 与 refresh 都是假的:这次才该回登录页 +const page2 = await context.newPage(); +await page2.addInitScript(() => { + localStorage.setItem("dadao.access", "bogus.access.token"); + localStorage.setItem("dadao.refresh", "bogus.refresh.token"); +}); +await page2.goto(`${WEB}/`, { waitUntil: "networkidle" }); +await page2.waitForTimeout(800); +check( + "连 refresh 都失效时,才回到登录页", + page2.url().includes("/login") || + (await page2.getByText(/手机号|邮箱/).first().isVisible().catch(() => false)) === true, + page2.url(), +); + +check("控制台没有未捕获异常", errors.length === 0, errors.join(" | ")); + +await page.screenshot({ path: "e2e/artifacts/session-refresh.png" }); +await browser.close(); + +console.log(fail === 0 ? "ALL PASS" : `${fail} FAILED`); +process.exit(fail === 0 ? 0 : 1); diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts index 8c23120..302986a 100644 --- a/web/src/lib/client.ts +++ b/web/src/lib/client.ts @@ -1,10 +1,12 @@ /** * 接口客户端。 * - * 三条约定: + * 四条约定: * 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。 * 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。 - * 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。 + * 3. 访问令牌只有 30 分钟:快到期先用 refresh 换一对新的,撞上 401 也再换一次重放, + * 换不动才清令牌回登录页。以前是 401 直接踢人,用着用着就被扔出去。 + * 4. 并发请求共用同一次刷新,别在 401 风暴里打出十个 /auth/refresh。 */ const BASE = "/api/v1"; @@ -48,6 +50,60 @@ export const session = { export const authExpired = new EventTarget(); +/** 访问令牌的剩余寿命(秒);解不出来就当作"不知道",交给 401 那条路。 */ +function accessExpiresIn(token: string | null): number | null { + if (!token) return null; + const payload = token.split(".")[1]; + if (!payload) return null; + try { + const padded = payload.replace(/-/g, "+").replace(/_/g, "/"); + const json = JSON.parse(atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="))); + if (typeof json?.exp !== "number") return null; + return json.exp - Math.floor(Date.now() / 1000); + } catch { + return null; + } +} + +let refreshing: Promise | null = null; + +/** + * 用 refresh token 换一对新令牌。 + * + * 返回 false 表示换不动(没令牌、过期、被改过),调用方负责清理会话。 + * `/auth/refresh` 的返回是**扁平**的令牌对象,不带 tokens 信封,别照登录那套解析。 + */ +export function refreshSession(): Promise { + if (refreshing) return refreshing; + const refresh = session.refresh; + if (!refresh) return Promise.resolve(false); + refreshing = (async () => { + try { + const resp = await fetch(`${BASE}/auth/refresh`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refresh }), + }); + const body = await resp.json().catch(() => null); + if (!resp.ok || !body || body.code !== 0) return false; + const tokens = body.data as Tokens; + if (!tokens?.access_token || !tokens?.refresh_token) return false; + session.save(tokens); + return true; + } catch { + return false; + } finally { + refreshing = null; + } + })(); + return refreshing; +} + +/** 这些路径自己就是在谈登录,401 不要拿去刷新(否则错密码会被刷新掩盖)。 */ +function isAuthPath(path: string): boolean { + return path.startsWith("/auth/login") || path.startsWith("/auth/register") || path.startsWith("/auth/refresh"); +} + /** * 幂等键。 * @@ -81,8 +137,13 @@ interface RequestOptions { idempotencyKey?: string; } -async function raw(path: string, opts: RequestOptions = {}): Promise { - const method: Method = opts.method ?? "GET"; +interface Parsed { + ok: boolean; + status: number; + body: { code: number; message: string; data: T; request_id: string } | null; +} + +async function send(path: string, opts: RequestOptions, method: Method): Promise> { const headers: Record = { Accept: "application/json" }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; @@ -101,21 +162,43 @@ async function raw(path: string, opts: RequestOptions = {}): Promise { body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }); - let body: { code: number; message: string; data: T; request_id: string } | null = null; + let body: Parsed["body"] = null; try { body = await resp.json(); } catch { body = null; } + return { ok: resp.ok, status: resp.status, body }; +} - if (!body) { - throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`); +async function raw(path: string, opts: RequestOptions = {}): Promise { + const method: Method = opts.method ?? "GET"; + const canRefresh = !isAuthPath(path); + + // 提前换:剩不到 90 秒就先续上,别等用户正在打字时吃一个 401 + if (canRefresh && session.refresh) { + const left = accessExpiresIn(session.access); + if (left !== null && left < 90) await refreshSession(); } - if (!resp.ok || body.code !== 0) { - if (resp.status === 401) { + + let result = await send(path, opts, method); + + if (result.status === 401) { + // 令牌可能只是过期了:换一对重放一次,用户的输入不该因为这件事丢掉 + if (canRefresh && (await refreshSession())) { + result = await send(path, opts, method); + } + if (result.status === 401) { session.clear(); authExpired.dispatchEvent(new Event("expired")); } + } + + const { ok, body } = result; + if (!body) { + throw new ApiError(result.status, `服务返回了非 JSON 响应(HTTP ${result.status})`); + } + if (!ok || body.code !== 0) { throw new ApiError(body.code, body.message, body.request_id); } return body.data; @@ -139,12 +222,25 @@ export async function uploadFile(path: string, file: File): Promise { const form = new FormData(); form.append("file", file, file.name); - const headers: Record = { Accept: "application/json" }; - const token = session.access; - if (token) headers.Authorization = `Bearer ${token}`; - headers["Idempotency-Key"] = newIdempotencyKey(); + const post = () => { + const headers: Record = { + Accept: "application/json", + "Idempotency-Key": newIdempotencyKey(), + }; + const token = session.access; + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(`${BASE}${path}`, { method: "POST", headers, body: form }); + }; - const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form }); + let resp = await post(); + if (resp.status === 401 && (await refreshSession())) { + // 附件也走同一条续期路线:换完令牌重发一次,不要把半截上传变成"重新登录" + resp = await post(); + if (resp.status === 401) { + session.clear(); + authExpired.dispatchEvent(new Event("expired")); + } + } let body: { code: number; message: string; data: T; request_id: string } | null = null; try { @@ -156,10 +252,6 @@ export async function uploadFile(path: string, file: File): Promise { throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`); } if (!resp.ok || body.code !== 0) { - if (resp.status === 401) { - session.clear(); - authExpired.dispatchEvent(new Event("expired")); - } throw new ApiError(body.code, body.message, body.request_id); } return body.data;