diff --git a/docs/pages/config/appsettings.zh.mdx b/docs/pages/config/appsettings.zh.mdx index f60d3ee3f..846f81a9c 100644 --- a/docs/pages/config/appsettings.zh.mdx +++ b/docs/pages/config/appsettings.zh.mdx @@ -198,7 +198,7 @@ GZCTF 仅支持 PostgreSQL 作为数据库,不支持 MySQL 等其他数据库 #### GoogleRecaptcha -配置 Google Recaptcha 的相关信息,用于注册时的验证码验证,可选项。 +配置 Google Recaptcha v3 的相关信息,可选项。 - **VerifyAPIAddress:** Google Recaptcha 验证 API 地址 - **RecaptchaThreshold:** Google Recaptcha 阈值,用于判断验证码是否有效 diff --git a/src/GZCTF/ClientApp/package.json b/src/GZCTF/ClientApp/package.json index 9ab8f6d3e..9b549d824 100644 --- a/src/GZCTF/ClientApp/package.json +++ b/src/GZCTF/ClientApp/package.json @@ -21,6 +21,7 @@ "@mantine/hooks": "^6.0.19", "@mantine/modals": "^6.0.19", "@mantine/notifications": "^6.0.19", + "@marsidev/react-turnstile": "^0.3.0", "@mdi/js": "^7.2.96", "@mdi/react": "^1.6.1", "@microsoft/signalr": "^7.0.10", @@ -37,6 +38,7 @@ "prismjs": "^1.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-google-recaptcha-v3": "^1.10.1", "react-pdf": "^7.3.3", "react-router": "^6.15.0", "react-router-dom": "^6.15.0", diff --git a/src/GZCTF/ClientApp/pnpm-lock.yaml b/src/GZCTF/ClientApp/pnpm-lock.yaml index 8223502f6..7eae8a3aa 100644 --- a/src/GZCTF/ClientApp/pnpm-lock.yaml +++ b/src/GZCTF/ClientApp/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: '@mantine/notifications': specifier: ^6.0.19 version: 6.0.19(@mantine/core@6.0.19)(@mantine/hooks@6.0.19)(react-dom@18.2.0)(react@18.2.0) + '@marsidev/react-turnstile': + specifier: ^0.3.0 + version: 0.3.0(react-dom@18.2.0)(react@18.2.0) '@mdi/js': specifier: ^7.2.96 version: 7.2.96 @@ -83,6 +86,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-google-recaptcha-v3: + specifier: ^1.10.1 + version: 1.10.1(react-dom@18.2.0)(react@18.2.0) react-pdf: specifier: ^7.3.3 version: 7.3.3(@types/react@18.2.21)(react-dom@18.2.0)(react@18.2.0) @@ -1069,6 +1075,16 @@ packages: dev: false optional: true + /@marsidev/react-turnstile@0.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-TSBOtQ4w+kDXVGQOpbTbkw7cuaRXxiQr6WNuPassvzEc1V+FNPYRCx2cRZ/0lSGtdClSUvsyG9prueeDQ6uhuw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@mdi/js@7.2.96: resolution: {integrity: sha512-paR9M9ZT7rKbh2boksNUynuSZMHhqRYnEZOm/KrZTjQ4/FzyhjLHuvw/8XYzP+E7fS4+/Ms/82EN1pl/OFsiIA==} dev: false @@ -3311,6 +3327,17 @@ packages: react: 18.2.0 dev: false + /react-google-recaptcha-v3@1.10.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==} + peerDependencies: + react: ^16.3 || ^17.0 || ^18.0 + react-dom: ^17.0 || ^18.0 + dependencies: + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false diff --git a/src/GZCTF/ClientApp/src/components/AccountView.tsx b/src/GZCTF/ClientApp/src/components/AccountView.tsx index 9241774fe..a619f930f 100644 --- a/src/GZCTF/ClientApp/src/components/AccountView.tsx +++ b/src/GZCTF/ClientApp/src/components/AccountView.tsx @@ -5,8 +5,8 @@ import LogoHeader from '@Components/LogoHeader' const useStyles = createStyles(() => ({ input: { - width: '25vw', - minWidth: '250px', + width: '300px', + minWidth: '300px', maxWidth: '300px', }, })) diff --git a/src/GZCTF/ClientApp/src/components/Captcha.tsx b/src/GZCTF/ClientApp/src/components/Captcha.tsx new file mode 100644 index 000000000..bc76042ba --- /dev/null +++ b/src/GZCTF/ClientApp/src/components/Captcha.tsx @@ -0,0 +1,119 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' +import { GoogleReCaptchaProvider, useGoogleReCaptcha } from 'react-google-recaptcha-v3' +import { Box, BoxProps, useMantineTheme } from '@mantine/core' +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile' +import api, { CaptchaProvider } from '@Api' + +interface CaptchaProps extends BoxProps { + action: string +} + +interface CaptchaResult { + valid: boolean + token?: string | null +} + +export interface CaptchaInstance { + getToken: () => Promise +} + +export const useCaptchaRef = () => { + const captchaRef = useRef(null) + + const getToken = async () => { + const res = await captchaRef.current?.getToken() + return res ?? { valid: false } + } + + return { captchaRef, getToken } as const +} + +const ReCaptchaBox = forwardRef((props, ref) => { + const { action, ...others } = props + const { executeRecaptcha } = useGoogleReCaptcha() + + useImperativeHandle(ref, () => ({ + getToken: async () => { + if (!executeRecaptcha) { + return { valid: false } + } + + const token = await executeRecaptcha(action) + return { valid: !!token, token } + }, + })) + + return +}) + +const Captcha = forwardRef((props, ref) => { + const { action, ...others } = props + const theme = useMantineTheme() + + const { data: info, error } = api.info.useInfoGetClientCaptchaInfo({ + refreshInterval: 0, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenHidden: false, + shouldRetryOnError: false, + refreshWhenOffline: false, + }) + + const type = info?.type ?? CaptchaProvider.None + const turnstileRef = useRef(null) + const reCaptchaRef = useRef(null) + + useImperativeHandle(ref, () => ({ + getToken: async () => { + if (error || !info) { + return { valid: false } + } + + if (!info?.siteKey || type === CaptchaProvider.None) { + return { valid: true } + } + + if (type === CaptchaProvider.GoogleRecaptcha) { + const res = await reCaptchaRef.current?.getToken() + return res ?? { valid: false } + } + + const token = turnstileRef.current?.getResponse() + return { valid: !!token, token } + }, + })) + + if (error || !info?.siteKey || type === CaptchaProvider.None) { + return + } + + if (type === CaptchaProvider.GoogleRecaptcha) { + return ( + + + + ) + } + + return ( + + + + ) +}) + +export default Captcha diff --git a/src/GZCTF/ClientApp/src/pages/account/Login.tsx b/src/GZCTF/ClientApp/src/pages/account/Login.tsx index 88306d84a..fe53958bb 100644 --- a/src/GZCTF/ClientApp/src/pages/account/Login.tsx +++ b/src/GZCTF/ClientApp/src/pages/account/Login.tsx @@ -1,20 +1,18 @@ import { FC, useEffect, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' -import { PasswordInput, Grid, TextInput, Button, Anchor, Box } from '@mantine/core' +import { PasswordInput, Grid, TextInput, Button, Anchor } from '@mantine/core' import { useInputState } from '@mantine/hooks' -import { showNotification } from '@mantine/notifications' +import { showNotification, updateNotification } from '@mantine/notifications' import { mdiCheck, mdiClose } from '@mdi/js' import { Icon } from '@mdi/react' import AccountView from '@Components/AccountView' -import { showErrorNotification } from '@Utils/ApiErrorHandler' -import { useCaptcha } from '@Utils/useCaptcha' +import Captcha, { useCaptchaRef } from '@Components/Captcha' import { usePageTitle } from '@Utils/usePageTitle' import { useUser } from '@Utils/useUser' import api from '@Api' const Login: FC = () => { const params = useSearchParams()[0] - const captcha = useCaptcha('login') const navigate = useNavigate() const [pwd, setPwd] = useInputState('') @@ -22,6 +20,7 @@ const Login: FC = () => { const [disabled, setDisabled] = useState(false) const [needRedirect, setNeedRedirect] = useState(false) + const { captchaRef, getToken } = useCaptchaRef() const { user, mutate } = useUser() usePageTitle('登录') @@ -51,28 +50,54 @@ const Login: FC = () => { return } - const token = await captcha?.getChallenge() + const { valid, token } = await getToken() - api.account - .accountLogIn({ + if (!valid) { + showNotification({ + color: 'orange', + title: '请等待验证码……', + message: '请稍后重试', + loading: true, + }) + return + } + + showNotification({ + color: 'orange', + id: 'login-status', + title: '请求已发送……', + message: '等待服务器验证', + loading: true, + autoClose: false, + }) + + try { + await api.account.accountLogIn({ userName: uname, password: pwd, challenge: token, }) - .then(() => { - showNotification({ - color: 'teal', - title: '登录成功', - message: '跳转回登录前页面', - icon: , - }) - setNeedRedirect(true) - mutate() + + updateNotification({ + id: 'login-status', + color: 'teal', + title: '登录成功', + message: '跳转回登录前页面', + icon: , }) - .catch((err) => { - showErrorNotification(err) - setDisabled(false) + setNeedRedirect(true) + mutate() + } catch (err: any) { + updateNotification({ + id: 'login-status', + color: 'red', + title: '遇到了问题', + message: `${err.response.data.title}`, + icon: , }) + } finally { + setDisabled(false) + } } return ( @@ -97,7 +122,7 @@ const Login: FC = () => { disabled={disabled} onChange={(event) => setPwd(event.currentTarget.value)} /> - + ({ fontSize: theme.fontSizes.xs, diff --git a/src/GZCTF/ClientApp/src/pages/account/Recovery.tsx b/src/GZCTF/ClientApp/src/pages/account/Recovery.tsx index 3483e37d0..f8844dcc4 100644 --- a/src/GZCTF/ClientApp/src/pages/account/Recovery.tsx +++ b/src/GZCTF/ClientApp/src/pages/account/Recovery.tsx @@ -1,28 +1,28 @@ import { FC, useState } from 'react' import { Link } from 'react-router-dom' -import { TextInput, Button, Anchor, Box } from '@mantine/core' +import { TextInput, Button, Anchor } from '@mantine/core' import { useInputState } from '@mantine/hooks' import { showNotification, updateNotification } from '@mantine/notifications' import { mdiCheck, mdiClose } from '@mdi/js' import { Icon } from '@mdi/react' import AccountView from '@Components/AccountView' -import { useCaptcha } from '@Utils/useCaptcha' +import Captcha, { useCaptchaRef } from '@Components/Captcha' import { usePageTitle } from '@Utils/usePageTitle' import api from '@Api' const Recovery: FC = () => { const [email, setEmail] = useInputState('') - const captcha = useCaptcha('recovery') const [disabled, setDisabled] = useState(false) + const { captchaRef, getToken } = useCaptchaRef() usePageTitle('找回账号') const onRecovery = async (event: React.FormEvent) => { event.preventDefault() - const token = await captcha?.getChallenge() + const { valid, token } = await getToken() - if (!token) { + if (!valid) { showNotification({ color: 'orange', title: '请等待验证码……', @@ -43,32 +43,30 @@ const Recovery: FC = () => { autoClose: false, }) - api.account - .accountRecovery({ + try { + await api.account.accountRecovery({ email, challenge: token, }) - .then(() => { - updateNotification({ - id: 'recovery-status', - color: 'teal', - title: '一封恢复邮件已发送', - message: '请检查你的邮箱及垃圾邮件~', - icon: , - }) - }) - .catch((err) => { - updateNotification({ - id: 'recovery-status', - color: 'red', - title: '遇到了问题', - message: `${err.response.data.title}`, - icon: , - }) + + updateNotification({ + id: 'recovery-status', + color: 'teal', + title: '一封恢复邮件已发送', + message: '请检查你的邮箱及垃圾邮件~', + icon: , }) - .finally(() => { - setDisabled(false) + } catch (err: any) { + updateNotification({ + id: 'recovery-status', + color: 'red', + title: '遇到了问题', + message: `${err.response.data.title}`, + icon: , }) + } finally { + setDisabled(false) + } } return ( @@ -83,7 +81,7 @@ const Recovery: FC = () => { disabled={disabled} onChange={(event) => setEmail(event.currentTarget.value)} /> - + ({ fontSize: theme.fontSizes.xs, diff --git a/src/GZCTF/ClientApp/src/pages/account/Register.tsx b/src/GZCTF/ClientApp/src/pages/account/Register.tsx index d16ac96bf..5acbe52a4 100644 --- a/src/GZCTF/ClientApp/src/pages/account/Register.tsx +++ b/src/GZCTF/ClientApp/src/pages/account/Register.tsx @@ -1,13 +1,13 @@ import { FC, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { Button, Anchor, TextInput, PasswordInput, Box } from '@mantine/core' +import { Button, Anchor, TextInput, PasswordInput } from '@mantine/core' import { useInputState } from '@mantine/hooks' import { showNotification, updateNotification } from '@mantine/notifications' import { mdiCheck, mdiClose } from '@mdi/js' import { Icon } from '@mdi/react' import AccountView from '@Components/AccountView' +import Captcha, { useCaptchaRef } from '@Components/Captcha' import StrengthPasswordInput from '@Components/StrengthPasswordInput' -import { useCaptcha } from '@Utils/useCaptcha' import { usePageTitle } from '@Utils/usePageTitle' import api, { RegisterStatus } from '@Api' @@ -43,7 +43,7 @@ const Register: FC = () => { const [disabled, setDisabled] = useState(false) const navigate = useNavigate() - const captcha = useCaptcha('register') + const { captchaRef, getToken } = useCaptchaRef() usePageTitle('注册') @@ -60,9 +60,9 @@ const Register: FC = () => { return } - const token = await captcha?.getChallenge() + const { valid, token } = await getToken() - if (!token) { + if (!valid) { showNotification({ color: 'orange', title: '请等待验证码……', @@ -83,40 +83,37 @@ const Register: FC = () => { autoClose: false, }) - api.account - .accountRegister({ + try { + const res = await api.account.accountRegister({ userName: uname, password: pwd, email: email, challenge: token, }) - .then((res) => { - const data = RegisterStatusMap.get(res.data.data) - if (data) { - updateNotification({ - id: 'register-status', - color: 'teal', - title: data.title, - message: data.message, - icon: , - }) - - if (res.data.data === RegisterStatus.LoggedIn) navigate('/') - else navigate('/account/login') - } - }) - .catch((err) => { + const data = RegisterStatusMap.get(res.data.data) + if (data) { updateNotification({ id: 'register-status', - color: 'red', - title: '遇到了问题', - message: `${err.response.data.title}`, - icon: , + color: 'teal', + title: data.title, + message: data.message, + icon: , }) + + if (res.data.data === RegisterStatus.LoggedIn) navigate('/') + else navigate('/account/login') + } + } catch (err: any) { + updateNotification({ + id: 'register-status', + color: 'red', + title: '遇到了问题', + message: `${err.response.data.title}`, + icon: , }) - .finally(() => { - setDisabled(false) - }) + } finally { + setDisabled(false) + } } return ( @@ -155,7 +152,7 @@ const Register: FC = () => { w="100%" error={pwd !== retypedPwd} /> - + ({ fontSize: theme.fontSizes.xs, diff --git a/src/GZCTF/ClientApp/src/utils/useCaptcha.ts b/src/GZCTF/ClientApp/src/utils/useCaptcha.ts deleted file mode 100644 index 76b3b9939..000000000 --- a/src/GZCTF/ClientApp/src/utils/useCaptcha.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useState } from 'react' -import { useMantineTheme } from '@mantine/core' -import api, { CaptchaProvider } from '@Api' - -declare global { - interface Window { - turnstile: any - grecaptcha: any - onloadTurnstileCallback: any - } -} - -interface Captcha { - siteKey: string | null - action: string - getChallenge(): Promise -} - -export class reCAPTCHA implements Captcha { - siteKey: string | null - action: string - - static removeCaptcha() { - const script = document.getElementById('grecaptcha-script') - if (script) { - script.remove() - } - - const recaptchaElems = document.getElementsByClassName('grecaptcha-badge') - if (recaptchaElems.length) { - recaptchaElems[0].remove() - } - } - - async getChallenge(): Promise { - if (this.siteKey === null) return null - - let token = '' - await window.grecaptcha.execute(this.siteKey, { action: this.action }).then((res: string) => { - token = res - }) - return token.length > 0 ? token : null - } - - constructor(siteKey: string, action: string) { - this.loadReCaptcha(siteKey) - this.siteKey = siteKey - this.action = action - } - - public loadReCaptcha(siteKey: string) { - if (!siteKey || siteKey === 'NOTOKEN') return - const script = document.createElement('script') - script.id = 'grecaptcha-script' - script.src = `https://www.recaptcha.net/recaptcha/api.js?render=${siteKey}` - document.body.appendChild(script) - } -} - -export class Turnstile implements Captcha { - siteKey: string | null - action: string - challenge: string | null - - static removeCaptcha() { - const script = document.getElementById('turnstile-script') - if (script) { - script.remove() - } - } - - async getChallenge(): Promise { - if (this.siteKey === null) return null - - return this.challenge - } - - public loadTurnstile(siteKey: string, action: string, theme: string = 'light') { - if (!siteKey) return - - const script = document.createElement('script') - script.id = 'turnstile-script' - script.src = - 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback' - script.async = true - script.defer = true - document.body.appendChild(script) - - window.onloadTurnstileCallback = () => { - window.turnstile.render('#captcha', { - sitekey: siteKey, - theme: theme, - action: action, - callback: (token: string) => (this.challenge = token), - }) - } - } - - constructor(siteKey: string, action: string, theme: string = 'light') { - this.loadTurnstile(siteKey, action, theme) - this.siteKey = siteKey - this.action = action - this.challenge = null - } -} - -export const useCaptcha = (action: string) => { - const { data: info, error } = api.info.useInfoGetClientCaptchaInfo({ - refreshInterval: 0, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenHidden: false, - shouldRetryOnError: false, - refreshWhenOffline: false, - }) - - const theme = useMantineTheme() - const [reCaptcha, setReCaptcha] = useState(null) - - useEffect(() => { - if (info?.type === CaptchaProvider.GoogleRecaptcha) { - setReCaptcha(info?.siteKey && !error ? new reCAPTCHA(info.siteKey, action) : null) - return reCAPTCHA.removeCaptcha - } else if (info?.type === CaptchaProvider.CloudflareTurnstile) { - setReCaptcha( - info?.siteKey && !error ? new Turnstile(info.siteKey, action, theme.colorScheme) : null - ) - return Turnstile.removeCaptcha - } - }, [info, error, action]) - - return reCaptcha -} diff --git a/src/GZCTF/Extensions/CaptchaExtension.cs b/src/GZCTF/Extensions/CaptchaExtension.cs index 65a04e0f1..76352587a 100644 --- a/src/GZCTF/Extensions/CaptchaExtension.cs +++ b/src/GZCTF/Extensions/CaptchaExtension.cs @@ -55,7 +55,7 @@ public override async Task VerifyAsync(ModelWithCaptcha model, HttpContext var ip = context.Connection.RemoteIpAddress; var api = _config.GoogleRecaptcha.VerifyAPIAddress; - var result = await _httpClient.GetAsync($"{api}?secret={_config.SecretKey}&response={token}&remoteip={ip}", token); + var result = await _httpClient.GetAsync($"{api}?secret={_config.SecretKey}&response={model.Challenge}&remoteip={ip}", token); var res = await result.Content.ReadFromJsonAsync(cancellationToken: token); return res is not null && res.Success && res.Score >= _config.GoogleRecaptcha.RecaptchaThreshold; diff --git a/src/GZCTF/GZCTF.csproj b/src/GZCTF/GZCTF.csproj index 828649ddd..bdbd04e2d 100644 --- a/src/GZCTF/GZCTF.csproj +++ b/src/GZCTF/GZCTF.csproj @@ -52,7 +52,7 @@ - + diff --git a/src/GZCTF/Properties/launchSettings.json b/src/GZCTF/Properties/launchSettings.json index 0ffdd6386..f58773ceb 100644 --- a/src/GZCTF/Properties/launchSettings.json +++ b/src/GZCTF/Properties/launchSettings.json @@ -3,10 +3,11 @@ "GZCTF": { "commandName": "Project", "launchBrowser": true, - "applicationUrl": "http://localhost:20461", + "applicationUrl": "http://localhost:55000", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" } } } -} \ No newline at end of file +}