feat(core): captcha service (#8616)

This commit is contained in:
EYHN 2024-10-28 18:06:52 +00:00
parent 81029db6ce
commit 7699296f11
No known key found for this signature in database
GPG Key ID: 46C9E26A75AB276C
12 changed files with 246 additions and 190 deletions

View File

@ -6,6 +6,7 @@ import { Telemetry } from '@affine/core/components/telemetry';
import { router } from '@affine/core/desktop/router'; import { router } from '@affine/core/desktop/router';
import { configureCommonModules } from '@affine/core/modules'; import { configureCommonModules } from '@affine/core/modules';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
import { ValidatorProvider } from '@affine/core/modules/cloud';
import { I18nProvider } from '@affine/core/modules/i18n'; import { I18nProvider } from '@affine/core/modules/i18n';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage'; import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
@ -75,6 +76,15 @@ framework.impl(ClientSchemaProvider, {
return appInfo?.schema; return appInfo?.schema;
}, },
}); });
framework.impl(ValidatorProvider, {
async validate(_challenge, resource) {
const token = await apis?.ui?.getChallengeResponse(resource);
if (!token) {
throw new Error('Challenge failed');
}
return token;
},
});
const frameworkProvider = framework.provider(); const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event // setup application lifecycle events, and emit application start event

View File

