fix: restrict password login to existing phone accounts
This commit is contained in:
@@ -13,7 +13,6 @@ const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
@@ -36,7 +35,6 @@ vi.mock('../../services/authService', () => ({
|
||||
changePassword: authMocks.changePassword,
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
@@ -106,16 +104,13 @@ beforeEach(() => {
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
username: 'guest_tester',
|
||||
password: 'auto_password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
|
||||
function ProtectedActionButton({
|
||||
onAuthenticated,
|
||||
}: {
|
||||
onAuthenticated: () => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
@@ -178,7 +173,6 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate waits for access token refresh before exposing restored user content', async () => {
|
||||
@@ -220,7 +214,6 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
|
||||
);
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
|
||||
@@ -245,7 +238,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
|
||||
@@ -388,24 +381,26 @@ test('login modal resets draft state every time it is reopened', async () => {
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(within(firstDialog).getByRole('button', { name: '获取验证码' }));
|
||||
await user.click(
|
||||
within(firstDialog).getByRole('button', { name: '获取验证码' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await within(firstDialog).findByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
await within(firstDialog).findByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(firstDialog).getByRole('button', { name: '忘记密码' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '重置密码' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '关闭登录弹窗' }),
|
||||
within(firstDialog).getByRole('button', { name: '忘记密码' }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '重置密码' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭登录弹窗' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
@@ -426,7 +421,9 @@ test('login modal resets draft state every time it is reopened', async () => {
|
||||
).toBe('');
|
||||
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).queryByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
within(reopenedDialog).queryByText(
|
||||
'短信请求已提交,验证码有效期约 5 分钟。',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
|
||||
@@ -465,9 +462,9 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
).toBe('true');
|
||||
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
@@ -42,10 +41,7 @@ import {
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from './AuthUiContext';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
@@ -61,11 +57,6 @@ type AuthStatus =
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
// 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
@@ -204,37 +195,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const ensureAutoUser = async () => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('recovering');
|
||||
|
||||
try {
|
||||
const { user: nextUser } = await ensureAutoAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureStoredAccessToken();
|
||||
activateReadyUser(nextUser);
|
||||
setError('');
|
||||
} catch (autoAuthError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
autoAuthError instanceof Error
|
||||
? autoAuthError.message
|
||||
: '自动登录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
@@ -253,15 +213,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
allowDevGuestAutoAuth &&
|
||||
options &&
|
||||
options.availableLoginMethods.length === 0
|
||||
) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
@@ -269,11 +220,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
@@ -473,7 +419,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
if (status === 'checking' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在校验登录状态...
|
||||
</div>
|
||||
);
|
||||
@@ -481,8 +429,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
if (status === 'recovering' && !canKeepPlatformContentMounted) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
|
||||
正在自动创建或恢复账号...
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}
|
||||
>
|
||||
正在恢复登录状态...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -500,7 +450,11 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'bind_phone',
|
||||
captcha,
|
||||
);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
@@ -548,7 +502,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
!canKeepPlatformContentMounted
|
||||
) {
|
||||
return (
|
||||
<div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
|
||||
<div
|
||||
className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
|
||||
>
|
||||
<div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
|
||||
<div className="text-base font-medium text-[var(--platform-text-strong)]">
|
||||
登录状态异常
|
||||
@@ -651,7 +607,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
@@ -674,7 +632,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
@@ -687,7 +646,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(currentPassword, newPassword);
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
@@ -710,7 +672,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
const captchaChallenge =
|
||||
getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
@@ -742,12 +705,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onPasswordSubmit={async (username, password) => {
|
||||
onPasswordSubmit={async (phone, password) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(username, password);
|
||||
setStoredLastLoginPhone(username);
|
||||
const nextUser = await authEntry(phone, password);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
|
||||
@@ -34,7 +34,7 @@ type LoginScreenProps = {
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onPhoneSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onPasswordSubmit: (username: string, password: string) => Promise<void>;
|
||||
onPasswordSubmit: (phone: string, password: string) => Promise<void>;
|
||||
onResetPassword: (
|
||||
phone: string,
|
||||
code: string,
|
||||
@@ -96,12 +96,20 @@ export function LoginScreen({
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
|
||||
if (
|
||||
activeLoginTab === 'phone' &&
|
||||
!phoneLoginEnabled &&
|
||||
passwordLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
|
||||
if (
|
||||
activeLoginTab === 'password' &&
|
||||
!passwordLoginEnabled &&
|
||||
phoneLoginEnabled
|
||||
) {
|
||||
setActiveLoginTab('phone');
|
||||
}
|
||||
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
|
||||
@@ -182,7 +190,9 @@ export function LoginScreen({
|
||||
const result = await onSendCode(resetPhone, 'reset_password');
|
||||
setResetCooldownSeconds(result.cooldownSeconds);
|
||||
}}
|
||||
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
|
||||
onSubmit={() =>
|
||||
onResetPassword(resetPhone, resetCode, resetPasswordValue)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
@@ -216,13 +226,14 @@ export function LoginScreen({
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号/邮箱</span>
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="手机号或邮箱"
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
@@ -242,10 +253,12 @@ export function LoginScreen({
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled || !phone.trim() || !password.trim()}
|
||||
disabled={
|
||||
submitDisabled || !phone.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '注册/登录'}
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -277,7 +290,7 @@ export function LoginScreen({
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册/登录"
|
||||
submitLabel="登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField
|
||||
onPhoneChange={setPhone}
|
||||
@@ -299,7 +312,9 @@ export function LoginScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
@@ -544,7 +559,9 @@ function PasswordResetPanel({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loggingIn || !phone.trim() || !code.trim() || !password.trim()}
|
||||
disabled={
|
||||
loggingIn || !phone.trim() || !code.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '处理中' : '重置密码'}
|
||||
@@ -576,9 +593,17 @@ function WechatButton({
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return <div className="platform-banner platform-banner--danger text-sm">{message}</div>;
|
||||
return (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return <div className="platform-banner platform-banner--success text-sm">{message}</div>;
|
||||
return (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
@@ -184,7 +182,9 @@ function composeAbortSignal(
|
||||
timeoutMs: number | undefined,
|
||||
) {
|
||||
const shouldUseTimeout =
|
||||
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0;
|
||||
typeof timeoutMs === 'number' &&
|
||||
Number.isFinite(timeoutMs) &&
|
||||
timeoutMs > 0;
|
||||
|
||||
if (!shouldUseTimeout) {
|
||||
return {
|
||||
@@ -324,7 +324,11 @@ export function isTimeoutError(error: unknown) {
|
||||
return error instanceof Error && error.name === 'TimeoutError';
|
||||
}
|
||||
|
||||
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
|
||||
function shouldRetryError(
|
||||
error: unknown,
|
||||
attempt: number,
|
||||
retry: ResolvedRetryOptions,
|
||||
) {
|
||||
if (attempt >= retry.maxRetries || isAbortError(error)) {
|
||||
return false;
|
||||
}
|
||||
@@ -369,7 +373,9 @@ export class ApiClientError extends Error {
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
export function emitAuthStateChange() {
|
||||
@@ -437,52 +443,6 @@ export function clearStoredAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
|
||||
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export function setStoredAutoAuthCredentials(credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
|
||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
||||
}
|
||||
|
||||
export function clearStoredAutoAuthCredentials(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
@@ -588,10 +548,7 @@ export async function fetchWithApiAuth(
|
||||
}
|
||||
}
|
||||
|
||||
const timedRequest = composeAbortSignal(
|
||||
requestSignal,
|
||||
options.timeoutMs,
|
||||
);
|
||||
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(input, {
|
||||
@@ -648,10 +605,7 @@ export async function fetchWithApiAuth(
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await waitForRetry(
|
||||
buildRetryDelayMs(attempt, retry),
|
||||
requestSignal,
|
||||
);
|
||||
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ const apiClientMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
|
||||
@@ -17,12 +18,10 @@ vi.mock('./apiClient', async () => {
|
||||
import { ApiClientError } from './apiClient';
|
||||
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
@@ -80,41 +79,30 @@ describe('authService', () => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
const credentials = createAutoAuthCredentials();
|
||||
|
||||
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('auth entry trims guest credentials and写入 access token', async () => {
|
||||
it('auth entry posts phone password credentials and 写入 access token', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-entry-token',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'guest_abc123abc123',
|
||||
displayName: 'guest_abc123abc123',
|
||||
phoneNumberMasked: null,
|
||||
username: 'phone_00000001',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: ' guest_abc123abc123 ',
|
||||
password: ' auto_secret_password ',
|
||||
});
|
||||
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(user.phoneNumberMasked).toBe('138****8000');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
phone: '13800138000',
|
||||
password: 'secret123',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
@@ -127,62 +115,6 @@ describe('authService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-auto-token',
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
publicUserCode: 'SY-00000002',
|
||||
username: 'guest_saveduser01',
|
||||
displayName: 'guest_saveduser01',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
const authEntryBody = JSON.parse(
|
||||
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(result.credentials.password).toMatch(
|
||||
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
|
||||
);
|
||||
expect(authEntryBody).toEqual(result.credentials);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-auto-shared-token',
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
publicUserCode: 'SY-00000003',
|
||||
username: 'guest_auto',
|
||||
displayName: 'guest_auto',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
ensureAutoAuthUser(),
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -327,7 +259,8 @@ describe('authService', () => {
|
||||
}),
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
authorizationUrl:
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
@@ -28,22 +28,14 @@ import {
|
||||
ApiClientError,
|
||||
type ApiRequestOptions,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
emitAuthStateChange,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } 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[];
|
||||
@@ -59,11 +51,6 @@ export type ConsumedAuthCallback = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
let pendingAutoAuthUser: Promise<{
|
||||
user: AuthUser;
|
||||
credentials: AutoAuthCredentials;
|
||||
}> | null = null;
|
||||
|
||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
@@ -108,7 +95,8 @@ export function getCaptchaChallengeFromError(
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
|
||||
const challenge = (error.details as { captchaChallenge?: unknown })
|
||||
.captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
@@ -124,39 +112,8 @@ export function getCaptchaChallengeFromError(
|
||||
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({ emit: false });
|
||||
clearStoredAutoAuthCredentials({ emit: false });
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
@@ -265,14 +222,16 @@ export async function getAuthLoginOptions() {
|
||||
);
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
export async function authEntry(phone: string, password: string) {
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
password: password.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
@@ -326,37 +285,6 @@ export async function resetPassword(
|
||||
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() {
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
};
|
||||
})();
|
||||
|
||||
try {
|
||||
return await pendingAutoAuthUser;
|
||||
} finally {
|
||||
pendingAutoAuthUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user