summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-08-28 18:25:31 +0900
committerGitHub <noreply@github.com>2023-08-28 18:25:31 +0900
commit257c4fccf1193f111686f039e06cc4d00b9dce37 (patch)
treeb502d371495bc5a6c18349eb9fd9089cee4f4fa0
parentMerge branch 'develop' of https://github.com/misskey-dev/misskey into develop (diff)
downloadmisskey-257c4fccf1193f111686f039e06cc4d00b9dce37.tar.gz
misskey-257c4fccf1193f111686f039e06cc4d00b9dce37.tar.bz2
misskey-257c4fccf1193f111686f039e06cc4d00b9dce37.zip
feat: Refine 2fa (#11766)
* wip * Update 2fa.qrdialog.vue * Update 2fa.vue * Update CHANGELOG.md * tweak * :v:
-rw-r--r--CHANGELOG.md1
-rw-r--r--locales/index.d.ts9
-rw-r--r--locales/ja-JP.yml15
-rw-r--r--packages/backend/migration/1690569881926-user-2fa-backup-codes.js11
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts1
-rw-r--r--packages/backend/src/models/entities/UserProfile.ts5
-rw-r--r--packages/backend/src/models/json-schema/user.ts5
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/done.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts1
-rw-r--r--packages/backend/test/e2e/2fa.ts12
-rw-r--r--packages/backend/test/e2e/users.ts2
-rw-r--r--packages/frontend/.storybook/changes.ts5
-rw-r--r--packages/frontend/.storybook/fakes.ts6
-rw-r--r--packages/frontend/.storybook/generate.tsx5
-rw-r--r--packages/frontend/.storybook/main.ts5
-rw-r--r--packages/frontend/.storybook/manager.ts5
-rw-r--r--packages/frontend/.storybook/mocks.ts5
-rw-r--r--packages/frontend/.storybook/preload-locale.ts5
-rw-r--r--packages/frontend/.storybook/preload-theme.ts5
-rw-r--r--packages/frontend/.storybook/preview.ts5
-rw-r--r--packages/frontend/src/components/MkSignin.vue2
-rw-r--r--packages/frontend/src/pages/scratchpad.vue10
-rw-r--r--packages/frontend/src/pages/settings/2fa.qrdialog.vue178
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue43
-rw-r--r--packages/frontend/src/style.scss9
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md1
-rw-r--r--packages/misskey-js/src/entities.ts1
28 files changed, 267 insertions, 99 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e57a2c4fd3..a2f5d086cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@
- お知らせのバナー表示やダイアログ表示が可能に
- お知らせのアイコンを設定可能に
- チャンネルをセンシティブ指定できるようになりました
+- 二要素認証のバックアップコードが生成されるようになりました
### Client
- プロフィールにその人が作ったPlayの一覧出せるように
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 1f25edd0ef..1d756a14d8 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,11 @@ export interface Locale {
"renewTOTPConfirm": string;
"renewTOTPOk": string;
"renewTOTPCancel": string;
+ "checkBackupCodesBeforeCloseThisWizard": string;
+ "backupCodes": string;
+ "backupCodesDescription": string;
+ "backupCodeUsedWarning": string;
+ "backupCodesExhaustedWarning": string;
};
"_permissions": {
"read:account": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2e0e64bbef..3396a3a832 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,14 @@ _2fa:
removeKeyConfirm: "{name}を削除しますか?"
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
renewTOTP: "認証アプリを再設定"
- renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
+ renewTOTPConfirm: "今までの認証アプリの確認コードおよびバックアップコードは使用できなくなります"
renewTOTPOk: "再設定する"
renewTOTPCancel: "やめておく"
+ checkBackupCodesBeforeCloseThisWizard: "このウィザードを閉じる前に、以下のバックアップコードを確認してください。"
+ backupCodes: "バックアップコード"
+ backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。"
+ backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。"
+ backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
_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/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 236ee9f0b4..d12fd1b620 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -434,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
+ twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
where: { userId: user.id, isSpecified: true },
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/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index 0c205654eb..3314464c31 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -321,6 +321,11 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
+ twoFactorBackupCodesStock: {
+ type: 'string',
+ enum: ['full', 'partial', 'none'],
+ nullable: false, optional: false,
+ },
hideOnlineStatus: {
type: 'boolean',
nullable: false, optional: 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..2d1457b9b5 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,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('not verified');
}
+ const backupCodes = Array.from({ length: 5 }, () => new OTPAuth.Secret().base32);
+
await this.userProfilesRepository.update(me.id, {
twoFactorSecret: profile.twoFactorTempSecret,
+ twoFactorBackupSecret: backupCodes,
twoFactorEnabled: true,
});
@@ -64,6 +67,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts
index 8afbcbe322..2c396813ff 100644
--- a/packages/backend/test/e2e/users.ts
+++ b/packages/backend/test/e2e/users.ts
@@ -152,6 +152,7 @@ describe('ユーザー', () => {
preventAiLearning: user.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
+ twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
hasUnreadMentions: user.hasUnreadMentions,
@@ -398,6 +399,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.preventAiLearning, true);
assert.strictEqual(response.isExplorable, true);
assert.strictEqual(response.isDeleted, false);
+ assert.strictEqual(response.twoFactorBackupCodesStock, 'none');
assert.strictEqual(response.hideOnlineStatus, false);
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
assert.strictEqual(response.hasUnreadMentions, false);
diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts
index a1275132be..0cc648fbae 100644
--- a/packages/frontend/.storybook/changes.ts
+++ b/packages/frontend/.storybook/changes.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index a4289cff7d..14481deeea 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import type { entities } from 'misskey-js'
export function abuseUserReport() {
@@ -110,6 +115,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
publicReactions: false,
securityKeys: false,
twoFactorEnabled: false,
+ twoFactorBackupCodesStock: 'none',
updatedAt: null,
uri: null,
url: null,
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index d47d8672c7..d61df9e7be 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { existsSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path/posix';
diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts
index b64979980a..a450f8b46b 100644
--- a/packages/frontend/.storybook/main.ts
+++ b/packages/frontend/.storybook/main.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { StorybookConfig } from '@storybook/vue3-vite';
diff --git a/packages/frontend/.storybook/manager.ts b/packages/frontend/.storybook/manager.ts
index 5653deee84..8f501111d0 100644
--- a/packages/frontend/.storybook/manager.ts
+++ b/packages/frontend/.storybook/manager.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { addons } from '@storybook/manager-api';
import { create } from '@storybook/theming/create';
diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts
index 4091e39686..b60755feea 100644
--- a/packages/frontend/.storybook/mocks.ts
+++ b/packages/frontend/.storybook/mocks.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { type SharedOptions, rest } from 'msw';
export const onUnhandledRequest = ((req, print) => {
diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts
index 2b7362b88d..349cc13508 100644
--- a/packages/frontend/.storybook/preload-locale.ts
+++ b/packages/frontend/.storybook/preload-locale.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { writeFile } from 'node:fs/promises';
import locales from '../../../locales/index.js';
diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts
index 42fbeff738..ad2cf18a35 100644
--- a/packages/frontend/.storybook/preload-theme.ts
+++ b/packages/frontend/.storybook/preload-theme.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { readFile, writeFile } from 'node:fs/promises';
import JSON5 from 'json5';
diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index 67c81c666b..9860b60c67 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -1,3 +1,8 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
import { addons } from '@storybook/addons';
import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3';
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
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
- <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
+ <MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
<template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template>
</MkInput>
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
<MkSpacer :contentMax="800">
<div :class="$style.root">
<div :class="$style.editor" class="_panel">
- <PrismEditor v-model="code" class="_code code" :highlight="highlighter" :lineNumbers="false"/>
+ <PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
</div>
@@ -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..cf6b0227fd 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -4,45 +4,110 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkModal
- ref="dialogEl"
- :preferType="'dialog'"
- :zPriority="'low'"
- @click="cancel"
+<MkModalWindow
+ ref="dialog"
+ :width="500"
+ :height="550"
@close="cancel"
@closed="emit('closed')"
>
- <div :class="$style.root" class="_gaps_m">
- <I18n :src="i18n.ts._2fa.step1" tag="div">
- <template #a>
- <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
+ <template #header>{{ i18n.ts.setupOf2fa }}</template>
+
+ <div style="overflow-x: clip;">
+ <Transition
+ mode="out-in"
+ :enterActiveClass="$style.transition_x_enterActive"
+ :leaveActiveClass="$style.transition_x_leaveActive"
+ :enterFromClass="$style.transition_x_enterFrom"
+ :leaveToClass="$style.transition_x_leaveTo"
+ >
+ <template v-if="page === 0">
+ <div style="height: 100cqh; overflow: auto; text-align: center;">
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps">
+ <I18n :src="i18n.ts._2fa.step1" tag="div">
+ <template #a>
+ <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
+ </template>
+ <template #b>
+ <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
+ </template>
+ </I18n>
+ <div>{{ i18n.ts._2fa.step2 }}<br>{{ i18n.ts._2fa.step2Click }}</div>
+ <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
+ <MkKeyValue :copy="twoFactorData.url">
+ <template #key>{{ i18n.ts._2fa.step2Uri }}</template>
+ <template #value>{{ twoFactorData.url }}</template>
+ </MkKeyValue>
+ </div>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
+ <MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
+ <template v-else-if="page === 1">
+ <div style="height: 100cqh; overflow: auto;">
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps">
+ <div>{{ i18n.ts._2fa.step3Title }}</div>
+ <MkInput v-model="token" autocomplete="one-time-code" type="number"></MkInput>
+ <div>{{ i18n.ts._2fa.step3 }}</div>
+ </div>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
+ <MkButton primary rounded gradate @click="tokenDone">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </MkSpacer>
+ </div>
</template>
- <template #b>
- <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
+ <template v-else-if="page === 2">
+ <div style="height: 100cqh; overflow: auto;">
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div class="_gaps">
+ <div style="text-align: center;">{{ i18n.ts._2fa.setupCompleted }}🎉</div>
+ <div style="text-align: center;">{{ i18n.ts._2fa.step4 }}</div>
+ <div style="text-align: center; font-weight: bold;">{{ i18n.ts._2fa.checkBackupCodesBeforeCloseThisWizard }}</div>
+
+ <MkFolder :defaultOpen="true">
+ <template #icon><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts._2fa.backupCodes }}</template>
+
+ <div class="_gaps">
+ <MkInfo warn>{{ i18n.ts._2fa.backupCodesDescription }}</MkInfo>
+
+ <div v-for="(code, i) in backupCodes" :key="code" class="_gaps_s">
+ <MkKeyValue :copy="code">
+ <template #key>#{{ i + 1 }}</template>
+ <template #value><code class="_monospace">{{ code }}</code></template>
+ </MkKeyValue>
+ </div>
+ </div>
+ </MkFolder>
+ </div>
+ <div class="_buttonsCenter" style="margin-top: 16px;">
+ <MkButton primary rounded gradate @click="allDone">{{ i18n.ts.done }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
</template>
- </I18n>
- <div>
- {{ i18n.ts._2fa.step2 }}<br>
- {{ i18n.ts._2fa.step2Click }}
- </div>
- <a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
- <MkKeyValue :copy="twoFactorData.url">
- <template #key>{{ i18n.ts._2fa.step2Url }}</template>
- <template #value>{{ twoFactorData.url }}</template>
- </MkKeyValue>
- <div class="_buttons">
- <MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton>
- <MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton>
- </div>
+ </Transition>
</div>
-</MkModal>
+</MkModalWindow>
</template>
<script lang="ts" setup>
+import { shallowRef, ref } from 'vue';
import MkButton from '@/components/MkButton.vue';
-import MkModal from '@/components/MkModal.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n';
+import * as os from '@/os';
+import MkFolder from '@/components/MkFolder.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { confetti } from '@/scripts/confetti';
defineProps<{
twoFactorData: {
@@ -52,36 +117,53 @@ defineProps<{
}>();
const emit = defineEmits<{
- (ev: 'ok'): void;
- (ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
-const cancel = () => {
- emit('cancel');
- emit('closed');
-};
+const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
+const page = ref(0);
+const token = ref<string | number | null>(null);
+const backupCodes = ref<string[]>();
+
+function cancel() {
+ dialog.value.close();
+}
+
+async function tokenDone() {
+ const res = await os.apiWithDialog('i/2fa/done', {
+ token: token.value.toString(),
+ });
-const ok = () => {
- emit('ok');
- emit('closed');
-};
+ backupCodes.value = res.backupCodes;
+
+ page.value++;
+
+ confetti({
+ duration: 1000 * 3,
+ });
+}
+
+function allDone() {
+ dialog.value.close();
+}
</script>
<style lang="scss" module>
-.root {
- position: relative;
- margin: auto;
- padding: 32px;
- min-width: 320px;
- max-width: calc(100svw - 64px);
- box-sizing: border-box;
- background: var(--panel);
- border-radius: var(--radius);
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateX(50px);
+}
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateX(-50px);
}
.qr {
- width: 20em;
- max-width: 100%;
+ width: 200px;
+ max-width: 100%;
}
</style>
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index d2998ceae7..6871091aad 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -8,20 +8,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts['2fa'] }}</template>
<div v-if="$i" class="_gaps_s">
- <MkFolder>
+ <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'partial'" warn>
+ {{ i18n.ts._2fa.backupCodeUsedWarning }}
+ </MkInfo>
+ <MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'none'" warn>
+ {{ i18n.ts._2fa.backupCodesExhaustedWarning }}
+ </MkInfo>
+
+ <MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-shield-lock"></i></template>
<template #label>{{ i18n.ts.totp }}</template>
<template #caption>{{ i18n.ts.totpDescription }}</template>
+
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
<template v-if="$i.securityKeysList.length > 0">
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
</template>
- <MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
+ <MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
</div>
- <MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
+ <MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
</MkFolder>
<MkFolder>
@@ -85,7 +93,6 @@ withDefaults(defineProps<{
first: false,
});
-const twoFactorData = ref<any>(null);
const supportsCredentials = ref(!!navigator.credentials);
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
@@ -102,31 +109,9 @@ async function registerTOTP() {
password: password.result,
});
- const qrdialog = await new Promise<boolean>(res => {
- os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
- twoFactorData,
- }, {
- 'ok': () => res(true),
- 'cancel': () => res(false),
- }, 'closed');
- });
- if (!qrdialog) return;
-
- const token = await os.inputNumber({
- title: i18n.ts._2fa.step3Title,
- text: i18n.ts._2fa.step3,
- autocomplete: 'one-time-code',
- });
- if (token.canceled) return;
-
- await os.apiWithDialog('i/2fa/done', {
- token: token.result.toString(),
- });
-
- await os.alert({
- type: 'success',
- text: i18n.ts._2fa.step4,
- });
+ os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
+ twoFactorData,
+ }, {}, 'closed');
}
function unregisterTOTP() {
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 948d27536c..c644fc76da 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -400,15 +400,6 @@ hr {
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
}
-._code {
- @extend ._monospace;
- background: #2d2d2d;
- color: #ccc;
- font-size: 14px;
- line-height: 1.5;
- padding: 5px;
-}
-
.prism-editor__textarea:focus {
outline: none;
}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index aa9c2157b6..4d17d69152 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2462,6 +2462,7 @@ type MeDetailed = UserDetailed & {
receiveAnnouncementEmail: boolean;
usePasswordLessLogin: boolean;
unreadAnnouncements: Announcement[];
+ twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
[other: string]: any;
};
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index f0c6104ed3..64742fa5b6 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -105,6 +105,7 @@ export type MeDetailed = UserDetailed & {
receiveAnnouncementEmail: boolean;
usePasswordLessLogin: boolean;
unreadAnnouncements: Announcement[];
+ twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
[other: string]: any;
};