enhance: 二要素認証設定時のセキュリティを強化 (#11863)
* enhance: 二要素認証設定時のセキュリティを強化 パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要にする * Update CoreModule.ts * Update 2fa.ts * wip * wip * Update 2fa.ts * tweak
This commit is contained in:
parent
eca8c7a52f
commit
c836157edb
23 changed files with 400 additions and 122 deletions
|
@ -30,6 +30,8 @@
|
||||||
- Feat: プロフィールでのリンク検証
|
- Feat: プロフィールでのリンク検証
|
||||||
- Feat: 通知をテストできるようになりました
|
- Feat: 通知をテストできるようになりました
|
||||||
- Feat: PWAのアイコンが設定できるようになりました
|
- Feat: PWAのアイコンが設定できるようになりました
|
||||||
|
- Enhance: 二要素認証設定時のセキュリティを強化
|
||||||
|
- パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要になりました
|
||||||
- Enhance: manifest.jsonをオーバーライド可能に
|
- Enhance: manifest.jsonをオーバーライド可能に
|
||||||
- Enhance: 依存関係の更新
|
- Enhance: 依存関係の更新
|
||||||
- Enhance: ローカリゼーションの更新
|
- Enhance: ローカリゼーションの更新
|
||||||
|
@ -40,9 +42,7 @@
|
||||||
- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
|
- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
|
||||||
- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加
|
- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加
|
||||||
- Feat: 新しい実績を追加
|
- Feat: 新しい実績を追加
|
||||||
- Enhance: ノート詳細ページを改修
|
- Enhance: ノート詳細ページでリノート一覧、リアクション一覧タブを追加
|
||||||
- 読み込み時のパフォーマンスが向上しました
|
|
||||||
- リノート一覧、リアクション一覧がタブとして追加されました
|
|
||||||
- ノートのメニューからは当該項目は消えました
|
- ノートのメニューからは当該項目は消えました
|
||||||
- Enhance: プロフィールにその人が作ったPlayの一覧出せるように
|
- Enhance: プロフィールにその人が作ったPlayの一覧出せるように
|
||||||
- Enhance: メニューのスイッチの動作を改善
|
- Enhance: メニューのスイッチの動作を改善
|
||||||
|
@ -62,6 +62,7 @@
|
||||||
- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように
|
- Enhance: AiScriptで`LOCALE`として現在の設定言語を取得できるように
|
||||||
- Enhance: Mk:apiが失敗した時にエラー型の値(AiScript 0.16.0で追加)を返すように
|
- Enhance: Mk:apiが失敗した時にエラー型の値(AiScript 0.16.0で追加)を返すように
|
||||||
- Enhance: ScratchpadでAsync:系関数やボタンのコールバックなどのエラーにもダイアログを出すように(試験的なためPlayなどには未実装)
|
- Enhance: ScratchpadでAsync:系関数やボタンのコールバックなどのエラーにもダイアログを出すように(試験的なためPlayなどには未実装)
|
||||||
|
- Enhance: ノート詳細ページ読み込み時のパフォーマンスが向上しました
|
||||||
- Enhance: タイムラインでリスト/アンテナ選択時のパフォーマンスを改善
|
- Enhance: タイムラインでリスト/アンテナ選択時のパフォーマンスを改善
|
||||||
- Enhance: 「Moderation note」、「Add moderation note」をローカライズできるように
|
- Enhance: 「Moderation note」、「Add moderation note」をローカライズできるように
|
||||||
- Enhance: 細かなデザインの調整
|
- Enhance: 細かなデザインの調整
|
||||||
|
|
3
locales/index.d.ts
vendored
3
locales/index.d.ts
vendored
|
@ -1119,6 +1119,8 @@ export interface Locale {
|
||||||
"verifiedLink": string;
|
"verifiedLink": string;
|
||||||
"notifyNotes": string;
|
"notifyNotes": string;
|
||||||
"unnotifyNotes": string;
|
"unnotifyNotes": string;
|
||||||
|
"authentication": string;
|
||||||
|
"authenticationRequiredToContinue": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
@ -1833,7 +1835,6 @@ export interface Locale {
|
||||||
"_2fa": {
|
"_2fa": {
|
||||||
"alreadyRegistered": string;
|
"alreadyRegistered": string;
|
||||||
"registerTOTP": string;
|
"registerTOTP": string;
|
||||||
"passwordToTOTP": string;
|
|
||||||
"step1": string;
|
"step1": string;
|
||||||
"step2": string;
|
"step2": string;
|
||||||
"step2Click": string;
|
"step2Click": string;
|
||||||
|
|
|
@ -1116,6 +1116,8 @@ keepScreenOn: "デバイスの画面を常にオンにする"
|
||||||
verifiedLink: "このリンク先の所有者であることが確認されました"
|
verifiedLink: "このリンク先の所有者であることが確認されました"
|
||||||
notifyNotes: "投稿を通知"
|
notifyNotes: "投稿を通知"
|
||||||
unnotifyNotes: "投稿の通知を解除"
|
unnotifyNotes: "投稿の通知を解除"
|
||||||
|
authentication: "認証"
|
||||||
|
authenticationRequiredToContinue: "続けるには認証を行ってください"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
@ -1750,7 +1752,6 @@ _timelineTutorial:
|
||||||
_2fa:
|
_2fa:
|
||||||
alreadyRegistered: "既に設定は完了しています。"
|
alreadyRegistered: "既に設定は完了しています。"
|
||||||
registerTOTP: "認証アプリの設定を開始"
|
registerTOTP: "認証アプリの設定を開始"
|
||||||
passwordToTOTP: "パスワードを入力してください"
|
|
||||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||||
|
|
|
@ -51,6 +51,7 @@ import { UserKeypairService } from './UserKeypairService.js';
|
||||||
import { UserListService } from './UserListService.js';
|
import { UserListService } from './UserListService.js';
|
||||||
import { UserMutingService } from './UserMutingService.js';
|
import { UserMutingService } from './UserMutingService.js';
|
||||||
import { UserSuspendService } from './UserSuspendService.js';
|
import { UserSuspendService } from './UserSuspendService.js';
|
||||||
|
import { UserAuthService } from './UserAuthService.js';
|
||||||
import { VideoProcessingService } from './VideoProcessingService.js';
|
import { VideoProcessingService } from './VideoProcessingService.js';
|
||||||
import { WebhookService } from './WebhookService.js';
|
import { WebhookService } from './WebhookService.js';
|
||||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||||
|
@ -177,6 +178,7 @@ const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisti
|
||||||
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
||||||
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
|
const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService };
|
||||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
||||||
|
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
||||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||||
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
|
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
|
||||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||||
|
@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
UserListService,
|
UserListService,
|
||||||
UserMutingService,
|
UserMutingService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
|
UserAuthService,
|
||||||
VideoProcessingService,
|
VideoProcessingService,
|
||||||
WebhookService,
|
WebhookService,
|
||||||
UtilityService,
|
UtilityService,
|
||||||
|
@ -428,6 +431,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$UserListService,
|
$UserListService,
|
||||||
$UserMutingService,
|
$UserMutingService,
|
||||||
$UserSuspendService,
|
$UserSuspendService,
|
||||||
|
$UserAuthService,
|
||||||
$VideoProcessingService,
|
$VideoProcessingService,
|
||||||
$WebhookService,
|
$WebhookService,
|
||||||
$UtilityService,
|
$UtilityService,
|
||||||
|
@ -551,6 +555,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
UserListService,
|
UserListService,
|
||||||
UserMutingService,
|
UserMutingService,
|
||||||
UserSuspendService,
|
UserSuspendService,
|
||||||
|
UserAuthService,
|
||||||
VideoProcessingService,
|
VideoProcessingService,
|
||||||
WebhookService,
|
WebhookService,
|
||||||
UtilityService,
|
UtilityService,
|
||||||
|
@ -672,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$UserListService,
|
$UserListService,
|
||||||
$UserMutingService,
|
$UserMutingService,
|
||||||
$UserSuspendService,
|
$UserSuspendService,
|
||||||
|
$UserAuthService,
|
||||||
$VideoProcessingService,
|
$VideoProcessingService,
|
||||||
$WebhookService,
|
$WebhookService,
|
||||||
$UtilityService,
|
$UtilityService,
|
||||||
|
|
45
packages/backend/src/core/UserAuthService.ts
Normal file
45
packages/backend/src/core/UserAuthService.ts
Normal file
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import type { MiLocalUser } from '@/models/User.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
import { RateLimiterService } from './RateLimiterService.js';
|
import { RateLimiterService } from './RateLimiterService.js';
|
||||||
import { SigninService } from './SigninService.js';
|
import { SigninService } from './SigninService.js';
|
||||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
|
||||||
|
@ -42,6 +43,7 @@ export class SigninApiService {
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private rateLimiterService: RateLimiterService,
|
private rateLimiterService: RateLimiterService,
|
||||||
private signinService: SigninService,
|
private signinService: SigninService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private webAuthnService: WebAuthnService,
|
private webAuthnService: WebAuthnService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
@ -154,27 +156,15 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.twoFactorBackupSecret?.includes(token)) {
|
try {
|
||||||
await this.userProfilesRepository.update({ userId: profile.userId }, {
|
await this.userAuthService.twoFactorAuthenticate(profile, token);
|
||||||
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
|
} catch (e) {
|
||||||
});
|
|
||||||
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) {
|
|
||||||
return await fail(403, {
|
return await fail(403, {
|
||||||
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
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) {
|
} else if (body.credential) {
|
||||||
if (!same && !profile.usePasswordLessLogin) {
|
if (!same && !profile.usePasswordLessLogin) {
|
||||||
return await fail(403, {
|
return await fail(403, {
|
||||||
|
|
|
@ -47,7 +47,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||||
digits: 6,
|
digits: 6,
|
||||||
token,
|
token,
|
||||||
window: 1,
|
window: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (delta === null) {
|
if (delta === null) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js';
|
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js';
|
||||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -37,6 +38,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
name: { type: 'string', minLength: 1, maxLength: 30 },
|
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||||
credential: { type: 'object' },
|
credential: { type: 'object' },
|
||||||
},
|
},
|
||||||
|
@ -54,16 +56,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||||
|
|
||||||
private webAuthnService: WebAuthnService,
|
private webAuthnService: WebAuthnService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -41,6 +42,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['password'],
|
required: ['password'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -53,8 +55,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
private webAuthnService: WebAuthnService,
|
private webAuthnService: WebAuthnService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOne({
|
const profile = await this.userProfilesRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
|
@ -66,10 +70,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
throw new ApiError(meta.errors.userNotFound);
|
throw new ApiError(meta.errors.userNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -31,6 +32,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['password'],
|
required: ['password'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -43,14 +45,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -30,6 +31,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
credentialId: { type: 'string' },
|
credentialId: { type: 'string' },
|
||||||
},
|
},
|
||||||
required: ['password', 'credentialId'],
|
required: ['password', 'credentialId'],
|
||||||
|
@ -45,15 +47,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
import { ApiError } from '@/server/api/error.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -30,6 +31,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['password'],
|
required: ['password'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -41,15 +43,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password ?? '');
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { UserProfilesRepository } from '@/models/_.js';
|
import type { UserProfilesRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -20,6 +21,7 @@ export const paramDef = {
|
||||||
properties: {
|
properties: {
|
||||||
currentPassword: { type: 'string' },
|
currentPassword: { type: 'string' },
|
||||||
newPassword: { type: 'string', minLength: 1 },
|
newPassword: { type: 'string', minLength: 1 },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['currentPassword', 'newPassword'],
|
required: ['currentPassword', 'newPassword'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -29,14 +31,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.currentPassword, profile.password!);
|
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.currentPassword, profile.password!);
|
||||||
|
|
||||||
|
if (!passwordMatched) {
|
||||||
throw new Error('incorrect password');
|
throw new Error('incorrect password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
|
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
@ -20,6 +21,7 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['password'],
|
required: ['password'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -33,19 +35,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private deleteAccountService: DeleteAccountService,
|
private deleteAccountService: DeleteAccountService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
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 });
|
const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id });
|
||||||
if (userDetailed.isDeleted) {
|
if (userDetailed.isDeleted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare password
|
const passwordMatched = await bcrypt.compare(ps.password, profile.password!);
|
||||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
if (!passwordMatched) {
|
||||||
|
|
||||||
if (!same) {
|
|
||||||
throw new Error('incorrect password');
|
throw new Error('incorrect password');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import type { Config } from '@/config.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
|
||||||
|
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -46,6 +47,7 @@ export const paramDef = {
|
||||||
properties: {
|
properties: {
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
email: { type: 'string', nullable: true },
|
email: { type: 'string', nullable: true },
|
||||||
|
token: { type: 'string', nullable: true },
|
||||||
},
|
},
|
||||||
required: ['password'],
|
required: ['password'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -61,15 +63,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
|
private userAuthService: UserAuthService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const token = ps.token;
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
|
|
||||||
// Compare password
|
if (profile.twoFactorEnabled) {
|
||||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
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);
|
throw new ApiError(meta.errors.incorrectPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,10 +60,12 @@ describe('2要素認証', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyDoneParam = (param: {
|
const keyDoneParam = (param: {
|
||||||
|
token: string,
|
||||||
keyName: string,
|
keyName: string,
|
||||||
credentialId: Buffer,
|
credentialId: Buffer,
|
||||||
creationOptions: PublicKeyCredentialCreationOptionsJSON,
|
creationOptions: PublicKeyCredentialCreationOptionsJSON,
|
||||||
}): {
|
}): {
|
||||||
|
token: string,
|
||||||
password: string,
|
password: string,
|
||||||
name: string,
|
name: string,
|
||||||
credential: RegistrationResponseJSON,
|
credential: RegistrationResponseJSON,
|
||||||
|
@ -94,6 +96,7 @@ describe('2要素認証', () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
password,
|
password,
|
||||||
|
token: param.token,
|
||||||
name: param.keyName,
|
name: param.keyName,
|
||||||
credential: <RegistrationResponseJSON>{
|
credential: <RegistrationResponseJSON>{
|
||||||
id: param.credentialId.toString('base64url'),
|
id: param.credentialId.toString('base64url'),
|
||||||
|
@ -218,6 +221,12 @@ describe('2要素認証', () => {
|
||||||
});
|
});
|
||||||
assert.strictEqual(signinResponse.status, 200);
|
assert.strictEqual(signinResponse.status, 200);
|
||||||
assert.notEqual(signinResponse.body.i, undefined);
|
assert.notEqual(signinResponse.body.i, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('が設定でき、セキュリティキーでログインできる。', async () => {
|
test('が設定でき、セキュリティキーでログインできる。', async () => {
|
||||||
|
@ -233,6 +242,7 @@ describe('2要素認証', () => {
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
password,
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(registerKeyResponse.status, 200);
|
assert.strictEqual(registerKeyResponse.status, 200);
|
||||||
assert.notEqual(registerKeyResponse.body.rp, undefined);
|
assert.notEqual(registerKeyResponse.body.rp, undefined);
|
||||||
|
@ -241,6 +251,7 @@ describe('2要素認証', () => {
|
||||||
const keyName = 'example-key';
|
const keyName = 'example-key';
|
||||||
const credentialId = crypto.randomBytes(0x41);
|
const credentialId = crypto.randomBytes(0x41);
|
||||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
keyName,
|
keyName,
|
||||||
credentialId,
|
credentialId,
|
||||||
creationOptions: registerKeyResponse.body,
|
creationOptions: registerKeyResponse.body,
|
||||||
|
@ -271,6 +282,12 @@ describe('2要素認証', () => {
|
||||||
}));
|
}));
|
||||||
assert.strictEqual(signinResponse2.status, 200);
|
assert.strictEqual(signinResponse2.status, 200);
|
||||||
assert.notEqual(signinResponse2.body.i, undefined);
|
assert.notEqual(signinResponse2.body.i, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => {
|
test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => {
|
||||||
|
@ -285,6 +302,7 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(doneResponse.status, 200);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
password,
|
password,
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(registerKeyResponse.status, 200);
|
assert.strictEqual(registerKeyResponse.status, 200);
|
||||||
|
@ -292,6 +310,7 @@ describe('2要素認証', () => {
|
||||||
const keyName = 'example-key';
|
const keyName = 'example-key';
|
||||||
const credentialId = crypto.randomBytes(0x41);
|
const credentialId = crypto.randomBytes(0x41);
|
||||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
keyName,
|
keyName,
|
||||||
credentialId,
|
credentialId,
|
||||||
creationOptions: registerKeyResponse.body,
|
creationOptions: registerKeyResponse.body,
|
||||||
|
@ -326,6 +345,12 @@ describe('2要素認証', () => {
|
||||||
});
|
});
|
||||||
assert.strictEqual(signinResponse2.status, 200);
|
assert.strictEqual(signinResponse2.status, 200);
|
||||||
assert.notEqual(signinResponse2.body.i, undefined);
|
assert.notEqual(signinResponse2.body.i, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => {
|
test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => {
|
||||||
|
@ -340,6 +365,7 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(doneResponse.status, 200);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
password,
|
password,
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(registerKeyResponse.status, 200);
|
assert.strictEqual(registerKeyResponse.status, 200);
|
||||||
|
@ -347,6 +373,7 @@ describe('2要素認証', () => {
|
||||||
const keyName = 'example-key';
|
const keyName = 'example-key';
|
||||||
const credentialId = crypto.randomBytes(0x41);
|
const credentialId = crypto.randomBytes(0x41);
|
||||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
keyName,
|
keyName,
|
||||||
credentialId,
|
credentialId,
|
||||||
creationOptions: registerKeyResponse.body,
|
creationOptions: registerKeyResponse.body,
|
||||||
|
@ -367,6 +394,12 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(securityKeys.length, 1);
|
assert.strictEqual(securityKeys.length, 1);
|
||||||
assert.strictEqual(securityKeys[0].name, renamedKey);
|
assert.strictEqual(securityKeys[0].name, renamedKey);
|
||||||
assert.notEqual(securityKeys[0].lastUsed, undefined);
|
assert.notEqual(securityKeys[0].lastUsed, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('が設定でき、設定したセキュリティキーを削除できる。', async () => {
|
test('が設定でき、設定したセキュリティキーを削除できる。', async () => {
|
||||||
|
@ -381,6 +414,7 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(doneResponse.status, 200);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
password,
|
password,
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(registerKeyResponse.status, 200);
|
assert.strictEqual(registerKeyResponse.status, 200);
|
||||||
|
@ -388,6 +422,7 @@ describe('2要素認証', () => {
|
||||||
const keyName = 'example-key';
|
const keyName = 'example-key';
|
||||||
const credentialId = crypto.randomBytes(0x41);
|
const credentialId = crypto.randomBytes(0x41);
|
||||||
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
keyName,
|
keyName,
|
||||||
credentialId,
|
credentialId,
|
||||||
creationOptions: registerKeyResponse.body,
|
creationOptions: registerKeyResponse.body,
|
||||||
|
@ -400,6 +435,7 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(iResponse.status, 200);
|
assert.strictEqual(iResponse.status, 200);
|
||||||
for (const key of iResponse.body.securityKeysList) {
|
for (const key of iResponse.body.securityKeysList) {
|
||||||
const removeKeyResponse = await api('/i/2fa/remove-key', {
|
const removeKeyResponse = await api('/i/2fa/remove-key', {
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
password,
|
password,
|
||||||
credentialId: key.id,
|
credentialId: key.id,
|
||||||
}, alice);
|
}, alice);
|
||||||
|
@ -418,6 +454,12 @@ describe('2要素認証', () => {
|
||||||
});
|
});
|
||||||
assert.strictEqual(signinResponse.status, 200);
|
assert.strictEqual(signinResponse.status, 200);
|
||||||
assert.notEqual(signinResponse.body.i, undefined);
|
assert.notEqual(signinResponse.body.i, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => {
|
test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => {
|
||||||
|
@ -438,6 +480,7 @@ describe('2要素認証', () => {
|
||||||
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true);
|
assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true);
|
||||||
|
|
||||||
const unregisterResponse = await api('/i/2fa/unregister', {
|
const unregisterResponse = await api('/i/2fa/unregister', {
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
password,
|
password,
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(unregisterResponse.status, 204);
|
assert.strictEqual(unregisterResponse.status, 204);
|
||||||
|
@ -447,5 +490,11 @@ describe('2要素認証', () => {
|
||||||
});
|
});
|
||||||
assert.strictEqual(signinResponse.status, 200);
|
assert.strictEqual(signinResponse.status, 200);
|
||||||
assert.notEqual(signinResponse.body.i, undefined);
|
assert.notEqual(signinResponse.body.i, undefined);
|
||||||
|
|
||||||
|
// 後片付け
|
||||||
|
await api('/i/2fa/unregister', {
|
||||||
|
password,
|
||||||
|
token: otpToken(registerResponse.body.secret),
|
||||||
|
}, alice);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -155,6 +155,10 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
70
packages/frontend/src/components/MkPasswordDialog.vue
Normal file
70
packages/frontend/src/components/MkPasswordDialog.vue
Normal file
|
@ -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>
|
|
@ -17,6 +17,7 @@ import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
|
||||||
import MkPageWindow from '@/components/MkPageWindow.vue';
|
import MkPageWindow from '@/components/MkPageWindow.vue';
|
||||||
import MkToast from '@/components/MkToast.vue';
|
import MkToast from '@/components/MkToast.vue';
|
||||||
import MkDialog from '@/components/MkDialog.vue';
|
import MkDialog from '@/components/MkDialog.vue';
|
||||||
|
import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
|
||||||
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
|
import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
|
||||||
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
|
import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue';
|
||||||
import MkPopupMenu from '@/components/MkPopupMenu.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: {
|
export function select<C = any>(props: {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|
|
@ -94,16 +94,12 @@ withDefaults(defineProps<{
|
||||||
const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
|
const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
|
||||||
|
|
||||||
async function registerTOTP(): Promise<void> {
|
async function registerTOTP(): Promise<void> {
|
||||||
const password = await os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts._2fa.registerTOTP,
|
if (auth.canceled) return;
|
||||||
text: i18n.ts._2fa.passwordToTOTP,
|
|
||||||
type: 'password',
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
});
|
|
||||||
if (password.canceled) return;
|
|
||||||
|
|
||||||
const twoFactorData = await os.apiWithDialog('i/2fa/register', {
|
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')), {
|
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
|
||||||
|
@ -111,22 +107,19 @@ async function registerTOTP(): Promise<void> {
|
||||||
}, {}, 'closed');
|
}, {}, 'closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
function unregisterTOTP(): void {
|
async function unregisterTOTP(): Promise<void> {
|
||||||
os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
os.apiWithDialog('i/2fa/unregister', {
|
os.apiWithDialog('i/2fa/unregister', {
|
||||||
password: password,
|
password: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
os.alert({
|
os.alert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: error,
|
text: error,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renewTOTP(): void {
|
function renewTOTP(): void {
|
||||||
|
@ -150,15 +143,12 @@ async function unregisterKey(key) {
|
||||||
});
|
});
|
||||||
if (confirm.canceled) return;
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
const password = await os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
});
|
|
||||||
if (password.canceled) return;
|
|
||||||
|
|
||||||
await os.apiWithDialog('i/2fa/remove-key', {
|
await os.apiWithDialog('i/2fa/remove-key', {
|
||||||
password: password.result,
|
password: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
credentialId: key.id,
|
credentialId: key.id,
|
||||||
});
|
});
|
||||||
os.success();
|
os.success();
|
||||||
|
@ -181,16 +171,13 @@ async function renameKey(key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addSecurityKey() {
|
async function addSecurityKey() {
|
||||||
const password = await os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
});
|
|
||||||
if (password.canceled) return;
|
|
||||||
|
|
||||||
const registrationOptions = parseCreationOptionsFromJSON({
|
const registrationOptions = parseCreationOptionsFromJSON({
|
||||||
publicKey: await os.apiWithDialog('i/2fa/register-key', {
|
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;
|
if (!credential) return;
|
||||||
|
|
||||||
|
const auth2 = await os.authenticateDialog();
|
||||||
|
if (auth2.canceled) return;
|
||||||
|
|
||||||
await os.apiWithDialog('i/2fa/key-done', {
|
await os.apiWithDialog('i/2fa/key-done', {
|
||||||
password: password.result,
|
password: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
name: name.result,
|
name: name.result,
|
||||||
credential: credential.toJSON(),
|
credential: credential.toJSON(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -67,18 +67,16 @@ const onChangeReceiveAnnouncementEmail = (v) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEmailAddress = () => {
|
async function saveEmailAddress() {
|
||||||
os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
os.apiWithDialog('i/update-email', {
|
os.apiWithDialog('i/update-email', {
|
||||||
password: password,
|
password: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
email: emailAddress.value,
|
email: emailAddress.value,
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
|
const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention'));
|
||||||
const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
|
const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply'));
|
||||||
|
|
|
@ -113,14 +113,12 @@ async function deleteAccount() {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canceled, result: password } = await os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
});
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
await os.apiWithDialog('i/delete-account', {
|
await os.apiWithDialog('i/delete-account', {
|
||||||
password: password,
|
password: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
await os.alert({
|
await os.alert({
|
||||||
|
|
|
@ -55,13 +55,6 @@ const pagination = {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function change() {
|
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({
|
const { canceled: canceled2, result: newPassword } = await os.inputText({
|
||||||
title: i18n.ts.newPassword,
|
title: i18n.ts.newPassword,
|
||||||
type: 'password',
|
type: 'password',
|
||||||
|
@ -84,21 +77,23 @@ async function change() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await os.authenticateDialog();
|
||||||
|
if (auth.canceled) return;
|
||||||
|
|
||||||
os.apiWithDialog('i/change-password', {
|
os.apiWithDialog('i/change-password', {
|
||||||
currentPassword,
|
currentPassword: auth.result.password,
|
||||||
|
token: auth.result.token,
|
||||||
newPassword,
|
newPassword,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateToken() {
|
async function regenerateToken() {
|
||||||
os.inputText({
|
const auth = await os.authenticateDialog();
|
||||||
title: i18n.ts.password,
|
if (auth.canceled) return;
|
||||||
type: 'password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
os.api('i/regenerate-token', {
|
os.api('i/regenerate-token', {
|
||||||
password: password,
|
password: auth.result.password,
|
||||||
});
|
token: auth.result.token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue