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:
parent
166067f746
commit
1309367884
13 changed files with 130 additions and 3 deletions
|
@ -349,6 +349,10 @@ recaptcha: "reCAPTCHA"
|
|||
enableRecaptcha: "reCAPTCHAを有効にする"
|
||||
recaptchaSiteKey: "サイトキー"
|
||||
recaptchaSecretKey: "シークレットキー"
|
||||
turnstile: "Turnstile"
|
||||
enableTurnstile: "Turnstileを有効にする"
|
||||
turnstileSiteKey: "サイトキー"
|
||||
turnstileSecretKey: "シークレットキー"
|
||||
avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。"
|
||||
antennas: "アンテナ"
|
||||
manageAntennas: "アンテナの管理"
|
||||
|
|
15
packages/backend/migration/1664694635394-turnstile.js
Normal file
15
packages/backend/migration/1664694635394-turnstile.js
Normal 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"`);
|
||||
}
|
||||
}
|
|
@ -66,5 +66,16 @@ export class CaptchaService {
|
|||
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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> {
|
|||
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<typeof meta, typeof paramDef> {
|
|||
blockedHosts: instance.blockedHosts,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
recaptchaSecretKey: instance.recaptchaSecretKey,
|
||||
turnstileSecretKey: instance.turnstileSecretKey,
|
||||
sensitiveMediaDetection: instance.sensitiveMediaDetection,
|
||||
sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
|
||||
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> {
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> {
|
|||
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<typeof meta, typeof paramDef> {
|
|||
elasticsearch: this.config.elasticsearch ? true : false,
|
||||
hcaptcha: instance.enableHcaptcha,
|
||||
recaptcha: instance.enableRecaptcha,
|
||||
turnstile: instance.enableTurnstile,
|
||||
objectStorage: instance.useObjectStorage,
|
||||
twitter: instance.enableTwitterIntegration,
|
||||
github: instance.enableGithubIntegration,
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
</MkSwitch>
|
||||
<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.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>
|
||||
</form>
|
||||
</template>
|
||||
|
@ -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',
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
|
||||
<option value="hcaptcha">hCaptcha</option>
|
||||
<option value="recaptcha">reCAPTCHA</option>
|
||||
<option value="turnstile">Turnstile</option>
|
||||
</FormRadios>
|
||||
|
||||
<template v-if="provider === 'hcaptcha'">
|
||||
|
@ -36,6 +37,20 @@
|
|||
<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
|
||||
</FormSlot>
|
||||
</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>
|
||||
</div>
|
||||
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<template #label>{{ i18n.ts.botProtection }}</template>
|
||||
<template v-if="enableHcaptcha" #suffix>hCaptcha</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>
|
||||
|
||||
<XBotProtection/>
|
||||
|
@ -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 :
|
||||
|
|
Loading…
Reference in a new issue