@ -7,14 +7,14 @@ import {
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud'; import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as style from './style.css'; import * as style from './style.css';
import { Captcha, useCaptcha } from './use-captcha'; import { Captcha } from './use-captcha';
export const AfterSignInSendEmail = ({ export const AfterSignInSendEmail = ({
setAuthData: setAuth, setAuthData: setAuth,
@ -37,21 +37,23 @@ export const AfterSignInSendEmail = ({
const t = useI18n(); const t = useI18n();
const authService = useService(AuthService); const authService = useService(AuthService);
const captchaService = useService(CaptchaService);
const [verifyToken, challenge] = useCaptcha(); const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const onResendClick = useAsyncCallback(async () => { const onResendClick = useAsyncCallback(async () => {
setIsSending(true); setIsSending(true);
try { try {
if (verifyToken) { setResendCountDown(60);
setResendCountDown(60); captchaService.revalidate();
await authService.sendEmailMagicLink( await authService.sendEmailMagicLink(
email, email,
verifyToken, verifyToken,
challenge, challenge,
redirectUrl redirectUrl
); );
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
notify.error({ notify.error({
@ -59,7 +61,7 @@ export const AfterSignInSendEmail = ({
}); });
} }
setIsSending(false); setIsSending(false);
}, [authService, challenge, email, redirectUrl, verifyToken]); }, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
const onSignInWithPasswordClick = useCallback(() => { const onSignInWithPasswordClick = useCallback(() => {
setAuth({ state: 'signInWithPassword' }); setAuth({ state: 'signInWithPassword' });
@ -89,8 +91,10 @@ export const AfterSignInSendEmail = ({
<> <>
<Captcha /> <Captcha />
<Button <Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}} style={
disabled={!verifyToken || isSending} !verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
}
disabled={(!verifyToken && needCaptcha) || isSending}
variant="plain" variant="plain"
size="large" size="large"
onClick={onResendClick} onClick={onResendClick}

View File

@ -7,15 +7,16 @@ import {
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { CaptchaService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { AuthService } from '../../../modules/cloud'; import { AuthService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as style from './style.css'; import * as style from './style.css';
import { Captcha, useCaptcha } from './use-captcha'; import { Captcha } from './use-captcha';
export const AfterSignUpSendEmail: FC< export const AfterSignUpSendEmail: FC<
AuthPanelProps<'afterSignUpSendEmail'> AuthPanelProps<'afterSignUpSendEmail'>
@ -36,19 +37,22 @@ export const AfterSignUpSendEmail: FC<
const t = useI18n(); const t = useI18n();
const authService = useService(AuthService); const authService = useService(AuthService);
const [verifyToken, challenge] = useCaptcha(); const captchaService = useService(CaptchaService);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const onResendClick = useAsyncCallback(async () => { const onResendClick = useAsyncCallback(async () => {
setIsSending(true); setIsSending(true);
try { try {
if (verifyToken) { captchaService.revalidate();
await authService.sendEmailMagicLink( await authService.sendEmailMagicLink(
email, email,
verifyToken, verifyToken,
challenge, challenge,
redirectUrl redirectUrl
); );
}
setResendCountDown(60); setResendCountDown(60);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -57,7 +61,7 @@ export const AfterSignUpSendEmail: FC<
}); });
} }
setIsSending(false); setIsSending(false);
}, [authService, challenge, email, redirectUrl, verifyToken]); }, [authService, captchaService, challenge, email, redirectUrl, verifyToken]);
return ( return (
<> <>
@ -79,8 +83,10 @@ export const AfterSignUpSendEmail: FC<
<> <>
<Captcha /> <Captcha />
<Button <Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}} style={
disabled={!verifyToken || isSending} !verifyToken && needCaptcha ? { cursor: 'not-allowed' } : {}
}
disabled={(!verifyToken && needCaptcha) || isSending}
variant="plain" variant="plain"
size="large" size="large"
onClick={onResendClick} onClick={onResendClick}

View File

@ -6,15 +6,15 @@ import {
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud'; import { AuthService, CaptchaService } from '@affine/core/modules/cloud';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import * as styles from './style.css'; import * as styles from './style.css';
import { useCaptcha } from './use-captcha'; import { Captcha } from './use-captcha';
export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
setAuthData, setAuthData,
@ -26,15 +26,20 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false); const [passwordError, setPasswordError] = useState(false);
const [verifyToken, challenge, refreshChallenge] = useCaptcha(); const captchaService = useService(CaptchaService);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [sendingEmail, setSendingEmail] = useState(false); const [sendingEmail, setSendingEmail] = useState(false);
const onSignIn = useAsyncCallback(async () => { const onSignIn = useAsyncCallback(async () => {
if (isLoading || !verifyToken) return; if (isLoading) return;
setIsLoading(true); setIsLoading(true);
try { try {
captchaService.revalidate();
await authService.signInPassword({ await authService.signInPassword({
email, email,
password, password,
@ -44,33 +49,31 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setPasswordError(true); setPasswordError(true);
refreshChallenge?.();
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [ }, [
isLoading, isLoading,
verifyToken, verifyToken,
captchaService,
authService, authService,
email, email,
password, password,
challenge, challenge,
refreshChallenge,
]); ]);
const sendMagicLink = useAsyncCallback(async () => { const sendMagicLink = useAsyncCallback(async () => {
if (sendingEmail) return; if (sendingEmail) return;
setSendingEmail(true); setSendingEmail(true);
try { try {
if (verifyToken) { captchaService.revalidate();
await authService.sendEmailMagicLink( await authService.sendEmailMagicLink(
email, email,
verifyToken, verifyToken,
challenge, challenge,
redirectUrl redirectUrl
); );
setAuthData({ state: 'afterSignInSendEmail' }); setAuthData({ state: 'afterSignInSendEmail' });
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
notify.error({ notify.error({
@ -82,6 +85,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
}, [ }, [
sendingEmail, sendingEmail,
verifyToken, verifyToken,
captchaService,
authService, authService,
email, email,
challenge, challenge,
@ -139,21 +143,24 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
{t['com.affine.auth.forget']()} {t['com.affine.auth.forget']()}
</a> </a>
</div> </div>
<div className={styles.sendMagicLinkButtonRow}> {(verifyToken || !needCaptcha) && (
<a <div className={styles.sendMagicLinkButtonRow}>
data-testid="send-magic-link-button" <a
className={styles.linkButton} data-testid="send-magic-link-button"
onClick={sendMagicLink} className={styles.linkButton}
> onClick={sendMagicLink}
{t['com.affine.auth.sign.auth.code.send-email.sign-in']()} >
</a> {t['com.affine.auth.sign.auth.code.send-email.sign-in']()}
</div> </a>
</div>
)}
{!verifyToken && needCaptcha && <Captcha />}
<Button <Button
data-testid="sign-in-button" data-testid="sign-in-button"
variant="primary" variant="primary"
size="extraLarge" size="extraLarge"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={isLoading} disabled={isLoading || (!verifyToken && needCaptcha)}
onClick={onSignIn} onClick={onSignIn}
> >
{t['com.affine.auth.sign.in']()} {t['com.affine.auth.sign.in']()}

View File

@ -2,9 +2,10 @@ import { notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components'; import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { CaptchaService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { ArrowRightBigIcon } from '@blocksuite/icons/rc'; import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import type { FC } from 'react'; import type { FC } from 'react';
import { useState } from 'react'; import { useState } from 'react';
@ -15,7 +16,7 @@ import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index'; import type { AuthPanelProps } from './index';
import { OAuth } from './oauth'; import { OAuth } from './oauth';
import * as style from './style.css'; import * as style from './style.css';
import { Captcha, useCaptcha } from './use-captcha'; import { Captcha } from './use-captcha';
function validateEmail(email: string) { function validateEmail(email: string) {
return emailRegex.test(email); return emailRegex.test(email);
@ -30,7 +31,11 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
const authService = useService(AuthService); const authService = useService(AuthService);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [isMutating, setIsMutating] = useState(false); const [isMutating, setIsMutating] = useState(false);
const [verifyToken, challenge, refreshChallenge] = useCaptcha(); const captchaService = useService(CaptchaService);
const verifyToken = useLiveData(captchaService.verifyToken$);
const needCaptcha = useLiveData(captchaService.needCaptcha$);
const challenge = useLiveData(captchaService.challenge$);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true); const [isValidEmail, setIsValidEmail] = useState(true);
@ -49,29 +54,16 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
const { hasPassword, registered } = const { hasPassword, registered } =
await authService.checkUserByEmail(email); await authService.checkUserByEmail(email);
if (verifyToken) { if (registered) {
if (registered) { // provider password sign-in if user has by default
// provider password sign-in if user has by default // If with payment, onl support email sign in to avoid redirect to affine app
// If with payment, onl support email sign in to avoid redirect to affine app if (hasPassword) {
if (hasPassword) { setAuthState({
refreshChallenge?.(); state: 'signInWithPassword',
setAuthState({ email,
state: 'signInWithPassword', });
email,
});
} else {
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({
state: 'afterSignInSendEmail',
email,
});
}
} else { } else {
captchaService.revalidate();
await authService.sendEmailMagicLink( await authService.sendEmailMagicLink(
email, email,
verifyToken, verifyToken,
@ -79,10 +71,22 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
redirectUrl redirectUrl
); );
setAuthState({ setAuthState({
state: 'afterSignUpSendEmail', state: 'afterSignInSendEmail',
email, email,
}); });
} }
} else {
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
redirectUrl
);
setAuthState({
state: 'afterSignUpSendEmail',
email,
});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -96,10 +100,10 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
setIsMutating(false); setIsMutating(false);
}, [ }, [
authService, authService,
captchaService,
challenge, challenge,
email, email,
redirectUrl, redirectUrl,
refreshChallenge,
setAuthState, setAuthState,
verifyToken, verifyToken,
]); ]);
@ -125,7 +129,7 @@ export const SignIn: FC<AuthPanelProps<'signIn'>> = ({
onEnter={onContinue} onEnter={onContinue}
/> />
{verifyToken ? ( {verifyToken || !needCaptcha ? (
<Button <Button
style={{ width: '100%' }} style={{ width: '100%' }}
size="extraLarge" size="extraLarge"

View File

@ -1,120 +1,45 @@
import { apis } from '@affine/electron-api'; import { CaptchaService } from '@affine/core/modules/cloud';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { atom, useAtom, useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react';
import { useEffect, useRef } from 'react';
import useSWR from 'swr';
import { ServerConfigService } from '../../../modules/cloud';
import * as style from './style.css'; import * as style from './style.css';
type Challenge = {
challenge: string;
resource: string;
};
const challengeFetcher = async (url: string) => {
if (!BUILD_CONFIG.isElectron) {
return undefined;
}
const res = await fetch(url);
if (!res.ok) {
throw new Error('Failed to fetch challenge');
}
const challenge = (await res.json()) as Challenge;
if (!challenge || !challenge.challenge || !challenge.resource) {
throw new Error('Invalid challenge');
}
return challenge;
};
const generateChallengeResponse = async (challenge: string) => {
if (!BUILD_CONFIG.isElectron) {
return undefined;
}
return await apis?.ui?.getChallengeResponse(challenge);
};
const captchaAtom = atom<string | undefined>(undefined);
const responseAtom = atom<string | undefined>(undefined);
const useHasCaptcha = () => {
const serverConfig = useService(ServerConfigService).serverConfig;
const hasCaptcha = useLiveData(serverConfig.features$.map(r => r?.captcha));
return hasCaptcha || false;
};
export const Captcha = () => { export const Captcha = () => {
const setCaptcha = useSetAtom(captchaAtom); const captchaService = useService(CaptchaService);
const [response] = useAtom(responseAtom); const hasCaptchaFeature = useLiveData(captchaService.needCaptcha$);
const hasCaptchaFeature = useHasCaptcha(); const isLoading = useLiveData(captchaService.isLoading$);
const verifyToken = useLiveData(captchaService.verifyToken$);
useEffect(() => {
if (hasCaptchaFeature) {
captchaService.revalidate();
}
}, [captchaService, hasCaptchaFeature]);
const handleTurnstileSuccess = useCallback(
(token: string) => {
captchaService.verifyToken$.next(token);
},
[captchaService]
);
if (!hasCaptchaFeature) { if (!hasCaptchaFeature) {
return null; return null;
} }
if (BUILD_CONFIG.isElectron) { if (isLoading) {
if (response) { return <div className={style.captchaWrapper}>Loading...</div>;
return <div className={style.captchaWrapper}>Making Challenge</div>; }
} else {
return <div className={style.captchaWrapper}>Verified Client</div>; if (verifyToken) {
} return <div className={style.captchaWrapper}>Verified Client</div>;
} }
return ( return (
<Turnstile <Turnstile
className={style.captchaWrapper} className={style.captchaWrapper}
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'} siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
onSuccess={setCaptcha} onSuccess={handleTurnstileSuccess}
/> />
); );
}; };
export const useCaptcha = (): [string | undefined, string?, (() => void)?] => {
const [verifyToken] = useAtom(captchaAtom);
const [response, setResponse] = useAtom(responseAtom);
const hasCaptchaFeature = useHasCaptcha();
const { data: challenge, mutate } = useSWR(
'/api/auth/challenge',
challengeFetcher,
{
suspense: false,
revalidateOnFocus: false,
}
);
const prevChallenge = useRef('');
useEffect(() => {
if (
BUILD_CONFIG.isElectron &&
hasCaptchaFeature &&
challenge?.challenge &&
prevChallenge.current !== challenge.challenge
) {
prevChallenge.current = challenge.challenge;
generateChallengeResponse(challenge.resource)
.then(setResponse)
.catch(err => {
console.error('Error getting challenge response:', err);
});
}
}, [challenge, hasCaptchaFeature, setResponse]);
if (!hasCaptchaFeature) {
return ['XXXX.DUMMY.TOKEN.XXXX'];
}
if (BUILD_CONFIG.isElectron) {
if (response) {
return [response, challenge?.challenge, mutate];
} else {
return [undefined, challenge?.challenge];
}
}
return [verifyToken];
};

View File

@ -6,8 +6,10 @@ export {
isNetworkError, isNetworkError,
NetworkError, NetworkError,
} from './error'; } from './error';
export { ValidatorProvider } from './provider/validator';
export { WebSocketAuthProvider } from './provider/websocket-auth'; export { WebSocketAuthProvider } from './provider/websocket-auth';
export { AccountChanged, AuthService } from './services/auth'; export { AccountChanged, AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { FetchService } from './services/fetch'; export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql'; export { GraphQLService } from './services/graphql';
export { InvoicesService } from './services/invoices'; export { InvoicesService } from './services/invoices';
@ -38,8 +40,10 @@ import { UserCopilotQuota } from './entities/user-copilot-quota';
import { UserFeature } from './entities/user-feature'; import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota'; import { UserQuota } from './entities/user-quota';
import { DefaultFetchProvider, FetchProvider } from './provider/fetch'; import { DefaultFetchProvider, FetchProvider } from './provider/fetch';
import { ValidatorProvider } from './provider/validator';
import { WebSocketAuthProvider } from './provider/websocket-auth'; import { WebSocketAuthProvider } from './provider/websocket-auth';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { CaptchaService } from './services/captcha';
import { CloudDocMetaService } from './services/cloud-doc-meta'; import { CloudDocMetaService } from './services/cloud-doc-meta';
import { FetchService } from './services/fetch'; import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql'; import { GraphQLService } from './services/graphql';
@ -75,6 +79,13 @@ export function configureCloudModule(framework: Framework) {
.service(ServerConfigService) .service(ServerConfigService)
.entity(ServerConfig, [ServerConfigStore]) .entity(ServerConfig, [ServerConfigStore])
.store(ServerConfigStore, [GraphQLService]) .store(ServerConfigStore, [GraphQLService])
.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerConfigService),
f.get(FetchService),
f.getOptional(ValidatorProvider)
);
})
.service(AuthService, [FetchService, AuthStore, UrlService]) .service(AuthService, [FetchService, AuthStore, UrlService])
.store(AuthStore, [FetchService, GraphQLService, GlobalState]) .store(AuthStore, [FetchService, GraphQLService, GlobalState])
.entity(AuthSession, [AuthStore]) .entity(AuthSession, [AuthStore])

View File

@ -0,0 +1,12 @@
import { createIdentifier } from '@toeverything/infra';
export interface ValidatorProvider {
/**
* Calculate a token based on the server's challenge and resource to pass the
* challenge validation.
*/
validate: (challenge: string, resource: string) => Promise<string>;
}
export const ValidatorProvider =
createIdentifier<ValidatorProvider>('ValidatorProvider');

View File

@ -113,7 +113,7 @@ export class AuthService extends Service {
async sendEmailMagicLink( async sendEmailMagicLink(
email: string, email: string,
verifyToken: string, verifyToken?: string,
challenge?: string, challenge?: string,
redirectUrl?: string // url to redirect to after signed-in redirectUrl?: string // url to redirect to after signed-in
) { ) {
@ -137,7 +137,7 @@ export class AuthService extends Service {
}), }),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...this.captchaHeaders(verifyToken, challenge), ...(verifyToken ? this.captchaHeaders(verifyToken, challenge) : {}),
}, },
}); });
} catch (e) { } catch (e) {
@ -224,7 +224,7 @@ export class AuthService extends Service {
async signInPassword(credential: { async signInPassword(credential: {
email: string; email: string;
password: string; password: string;
verifyToken: string; verifyToken?: string;
challenge?: string; challenge?: string;
}) { }) {
track.$.$.auth.signIn({ method: 'password' }); track.$.$.auth.signIn({ method: 'password' });
@ -234,7 +234,9 @@ export class AuthService extends Service {
body: JSON.stringify(credential), body: JSON.stringify(credential),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...this.captchaHeaders(credential.verifyToken, credential.challenge), ...(credential.verifyToken
? this.captchaHeaders(credential.verifyToken, credential.challenge)
: {}),
}, },
}); });
this.session.revalidate(); this.session.revalidate();

View File

@ -0,0 +1,75 @@
import {
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import type { ValidatorProvider } from '../provider/validator';
import type { FetchService } from './fetch';
import type { ServerConfigService } from './server-config';
export class CaptchaService extends Service {
needCaptcha$ = this.serverConfigService.serverConfig.features$.map(
r => r?.captcha || false
);
challenge$ = new LiveData<string | undefined>(undefined);
isLoading$ = new LiveData(false);
verifyToken$ = new LiveData<string | undefined>(undefined);
error$ = new LiveData<any | undefined>(undefined);
constructor(
private readonly serverConfigService: ServerConfigService,
private readonly fetchService: FetchService,
public readonly validatorProvider?: ValidatorProvider
) {
super();
}
revalidate = effect(
switchMap(() => {
return fromPromise(async signal => {
if (!this.needCaptcha$.value) {
return {};
}
const res = await this.fetchService.fetch('/api/auth/challenge', {
signal,
});
const data = (await res.json()) as {
challenge: string;
resource: string;
};
if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge');
}
if (this.validatorProvider) {
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
token,
challenge: data.challenge,
};
}
return { challenge: data.challenge, token: undefined };
}).pipe(
mergeMap(({ challenge, token }) => {
this.verifyToken$.next(token);
this.challenge$.next(challenge);
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => {
this.verifyToken$.next(undefined);
this.isLoading$.next(true);
}),
onComplete(() => this.isLoading$.next(false))
);
})
);
}

View File

@ -10,7 +10,7 @@
"fr": 75, "fr": 75,
"hi": 2, "hi": 2,
"it": 1, "it": 1,
"ja": 99, "ja": 100,
"ko": 89, "ko": 89,
"pl": 0, "pl": 0,
"pt-BR": 97, "pt-BR": 97,

View File

@ -189,7 +189,7 @@ export async function loginUser(
} }
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.getByTestId('sign-in-button').click(); await page.getByTestId('sign-in-button').click();
await page.waitForTimeout(200); await page.waitForTimeout(500);
if (config?.afterLogin) { if (config?.afterLogin) {
await config.afterLogin(); await config.afterLogin();
} }