Skip to content

Commit

Permalink
enhance: 二要素認証設定時のセキュリティを強化 (#11863)
Browse files Browse the repository at this point in the history
* enhance: 二要素認証設定時のセキュリティを強化

パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要にする

* Update CoreModule.ts

* Update 2fa.ts

* wip

* wip

* Update 2fa.ts

* tweak
  • Loading branch information
syuilo authored Sep 22, 2023
1 parent eca8c7a commit c836157
Show file tree
Hide file tree
Showing 23 changed files with 400 additions and 122 deletions.
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
- Feat: プロフィールでのリンク検証
- Feat: 通知をテストできるようになりました
- Feat: PWAのアイコンが設定できるようになりました
- Enhance: 二要素認証設定時のセキュリティを強化
- パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要になりました
- Enhance: manifest.jsonをオーバーライド可能に
- Enhance: 依存関係の更新
- Enhance: ローカリゼーションの更新
Expand All @@ -40,10 +42,8 @@
- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加
- Feat: 新しい実績を追加
- Enhance: ノート詳細ページを改修
- 読み込み時のパフォーマンスが向上しました
- リノート一覧、リアクション一覧がタブとして追加されました
- ノートのメニューからは当該項目は消えました
- Enhance: ノート詳細ページでリノート一覧、リアクション一覧タブを追加
- ノートのメニューからは当該項目は消えました
- Enhance: プロフィールにその人が作ったPlayの一覧出せるように
- Enhance: メニューのスイッチの動作を改善
- Enhance: 絵文字ピッカーの検索の表示件数を100件に増加
Expand All @@ -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: 細かなデザインの調整
Expand Down
3 changes: 2 additions & 1 deletion locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,8 @@ export interface Locale {
"verifiedLink": string;
"notifyNotes": string;
"unnotifyNotes": string;
"authentication": string;
"authenticationRequiredToContinue": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
Expand Down Expand Up @@ -1833,7 +1835,6 @@ export interface Locale {
"_2fa": {
"alreadyRegistered": string;
"registerTOTP": string;
"passwordToTOTP": string;
"step1": string;
"step2": string;
"step2Click": string;
Expand Down
3 changes: 2 additions & 1 deletion locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,8 @@ keepScreenOn: "デバイスの画面を常にオンにする"
verifiedLink: "このリンク先の所有者であることが確認されました"
notifyNotes: "投稿を通知"
unnotifyNotes: "投稿の通知を解除"
authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください"

_announcement:
forExistingUsers: "既存ユーザーのみ"
Expand Down Expand Up @@ -1750,7 +1752,6 @@ _timelineTutorial:
_2fa:
alreadyRegistered: "既に設定は完了しています。"
registerTOTP: "認証アプリの設定を開始"
passwordToTOTP: "パスワードを入力してください"
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
step2: "次に、表示されているQRコードをアプリでスキャンします。"
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserListService,
UserMutingService,
UserSuspendService,
UserAuthService,
VideoProcessingService,
WebhookService,
UtilityService,
Expand Down Expand Up @@ -428,6 +431,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserListService,
$UserMutingService,
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
Expand Down Expand Up @@ -551,6 +555,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserListService,
UserMutingService,
UserSuspendService,
UserAuthService,
VideoProcessingService,
WebhookService,
UtilityService,
Expand Down Expand Up @@ -672,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserListService,
$UserMutingService,
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
$WebhookService,
$UtilityService,
Expand Down
45 changes: 45 additions & 0 deletions packages/backend/src/core/UserAuthService.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
}
28 changes: 9 additions & 19 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,6 +43,7 @@ export class SigninApiService {
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService,
) {
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -203,6 +193,6 @@ export class SigninApiService {
reply.code(200);
return authRequest;
}
// never get here
// never get here
}
}
2 changes: 1 addition & 1 deletion packages/backend/src/server/api/endpoints/i/2fa/done.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 17 additions & 3 deletions packages/backend/src/server/api/endpoints/i/2fa/key-done.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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' },
},
Expand All @@ -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);
}

Expand Down
20 changes: 17 additions & 3 deletions packages/backend/src/server/api/endpoints/i/2fa/register-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,6 +42,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
Expand All @@ -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,
Expand All @@ -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);
}

Expand Down
21 changes: 18 additions & 3 deletions packages/backend/src/server/api/endpoints/i/2fa/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,7 @@ export const paramDef = {
type: 'object',
properties: {
password: { type: 'string' },
token: { type: 'string', nullable: true },
},
required: ['password'],
} as const;
Expand All @@ -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);
}

Expand Down
Loading

0 comments on commit c836157

Please sign in to comment.