feat: add captcha support for sign in/up (#4582)

This commit is contained in:
DarkSky
2023-10-18 03:06:07 -05:00
committed by GitHub
parent 524e48c8e6
commit 63ca9671be
42 changed files with 1275 additions and 302 deletions
+3
View File
@@ -359,6 +359,9 @@ export const createConfiguration: (
'process.env.TRACE_REPORT_ENDPOINT': JSON.stringify(
process.env.TRACE_REPORT_ENDPOINT
),
'process.env.CAPTCHA_SITE_KEY': JSON.stringify(
process.env.CAPTCHA_SITE_KEY
),
runtimeConfig: JSON.stringify(runtimeConfig),
}),
new CopyPlugin({
+7
View File
@@ -29,6 +29,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableMoveDatabase: false,
enableNotificationCenter: true,
enableCloud: true,
enableCaptcha: true,
enableEnhanceShareMode: false,
serverUrlPrefix: 'https://app.affine.pro',
editorFlags,
@@ -62,6 +63,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableMoveDatabase: false,
enableNotificationCenter: true,
enableCloud: true,
enableCaptcha: true,
enableEnhanceShareMode: false,
serverUrlPrefix: 'https://affine.fail',
editorFlags,
@@ -107,6 +109,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableCloud: process.env.ENABLE_CLOUD
? process.env.ENABLE_CLOUD === 'true'
: currentBuildPreset.enableCloud,
enableCaptcha: process.env.ENABLE_CAPTCHA
? process.env.ENABLE_CAPTCHA === 'true'
: buildFlags.mode === 'development'
? false
: currentBuildPreset.enableCaptcha,
enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
: currentBuildPreset.enableEnhanceShareMode,
+2 -1
View File
@@ -37,6 +37,7 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@marsidev/react-turnstile": "^0.3.1",
"@mui/material": "^5.14.13",
"@radix-ui/react-select": "^2.0.0",
"@react-hookz/web": "^23.1.0",
@@ -61,7 +62,7 @@
"react-router-dom": "^6.16.0",
"rxjs": "^7.8.1",
"ses": "^0.18.8",
"swr": "2.2.0",
"swr": "2.2.4",
"valtio": "^1.11.2",
"y-protocols": "^1.0.6",
"yjs": "^13.6.8",
@@ -13,6 +13,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignInSendEmail = ({
setAuthState,
@@ -21,6 +22,7 @@ export const AfterSignInSendEmail = ({
}: AuthPanelProps) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const { resendCountDown, allowSendEmail, signIn } = useAuth();
if (loginStatus === 'authenticated') {
@@ -28,8 +30,10 @@ export const AfterSignInSendEmail = ({
}
const onResendClick = useCallback(async () => {
await signIn(email);
}, [email, signIn]);
if (verifyToken) {
await signIn(email, verifyToken, challenge);
}
}, [challenge, email, signIn, verifyToken]);
return (
<>
@@ -37,7 +41,7 @@ export const AfterSignInSendEmail = ({
title={t['com.affine.auth.sign.in']()}
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
<AuthContent style={{ height: 100 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
@@ -45,9 +49,18 @@ export const AfterSignInSendEmail = ({
<div className={style.resendWrapper}>
{allowSendEmail ? (
<Button type="plain" size="large" onClick={onResendClick}>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
<>
<Captcha />
<Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
disabled={!verifyToken}
type="plain"
size="large"
onClick={onResendClick}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
</>
) : (
<>
<span className="resend-code-hint">
@@ -12,6 +12,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState,
@@ -20,6 +21,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const { resendCountDown, allowSendEmail, signUp } = useAuth();
@@ -28,8 +30,10 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
}
const onResendClick = useCallback(async () => {
await signUp(email);
}, [email, signUp]);
if (verifyToken) {
await signUp(email, verifyToken, challenge);
}
}, [challenge, email, signUp, verifyToken]);
return (
<>
@@ -37,7 +41,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
title={t['com.affine.auth.sign.up']()}
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
<AuthContent style={{ height: 100 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
@@ -45,9 +49,18 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
<div className={style.resendWrapper}>
{allowSendEmail ? (
<Button type="plain" size="large" onClick={onResendClick}>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
<>
<Captcha />
<Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
disabled={!verifyToken}
type="plain"
size="large"
onClick={onResendClick}
>
{t['com.affine.auth.sign.auth.code.resend.hint']()}
</Button>
</>
) : (
<>
<span className="resend-code-hint">
@@ -18,6 +18,7 @@ import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
function validateEmail(email: string) {
return emailRegex.test(email);
@@ -31,6 +32,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const {
isMutating: isSigningIn,
@@ -78,20 +80,33 @@ export const SignIn: FC<AuthPanelProps> = ({
}
setAuthEmail(email);
if (user) {
const res = await signIn(email);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
if (verifyToken) {
if (user) {
const res = await signIn(email, verifyToken, challenge);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
}
setAuthState('afterSignInSendEmail');
} else {
const res = await signUp(email, verifyToken, challenge);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
} else if (!res || res.status >= 400 || res.error) {
return;
}
setAuthState('afterSignUpSendEmail');
}
setAuthState('afterSignInSendEmail');
} else {
const res = await signUp(email);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
}
setAuthState('afterSignUpSendEmail');
}
}, [email, setAuthEmail, setAuthState, signIn, signUp, verifyUser]);
}, [
challenge,
email,
setAuthEmail,
setAuthState,
signIn,
signUp,
verifyToken,
verifyUser,
]);
return (
<>
@@ -133,41 +148,45 @@ export const SignIn: FC<AuthPanelProps> = ({
onEnter={onContinue}
/>
<Button
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating || isSigningIn}
disabled={!allowSendEmail}
icon={
allowSendEmail || isMutating ? (
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
) : (
<CountDownRender
className={style.resendCountdownInButton}
timeLeft={resendCountDown}
/>
)
}
iconPosition="end"
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
{verifyToken ? null : <Captcha />}
{verifyToken ? (
<Button
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating || isSigningIn}
disabled={!allowSendEmail}
icon={
allowSendEmail || isMutating ? (
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
) : (
<CountDownRender
className={style.resendCountdownInButton}
timeLeft={resendCountDown}
/>
)
}
iconPosition="end"
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
) : null}
<div className={style.authMessage}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.message">
By clicking &quot;Continue with Google/Email&quot; above, you acknowledge that
you agree to AFFiNE&apos;s <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
</Trans>
</Trans>
</div>
</div>
</>
@@ -4,6 +4,12 @@ export const authModalContent = style({
marginTop: '30px',
});
export const captchaWrapper = style({
margin: 'auto',
marginBottom: '4px',
textAlign: 'center',
});
export const authMessage = style({
marginTop: '30px',
color: 'var(--affine-text-secondary-color)',
@@ -28,8 +34,9 @@ export const forgetPasswordButton = style({
});
export const resendWrapper = style({
height: 32,
height: 77,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
marginTop: 30,
@@ -63,7 +63,7 @@ export const useAuth = () => {
const startResendCountDown = useSetAtom(countDownAtom);
const signIn = useCallback(
async (email: string) => {
async (email: string, verifyToken: string, challenge?: string) => {
setAuthStore(prev => {
return {
...prev,
@@ -71,11 +71,20 @@ export const useAuth = () => {
};
});
const res = await signInCloud('email', {
email: email,
callbackUrl: '/auth/signIn',
redirect: false,
}).catch(console.error);
const res = await signInCloud(
'email',
{
email: email,
callbackUrl: '/auth/signIn',
redirect: false,
},
challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }
).catch(console.error);
handleSendEmailError(res, pushNotification);
@@ -93,7 +102,7 @@ export const useAuth = () => {
);
const signUp = useCallback(
async (email: string) => {
async (email: string, verifyToken: string, challenge?: string) => {
setAuthStore(prev => {
return {
...prev,
@@ -101,11 +110,20 @@ export const useAuth = () => {
};
});
const res = await signInCloud('email', {
email: email,
callbackUrl: '/auth/signUp',
redirect: false,
}).catch(console.error);
const res = await signInCloud(
'email',
{
email: email,
callbackUrl: '/auth/signUp',
redirect: false,
},
challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }
).catch(console.error);
handleSendEmailError(res, pushNotification);
@@ -0,0 +1,105 @@
import { fetchWithTraceReport } from '@affine/graphql';
import { Turnstile } from '@marsidev/react-turnstile';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react';
import useSWR from 'swr';
import * as style from './style.css';
type Challenge = {
challenge: string;
resource: string;
};
const challengeFetcher = async (url: string) => {
if (!environment.isDesktop) {
return undefined;
}
const res = await fetchWithTraceReport(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 (!environment.isDesktop) {
return undefined;
}
return await window.apis?.ui?.getChallengeResponse(challenge);
};
const captchaAtom = atom<string | undefined>(undefined);
const responseAtom = atom<string | undefined>(undefined);
export const Captcha = () => {
const setCaptcha = useSetAtom(captchaAtom);
const [response] = useAtom(responseAtom);
if (!runtimeConfig.enableCaptcha) {
return <></>;
}
if (environment.isDesktop) {
if (response) {
return <div className={style.captchaWrapper}>Making Challenge</div>;
} else {
return <div className={style.captchaWrapper}>Verified Client</div>;
}
}
return (
<Turnstile
className={style.captchaWrapper}
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
onSuccess={setCaptcha}
/>
);
};
export const useCaptcha = (): [string | undefined, string?] => {
const [verifyToken] = useAtom(captchaAtom);
const [response, setResponse] = useAtom(responseAtom);
const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, {
suspense: false,
revalidateOnFocus: false,
});
const prevChallenge = useRef('');
useEffect(() => {
if (
runtimeConfig.enableCaptcha &&
environment.isDesktop &&
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, setResponse]);
if (!runtimeConfig.enableCaptcha) {
return ['XXXX.DUMMY.TOKEN.XXXX'];
}
if (environment.isDesktop) {
if (response) {
return [response, challenge?.challenge];
} else {
return [undefined, challenge?.challenge];
}
}
return [verifyToken];
};