feat(auth): 集成认证授权功能并优化API客户端
- 实现了完整的登录注册认证流程,包括密码验证和用户资料获取 - 集成了JWT令牌管理和自动刷新机制,支持设备ID生成和管理 - 添加了WebSocket连接配置和API基础URL环境变量设置 - 实现了API客户端的请求拦截器,包括令牌验证和错误处理逻辑 - 集成了MD5加密和认证令牌缓存机制,提升安全性 - 添加了多语言国际化支持,包括英语、中文、马来语和印尼语 - 实现了认证状态管理和本地存储持久化功能 - 添加了表单验证schema和错误处理机制,增强用户体验
This commit is contained in:
@@ -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}×tamp=${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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user