From f0a06c33673d4d42102190986ca354989dbb4ccb Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:31:33 +0800 Subject: [PATCH] feat(auth): add auth session module with user entity and session management Split the auth session module from the main codebase: entities/user: - User, AuthTokens, UserApis type definitions - createUserApis backend adapter (snake_case -> camelCase mapping) - Supports ApiClient injection for testability features/auth-session: - AuthSessionProvider: three-state session (booting -> guest | authenticated) - useAuthSession() hook for consuming auth context - ProtectedRoute: route guard with safe returnTo validation - resolveAuthMode(): local dev by default, forces backend in production - createLocalUserApis(): browser-only dev adapter (no password verification) - session-storage: refresh token localStorage persistence with graceful degradation Tests: 14 test cases covering session lifecycle, token rotation, StrictMode dedup, stale request handling, JWT refresh scheduling, local dev adapter, and route protection. --- frontend/src/entities/user/api.ts | 138 ++++++ frontend/src/entities/user/index.ts | 35 ++ .../src/features/auth-session/index.test.tsx | 348 +++++++++++++++ frontend/src/features/auth-session/index.tsx | 396 ++++++++++++++++++ .../auth-session/session-storage.test.ts | 49 +++ .../features/auth-session/session-storage.ts | 40 ++ 6 files changed, 1006 insertions(+) create mode 100644 frontend/src/entities/user/api.ts create mode 100644 frontend/src/entities/user/index.ts create mode 100644 frontend/src/features/auth-session/index.test.tsx create mode 100644 frontend/src/features/auth-session/index.tsx create mode 100644 frontend/src/features/auth-session/session-storage.test.ts create mode 100644 frontend/src/features/auth-session/session-storage.ts diff --git a/frontend/src/entities/user/api.ts b/frontend/src/entities/user/api.ts new file mode 100644 index 00000000..53b86038 --- /dev/null +++ b/frontend/src/entities/user/api.ts @@ -0,0 +1,138 @@ +import type { AuthTokens, User, UserApis } from '.' + +import { ApiError, createApiClient, getApiAccessToken } from '@/shared/api' +import type { ApiClient, ApiClientOptions } from '@/shared/api' + +interface BackendUser { + id: number + email: string + nickname: string | null + email_verified_at: string | null + status: number +} + +interface BackendAuthTokens { + access_token: string + refresh_token: string + user: BackendUser +} + +export interface CreateUserApisOptions extends ApiClientOptions { + client?: ApiClient +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidResponse(data: unknown): never { + throw new ApiError('用户认证响应格式无效', { kind: 'invalid-response', data }) +} + +function toUser(raw: unknown): User { + if ( + !isRecord(raw) || + typeof raw.id !== 'number' || + typeof raw.email !== 'string' || + (typeof raw.nickname !== 'string' && raw.nickname !== null) || + (typeof raw.email_verified_at !== 'string' && raw.email_verified_at !== null) || + (raw.status !== 0 && raw.status !== 1) + ) { + invalidResponse(raw) + } + + return { + id: raw.id, + email: raw.email, + nickname: raw.nickname, + emailVerifiedAt: raw.email_verified_at, + status: raw.status === 1 ? 'banned' : 'normal', + } +} + +function toAuthTokens(raw: unknown): AuthTokens { + if ( + !isRecord(raw) || + typeof raw.access_token !== 'string' || + typeof raw.refresh_token !== 'string' || + !isRecord(raw.user) + ) { + invalidResponse(raw) + } + + return { + accessToken: raw.access_token, + refreshToken: raw.refresh_token, + user: toUser(raw.user), + } +} + +export function createUserApis(options: CreateUserApisOptions = {}): UserApis { + const { client, ...clientOptions } = options + const apiClient = + client ?? + createApiClient({ + ...clientOptions, + getAccessToken: clientOptions.getAccessToken ?? getApiAccessToken, + }) + + return { + async sendCode(input): Promise { + await apiClient.request('/auth/send-code', { method: 'POST', json: input }) + }, + + async register(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/register', { + method: 'POST', + json: input, + }), + ) + }, + + async login(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login', { + method: 'POST', + json: input, + }), + ) + }, + + async loginByCode(input): Promise { + return toAuthTokens( + await apiClient.request('/auth/login-by-code', { + method: 'POST', + json: input, + }), + ) + }, + + async refresh(refreshToken): Promise { + return toAuthTokens( + await apiClient.request('/auth/refresh', { + method: 'POST', + json: { refresh_token: refreshToken }, + }), + ) + }, + + async logout(refreshToken): Promise { + await apiClient.request('/auth/logout', { + method: 'POST', + json: { refresh_token: refreshToken }, + }) + }, + + async me(): Promise { + return toUser(await apiClient.request('/auth/me')) + }, + + async changePassword(input): Promise { + await apiClient.request('/auth/change-password', { + method: 'POST', + json: { old_password: input.oldPassword, new_password: input.newPassword }, + }) + }, + } +} diff --git a/frontend/src/entities/user/index.ts b/frontend/src/entities/user/index.ts new file mode 100644 index 00000000..4ff9e737 --- /dev/null +++ b/frontend/src/entities/user/index.ts @@ -0,0 +1,35 @@ +/** 已认证用户的前端领域表示。 */ +export interface User { + id: number + email: string + nickname: string | null + emailVerifiedAt: string | null + status: 'normal' | 'banned' +} + +/** 一次认证成功后由后端签发的访问与刷新令牌。 */ +export interface AuthTokens { + accessToken: string + refreshToken: string + user: User +} + +/** 用户认证与账户设置的后端接口。 */ +export interface UserApis { + sendCode(input: { + email: string + purpose: 'login' | 'register' | 'reset_password' + }): Promise + register(input: { + email: string + password: string + code: string + nickname?: string + }): Promise + login(input: { email: string; password: string; code: string }): Promise + loginByCode(input: { email: string; code: string }): Promise + refresh(refreshToken: string): Promise + logout(refreshToken: string): Promise + me(): Promise + changePassword(input: { oldPassword: string; newPassword: string }): Promise +} diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx new file mode 100644 index 00000000..c1b8609e --- /dev/null +++ b/frontend/src/features/auth-session/index.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { StrictMode, type ReactNode } from 'react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { getApiAccessToken } from '@/shared/api' +import { + AuthSessionProvider, + ProtectedRoute, + createLocalUserApis, + resolveAuthMode, + useAuthSession, +} from '.' +import { REFRESH_TOKEN_STORAGE_KEY } from './session-storage' + +const user: User = { + id: 7, + email: 'ada@example.test', + nickname: 'Ada', + emailVerifiedAt: '2026-08-05T00:00:00Z', + status: 'normal', +} + +let session: ReturnType | undefined + +function SessionProbe() { + session = useAuthSession() + return {session.state.status} +} + +function LocationProbe() { + const location = useLocation() + return {`${location.pathname}${location.search}`} +} + +function renderProvider(apis: UserApis, children: ReactNode = , strict = false) { + const tree = {children} + return render(strict ? {tree} : tree) +} + +beforeEach(() => { + vi.stubEnv('VITE_AUTH_MODE', 'backend') + session = undefined + window.localStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('AuthSessionProvider', () => { + it('rotates the stored refresh token once in StrictMode, exposes it only through memory, then loads the current user', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const rotated = tokens('rotated-access', 'rotated-refresh') + const apis = createApis({ + refresh: vi.fn(async () => rotated), + me: vi.fn(async () => user), + }) + + renderProvider(apis, , true) + + await waitFor(() => + expect(screen.getByLabelText('session-status').textContent).toBe('authenticated'), + ) + expect(apis.refresh).toHaveBeenCalledTimes(1) + expect(apis.refresh).toHaveBeenCalledWith('stored-refresh') + expect(apis.me).toHaveBeenCalledTimes(1) + expect(getApiAccessToken()).toBe('rotated-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('rotated-refresh') + }) + + it('stores the rotated refresh token and keeps the access token in memory after login', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + expect(session?.state).toEqual({ status: 'authenticated', user }) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('unregisters its in-memory access token provider when unmounted', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('login-access', 'login-refresh')) }) + const view = renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + expect(getApiAccessToken()).toBe('login-access') + + view.unmount() + + expect(getApiAccessToken()).toBeUndefined() + }) + + it('falls back to a cleared guest session when startup token rotation fails', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'revoked-refresh') + const apis = createApis({ + refresh: vi.fn(async () => { + throw new Error('refresh revoked') + }), + }) + + renderProvider(apis) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not restore a stale startup session after the user logs out', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupRefresh = deferred() + const apis = createApis({ refresh: vi.fn(() => startupRefresh.promise) }) + renderProvider(apis) + await waitFor(() => expect(apis.refresh).toHaveBeenCalledWith('stored-refresh')) + + await act(async () => { + await session?.logout() + }) + await act(async () => { + startupRefresh.resolve(tokens('stale-access', 'stale-refresh')) + await startupRefresh.promise + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'guest', user: null })) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('does not let a stale startup failure clear a newer login', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const startupMe = deferred() + const apis = createApis({ + refresh: vi.fn(async () => tokens('startup-access', 'startup-refresh')), + me: vi.fn(() => startupMe.promise), + login: vi.fn(async () => tokens('login-access', 'login-refresh')), + }) + renderProvider(apis) + await waitFor(() => expect(apis.me).toHaveBeenCalledTimes(1)) + + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + await act(async () => { + startupMe.reject(new Error('stale me failure')) + await Promise.resolve() + }) + + await waitFor(() => expect(session?.state).toEqual({ status: 'authenticated', user })) + expect(getApiAccessToken()).toBe('login-access') + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('login-refresh') + }) + + it('clears the local session before surfacing a backend logout failure', async () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'stored-refresh') + const apis = createApis({ + refresh: vi.fn(async () => tokens('access', 'rotated-refresh')), + me: vi.fn(async () => user), + logout: vi.fn(async () => { + throw new Error('backend unavailable') + }), + }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('authenticated')) + + await act(async () => { + await expect(session?.logout()).rejects.toThrow('backend unavailable') + }) + + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + expect(apis.logout).toHaveBeenCalledWith('rotated-refresh') + }) + + it('clears the session after a successful password change because the backend revokes refresh tokens', async () => { + const apis = createApis({ login: vi.fn(async () => tokens('access', 'refresh')) }) + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await session?.changePassword({ oldPassword: 'password1', newPassword: 'password2' }) + }) + + expect(apis.changePassword).toHaveBeenCalledWith({ + oldPassword: 'password1', + newPassword: 'password2', + }) + expect(session?.state).toEqual({ status: 'guest', user: null }) + expect(getApiAccessToken()).toBeNull() + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('refreshes an expiring JWT sixty seconds before expiry and rotates the refresh token', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-05T00:00:00Z')) + const expiringAccess = jwtExpiringAt(Date.now() + 120_000) + const refreshedAccess = jwtExpiringAt(Date.now() + 3_600_000) + const apis = createApis({ login: vi.fn(async () => tokens(expiringAccess, 'refresh-1')) }) + vi.mocked(apis.refresh).mockResolvedValue(tokens(refreshedAccess, 'refresh-2')) + renderProvider(apis) + await act(async () => Promise.resolve()) + await act(async () => { + await session?.login({ email: 'ada@example.test', password: 'password1', code: '123456' }) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + + expect(apis.refresh).toHaveBeenCalledWith('refresh-1') + expect(getApiAccessToken()).toBe(refreshedAccess) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-2') + }) + + it('exposes every account action as a Promise-returning operation', async () => { + const apis = createApis() + renderProvider(apis) + await waitFor(() => expect(session?.state.status).toBe('guest')) + + await expect( + session?.sendCode({ email: 'ada@example.test', purpose: 'login' }), + ).resolves.toBeUndefined() + await expect( + session?.register({ email: 'ada@example.test', password: 'password1', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + await expect( + session?.loginByCode({ email: 'ada@example.test', code: '123456' }), + ).resolves.toEqual(tokens('access', 'refresh')) + }) +}) + +describe('resolveAuthMode', () => { + it('开发环境默认使用本地登录,生产环境始终使用真实后端认证', () => { + expect(resolveAuthMode('', true)).toBe('local') + expect(resolveAuthMode('local', true)).toBe('local') + expect(resolveAuthMode('backend', true)).toBe('backend') + expect(resolveAuthMode('local', false)).toBe('backend') + }) +}) + +describe('createLocalUserApis', () => { + it('只保存本地用户资料,不把密码和验证码写入浏览器存储,并可恢复会话', async () => { + const apis = createLocalUserApis() + const authenticated = await apis.register({ + email: 'ada@example.test', + password: 'password1', + code: '123456', + nickname: 'Ada', + }) + + expect(authenticated.user).toMatchObject({ + email: 'ada@example.test', + nickname: 'Ada', + status: 'normal', + }) + expect(JSON.stringify(window.localStorage)).not.toContain('password1') + expect(JSON.stringify(window.localStorage)).not.toContain('123456') + + await expect(createLocalUserApis().refresh(authenticated.refreshToken)).resolves.toMatchObject({ + user: authenticated.user, + }) + }) +}) + +describe('ProtectedRoute', () => { + it.each([ + [ + '/projects/7?tab=assets#frames', + '/?account=login&returnTo=%2Fprojects%2F7%3Ftab%3Dassets%23frames', + ], + ['//evil.example/path', '/?account=login'], + ])( + 'returns a guest from %s to the public login panel with only a safe same-site returnTo', + async (entry, expected) => { + const apis = createApis() + renderProvider( + apis, + + + +

Private

+ + } + /> + } /> +
+
, + ) + + await waitFor(() => expect(screen.getByLabelText('location').textContent).toBe(expected)) + expect(screen.queryByRole('heading', { name: 'Private' })).toBeNull() + }, + ) +}) + +function tokens(accessToken: string, refreshToken: string): AuthTokens { + return { accessToken, refreshToken, user } +} + +function jwtExpiringAt(expiryTime: number): string { + const payload = btoa(JSON.stringify({ exp: Math.floor(expiryTime / 1000) })) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + return `header.${payload}.signature` +} + +function createApis(overrides: Partial = {}): UserApis { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens('access', 'refresh')), + login: vi.fn(async () => tokens('access', 'refresh')), + loginByCode: vi.fn(async () => tokens('access', 'refresh')), + refresh: vi.fn(async () => tokens('access', 'refresh')), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + ...overrides, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx new file mode 100644 index 00000000..07e51580 --- /dev/null +++ b/frontend/src/features/auth-session/index.tsx @@ -0,0 +1,396 @@ +/* oxlint-disable react/only-export-components -- 该模块的公共契约同时导出 Provider、路由守卫和 hook。 */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' +import { Navigate, useLocation } from 'react-router' + +import type { AuthTokens, User, UserApis } from '@/entities/user' +import { registerApiAccessTokenProvider } from '@/shared/api' +import { clearRefreshToken, loadRefreshToken, saveRefreshToken } from './session-storage' + +export type AuthSessionState = + | { status: 'booting'; user: null } + | { status: 'guest'; user: null } + | { status: 'authenticated'; user: User } + +export interface AuthSessionValue { + state: AuthSessionState + sendCode(input: Parameters[0]): Promise + register(input: Parameters[0]): Promise + login(input: Parameters[0]): Promise + loginByCode(input: Parameters[0]): Promise + changePassword(input: Parameters[0]): Promise + logout(): Promise +} + +export interface AuthSessionProviderProps { + apis: UserApis + children: ReactNode +} + +export type AuthMode = 'local' | 'backend' + +interface RefreshInFlight { + refreshToken: string + promise: Promise +} + +interface BootstrapResult { + user: User +} + +const AuthSessionContext = createContext(null) + +/** + * 本地开发默认使用浏览器内的开发登录,生产环境始终连接真实认证接口。 + * 开发者仍可通过 VITE_AUTH_MODE=backend 在本地完整调试登录流程。 + */ +export function resolveAuthMode( + value = import.meta.env.VITE_AUTH_MODE, + development = import.meta.env.DEV, +): AuthMode { + if (!development) return 'backend' + return value === 'backend' ? 'backend' : 'local' +} + +/** + * 按运行模式装配认证上下文;本地与后端适配器共用同一份会话逻辑。 + */ +export function AuthModeProvider({ apis, children }: { apis: UserApis; children: ReactNode }) { + return {children} +} + +const LOCAL_USER_STORAGE_KEY = 'windup.auth.local-user' + +/** + * 本地开发认证适配器。 + * + * 它只保存可展示的用户资料,不保存或校验密码、验证码;生产构建不会装配它。 + * 后端认证可用后只需切换 VITE_AUTH_MODE=backend,页面和会话逻辑无需重写。 + */ +export function createLocalUserApis(): UserApis { + let currentUser = readLocalUser() + + const authenticate = (email: string, nickname?: string): AuthTokens => { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) throw new Error('请输入邮箱') + currentUser = { + id: currentUser?.email === normalizedEmail ? currentUser.id : 1, + email: normalizedEmail, + nickname: nickname?.trim() || currentUser?.nickname || normalizedEmail.split('@')[0] || null, + emailVerifiedAt: currentUser?.emailVerifiedAt ?? new Date().toISOString(), + status: 'normal', + } + saveLocalUser(currentUser) + return localTokens(currentUser) + } + + return { + async sendCode() {}, + async register(input) { + return authenticate(input.email, input.nickname) + }, + async login(input) { + return authenticate(input.email) + }, + async loginByCode(input) { + return authenticate(input.email) + }, + async refresh(refreshToken) { + currentUser = readLocalUser() + if (!currentUser || refreshToken !== localRefreshToken(currentUser)) { + throw new Error('本地登录已失效') + } + return localTokens(currentUser) + }, + async logout() {}, + async me() { + currentUser = readLocalUser() + if (!currentUser) throw new Error('本地用户不存在') + return currentUser + }, + async changePassword() { + if (!currentUser) throw new Error('请先登录') + }, + } +} + +function localTokens(user: User): AuthTokens { + return { + accessToken: `local-access:${user.id}`, + refreshToken: localRefreshToken(user), + user, + } +} + +function localRefreshToken(user: User): string { + return `local-refresh:${user.id}` +} + +function readLocalUser(): User | null { + try { + const raw = globalThis.localStorage?.getItem(LOCAL_USER_STORAGE_KEY) + if (!raw) return null + const value: unknown = JSON.parse(raw) + if ( + !isRecord(value) || + typeof value.id !== 'number' || + typeof value.email !== 'string' || + (typeof value.nickname !== 'string' && value.nickname !== null) || + (typeof value.emailVerifiedAt !== 'string' && value.emailVerifiedAt !== null) || + (value.status !== 'normal' && value.status !== 'banned') + ) { + return null + } + return value as unknown as User + } catch { + return null + } +} + +function saveLocalUser(user: User): void { + globalThis.localStorage?.setItem(LOCAL_USER_STORAGE_KEY, JSON.stringify(user)) +} + +export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps) { + const [state, setState] = useState({ status: 'booting', user: null }) + const [accessTokenVersion, setAccessTokenVersion] = useState(0) + const accessTokenRef = useRef(null) + const refreshTokenRef = useRef(null) + const sessionGenerationRef = useRef(0) + const refreshInFlightRef = useRef(null) + const bootstrapPromiseRef = useRef | null>(null) + const bootstrapGenerationRef = useRef(null) + + const storeTokenMaterial = useCallback((tokens: AuthTokens) => { + accessTokenRef.current = tokens.accessToken + refreshTokenRef.current = tokens.refreshToken + saveRefreshToken(tokens.refreshToken) + setAccessTokenVersion((version) => version + 1) + }, []) + + const applyTokens = useCallback( + (tokens: AuthTokens) => { + sessionGenerationRef.current += 1 + storeTokenMaterial(tokens) + setState({ status: 'authenticated', user: tokens.user }) + }, + [storeTokenMaterial], + ) + + const clearSession = useCallback(() => { + sessionGenerationRef.current += 1 + accessTokenRef.current = null + refreshTokenRef.current = null + clearRefreshToken() + setAccessTokenVersion((version) => version + 1) + setState({ status: 'guest', user: null }) + }, []) + + const rotateTokens = useCallback( + (refreshToken: string): Promise => { + const inFlight = refreshInFlightRef.current + if (inFlight?.refreshToken === refreshToken) return inFlight.promise + + const promise = apis.refresh(refreshToken) + const current = { refreshToken, promise } + refreshInFlightRef.current = current + const clearInFlight = () => { + if (refreshInFlightRef.current === current) refreshInFlightRef.current = null + } + void promise.then(clearInFlight, clearInFlight) + return promise + }, + [apis], + ) + + useEffect(() => registerApiAccessTokenProvider(() => accessTokenRef.current), []) + + useEffect(() => { + let active = true + + if (!bootstrapPromiseRef.current) { + const bootstrapGeneration = sessionGenerationRef.current + bootstrapGenerationRef.current = bootstrapGeneration + const persistedRefreshToken = loadRefreshToken() + bootstrapPromiseRef.current = persistedRefreshToken + ? rotateTokens(persistedRefreshToken).then(async (tokens) => { + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + storeTokenMaterial(tokens) + const user = await apis.me() + if (sessionGenerationRef.current !== bootstrapGeneration) return undefined + return { user } + }) + : Promise.resolve(null) + } + + void bootstrapPromiseRef.current.then( + (result) => { + if ( + !active || + bootstrapGenerationRef.current !== sessionGenerationRef.current || + result === undefined + ) + return + if (!result) { + clearSession() + return + } + setState({ status: 'authenticated', user: result.user }) + }, + () => { + if (active && bootstrapGenerationRef.current === sessionGenerationRef.current) + clearSession() + }, + ) + + return () => { + active = false + } + }, [apis, clearSession, rotateTokens, storeTokenMaterial]) + + useEffect(() => { + const accessToken = accessTokenRef.current + const refreshAt = getRefreshTime(accessToken) + if (refreshAt === null) return + + let cancelled = false + let timer: ReturnType | undefined + + const schedule = () => { + const delay = Math.max(0, refreshAt - Date.now()) + timer = setTimeout( + () => { + if (cancelled) return + if (Date.now() < refreshAt) { + schedule() + return + } + + const refreshToken = refreshTokenRef.current + if (!refreshToken) return + void rotateTokens(refreshToken).then( + (tokens) => { + if (!cancelled && refreshTokenRef.current === refreshToken) applyTokens(tokens) + }, + () => { + if (!cancelled && refreshTokenRef.current === refreshToken) clearSession() + }, + ) + }, + Math.min(delay, 2_147_483_647), + ) + } + + schedule() + return () => { + cancelled = true + if (timer !== undefined) clearTimeout(timer) + } + }, [accessTokenVersion, applyTokens, clearSession, rotateTokens]) + + const sendCode = useCallback( + (input: Parameters[0]) => apis.sendCode(input), + [apis], + ) + + const register = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.register(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const login = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.login(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const loginByCode = useCallback( + async (input: Parameters[0]) => { + const tokens = await apis.loginByCode(input) + applyTokens(tokens) + return tokens + }, + [apis, applyTokens], + ) + + const changePassword = useCallback( + async (input: Parameters[0]) => { + await apis.changePassword(input) + clearSession() + }, + [apis, clearSession], + ) + + const logout = useCallback(async () => { + const refreshToken = refreshTokenRef.current + clearSession() + if (refreshToken) await apis.logout(refreshToken) + }, [apis, clearSession]) + + const value = useMemo( + () => ({ state, sendCode, register, login, loginByCode, changePassword, logout }), + [changePassword, login, loginByCode, logout, register, sendCode, state], + ) + + return {children} +} + +export function useAuthSession(): AuthSessionValue { + const session = useContext(AuthSessionContext) + if (!session) throw new Error('useAuthSession 必须在 AuthSessionProvider 内使用') + return session +} + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { state } = useAuthSession() + const location = useLocation() + + if (state.status === 'booting') return null + if (state.status === 'authenticated') return children + + const returnTo = `${location.pathname}${location.search}${location.hash}` + const loginTarget = isSafeReturnTo(returnTo) + ? `/?account=login&returnTo=${encodeURIComponent(returnTo)}` + : '/?account=login' + return +} + +function isSafeReturnTo(value: string): boolean { + return value.startsWith('/') && !value.startsWith('//') +} + +function getRefreshTime(accessToken: string | null): number | null { + if (!accessToken) return null + const payload = accessToken.split('.')[1] + if (!payload) return null + + try { + const base64 = payload.replaceAll('-', '+').replaceAll('_', '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + const parsed: unknown = JSON.parse(globalThis.atob(padded)) + if (!isRecord(parsed) || typeof parsed.exp !== 'number' || !Number.isFinite(parsed.exp)) + return null + return parsed.exp * 1_000 - 60_000 + } catch { + return null + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts new file mode 100644 index 00000000..28af8652 --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' + +import { + REFRESH_TOKEN_STORAGE_KEY, + clearRefreshToken, + loadRefreshToken, + saveRefreshToken, +} from './session-storage' + +afterEach(() => { + window.localStorage.clear() +}) + +describe('auth session storage', () => { + it('persists only the refresh token under the authentication key', () => { + saveRefreshToken('refresh-token') + + expect(window.localStorage.length).toBe(1) + expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBe('refresh-token') + expect(loadRefreshToken()).toBe('refresh-token') + }) + + it('removes the persisted refresh token', () => { + window.localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, 'refresh-token') + + clearRefreshToken() + + expect(loadRefreshToken()).toBeNull() + }) + + it('treats unavailable or failing local storage as an empty best-effort store', () => { + const failingStorage = { + getItem(): string | null { + throw new DOMException('blocked') + }, + setItem(): void { + throw new DOMException('blocked') + }, + removeItem(): void { + throw new DOMException('blocked') + }, + } + + expect(loadRefreshToken(failingStorage)).toBeNull() + expect(() => saveRefreshToken('refresh-token', failingStorage)).not.toThrow() + expect(() => clearRefreshToken(failingStorage)).not.toThrow() + }) +}) diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts new file mode 100644 index 00000000..06ba96ca --- /dev/null +++ b/frontend/src/features/auth-session/session-storage.ts @@ -0,0 +1,40 @@ +export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' + +type RefreshTokenStorage = Pick + +function getLocalStorage(): RefreshTokenStorage | null { + try { + return globalThis.localStorage + } catch { + return null + } +} + +export function loadRefreshToken( + storage: RefreshTokenStorage | null = getLocalStorage(), +): string | null { + try { + return storage?.getItem(REFRESH_TOKEN_STORAGE_KEY) ?? null + } catch { + return null + } +} + +export function saveRefreshToken( + refreshToken: string, + storage: RefreshTokenStorage | null = getLocalStorage(), +): void { + try { + storage?.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken) + } catch { + // 持久化不可用时仍保留当前内存会话。 + } +} + +export function clearRefreshToken(storage: RefreshTokenStorage | null = getLocalStorage()): void { + try { + storage?.removeItem(REFRESH_TOKEN_STORAGE_KEY) + } catch { + // 清理是尽力而为;浏览器禁用存储时不能让应用崩溃。 + } +}