enhance: 二要素認証設定時のセキュリティを強化 (#11863)

* enhance: 二要素認証設定時のセキュリティを強化

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

* Update CoreModule.ts

* Update 2fa.ts

* wip

* wip

* Update 2fa.ts

* tweak
This commit is contained in:
syuilo 2023-09-22 14:12:33 +09:00 committed by GitHub
parent eca8c7a52f
commit c836157edb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 400 additions and 122 deletions

View file

@ -30,6 +30,8 @@
- Feat: プロフィールでのリンク検証 - Feat: プロフィールでのリンク検証
- Feat: 通知をテストできるようになりました - Feat: 通知をテストできるようになりました
- Feat: PWAのアイコンが設定できるようになりました - Feat: PWAのアイコンが設定できるようになりました
- Enhance: 二要素認証設定時のセキュリティを強化
- パスワード入力が必要な操作を行う際、二要素認証が有効であれば確認コードの入力も必要になりました
- Enhance: manifest.jsonをオーバーライド可能に - Enhance: manifest.jsonをオーバーライド可能に
- Enhance: 依存関係の更新 - Enhance: 依存関係の更新
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新
@ -40,10 +42,8 @@
- Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`) - Feat: Playで直接投稿フォームを埋め込めるように(`Ui:C:postForm`)
- Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加 - Feat: クライアントを起動している間、デバイスの画面が自動でオフになるのを防ぐオプションを追加
- Feat: 新しい実績を追加 - Feat: 新しい実績を追加
- Enhance: ノート詳細ページを改修 - Enhance: ノート詳細ページでリノート一覧、リアクション一覧タブを追加
- 読み込み時のパフォーマンスが向上しました - ノートのメニューからは当該項目は消えました
- リノート一覧、リアクション一覧がタブとして追加されました
- ノートのメニューからは当該項目は消えました
- Enhance: プロフィールにその人が作ったPlayの一覧出せるように - Enhance: プロフィールにその人が作ったPlayの一覧出せるように
- Enhance: メニューのスイッチの動作を改善 - Enhance: メニューのスイッチの動作を改善
- Enhance: 絵文字ピッカーの検索の表示件数を100件に増加 - Enhance: 絵文字ピッカーの検索の表示件数を100件に増加
@ -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
View file

@ -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;

View file

@ -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コードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"

View file

@ -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,

View 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');
}
}
}
}

View file

@ -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,
) { ) {
} }
@ -124,7 +126,7 @@ export class SigninApiService {
const same = await bcrypt.compare(password, profile.password!); const same = await bcrypt.compare(password, profile.password!);
const fail = async (status?: number, failure?: { id: string }) => { const fail = async (status?: number, failure?: { id: string }) => {
// Append signin history // Append signin history
await this.signinsRepository.insert({ await this.signinsRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: new Date(),
@ -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, {
@ -203,6 +193,6 @@ export class SigninApiService {
reply.code(200); reply.code(200);
return authRequest; return authRequest;
} }
// never get here // never get here
} }
} }

View file

@ -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) {

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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);
} }

View file

@ -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');
} }

View file

@ -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');
} }

View file

@ -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);
} }

View file

@ -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);
}); });
}); });

View file

@ -155,6 +155,10 @@ onMounted(() => {
} }
}); });
}); });
defineExpose({
focus,
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View 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>

View file

@ -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;

View file

@ -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,20 +107,17 @@ 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', os.apiWithDialog('i/2fa/unregister', {
}).then(({ canceled, result: password }) => { password: auth.result.password,
if (canceled) return; token: auth.result.token,
os.apiWithDialog('i/2fa/unregister', { }).catch(error => {
password: password, os.alert({
}).catch(error => { type: 'error',
os.alert({ text: error,
type: 'error',
text: error,
});
}); });
}); });
} }
@ -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(),
}); });

View file

@ -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 }) => { os.apiWithDialog('i/update-email', {
if (canceled) return; password: auth.result.password,
os.apiWithDialog('i/update-email', { token: auth.result.token,
password: password, 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'));

View file

@ -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({

View file

@ -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 }) => { os.api('i/regenerate-token', {
if (canceled) return; password: auth.result.password,
os.api('i/regenerate-token', { token: auth.result.token,
password: password,
});
}); });
} }