From 1074d625ed1d651702aca1016cad165e256bab29 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:11:09 +0900 Subject: [PATCH] enhance: require captcha for signin (#14655) * wip * Update MkSignin.vue * Update MkSignin.vue * wip * Update CHANGELOG.md --- CHANGELOG.md | 2 +- .../src/server/api/SigninApiService.ts | 37 +++++++++++++++++++ packages/frontend/src/components/MkSignin.vue | 35 +++++++++++++++++- .../src/components/MkSignupDialog.form.vue | 4 +- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc07476e..8f0fd24c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました ### Client - Enhance: フォロワーへのメッセージ欄のデザイン改良 diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index edac9b3be..2ccc75da0 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -9,6 +9,7 @@ import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { + MiMeta, SigninsRepository, UserProfilesRepository, UsersRepository, @@ -20,6 +21,8 @@ import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; +import { CaptchaService } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; @@ -31,6 +34,9 @@ export class SigninApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,6 +51,7 @@ export class SigninApiService { private signinService: SigninService, private userAuthService: UserAuthService, private webAuthnService: WebAuthnService, + private captchaService: CaptchaService, ) { } @@ -56,6 +63,10 @@ export class SigninApiService { password: string; token?: string; credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; }; }>, reply: FastifyReply, @@ -139,6 +150,32 @@ export class SigninApiService { }; if (!profile.twoFactorEnabled) { + if (process.env.NODE_ENV !== 'test') { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + } + if (same) { return this.signinService.signin(request, reply, user); } else { diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 7942a84d6..8ebdac022 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -32,7 +32,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #prefix><i class="ti ti-lock"></i></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> </MkInput> - <MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> + <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> + <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> + <MkButton type="submit" large primary rounded :disabled="captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> </div> <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> <div v-if="user && user.securityKeys" class="twofa-group tap-group"> @@ -68,7 +72,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; +import { computed, defineAsyncComponent, ref } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; @@ -85,6 +89,8 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; const signing = ref(false); const user = ref<Misskey.entities.UserDetailed | null>(null); @@ -98,6 +104,22 @@ const isBackupCode = ref(false); const queryingKey = ref(false); let credentialRequest: CredentialRequestOptions | null = null; const passkey_context = ref(''); +const hcaptcha = ref<Captcha | undefined>(); +const mcaptcha = ref<Captcha | undefined>(); +const recaptcha = ref<Captcha | undefined>(); +const turnstile = ref<Captcha | undefined>(); +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 captchaFailed = computed((): boolean => { + return ( + instance.enableHcaptcha && !hCaptchaResponse.value || + instance.enableMcaptcha && !mCaptchaResponse.value || + instance.enableRecaptcha && !reCaptchaResponse.value || + instance.enableTurnstile && !turnstileResponse.value); +}); const emit = defineEmits<{ (ev: 'login', v: any): void; @@ -227,6 +249,10 @@ function onSubmit(): void { misskeyApi('signin', { username: username.value, password: password.value, + 'hcaptcha-response': hCaptchaResponse.value, + 'm-captcha-response': mCaptchaResponse.value, + 'g-recaptcha-response': reCaptchaResponse.value, + 'turnstile-response': turnstileResponse.value, token: user.value?.twoFactorEnabled ? token.value : undefined, }).then(res => { emit('login', res); @@ -236,6 +262,11 @@ function onSubmit(): void { } function loginFailed(err: any): void { + hcaptcha.value?.reset?.(); + mcaptcha.value?.reset?.(); + recaptcha.value?.reset?.(); + turnstile.value?.reset?.(); + switch (err.id) { case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { os.alert({ diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 4ab4380ad..38cac7f64 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; +import * as config from '@@/js/config.js'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; -import * as config from '@@/js/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { login } from '@/account.js'; @@ -105,6 +105,7 @@ const emit = defineEmits<{ const host = toUnicode(config.host); const hcaptcha = ref<Captcha | undefined>(); +const mcaptcha = ref<Captcha | undefined>(); const recaptcha = ref<Captcha | undefined>(); const turnstile = ref<Captcha | undefined>(); @@ -281,6 +282,7 @@ async function onSubmit(): Promise<void> { } catch { submitting.value = false; hcaptcha.value?.reset?.(); + mcaptcha.value?.reset?.(); recaptcha.value?.reset?.(); turnstile.value?.reset?.();