fix: restrict password login to existing phone accounts

This commit is contained in:
2026-04-26 01:11:45 +08:00
parent c4b9b8173f
commit 0a0f3f1bd8
33 changed files with 489 additions and 778 deletions

View File

@@ -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');

View File

@@ -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(

View File

@@ -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>
);
}