diff --git a/docs/content/rctf/configuration.md b/docs/content/rctf/configuration.md index 502f264..b39b8f7 100644 --- a/docs/content/rctf/configuration.md +++ b/docs/content/rctf/configuration.md @@ -60,15 +60,18 @@ Important values to configure to customize your CTF. Optional configuration to enable additional features. -| YAML/JSON name | environment name | required | default value | type | description | -| ---------------------- | ---------------------------- | -------- | ------------- | -------- | ------------------------------------------------------------------------------ | -| `sponsors` | _(none)_ | yes | `[]` | array | list of CTF sponsors. [documentation](management/home.md) | -| `globalSiteTag` | `RCTF_GLOBAL_SITE_TAG` | no | _(none)_ | string | Google Analytics site tag | -| `email.provider` | _(none)_ | no | _(none)_ | provider | provider for email sending. [documentation](providers/emails/index.md) | -| `email.from` | _(none)_ | no | _(none)_ | provider | `from:` address when sending email. [documentation](providers/emails/index.md) | -| `email.logoUrl` | `RCTF_EMAIL_LOGO_URL` | no | _(none)_ | string | URL to raster image of the CTF's logo | -| `ctftime.clientId` | `RCTF_CTFTIME_CLIENT_ID` | no | _(none)_ | string | CTFtime OAuth client ID. [documentation](integrations/ctftime.md) | -| `ctftime.clientSecret` | `RCTF_CTFTIME_CLIENT_SECRET` | no | _(none)_ | string | CTFtime OAuth client secret. [documentation](integrations/ctftime.md) | +| YAML/JSON name | environment name | required | default value | type | description | +| ---------------------------- | ---------------------------------- | -------- | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `sponsors` | _(none)_ | yes | `[]` | array | list of CTF sponsors. [documentation](management/home.md) | +| `globalSiteTag` | `RCTF_GLOBAL_SITE_TAG` | no | _(none)_ | string | Google Analytics site tag | +| `email.provider` | _(none)_ | no | _(none)_ | provider | provider for email sending. [documentation](providers/emails/index.md) | +| `email.from` | _(none)_ | no | _(none)_ | provider | `from:` address when sending email. [documentation](providers/emails/index.md) | +| `email.logoUrl` | `RCTF_EMAIL_LOGO_URL` | no | _(none)_ | string | URL to raster image of the CTF's logo | +| `ctftime.clientId` | `RCTF_CTFTIME_CLIENT_ID` | no | _(none)_ | string | CTFtime OAuth client ID. [documentation](integrations/ctftime.md) | +| `ctftime.clientSecret` | `RCTF_CTFTIME_CLIENT_SECRET` | no | _(none)_ | string | CTFtime OAuth client secret. [documentation](integrations/ctftime.md) | +| `recaptcha.siteKey` | `RCTF_RECAPTCHA_SITE_KEY` | no | _(none)_ | string | reCAPTCHA public site key. [documentation](integrations/recaptcha.md) | +| `recaptcha.secretKey` | `RCTF_RECAPTCHA_SECRET_KEY` | no | _(none)_ | string | reCAPTCHA secret key. [documentation](integrations/recaptcha.md) | +| `recaptcha.protectedActions` | `RCTF_RECAPTCHA_PROTECTED_ACTIONS` | no | _(none)_ | array | list of actions protected by reCAPTCHA (`register`, `recover`, `setEmail`). [documentation](integrations/recaptcha.md) | ### Advanced diff --git a/docs/content/rctf/integrations/recaptcha.md b/docs/content/rctf/integrations/recaptcha.md new file mode 100644 index 0000000..94af876 --- /dev/null +++ b/docs/content/rctf/integrations/recaptcha.md @@ -0,0 +1,25 @@ +# reCAPTCHA + +To protect sensitive actions from abuse, configure Google reCAPTCHA v2 (invisible). +You can [register for credentials here](https://www.google.com/recaptcha/admin/create). + +Copy the values from Google into `recaptcha.siteKey` and `recaptcha.secretKey` (or +`RCTF_RECAPTCHA_SITE_KEY` and `RCTF_RECAPTCHA_SECRET_KEY`). + +You must also set `recaptcha.protectedActions` to a list of actions that require a +token. Valid values are: + +- `register` +- `recover` +- `setEmail` + +Example configuration to protect registration and auth recovery endpoints: + +```yaml +recaptcha: + siteKey: AAA + secretKey: BBB + protectedActions: + - register + - recover +``` diff --git a/packages/api-types/src/responses/badRecaptchaCode.yml b/packages/api-types/src/responses/badRecaptchaCode.yml new file mode 100644 index 0000000..923ee93 --- /dev/null +++ b/packages/api-types/src/responses/badRecaptchaCode.yml @@ -0,0 +1,2 @@ +status: 401 +message: The recaptcha code is invalid. diff --git a/packages/api-types/src/routes/auth/recover/post.yml b/packages/api-types/src/routes/auth/recover/post.yml index 3d530a5..c65d32e 100644 --- a/packages/api-types/src/routes/auth/recover/post.yml +++ b/packages/api-types/src/routes/auth/recover/post.yml @@ -7,6 +7,8 @@ schema: properties: email: type: string + recaptchaCode: + type: string required: - email responses: @@ -14,3 +16,4 @@ responses: - badEndpoint - badEmail - badUnknownEmail + - badRecaptchaCode diff --git a/packages/api-types/src/routes/auth/register/post.yml b/packages/api-types/src/routes/auth/register/post.yml index 926476a..3778881 100644 --- a/packages/api-types/src/routes/auth/register/post.yml +++ b/packages/api-types/src/routes/auth/register/post.yml @@ -11,6 +11,8 @@ schema: type: string ctftimeToken: type: string + recaptchaCode: + type: string required: - name oneOf: @@ -29,3 +31,4 @@ responses: - badKnownEmail - badKnownName - badRegistrationsDisabled + - badRecaptchaCode diff --git a/packages/api-types/src/routes/users/me/auth/email/put.yml b/packages/api-types/src/routes/users/me/auth/email/put.yml index 85ee197..4838283 100644 --- a/packages/api-types/src/routes/users/me/auth/email/put.yml +++ b/packages/api-types/src/routes/users/me/auth/email/put.yml @@ -7,6 +7,8 @@ schema: properties: email: type: string + recaptchaCode: + type: string required: - email responses: @@ -16,3 +18,4 @@ responses: - badKnownEmail - badEmailChangeDivision - badUnknownUser + - badRecaptchaCode diff --git a/packages/client/src/components/recaptcha.js b/packages/client/src/components/recaptcha.js index 478040f..6c01a51 100644 --- a/packages/client/src/components/recaptcha.js +++ b/packages/client/src/components/recaptcha.js @@ -16,6 +16,17 @@ const loadRecaptchaScript = () => const recaptchaQueue = [] let recaptchaPromise let recaptchaId +let recaptchaContainer +const getRecaptchaContainer = () => { + if (recaptchaContainer) { + return recaptchaContainer + } + const container = document.createElement('div') + container.style.display = 'none' + document.body.appendChild(container) + recaptchaContainer = container + return container +} const handleRecaptchaNext = async () => { if (recaptchaQueue.length === 0) { return @@ -38,8 +49,9 @@ const loadRecaptcha = async () => { recaptchaPromise = recaptchaPromise ?? loadRecaptchaScript() recaptchaId = recaptchaId ?? - (await recaptchaPromise).render({ + (await recaptchaPromise).render(getRecaptchaContainer(), { theme: 'dark', + size: 'invisible', sitekey: config.recaptcha.siteKey, callback: handleRecaptchaDone, 'error-callback': handleRecaptchaError, diff --git a/packages/client/src/routes/register.js b/packages/client/src/routes/register.js index 718e2d4..e3760f9 100644 --- a/packages/client/src/routes/register.js +++ b/packages/client/src/routes/register.js @@ -17,9 +17,6 @@ import { RecaptchaLegalNotice, } from '../components/recaptcha' -// legacy check for class components -const recaptchaEnabled = config.recaptcha?.protectedActions.includes('register') - export default withStyles( { root: { @@ -47,6 +44,9 @@ export default withStyles( }, }, class Register extends Component { + recaptchaEnabled = () => + config.recaptcha?.protectedActions?.includes('register') + state = { name: '', email: '', @@ -59,7 +59,7 @@ export default withStyles( componentDidMount() { document.title = `Registration | ${config.ctfName}` - if (recaptchaEnabled) { + if (this.recaptchaEnabled()) { loadRecaptcha() } } @@ -76,6 +76,7 @@ export default withStyles( verifySent, } ) { + const recaptchaEnabled = this.recaptchaEnabled() if (ctftimeToken) { return ( { e.preventDefault() - const recaptchaCode = recaptchaEnabled + const recaptchaCode = this.recaptchaEnabled() ? await requestRecaptchaCode() : undefined diff --git a/packages/server/src/api/auth/recover.ts b/packages/server/src/api/auth/recover.ts index 2bafa50..9e6832b 100644 --- a/packages/server/src/api/auth/recover.ts +++ b/packages/server/src/api/auth/recover.ts @@ -8,12 +8,27 @@ import { getToken, tokenKinds } from '../../auth/token' import { getUserByEmail } from '../../database/users' import config from '../../config/server' import { sendVerification } from '../../email' +import { + checkProtectedAction, + RecaptchaProtectedActions, + verifyRecaptchaCode, +} from '../../util/recaptcha' + +const recaptchaEnabled = checkProtectedAction(RecaptchaProtectedActions.recover) export default makeFastifyRoute(authRecoverPost, async ({ req, res }) => { if (!config.email) { return res.badEndpoint() } + if ( + recaptchaEnabled && + (!req.body.recaptchaCode || + !(await verifyRecaptchaCode(req.body.recaptchaCode))) + ) { + return res.badRecaptchaCode() + } + const email = normalizeEmail(req.body.email) if (!emailValidator.validate(email)) { return res.badEmail() diff --git a/packages/server/src/api/auth/register.js b/packages/server/src/api/auth/register.js index fca3cd4..3235092 100644 --- a/packages/server/src/api/auth/register.js +++ b/packages/server/src/api/auth/register.js @@ -9,11 +9,23 @@ import config from '../../config/server' import { getUserByNameOrEmail } from '../../database/users' import { sendVerification } from '../../email' +const recaptchaEnabled = util.recaptcha.checkProtectedAction( + util.recaptcha.RecaptchaProtectedActions.register +) + export default makeFastifyRoute(authRegisterPost, async ({ req, res }) => { if (!config.registrationsEnabled) { return res.badRegistrationsDisabled() } + if ( + recaptchaEnabled && + (!req.body.recaptchaCode || + !(await util.recaptcha.verifyRecaptchaCode(req.body.recaptchaCode))) + ) { + return res.badRecaptchaCode() + } + let email let ctftimeId if (req.body.ctftimeToken !== undefined) { diff --git a/packages/server/src/api/users/me-auth/email/put.ts b/packages/server/src/api/users/me-auth/email/put.ts index c3c7e53..8628c3e 100644 --- a/packages/server/src/api/users/me-auth/email/put.ts +++ b/packages/server/src/api/users/me-auth/email/put.ts @@ -9,10 +9,27 @@ import { divisionAllowed } from '../../../../util/restrict' import { getToken, tokenKinds } from '../../../../auth/token' import { getUserByEmail, updateUser } from '../../../../database/users' import { sendVerification } from '../../../../email' +import { + checkProtectedAction, + RecaptchaProtectedActions, + verifyRecaptchaCode, +} from '../../../../util/recaptcha' + +const recaptchaEnabled = checkProtectedAction( + RecaptchaProtectedActions.setEmail +) export default makeFastifyRoute( usersMeAuthEmailPut, async ({ req, user, res }) => { + if ( + recaptchaEnabled && + (!req.body.recaptchaCode || + !(await verifyRecaptchaCode(req.body.recaptchaCode))) + ) { + return res.badRecaptchaCode() + } + const email = normalizeEmail(req.body.email) if (!emailValidator.validate(email)) { return res.badEmail() diff --git a/packages/server/src/config/client.ts b/packages/server/src/config/client.ts index e94545a..60db5a5 100644 --- a/packages/server/src/config/client.ts +++ b/packages/server/src/config/client.ts @@ -22,6 +22,13 @@ const config: ClientConfig = { : { clientId: server.ctftime.clientId, }, + recaptcha: + server.recaptcha == null + ? undefined + : { + siteKey: server.recaptcha.siteKey, + protectedActions: server.recaptcha.protectedActions, + }, } export default config diff --git a/packages/server/src/config/load.ts b/packages/server/src/config/load.ts index f87f471..6db645b 100644 --- a/packages/server/src/config/load.ts +++ b/packages/server/src/config/load.ts @@ -6,6 +6,7 @@ import { removeUndefined } from '../util/object' import path from 'path' import fs from 'fs' import yaml from 'yaml' +import type { RecaptchaProtectedActions } from '../util/recaptcha' const isRoot = (p: string): boolean => { const parsed = path.parse(p) @@ -94,6 +95,15 @@ export const loadEnvConfig = (): PartialDeep => clientId: process.env.RCTF_CTFTIME_CLIENT_ID, clientSecret: process.env.RCTF_CTFTIME_CLIENT_SECRET, }, + recaptcha: { + siteKey: process.env.RCTF_RECAPTCHA_SITE_KEY, + secretKey: process.env.RCTF_RECAPTCHA_SECRET_KEY, + protectedActions: (process.env.RCTF_RECAPTCHA_PROTECTED_ACTIONS?.split( + ',' + ) + .map(s => s.trim()) + .filter(Boolean) ?? undefined) as RecaptchaProtectedActions[], + }, userMembers: nullsafeParseBoolEnv(process.env.RCTF_USER_MEMBERS), homeContent: process.env.RCTF_HOME_CONTENT, ctfName: process.env.RCTF_NAME, diff --git a/packages/server/src/config/types.ts b/packages/server/src/config/types.ts index 75f13d7..e3ed632 100644 --- a/packages/server/src/config/types.ts +++ b/packages/server/src/config/types.ts @@ -1,3 +1,4 @@ +import { RecaptchaProtectedActions } from '../util/recaptcha' import { ACL } from '../util/restrict' export interface ProviderConfig { @@ -48,6 +49,12 @@ export interface ServerConfig { clientSecret: string } + recaptcha?: { + siteKey: string + secretKey: string + protectedActions: RecaptchaProtectedActions[] + } + userMembers: boolean registrationsEnabled: boolean @@ -105,4 +112,8 @@ export type ClientConfig = Pick< > & { emailEnabled: boolean ctftime?: Pick, 'clientId'> + recaptcha?: Pick< + NonNullable, + 'siteKey' | 'protectedActions' + > } diff --git a/packages/server/src/util/index.ts b/packages/server/src/util/index.ts index 66fddc5..80c7444 100644 --- a/packages/server/src/util/index.ts +++ b/packages/server/src/util/index.ts @@ -8,6 +8,7 @@ export * as normalize from './normalize' export * as validate from './validate' export * as scores from './scores' export * as restrict from './restrict' +export * as recaptcha from './recaptcha' export const serveIndex: FastifyPluginAsync<{ indexPath: string }> = async ( fastify, diff --git a/packages/server/src/util/recaptcha.ts b/packages/server/src/util/recaptcha.ts new file mode 100644 index 0000000..4c9285e --- /dev/null +++ b/packages/server/src/util/recaptcha.ts @@ -0,0 +1,30 @@ +import got from 'got' +import config from '../config/server' + +export enum RecaptchaProtectedActions { + register = 'register', + recover = 'recover', + setEmail = 'setEmail', +} + +export const verifyRecaptchaCode = async (code: string): Promise => { + if (!config.recaptcha) { + throw new Error('recaptcha is not configured') + } + const { body }: { body: { success: boolean } } = await got({ + url: 'https://www.google.com/recaptcha/api/siteverify', + method: 'POST', + responseType: 'json', + form: { + secret: config.recaptcha.secretKey, + response: code, + }, + }) + return body.success +} + +export const checkProtectedAction = ( + action: RecaptchaProtectedActions +): boolean => { + return config.recaptcha?.protectedActions.includes(action) ?? false +}