fix(client): 修复登录会话自动续期
This commit is contained in:
+7
-4
@@ -46,6 +46,11 @@ function mapError(error: unknown) {
|
||||
|
||||
let refreshPromise: Promise<string> | 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(), '')
|
||||
})
|
||||
+15
-6
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<MerchantJwtPayload>
|
||||
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('登录凭证无法解析,请重新登录')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const impersonationFailed = route.query.impersonation === 'failed'
|
||||
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
|
||||
const rememberedLoginId = getRememberedLoginId()
|
||||
const form = reactive<LoginForm>({
|
||||
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() {
|
||||
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
|
||||
</el-form-item>
|
||||
<div class="auth-form-options">
|
||||
<el-checkbox v-model="form.remember">记住我</el-checkbox>
|
||||
<el-checkbox v-model="form.remember">记住账号</el-checkbox>
|
||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||
</div>
|
||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||
|
||||
Reference in New Issue
Block a user