mirror of
https://github.com/misskey-dev/misskey.git
synced 2025-03-23 00:34:52 +01:00
add server-side verify
This commit is contained in:
parent
f1c894a81e
commit
738d86f734
11 changed files with 748 additions and 36 deletions
|
@ -7,6 +7,38 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { bindThis } from '@/decorators.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 = {
|
type CaptchaResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
'error-codes'?: string[];
|
'error-codes'?: string[];
|
||||||
|
@ -44,32 +76,32 @@ export class CaptchaService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
if (response == null) {
|
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 => {
|
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) {
|
if (result.success !== true) {
|
||||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||||
throw new Error(`recaptcha-failed: ${errorCodes}`);
|
throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
if (response == null) {
|
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 => {
|
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) {
|
if (result.success !== true) {
|
||||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
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
|
@bindThis
|
||||||
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
|
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
|
||||||
if (response == null) {
|
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);
|
const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost);
|
||||||
|
@ -94,43 +126,122 @@ export class CaptchaService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.status !== 200) {
|
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 };
|
const resp = (await result.json()) as { valid: boolean };
|
||||||
|
|
||||||
if (!resp.valid) {
|
if (!resp.valid) {
|
||||||
throw new Error('mcaptcha-request-failed');
|
throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
if (response == null) {
|
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 => {
|
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) {
|
if (result.success !== true) {
|
||||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||||
throw new Error(`turnstile-failed: ${errorCodes}`);
|
throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
|
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
|
||||||
if (response == null) {
|
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';
|
const success = response === 'testcaptcha-passed';
|
||||||
|
|
||||||
if (!success) {
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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_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_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_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_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_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
|
||||||
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.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_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_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_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_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_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 };
|
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_delete,
|
||||||
$admin_avatarDecorations_list,
|
$admin_avatarDecorations_list,
|
||||||
$admin_avatarDecorations_update,
|
$admin_avatarDecorations_update,
|
||||||
|
$admin_captcha_test,
|
||||||
$admin_deleteAllFilesOfAUser,
|
$admin_deleteAllFilesOfAUser,
|
||||||
$admin_unsetUserAvatar,
|
$admin_unsetUserAvatar,
|
||||||
$admin_unsetUserBanner,
|
$admin_unsetUserBanner,
|
||||||
|
@ -1194,6 +1197,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$admin_avatarDecorations_delete,
|
$admin_avatarDecorations_delete,
|
||||||
$admin_avatarDecorations_list,
|
$admin_avatarDecorations_list,
|
||||||
$admin_avatarDecorations_update,
|
$admin_avatarDecorations_update,
|
||||||
|
$admin_captcha_test,
|
||||||
$admin_deleteAllFilesOfAUser,
|
$admin_deleteAllFilesOfAUser,
|
||||||
$admin_unsetUserAvatar,
|
$admin_unsetUserAvatar,
|
||||||
$admin_unsetUserBanner,
|
$admin_unsetUserBanner,
|
||||||
|
|
|
@ -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_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_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_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_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_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
|
||||||
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.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/delete', ep___admin_avatarDecorations_delete],
|
||||||
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
|
['admin/avatar-decorations/list', ep___admin_avatarDecorations_list],
|
||||||
['admin/avatar-decorations/update', ep___admin_avatarDecorations_update],
|
['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/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser],
|
||||||
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
|
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
|
||||||
['admin/unset-user-banner', ep___admin_unsetUserBanner],
|
['admin/unset-user-banner', ep___admin_unsetUserBanner],
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
324
packages/backend/test/unit/CaptchaService.ts
Normal file
324
packages/backend/test/unit/CaptchaService.ts
Normal 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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -38,12 +38,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
|
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
|
||||||
<template #label>{{ i18n.ts.preview }}</template>
|
<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>
|
</FormSlot>
|
||||||
<MkInfo>
|
<MkInfo>
|
||||||
<div :class="$style.captchaInfoMsg">
|
<div :class="$style.captchaInfoMsg">
|
||||||
<div>サイトキーに"10000000-ffff-ffff-ffff-000000000001"と入力することで動作をテスト出来ます。<br/>本番運用時には必ず正規のサイトキーを設定してください。</div>
|
<div>
|
||||||
<div>ref: <a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a></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>
|
</div>
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
</template>
|
</template>
|
||||||
|
@ -63,7 +68,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
|
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
|
||||||
<template #label>{{ i18n.ts.preview }}</template>
|
<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>
|
</FormSlot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -78,12 +86,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
|
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
|
||||||
<template #label>{{ i18n.ts.preview }}</template>
|
<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>
|
</FormSlot>
|
||||||
<MkInfo>
|
<MkInfo>
|
||||||
<div :class="$style.captchaInfoMsg">
|
<div :class="$style.captchaInfoMsg">
|
||||||
<div>サイトキーに"6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"と入力することで動作をテスト出来ます。<br/>本番運用時には必ず正規のサイトキーを設定してください。</div>
|
<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>
|
サイトキーに"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>
|
</div>
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
</template>
|
</template>
|
||||||
|
@ -99,12 +117,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
|
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
|
||||||
<template #label>{{ i18n.ts.preview }}</template>
|
<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>
|
</FormSlot>
|
||||||
<MkInfo>
|
<MkInfo>
|
||||||
<div :class="$style.captchaInfoMsg">
|
<div :class="$style.captchaInfoMsg">
|
||||||
<div>サイトキーに"1x00000000000000000000AA"と入力することで動作をテスト出来ます。<br/>本番運用時には必ず正規のサイトキーを設定してください。</div>
|
<div>
|
||||||
<div>ref: <a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a></div>
|
サイトキーに"1x00000000000000000000AA"と入力することで動作をテスト出来ます。<br/>本番運用時には必ず正規のサイトキーを設定してください。
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
ref: <a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare
|
||||||
|
Docs</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
</template>
|
</template>
|
||||||
|
@ -113,15 +139,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
|
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
|
||||||
<FormSlot>
|
<FormSlot>
|
||||||
<template #label>{{ i18n.ts.preview }}</template>
|
<template #label>{{ i18n.ts.preview }}</template>
|
||||||
<MkCaptcha v-model="testCaptchaResponse" provider="testcaptcha" :sitekey="null"/>
|
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
|
||||||
</FormSlot>
|
</FormSlot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<MkInfo v-if="!verifyResult && verifyErrorText" warn>
|
||||||
|
{{ verifyErrorText }}
|
||||||
|
</MkInfo>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 MkRadios from '@/components/MkRadios.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import FormSlot from '@/components/form/slot.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 meta = await misskeyApi('admin/meta');
|
||||||
|
|
||||||
const hCaptchaResponse = ref<string | null>(null);
|
const captchaResult = ref<string | null>(null);
|
||||||
const mCaptchaResponse = ref<string | null>(null);
|
const verifyResult = ref<boolean>(false);
|
||||||
const reCaptchaResponse = ref<string | null>(null);
|
const verifyErrorText = ref<string | null>(null);
|
||||||
const turnstileResponse = ref<string | null>(null);
|
|
||||||
const testCaptchaResponse = ref<string | null>(null);
|
|
||||||
|
|
||||||
const canSaving = computed((): boolean => {
|
const canSaving = computed((): boolean => {
|
||||||
return (botProtectionForm.state.provider === null) ||
|
return (botProtectionForm.state.provider === null) ||
|
||||||
(botProtectionForm.state.provider === 'hcaptcha' && !!hCaptchaResponse.value) ||
|
(botProtectionForm.state.provider === 'hcaptcha' && verifyResult.value) ||
|
||||||
(botProtectionForm.state.provider === 'mcaptcha' && !!mCaptchaResponse.value) ||
|
(botProtectionForm.state.provider === 'mcaptcha' && verifyResult.value) ||
|
||||||
(botProtectionForm.state.provider === 'recaptcha' && !!reCaptchaResponse.value) ||
|
(botProtectionForm.state.provider === 'recaptcha' && verifyResult.value) ||
|
||||||
(botProtectionForm.state.provider === 'turnstile' && !!turnstileResponse.value) ||
|
(botProtectionForm.state.provider === 'turnstile' && verifyResult.value) ||
|
||||||
(botProtectionForm.state.provider === 'testcaptcha' && !!testCaptchaResponse.value);
|
(botProtectionForm.state.provider === 'testcaptcha' && verifyResult.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const botProtectionForm = useForm({
|
const botProtectionForm = useForm({
|
||||||
|
@ -193,6 +222,56 @@ const botProtectionForm = useForm({
|
||||||
});
|
});
|
||||||
fetchInstance(true);
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
|
@ -136,6 +136,12 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
|
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)
|
// @public (undocumented)
|
||||||
type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
|
type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
@ -1261,6 +1267,8 @@ declare namespace entities {
|
||||||
AdminAvatarDecorationsListRequest,
|
AdminAvatarDecorationsListRequest,
|
||||||
AdminAvatarDecorationsListResponse,
|
AdminAvatarDecorationsListResponse,
|
||||||
AdminAvatarDecorationsUpdateRequest,
|
AdminAvatarDecorationsUpdateRequest,
|
||||||
|
AdminCaptchaTestRequest,
|
||||||
|
AdminCaptchaTestResponse,
|
||||||
AdminDeleteAllFilesOfAUserRequest,
|
AdminDeleteAllFilesOfAUserRequest,
|
||||||
AdminUnsetUserAvatarRequest,
|
AdminUnsetUserAvatarRequest,
|
||||||
AdminUnsetUserBannerRequest,
|
AdminUnsetUserBannerRequest,
|
||||||
|
|
|
@ -250,6 +250,18 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): 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.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
@ -36,6 +36,8 @@ import type {
|
||||||
AdminAvatarDecorationsListRequest,
|
AdminAvatarDecorationsListRequest,
|
||||||
AdminAvatarDecorationsListResponse,
|
AdminAvatarDecorationsListResponse,
|
||||||
AdminAvatarDecorationsUpdateRequest,
|
AdminAvatarDecorationsUpdateRequest,
|
||||||
|
AdminCaptchaTestRequest,
|
||||||
|
AdminCaptchaTestResponse,
|
||||||
AdminDeleteAllFilesOfAUserRequest,
|
AdminDeleteAllFilesOfAUserRequest,
|
||||||
AdminUnsetUserAvatarRequest,
|
AdminUnsetUserAvatarRequest,
|
||||||
AdminUnsetUserBannerRequest,
|
AdminUnsetUserBannerRequest,
|
||||||
|
@ -604,6 +606,7 @@ export type Endpoints = {
|
||||||
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
|
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
|
||||||
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
|
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
|
||||||
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
|
'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/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse };
|
||||||
'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse };
|
'admin/unset-user-avatar': { req: AdminUnsetUserAvatarRequest; res: EmptyResponse };
|
||||||
'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse };
|
'admin/unset-user-banner': { req: AdminUnsetUserBannerRequest; res: EmptyResponse };
|
||||||
|
|
|
@ -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 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 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 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 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 AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json'];
|
||||||
export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json'];
|
export type AdminUnsetUserBannerRequest = operations['admin___unset-user-banner']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -215,6 +215,16 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['admin___avatar-decorations___update'];
|
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': {
|
||||||
/**
|
/**
|
||||||
* 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
|
* admin/delete-all-files-of-a-user
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
Loading…
Add table
Reference in a new issue