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…
	
	Add table
		Add a link
		
	
		Reference in a new issue