Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance: introduce testCaptcha #14745

Merged
merged 1 commit into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5166,6 +5166,10 @@ export interface Locale extends ILocale {
* 対象
*/
"target": string;
/**
* CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>
*/
"testCaptchaWarning": string;
"_abuseUserReport": {
/**
* 転送
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,7 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ"
target: "対象"
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"

_abuseUserReport:
forward: "転送"
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/migration/1728550878802-testcaptcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class Testcaptcha1728550878802 {
name = 'Testcaptcha1728550878802'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`);
}
}
13 changes: 13 additions & 0 deletions packages/backend/src/core/CaptchaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,18 @@ export class CaptchaService {
throw new Error(`turnstile-failed: ${errorCodes}`);
}
}

@bindThis
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
samunohito marked this conversation as resolved.
Show resolved Hide resolved
if (response == null) {
throw new Error('testcaptcha-failed: no response provided');
}

const success = response === 'testcaptcha-passed';

if (!success) {
throw new Error('testcaptcha-failed');
}
}
}

1 change: 1 addition & 0 deletions packages/backend/src/core/entities/MetaEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class MetaEntityService {
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/Meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ export class MiMeta {
})
public turnstileSecretKey: string | null;

@Column('boolean', {
default: false,
})
public enableTestcaptcha: boolean;

// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること

@Column('enum', {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/models/json-schema/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/ApiServerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));

Expand All @@ -132,6 +133,7 @@ export class ApiServerService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
};
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));

Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class SigninApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
};
}>,
reply: FastifyReply,
Expand Down Expand Up @@ -194,6 +195,12 @@ export class SigninApiService {
throw new FastifyReplyError(400, err);
});
}

if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
}

if (same) {
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/server/api/SignupApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class SignupApiService {
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
'm-captcha-response'?: string;
'testcaptcha-response'?: string;
}
}>,
reply: FastifyReply,
Expand Down Expand Up @@ -99,6 +100,12 @@ export class SignupApiService {
throw new FastifyReplyError(400, err);
});
}

if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
});
}
}

const username = body['username'];
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
enableTestcaptcha: {
type: 'boolean',
optional: false, nullable: false,
},
swPublickey: {
type: 'string',
optional: false, nullable: true,
Expand Down Expand Up @@ -555,6 +559,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
recaptchaSiteKey: instance.recaptchaSiteKey,
enableTurnstile: instance.enableTurnstile,
turnstileSiteKey: instance.turnstileSiteKey,
enableTestcaptcha: instance.enableTestcaptcha,
swPublickey: instance.swPublicKey,
themeColor: instance.themeColor,
mascotImageUrl: instance.mascotImageUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const paramDef = {
enableTurnstile: { type: 'boolean' },
turnstileSiteKey: { type: 'string', nullable: true },
turnstileSecretKey: { type: 'string', nullable: true },
enableTestcaptcha: { type: 'boolean' },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
Expand Down Expand Up @@ -357,6 +358,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.turnstileSecretKey = ps.turnstileSecretKey;
}

if (ps.enableTestcaptcha !== undefined) {
set.enableTestcaptcha = ps.enableTestcaptcha;
}

if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}
Expand Down
Binary file added packages/frontend/assets/testcaptcha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 26 additions & 2 deletions packages/frontend/src/components/MkCaptcha.vue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このコンポーネントはCaptchaの抽象層だと思うのでTestCaptchaの実装は別コンポーネントにしたいきがするけどいい切り分け単位も思いつかない

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CaptchaProvider単位とかですかね?

v-if="props.provider == 'xxx'"を判定しているdiv要素単位で切り出して、

  • MkCaptcha.mcaptcha.vue
  • MkCaptcha.testcaptcha.vue
  • MkCaptcha.default.vue(特別な実装が必要のない奴はこれを使う)

みたいにすると良さそうかも…?

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<div id="mcaptcha__widget-container" class="m-captcha-style"></div>
<div ref="captchaEl"></div>
</div>
<div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;">
<img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/>
<div v-if="testcaptchaPassed">
<div style="color: green;">Test captcha passed!</div>
</div>
<div v-else>
<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
</div>
</div>
<div v-else ref="captchaEl"></div>
</div>
</template>
Expand All @@ -29,7 +40,7 @@ export type Captcha = {
getResponse(id: string): string;
};
anatawa12 marked this conversation as resolved.
Show resolved Hide resolved

export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha';

type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha;
Expand All @@ -54,12 +65,16 @@ const available = ref(false);

const captchaEl = shallowRef<HTMLDivElement | undefined>();

const testcaptchaInput = ref('');
const testcaptchaPassed = ref(false);

const variable = computed(() => {
switch (props.provider) {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
case 'turnstile': return 'turnstile';
case 'mcaptcha': return 'mcaptcha';
case 'testcaptcha': return 'testcaptcha';
}
});

Expand All @@ -71,14 +86,15 @@ const src = computed(() => {
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';
case 'mcaptcha': return null;
case 'testcaptcha': return null;
}
});

const scriptId = computed(() => `script-${props.provider}`);

const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);

if (loaded || props.provider === 'mcaptcha') {
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
available.value = true;
} else if (src.value !== null) {
(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
Expand All @@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') {

function reset() {
if (captcha.value.reset) captcha.value.reset();
testcaptchaPassed.value = false;
testcaptchaInput.value = '';
}

async function requestRender() {
Expand Down Expand Up @@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) {
}
}

function testcaptchaSubmit() {
testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii';
callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined);
if (!testcaptchaPassed.value) testcaptchaInput.value = '';
}

onMounted(() => {
if (available.value) {
window.addEventListener('message', onReceivedMessage);
Expand Down
9 changes: 8 additions & 1 deletion packages/frontend/src/components/MkSignin.password.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
</div>

<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
Expand All @@ -44,6 +45,7 @@ export type PwResponse = {
mCaptchaResponse: string | null;
reCaptchaResponse: string | null;
turnstileResponse: string | null;
testcaptchaResponse: string | null;
};
};
</script>
Expand Down Expand Up @@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha');
const mCaptcha = useTemplateRef('mcaptcha');
const reCaptcha = useTemplateRef('recaptcha');
const turnstile = useTemplateRef('turnstile');
const testcaptcha = useTemplateRef('testcaptcha');

const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);

const captchaFailed = computed((): boolean => {
return (
(instance.enableHcaptcha && !hCaptchaResponse.value) ||
(instance.enableMcaptcha && !mCaptchaResponse.value) ||
(instance.enableRecaptcha && !reCaptchaResponse.value) ||
(instance.enableTurnstile && !turnstileResponse.value)
(instance.enableTurnstile && !turnstileResponse.value) ||
(instance.enableTestcaptcha && !testcaptchaResponse.value)
);
});

Expand All @@ -104,6 +109,7 @@ function onSubmit() {
mCaptchaResponse: mCaptchaResponse.value,
reCaptchaResponse: reCaptchaResponse.value,
turnstileResponse: turnstileResponse.value,
testcaptchaResponse: testcaptchaResponse.value,
},
});
}
Expand All @@ -113,6 +119,7 @@ function resetCaptcha() {
mCaptcha.value?.reset();
reCaptcha.value?.reset();
turnstile.value?.reset();
testcaptcha.value?.reset();
anatawa12 marked this conversation as resolved.
Show resolved Hide resolved
}

defineExpose({
Expand Down
6 changes: 3 additions & 3 deletions packages/frontend/src/components/MkSignin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
import * as Misskey from 'misskey-js';
import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';

import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { login } from '@/account.js';
Expand All @@ -79,9 +81,6 @@ import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
import XTotp from '@/components/MkSignin.totp.vue';
import XPasskey from '@/components/MkSignin.passkey.vue';

import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';

const emit = defineEmits<{
(ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void;
}>();
Expand Down Expand Up @@ -188,6 +187,7 @@ async function onPasswordSubmitted(pw: PwResponse) {
'm-captcha-response': pw.captcha.mCaptchaResponse,
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
'turnstile-response': pw.captcha.turnstileResponse,
'testcaptcha-response': pw.captcha.testcaptchaResponse,
});
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/frontend/src/components/MkSignupDialog.form.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
<template v-if="submitting">
<MkLoading :em="true" :colored="false"/>
Expand Down Expand Up @@ -108,6 +109,7 @@ const hcaptcha = ref<Captcha | undefined>();
const mcaptcha = ref<Captcha | undefined>();
const recaptcha = ref<Captcha | undefined>();
const turnstile = ref<Captcha | undefined>();
const testcaptcha = ref<Captcha | undefined>();

const username = ref<string>('');
const password = ref<string>('');
Expand All @@ -123,6 +125,7 @@ const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const testcaptchaResponse = ref<string | null>(null);
const usernameAbortController = ref<null | AbortController>(null);
const emailAbortController = ref<null | AbortController>(null);

Expand All @@ -132,6 +135,7 @@ const shouldDisableSubmitting = computed((): boolean => {
instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value ||
instance.enableTestcaptcha && !testcaptchaResponse.value ||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
usernameState.value !== 'ok' ||
passwordRetypeState.value !== 'match';
Expand Down Expand Up @@ -259,6 +263,7 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
'testcaptcha-response': testcaptchaResponse.value,
};

const res = await fetch(`${config.apiUrl}/signup`, {
Expand Down Expand Up @@ -301,6 +306,7 @@ function onSignupApiError() {
mcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
testcaptcha.value?.reset?.();

os.alert({
type: 'error',
Expand Down
Loading
Loading