Add Cloudflare Turnstile CAPTCHA support (#9111)

* Add Cloudflare Turnstile CAPTCHA support

* Update packages/client/src/components/MkCaptcha.vue

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
CyberRex 2022-10-13 09:19:57 +09:00 committed by GitHub
parent 166067f746
commit 1309367884
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 130 additions and 3 deletions

View file

@ -349,6 +349,10 @@ recaptcha: "reCAPTCHA"
enableRecaptcha: "reCAPTCHAを有効にする" enableRecaptcha: "reCAPTCHAを有効にする"
recaptchaSiteKey: "サイトキー" recaptchaSiteKey: "サイトキー"
recaptchaSecretKey: "シークレットキー" recaptchaSecretKey: "シークレットキー"
turnstile: "Turnstile"
enableTurnstile: "Turnstileを有効にする"
turnstileSiteKey: "サイトキー"
turnstileSecretKey: "シークレットキー"
avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますかキャンセルして複数のCaptchaを有効化したままにすることも可能です。" avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますかキャンセルして複数のCaptchaを有効化したままにすることも可能です。"
antennas: "アンテナ" antennas: "アンテナ"
manageAntennas: "アンテナの管理" manageAntennas: "アンテナの管理"

View file

@ -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"`);
}
}

View file

@ -66,5 +66,16 @@ export class CaptchaService {
throw `hcaptcha-failed: ${errorCodes}`; throw `hcaptcha-failed: ${errorCodes}`;
} }
} }
public async verifyTurnstile(secret: string, response: string): Promise<void> {
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}`;
}
}
} }

View file

@ -188,6 +188,23 @@ export class Meta {
}) })
public recaptchaSecretKey: string | null; 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', { @Column('enum', {
enum: ['none', 'all', 'local', 'remote'], enum: ['none', 'all', 'local', 'remote'],
default: 'none', default: 'none',

View file

@ -61,6 +61,12 @@ export class SignupApiService {
ctx.throw(400, e); 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']; const username = body['username'];

View file

@ -47,6 +47,14 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
enableTurnstile: {
type: 'boolean',
optional: false, nullable: false,
},
turnstileSiteKey: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -197,6 +205,10 @@ export const meta = {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,
}, },
turnstileSecretKey: {
type: 'string',
optional: true, nullable: true,
}
sensitiveMediaDetection: { sensitiveMediaDetection: {
type: 'string', type: 'string',
optional: true, nullable: false, optional: true, nullable: false,
@ -374,6 +386,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
hcaptchaSiteKey: instance.hcaptchaSiteKey, hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha, enableRecaptcha: instance.enableRecaptcha,
recaptchaSiteKey: instance.recaptchaSiteKey, recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl, mascotImageUrl: instance.mascotImageUrl,
@ -400,6 +414,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
blockedHosts: instance.blockedHosts, blockedHosts: instance.blockedHosts,
hcaptchaSecretKey: instance.hcaptchaSecretKey, hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey,
turnstileSecretKey: instance.turnstileSecretKey,
sensitiveMediaDetection: instance.sensitiveMediaDetection, sensitiveMediaDetection: instance.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,

View file

@ -52,6 +52,9 @@ export const paramDef = {
enableRecaptcha: { type: 'boolean' }, enableRecaptcha: { type: 'boolean' },
recaptchaSiteKey: { type: 'string', nullable: true }, recaptchaSiteKey: { type: 'string', nullable: true },
recaptchaSecretKey: { 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'] }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' }, setSensitiveFlagAutomatically: { type: 'boolean' },
@ -231,6 +234,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.recaptchaSecretKey = ps.recaptchaSecretKey; 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) { if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection; set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
} }

View file

@ -119,6 +119,14 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
enableTurnstile: {
type: 'boolean',
optional: false, nullable: false,
},
turnstileSiteKey: {
type: 'string',
optional: false, nullable: true,
},
swPublickey: { swPublickey: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
@ -372,6 +380,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
hcaptchaSiteKey: instance.hcaptchaSiteKey, hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha, enableRecaptcha: instance.enableRecaptcha,
recaptchaSiteKey: instance.recaptchaSiteKey, recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
swPublickey: instance.swPublicKey, swPublickey: instance.swPublicKey,
themeColor: instance.themeColor, themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl, mascotImageUrl: instance.mascotImageUrl,
@ -423,6 +433,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
elasticsearch: this.config.elasticsearch ? true : false, elasticsearch: this.config.elasticsearch ? true : false,
hcaptcha: instance.enableHcaptcha, hcaptcha: instance.enableHcaptcha,
recaptcha: instance.enableRecaptcha, recaptcha: instance.enableRecaptcha,
turnstile: instance.enableTurnstile,
objectStorage: instance.useObjectStorage, objectStorage: instance.useObjectStorage,
twitter: instance.enableTwitterIntegration, twitter: instance.enableTwitterIntegration,
github: instance.enableGithubIntegration, github: instance.enableGithubIntegration,

View file

@ -20,7 +20,7 @@ type Captcha = {
getResponse(id: string): string; getResponse(id: string): string;
}; };
type CaptchaProvider = 'hcaptcha' | 'recaptcha'; type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
type CaptchaContainer = { type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha; readonly [_ in CaptchaProvider]?: Captcha;
@ -48,6 +48,7 @@ const variable = computed(() => {
switch (props.provider) { switch (props.provider) {
case 'hcaptcha': return 'hcaptcha'; case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha'; case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile';
} }
}); });
@ -57,6 +58,7 @@ const src = computed(() => {
switch (props.provider) { switch (props.provider) {
case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off'; 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 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
} }
}); });

View file

@ -59,6 +59,7 @@
</MkSwitch> </MkSwitch>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="_formBlock captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton> <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
</form> </form>
</template> </template>
@ -92,6 +93,7 @@ const host = toUnicode(config.host);
let hcaptcha = $ref(); let hcaptcha = $ref();
let recaptcha = $ref(); let recaptcha = $ref();
let turnstile = $ref();
let username: string = $ref(''); let username: string = $ref('');
let password: string = $ref(''); let password: string = $ref('');
@ -106,12 +108,14 @@ let submitting: boolean = $ref(false);
let ToSAgreement: boolean = $ref(false); let ToSAgreement: boolean = $ref(false);
let hCaptchaResponse = $ref(null); let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null); let reCaptchaResponse = $ref(null);
let turnstileResponse = $ref(null);
const shouldDisableSubmitting = $computed((): boolean => { const shouldDisableSubmitting = $computed((): boolean => {
return submitting || return submitting ||
instance.tosUrl && !ToSAgreement || instance.tosUrl && !ToSAgreement ||
instance.enableHcaptcha && !hCaptchaResponse || instance.enableHcaptcha && !hCaptchaResponse ||
instance.enableRecaptcha && !reCaptchaResponse || instance.enableRecaptcha && !reCaptchaResponse ||
instance.enableTurnstile && !turnstileResponse ||
passwordRetypeState === 'not-match'; passwordRetypeState === 'not-match';
}); });
@ -198,6 +202,7 @@ function onSubmit(): void {
invitationCode, invitationCode,
'hcaptcha-response': hCaptchaResponse, 'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse, 'g-recaptcha-response': reCaptchaResponse,
'turnstile-response': turnstileResponse,
}).then(() => { }).then(() => {
if (instance.emailRequiredForSignup) { if (instance.emailRequiredForSignup) {
os.alert({ os.alert({
@ -222,6 +227,7 @@ function onSubmit(): void {
submitting = false; submitting = false;
hcaptcha.reset?.(); hcaptcha.reset?.();
recaptcha.reset?.(); recaptcha.reset?.();
turnstile.reset?.();
os.alert({ os.alert({
type: 'error', type: 'error',

View file

@ -6,6 +6,7 @@
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option> <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option> <option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">reCAPTCHA</option> <option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
</FormRadios> </FormRadios>
<template v-if="provider === 'hcaptcha'"> <template v-if="provider === 'hcaptcha'">
@ -36,6 +37,20 @@
<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
</FormSlot> </FormSlot>
</template> </template>
<template v-else-if="provider === 'turnstile'">
<FormInput v-model="turnstileSiteKey" class="_formBlock">
<template #prefix><i class="fas fa-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</FormInput>
<FormInput v-model="turnstileSecretKey" class="_formBlock">
<template #prefix><i class="fas fa-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</FormInput>
<FormSlot class="_formBlock">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/>
</FormSlot>
</template>
<FormButton primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> <FormButton primary @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
</div> </div>
@ -61,6 +76,8 @@ let hcaptchaSiteKey: string | null = $ref(null);
let hcaptchaSecretKey: string | null = $ref(null); let hcaptchaSecretKey: string | null = $ref(null);
let recaptchaSiteKey: string | null = $ref(null); let recaptchaSiteKey: string | null = $ref(null);
let recaptchaSecretKey: 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() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
@ -68,8 +85,10 @@ async function init() {
hcaptchaSecretKey = meta.hcaptchaSecretKey; hcaptchaSecretKey = meta.hcaptchaSecretKey;
recaptchaSiteKey = meta.recaptchaSiteKey; recaptchaSiteKey = meta.recaptchaSiteKey;
recaptchaSecretKey = meta.recaptchaSecretKey; 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() { function save() {
@ -80,6 +99,9 @@ function save() {
enableRecaptcha: provider === 'recaptcha', enableRecaptcha: provider === 'recaptcha',
recaptchaSiteKey, recaptchaSiteKey,
recaptchaSecretKey, recaptchaSecretKey,
enableTurnstile: provider === 'turnstile',
turnstileSiteKey,
turnstileSecretKey,
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();
}); });

View file

@ -53,7 +53,7 @@ let view = $ref(null);
let el = $ref(null); let el = $ref(null);
let pageProps = $ref({}); let pageProps = $ref({});
let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); 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 noEmailServer = !instance.enableEmail;
let thereIsUnresolvedAbuseReport = $ref(false); let thereIsUnresolvedAbuseReport = $ref(false);
let currentPage = $computed(() => router.currentRef.value.child); let currentPage = $computed(() => router.currentRef.value.child);

View file

@ -9,6 +9,7 @@
<template #label>{{ i18n.ts.botProtection }}</template> <template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="enableHcaptcha" #suffix>hCaptcha</template> <template v-if="enableHcaptcha" #suffix>hCaptcha</template>
<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<XBotProtection/> <XBotProtection/>
@ -120,6 +121,7 @@ import { definePageMetadata } from '@/scripts/page-metadata';
let summalyProxy: string = $ref(''); let summalyProxy: string = $ref('');
let enableHcaptcha: boolean = $ref(false); let enableHcaptcha: boolean = $ref(false);
let enableRecaptcha: boolean = $ref(false); let enableRecaptcha: boolean = $ref(false);
let enableTurnstile: boolean = $ref(false);
let sensitiveMediaDetection: string = $ref('none'); let sensitiveMediaDetection: string = $ref('none');
let sensitiveMediaDetectionSensitivity: number = $ref(0); let sensitiveMediaDetectionSensitivity: number = $ref(0);
let setSensitiveFlagAutomatically: boolean = $ref(false); let setSensitiveFlagAutomatically: boolean = $ref(false);
@ -132,6 +134,7 @@ async function init() {
summalyProxy = meta.summalyProxy; summalyProxy = meta.summalyProxy;
enableHcaptcha = meta.enableHcaptcha; enableHcaptcha = meta.enableHcaptcha;
enableRecaptcha = meta.enableRecaptcha; enableRecaptcha = meta.enableRecaptcha;
enableTurnstile = meta.enableTurnstile;
sensitiveMediaDetection = meta.sensitiveMediaDetection; sensitiveMediaDetection = meta.sensitiveMediaDetection;
sensitiveMediaDetectionSensitivity = sensitiveMediaDetectionSensitivity =
meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :