From 1309367884197f4f4d94686fddfbd99fa20262bc Mon Sep 17 00:00:00 2001 From: CyberRex Date: Thu, 13 Oct 2022 09:19:57 +0900 Subject: [PATCH] Add Cloudflare Turnstile CAPTCHA support (#9111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Cloudflare Turnstile CAPTCHA support * Update packages/client/src/components/MkCaptcha.vue Co-authored-by: Acid Chicken (硫酸鶏) Co-authored-by: Acid Chicken (硫酸鶏) --- locales/ja-JP.yml | 4 ++++ .../migration/1664694635394-turnstile.js | 15 ++++++++++++ packages/backend/src/core/CaptchaService.ts | 11 +++++++++ packages/backend/src/models/entities/Meta.ts | 17 +++++++++++++ .../src/server/api/SignupApiService.ts | 6 +++++ .../src/server/api/endpoints/admin/meta.ts | 15 ++++++++++++ .../server/api/endpoints/admin/update-meta.ts | 15 ++++++++++++ .../backend/src/server/api/endpoints/meta.ts | 11 +++++++++ packages/client/src/components/MkCaptcha.vue | 4 +++- packages/client/src/components/MkSignup.vue | 6 +++++ .../client/src/pages/admin/bot-protection.vue | 24 ++++++++++++++++++- packages/client/src/pages/admin/index.vue | 2 +- packages/client/src/pages/admin/security.vue | 3 +++ 13 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 packages/backend/migration/1664694635394-turnstile.js diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cedfe7b1b..2fd44588c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -349,6 +349,10 @@ recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHAを有効にする" recaptchaSiteKey: "サイトキー" recaptchaSecretKey: "シークレットキー" +turnstile: "Turnstile" +enableTurnstile: "Turnstileを有効にする" +turnstileSiteKey: "サイトキー" +turnstileSecretKey: "シークレットキー" avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。" antennas: "アンテナ" manageAntennas: "アンテナの管理" diff --git a/packages/backend/migration/1664694635394-turnstile.js b/packages/backend/migration/1664694635394-turnstile.js new file mode 100644 index 000000000..4a3344395 --- /dev/null +++ b/packages/backend/migration/1664694635394-turnstile.js @@ -0,0 +1,15 @@ +export class turnstile1664694635394 { + name = 'turnstile1664694635394' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTurnstile" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "turnstileSiteKey" character varying(64)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "turnstileSecretKey" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "turnstileSecretKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "turnstileSiteKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTurnstile"`); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index 67b4b9006..acfa7d591 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -66,5 +66,16 @@ export class CaptchaService { throw `hcaptcha-failed: ${errorCodes}`; } } + + public async verifyTurnstile(secret: string, response: string): Promise { + const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(e => { + throw `turnstile-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; + throw `turnstile-failed: ${errorCodes}`; + } + } } diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index f528b7ac0..fb25e370d 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -188,6 +188,23 @@ export class Meta { }) public recaptchaSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTurnstile: boolean; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public turnstileSiteKey: string | null; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public turnstileSecretKey: string | null; + @Column('enum', { enum: ['none', 'all', 'local', 'remote'], default: 'none', diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 6552dac4b..edb8e4e8e 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -61,6 +61,12 @@ export class SignupApiService { ctx.throw(400, e); }); } + + if (instance.enableTurnstile && instance.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(e => { + ctx.throw(400, e); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 5b43c180d..e5b8b6f8f 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -47,6 +47,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTurnstile: { + type: 'boolean', + optional: false, nullable: false, + }, + turnstileSiteKey: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -197,6 +205,10 @@ export const meta = { type: 'string', optional: true, nullable: true, }, + turnstileSecretKey: { + type: 'string', + optional: true, nullable: true, + } sensitiveMediaDetection: { type: 'string', optional: true, nullable: false, @@ -374,6 +386,8 @@ export default class extends Endpoint { hcaptchaSiteKey: instance.hcaptchaSiteKey, enableRecaptcha: instance.enableRecaptcha, recaptchaSiteKey: instance.recaptchaSiteKey, + enableTurnstile: instance.enableTurnstile, + turnstileSiteKey: instance.turnstileSiteKey, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -400,6 +414,7 @@ export default class extends Endpoint { blockedHosts: instance.blockedHosts, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, + turnstileSecretKey: instance.turnstileSecretKey, sensitiveMediaDetection: instance.sensitiveMediaDetection, sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 48fae9b94..2a19b1df5 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -52,6 +52,9 @@ export const paramDef = { enableRecaptcha: { type: 'boolean' }, recaptchaSiteKey: { type: 'string', nullable: true }, recaptchaSecretKey: { type: 'string', nullable: true }, + enableTurnstile: { type: 'boolean' }, + turnstileSiteKey: { type: 'string', nullable: true }, + turnstileSecretKey: { type: 'string', nullable: true }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -231,6 +234,18 @@ export default class extends Endpoint { set.recaptchaSecretKey = ps.recaptchaSecretKey; } + if (ps.enableTurnstile !== undefined) { + set.enableTurnstile = ps.enableTurnstile; + } + + if (ps.turnstileSiteKey !== undefined) { + set.turnstileSiteKey = ps.turnstileSiteKey; + } + + if (ps.turnstileSecretKey !== undefined) { + set.turnstileSecretKey = ps.turnstileSecretKey; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 5c09c3394..f2e6e6aea 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -119,6 +119,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTurnstile: { + type: 'boolean', + optional: false, nullable: false, + }, + turnstileSiteKey: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -372,6 +380,8 @@ export default class extends Endpoint { hcaptchaSiteKey: instance.hcaptchaSiteKey, enableRecaptcha: instance.enableRecaptcha, recaptchaSiteKey: instance.recaptchaSiteKey, + enableTurnstile: instance.enableTurnstile, + turnstileSiteKey: instance.turnstileSiteKey, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -423,6 +433,7 @@ export default class extends Endpoint { elasticsearch: this.config.elasticsearch ? true : false, hcaptcha: instance.enableHcaptcha, recaptcha: instance.enableRecaptcha, + turnstile: instance.enableTurnstile, objectStorage: instance.useObjectStorage, twitter: instance.enableTwitterIntegration, github: instance.enableGithubIntegration, diff --git a/packages/client/src/components/MkCaptcha.vue b/packages/client/src/components/MkCaptcha.vue index 736073491..b399bb892 100644 --- a/packages/client/src/components/MkCaptcha.vue +++ b/packages/client/src/components/MkCaptcha.vue @@ -20,7 +20,7 @@ type Captcha = { getResponse(id: string): string; }; -type CaptchaProvider = 'hcaptcha' | 'recaptcha'; +type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -48,6 +48,7 @@ const variable = computed(() => { switch (props.provider) { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; + case 'turnstile': return 'turnstile'; } }); @@ -57,6 +58,7 @@ const src = computed(() => { switch (props.provider) { case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; + case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; } }); diff --git a/packages/client/src/components/MkSignup.vue b/packages/client/src/components/MkSignup.vue index a324bb6f5..c1f91b18c 100644 --- a/packages/client/src/components/MkSignup.vue +++ b/packages/client/src/components/MkSignup.vue @@ -59,6 +59,7 @@ + {{ i18n.ts.start }} @@ -92,6 +93,7 @@ const host = toUnicode(config.host); let hcaptcha = $ref(); let recaptcha = $ref(); +let turnstile = $ref(); let username: string = $ref(''); let password: string = $ref(''); @@ -106,12 +108,14 @@ let submitting: boolean = $ref(false); let ToSAgreement: boolean = $ref(false); let hCaptchaResponse = $ref(null); let reCaptchaResponse = $ref(null); +let turnstileResponse = $ref(null); const shouldDisableSubmitting = $computed((): boolean => { return submitting || instance.tosUrl && !ToSAgreement || instance.enableHcaptcha && !hCaptchaResponse || instance.enableRecaptcha && !reCaptchaResponse || + instance.enableTurnstile && !turnstileResponse || passwordRetypeState === 'not-match'; }); @@ -198,6 +202,7 @@ function onSubmit(): void { invitationCode, 'hcaptcha-response': hCaptchaResponse, 'g-recaptcha-response': reCaptchaResponse, + 'turnstile-response': turnstileResponse, }).then(() => { if (instance.emailRequiredForSignup) { os.alert({ @@ -222,6 +227,7 @@ function onSubmit(): void { submitting = false; hcaptcha.reset?.(); recaptcha.reset?.(); + turnstile.reset?.(); os.alert({ type: 'error', diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue index 72d5e379d..484a9d1a1 100644 --- a/packages/client/src/pages/admin/bot-protection.vue +++ b/packages/client/src/pages/admin/bot-protection.vue @@ -6,6 +6,7 @@ + + {{ i18n.ts.save }} @@ -61,6 +76,8 @@ let hcaptchaSiteKey: string | null = $ref(null); let hcaptchaSecretKey: string | null = $ref(null); let recaptchaSiteKey: string | null = $ref(null); let recaptchaSecretKey: string | null = $ref(null); +let turnstileSiteKey: string | null = $ref(null); +let turnstileSecretKey: string | null = $ref(null); async function init() { const meta = await os.api('admin/meta'); @@ -68,8 +85,10 @@ async function init() { hcaptchaSecretKey = meta.hcaptchaSecretKey; recaptchaSiteKey = meta.recaptchaSiteKey; recaptchaSecretKey = meta.recaptchaSecretKey; + turnstileSiteKey = meta.turnstileSiteKey; + turnstileSecretKey = meta.turnstileSecretKey; - provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : null; + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null; } function save() { @@ -80,6 +99,9 @@ function save() { enableRecaptcha: provider === 'recaptcha', recaptchaSiteKey, recaptchaSecretKey, + enableTurnstile: provider === 'turnstile', + turnstileSiteKey, + turnstileSecretKey, }).then(() => { fetchInstance(); }); diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue index 9200b5d54..20f82bba2 100644 --- a/packages/client/src/pages/admin/index.vue +++ b/packages/client/src/pages/admin/index.vue @@ -53,7 +53,7 @@ let view = $ref(null); let el = $ref(null); let pageProps = $ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); -let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha; +let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; let noEmailServer = !instance.enableEmail; let thereIsUnresolvedAbuseReport = $ref(false); let currentPage = $computed(() => router.currentRef.value.child); diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue index c36cedb31..65d079c2c 100644 --- a/packages/client/src/pages/admin/security.vue +++ b/packages/client/src/pages/admin/security.vue @@ -9,6 +9,7 @@ + @@ -120,6 +121,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; let summalyProxy: string = $ref(''); let enableHcaptcha: boolean = $ref(false); let enableRecaptcha: boolean = $ref(false); +let enableTurnstile: boolean = $ref(false); let sensitiveMediaDetection: string = $ref('none'); let sensitiveMediaDetectionSensitivity: number = $ref(0); let setSensitiveFlagAutomatically: boolean = $ref(false); @@ -132,6 +134,7 @@ async function init() { summalyProxy = meta.summalyProxy; enableHcaptcha = meta.enableHcaptcha; enableRecaptcha = meta.enableRecaptcha; + enableTurnstile = meta.enableTurnstile; sensitiveMediaDetection = meta.sensitiveMediaDetection; sensitiveMediaDetectionSensitivity = meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :