388 lines
9.2 KiB
TypeScript
388 lines
9.2 KiB
TypeScript
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,
|
|
requestJson,
|
|
setStoredAccessToken,
|
|
setStoredAutoAuthCredentials,
|
|
} from './apiClient';
|
|
|
|
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 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('');
|
|
}
|
|
|
|
export function createAutoAuthCredentials(): AutoAuthCredentials {
|
|
return {
|
|
username: `guest_${buildRandomSegment(12)}`,
|
|
password: `auto_${buildRandomSegment(24)}_${buildRandomSegment(8)}`,
|
|
};
|
|
}
|
|
|
|
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({
|
|
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),
|
|
},
|
|
'登录失败',
|
|
);
|
|
|
|
setStoredAccessToken(response.token);
|
|
return response.user;
|
|
}
|
|
|
|
export async function authEntryWithStoredCredentials(
|
|
credentials: AutoAuthCredentials,
|
|
) {
|
|
const normalizedCredentials = normalizeCredentials(credentials);
|
|
const user = await authEntry(
|
|
normalizedCredentials.username,
|
|
normalizedCredentials.password,
|
|
);
|
|
setStoredAutoAuthCredentials(normalizedCredentials);
|
|
return user;
|
|
}
|
|
|
|
export async function ensureAutoAuthUser() {
|
|
const credentials =
|
|
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
|
const user = await authEntryWithStoredCredentials(credentials);
|
|
|
|
return {
|
|
user,
|
|
credentials,
|
|
};
|
|
}
|
|
|
|
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',
|
|
},
|
|
'读取当前用户失败',
|
|
);
|
|
|
|
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<LogoutResponse>(
|
|
'/api/auth/logout',
|
|
{
|
|
method: 'POST',
|
|
},
|
|
'退出登录失败',
|
|
);
|
|
} finally {
|
|
clearAuthSession();
|
|
}
|
|
}
|
|
|
|
export async function logoutAllAuthSessions() {
|
|
try {
|
|
await requestJson<AuthLogoutAllResponse>(
|
|
'/api/auth/logout-all',
|
|
{
|
|
method: 'POST',
|
|
},
|
|
'退出全部设备失败',
|
|
);
|
|
} finally {
|
|
clearAuthSession();
|
|
}
|
|
}
|