feat(auth): harden token lifecycle and password policy

This commit is contained in:
yoyuzh
2026-03-19 14:51:18 +08:00
parent 41a83d2805
commit a78d0dc2db
26 changed files with 1047 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import { apiRequest } from '@/src/lib/api';
import {
clearStoredSession,
createSession,
readStoredSession,
saveStoredSession,
SESSION_EVENT_NAME,
@@ -27,10 +28,7 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | null>(null);
function buildSession(auth: AuthResponse): AuthSession {
return {
token: auth.token,
user: auth.user,
};
return createSession(auth);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { afterEach, beforeEach, test } from 'node:test';
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, shouldRetryRequest, toNetworkApiError } from './api';
import { clearStoredSession, saveStoredSession } from './session';
import { clearStoredSession, readStoredSession, saveStoredSession } from './session';
class MemoryStorage implements Storage {
private store = new Map<string, string>();
@@ -135,6 +135,7 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
let request: Request | URL | string | undefined;
saveStoredSession({
token: 'token-123',
refreshToken: 'refresh-123',
user: {
id: 1,
username: 'tester',
@@ -230,6 +231,7 @@ test('network fetch failures are converted to readable api errors', () => {
test('apiUploadRequest attaches auth header and forwards upload progress', async () => {
saveStoredSession({
token: 'token-456',
refreshToken: 'refresh-456',
user: {
id: 2,
username: 'uploader',
@@ -309,3 +311,158 @@ test('apiBinaryUploadRequest sends raw file body to signed upload url', async ()
{loaded: 128, total: 128},
]);
});
test('apiRequest refreshes expired access token once and retries the original request', async () => {
const calls: Array<{url: string; authorization: string | null; body: string | null}> = [];
saveStoredSession({
token: 'expired-token',
refreshToken: 'refresh-1',
user: {
id: 3,
username: 'alice',
email: 'alice@example.com',
createdAt: '2026-03-18T10:00:00',
},
});
globalThis.fetch = async (input, init) => {
const url = String(input);
const headers = new Headers(init?.headers);
calls.push({
url,
authorization: headers.get('Authorization'),
body: typeof init?.body === 'string' ? init.body : null,
});
if (url.endsWith('/user/profile') && calls.length === 1) {
return new Response(
JSON.stringify({
code: 1001,
msg: '用户未登录',
data: null,
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
);
}
if (url.endsWith('/auth/refresh')) {
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
token: 'new-access-token',
accessToken: 'new-access-token',
refreshToken: 'refresh-2',
user: {
id: 3,
username: 'alice',
email: 'alice@example.com',
createdAt: '2026-03-18T10:00:00',
},
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
}
return new Response(
JSON.stringify({
code: 0,
msg: 'success',
data: {
id: 3,
username: 'alice',
email: 'alice@example.com',
createdAt: '2026-03-18T10:00:00',
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
};
const profile = await apiRequest<{id: number; username: string}>('/user/profile');
assert.equal(profile.username, 'alice');
assert.equal(calls.length, 3);
assert.equal(calls[0]?.authorization, 'Bearer expired-token');
assert.equal(calls[1]?.url, '/api/auth/refresh');
assert.equal(calls[2]?.authorization, 'Bearer new-access-token');
assert.deepEqual(JSON.parse(calls[1]?.body || '{}'), {refreshToken: 'refresh-1'});
assert.deepEqual(readStoredSession(), {
token: 'new-access-token',
refreshToken: 'refresh-2',
user: {
id: 3,
username: 'alice',
email: 'alice@example.com',
createdAt: '2026-03-18T10:00:00',
},
});
});
test('apiRequest clears session when refresh fails after a 401 response', async () => {
let callCount = 0;
saveStoredSession({
token: 'expired-token',
refreshToken: 'refresh-1',
user: {
id: 5,
username: 'bob',
email: 'bob@example.com',
createdAt: '2026-03-18T10:00:00',
},
});
globalThis.fetch = async (input) => {
callCount += 1;
const url = String(input);
if (url.endsWith('/auth/refresh')) {
return new Response(
JSON.stringify({
code: 1001,
msg: '刷新令牌已过期',
data: null,
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
);
}
return new Response(
JSON.stringify({
code: 1001,
msg: '用户未登录',
data: null,
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
);
};
await assert.rejects(() => apiRequest('/user/profile'), /用户未登录/);
assert.equal(callCount, 2);
assert.equal(readStoredSession(), null);
});

View File

@@ -1,4 +1,5 @@
import { clearStoredSession, readStoredSession } from './session';
import type { AuthResponse } from './types';
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
interface ApiEnvelope<T> {
code: number;
@@ -25,6 +26,9 @@ interface ApiBinaryUploadRequestInit {
}
const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, '');
const AUTH_REFRESH_PATH = '/auth/refresh';
let refreshRequestPromise: Promise<boolean> | null = null;
export class ApiError extends Error {
code?: number;
@@ -93,6 +97,20 @@ function resolveUrl(path: string) {
return `${API_BASE_URL}${normalizedPath}`;
}
function normalizePath(path: string) {
return path.startsWith('/') ? path : `/${path}`;
}
function shouldAttemptTokenRefresh(path: string) {
const normalizedPath = normalizePath(path);
return ![
'/auth/login',
'/auth/register',
'/auth/dev-login',
AUTH_REFRESH_PATH,
].includes(normalizedPath);
}
function buildRequestBody(body: ApiRequestInit['body']) {
if (body == null) {
return undefined;
@@ -111,6 +129,58 @@ function buildRequestBody(body: ApiRequestInit['body']) {
return JSON.stringify(body);
}
async function refreshAccessToken() {
const currentSession = readStoredSession();
if (!currentSession?.refreshToken) {
clearStoredSession();
return false;
}
if (refreshRequestPromise) {
return refreshRequestPromise;
}
refreshRequestPromise = (async () => {
try {
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: currentSession.refreshToken,
}),
});
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
clearStoredSession();
return false;
}
const payload = (await response.json()) as ApiEnvelope<AuthResponse>;
if (payload.code !== 0 || !payload.data) {
clearStoredSession();
return false;
}
saveStoredSession({
...currentSession,
...createSession(payload.data),
user: payload.data.user ?? currentSession.user,
});
return true;
} catch {
clearStoredSession();
return false;
} finally {
refreshRequestPromise = null;
}
})();
return refreshRequestPromise;
}
async function parseApiError(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
@@ -140,7 +210,7 @@ export function shouldRetryRequest(
return attempt <= getMaxRetryAttempts(path, init);
}
async function performRequest(path: string, init: ApiRequestInit = {}) {
async function performRequest(path: string, init: ApiRequestInit = {}, allowRefresh = true): Promise<Response> {
const session = readStoredSession();
const headers = new Headers(init.headers);
const requestBody = buildRequestBody(init.body);
@@ -180,7 +250,14 @@ async function performRequest(path: string, init: ApiRequestInit = {}) {
throw toNetworkApiError(lastError);
}
if (response.status === 401 || response.status === 403) {
if (response.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return performRequest(path, init, false);
}
}
if (response.status === 401) {
clearStoredSession();
}
@@ -200,16 +277,13 @@ export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
const payload = (await response.json()) as ApiEnvelope<T>;
if (!response.ok || payload.code !== 0) {
if (response.status === 401 || payload.code === 401) {
clearStoredSession();
}
throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
}
return payload.data;
}
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, allowRefresh: boolean): Promise<T> {
const session = readStoredSession();
const headers = new Headers(init.headers);
@@ -248,8 +322,21 @@ export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
xhr.onload = () => {
const contentType = xhr.getResponseHeader('content-type') || '';
if (xhr.status === 401 || xhr.status === 403) {
clearStoredSession();
if (xhr.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
refreshAccessToken()
.then((refreshed) => {
if (refreshed) {
resolve(apiUploadRequestInternal<T>(path, init, false));
return;
}
clearStoredSession();
reject(new ApiError('登录状态已失效,请重新登录', 401));
})
.catch((error) => {
clearStoredSession();
reject(error instanceof ApiError ? error : toNetworkApiError(error));
});
return;
}
if (!contentType.includes('application/json')) {
@@ -264,7 +351,7 @@ export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
const payload = JSON.parse(xhr.responseText) as ApiEnvelope<T>;
if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) {
if (xhr.status === 401 || payload.code === 401) {
if (xhr.status === 401) {
clearStoredSession();
}
reject(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
@@ -278,6 +365,10 @@ export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit) {
});
}
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit): Promise<T> {
return apiUploadRequestInternal<T>(path, init, true);
}
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
const headers = new Headers(init.headers);

View File

@@ -1,4 +1,4 @@
import type { AuthSession } from './types';
import type { AuthResponse, AuthSession } from './types';
const SESSION_STORAGE_KEY = 'portal-session';
const POST_LOGIN_PENDING_KEY = 'portal-post-login-pending';
@@ -10,6 +10,40 @@ function notifySessionChanged() {
}
}
function normalizeSession(value: unknown): AuthSession | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Partial<AuthSession> & {accessToken?: string};
const token = typeof candidate.token === 'string' && candidate.token.trim()
? candidate.token
: typeof candidate.accessToken === 'string' && candidate.accessToken.trim()
? candidate.accessToken
: null;
if (!token || !candidate.user) {
return null;
}
return {
token,
refreshToken:
typeof candidate.refreshToken === 'string' && candidate.refreshToken.trim()
? candidate.refreshToken
: null,
user: candidate.user,
};
}
export function createSession(auth: AuthResponse): AuthSession {
return {
token: auth.accessToken || auth.token,
refreshToken: auth.refreshToken ?? null,
user: auth.user,
};
}
export function readStoredSession(): AuthSession | null {
if (typeof localStorage === 'undefined') {
return null;
@@ -21,7 +55,11 @@ export function readStoredSession(): AuthSession | null {
}
try {
return JSON.parse(rawValue) as AuthSession;
const session = normalizeSession(JSON.parse(rawValue));
if (!session) {
localStorage.removeItem(SESSION_STORAGE_KEY);
}
return session;
} catch {
localStorage.removeItem(SESSION_STORAGE_KEY);
return null;

View File

@@ -7,11 +7,14 @@ export interface UserProfile {
export interface AuthSession {
token: string;
refreshToken?: string | null;
user: UserProfile;
}
export interface AuthResponse {
token: string;
accessToken?: string;
refreshToken?: string | null;
user: UserProfile;
}

View File

@@ -8,7 +8,7 @@ import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { apiRequest, ApiError } from '@/src/lib/api';
import { cn } from '@/src/lib/utils';
import { markPostLoginPending, saveStoredSession } from '@/src/lib/session';
import { createSession, markPostLoginPending, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse } from '@/src/lib/types';
const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
@@ -59,10 +59,7 @@ export default function Login() {
}
}
saveStoredSession({
token: auth.token,
user: auth.user,
});
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate('/overview');
@@ -87,10 +84,7 @@ export default function Login() {
},
});
saveStoredSession({
token: auth.token,
user: auth.user,
});
saveStoredSession(createSession(auth));
markPostLoginPending();
setLoading(false);
navigate('/overview');
@@ -301,10 +295,13 @@ export default function Login() {
value={registerPassword}
onChange={(event) => setRegisterPassword(event.target.value)}
required
minLength={6}
minLength={10}
maxLength={64}
/>
</div>
<p className="text-xs text-slate-500 ml-1">
10
</p>
</div>
</div>