🐛 fix: handle auth captcha retries (#14346)

This commit is contained in:
YuTengjing
2026-05-01 18:27:04 +08:00
committed by GitHub
parent 626d274859
commit b2130f7612
15 changed files with 236 additions and 37 deletions
+4
View File
@@ -33,6 +33,10 @@
"authModal.signIn": "重新登录",
"authModal.signingIn": "登录中...",
"authModal.title": "登录已过期",
"betterAuth.captcha.continue": "继续",
"betterAuth.captcha.description": "请先完成下方安全验证。验证通过后,我们会自动继续注册或登录。",
"betterAuth.captcha.pendingDescription": "请先完成人机验证,然后继续。",
"betterAuth.captcha.title": "需要完成安全验证",
"betterAuth.errors.confirmPasswordRequired": "请确认密码",
"betterAuth.errors.emailExists": "该邮箱已注册,请直接登录",
"betterAuth.errors.emailInvalid": "请输入有效的邮箱地址或用户名",
+4
View File
@@ -8,6 +8,10 @@
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./headers": {
"types": "./src/headers.ts",
"default": "./src/headers.ts"
},
"./parseError": {
"types": "./src/parseError.ts",
"default": "./src/parseError.ts"
@@ -5,6 +5,7 @@ import AnalyticsRSCProvider from '@/layout/AnalyticsRSCProvider';
import AuthProvider from '@/layout/AuthProvider';
import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider';
import StyleRegistry from '@/layout/GlobalProvider/StyleRegistry';
import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags';
import { getServerAuthConfig } from '@/server/globalConfig/getServerAuthConfig';
import { RouteVariants } from '@/utils/server/routeVariants';
@@ -20,6 +21,7 @@ interface AuthGlobalProviderProps {
const AuthGlobalProvider = async ({ children, variants }: AuthGlobalProviderProps) => {
const { locale, isMobile } = RouteVariants.deserializeVariants(variants);
const serverConfig = getServerAuthConfig();
const featureFlags = await getServerFeatureFlagsStateFromRuntimeConfig();
return (
<StyleRegistry>
@@ -27,6 +29,7 @@ const AuthGlobalProvider = async ({ children, variants }: AuthGlobalProviderProp
<NextThemeProvider>
<AuthThemeLite globalCDN={appEnv.CDN_USE_GLOBAL}>
<AuthServerConfigProvider
featureFlags={featureFlags}
isMobile={isMobile}
segmentVariants={variants}
serverConfig={serverConfig}
@@ -1,10 +1,13 @@
'use client';
import { createContext, memo, type ReactNode, use } from 'react';
import type { ReactNode } from 'react';
import { createContext, memo, use } from 'react';
import { type GlobalServerConfig } from '@/types/serverConfig';
import type { IFeatureFlagsState } from '@/config/featureFlags';
import type { GlobalServerConfig } from '@/types/serverConfig';
interface AuthServerConfigState {
featureFlags: Partial<IFeatureFlagsState>;
isMobile?: boolean;
segmentVariants?: string;
serverConfig: GlobalServerConfig;
@@ -15,15 +18,17 @@ const AuthServerConfigContext = createContext<AuthServerConfigState | null>(null
interface Props {
children: ReactNode;
featureFlags?: Partial<IFeatureFlagsState>;
isMobile?: boolean;
segmentVariants?: string;
serverConfig?: GlobalServerConfig;
}
export const AuthServerConfigProvider = memo<Props>(
({ children, serverConfig, isMobile, segmentVariants }) => (
({ children, featureFlags, serverConfig, isMobile, segmentVariants }) => (
<AuthServerConfigContext
value={{
featureFlags: featureFlags || {},
isMobile,
segmentVariants,
serverConfig: serverConfig || { aiProvider: {}, telemetry: {} },
@@ -14,6 +14,7 @@ const mockSignInOauth2 = vi.hoisted(() => vi.fn());
const mockSignInEmail = vi.hoisted(() => vi.fn());
const mockSignInMagicLink = vi.hoisted(() => vi.fn());
const mockRequestPasswordReset = vi.hoisted(() => vi.fn());
const mockGetCaptchaTokenOnError = vi.hoisted(() => vi.fn());
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
@@ -48,6 +49,7 @@ vi.mock('@/business/client/hooks/useBusinessSignin', () => ({
useBusinessSignin: () => ({
businessElement: null,
getAdditionalData: async () => ({}),
getCaptchaTokenOnError: mockGetCaptchaTokenOnError,
getFetchOptions: async () => undefined,
preSocialSigninCheck: async () => true,
ssoProviders: [],
@@ -97,6 +99,7 @@ describe('useSignIn', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParamsGet.mockReturnValue(null);
mockGetCaptchaTokenOnError.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -346,6 +349,46 @@ describe('useSignIn', () => {
expect(mockMessageError).toHaveBeenCalled();
});
it('should retry social sign in with captcha token when captcha is required', async () => {
mockGetCaptchaTokenOnError.mockResolvedValue('captcha-token');
mockSignInSocial
.mockResolvedValueOnce({
error: { code: 'CAPTCHA_REQUIRED', message: 'Missing CAPTCHA response', status: 400 },
})
.mockResolvedValueOnce({ url: 'https://google.com/auth' });
const { result } = renderHook(() => useSignIn());
await act(async () => {
await result.current.handleSocialSignIn('google');
});
expect(mockSignInSocial).toHaveBeenCalledTimes(2);
expect(mockSignInSocial).toHaveBeenLastCalledWith(
expect.objectContaining({
fetchOptions: { headers: { 'x-captcha-response': 'captcha-token' } },
provider: 'google',
}),
);
expect(mockMessageError).not.toHaveBeenCalled();
});
it('should stop social sign in when captcha modal is cancelled', async () => {
mockGetCaptchaTokenOnError.mockResolvedValue(null);
mockSignInSocial.mockResolvedValue({
error: { code: 'CAPTCHA_REQUIRED', message: 'Missing CAPTCHA response', status: 400 },
});
const { result } = renderHook(() => useSignIn());
await act(async () => {
await result.current.handleSocialSignIn('google');
});
expect(mockSignInSocial).toHaveBeenCalledTimes(1);
expect(mockMessageError).not.toHaveBeenCalled();
});
it('should save last auth provider to localStorage', async () => {
mockSignInSocial.mockResolvedValue({ url: 'https://google.com/auth' });
+29 -15
View File
@@ -4,8 +4,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type CheckUserResponseData } from '@/app/(backend)/api/auth/check-user/route';
import { type ResolveUsernameResponseData } from '@/app/(backend)/api/auth/resolve-username/route';
import type { CheckUserResponseData } from '@/app/(backend)/api/auth/check-user/route';
import type { ResolveUsernameResponseData } from '@/app/(backend)/api/auth/resolve-username/route';
import { useBusinessSignin } from '@/business/client/hooks/useBusinessSignin';
import { message } from '@/components/AntdStaticMethods';
import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked';
@@ -13,6 +13,8 @@ import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client';
import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
import { useAuthServerConfigStore } from '../_layout/AuthServerConfigProvider';
import type { AuthFetchOptions } from '../utils/authFetchOptions';
import { withCaptchaToken } from '../utils/authFetchOptions';
import { EMAIL_REGEX, USERNAME_REGEX } from './SignInEmailStep';
const LAST_AUTH_PROVIDER_KEY = 'lobehub:auth:last-provider:v1';
@@ -57,6 +59,7 @@ export const useSignIn = () => {
ssoProviders,
preSocialSigninCheck,
getAdditionalData,
getCaptchaTokenOnError,
getFetchOptions,
} = useBusinessSignin();
@@ -228,19 +231,30 @@ export const useSignIn = () => {
const callbackUrl = searchParams.get('callbackUrl') || '/';
const additionalData = await getAdditionalData();
const fetchOptions = await getFetchOptions();
const result = isBuiltinProvider(normalizedProvider)
? await signIn.social({
additionalData,
callbackURL: callbackUrl,
fetchOptions,
provider: normalizedProvider,
})
: await signIn.oauth2({
additionalData,
callbackURL: callbackUrl,
fetchOptions,
providerId: normalizedProvider,
});
const signInWithFetchOptions = async (nextFetchOptions?: AuthFetchOptions) =>
isBuiltinProvider(normalizedProvider)
? await signIn.social({
additionalData,
callbackURL: callbackUrl,
fetchOptions: nextFetchOptions,
provider: normalizedProvider,
})
: await signIn.oauth2({
additionalData,
callbackURL: callbackUrl,
fetchOptions: nextFetchOptions,
providerId: normalizedProvider,
});
let result = await signInWithFetchOptions(fetchOptions);
if (result && 'error' in result && result.error) {
const captchaToken = await getCaptchaTokenOnError(result.error);
if (captchaToken === null) return;
if (captchaToken) {
result = await signInWithFetchOptions(withCaptchaToken(fetchOptions, captchaToken));
}
}
if (result && 'error' in result && result.error) throw result.error;
} catch (error) {
console.error(`${normalizedProvider} sign in error:`, error);
@@ -9,6 +9,7 @@ const mockPush = vi.hoisted(() => vi.fn());
const mockSearchParamsGet = vi.hoisted(() => vi.fn().mockReturnValue(null));
const mockMessageError = vi.hoisted(() => vi.fn());
const mockSignUpEmail = vi.hoisted(() => vi.fn());
const mockGetCaptchaTokenOnError = vi.hoisted(() => vi.fn());
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
@@ -31,6 +32,7 @@ vi.mock('@lobechat/business-const', () => ({
vi.mock('@/business/client/hooks/useBusinessSignup', () => ({
useBusinessSignup: () => ({
businessElement: null,
getCaptchaTokenOnError: mockGetCaptchaTokenOnError,
getFetchOptions: async () => undefined,
preSocialSignupCheck: async () => true,
}),
@@ -53,6 +55,7 @@ describe('useSignUp', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParamsGet.mockReturnValue(null);
mockGetCaptchaTokenOnError.mockResolvedValue(undefined);
mockEnableEmailVerification = false;
});
@@ -199,6 +202,47 @@ describe('useSignUp', () => {
expect(mockPush).not.toHaveBeenCalled();
});
it('should retry sign up with captcha token when captcha is required', async () => {
mockGetCaptchaTokenOnError.mockResolvedValue('captcha-token');
mockSignUpEmail
.mockResolvedValueOnce({
error: { code: 'CAPTCHA_REQUIRED', message: 'Missing CAPTCHA response' },
})
.mockResolvedValueOnce({ error: null });
const { result } = renderHook(() => useSignUp());
await act(async () => {
await result.current.onSubmit(validValues);
});
expect(mockSignUpEmail).toHaveBeenCalledTimes(2);
expect(mockSignUpEmail).toHaveBeenLastCalledWith(
expect.objectContaining({
fetchOptions: { headers: { 'x-captcha-response': 'captcha-token' } },
}),
);
expect(mockMessageError).not.toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith('/');
});
it('should stop sign up when captcha modal is cancelled', async () => {
mockGetCaptchaTokenOnError.mockResolvedValue(null);
mockSignUpEmail.mockResolvedValue({
error: { code: 'CAPTCHA_REQUIRED', message: 'Missing CAPTCHA response' },
});
const { result } = renderHook(() => useSignUp());
await act(async () => {
await result.current.onSubmit(validValues);
});
expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
expect(mockMessageError).not.toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalled();
});
it('should show generic error on unexpected exception', async () => {
mockSignUpEmail.mockRejectedValue(new Error('network error'));
@@ -4,23 +4,36 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type BusinessSignupFomData } from '@/business/client/hooks/useBusinessSignup';
import type { BusinessSignupFomData } from '@/business/client/hooks/useBusinessSignup';
import { useBusinessSignup } from '@/business/client/hooks/useBusinessSignup';
import { message } from '@/components/AntdStaticMethods';
import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked';
import { signUp } from '@/libs/better-auth/auth-client';
import { useAuthServerConfigStore } from '../../_layout/AuthServerConfigProvider';
import { type BaseSignUpFormValues } from './types';
import type { AuthFetchOptions } from '../../utils/authFetchOptions';
import { withCaptchaToken } from '../../utils/authFetchOptions';
import type { BaseSignUpFormValues } from './types';
export type SignUpFormValues = BaseSignUpFormValues & BusinessSignupFomData;
interface SignUpErrorLike {
code?: string;
details?: {
cause?: {
code?: string;
};
};
message?: string;
}
export const useSignUp = () => {
const { t } = useTranslation(['auth', 'authError']);
const router = useRouter();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const { getFetchOptions, preSocialSignupCheck, businessElement } = useBusinessSignup(form);
const { getCaptchaTokenOnError, getFetchOptions, preSocialSignupCheck, businessElement } =
useBusinessSignup(form);
const enableEmailVerification = useAuthServerConfigStore(
(s) => s.serverConfig.enableEmailVerification || false,
);
@@ -37,34 +50,47 @@ export const useSignUp = () => {
const callbackUrl = searchParams.get('callbackUrl') || '/';
const username = values.email.split('@')[0];
const fetchOptions = await getFetchOptions();
const { error } = await signUp.email({
callbackURL: callbackUrl,
email: values.email,
fetchOptions: await getFetchOptions(),
name: username,
password: values.password,
});
const submit = async (nextFetchOptions?: AuthFetchOptions) =>
signUp.email({
callbackURL: callbackUrl,
email: values.email,
fetchOptions: nextFetchOptions,
name: username,
password: values.password,
});
let { error } = await submit(fetchOptions);
if (error) {
const captchaToken = await getCaptchaTokenOnError(error);
if (captchaToken === null) return;
if (captchaToken) {
({ error } = await submit(withCaptchaToken(fetchOptions, captchaToken)));
}
}
if (error) {
const signUpError = error as SignUpErrorLike;
const isEmailDuplicate =
error.code === 'FAILED_TO_CREATE_USER' &&
(error as any)?.details?.cause?.code === '23505';
signUpError.code === 'FAILED_TO_CREATE_USER' &&
signUpError.details?.cause?.code === '23505';
if (isEmailDuplicate) {
message.error(t('betterAuth.errors.emailExists'));
return;
}
if (error.code === 'INVALID_EMAIL' || error.message === 'Invalid email') {
if (signUpError.code === 'INVALID_EMAIL' || signUpError.message === 'Invalid email') {
message.error(t('betterAuth.errors.emailInvalid'));
return;
}
const translated = error.code
? t(`authError:codes.${error.code}`, { defaultValue: '' })
const translated = signUpError.code
? t(`authError:codes.${signUpError.code}`, { defaultValue: '' })
: '';
message.error(translated || error.message || t('betterAuth.signup.error'));
message.error(translated || signUpError.message || t('betterAuth.signup.error'));
return;
}
@@ -0,0 +1,19 @@
import { headersToRecord } from '@lobechat/fetch-sse/headers';
import { CAPTCHA_RESPONSE_HEADER } from '@/libs/better-auth/captcha';
export interface AuthFetchOptions {
headers?: HeadersInit;
query?: Record<string, unknown>;
}
export const withCaptchaToken = (
fetchOptions: AuthFetchOptions | undefined,
captchaToken: string,
) => ({
...fetchOptions,
headers: {
...headersToRecord(fetchOptions?.headers),
[CAPTCHA_RESPONSE_HEADER]: captchaToken,
},
});
@@ -6,7 +6,9 @@ export const useBusinessSignin = () => {
getAdditionalData: async () => {
return {};
},
getFetchOptions: async () => undefined as Record<string, any> | undefined,
// eslint-disable-next-line unused-imports/no-unused-vars
getCaptchaTokenOnError: async (error: unknown) => undefined as string | null | undefined,
getFetchOptions: async () => undefined as Record<string, unknown> | undefined,
preSocialSigninCheck: async () => {
return true;
},
@@ -1,4 +1,4 @@
import { type BaseSignUpFormValues } from '@/app/[variants]/(auth)/signup/[[...signup]]/types';
import type { BaseSignUpFormValues } from '@/app/[variants]/(auth)/signup/[[...signup]]/types';
export interface BusinessSignupFomData {}
@@ -6,6 +6,8 @@ export interface BusinessSignupFomData {}
export const useBusinessSignup = (form: any) => {
return {
businessElement: null,
// eslint-disable-next-line unused-imports/no-unused-vars
getCaptchaTokenOnError: async (error: unknown) => undefined as string | null | undefined,
getFetchOptions: async () => {
return {};
},
+16 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { evaluateFeatureFlag, FeatureFlagsSchema, mapFeatureFlagsEnvToState } from './schema';
import {
DEFAULT_FEATURE_FLAGS,
evaluateFeatureFlag,
FeatureFlagsSchema,
mapFeatureFlagsEnvToState,
} from './schema';
describe('FeatureFlagsSchema', () => {
it('should validate correct feature flags with boolean values', () => {
@@ -96,6 +101,12 @@ describe('evaluateFeatureFlag', () => {
});
describe('mapFeatureFlagsEnvToState', () => {
it('should enable auth captcha by default', () => {
const mappedState = mapFeatureFlagsEnvToState(DEFAULT_FEATURE_FLAGS);
expect(mappedState.enableAuthCaptcha).toBe(true);
});
it('should correctly map boolean feature flags to state', () => {
const config = {
provider_settings: true,
@@ -110,6 +121,7 @@ describe('mapFeatureFlagsEnvToState', () => {
agent_self_iteration: true,
agent_onboarding: true,
agent_task: true,
auth_captcha: true,
market: true,
speech_to_text: true,
changelog: false,
@@ -136,6 +148,7 @@ describe('mapFeatureFlagsEnvToState', () => {
enableAgentSelfIteration: true,
enableAgentOnboarding: true,
enableAgentTask: true,
enableAuthCaptcha: true,
showMarket: true,
enableSTT: true,
showCloudPromotion: true,
@@ -151,6 +164,7 @@ describe('mapFeatureFlagsEnvToState', () => {
agent_self_iteration: ['user-123'],
agent_onboarding: ['user-123'],
agent_task: ['user-123'],
auth_captcha: ['user-123'],
create_session: ['user-789'],
dalle: true,
knowledge_base: ['user-123'],
@@ -163,6 +177,7 @@ describe('mapFeatureFlagsEnvToState', () => {
expect(mappedState.enableAgentSelfIteration).toBe(true); // user-123 is in allowlist
expect(mappedState.enableAgentOnboarding).toBe(true); // user-123 is in allowlist
expect(mappedState.enableAgentTask).toBe(true); // user-123 is in allowlist
expect(mappedState.enableAuthCaptcha).toBe(true); // user-123 is in allowlist
expect(mappedState.enableKnowledgeBase).toBe(true); // user-123 is in allowlist
});
+4
View File
@@ -33,6 +33,8 @@ export const FeatureFlagsSchema = z.object({
agent_self_iteration: FeatureFlagValue.optional(),
agent_onboarding: FeatureFlagValue.optional(),
agent_task: FeatureFlagValue.optional(),
// Cloud feature flag. Keep here until cloud owns a separate runtime flag domain.
auth_captcha: FeatureFlagValue.optional(),
cloud_promotion: FeatureFlagValue.optional(),
// the flags below can only be used with commercial license
@@ -82,6 +84,7 @@ export const DEFAULT_FEATURE_FLAGS: IFeatureFlags = {
agent_self_iteration: isDev,
agent_onboarding: isDev,
agent_task: isDev,
auth_captcha: true,
cloud_promotion: false,
market: true,
@@ -116,6 +119,7 @@ export const mapFeatureFlagsEnvToState = (config: IFeatureFlags, userId?: string
enableAgentSelfIteration: evaluateFeatureFlag(config.agent_self_iteration, userId),
enableAgentOnboarding: evaluateFeatureFlag(config.agent_onboarding, userId),
enableAgentTask: evaluateFeatureFlag(config.agent_task, userId),
enableAuthCaptcha: evaluateFeatureFlag(config.auth_captcha, userId),
showCloudPromotion: evaluateFeatureFlag(config.cloud_promotion, userId),
+9
View File
@@ -0,0 +1,9 @@
export const CAPTCHA_RESPONSE_HEADER = 'x-captcha-response';
export const CAPTCHA_REQUIRED_CODE = 'CAPTCHA_REQUIRED';
export const CAPTCHA_FAILED_CODE = 'CAPTCHA_FAILED';
export const CAPTCHA_UNAVAILABLE_CODE = 'CAPTCHA_UNAVAILABLE';
export const CAPTCHA_REQUIRED_MESSAGE = 'Missing CAPTCHA response';
export const CAPTCHA_FAILED_MESSAGE = 'Captcha verification failed';
export const CAPTCHA_UNAVAILABLE_MESSAGE = 'CAPTCHA service unavailable';
+5
View File
@@ -62,6 +62,11 @@ export default {
'betterAuth.resetPassword.success':
'Password reset successful, please sign in with your new password',
'betterAuth.resetPassword.title': 'Reset Password',
'betterAuth.captcha.continue': 'Continue',
'betterAuth.captcha.description':
'Complete the security verification below. We will continue your sign up or sign in automatically.',
'betterAuth.captcha.pendingDescription': 'Please complete the verification first, then continue.',
'betterAuth.captcha.title': 'Security verification required',
'betterAuth.signin.backToEmail': 'Back to change email',
'betterAuth.signin.continueWithApple': 'Continue with Apple',
'betterAuth.signin.continueWithAuth0': 'Sign in with Auth0',