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: enhance: require captcha for signin (#14655) * wip * Update MkSignin.vue * Update MkSignin.vue * wip * Update CHANGELOG.md --- .../backend/src/server/api/SigninApiService.ts | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'packages/backend/src/server/api/SigninApiService.ts') diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index edac9b3beb..2ccc75da00 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 { -- cgit v1.2.3-freya From 975c2e7bc567618c3f8b0082afcba6530d679dae Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:23:33 +0900 Subject: enhance(frontend): サインイン画面の改善 (#14658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Update MkSignin.vue * Update MkSignin.vue * wip * Update CHANGELOG.md * enhance(frontend): サインイン画面の改善 * Update Changelog * 14655の変更取り込み * spdx * fix * fix * fix * :art: * :art: * :art: * :art: * Captchaがリセットされない問題を修正 * 次の処理をsignin apiから読み取るように * Add Comments * fix * fix test * attempt to fix test * fix test * fix test * fix test * fix * fix test * fix: 一部のエラーがちゃんと出るように * Update Changelog * :art: * :art: * remove border --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 4 + cypress/e2e/basic.cy.ts | 14 +- cypress/support/commands.ts | 4 +- locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../backend/src/core/entities/UserEntityService.ts | 13 +- packages/backend/src/models/json-schema/user.ts | 42 +- .../backend/src/server/api/SigninApiService.ts | 86 ++- packages/backend/test/e2e/2fa.ts | 99 +-- packages/backend/test/e2e/users.ts | 15 +- .../frontend/src/components/MkSignin.input.vue | 206 ++++++ .../frontend/src/components/MkSignin.passkey.vue | 92 +++ .../frontend/src/components/MkSignin.password.vue | 181 ++++++ packages/frontend/src/components/MkSignin.totp.vue | 74 +++ packages/frontend/src/components/MkSignin.vue | 694 ++++++++++----------- .../frontend/src/components/MkSigninDialog.vue | 80 ++- packages/misskey-js/etc/misskey-js.api.md | 2 +- packages/misskey-js/src/autogen/types.ts | 15 +- packages/misskey-js/src/entities.ts | 2 +- 19 files changed, 1150 insertions(+), 478 deletions(-) create mode 100644 packages/frontend/src/components/MkSignin.input.vue create mode 100644 packages/frontend/src/components/MkSignin.passkey.vue create mode 100644 packages/frontend/src/components/MkSignin.password.vue create mode 100644 packages/frontend/src/components/MkSignin.totp.vue (limited to 'packages/backend/src/server/api/SigninApiService.ts') diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4ddabd55..a31be063f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) - ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。 - なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能です(UI上で初期パスワードの入力欄を空欄にすると続行できます)。 +- ユーザーデータを読み込む際の型が一部変更されました。 + - `twoFactorEnabled`, `usePasswordLessLogin`, `securityKeys`: 自分とモデレーター以外のユーザーからは取得できなくなりました ### General - Feat: サーバー初期設定時に初期パスワードを設定できるように @@ -14,9 +16,11 @@ ### Client - Enhance: デザインの調整 +- Enhance: ログイン画面の認証フローを改善 ### Server - Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように +- Enhance: 自分とモデレーター以外のユーザーから二要素認証関連のデータが取得できないように ## 2024.9.0 diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index e4baeacbf3..c9d7e0a24a 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -123,8 +123,13 @@ describe('After user signup', () => { cy.intercept('POST', '/api/signin').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - // Enterキーでサインインできるかの確認も兼ねる + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + // Enterキーで続行できるかの確認も兼ねる + cy.get('[data-cy-signin-username] input').type('alice{enter}'); + + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); + // Enterキーで続行できるかの確認も兼ねる cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); cy.wait('@signin'); @@ -139,8 +144,9 @@ describe('After user signup', () => { cy.visitHome(); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type('alice{enter}'); // TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 3cdf4e2087..ed5cda31b0 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -58,7 +58,9 @@ Cypress.Commands.add('login', (username, password) => { cy.intercept('POST', '/api/signin').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type(username); + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type(`${username}{enter}`); + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); cy.get('[data-cy-signin-password] input').type(`${password}{enter}`); cy.wait('@signin').as('signedIn'); diff --git a/locales/index.d.ts b/locales/index.d.ts index 86a6df3100..1a0547ebc6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3714,6 +3714,10 @@ export interface Locale extends ILocale { * パスワードが間違っています。 */ "incorrectPassword": string; + /** + * ワンタイムパスワードが間違っているか、期限切れになっています。 + */ + "incorrectTotp": string; /** * 「{choice}」に投票しますか? */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 62317cd5e6..92014c8abc 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -924,6 +924,7 @@ followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見る" deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" incorrectPassword: "パスワードが間違っています。" +incorrectTotp: "ワンタイムパスワードが間違っているか、期限切れになっています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 69e2d6fc89..c9939adf11 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -545,11 +545,6 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, - twoFactorEnabled: profile!.twoFactorEnabled, - usePasswordLessLogin: profile!.usePasswordLessLogin, - securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) - : false, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, @@ -564,6 +559,14 @@ export class UserEntityService implements OnModuleInit { moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), + ...(isDetailed && (isMe || iAmModerator) ? { + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) + : false, + } : {}), + ...(isDetailed && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 16c8a5a097..9cffd680f2 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -346,21 +346,6 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, enum: ['public', 'followers', 'private'], }, - twoFactorEnabled: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - usePasswordLessLogin: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, - securityKeys: { - type: 'boolean', - nullable: false, optional: false, - default: false, - }, roles: { type: 'array', nullable: false, optional: false, @@ -382,6 +367,18 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: true, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: true, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: true, + }, //#region relations isFollowing: { type: 'boolean', @@ -630,6 +627,21 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, ref: 'RolePolicies', }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, //#region secrets email: { type: 'string', diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 2ccc75da00..81684beb3c 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -12,6 +12,7 @@ import type { MiMeta, SigninsRepository, UserProfilesRepository, + UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -25,9 +26,27 @@ 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'; +import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; +/** + * next を指定すると、次にクライアント側で行うべき処理を指定できる。 + * + * - `captcha`: パスワードと、(有効になっている場合は)CAPTCHAを求める + * - `password`: パスワードを求める + * - `totp`: ワンタイムパスワードを求める + * - `passkey`: WebAuthn認証を求める(WebAuthnに対応していないブラウザの場合はワンタイムパスワード) + */ + +type SigninErrorResponse = { + id: string; + next?: 'captcha' | 'password' | 'totp'; +} | { + id: string; + next: 'passkey'; + authRequest: PublicKeyCredentialRequestOptionsJSON; +}; + @Injectable() export class SigninApiService { constructor( @@ -43,6 +62,9 @@ export class SigninApiService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, @@ -60,7 +82,7 @@ export class SigninApiService { request: FastifyRequest<{ Body: { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; 'hcaptcha-response'?: string; @@ -79,7 +101,7 @@ export class SigninApiService { const password = body['password']; const token = body['token']; - function error(status: number, error: { id: string }) { + function error(status: number, error: SigninErrorResponse) { reply.code(status); return { error }; } @@ -103,11 +125,6 @@ export class SigninApiService { return; } - if (typeof password !== 'string') { - reply.code(400); - return; - } - if (token != null && typeof token !== 'string') { reply.code(400); return; @@ -132,11 +149,36 @@ export class SigninApiService { } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); + + if (password == null) { + reply.code(403); + if (profile.twoFactorEnabled) { + return { + error: { + id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', + next: 'password', + }, + } satisfies { error: SigninErrorResponse }; + } else { + return { + error: { + id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', + next: 'captcha', + }, + } satisfies { error: SigninErrorResponse }; + } + } + + if (typeof password !== 'string') { + reply.code(400); + return; + } // Compare password const same = await bcrypt.compare(password, profile.password!); - const fail = async (status?: number, failure?: { id: string }) => { + const fail = async (status?: number, failure?: SigninErrorResponse) => { // Append signin history await this.signinsRepository.insert({ id: this.idService.gen(), @@ -217,7 +259,7 @@ export class SigninApiService { id: '93b86c4b-72f9-40eb-9815-798928603d1e', }); } - } else { + } else if (securityKeysAvailable) { if (!same && !profile.usePasswordLessLogin) { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', @@ -226,8 +268,28 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); - reply.code(200); - return authRequest; + reply.code(403); + return { + error: { + id: '06e661b9-8146-4ae3-bde5-47138c0ae0c4', + next: 'passkey', + authRequest, + }, + } satisfies { error: SigninErrorResponse }; + } else { + if (!same || !profile.twoFactorEnabled) { + return await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } else { + reply.code(403); + return { + error: { + id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', + next: 'totp', + }, + } satisfies { error: SigninErrorResponse }; + } } // never get here } diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 06548fa7da..88c32b4346 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -136,13 +136,7 @@ describe('2要素認証', () => { keyName: string, credentialId: Buffer, requestOptions: PublicKeyCredentialRequestOptionsJSON, - }): { - username: string, - password: string, - credential: AuthenticationResponseJSON, - 'g-recaptcha-response'?: string | null, - 'hcaptcha-response'?: string | null, - } => { + }): misskey.entities.SigninRequest => { // AuthenticatorAssertionResponse.authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const authenticatorData = Buffer.concat([ @@ -202,11 +196,16 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }, alice); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const signinWithoutTokenResponse = await api('signin', { + ...signinParam(), + }); + assert.strictEqual(signinWithoutTokenResponse.status, 403); + assert.deepStrictEqual(signinWithoutTokenResponse.body, { + error: { + id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', + next: 'totp', + }, + }); const signinResponse = await api('signin', { ...signinParam(), @@ -253,26 +252,28 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true); - const signinResponse = await api('signin', { ...signinParam(), }); - assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); - assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); - assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); + const signinResponseBody = signinResponse.body as unknown as { + error: { + id: string; + next: 'passkey'; + authRequest: PublicKeyCredentialRequestOptionsJSON; + }; + }; + assert.strictEqual(signinResponse.status, 403); + assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4'); + assert.strictEqual(signinResponseBody.error.next, 'passkey'); + assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined); + assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined); + assert.strictEqual(signinResponseBody.error.authRequest.allowCredentials && signinResponseBody.error.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url')); const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, - } as any)); + requestOptions: signinResponseBody.error.authRequest, + })); assert.strictEqual(signinResponse2.status, 200); assert.notEqual(signinResponse2.body.i, undefined); @@ -315,24 +316,32 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(passwordLessResponse.status, 204); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.usePasswordLessLogin, true); const signinResponse = await api('signin', { ...signinParam(), password: '', }); - assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); + const signinResponseBody = signinResponse.body as unknown as { + error: { + id: string; + next: 'passkey'; + authRequest: PublicKeyCredentialRequestOptionsJSON; + }; + }; + assert.strictEqual(signinResponse.status, 403); + assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4'); + assert.strictEqual(signinResponseBody.error.next, 'passkey'); + assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined); + assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined); const signinResponse2 = await api('signin', { ...signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, + requestOptions: signinResponseBody.error.authRequest, } as any), password: '', }); @@ -424,11 +433,11 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.status, 200); // テストの実行順によっては複数残ってるので全部消す - const iResponse = await api('i', { + const beforeIResponse = await api('i', { }, alice); - assert.strictEqual(iResponse.status, 200); - assert.ok(iResponse.body.securityKeysList); - for (const key of iResponse.body.securityKeysList) { + assert.strictEqual(beforeIResponse.status, 200); + assert.ok(beforeIResponse.body.securityKeysList); + for (const key of beforeIResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { token: otpToken(registerResponse.body.secret), password, @@ -437,11 +446,9 @@ describe('2要素認証', () => { assert.strictEqual(removeKeyResponse.status, 200); } - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false); + const afterIResponse = await api('i', {}, alice); + assert.strictEqual(afterIResponse.status, 200); + assert.strictEqual(afterIResponse.body.securityKeys, false); const signinResponse = await api('signin', { ...signinParam(), @@ -468,11 +475,9 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { token: otpToken(registerResponse.body.secret), diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 8ebe9af792..822ca14ae6 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -83,9 +83,6 @@ describe('ユーザー', () => { publicReactions: user.publicReactions, followingVisibility: user.followingVisibility, followersVisibility: user.followersVisibility, - twoFactorEnabled: user.twoFactorEnabled, - usePasswordLessLogin: user.usePasswordLessLogin, - securityKeys: user.securityKeys, roles: user.roles, memo: user.memo, }); @@ -149,6 +146,9 @@ describe('ユーザー', () => { achievements: user.achievements, loggedInDays: user.loggedInDays, policies: user.policies, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, ...(security ? { email: user.email, emailVerified: user.emailVerified, @@ -343,9 +343,6 @@ describe('ユーザー', () => { assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public'); - assert.strictEqual(response.twoFactorEnabled, false); - assert.strictEqual(response.usePasswordLessLogin, false); - assert.strictEqual(response.securityKeys, false); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); @@ -385,6 +382,9 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); assert.notStrictEqual(response.email, undefined); assert.strictEqual(response.emailVerified, false); assert.deepStrictEqual(response.securityKeysList, []); @@ -618,6 +618,9 @@ describe('ユーザー', () => { { label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator }, // @ts-expect-error UserDetailedNotMe doesn't include isModerator { label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined }, + { label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false }, + { label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined }, + { label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false }, { label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced }, // FIXME: 落ちる //{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended }, diff --git a/packages/frontend/src/components/MkSignin.input.vue b/packages/frontend/src/components/MkSignin.input.vue new file mode 100644 index 0000000000..6336b78c80 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.input.vue @@ -0,0 +1,206 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSignin.passkey.vue b/packages/frontend/src/components/MkSignin.passkey.vue new file mode 100644 index 0000000000..0d68955fab --- /dev/null +++ b/packages/frontend/src/components/MkSignin.passkey.vue @@ -0,0 +1,92 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue new file mode 100644 index 0000000000..2d79e2aeb1 --- /dev/null +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -0,0 +1,181 @@ + + + + + + + + + diff --git a/packages/frontend/src/components/MkSignin.totp.vue b/packages/frontend/src/components/MkSignin.totp.vue new file mode 100644 index 0000000000..880c08315e --- /dev/null +++ b/packages/frontend/src/components/MkSignin.totp.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index abbff8e1f2..81a98cae0e 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -4,438 +4,402 @@ SPDX-License-Identifier: AGPL-3.0-only --> - diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index d48780e9de..8351d7d5e0 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only --> + + diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 5f4792eb74..9ad784c296 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -3040,7 +3040,7 @@ type Signin = components['schemas']['Signin']; // @public (undocumented) type SigninRequest = { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; 'hcaptcha-response'?: string | null; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 32646d28ed..3876a0bfe5 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3782,16 +3782,13 @@ export type components = { followingVisibility: 'public' | 'followers' | 'private'; /** @enum {string} */ followersVisibility: 'public' | 'followers' | 'private'; - /** @default false */ - twoFactorEnabled: boolean; - /** @default false */ - usePasswordLessLogin: boolean; - /** @default false */ - securityKeys: boolean; roles: components['schemas']['RoleLite'][]; followedMessage?: string | null; memo: string | null; moderationNote?: string; + twoFactorEnabled?: boolean; + usePasswordLessLogin?: boolean; + securityKeys?: boolean; isFollowing?: boolean; isFollowed?: boolean; hasPendingFollowRequestFromYou?: boolean; @@ -3972,6 +3969,12 @@ export type components = { }[]; loggedInDays: number; policies: components['schemas']['RolePolicies']; + /** @default false */ + twoFactorEnabled: boolean; + /** @default false */ + usePasswordLessLogin: boolean; + /** @default false */ + securityKeys: boolean; email?: string | null; emailVerified?: boolean | null; securityKeysList?: { diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 36b7f5bca3..98ac50e5a1 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -269,7 +269,7 @@ export type SignupPendingResponse = { export type SigninRequest = { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; 'hcaptcha-response'?: string | null; -- cgit v1.2.3-freya From ae3c155490d9b5a574c45309744ba2a0cbe78932 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sat, 5 Oct 2024 12:03:47 +0900 Subject: fix: signin の資格情報が足りないだけの場合はエラーにせず200を返すように (#14700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: signin の資格情報が足りないだけの場合はエラーにせず200を返すように * run api extractor * fix * fix * fix test * /signin -> /signin-flow * fix * fix lint * rename * fix * fix --- cypress/e2e/basic.cy.ts | 2 +- cypress/support/commands.ts | 2 +- .../backend/src/server/api/ApiServerService.ts | 2 +- .../backend/src/server/api/SigninApiService.ts | 66 ++---- packages/backend/src/server/api/SigninService.ts | 6 +- packages/backend/test/e2e/2fa.ts | 73 +++---- packages/backend/test/e2e/endpoints.ts | 8 +- packages/frontend/src/components/MkSignin.vue | 236 +++++++++++---------- .../src/components/MkSignupDialog.form.vue | 11 +- .../frontend/src/components/MkSignupDialog.vue | 4 +- packages/misskey-js/etc/misskey-js.api.md | 24 ++- packages/misskey-js/src/api.types.ts | 10 +- packages/misskey-js/src/entities.ts | 22 +- 13 files changed, 231 insertions(+), 235 deletions(-) (limited to 'packages/backend/src/server/api/SigninApiService.ts') diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index c9d7e0a24a..d2efbf709c 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -120,7 +120,7 @@ describe('After user signup', () => { it('signin', () => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index ed5cda31b0..197ff963ac 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -55,7 +55,7 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => { Cypress.Commands.add('login', (username, password) => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 356e145681..6b760c258b 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -133,7 +133,7 @@ export class ApiServerService { 'turnstile-response'?: string; 'm-captcha-response'?: string; }; - }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); fastify.post<{ Body: { diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 81684beb3c..0d24ffa56a 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -5,8 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; import type { MiMeta, @@ -26,27 +26,9 @@ 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, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'; +import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { FastifyReply, FastifyRequest } from 'fastify'; -/** - * next を指定すると、次にクライアント側で行うべき処理を指定できる。 - * - * - `captcha`: パスワードと、(有効になっている場合は)CAPTCHAを求める - * - `password`: パスワードを求める - * - `totp`: ワンタイムパスワードを求める - * - `passkey`: WebAuthn認証を求める(WebAuthnに対応していないブラウザの場合はワンタイムパスワード) - */ - -type SigninErrorResponse = { - id: string; - next?: 'captcha' | 'password' | 'totp'; -} | { - id: string; - next: 'passkey'; - authRequest: PublicKeyCredentialRequestOptionsJSON; -}; - @Injectable() export class SigninApiService { constructor( @@ -101,7 +83,7 @@ export class SigninApiService { const password = body['password']; const token = body['token']; - function error(status: number, error: SigninErrorResponse) { + function error(status: number, error: { id: string }) { reply.code(status); return { error }; } @@ -152,21 +134,17 @@ export class SigninApiService { const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); if (password == null) { - reply.code(403); + reply.code(200); if (profile.twoFactorEnabled) { return { - error: { - id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', - next: 'password', - }, - } satisfies { error: SigninErrorResponse }; + finished: false, + next: 'password', + } satisfies Misskey.entities.SigninFlowResponse; } else { return { - error: { - id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', - next: 'captcha', - }, - } satisfies { error: SigninErrorResponse }; + finished: false, + next: 'captcha', + } satisfies Misskey.entities.SigninFlowResponse; } } @@ -178,7 +156,7 @@ export class SigninApiService { // Compare password const same = await bcrypt.compare(password, profile.password!); - const fail = async (status?: number, failure?: SigninErrorResponse) => { + const fail = async (status?: number, failure?: { id: string; }) => { // Append signin history await this.signinsRepository.insert({ id: this.idService.gen(), @@ -268,27 +246,23 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); - reply.code(403); + reply.code(200); return { - error: { - id: '06e661b9-8146-4ae3-bde5-47138c0ae0c4', - next: 'passkey', - authRequest, - }, - } satisfies { error: SigninErrorResponse }; + finished: false, + next: 'passkey', + authRequest, + } satisfies Misskey.entities.SigninFlowResponse; } else { if (!same || !profile.twoFactorEnabled) { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); } else { - reply.code(403); + reply.code(200); return { - error: { - id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', - next: 'totp', - }, - } satisfies { error: SigninErrorResponse }; + finished: false, + next: 'totp', + } satisfies Misskey.entities.SigninFlowResponse; } } // never get here diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 4b041f373f..640356b50c 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; @@ -57,9 +58,10 @@ export class SigninService { reply.code(200); return { + finished: true, id: user.id, - i: user.token, - }; + i: user.token!, + } satisfies Misskey.entities.SigninFlowResponse; } } diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 88c32b4346..48e1bababb 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -136,7 +136,7 @@ describe('2要素認証', () => { keyName: string, credentialId: Buffer, requestOptions: PublicKeyCredentialRequestOptionsJSON, - }): misskey.entities.SigninRequest => { + }): misskey.entities.SigninFlowRequest => { // AuthenticatorAssertionResponse.authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const authenticatorData = Buffer.concat([ @@ -196,22 +196,21 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const signinWithoutTokenResponse = await api('signin', { + const signinWithoutTokenResponse = await api('signin-flow', { ...signinParam(), }); - assert.strictEqual(signinWithoutTokenResponse.status, 403); + assert.strictEqual(signinWithoutTokenResponse.status, 200); assert.deepStrictEqual(signinWithoutTokenResponse.body, { - error: { - id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf', - next: 'totp', - }, + finished: false, + next: 'totp', }); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -252,29 +251,23 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); - const signinResponseBody = signinResponse.body as unknown as { - error: { - id: string; - next: 'passkey'; - authRequest: PublicKeyCredentialRequestOptionsJSON; - }; - }; - assert.strictEqual(signinResponse.status, 403); - assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4'); - assert.strictEqual(signinResponseBody.error.next, 'passkey'); - assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined); - assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined); - assert.strictEqual(signinResponseBody.error.authRequest.allowCredentials && signinResponseBody.error.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url')); - - const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); + assert.strictEqual(signinResponse.body.authRequest.allowCredentials && signinResponse.body.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url')); + + const signinResponse2 = await api('signin-flow', signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponseBody.error.authRequest, + requestOptions: signinResponse.body.authRequest, })); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -320,32 +313,26 @@ describe('2要素認証', () => { assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.body.usePasswordLessLogin, true); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), password: '', }); - const signinResponseBody = signinResponse.body as unknown as { - error: { - id: string; - next: 'passkey'; - authRequest: PublicKeyCredentialRequestOptionsJSON; - }; - }; - assert.strictEqual(signinResponse.status, 403); - assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4'); - assert.strictEqual(signinResponseBody.error.next, 'passkey'); - assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined); - assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); - const signinResponse2 = await api('signin', { + const signinResponse2 = await api('signin-flow', { ...signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponseBody.error.authRequest, + requestOptions: signinResponse.body.authRequest, } as any), password: '', }); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -450,11 +437,12 @@ describe('2要素認証', () => { assert.strictEqual(afterIResponse.status, 200); assert.strictEqual(afterIResponse.body.securityKeys, false); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -485,10 +473,11 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(unregisterResponse.status, 204); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 5aaec7f6f9..b91d77c398 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -66,9 +66,9 @@ describe('Endpoints', () => { }); }); - describe('signin', () => { + describe('signin-flow', () => { test('間違ったパスワードでサインインできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'bar', }); @@ -77,7 +77,7 @@ describe('Endpoints', () => { }); test('クエリをインジェクションできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', // @ts-expect-error password must be string password: { @@ -89,7 +89,7 @@ describe('Endpoints', () => { }); test('正しい情報でサインインできる', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'test1', }); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 03dd61f6c6..26e1ac516c 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -83,7 +83,7 @@ import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/br import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; const emit = defineEmits<{ - (ev: 'login', v: Misskey.entities.SigninResponse): void; + (ev: 'login', v: Misskey.entities.SigninFlowResponse): void; }>(); const props = withDefaults(defineProps<{ @@ -212,23 +212,63 @@ async function onTotpSubmitted(token: string) { } } -async function tryLogin(req: Partial): Promise { +async function tryLogin(req: Partial): Promise { const _req = { username: req.username ?? userInfo.value?.username, ...req, }; - function assertIsSigninRequest(x: Partial): x is Misskey.entities.SigninRequest { + function assertIsSigninFlowRequest(x: Partial): x is Misskey.entities.SigninFlowRequest { return x.username != null; } - if (!assertIsSigninRequest(_req)) { + if (!assertIsSigninFlowRequest(_req)) { throw new Error('Invalid request'); } - return await misskeyApi('signin', _req).then(async (res) => { - emit('login', res); - await onLoginSucceeded(res); + return await misskeyApi('signin-flow', _req).then(async (res) => { + if (res.finished) { + emit('login', res); + await onLoginSucceeded(res); + } else { + switch (res.next) { + case 'captcha': { + needCaptcha.value = true; + page.value = 'password'; + break; + } + case 'password': { + needCaptcha.value = false; + page.value = 'password'; + break; + } + case 'totp': { + page.value = 'totp'; + break; + } + case 'passkey': { + if (webAuthnSupported()) { + credentialRequest.value = parseRequestOptionsFromJSON({ + publicKey: res.authRequest, + }); + page.value = 'passkey'; + } else { + page.value = 'totp'; + } + break; + } + } + + if (doingPasskeyFromInputPage.value === true) { + doingPasskeyFromInputPage.value = false; + page.value = 'input'; + password.value = ''; + } + passwordPageEl.value?.resetCaptcha(); + nextTick(() => { + waiting.value = false; + }); + } return res; }).catch((err) => { onSigninApiError(err); @@ -236,7 +276,7 @@ async function tryLogin(req: Partial): Promise(); @@ -269,14 +269,19 @@ async function onSubmit(): Promise { }); emit('signupEmailPending'); } else { - const res = await misskeyApi('signin', { + const res = await misskeyApi('signin-flow', { username: username.value, password: password.value, }); emit('signup', res); - if (props.autoSet) { + if (props.autoSet && res.finished) { return login(res.i); + } else { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); } } } catch { diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 97310d32a6..4cccd99492 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{ }); const emit = defineEmits<{ - (ev: 'done', res: Misskey.entities.SigninResponse): void; + (ev: 'done', res: Misskey.entities.SigninFlowResponse): void; (ev: 'closed'): void; }>(); @@ -55,7 +55,7 @@ const dialog = shallowRef>(); const isAcceptedServerRule = ref(false); -function onSignup(res: Misskey.entities.SigninResponse) { +function onSignup(res: Misskey.entities.SigninFlowResponse) { emit('done', res); dialog.value?.close(); } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 9ad784c296..732352abd8 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1158,9 +1158,9 @@ export type Endpoints = Overwrite> = T[keyof T]; -- cgit v1.2.3-freya From af1cbc131fc9e045692f9f9def708c0978817fff Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 11 Oct 2024 21:05:53 +0900 Subject: wip (#14745) --- locales/index.d.ts | 4 +++ locales/ja-JP.yml | 1 + .../backend/migration/1728550878802-testcaptcha.js | 16 ++++++++++++ packages/backend/src/core/CaptchaService.ts | 13 ++++++++++ .../backend/src/core/entities/MetaEntityService.ts | 1 + packages/backend/src/models/Meta.ts | 5 ++++ packages/backend/src/models/json-schema/meta.ts | 4 +++ .../backend/src/server/api/ApiServerService.ts | 2 ++ .../backend/src/server/api/SigninApiService.ts | 7 ++++++ .../backend/src/server/api/SignupApiService.ts | 7 ++++++ .../backend/src/server/api/endpoints/admin/meta.ts | 5 ++++ .../src/server/api/endpoints/admin/update-meta.ts | 5 ++++ packages/frontend/assets/testcaptcha.png | Bin 0 -> 2634 bytes packages/frontend/src/components/MkCaptcha.vue | 28 +++++++++++++++++++-- .../frontend/src/components/MkSignin.password.vue | 9 ++++++- packages/frontend/src/components/MkSignin.vue | 6 ++--- .../src/components/MkSignupDialog.form.vue | 6 +++++ .../frontend/src/pages/admin/bot-protection.vue | 15 ++++++++++- packages/misskey-js/src/autogen/types.ts | 3 +++ 19 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 packages/backend/migration/1728550878802-testcaptcha.js create mode 100644 packages/frontend/assets/testcaptcha.png (limited to 'packages/backend/src/server/api/SigninApiService.ts') diff --git a/locales/index.d.ts b/locales/index.d.ts index f0dead1245..dab8eb0361 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5166,6 +5166,10 @@ export interface Locale extends ILocale { * 対象 */ "target": string; + /** + * CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。 + */ + "testCaptchaWarning": string; "_abuseUserReport": { /** * 転送 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 48a670ce50..440ffa9306 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1287,6 +1287,7 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。" passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" messageToFollower: "フォロワーへのメッセージ" target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" _abuseUserReport: forward: "転送" diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js new file mode 100644 index 0000000000..d8d987c0c1 --- /dev/null +++ b/packages/backend/migration/1728550878802-testcaptcha.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Testcaptcha1728550878802 { + name = 'Testcaptcha1728550878802' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`); + } +} diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..206d0dbe0a 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -119,5 +119,18 @@ export class CaptchaService { throw new Error(`turnstile-failed: ${errorCodes}`); } } + + @bindThis + public async verifyTestcaptcha(response: string | null | undefined): Promise { + if (response == null) { + throw new Error('testcaptcha-failed: no response provided'); + } + + const success = response === 'testcaptcha-passed'; + + if (!success) { + throw new Error('testcaptcha-failed'); + } + } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index fbd982eb34..409dca3426 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -96,6 +96,7 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index d29689f907..fd007de6c6 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -258,6 +258,11 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTestcaptcha: boolean; + // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること @Column('enum', { diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 99feeaa7d7..e3fd63464a 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -115,6 +115,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index be63635efe..3a8cb19f01 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -119,6 +119,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); @@ -132,6 +133,7 @@ export class ApiServerService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 0d24ffa56a..1d983ca4bc 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -71,6 +71,7 @@ export class SigninApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -194,6 +195,12 @@ export class SigninApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } if (same) { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index c499638018..3ec5e5d3e6 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -67,6 +67,7 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, @@ -99,6 +100,12 @@ export class SignupApiService { throw new FastifyReplyError(400, err); }); } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } } const username = body['username']; diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index b76ed5c524..abb3c17be3 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -69,6 +69,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -555,6 +559,7 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 9ffae840b6..e97ac4e2b9 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -78,6 +78,7 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, @@ -357,6 +358,10 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } diff --git a/packages/frontend/assets/testcaptcha.png b/packages/frontend/assets/testcaptcha.png new file mode 100644 index 0000000000..9bfd252b51 Binary files /dev/null and b/packages/frontend/assets/testcaptcha.png differ diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c5b6e0caed..82fc89e51c 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ +
+
Test captcha passed!
+
+
+
Type "ai-chan-kawaii" to pass captcha
+ + +
+
@@ -29,7 +40,7 @@ export type Captcha = { getResponse(id: string): string; }; -export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha'; +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -54,12 +65,16 @@ const available = ref(false); const captchaEl = shallowRef(); +const testcaptchaInput = ref(''); +const testcaptchaPassed = ref(false); + const variable = computed(() => { switch (props.provider) { case 'hcaptcha': return 'hcaptcha'; case 'recaptcha': return 'grecaptcha'; case 'turnstile': return 'turnstile'; case 'mcaptcha': return 'mcaptcha'; + case 'testcaptcha': return 'testcaptcha'; } }); @@ -71,6 +86,7 @@ const src = computed(() => { case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit'; case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; case 'mcaptcha': return null; + case 'testcaptcha': return null; } }); @@ -78,7 +94,7 @@ const scriptId = computed(() => `script-${props.provider}`); const captcha = computed(() => window[variable.value] || {} as unknown as Captcha); -if (loaded || props.provider === 'mcaptcha') { +if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') { available.value = true; } else if (src.value !== null) { (document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), { @@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') { function reset() { if (captcha.value.reset) captcha.value.reset(); + testcaptchaPassed.value = false; + testcaptchaInput.value = ''; } async function requestRender() { @@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) { } } +function testcaptchaSubmit() { + testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii'; + callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined); + if (!testcaptchaPassed.value) testcaptchaInput.value = ''; +} + onMounted(() => { if (available.value) { window.addEventListener('message', onReceivedMessage); diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue index f30bf5f861..5608122a39 100644 --- a/packages/frontend/src/components/MkSignin.password.vue +++ b/packages/frontend/src/components/MkSignin.password.vue @@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only + {{ i18n.ts.continue }} @@ -44,6 +45,7 @@ export type PwResponse = { mCaptchaResponse: string | null; reCaptchaResponse: string | null; turnstileResponse: string | null; + testcaptchaResponse: string | null; }; }; @@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha'); const mCaptcha = useTemplateRef('mcaptcha'); const reCaptcha = useTemplateRef('recaptcha'); const turnstile = useTemplateRef('turnstile'); +const testcaptcha = useTemplateRef('testcaptcha'); const hCaptchaResponse = ref(null); const mCaptchaResponse = ref(null); const reCaptchaResponse = ref(null); const turnstileResponse = ref(null); +const testcaptchaResponse = ref(null); const captchaFailed = computed((): boolean => { return ( (instance.enableHcaptcha && !hCaptchaResponse.value) || (instance.enableMcaptcha && !mCaptchaResponse.value) || (instance.enableRecaptcha && !reCaptchaResponse.value) || - (instance.enableTurnstile && !turnstileResponse.value) + (instance.enableTurnstile && !turnstileResponse.value) || + (instance.enableTestcaptcha && !testcaptchaResponse.value) ); }); @@ -104,6 +109,7 @@ function onSubmit() { mCaptchaResponse: mCaptchaResponse.value, reCaptchaResponse: reCaptchaResponse.value, turnstileResponse: turnstileResponse.value, + testcaptchaResponse: testcaptchaResponse.value, }, }); } @@ -113,6 +119,7 @@ function resetCaptcha() { mCaptcha.value?.reset(); reCaptcha.value?.reset(); turnstile.value?.reset(); + testcaptcha.value?.reset(); } defineExpose({ diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index a773cefdab..776ee20e36 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue' import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; +import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; +import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { login } from '@/account.js'; @@ -79,9 +81,6 @@ import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue'; import XTotp from '@/components/MkSignin.totp.vue'; import XPasskey from '@/components/MkSignin.passkey.vue'; -import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; -import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; - const emit = defineEmits<{ (ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void; }>(); @@ -188,6 +187,7 @@ async function onPasswordSubmitted(pw: PwResponse) { 'm-captcha-response': pw.captcha.mCaptchaResponse, 'g-recaptcha-response': pw.captcha.reCaptchaResponse, 'turnstile-response': pw.captcha.turnstileResponse, + 'testcaptcha-response': pw.captcha.testcaptchaResponse, }); } } diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index ffb5551ff3..3d1c44fc90 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only +