From b3649c314a4d0d110b73e4f9dde15fcf41b3f47b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 18:11:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E4=BF=AE=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BC=9A=E8=AF=9D=E8=87=AA=E5=8A=A8=E7=BB=AD=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/api/http.ts | 11 +++-- src/api/session.test.mjs | 58 +++++++++++++++++++++++ src/api/session.ts | 21 +++++--- src/modules/auth/store/auth.ts | 29 ++++++++---- src/modules/auth/utils/jwt.test.mjs | 4 +- src/modules/auth/utils/jwt.ts | 12 ++++- src/modules/auth/views/AuthViews.test.mjs | 11 ++++- src/modules/auth/views/LoginView.vue | 8 ++-- 9 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 src/api/session.test.mjs diff --git a/README.md b/README.md index b2a4b55..91bac4a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ pnpm test 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 -登录表单在开发和生产环境均默认留空;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。 +登录表单在开发和生产环境默认留空;用户主动选择“记住账号”后只回填登录账号、不保存密码,也不改变 Access/Refresh Token 的持久化和刷新策略;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。 “我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。 diff --git a/src/api/http.ts b/src/api/http.ts index ed8a391..0d619ee 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -46,6 +46,11 @@ function mapError(error: unknown) { let refreshPromise: Promise | null = null let redirectingToLogin = false +const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] + +function isPublicAuthRequest(url?: string) { + return publicAuthPaths.some((path) => url?.endsWith(path)) +} function redirectToLoginAfterSessionExpiry() { if (redirectingToLogin || typeof window === 'undefined') return @@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() { transport.interceptors.request.use((config) => { const token = getAccessToken() - if (token) config.headers.Authorization = `Bearer ${token}` + if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}` return config }) @@ -106,9 +111,7 @@ transport.interceptors.response.use( (response) => response, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined - const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange'] - const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path)) - if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) { + if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs new file mode 100644 index 0000000..ce48286 --- /dev/null +++ b/src/api/session.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + REMEMBERED_LOGIN_ID_KEY, + clearSessionTokens, + getRememberedLoginId, + persistRememberedLoginId, + persistTokenPair, +} from './session.ts' + +function storage() { + const values = new Map() + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + } +} + +function installStorage() { + globalThis.localStorage = storage() + globalThis.sessionStorage = storage() +} + +test('persists the normal rotating session independently of remembered account preference', () => { + installStorage() + persistTokenPair({ + tokenType: 'Bearer', + accessToken: 'access-token', + expiresIn: 1440, + refreshToken: 'refresh-token', + refreshExpiresIn: 604800, + }) + + assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), 'access-token') + assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), 'refresh-token') + assert.equal(sessionStorage.getItem(ACCESS_TOKEN_KEY), null) + assert.equal(sessionStorage.getItem(REFRESH_TOKEN_KEY), null) +}) + +test('remembers only the login id and keeps it when session tokens are cleared', () => { + installStorage() + persistRememberedLoginId('merchant-user', true) + localStorage.setItem(ACCESS_TOKEN_KEY, 'access-token') + localStorage.setItem(REFRESH_TOKEN_KEY, 'refresh-token') + + clearSessionTokens() + + assert.equal(getRememberedLoginId(), 'merchant-user') + assert.equal(localStorage.getItem(REMEMBERED_LOGIN_ID_KEY), 'merchant-user') + assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), null) + assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), null) + + persistRememberedLoginId('merchant-user', false) + assert.equal(getRememberedLoginId(), '') +}) diff --git a/src/api/session.ts b/src/api/session.ts index 9d933bb..2f2dd3b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -2,6 +2,7 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token' export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token' export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token' export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at' +export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id' export interface TokenPair { tokenType: string @@ -33,17 +34,25 @@ export function getOverseasAccessToken() { return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || '' } -export function persistTokenPair(tokens: TokenPair, remember?: boolean) { - const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage +export function persistTokenPair(tokens: TokenPair) { clearSessionTokens() - storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) - storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) { - storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) - storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) + localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) + localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) } } +export function getRememberedLoginId() { + return localStorage.getItem(REMEMBERED_LOGIN_ID_KEY) || '' +} + +export function persistRememberedLoginId(loginId: string, remember: boolean) { + if (remember) localStorage.setItem(REMEMBERED_LOGIN_ID_KEY, loginId) + else localStorage.removeItem(REMEMBERED_LOGIN_ID_KEY) +} + export function persistTemporaryAccessToken(accessToken: string) { clearSessionTokens() sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken) diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 76e63fb..9c1a903 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -2,7 +2,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { CurrentUser } from '@chongchong/shared' import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth' -import { ApiError } from '@/api/http' +import { ApiError, refreshAuthenticatedSession } from '@/api/http' import { FRONTEND_TO_MERCHANT_PERMISSION_CODES, hasMappedMerchantPermission, @@ -11,8 +11,8 @@ import { getTenantProfile, updateMerchantPersonalProfile, } from '@/modules/tenant-management/public' -import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session' -import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' +import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session' +import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt' export interface ClientAccountProfile { username: string @@ -103,8 +103,8 @@ export const useAuthStore = defineStore('client-auth', () => { if (token.value) { try { applyToken(token.value) - } catch { - clearSession() + } catch (error) { + if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession() } } @@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => { async function restoreSession() { if (sessionRestored.value) return if (!isAuthenticated.value) { - sessionRestored.value = true - return + if (!getRefreshToken()) { + sessionRestored.value = true + return + } + try { + applyToken(await refreshAuthenticatedSession()) + } catch (error) { + if (isExpiredSessionError(error)) { + clearSession() + throw error + } + sessionRestored.value = true + return + } } try { await loadSessionContext() @@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => { async function login(loginId: string, password: string, remember = false) { const tokens = await loginMerchant(loginId, password) const decoded = decodeMerchantToken(tokens.accessToken) - persistTokenPair(tokens, remember) + persistTokenPair(tokens) + persistRememberedLoginId(loginId, remember) token.value = tokens.accessToken payload.value = decoded user.value = currentUser(decoded) diff --git a/src/modules/auth/utils/jwt.test.mjs b/src/modules/auth/utils/jwt.test.mjs index b0a0bed..bf1d3f7 100644 --- a/src/modules/auth/utils/jwt.test.mjs +++ b/src/modules/auth/utils/jwt.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { decodeMerchantToken } from './jwt.ts' +import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts' function tokenFor(payload) { const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url') @@ -29,5 +29,5 @@ test('rejects malformed tokens', () => { }) test('rejects expired tokens', () => { - assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/) + assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError) }) diff --git a/src/modules/auth/utils/jwt.ts b/src/modules/auth/utils/jwt.ts index 73bdcda..4ec31df 100644 --- a/src/modules/auth/utils/jwt.ts +++ b/src/modules/auth/utils/jwt.ts @@ -9,6 +9,13 @@ export interface MerchantJwtPayload { exp: number } +export class MerchantTokenExpiredError extends Error { + constructor() { + super('登录状态已过期') + this.name = 'MerchantTokenExpiredError' + } +} + function decodeBase64Url(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') @@ -27,10 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload { const payload = value as Partial if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error() if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端') - if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError() return payload as MerchantJwtPayload } catch (error) { - if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error + if (error instanceof Error && error.message === '账号不属于当前应用端') throw error + if (error instanceof MerchantTokenExpiredError) throw error throw new Error('登录凭证无法解析,请重新登录') } } diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index e9d911c..44831e8 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -44,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => { assert.match(loginSource, /忘记密码/) }) -test('persists both rotating tokens according to the remember choice', () => { +test('persists rotating tokens independently and only remembers the login id by choice', () => { assert.match(sessionSource, /ACCESS_TOKEN_KEY/) assert.match(sessionSource, /REFRESH_TOKEN_KEY/) - assert.match(sessionSource, /remember \? localStorage : sessionStorage/) + assert.match(sessionSource, /localStorage\.setItem\(ACCESS_TOKEN_KEY, tokens\.accessToken\)/) + assert.match(sessionSource, /localStorage\.setItem\(REFRESH_TOKEN_KEY, tokens\.refreshToken\)/) + assert.match(sessionSource, /persistRememberedLoginId/) + assert.match(loginSource, /getRememberedLoginId/) + assert.match(loginSource, /记住账号/) assert.match(sessionSource, /clearSessionTokens/) }) @@ -93,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /persistTokenPair\(tokens\)/) + assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/) assert.match(httpSource, /request\._retriedAfterRefresh = true/) assert.match(httpSource, /return await transport\.request\(request\)/) assert.match(httpSource, /envelope\.code !== '200'/) @@ -102,6 +107,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) + assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/) + assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/) assert.doesNotMatch(authStoreSource, /mock-client:/) }) diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue index f10f820..6f48b3a 100644 --- a/src/modules/auth/views/LoginView.vue +++ b/src/modules/auth/views/LoginView.vue @@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue' import { useAuthStore } from '@/modules/auth/store/auth' import AuthShell from '@/modules/auth/components/AuthShell.vue' +import { getRememberedLoginId } from '@/api/session' interface LoginForm { account: string @@ -19,12 +20,13 @@ const formRef = ref() const loading = ref(false) const impersonationFailed = route.query.impersonation === 'failed' const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated' +const rememberedLoginId = getRememberedLoginId() const form = reactive({ account: typeof route.query.account === 'string' ? route.query.account - : '', + : rememberedLoginId, password: '', - remember: false, + remember: Boolean(rememberedLoginId), }) const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/ const mainlandMobilePattern = /^1[3-9]\d{9}$/ @@ -113,7 +115,7 @@ function showResetHint() {
- 记住我 + 记住账号
登录