feat(auth): 集成认证授权功能并优化API客户端

- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取
- 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理
- 添加了WebSocket连接配置和API基础URL环境变量设置
- 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑
- 集成了MD5加密和认证令牌缓存机制,提升安全性
- 添加了多语言国际化支持,包括英语、中文、马来语和印尼语
- 实现了认证状态管理和本地存储持久化功能
- 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
JiaJun
2026-05-16 09:03:55 +08:00
parent 6aaf90a6ac
commit 5dd4e31db4
81 changed files with 6086 additions and 627 deletions

View File

@@ -4,14 +4,15 @@ import {
DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS,
} from '@/constants'
import type { AuthTokenDto } from '@/features/auth/api/types'
import { ApiError } from '@/lib/api/api-error.ts'
import {
handleUnauthorizedSession,
tryRefreshAuthSession,
} from '@/lib/auth/auth-session'
import { useAuthStore } from '@/store/auth'
import { ApiError } from './api-error'
import type { ApiResponse } from './types'
import { md5 } from '@/lib/crypto/md5'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import type { ApiResponse } from '@/type'
type RequestOptions = Omit<Options, 'json'>
type JsonRequestOptions<TBody> = RequestOptions & {
@@ -20,7 +21,12 @@ type JsonRequestOptions<TBody> = RequestOptions & {
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
function normalizeApiBaseUrl(baseUrl: string | undefined) {
@@ -96,6 +102,15 @@ async function toApiError(error: unknown) {
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
const authTokenClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
headers: {
Accept: DEFAULT_REQUEST_ACCEPT_HEADER,
},
})
const apiClient = ky.create({
prefix: apiBaseUrl,
retry: 0,
@@ -109,6 +124,7 @@ const apiClient = ky.create({
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
request.headers.set('user-token', token)
}
if (shouldLogRequests) {
@@ -128,9 +144,153 @@ const apiClient = ky.create({
},
})
function shouldAttachAuthToken(input: string) {
return input !== AUTH_TOKEN_ENDPOINT
}
function shouldTryRefreshAccessToken(input: string, options?: Options) {
if (
input === AUTH_REFRESH_ENDPOINT ||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] === true
) {
return false
}
const authState = useAuthStore.getState()
return Boolean(
authState.accessToken &&
authState.accessTokenExpiresAt &&
authState.accessTokenExpiresAt <=
Date.now() + ACCESS_TOKEN_REFRESH_SKEW_MS,
)
}
function unwrapEnvelopeData<T>(response: ApiResponse<T>) {
if (response.code === 1) {
return response.data
}
throw new ApiError({
data: response,
message:
'msg' in response && typeof response.msg === 'string'
? response.msg
: 'message' in response && typeof response.message === 'string'
? response.message
: API_ERROR_MESSAGES.unexpected,
})
}
async function fetchAuthToken() {
try {
const authState = useAuthStore.getState()
if (
authState.apiAuthToken &&
authState.apiAuthTokenExpiresAt &&
authState.apiAuthTokenExpiresAt > Date.now() + AUTH_TOKEN_CACHE_SKEW_MS
) {
return authState.apiAuthToken
}
if (!authSecret) {
throw new ApiError({
message: 'auth.errors.authTokenConfigMissing',
})
}
const deviceId = getAuthDeviceId()
const timestamp = Math.floor(Date.now() / 1000)
const signature = md5(
`device_id=${deviceId}&secret=${authSecret}&timestamp=${timestamp}`,
).toUpperCase()
const response = await authTokenClient
.get(AUTH_TOKEN_ENDPOINT, {
searchParams: {
device_id: deviceId,
secret: authSecret,
signature,
timestamp: String(timestamp),
},
})
.json<ApiResponse<AuthTokenDto>>()
const data = unwrapEnvelopeData(response)
const expiresAt = Date.now() + data.expires_in * 1000
useAuthStore.getState().setApiAuthToken({
expiresAt,
serverTime: data.server_time,
value: data.auth_token,
})
return data.auth_token
} catch (error) {
throw await toApiError(error)
}
}
export async function prefetchAuthToken() {
await fetchAuthToken()
}
function createHeaders(headersInit?: Options['headers']) {
const headers = new Headers()
if (!headersInit) {
return headers
}
if (headersInit instanceof Headers) {
headersInit.forEach((value, key) => {
headers.set(key, value)
})
return headers
}
if (Array.isArray(headersInit)) {
for (const [key, value] of headersInit) {
headers.set(key, value)
}
return headers
}
for (const [key, value] of Object.entries(headersInit)) {
if (typeof value === 'string') {
headers.set(key, value)
}
}
return headers
}
async function buildRequestOptions(input: string, options?: Options) {
const headers = createHeaders(options?.headers)
if (shouldAttachAuthToken(input) && !headers.has('auth-token')) {
headers.set('auth-token', await fetchAuthToken())
}
return {
...options,
headers,
} satisfies Options
}
async function request<TResponse>(input: string, options?: Options) {
try {
const response = await apiClient(input, options)
if (shouldTryRefreshAccessToken(input, options)) {
await tryRefreshAuthSession()
}
const response = await apiClient(
input,
await buildRequestOptions(input, options),
)
const data = await parseResponseBody(response)
return data as TResponse
@@ -138,6 +298,7 @@ async function request<TResponse>(input: string, options?: Options) {
if (
error instanceof HTTPError &&
error.response.status === 401 &&
input !== AUTH_REFRESH_ENDPOINT &&
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
) {