mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +08:00
feat: add captcha support for sign in/up (#4582)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "Continue with Google/Email" above, you acknowledge that
|
||||
you agree to AFFiNE'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];
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mintChallengeResponse } from '@affine/native';
|
||||
|
||||
export const getChallengeResponse = async (resource: string) => {
|
||||
// 20 bits challenge is a balance between security and user experience
|
||||
// 20 bits challenge cost time is about 1-3s on m2 macbook air
|
||||
return mintChallengeResponse(resource, 20);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { getLinkPreview } from 'link-preview-js';
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { getChallengeResponse } from './challenge';
|
||||
import { getGoogleOauthCode } from './google-auth';
|
||||
|
||||
export const uiHandlers = {
|
||||
@@ -45,6 +46,9 @@ export const uiHandlers = {
|
||||
getGoogleOauthCode: async () => {
|
||||
return getGoogleOauthCode();
|
||||
},
|
||||
getChallengeResponse: async (_, challenge: string) => {
|
||||
return getChallengeResponse(challenge);
|
||||
},
|
||||
getBookmarkDataByLink: async (_, link: string) => {
|
||||
if (
|
||||
(link.startsWith('https://x.com/') ||
|
||||
|
||||
@@ -3,4 +3,4 @@ NEXTAUTH_URL="http://localhost:8080"
|
||||
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
|
||||
OAUTH_EMAIL_LOGIN=""
|
||||
OAUTH_EMAIL_PASSWORD=""
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
@@ -320,6 +320,27 @@ export interface AFFiNEConfig {
|
||||
sender: string;
|
||||
password: string;
|
||||
};
|
||||
captcha: {
|
||||
/**
|
||||
* whether to enable captcha
|
||||
*/
|
||||
enable: boolean;
|
||||
turnstile: {
|
||||
/**
|
||||
* Cloudflare Turnstile CAPTCHA secret
|
||||
* default value is demo api key, witch always return success
|
||||
*/
|
||||
secret: string;
|
||||
};
|
||||
challenge: {
|
||||
/**
|
||||
* challenge bits length
|
||||
* default value is 20, which can resolve in 0.5-3 second in M2 MacBook Air in single thread
|
||||
* @default 20
|
||||
*/
|
||||
bits: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
doc: {
|
||||
|
||||
@@ -62,6 +62,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
|
||||
R2_OBJECT_STORAGE_SECRET_ACCESS_KEY: 'objectStorage.r2.secretAccessKey',
|
||||
R2_OBJECT_STORAGE_BUCKET: 'objectStorage.r2.bucket',
|
||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
|
||||
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
|
||||
@@ -147,6 +149,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||
leeway: 60,
|
||||
captcha: {
|
||||
enable: false,
|
||||
turnstile: {
|
||||
secret: '1x0000000000000000000000000000000AA',
|
||||
},
|
||||
challenge: {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
privateKey: jwtKeyPair.privateKey,
|
||||
publicKey: jwtKeyPair.publicKey,
|
||||
enableSignup: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
All,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Logger,
|
||||
Next,
|
||||
@@ -17,12 +18,14 @@ import { hash, verify } from '@node-rs/argon2';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { pick } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { Metrics } from '../../metrics/metrics';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { SessionService } from '../../session';
|
||||
import { AuthThrottlerGuard, Throttle } from '../../throttler';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
@@ -43,12 +46,28 @@ export class NextAuthController {
|
||||
private readonly authService: AuthService,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly metrics: Metrics
|
||||
private readonly metrics: Metrics,
|
||||
private readonly session: SessionService
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 60,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Get('/challenge')
|
||||
async getChallenge(@Res() res: Response) {
|
||||
const challenge = nanoid();
|
||||
const resource = nanoid();
|
||||
await this.session.set(challenge, resource, 5 * 60 * 1000);
|
||||
res.json({ challenge, resource });
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
@@ -128,6 +147,16 @@ export class NextAuthController {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = this.callbackSession;
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.auth.captcha.enable &&
|
||||
req.method === 'POST' &&
|
||||
action === 'signin'
|
||||
) {
|
||||
const isVerified = await this.verifyChallenge(req, res);
|
||||
if (!isVerified) return;
|
||||
}
|
||||
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
@@ -270,6 +299,44 @@ export class NextAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
|
||||
const challenge = req.query?.challenge;
|
||||
if (typeof challenge === 'string' && challenge) {
|
||||
const resource = await this.session.get(challenge);
|
||||
|
||||
if (!resource) {
|
||||
this.rejectResponse(res, 'Invalid Challenge');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChallengeVerified =
|
||||
await this.authService.verifyChallengeResponse(
|
||||
req.query?.token,
|
||||
resource
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
|
||||
);
|
||||
|
||||
if (!isChallengeVerified) {
|
||||
this.rejectResponse(res, 'Invalid Challenge Response');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const isTokenVerified = await this.authService.verifyCaptchaToken(
|
||||
req.query?.token,
|
||||
req.headers['CF-Connecting-IP'] as string
|
||||
);
|
||||
|
||||
if (!isTokenVerified) {
|
||||
this.rejectResponse(res, 'Invalid Captcha Response');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async verifyUserFromRequest(req: Request): Promise<User> {
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
@@ -311,6 +378,18 @@ export class NextAuthController {
|
||||
}
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
|
||||
rejectResponse(res: Response, error: string, status = 400) {
|
||||
res.status(status);
|
||||
res.json({
|
||||
url: `https://${this.config.baseUrl}/api/auth/error?${new URLSearchParams(
|
||||
{
|
||||
error,
|
||||
}
|
||||
).toString()}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkUrlOrigin = (url: string, origin: string) => {
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import type { User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { verifyChallengeResponse } from '../../storage';
|
||||
import { MailService } from './mailer';
|
||||
|
||||
export type UserClaim = Pick<
|
||||
@@ -110,6 +112,38 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCaptchaToken(token: any, ip: string) {
|
||||
if (typeof token !== 'string' || !token) return false;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('secret', this.config.auth.captcha.turnstile.secret);
|
||||
formData.append('response', token);
|
||||
formData.append('remoteip', ip);
|
||||
// prevent replay attack
|
||||
formData.append('idempotency_key', nanoid());
|
||||
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome = await result.json();
|
||||
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(this.config.affineEnv === 'dev' || outcome.hostname === this.config.host)
|
||||
);
|
||||
}
|
||||
|
||||
async verifyChallengeResponse(response: any, resource: string) {
|
||||
return verifyChallengeResponse(
|
||||
response,
|
||||
this.config.auth.captcha.challenge.bits,
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -34,3 +34,17 @@ export class StorageModule {
|
||||
}
|
||||
|
||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||
|
||||
export const verifyChallengeResponse = async (
|
||||
response: any,
|
||||
bits: number,
|
||||
resource: string
|
||||
) => {
|
||||
if (typeof response !== 'string' || !response || !resource) return false;
|
||||
return storageModule.verifyChallengeResponse(response, bits, resource);
|
||||
};
|
||||
|
||||
export const mintChallengeResponse = async (resource: string, bits: number) => {
|
||||
if (!resource) return null;
|
||||
return storageModule.mintChallengeResponse(resource, bits);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuthModule } from '../src/modules/auth';
|
||||
import { AuthResolver } from '../src/modules/auth/resolver';
|
||||
import { AuthService } from '../src/modules/auth/service';
|
||||
import { PrismaModule } from '../src/prisma';
|
||||
import { mintChallengeResponse, verifyChallengeResponse } from '../src/storage';
|
||||
import { RateLimiterModule } from '../src/throttler';
|
||||
|
||||
let authService: AuthService;
|
||||
@@ -176,3 +177,10 @@ test('should return valid sessionToken if request headers valid', async t => {
|
||||
);
|
||||
t.is(result.sessionToken, '123456');
|
||||
});
|
||||
|
||||
test('verify challenge', async t => {
|
||||
const resource = 'xp8D3rcXV9bMhWrb6abxl';
|
||||
const response = await mintChallengeResponse(resource, 20);
|
||||
const success = await verifyChallengeResponse(response, 20, resource);
|
||||
t.true(success);
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ export default {
|
||||
process.env.SHOULD_REPORT_TRACE === 'true'
|
||||
)}`,
|
||||
'process.env.TRACE_REPORT_ENDPOINT': `"${process.env.TRACE_REPORT_ENDPOINT}"`,
|
||||
'process.env.CAPTCHA_SITE_KEY': `"${process.env.CAPTCHA_SITE_KEY}"`,
|
||||
runtimeConfig: getRuntimeConfig({
|
||||
distribution: 'browser',
|
||||
mode: 'development',
|
||||
|
||||
Reference in New Issue
Block a user