diff --git a/locales/index.d.ts b/locales/index.d.ts index 1f25edd0ef..09af138430 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -414,6 +414,7 @@ export interface Locale { "administrator": string; "token": string; "2fa": string; + "setupOf2fa": string; "totp": string; "totpDescription": string; "moderator": string; @@ -1811,9 +1812,10 @@ export interface Locale { "step1": string; "step2": string; "step2Click": string; - "step2Url": string; + "step2Uri": string; "step3Title": string; "step3": string; + "setupCompleted": string; "step4": string; "securityKeyNotSupported": string; "registerTOTPBeforeKey": string; @@ -1829,6 +1831,9 @@ export interface Locale { "renewTOTPConfirm": string; "renewTOTPOk": string; "renewTOTPCancel": string; + "checkBackupCodesBeforeCloseThisWizard": string; + "backupCodes": string; + "backupCodesDescription": string; }; "_permissions": { "read:account": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2e0e64bbef..c20d2aac80 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -411,6 +411,7 @@ aboutMisskey: "Misskeyについて" administrator: "管理者" token: "確認コード" 2fa: "二要素認証" +setupOf2fa: "二要素認証のセットアップ" totp: "認証アプリ" totpDescription: "認証アプリを使ってワンタイムパスワードを入力" moderator: "モデレーター" @@ -1729,10 +1730,11 @@ _2fa: step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step2: "次に、表示されているQRコードをアプリでスキャンします。" step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" - step2Url: "デスクトップアプリでは次のURIを入力します:" + step2Uri: "デスクトップアプリを使用する場合は次のURIを入力します" step3Title: "確認コードを入力" - step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" - step4: "これからログインするときも、同じように確認コードを入力します。" + step3: "アプリに表示されている確認コード(トークン)を入力します。" + setupCompleted: "設定が完了しました" + step4: "これからログインするときも、同じようにコードを入力します。" securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" @@ -1744,9 +1746,12 @@ _2fa: removeKeyConfirm: "{name}を削除しますか?" whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" renewTOTP: "認証アプリを再設定" - renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" + renewTOTPConfirm: "今までの認証アプリの確認コードおよびバックアップコードは使用できなくなります" renewTOTPOk: "再設定する" renewTOTPCancel: "やめておく" + checkBackupCodesBeforeCloseThisWizard: "このウィザードを閉じる前に、以下のバックアップコードを確認してください。" + backupCodes: "バックアップコード" + backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。" _permissions: "read:account": "アカウントの情報を見る" diff --git a/packages/backend/migration/1690569881926-user-2fa-backup-codes.js b/packages/backend/migration/1690569881926-user-2fa-backup-codes.js new file mode 100644 index 0000000000..2049df8ea2 --- /dev/null +++ b/packages/backend/migration/1690569881926-user-2fa-backup-codes.js @@ -0,0 +1,11 @@ +export class User2faBackupCodes1690569881926 { + name = 'User2faBackupCodes1690569881926' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "twoFactorBackupSecret" character varying array`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "twoFactorBackupSecret"`); + } +} diff --git a/packages/backend/src/models/entities/UserProfile.ts b/packages/backend/src/models/entities/UserProfile.ts index 54144cb429..0fd26f4d63 100644 --- a/packages/backend/src/models/entities/UserProfile.ts +++ b/packages/backend/src/models/entities/UserProfile.ts @@ -101,6 +101,11 @@ export class MiUserProfile { }) public twoFactorSecret: string | null; + @Column('varchar', { + nullable: true, array: true, + }) + public twoFactorBackupSecret: string[] | null; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index d68b2617e3..58a5cca4fc 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -160,6 +160,13 @@ export class SigninApiService { }); } + if (profile.twoFactorBackupSecret?.includes(token)) { + await this.userProfilesRepository.update({ userId: profile.userId }, { + twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token), + }); + return this.signinService.signin(request, reply, user); + } + const delta = OTPAuth.TOTP.validate({ secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!), digits: 6, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index e508a28cc0..01c9532ca8 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -54,8 +54,13 @@ export default class extends Endpoint { // eslint- throw new Error('not verified'); } + const backupCodes = Array.from({ length: 20 }, () => { + return new OTPAuth.Secret().base32; + }); + await this.userProfilesRepository.update(me.id, { twoFactorSecret: profile.twoFactorTempSecret, + twoFactorBackupSecret: backupCodes, twoFactorEnabled: true, }); @@ -64,6 +69,10 @@ export default class extends Endpoint { // eslint- detail: true, includeSecrets: true, })); + + return { + backupCodes: backupCodes, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index ee58fb2af4..e017e2ef53 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -46,6 +46,7 @@ export default class extends Endpoint { // eslint- await this.userProfilesRepository.update(me.id, { twoFactorSecret: null, + twoFactorBackupSecret: null, twoFactorEnabled: false, usePasswordLessLogin: false, }); diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 387249871e..0aa7427da8 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -191,7 +191,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const usersShowResponse = await api('/users/show', { username, @@ -216,7 +216,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('/i/2fa/register-key', { password, @@ -272,7 +272,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('/i/2fa/register-key', { password, @@ -329,7 +329,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('/i/2fa/register-key', { password, @@ -371,7 +371,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const registerKeyResponse = await api('/i/2fa/register-key', { password, @@ -423,7 +423,7 @@ describe('2要素認証', () => { const doneResponse = await api('/i/2fa/done', { token: otpToken(registerResponse.body.secret), }, alice); - assert.strictEqual(doneResponse.status, 204); + assert.strictEqual(doneResponse.status, 200); const usersShowResponse = await api('/users/show', { username, diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 2f1130d992..19f418b48d 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index ec251c6640..6d68a26c3c 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -175,6 +175,14 @@ definePageMetadata({ position: relative; } +.code { + background: #2d2d2d; + color: #ccc; + font-size: 14px; + line-height: 1.5; + padding: 5px; +} + .ui { padding: 32px; } diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue index 245d3e79e8..657b9a1642 100644 --- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -4,45 +4,109 @@ SPDX-License-Identifier: AGPL-3.0-only -->