add server-side verify

This commit is contained in:
おさむのひと 2024-12-20 09:12:16 +09:00
parent f1c894a81e
commit 738d86f734
11 changed files with 748 additions and 36 deletions

View file

@ -7,6 +7,38 @@ import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
export const supportedCaptchaProviders = ['hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const;
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
export const captchaErrorCodes = {
invalidProvider: Symbol('invalidProvider'),
invalidParameters: Symbol('invalidParameters'),
noResponseProvided: Symbol('noResponseProvided'),
requestFailed: Symbol('requestFailed'),
verificationFailed: Symbol('verificationFailed'),
unknown: Symbol('unknown'),
} as const;
export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes];
export class CaptchaError extends Error {
public readonly code: CaptchaErrorCode;
constructor(code: CaptchaErrorCode, message: string) {
super(message);
this.code = code;
this.name = 'CaptchaError';
}
}
export type ValidateSuccess = {
success: true;
}
export type ValidateFailure = {
success: false;
error: CaptchaError;
}
export type ValidateResult = ValidateSuccess | ValidateFailure;
type CaptchaResponse = {
success: boolean;
'error-codes'?: string[];
@ -44,32 +76,32 @@ export class CaptchaService {
@bindThis
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('recaptcha-failed: no response provided');
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
throw new Error(`recaptcha-request-failed: ${err}`);
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw new Error(`recaptcha-failed: ${errorCodes}`);
throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`);
}
}
@bindThis
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('hcaptcha-failed: no response provided');
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
throw new Error(`hcaptcha-request-failed: ${err}`);
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw new Error(`hcaptcha-failed: ${errorCodes}`);
throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`);
}
}
@ -77,7 +109,7 @@ export class CaptchaService {
@bindThis
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('mcaptcha-failed: no response provided');
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided');
}
const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost);
@ -94,43 +126,122 @@ export class CaptchaService {
});
if (result.status !== 200) {
throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK');
throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK');
}
const resp = (await result.json()) as { valid: boolean };
if (!resp.valid) {
throw new Error('mcaptcha-request-failed');
throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed');
}
}
@bindThis
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('turnstile-failed: no response provided');
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
throw new Error(`turnstile-request-failed: ${err}`);
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`);
});
if (result.success !== true) {
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
throw new Error(`turnstile-failed: ${errorCodes}`);
throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`);
}
}
@bindThis
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
if (response == null) {
throw new Error('testcaptcha-failed: no response provided');
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided');
}
const success = response === 'testcaptcha-passed';
if (!success) {
throw new Error('testcaptcha-failed');
throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed');
}
}
/**
* captchaからの戻り値を検証します.
* captchaプロバイダの検証関数に委譲します.
*
* @param params
* @param params.provider captchaのプロバイダ
* @param params.sitekey mcaptchaの場合に指定するsitekey.
* @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret.
* @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL.
* @param params.captchaResult captchaプロバイダからの戻り値. 使
*
* @see verifyHcaptcha
* @see verifyMcaptcha
* @see verifyRecaptcha
* @see verifyTurnstile
* @see verifyTestcaptcha
*/
@bindThis
public async verify(params: {
provider: CaptchaProvider;
sitekey?: string;
secret?: string;
instanceUrl?: string;
captchaResult?: string | null;
}): Promise<ValidateResult> {
if (!supportedCaptchaProviders.includes(params.provider)) {
return {
success: false,
error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${params.provider}`),
};
}
const operation = {
hcaptcha: async () => {
if (!params.secret) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and response are required');
}
return this.verifyHcaptcha(params.secret, params.captchaResult);
},
mcaptcha: async () => {
if (!params.secret || !params.sitekey || !params.instanceUrl) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and response are required');
}
return this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult);
},
recaptcha: async () => {
if (!params.secret) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and response are required');
}
return this.verifyRecaptcha(params.secret, params.captchaResult);
},
turnstile: async () => {
if (!params.secret) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and response are required');
}
return this.verifyTurnstile(params.secret, params.captchaResult);
},
testcaptcha: async () => {
return this.verifyTestcaptcha(params.captchaResult);
},
}[params.provider];
return operation()
.then(() => ({ success: true }) as ValidateSuccess)
.catch(err => {
const error = err instanceof CaptchaError
? err
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
return {
success: false,
error,
};
});
}
}

View file

@ -28,6 +28,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_captcha_test from './endpoints/admin/captcha/test.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@ -416,6 +417,7 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de
const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default };
const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default };
const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default };
const $admin_captcha_test: Provider = { provide: 'ep:admin/captcha/test', useClass: ep___admin_captcha_test.default };
const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default };
const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
@ -808,6 +810,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_test,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
@ -1194,6 +1197,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_test,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,

View file

@ -33,6 +33,7 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d
import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js';
import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js';
import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js';
import * as ep___admin_captcha_test from './endpoints/admin/captcha/test.js';
import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js';
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
@ -420,6 +421,7 @@ const eps = [
['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete],
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
['admin/captcha/test', ep___admin_captcha_test],
['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
['admin/unset-user-banner', ep___admin_unsetUserBanner],

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js';
export const meta = {
tags: ['admin', 'captcha'],
requireCredential: true,
requireModerator: true,
secure: true,
kind: 'read:admin:captcha',
res: {
type: 'object',
properties: {
success: {
type: 'boolean',
},
error: {
type: 'object',
nullable: true,
optional: false,
properties: {
code: {
type: 'string',
},
message: {
type: 'string',
},
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
provider: {
type: 'string',
enum: supportedCaptchaProviders,
},
sitekey: {
type: 'string',
},
secret: {
type: 'string',
},
instanceUrl: {
type: 'string',
},
captchaResult: {
type: 'string',
},
},
required: ['provider'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private captchaService: CaptchaService,
) {
super(meta, paramDef, async (ps, me) => {
const result = await this.captchaService.verify({
provider: ps.provider,
sitekey: ps.sitekey,
secret: ps.secret,
instanceUrl: ps.instanceUrl,
captchaResult: ps.captchaResult,
});
if (result.success) {
return { success: true, error: null };
} else {
return {
success: false,
error: {
code: result.error.code.toString(),
message: result.error.message,
},
};
}
});
}
}

View file

@ -0,0 +1,324 @@
import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { Response } from 'node-fetch';
import {
CaptchaError,
CaptchaErrorCode,
captchaErrorCodes,
CaptchaService,
ValidateResult,
} from '@/core/CaptchaService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
describe('CaptchaService', () => {
let app: TestingModule;
let service: CaptchaService;
let httpRequestService: jest.Mocked<HttpRequestService>;
beforeAll(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
],
providers: [
CaptchaService,
{
provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }),
},
],
}).compile();
app.enableShutdownHooks();
service = app.get(CaptchaService);
httpRequestService = app.get(HttpRequestService) as jest.Mocked<HttpRequestService>;
});
beforeEach(() => {
httpRequestService.send.mockClear();
});
afterAll(async () => {
await app.close();
});
function successMock(result: object) {
httpRequestService.send.mockResolvedValue({
ok: true,
status: 200,
json: async () => (result),
} as Response);
}
function failureHttpMock() {
httpRequestService.send.mockResolvedValue({
ok: false,
status: 400,
} as Response);
}
function failureVerificationMock(result: object) {
httpRequestService.send.mockResolvedValue({
ok: true,
status: 200,
json: async () => (result),
} as Response);
}
async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise<void>) {
try {
await test();
expect(false).toBe(true);
} catch (e) {
console.log(e);
expect(e instanceof CaptchaError).toBe(true);
const _e = e as CaptchaError;
expect(_e.code).toBe(code);
}
}
describe('verifyRecaptcha', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyRecaptcha('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response'));
});
});
describe('verifyHcaptcha', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyHcaptcha('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response'));
});
});
describe('verifyMcaptcha', () => {
const host = 'https://localhost';
test('success', async () => {
successMock({ valid: true });
await service.verifyMcaptcha('secret', 'sitekey', host, 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ valid: false });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response'));
});
});
describe('verifyTurnstile', () => {
test('success', async () => {
successMock({ success: true });
await service.verifyTurnstile('secret', 'response');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null));
});
test('requestFailed', async () => {
failureHttpMock();
await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response'));
});
test('verificationFailed', async () => {
failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] });
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response'));
});
});
describe('verifyTestcaptcha', () => {
test('success', async () => {
await service.verifyTestcaptcha('testcaptcha-passed');
});
test('noResponseProvided', async () => {
await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null));
});
test('verificationFailed', async () => {
await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed'));
});
});
describe('validateSettings', () => {
const host = 'https://localhost';
describe('success', () => {
beforeEach(() => {
successMock({ success: true, valid: true });
});
async function assertSuccess(promise: Promise<ValidateResult>) {
await expect(promise)
.resolves
.toStrictEqual({ success: true });
}
test('hcaptcha', async () => {
await assertSuccess(service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'response' }));
});
test('mcaptcha', async () => {
await assertSuccess(service.verify({
provider: 'mcaptcha',
secret: 'secret',
sitekey: 'sitekey',
instanceUrl: host,
captchaResult: 'response',
}));
});
test('recaptcha', async () => {
await assertSuccess(service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'response' }));
});
test('turnstile', async () => {
await assertSuccess(service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'response' }));
});
test('testcaptcha', async () => {
await assertSuccess(service.verify({ provider: 'testcaptcha', captchaResult: 'testcaptcha-passed' }));
});
});
describe('failure', () => {
async function assertFailure(code: CaptchaErrorCode, promise: Promise<ValidateResult>) {
const res = await promise;
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.code).toBe(code);
}
}
describe('noResponseProvided', () => {
test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: null }));
});
test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({
provider: 'mcaptcha',
secret: 'secret',
sitekey: 'sitekey',
instanceUrl: host,
captchaResult: null,
}));
});
test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: null }));
});
test('turnstile', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: null }));
});
test('testcaptcha', async () => {
await assertFailure(captchaErrorCodes.noResponseProvided, service.verify({ provider: 'testcaptcha', captchaResult: null }));
});
});
describe('requestFailed', () => {
beforeEach(() => {
failureHttpMock();
});
test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'res' }));
});
test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({
provider: 'mcaptcha',
secret: 'secret',
sitekey: 'sitekey',
instanceUrl: host,
captchaResult: 'res',
}));
});
test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'res' }));
});
test('turnstile', async () => {
await assertFailure(captchaErrorCodes.requestFailed, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'res' }));
});
// testcaptchaはrequestFailedが発生しない
// test('testcaptcha', () => {});
});
describe('verificationFailed', () => {
beforeEach(() => {
failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] });
});
test('hcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'hcaptcha', secret: 'secret', captchaResult: 'res' }));
});
test('mcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({
provider: 'mcaptcha',
secret: 'secret',
sitekey: 'sitekey',
instanceUrl: host,
captchaResult: 'res',
}));
});
test('recaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'recaptcha', secret: 'secret', captchaResult: 'res' }));
});
test('turnstile', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'turnstile', secret: 'secret', captchaResult: 'res' }));
});
test('testcaptcha', async () => {
await assertFailure(captchaErrorCodes.verificationFailed, service.verify({ provider: 'testcaptcha', captchaResult: 'testcaptcha-failed' }));
});
});
});
});
});

View file

@ -38,12 +38,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha v-model="hCaptchaResponse" provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey"/>
<MkCaptcha v-model="captchaResult" provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey"/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>サイトキーに"10000000-ffff-ffff-ffff-000000000001"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください</div>
<div>ref: <a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a></div>
<div>
サイトキーに"10000000-ffff-ffff-ffff-000000000001"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください
</div>
<div>
ref: <a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha
Developer Guide</a>
</div>
</div>
</MkInfo>
</template>
@ -63,7 +68,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha v-model="mCaptchaResponse" provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/>
<MkCaptcha
v-model="captchaResult" provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey"
:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
/>
</FormSlot>
</template>
@ -78,12 +86,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha v-model="reCaptchaResponse" provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/>
<MkCaptcha
v-model="captchaResult" provider="recaptcha"
:sitekey="botProtectionForm.state.recaptchaSiteKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>サイトキーに"6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください</div>
<div>ref: <a href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do" target="_blank">reCAPTCHA FAQ</a></div>
<div>
サイトキーに"6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください
</div>
<div>
ref: <a
href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
target="_blank"
>reCAPTCHA FAQ</a>
</div>
</div>
</MkInfo>
</template>
@ -99,12 +117,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha v-model="turnstileResponse" provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey"/>
<MkCaptcha
v-model="captchaResult" provider="turnstile"
:sitekey="botProtectionForm.state.turnstileSiteKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>サイトキーに"1x00000000000000000000AA"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください</div>
<div>ref: <a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a></div>
<div>
サイトキーに"1x00000000000000000000AA"と入力することで動作をテスト出来ます<br/>本番運用時には必ず正規のサイトキーを設定してください
</div>
<div>
ref: <a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare
Docs</a>
</div>
</div>
</MkInfo>
</template>
@ -113,15 +139,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
<FormSlot>
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha v-model="testCaptchaResponse" provider="testcaptcha" :sitekey="null"/>
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
</FormSlot>
</template>
<MkInfo v-if="!verifyResult && verifyErrorText" warn>
{{ verifyErrorText }}
</MkInfo>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkRadios from '@/components/MkRadios.vue';
import MkInput from '@/components/MkInput.vue';
import FormSlot from '@/components/form/slot.vue';
@ -138,19 +169,17 @@ const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'
const meta = await misskeyApi('admin/meta');
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 captchaResult = ref<string | null>(null);
const verifyResult = ref<boolean>(false);
const verifyErrorText = ref<string | null>(null);
const canSaving = computed((): boolean => {
return (botProtectionForm.state.provider === null) ||
(botProtectionForm.state.provider === 'hcaptcha' && !!hCaptchaResponse.value) ||
(botProtectionForm.state.provider === 'mcaptcha' && !!mCaptchaResponse.value) ||
(botProtectionForm.state.provider === 'recaptcha' && !!reCaptchaResponse.value) ||
(botProtectionForm.state.provider === 'turnstile' && !!turnstileResponse.value) ||
(botProtectionForm.state.provider === 'testcaptcha' && !!testCaptchaResponse.value);
(botProtectionForm.state.provider === 'hcaptcha' && verifyResult.value) ||
(botProtectionForm.state.provider === 'mcaptcha' && verifyResult.value) ||
(botProtectionForm.state.provider === 'recaptcha' && verifyResult.value) ||
(botProtectionForm.state.provider === 'turnstile' && verifyResult.value) ||
(botProtectionForm.state.provider === 'testcaptcha' && verifyResult.value);
});
const botProtectionForm = useForm({
@ -193,6 +222,56 @@ const botProtectionForm = useForm({
});
fetchInstance(true);
});
watch(botProtectionForm.state, () => {
captchaResult.value = null;
if (botProtectionForm.state.provider === null) {
verifyResult.value = true;
} else {
verifyResult.value = false;
verifyErrorText.value = null;
}
});
watch(captchaResult, async () => {
const provider = botProtectionForm.state.provider;
const sitekey = provider === 'hcaptcha'
? botProtectionForm.state.hcaptchaSiteKey
: provider === 'mcaptcha'
? botProtectionForm.state.mcaptchaSiteKey
: provider === 'recaptcha'
? botProtectionForm.state.recaptchaSiteKey
: provider === 'turnstile'
? botProtectionForm.state.turnstileSiteKey
: null;
const secret = provider === 'hcaptcha'
? botProtectionForm.state.hcaptchaSecretKey
: provider === 'mcaptcha'
? botProtectionForm.state.mcaptchaSecretKey
: provider === 'recaptcha'
? botProtectionForm.state.recaptchaSecretKey
: provider === 'turnstile'
? botProtectionForm.state.turnstileSecretKey
: null;
if (captchaResult.value) {
const result = await misskeyApi('admin/captcha/test', {
provider: provider as Misskey.entities.AdminCaptchaTestRequest['provider'],
sitekey: sitekey ?? undefined,
secret: secret ?? undefined,
instanceUrl: botProtectionForm.state.mcaptchaInstanceUrl ?? undefined,
captchaResult: captchaResult.value,
});
verifyResult.value = result.success;
verifyErrorText.value = result.error
? result.error.message
: null;
} else {
verifyResult.value = false;
}
});
</script>
<style lang="scss" module>

View file

@ -136,6 +136,12 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations
// @public (undocumented)
type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCaptchaTestRequest = operations['admin___captcha___test']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminCaptchaTestResponse = operations['admin___captcha___test']['responses']['200']['content']['application/json'];
// @public (undocumented)
type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
@ -1261,6 +1267,8 @@ declare namespace entities {
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaTestRequest,
AdminCaptchaTestResponse,
AdminDeleteAllFilesOfAUserRequest,
AdminUnsetUserAvatarRequest,
AdminUnsetUserBannerRequest,

View file

@ -250,6 +250,18 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes* / **Permission**: *read:admin:captcha*
*/
request<E extends 'admin/captcha/test', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -36,6 +36,8 @@ import type {
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
AdminAvatarDecorationsUpdateRequest,
AdminCaptchaTestRequest,
AdminCaptchaTestResponse,
AdminDeleteAllFilesOfAUserRequest,
AdminUnsetUserAvatarRequest,
AdminUnsetUserBannerRequest,
@ -604,6 +606,7 @@ export type Endpoints = {
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
'admin/captcha/test': { req: AdminCaptchaTestRequest; res: AdminCaptchaTestResponse };
'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse };
'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse };
'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse };

View file

@ -39,6 +39,8 @@ export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-dec
export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json'];
export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
export type AdminCaptchaTestRequest = operations['admin___captcha___test']['requestBody']['content']['application/json'];
export type AdminCaptchaTestResponse = operations['admin___captcha___test']['responses']['200']['content']['application/json'];
export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json'];
export type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json'];
export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json'];

View file

@ -215,6 +215,16 @@ export type paths = {
*/
post: operations['admin___avatar-decorations___update'];
};
'/admin/captcha/test': {
/**
* admin/captcha/test
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes* / **Permission**: *read:admin:captcha*
*/
post: operations['admin___captcha___test'];
};
'/admin/delete-all-files-of-a-user': {
/**
* admin/delete-all-files-of-a-user
@ -6564,6 +6574,71 @@ export type operations = {
};
};
};
/**
* admin/captcha/test
* @description No description provided.
*
* **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties.
* **Credential required**: *Yes* / **Permission**: *read:admin:captcha*
*/
admin___captcha___test: {
requestBody: {
content: {
'application/json': {
/** @enum {string} */
provider: 'hcaptcha' | 'mcaptcha' | 'recaptcha' | 'turnstile' | 'testcaptcha';
sitekey?: string;
secret?: string;
instanceUrl?: string;
captchaResult?: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
success: boolean;
error: {
code: string;
message: string;
} | null;
};
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* admin/delete-all-files-of-a-user
* @description No description provided.