summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-09-22 14:12:33 +0900
committerGitHub <noreply@github.com>2023-09-22 14:12:33 +0900
commitc836157edb869e80b15f51bb8f48725e3b898b9a (patch)
treec275a865b697afa4c5d045d16b1ca8999999f9cf
parenttweak ui (diff)
downloadmisskey-c836157edb869e80b15f51bb8f48725e3b898b9a.tar.gz
misskey-c836157edb869e80b15f51bb8f48725e3b898b9a.tar.bz2
misskey-c836157edb869e80b15f51bb8f48725e3b898b9a.zip
enhance: 二要素認証設定時のセキュリティを強化 (#11863)
* enhance: 二要素認証設定時のセキュリティを強化 パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要にする * Update CoreModule.ts * Update 2fa.ts * wip * wip * Update 2fa.ts * tweak
-rw-r--r--CHANGELOG.md9
-rw-r--r--locales/index.d.ts3
-rw-r--r--locales/ja-JP.yml3
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/UserAuthService.ts45
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts28
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/done.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts21
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/i/change-password.ts22
-rw-r--r--packages/backend/src/server/api/endpoints/i/delete-account.ts23
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts20
-rw-r--r--packages/backend/test/e2e/2fa.ts49
-rw-r--r--packages/frontend/src/components/MkInput.vue4
-rw-r--r--packages/frontend/src/components/MkPasswordDialog.vue70
-rw-r--r--packages/frontend/src/os.ts13
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue65
-rw-r--r--packages/frontend/src/pages/settings/email.vue20
-rw-r--r--packages/frontend/src/pages/settings/other.vue10
-rw-r--r--packages/frontend/src/pages/settings/security.vue29
23 files changed, 400 insertions, 122 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 082b448c2b..92bb2c8161 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,8 @@
- Feat: プロフィールでのリンク検証
- Feat: 通知をテストできるようになりました
- Feat: PWAのアイコンが設定できるようになりました
+- Enhance: 二要素認証設定時のセキュリティを強化
+ - パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要になりました
- Enhance: manifest.jsonをオーバーライド可能に
- Enhance: 依存関係の更新
- Enhance: ローカリゼーションの更新
@@ -40,10 +42,8 @@
- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加
- Feat: 新しい実績を追加
-- Enhance: ノート詳細ページを改修
- - 読み込み時のパフォーマンスが向上しました
- - リノート一覧、リアクション一覧がタブとして追加されました
- - ノートのメニューからは当該項目は消えました
+- Enhance: ノート詳細ページでリノート一覧、リアクション一覧タブを追加
+ - ノートのメニューからは当該項目は消えました
- Enhance: プロフィールにその人が作ったPlayの一覧出せるように
- Enhance: メニューのスイッチの動作を改善
- Enhance: 絵文字ピッカーの検索の表示件数を100件に増加
@@ -62,6 +62,7 @@
- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように
- Enhance: Mk:apiが失敗した時にエラー型の値(AiScript 0.16.0で追加)を返すように
- Enhance: ScratchpadでAsync:系関数やボタンのコールバックなどのエラーにもダイアログを出すように(試験的なためPlayなどには未実装)
+- Enhance: ノート詳細ページ読み込み時のパフォーマンスが向上しました
- Enhance: タイムラインでリスト/アンテナ選択時のパフォーマンスを改善
- Enhance: 「Moderation note」、「Add moderation note」をローカライズできるように
- Enhance: 細かなデザインの調整
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 784f53355a..f7bc350e2b 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1119,6 +1119,8 @@ export interface Locale {
"verifiedLink": string;
"notifyNotes": string;
"unnotifyNotes": string;
+ "authentication": string;
+ "authenticationRequiredToContinue": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
@@ -1833,7 +1835,6 @@ export interface Locale {
"_2fa": {
"alreadyRegistered": string;
"registerTOTP": string;
- "passwordToTOTP": string;
"step1": string;
"step2": string;
"step2Click": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a7a6200472..5436cf0494 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1116,6 +1116,8 @@ keepScreenOn: "デバイスの画面を常にオンにする"
verifiedLink: "このリンク先の所有者であることが確認されました"
notifyNotes: "投稿を通知"
unnotifyNotes: "投稿の通知を解除"
+authentication: "認証"
+authenticationRequiredToContinue: "続けるには認証を行ってください"
_announcement:
forExistingUsers: "既存ユーザーのみ"
@@ -1750,7 +1752,6 @@ _timelineTutorial:
_2fa:
alreadyRegistered: "既に設定は完了しています。"
registerTOTP: "認証アプリの設定を開始"
- passwordToTOTP: "パスワードを入力してください"
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
step2: "次に、表示されているQRコードをアプリでスキャンします。"
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 18271ee346..78333e70a5 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -51,6 +51,7 @@ import { UserKeypairService } from './UserKeypairService.js';
import { UserListService } from './UserListService.js';
import { UserMutingService } from './UserMutingService.js';
import { UserSuspendService } from './UserSuspendService.js';
+import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js';
import { WebhookService } from './WebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
@@ -177,6 +178,7 @@ const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisti
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
+const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
@@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserListService,
UserMutingService,
UserSuspendService,
+ UserAuthService,
VideoProcessingService,
WebhookService,
UtilityService,
@@ -428,6 +431,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserListService,
$UserMutingService,
$UserSuspendService,
+ $UserAuthService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
@@ -551,6 +555,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserListService,
UserMutingService,
UserSuspendService,
+ UserAuthService,
VideoProcessingService,
WebhookService,
UtilityService,
@@ -672,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserListService,
$UserMutingService,
$UserSuspendService,
+ $UserAuthService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
diff --git a/packages/backend/src/core/UserAuthService.ts b/packages/backend/src/core/UserAuthService.ts
new file mode 100644
index 0000000000..ccf4dfc6bd
--- /dev/null
+++ b/packages/backend/src/core/UserAuthService.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { QueryFailedError } from 'typeorm';
+import * as OTPAuth from 'otpauth';
+import { DI } from '@/di-symbols.js';
+import type { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import type { MiLocalUser } from '@/models/User.js';
+
+@Injectable()
+export class UserAuthService {
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.userProfilesRepository)
+ private userProfilesRepository: UserProfilesRepository,
+ ) {
+ }
+
+ @bindThis
+ public async twoFactorAuthenticate(profile: MiUserProfile, token: string): Promise<void> {
+ if (profile.twoFactorBackupSecret?.includes(token)) {
+ await this.userProfilesRepository.update({ userId: profile.userId }, {
+ twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
+ });
+ } else {
+ const delta = OTPAuth.TOTP.validate({
+ secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
+ digits: 6,
+ token,
+ window: 5,
+ });
+
+ if (delta === null) {
+ throw new Error('authentication failed');
+ }
+ }
+ }
+}
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 48d74e2b02..150f3f24d4 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -19,6 +19,7 @@ import type { MiLocalUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
@@ -42,6 +43,7 @@ export class SigninApiService {
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
+ private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService,
) {
}
@@ -124,7 +126,7 @@ export class SigninApiService {
const same = await bcrypt.compare(password, profile.password!);
const fail = async (status?: number, failure?: { id: string }) => {
- // Append signin history
+ // Append signin history
await this.signinsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
@@ -154,27 +156,15 @@ 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,
- token,
- window: 1,
- });
-
- if (delta === null) {
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
return await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
});
- } else {
- return this.signinService.signin(request, reply, user);
}
+
+ return this.signinService.signin(request, reply, user);
} else if (body.credential) {
if (!same && !profile.usePasswordLessLogin) {
return await fail(403, {
@@ -203,6 +193,6 @@ export class SigninApiService {
reply.code(200);
return authRequest;
}
- // never get here
+ // never get here
}
}
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 c6a193fbb5..9f8e2894b8 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts
@@ -47,7 +47,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
digits: 6,
token,
- window: 1,
+ window: 5,
});
if (delta === null) {
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
index 4b0e761bb2..6d530aba3b 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
@@ -12,6 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { ApiError } from '@/server/api/error.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -37,6 +38,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
name: { type: 'string', minLength: 1, maxLength: 30 },
credential: { type: 'object' },
},
@@ -54,16 +56,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userSecurityKeysRepository: UserSecurityKeysRepository,
private webAuthnService: WebAuthnService,
+ private userAuthService: UserAuthService,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
- if (!same) {
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
index b4d5237941..c39005f2dd 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
@@ -10,6 +10,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { ApiError } from '@/server/api/error.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -41,6 +42,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@@ -53,8 +55,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userProfilesRepository: UserProfilesRepository,
private webAuthnService: WebAuthnService,
+ private userAuthService: UserAuthService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOne({
where: {
userId: me.id,
@@ -66,10 +70,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.userNotFound);
}
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
- if (!same) {
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
index 9d027b25bb..b358c812ee 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts
@@ -12,6 +12,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { ApiError } from '@/server/api/error.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -31,6 +32,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@@ -43,14 +45,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
+
+ private userAuthService: UserAuthService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
+
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
- if (!same) {
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
index ad2cb8c20b..da8ac98556 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts
@@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -30,6 +31,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
credentialId: { type: 'string' },
},
required: ['password', 'credentialId'],
@@ -45,15 +47,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
+ private userAuthService: UserAuthService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
- if (!same) {
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
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 b834dfff4c..338f12c5cd 100644
--- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
+++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts
@@ -11,6 +11,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -30,6 +31,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@@ -41,15 +43,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
+ private userAuthService: UserAuthService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
- if (!same) {
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password ?? '');
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts
index 868cff8ad7..a3c37ffdb7 100644
--- a/packages/backend/src/server/api/endpoints/i/change-password.ts
+++ b/packages/backend/src/server/api/endpoints/i/change-password.ts
@@ -8,6 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -20,6 +21,7 @@ export const paramDef = {
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 1 },
+ token: { type: 'string', nullable: true },
},
required: ['currentPassword', 'newPassword'],
} as const;
@@ -29,14 +31,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
+
+ private userAuthService: UserAuthService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.currentPassword, profile.password!);
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
+
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
+ const passwordMatched = await bcrypt.compare(ps.currentPassword, profile.password!);
- if (!same) {
+ if (!passwordMatched) {
throw new Error('incorrect password');
}
diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts
index f318d9cda9..fbac845fda 100644
--- a/packages/backend/src/server/api/endpoints/i/delete-account.ts
+++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts
@@ -9,6 +9,7 @@ import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
import { DI } from '@/di-symbols.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
export const meta = {
requireCredential: true,
@@ -20,6 +21,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
+ token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@@ -33,19 +35,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
+ private userAuthService: UserAuthService,
private deleteAccountService: DeleteAccountService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
+
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
+
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
+
const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id });
if (userDetailed.isDeleted) {
return;
}
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
-
- if (!same) {
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password!);
+ if (!passwordMatched) {
throw new Error('incorrect password');
}
diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts
index 77135bf855..a36b3a732b 100644
--- a/packages/backend/src/server/api/endpoints/i/update-email.ts
+++ b/packages/backend/src/server/api/endpoints/i/update-email.ts
@@ -14,6 +14,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
+import { UserAuthService } from '@/core/UserAuthService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -46,6 +47,7 @@ export const paramDef = {
properties: {
password: { type: 'string' },
email: { type: 'string', nullable: true },
+ token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
@@ -61,15 +63,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userEntityService: UserEntityService,
private emailService: EmailService,
+ private userAuthService: UserAuthService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
+ const token = ps.token;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
- // Compare password
- const same = await bcrypt.compare(ps.password, profile.password!);
+ if (profile.twoFactorEnabled) {
+ if (token == null) {
+ throw new Error('authentication failed');
+ }
+
+ try {
+ await this.userAuthService.twoFactorAuthenticate(profile, token);
+ } catch (e) {
+ throw new Error('authentication failed');
+ }
+ }
- if (!same) {
+ const passwordMatched = await bcrypt.compare(ps.password, profile.password!);
+ if (!passwordMatched) {
throw new ApiError(meta.errors.incorrectPassword);
}
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 80d2e9d353..ed967d2620 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -60,10 +60,12 @@ describe('2要素認証', () => {
};
const keyDoneParam = (param: {
+ token: string,
keyName: string,
credentialId: Buffer,
creationOptions: PublicKeyCredentialCreationOptionsJSON,
}): {
+ token: string,
password: string,
name: string,
credential: RegistrationResponseJSON,
@@ -94,6 +96,7 @@ describe('2要素認証', () => {
return {
password,
+ token: param.token,
name: param.keyName,
credential: <RegistrationResponseJSON>{
id: param.credentialId.toString('base64url'),
@@ -218,6 +221,12 @@ describe('2要素認証', () => {
});
assert.strictEqual(signinResponse.status, 200);
assert.notEqual(signinResponse.body.i, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
test('が設定でき、セキュリティキーでログインできる。', async () => {
@@ -233,6 +242,7 @@ describe('2要素認証', () => {
const registerKeyResponse = await api('/i/2fa/register-key', {
password,
+ token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(registerKeyResponse.status, 200);
assert.notEqual(registerKeyResponse.body.rp, undefined);
@@ -241,6 +251,7 @@ describe('2要素認証', () => {
const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
+ token: otpToken(registerResponse.body.secret),
keyName,
credentialId,
creationOptions: registerKeyResponse.body,
@@ -271,6 +282,12 @@ describe('2要素認証', () => {
}));
assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => {
@@ -285,6 +302,7 @@ describe('2要素認証', () => {
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
+ token: otpToken(registerResponse.body.secret),
password,
}, alice);
assert.strictEqual(registerKeyResponse.status, 200);
@@ -292,6 +310,7 @@ describe('2要素認証', () => {
const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
+ token: otpToken(registerResponse.body.secret),
keyName,
credentialId,
creationOptions: registerKeyResponse.body,
@@ -326,6 +345,12 @@ describe('2要素認証', () => {
});
assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => {
@@ -340,6 +365,7 @@ describe('2要素認証', () => {
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
+ token: otpToken(registerResponse.body.secret),
password,
}, alice);
assert.strictEqual(registerKeyResponse.status, 200);
@@ -347,6 +373,7 @@ describe('2要素認証', () => {
const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
+ token: otpToken(registerResponse.body.secret),
keyName,
credentialId,
creationOptions: registerKeyResponse.body,
@@ -367,6 +394,12 @@ describe('2要素認証', () => {
assert.strictEqual(securityKeys.length, 1);
assert.strictEqual(securityKeys[0].name, renamedKey);
assert.notEqual(securityKeys[0].lastUsed, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
test('が設定でき、設定したセキュリティキーを削除できる。', async () => {
@@ -381,6 +414,7 @@ describe('2要素認証', () => {
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
+ token: otpToken(registerResponse.body.secret),
password,
}, alice);
assert.strictEqual(registerKeyResponse.status, 200);
@@ -388,6 +422,7 @@ describe('2要素認証', () => {
const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
+ token: otpToken(registerResponse.body.secret),
keyName,
credentialId,
creationOptions: registerKeyResponse.body,
@@ -400,6 +435,7 @@ describe('2要素認証', () => {
assert.strictEqual(iResponse.status, 200);
for (const key of iResponse.body.securityKeysList) {
const removeKeyResponse = await api('/i/2fa/remove-key', {
+ token: otpToken(registerResponse.body.secret),
password,
credentialId: key.id,
}, alice);
@@ -418,6 +454,12 @@ describe('2要素認証', () => {
});
assert.strictEqual(signinResponse.status, 200);
assert.notEqual(signinResponse.body.i, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => {
@@ -438,6 +480,7 @@ describe('2要素認証', () => {
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true);
const unregisterResponse = await api('/i/2fa/unregister', {
+ token: otpToken(registerResponse.body.secret),
password,
}, alice);
assert.strictEqual(unregisterResponse.status, 204);
@@ -447,5 +490,11 @@ describe('2要素認証', () => {
});
assert.strictEqual(signinResponse.status, 200);
assert.notEqual(signinResponse.body.i, undefined);
+
+ // 後片付け
+ await api('/i/2fa/unregister', {
+ password,
+ token: otpToken(registerResponse.body.secret),
+ }, alice);
});
});
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index e9397ce86f..315ce958c5 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -155,6 +155,10 @@ onMounted(() => {
}
});
});
+
+defineExpose({
+ focus,
+});
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
new file mode 100644
index 0000000000..afb4929fcf
--- /dev/null
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -0,0 +1,70 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="370"
+ :height="400"
+ @close="onClose"
+ @closed="emit('closed')"
+>
+ <template #header>{{ i18n.ts.authentication }}</template>
+
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div style="padding: 0 0 16px 0; text-align: center;">
+ <i class="ti ti-lock" style="font-size: 32px; color: var(--accent);"></i>
+ <div style="margin-top: 10px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
+ </div>
+
+ <div class="_gaps">
+ <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
+ <template #prefix><i class="ti ti-password"></i></template>
+ </MkInput>
+
+ <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
+ <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
+ <template #prefix><i class="ti ti-123"></i></template>
+ </MkInput>
+
+ <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-lock-open"></i> {{ i18n.ts.continue }}</MkButton>
+ </div>
+ </MkSpacer>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { onMounted } from 'vue';
+import MkInput from '@/components/MkInput.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n.js';
+import { $i } from '@/account.js';
+
+const emit = defineEmits<{
+ (ev: 'done', v: { password: string; token: string | null; }): void;
+ (ev: 'closed'): void;
+ (ev: 'cancelled'): void;
+}>();
+
+const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
+const passwordInput = $shallowRef<InstanceType<typeof MkInput>>();
+const password = $ref('');
+const token = $ref(null);
+
+function onClose() {
+ emit('cancelled');
+ if (dialog) dialog.close();
+}
+
+function done(res) {
+ emit('done', { password, token });
+ if (dialog) dialog.close();
+}
+
+onMounted(() => {
+ if (passwordInput) passwordInput.focus();
+});
+</script>
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 4cd1c3ef31..8aed5797e1 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -17,6 +17,7 @@ import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
import MkPageWindow from '@/components/MkPageWindow.vue';
import MkToast from '@/components/MkToast.vue';
import MkDialog from '@/components/MkDialog.vue';
+import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
import MkPopupMenu from '@/components/MkPopupMenu.vue';
@@ -333,6 +334,18 @@ export function inputDate(props: {
});
}
+export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | {
+ canceled: false; result: { password: string; token: string | null; };
+}> {
+ return new Promise((resolve, reject) => {
+ popup(MkPasswordDialog, {}, {
+ done: result => {
+ resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
+ },
+ }, 'closed');
+ });
+}
+
export function select<C = any>(props: {
title?: string | null;
text?: string | null;
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 37455ac2d0..8a89a3a86d 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -94,16 +94,12 @@ withDefaults(defineProps<{
const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
async function registerTOTP(): Promise<void> {
- const password = await os.inputText({
- title: i18n.ts._2fa.registerTOTP,
- text: i18n.ts._2fa.passwordToTOTP,
- type: 'password',
- autocomplete: 'current-password',
- });
- if (password.canceled) return;
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
const twoFactorData = await os.apiWithDialog('i/2fa/register', {
- password: password.result,
+ password: auth.result.password,
+ token: auth.result.token,
});
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
@@ -111,20 +107,17 @@ async function registerTOTP(): Promise<void> {
}, {}, 'closed');
}
-function unregisterTOTP(): void {
- os.inputText({
- title: i18n.ts.password,
- type: 'password',
- autocomplete: 'current-password',
- }).then(({ canceled, result: password }) => {
- if (canceled) return;
- os.apiWithDialog('i/2fa/unregister', {
- password: password,
- }).catch(error => {
- os.alert({
- type: 'error',
- text: error,
- });
+async function unregisterTOTP(): Promise<void> {
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
+
+ os.apiWithDialog('i/2fa/unregister', {
+ password: auth.result.password,
+ token: auth.result.token,
+ }).catch(error => {
+ os.alert({
+ type: 'error',
+ text: error,
});
});
}
@@ -150,15 +143,12 @@ async function unregisterKey(key) {
});
if (confirm.canceled) return;
- const password = await os.inputText({
- title: i18n.ts.password,
- type: 'password',
- autocomplete: 'current-password',
- });
- if (password.canceled) return;
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
await os.apiWithDialog('i/2fa/remove-key', {
- password: password.result,
+ password: auth.result.password,
+ token: auth.result.token,
credentialId: key.id,
});
os.success();
@@ -181,16 +171,13 @@ async function renameKey(key) {
}
async function addSecurityKey() {
- const password = await os.inputText({
- title: i18n.ts.password,
- type: 'password',
- autocomplete: 'current-password',
- });
- if (password.canceled) return;
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
const registrationOptions = parseCreationOptionsFromJSON({
publicKey: await os.apiWithDialog('i/2fa/register-key', {
- password: password.result,
+ password: auth.result.password,
+ token: auth.result.token,
}),
});
@@ -211,8 +198,12 @@ async function addSecurityKey() {
);
if (!credential) return;
+ const auth2 = await os.authenticateDialog();
+ if (auth2.canceled) return;
+
await os.apiWithDialog('i/2fa/key-done', {
- password: password.result,
+ password: auth.result.password,
+ token: auth.result.token,
name: name.result,
credential: credential.toJSON(),
});
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index 1a70c3dbfb..82b7f0ae4c 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -67,18 +67,16 @@ const onChangeReceiveAnnouncementEmail = (v) => {
});
};
-const saveEmailAddress = () => {
- os.inputText({
- title: i18n.ts.password,
- type: 'password',
- }).then(({ canceled, result: password }) => {
- if (canceled) return;
- os.apiWithDialog('i/update-email', {
- password: password,
- email: emailAddress.value,
- });
+async function saveEmailAddress() {
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
+
+ os.apiWithDialog('i/update-email', {
+ password: auth.result.password,
+ token: auth.result.token,
+ email: emailAddress.value,
});
-};
+}
const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index c3278c22f3..e2fc021099 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -113,14 +113,12 @@ async function deleteAccount() {
if (canceled) return;
}
- const { canceled, result: password } = await os.inputText({
- title: i18n.ts.password,
- type: 'password',
- });
- if (canceled) return;
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
await os.apiWithDialog('i/delete-account', {
- password: password,
+ password: auth.result.password,
+ token: auth.result.token,
});
await os.alert({
diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue
index 7b04ab974b..eacd34778d 100644
--- a/packages/frontend/src/pages/settings/security.vue
+++ b/packages/frontend/src/pages/settings/security.vue
@@ -55,13 +55,6 @@ const pagination = {
};
async function change() {
- const { canceled: canceled1, result: currentPassword } = await os.inputText({
- title: i18n.ts.currentPassword,
- type: 'password',
- autocomplete: 'current-password',
- });
- if (canceled1) return;
-
const { canceled: canceled2, result: newPassword } = await os.inputText({
title: i18n.ts.newPassword,
type: 'password',
@@ -84,21 +77,23 @@ async function change() {
return;
}
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
+
os.apiWithDialog('i/change-password', {
- currentPassword,
+ currentPassword: auth.result.password,
+ token: auth.result.token,
newPassword,
});
}
-function regenerateToken() {
- os.inputText({
- title: i18n.ts.password,
- type: 'password',
- }).then(({ canceled, result: password }) => {
- if (canceled) return;
- os.api('i/regenerate-token', {
- password: password,
- });
+async function regenerateToken() {
+ const auth = await os.authenticateDialog();
+ if (auth.canceled) return;
+
+ os.api('i/regenerate-token', {
+ password: auth.result.password,
+ token: auth.result.token,
});
}