feat(auth): harden token lifecycle and password policy
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user