This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,4 +1,26 @@
import type {
AuthAuditLogEntry,
AuthAuditLogsResponse,
AuthCaptchaChallenge,
AuthEntryResponse,
AuthLiftRiskBlockResponse,
AuthLoginMethod,
AuthLogoutAllResponse,
AuthMeResponse,
AuthPhoneChangeResponse,
AuthPhoneLoginResponse,
AuthPhoneSendCodeResponse,
AuthRevokeSessionResponse,
AuthRiskBlocksResponse,
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
} from '../../packages/shared/src/contracts/auth';
import {
ApiClientError,
clearStoredAccessToken,
clearStoredAutoAuthCredentials,
getStoredAutoAuthCredentials,
@@ -7,19 +29,77 @@ import {
setStoredAutoAuthCredentials,
} from './apiClient';
export type AuthUser = {
id: string;
username: string;
};
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type AutoAuthCredentials = {
username: string;
password: string;
};
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
availableLoginMethods: AuthLoginMethod[];
};
export type { AuthSessionSummary };
export type { AuthCaptchaChallenge };
export type { AuthAuditLogEntry };
export type { AuthRiskBlockSummary };
export type ConsumedAuthCallback = {
provider: 'wechat' | 'unknown';
bindingStatus: string | null;
error: string | null;
};
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
export function getCaptchaChallengeFromError(
error: unknown,
): AuthCaptchaChallenge | null {
if (
error instanceof ApiClientError &&
error.code === 'CAPTCHA_REQUIRED' &&
error.details &&
typeof error.details === 'object' &&
'captchaChallenge' in error.details
) {
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
if (
challenge &&
typeof challenge === 'object' &&
'challengeId' in challenge &&
'promptText' in challenge &&
'imageDataUrl' in challenge &&
'expiresInSeconds' in challenge
) {
return challenge as AuthCaptchaChallenge;
}
}
return null;
}
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
return {
username: credentials.username.trim(),
password: credentials.password.trim(),
};
}
function buildRandomSegment(length: number) {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const bytes = crypto.getRandomValues(new Uint8Array(length));
const cryptoApi = globalThis.crypto;
if (!cryptoApi?.getRandomValues) {
return Array.from(
{length},
() => alphabet[Math.floor(Math.random() * alphabet.length)],
).join('');
}
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
}
@@ -31,20 +111,111 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
};
}
export async function authEntry(username: string, password: string) {
const response = await requestJson<{
token: string;
user: AuthUser;
}>(
'/api/auth/entry',
export function clearAuthSession() {
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
}
export async function sendPhoneLoginCode(
phone: string,
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
captcha?: {
challengeId?: string;
answer?: string;
},
) {
const response = await requestJson<AuthPhoneSendCodeResponse>(
'/api/auth/phone/send-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
phone: normalizePhoneInput(phone),
scene,
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
captchaAnswer: captcha?.answer?.trim() || undefined,
}),
},
'发送验证码失败',
);
return response;
}
export async function loginWithPhoneCode(phone: string, code: string) {
const response = await requestJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
code: code.trim(),
}),
},
'登录失败',
);
setStoredAccessToken(response.token);
return response.user;
}
export async function bindWechatPhone(phone: string, code: string) {
const response = await requestJson<AuthWechatBindPhoneResponse>(
'/api/auth/wechat/bind-phone',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
code: code.trim(),
}),
},
'绑定手机号失败',
);
setStoredAccessToken(response.token);
return response.user;
}
export async function changePhoneNumber(phone: string, code: string) {
const response = await requestJson<AuthPhoneChangeResponse>(
'/api/auth/phone/change',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: normalizePhoneInput(phone),
code: code.trim(),
}),
},
'更换手机号失败',
);
return response.user;
}
export async function startWechatLogin() {
const response = await requestJson<AuthWechatStartResponse>(
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
{
method: 'GET',
},
'微信登录暂不可用',
);
window.location.assign(response.authorizationUrl);
}
export async function authEntry(username: string, password: string) {
const credentials = normalizeCredentials({ username, password });
const response = await requestJson<AuthEntryResponse>(
'/api/auth/entry',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
},
'登录失败',
);
@@ -55,8 +226,12 @@ export async function authEntry(username: string, password: string) {
export async function authEntryWithStoredCredentials(
credentials: AutoAuthCredentials,
) {
const user = await authEntry(credentials.username, credentials.password);
setStoredAutoAuthCredentials(credentials);
const normalizedCredentials = normalizeCredentials(credentials);
const user = await authEntry(
normalizedCredentials.username,
normalizedCredentials.password,
);
setStoredAutoAuthCredentials(normalizedCredentials);
return user;
}
@@ -71,10 +246,49 @@ export async function ensureAutoAuthUser() {
};
}
export async function getCurrentAuthUser() {
const response = await requestJson<{
user: AuthUser | null;
}>(
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash;
if (!hash) {
return null;
}
const params = new URLSearchParams(hash);
const authToken = params.get('auth_token');
const authError = params.get('auth_error');
const providerValue = params.get('auth_provider');
const bindingStatus = params.get('auth_binding_status');
if (!authToken && !authError) {
return null;
}
if (authToken) {
setStoredAccessToken(authToken);
}
if (typeof window.history?.replaceState === 'function') {
window.history.replaceState(
null,
'',
`${window.location.pathname}${window.location.search}`,
);
}
return {
provider: providerValue === 'wechat' ? 'wechat' : 'unknown',
bindingStatus,
error: authError,
};
}
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
const response = await requestJson<AuthMeResponse>(
'/api/auth/me',
{
method: 'GET',
@@ -82,12 +296,71 @@ export async function getCurrentAuthUser() {
'读取当前用户失败',
);
return response.user;
return {
user: response.user,
availableLoginMethods: response.availableLoginMethods,
};
}
export async function getAuthSessions() {
const response = await requestJson<AuthSessionsResponse>(
'/api/auth/sessions',
{
method: 'GET',
},
'读取登录设备失败',
);
return response.sessions;
}
export async function revokeAuthSession(sessionId: string) {
await requestJson<AuthRevokeSessionResponse>(
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
{
method: 'POST',
},
'移除登录设备失败',
);
}
export async function getAuthAuditLogs() {
const response = await requestJson<AuthAuditLogsResponse>(
'/api/auth/audit-logs',
{
method: 'GET',
},
'读取账号操作记录失败',
);
return response.logs;
}
export async function getAuthRiskBlocks() {
const response = await requestJson<AuthRiskBlocksResponse>(
'/api/auth/risk-blocks',
{
method: 'GET',
},
'读取安全状态失败',
);
return response.blocks;
}
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
await requestJson<AuthLiftRiskBlockResponse>(
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
{
method: 'POST',
},
'解除保护失败',
);
}
export async function logoutAuthUser() {
try {
await requestJson<{ ok: true }>(
await requestJson<LogoutResponse>(
'/api/auth/logout',
{
method: 'POST',
@@ -95,7 +368,20 @@ export async function logoutAuthUser() {
'退出登录失败',
);
} finally {
clearStoredAccessToken();
clearStoredAutoAuthCredentials();
clearAuthSession();
}
}
export async function logoutAllAuthSessions() {
try {
await requestJson<AuthLogoutAllResponse>(
'/api/auth/logout-all',
{
method: 'POST',
},
'退出全部设备失败',
);
} finally {
clearAuthSession();
}
}