feat(frontend): Botプロテクションの設定変更時は実際に検証を通過しないと保存できないようにする (#15151)

* feat(frontend): CAPTCHAの設定変更時は実際に検証を通過しないと保存できないようにする

* なしでも保存できるようにした

* fix CHANGELOG.md

* フォームが増殖するのを修正

* add comment

* add server-side verify

* fix ci

* fix

* fix

* fix i18n

* add current.ts

* fix text

* fix

* regenerate locales

* fix MkFormFooter.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
おさむのひと
2025-01-14 19:57:58 +09:00
committed by GitHub
parent 7fbfc2e046
commit 64501c69a1
19 changed files with 1597 additions and 89 deletions

View File

@@ -6,6 +6,65 @@
import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { MiMeta } from '@/models/Meta.js';
import Logger from '@/logger.js';
import { LoggerService } from './LoggerService.js';
export const supportedCaptchaProviders = ['none', '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 type CaptchaSetting = {
provider: CaptchaProvider;
hcaptcha: {
siteKey: string | null;
secretKey: string | null;
}
mcaptcha: {
siteKey: string | null;
secretKey: string | null;
instanceUrl: string | null;
}
recaptcha: {
siteKey: string | null;
secretKey: string | null;
}
turnstile: {
siteKey: string | null;
secretKey: string | null;
}
}
export class CaptchaError extends Error {
public readonly code: CaptchaErrorCode;
public readonly cause?: unknown;
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
super(message);
this.code = code;
this.cause = cause;
this.name = 'CaptchaError';
}
}
export type CaptchaSaveSuccess = {
success: true;
}
export type CaptchaSaveFailure = {
success: false;
error: CaptchaError;
}
export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;
type CaptchaResponse = {
success: boolean;
@@ -14,9 +73,14 @@ type CaptchaResponse = {
@Injectable()
export class CaptchaService {
private readonly logger: Logger;
constructor(
private httpRequestService: HttpRequestService,
private metaService: MetaService,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('captcha');
}
@bindThis
@@ -44,32 +108,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 +141,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);
@@ -91,46 +155,251 @@ export class CaptchaService {
headers: {
'Content-Type': 'application/json',
},
});
}, { throwErrorWhenResponseNotOk: false });
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');
}
}
@bindThis
public async get(): Promise<CaptchaSetting> {
const meta = await this.metaService.fetch(true);
let provider: CaptchaProvider;
switch (true) {
case meta.enableHcaptcha: {
provider = 'hcaptcha';
break;
}
case meta.enableMcaptcha: {
provider = 'mcaptcha';
break;
}
case meta.enableRecaptcha: {
provider = 'recaptcha';
break;
}
case meta.enableTurnstile: {
provider = 'turnstile';
break;
}
case meta.enableTestcaptcha: {
provider = 'testcaptcha';
break;
}
default: {
provider = 'none';
break;
}
}
return {
provider: provider,
hcaptcha: {
siteKey: meta.hcaptchaSiteKey,
secretKey: meta.hcaptchaSecretKey,
},
mcaptcha: {
siteKey: meta.mcaptchaSitekey,
secretKey: meta.mcaptchaSecretKey,
instanceUrl: meta.mcaptchaInstanceUrl,
},
recaptcha: {
siteKey: meta.recaptchaSiteKey,
secretKey: meta.recaptchaSecretKey,
},
turnstile: {
siteKey: meta.turnstileSiteKey,
secretKey: meta.turnstileSecretKey,
},
};
}
/**
* captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します.
* 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します.
*
* @param provider 検証するcaptchaのプロバイダ
* @param params
* @param params.sitekey hcaptcha, recaptcha, turnstile, 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 save(
provider: CaptchaProvider,
params?: {
sitekey?: string | null;
secret?: string | null;
instanceUrl?: string | null;
captchaResult?: string | null;
},
): Promise<CaptchaSaveResult> {
if (!supportedCaptchaProviders.includes(provider)) {
return {
success: false,
error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`),
};
}
const operation = {
none: async () => {
await this.updateMeta(provider, params);
},
hcaptcha: async () => {
if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required');
}
await this.verifyHcaptcha(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
},
mcaptcha: async () => {
if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required');
}
await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult);
await this.updateMeta(provider, params);
},
recaptcha: async () => {
if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required');
}
await this.verifyRecaptcha(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
},
turnstile: async () => {
if (!params?.secret || !params.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required');
}
await this.verifyTurnstile(params.secret, params.captchaResult);
await this.updateMeta(provider, params);
},
testcaptcha: async () => {
if (!params?.captchaResult) {
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required');
}
await this.verifyTestcaptcha(params.captchaResult);
await this.updateMeta(provider, params);
},
}[provider];
return operation()
.then(() => ({ success: true }) as CaptchaSaveSuccess)
.catch(err => {
this.logger.info(err);
const error = err instanceof CaptchaError
? err
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
return {
success: false,
error,
};
});
}
@bindThis
private async updateMeta(
provider: CaptchaProvider,
params?: {
sitekey?: string | null;
secret?: string | null;
instanceUrl?: string | null;
},
) {
const metaPartial: Partial<
Pick<
MiMeta,
('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') |
('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') |
('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') |
('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') |
('enableTestcaptcha')
>
> = {
enableHcaptcha: provider === 'hcaptcha',
enableMcaptcha: provider === 'mcaptcha',
enableRecaptcha: provider === 'recaptcha',
enableTurnstile: provider === 'turnstile',
enableTestcaptcha: provider === 'testcaptcha',
};
const updateIfNotUndefined = <K extends keyof typeof metaPartial>(key: K, value: typeof metaPartial[K]) => {
if (value !== undefined) {
metaPartial[key] = value;
}
};
switch (provider) {
case 'hcaptcha': {
updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey);
updateIfNotUndefined('hcaptchaSecretKey', params?.secret);
break;
}
case 'mcaptcha': {
updateIfNotUndefined('mcaptchaSitekey', params?.sitekey);
updateIfNotUndefined('mcaptchaSecretKey', params?.secret);
updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl);
break;
}
case 'recaptcha': {
updateIfNotUndefined('recaptchaSiteKey', params?.sitekey);
updateIfNotUndefined('recaptchaSecretKey', params?.secret);
break;
}
case 'turnstile': {
updateIfNotUndefined('turnstileSiteKey', params?.sitekey);
updateIfNotUndefined('turnstileSecretKey', params?.secret);
break;
}
}
await this.metaService.update(metaPartial);
}
}

View File

@@ -28,6 +28,8 @@ 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_current from './endpoints/admin/captcha/current.js';
import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.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 +418,8 @@ 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_current: Provider = { provide: 'ep:admin/captcha/current', useClass: ep___admin_captcha_current.default };
const $admin_captcha_save: Provider = { provide: 'ep:admin/captcha/save', useClass: ep___admin_captcha_save.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 +812,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_current,
$admin_captcha_save,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,
@@ -1194,6 +1200,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_avatarDecorations_delete,
$admin_avatarDecorations_list,
$admin_avatarDecorations_update,
$admin_captcha_current,
$admin_captcha_save,
$admin_deleteAllFilesOfAUser,
$admin_unsetUserAvatar,
$admin_unsetUserBanner,

View File

@@ -33,6 +33,8 @@ 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_current from './endpoints/admin/captcha/current.js';
import * as ep___admin_captcha_save from './endpoints/admin/captcha/save.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 +422,8 @@ 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/current', ep___admin_captcha_current],
['admin/captcha/save', ep___admin_captcha_save],
['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,70 @@
/*
* 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,
requireAdmin: true,
// 実態はmetaの取得であるため
kind: 'read:admin:meta',
res: {
type: 'object',
properties: {
provider: {
type: 'string',
enum: supportedCaptchaProviders,
},
hcaptcha: {
type: 'object',
properties: {
siteKey: { type: 'string', nullable: true },
secretKey: { type: 'string', nullable: true },
},
},
mcaptcha: {
type: 'object',
properties: {
siteKey: { type: 'string', nullable: true },
secretKey: { type: 'string', nullable: true },
instanceUrl: { type: 'string', nullable: true },
},
},
recaptcha: {
type: 'object',
properties: {
siteKey: { type: 'string', nullable: true },
secretKey: { type: 'string', nullable: true },
},
},
turnstile: {
type: 'object',
properties: {
siteKey: { type: 'string', nullable: true },
secretKey: { type: 'string', nullable: true },
},
},
},
},
} as const;
export const paramDef = {} 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 () => {
return this.captchaService.get();
});
}
}

View File

@@ -0,0 +1,129 @@
/*
* 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 { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['admin', 'captcha'],
requireCredential: true,
requireAdmin: true,
// 実態はmetaの更新であるため
kind: 'write:admin:meta',
errors: {
invalidProvider: {
message: 'Invalid provider.',
code: 'INVALID_PROVIDER',
id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0',
httpStatusCode: 400,
},
invalidParameters: {
message: 'Invalid parameters.',
code: 'INVALID_PARAMETERS',
id: '26654194-410e-44e2-b42e-460ff6f92476',
httpStatusCode: 400,
},
noResponseProvided: {
message: 'No response provided.',
code: 'NO_RESPONSE_PROVIDED',
id: '40acbba8-0937-41fb-bb3f-474514d40afe',
httpStatusCode: 400,
},
requestFailed: {
message: 'Request failed.',
code: 'REQUEST_FAILED',
id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd',
httpStatusCode: 500,
},
verificationFailed: {
message: 'Verification failed.',
code: 'VERIFICATION_FAILED',
id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214',
httpStatusCode: 400,
},
unknown: {
message: 'unknown',
code: 'UNKNOWN',
id: 'f868d509-e257-42a9-99c1-42614b031a97',
httpStatusCode: 500,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
provider: {
type: 'string',
enum: supportedCaptchaProviders,
},
captchaResult: {
type: 'string', nullable: true,
},
sitekey: {
type: 'string', nullable: true,
},
secret: {
type: 'string', nullable: true,
},
instanceUrl: {
type: 'string', nullable: true,
},
},
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) => {
const result = await this.captchaService.save(ps.provider, {
sitekey: ps.sitekey,
secret: ps.secret,
instanceUrl: ps.instanceUrl,
captchaResult: ps.captchaResult,
});
if (!result.success) {
switch (result.error.code) {
case captchaErrorCodes.invalidProvider:
throw new ApiError({
...meta.errors.invalidProvider,
message: result.error.message,
});
case captchaErrorCodes.invalidParameters:
throw new ApiError({
...meta.errors.invalidParameters,
message: result.error.message,
});
case captchaErrorCodes.noResponseProvided:
throw new ApiError({
...meta.errors.noResponseProvided,
message: result.error.message,
});
case captchaErrorCodes.requestFailed:
throw new ApiError({
...meta.errors.requestFailed,
message: result.error.message,
});
case captchaErrorCodes.verificationFailed:
throw new ApiError({
...meta.errors.verificationFailed,
message: result.error.message,
});
default:
throw new ApiError(meta.errors.unknown);
}
}
});
}
}