🎨 2FA設定のデザイン向上 / セキュリティキーの名前を変更できるように (#9985)
* wip * fix * wip * wip * ✌️ * rename key * 🎨 * update CHANGELOG.md * パスワードレスログインの判断はサーバーで * 日本語 * 日本語 * 日本語 * 日本語 * ✌️ * fix * refactor * トークン→確認コード * fix password-less / qr click * use otpauth * 日本語 * autocomplete * パスワードレス設定は外に出す * 🎨 * 🎨 --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
ea92254b73
commit
980bf1306e
@ -12,6 +12,8 @@ You should also include the user name that made the change.
|
|||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
- Server: URLプレビュー(summaly)はプロキシを通すように
|
- Server: URLプレビュー(summaly)はプロキシを通すように
|
||||||
|
- Client: 2FA設定のUIをまともにした
|
||||||
|
- セキュリティキーの名前を変更できるように
|
||||||
|
|
||||||
### Bugfixes
|
### Bugfixes
|
||||||
-
|
-
|
||||||
|
@ -392,17 +392,20 @@ userList: "リスト"
|
|||||||
about: "情報"
|
about: "情報"
|
||||||
aboutMisskey: "Misskeyについて"
|
aboutMisskey: "Misskeyについて"
|
||||||
administrator: "管理者"
|
administrator: "管理者"
|
||||||
token: "トークン"
|
token: "確認コード"
|
||||||
twoStepAuthentication: "二段階認証"
|
2fa: "二要素認証"
|
||||||
|
totp: "認証アプリ"
|
||||||
|
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
|
||||||
moderator: "モデレーター"
|
moderator: "モデレーター"
|
||||||
moderation: "モデレーション"
|
moderation: "モデレーション"
|
||||||
nUsersMentioned: "{n}人が投稿"
|
nUsersMentioned: "{n}人が投稿"
|
||||||
|
securityKeyAndPasskey: "セキュリティキー・パスキー"
|
||||||
securityKey: "セキュリティキー"
|
securityKey: "セキュリティキー"
|
||||||
securityKeyName: "キーの名前"
|
|
||||||
registerSecurityKey: "セキュリティキーを登録する"
|
|
||||||
lastUsed: "最後の使用"
|
lastUsed: "最後の使用"
|
||||||
|
lastUsedAt: "最後の使用: {t}"
|
||||||
unregister: "登録を解除"
|
unregister: "登録を解除"
|
||||||
passwordLessLogin: "パスワード無しログイン"
|
passwordLessLogin: "パスワードレスログイン"
|
||||||
|
passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします"
|
||||||
resetPassword: "パスワードをリセット"
|
resetPassword: "パスワードをリセット"
|
||||||
newPasswordIs: "新しいパスワードは「{password}」です"
|
newPasswordIs: "新しいパスワードは「{password}」です"
|
||||||
reduceUiAnimation: "UIのアニメーションを減らす"
|
reduceUiAnimation: "UIのアニメーションを減らす"
|
||||||
@ -447,7 +450,6 @@ passwordMatched: "一致しました"
|
|||||||
passwordNotMatched: "一致していません"
|
passwordNotMatched: "一致していません"
|
||||||
signinWith: "{x}でログイン"
|
signinWith: "{x}でログイン"
|
||||||
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
||||||
tapSecurityKey: "セキュリティキーにタッチ"
|
|
||||||
or: "もしくは"
|
or: "もしくは"
|
||||||
language: "言語"
|
language: "言語"
|
||||||
uiLanguage: "UIの表示言語"
|
uiLanguage: "UIの表示言語"
|
||||||
@ -1519,14 +1521,29 @@ _tutorial:
|
|||||||
|
|
||||||
_2fa:
|
_2fa:
|
||||||
alreadyRegistered: "既に設定は完了しています。"
|
alreadyRegistered: "既に設定は完了しています。"
|
||||||
registerDevice: "デバイスを登録"
|
registerTOTP: "認証アプリの設定を開始"
|
||||||
registerKey: "キーを登録"
|
passwordToTOTP: "パスワードを入力してください"
|
||||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||||
step2Url: "デスクトップアプリでは次のURLを入力します:"
|
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||||
step3: "アプリに表示されているトークンを入力して完了です。"
|
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
||||||
step4: "これからログインするときも、同じようにトークンを入力します。"
|
step3Title: "確認コードを入力"
|
||||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。"
|
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
||||||
|
step4: "これからログインするときも、同じように確認コードを入力します。"
|
||||||
|
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||||
|
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||||
|
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||||
|
chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。"
|
||||||
|
registerSecurityKey: "セキュリティキー・パスキーを登録する"
|
||||||
|
securityKeyName: "キーの名前を入力"
|
||||||
|
tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください"
|
||||||
|
removeKey: "セキュリティキーを削除"
|
||||||
|
removeKeyConfirm: "{name}を削除しますか?"
|
||||||
|
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||||
|
renewTOTP: "認証アプリを再設定"
|
||||||
|
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
||||||
|
renewTOTPOk: "再設定する"
|
||||||
|
renewTOTPCancel: "やめておく"
|
||||||
|
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "アカウントの情報を見る"
|
"read:account": "アカウントの情報を見る"
|
||||||
@ -1861,3 +1878,7 @@ _deck:
|
|||||||
channel: "チャンネル"
|
channel: "チャンネル"
|
||||||
mentions: "あなた宛て"
|
mentions: "あなた宛て"
|
||||||
direct: "ダイレクト"
|
direct: "ダイレクト"
|
||||||
|
|
||||||
|
_dialog:
|
||||||
|
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||||
|
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"
|
||||||
|
@ -85,6 +85,7 @@
|
|||||||
"nsfwjs": "2.4.2",
|
"nsfwjs": "2.4.2",
|
||||||
"oauth": "0.10.0",
|
"oauth": "0.10.0",
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
|
"otpauth": "^9.0.2",
|
||||||
"parse5": "7.1.2",
|
"parse5": "7.1.2",
|
||||||
"pg": "8.9.0",
|
"pg": "8.9.0",
|
||||||
"private-ip": "3.0.0",
|
"private-ip": "3.0.0",
|
||||||
@ -108,7 +109,6 @@
|
|||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
"semver": "7.3.8",
|
"semver": "7.3.8",
|
||||||
"sharp": "0.31.3",
|
"sharp": "0.31.3",
|
||||||
"speakeasy": "2.0.0",
|
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"summaly": "github:misskey-dev/summaly",
|
"summaly": "github:misskey-dev/summaly",
|
||||||
@ -167,7 +167,6 @@
|
|||||||
"@types/semver": "7.3.13",
|
"@types/semver": "7.3.13",
|
||||||
"@types/sharp": "0.31.1",
|
"@types/sharp": "0.31.1",
|
||||||
"@types/sinonjs__fake-timers": "8.1.2",
|
"@types/sinonjs__fake-timers": "8.1.2",
|
||||||
"@types/speakeasy": "2.0.7",
|
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
"@types/tmp": "0.2.3",
|
"@types/tmp": "0.2.3",
|
||||||
"@types/unzipper": "0.10.5",
|
"@types/unzipper": "0.10.5",
|
||||||
|
@ -170,6 +170,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
|
|||||||
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
||||||
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
||||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||||
|
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||||
@ -486,6 +487,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___
|
|||||||
const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default };
|
const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default };
|
||||||
const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default };
|
const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default };
|
||||||
const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default };
|
const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default };
|
||||||
|
const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default };
|
||||||
const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default };
|
const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default };
|
||||||
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
|
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
|
||||||
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
|
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
|
||||||
@ -806,6 +808,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||||||
$i_2fa_passwordLess,
|
$i_2fa_passwordLess,
|
||||||
$i_2fa_registerKey,
|
$i_2fa_registerKey,
|
||||||
$i_2fa_register,
|
$i_2fa_register,
|
||||||
|
$i_2fa_updateKey,
|
||||||
$i_2fa_removeKey,
|
$i_2fa_removeKey,
|
||||||
$i_2fa_unregister,
|
$i_2fa_unregister,
|
||||||
$i_apps,
|
$i_apps,
|
||||||
@ -1120,6 +1123,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||||||
$i_2fa_passwordLess,
|
$i_2fa_passwordLess,
|
||||||
$i_2fa_registerKey,
|
$i_2fa_registerKey,
|
||||||
$i_2fa_register,
|
$i_2fa_register,
|
||||||
|
$i_2fa_updateKey,
|
||||||
$i_2fa_removeKey,
|
$i_2fa_removeKey,
|
||||||
$i_2fa_unregister,
|
$i_2fa_unregister,
|
||||||
$i_apps,
|
$i_apps,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as speakeasy from 'speakeasy';
|
import * as OTPAuth from "otpauth";
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
||||||
@ -155,19 +155,19 @@ export class SigninApiService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = (speakeasy as any).totp.verify({
|
const delta = OTPAuth.TOTP.validate({
|
||||||
secret: profile.twoFactorSecret,
|
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret),
|
||||||
encoding: 'base32',
|
digits: 6,
|
||||||
token: token,
|
token,
|
||||||
window: 2,
|
window: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verified) {
|
if (delta === null) {
|
||||||
return this.signinService.signin(request, reply, user);
|
|
||||||
} else {
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
||||||
if (!same && !profile.usePasswordLessLogin) {
|
if (!same && !profile.usePasswordLessLogin) {
|
||||||
|
@ -170,6 +170,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
|
|||||||
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
|
||||||
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
|
||||||
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
|
||||||
|
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
|
||||||
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
|
||||||
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
|
||||||
import * as ep___i_apps from './endpoints/i/apps.js';
|
import * as ep___i_apps from './endpoints/i/apps.js';
|
||||||
@ -484,6 +485,7 @@ const eps = [
|
|||||||
['i/2fa/password-less', ep___i_2fa_passwordLess],
|
['i/2fa/password-less', ep___i_2fa_passwordLess],
|
||||||
['i/2fa/register-key', ep___i_2fa_registerKey],
|
['i/2fa/register-key', ep___i_2fa_registerKey],
|
||||||
['i/2fa/register', ep___i_2fa_register],
|
['i/2fa/register', ep___i_2fa_register],
|
||||||
|
['i/2fa/update-key', ep___i_2fa_updateKey],
|
||||||
['i/2fa/remove-key', ep___i_2fa_removeKey],
|
['i/2fa/remove-key', ep___i_2fa_removeKey],
|
||||||
['i/2fa/unregister', ep___i_2fa_unregister],
|
['i/2fa/unregister', ep___i_2fa_unregister],
|
||||||
['i/apps', ep___i_apps],
|
['i/apps', ep___i_apps],
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import * as speakeasy from 'speakeasy';
|
import * as OTPAuth from 'otpauth';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import type { UserProfilesRepository } from '@/models/index.js';
|
import type { UserProfilesRepository } from '@/models/index.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
@ -22,8 +25,14 @@ export const paramDef = {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const token = ps.token.replace(/\s/g, '');
|
const token = ps.token.replace(/\s/g, '');
|
||||||
@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
throw new Error('二段階認証の設定が開始されていません');
|
throw new Error('二段階認証の設定が開始されていません');
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = (speakeasy as any).totp.verify({
|
const delta = OTPAuth.TOTP.validate({
|
||||||
secret: profile.twoFactorTempSecret,
|
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
|
||||||
encoding: 'base32',
|
digits: 6,
|
||||||
token: token,
|
token,
|
||||||
|
window: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!verified) {
|
if (delta === null) {
|
||||||
throw new Error('not verified');
|
throw new Error('not verified');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
twoFactorSecret: profile.twoFactorTempSecret,
|
twoFactorSecret: profile.twoFactorTempSecret,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Publish meUpdated event
|
||||||
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||||
|
detail: true,
|
||||||
|
includeSecrets: true,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export const paramDef = {
|
|||||||
attestationObject: { type: 'string' },
|
attestationObject: { type: 'string' },
|
||||||
password: { type: 'string' },
|
password: { type: 'string' },
|
||||||
challengeId: { type: 'string' },
|
challengeId: { type: 'string' },
|
||||||
name: { type: 'string' },
|
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||||
},
|
},
|
||||||
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
|
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
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/index.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
|
|
||||||
secure: true,
|
secure: true,
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noKey: {
|
||||||
|
message: 'No security key.',
|
||||||
|
code: 'NO_SECURITY_KEY',
|
||||||
|
id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userSecurityKeysRepository)
|
||||||
|
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
if (ps.value === true) {
|
||||||
|
// セキュリティキーがなければパスワードレスを有効にはできない
|
||||||
|
const keyCount = await this.userSecurityKeysRepository.count({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
lastUsed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keyCount === 0) {
|
||||||
|
await this.userProfilesRepository.update(me.id, {
|
||||||
|
usePasswordLessLogin: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new ApiError(meta.errors.noKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
usePasswordLessLogin: ps.value,
|
usePasswordLessLogin: ps.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Publish meUpdated event
|
||||||
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||||
|
detail: true,
|
||||||
|
includeSecrets: true,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as speakeasy from 'speakeasy';
|
import * as OTPAuth from 'otpauth';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { UserProfilesRepository } from '@/models/index.js';
|
import type { UserProfilesRepository } from '@/models/index.js';
|
||||||
@ -42,25 +42,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate user's secret key
|
// Generate user's secret key
|
||||||
const secret = speakeasy.generateSecret({
|
const secret = new OTPAuth.Secret();
|
||||||
length: 32,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
twoFactorTempSecret: secret.base32,
|
twoFactorTempSecret: secret.base32,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the data URL of the authenticator URL
|
// Get the data URL of the authenticator URL
|
||||||
const url = speakeasy.otpauthURL({
|
const totp = new OTPAuth.TOTP({
|
||||||
secret: secret.base32,
|
secret,
|
||||||
encoding: 'base32',
|
digits: 6,
|
||||||
label: me.username,
|
label: me.username,
|
||||||
issuer: this.config.host,
|
issuer: this.config.host,
|
||||||
});
|
});
|
||||||
const dataUrl = await QRCode.toDataURL(url);
|
const url = totp.toString();
|
||||||
|
const qr = await QRCode.toDataURL(url);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qr: dataUrl,
|
qr,
|
||||||
url,
|
url,
|
||||||
secret: secret.base32,
|
secret: secret.base32,
|
||||||
label: me.username,
|
label: me.username,
|
||||||
|
@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
id: ps.credentialId,
|
id: ps.credentialId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使われているキーがなくなったらパスワードレスログインをやめる
|
||||||
|
const keyCount = await this.userSecurityKeysRepository.count({
|
||||||
|
where: {
|
||||||
|
userId: me.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
lastUsed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keyCount === 0) {
|
||||||
|
await this.userProfilesRepository.update(me.id, {
|
||||||
|
usePasswordLessLogin: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Publish meUpdated event
|
// Publish meUpdated event
|
||||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||||
detail: true,
|
detail: true,
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import type { UserProfilesRepository } from '@/models/index.js';
|
import type { UserProfilesRepository } from '@/models/index.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.userProfilesRepository)
|
@Inject(DI.userProfilesRepository)
|
||||||
private userProfilesRepository: UserProfilesRepository,
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
|
||||||
@ -38,7 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
twoFactorSecret: null,
|
twoFactorSecret: null,
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
usePasswordLessLogin: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Publish meUpdated event
|
||||||
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||||
|
detail: true,
|
||||||
|
includeSecrets: true,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,78 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
|
||||||
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchKey: {
|
||||||
|
message: 'No such key.',
|
||||||
|
code: 'NO_SUCH_KEY',
|
||||||
|
id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512',
|
||||||
|
},
|
||||||
|
|
||||||
|
accessDenied: {
|
||||||
|
message: 'You do not have edit privilege of the channel.',
|
||||||
|
code: 'ACCESS_DENIED',
|
||||||
|
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', minLength: 1, maxLength: 30 },
|
||||||
|
credentialId: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['name', 'credentialId'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.userSecurityKeysRepository)
|
||||||
|
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private userEntityService: UserEntityService,
|
||||||
|
private globalEventService: GlobalEventService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||||
|
id: ps.credentialId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (key == null) {
|
||||||
|
throw new ApiError(meta.errors.noSuchKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.userId !== me.id) {
|
||||||
|
throw new ApiError(meta.errors.accessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userSecurityKeysRepository.update(key.id, {
|
||||||
|
name: ps.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish meUpdated event
|
||||||
|
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
|
||||||
|
detail: true,
|
||||||
|
includeSecrets: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -14,8 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
||||||
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
|
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
|
||||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
|
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
|
||||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||||
|
<template #caption>
|
||||||
|
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
|
||||||
|
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
|
||||||
|
</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||||
<template v-if="select.items">
|
<template v-if="select.items">
|
||||||
@ -28,7 +32,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||||
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||||
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="actions" :class="$style.buttons">
|
<div v-if="actions" :class="$style.buttons">
|
||||||
@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
type Input = {
|
type Input = {
|
||||||
type: HTMLInputElement['type'];
|
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||||
placeholder?: string | null;
|
placeholder?: string | null;
|
||||||
default: any | null;
|
autocomplete?: string;
|
||||||
|
default: string | number | null;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Select = {
|
type Select = {
|
||||||
@ -98,8 +105,28 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||||
|
|
||||||
const inputValue = ref(props.input?.default || null);
|
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||||
const selectedValue = ref(props.select?.default || null);
|
const selectedValue = ref(props.select?.default ?? null);
|
||||||
|
|
||||||
|
let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null);
|
||||||
|
const okButtonDisabled = $computed<boolean>(() => {
|
||||||
|
if (props.input) {
|
||||||
|
if (props.input.minLength) {
|
||||||
|
if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) {
|
||||||
|
disabledReason = 'charactersBelow';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.input.maxLength) {
|
||||||
|
if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) {
|
||||||
|
disabledReason = 'charactersExceeded';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
function done(canceled: boolean, result?) {
|
function done(canceled: boolean, result?) {
|
||||||
emit('done', { canceled, result });
|
emit('done', { canceled, result });
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
|
||||||
<div :class="$style.header" class="_button" @click="toggle">
|
<div :class="$style.header" class="_button" @click="toggle">
|
||||||
<span :class="$style.headerIcon"><slot name="icon"></slot></span>
|
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
|
||||||
<span :class="$style.headerText"><slot name="label"></slot></span>
|
<div :class="$style.headerText">
|
||||||
<span :class="$style.headerRight">
|
<div :class="$style.headerTextMain">
|
||||||
|
<slot name="label"></slot>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.headerTextSub">
|
||||||
|
<slot name="caption"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.headerRight">
|
||||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||||
<i v-else class="ti ti-chevron-down icon"></i>
|
<i v-else class="ti ti-chevron-down icon"></i>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
|
||||||
<Transition
|
<Transition
|
||||||
@ -139,6 +146,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerUpper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLower {
|
||||||
|
color: var(--fgTransparentWeak);
|
||||||
|
font-size: .85em;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.headerIcon {
|
.headerIcon {
|
||||||
margin-right: 0.75em;
|
margin-right: 0.75em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -161,6 +179,15 @@ onMounted(() => {
|
|||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerTextMain {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTextSub {
|
||||||
|
color: var(--fgTransparentWeak);
|
||||||
|
font-size: .85em;
|
||||||
|
}
|
||||||
|
|
||||||
.headerRight {
|
.headerRight {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string | number;
|
modelValue: string | number | null;
|
||||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@ -49,7 +49,7 @@ const props = defineProps<{
|
|||||||
pattern?: string;
|
pattern?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
autocomplete?: boolean;
|
autocomplete?: string;
|
||||||
spellcheck?: boolean;
|
spellcheck?: boolean;
|
||||||
step?: any;
|
step?: any;
|
||||||
datalist?: string[];
|
datalist?: string[];
|
||||||
|
@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval';
|
|||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string;
|
modelValue: string | null;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -48,7 +48,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'change', _ev: KeyboardEvent): void;
|
(ev: 'change', _ev: KeyboardEvent): void;
|
||||||
(ev: 'update:modelValue', value: string): void;
|
(ev: 'update:modelValue', value: string | null): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<template #prefix>@</template>
|
<template #prefix>@</template>
|
||||||
<template #suffix>@{{ host }}</template>
|
<template #suffix>@{{ host }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" :with-password-toggle="true" required data-cy-signin-password>
|
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :with-password-toggle="true" required data-cy-signin-password>
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
<template #prefix><i class="ti ti-lock"></i></template>
|
||||||
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
@ -28,11 +28,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="twofa-group totp-group">
|
<div class="twofa-group totp-group">
|
||||||
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
|
<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
|
||||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" :with-password-toggle="true" required>
|
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :with-password-toggle="true" required>
|
||||||
<template #label>{{ i18n.ts.password }}</template>
|
<template #label>{{ i18n.ts.password }}</template>
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
<template #prefix><i class="ti ti-lock"></i></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required>
|
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
|
||||||
<template #label>{{ i18n.ts.token }}</template>
|
<template #label>{{ i18n.ts.token }}</template>
|
||||||
<template #prefix><i class="ti ti-123"></i></template>
|
<template #prefix><i class="ti ti-123"></i></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
@ -246,7 +246,10 @@ export function inputText(props: {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
placeholder?: string | null;
|
placeholder?: string | null;
|
||||||
|
autocomplete?: string;
|
||||||
default?: string | null;
|
default?: string | null;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
}): Promise<{ canceled: true; result: undefined; } | {
|
}): Promise<{ canceled: true; result: undefined; } | {
|
||||||
canceled: false; result: string;
|
canceled: false; result: string;
|
||||||
}> {
|
}> {
|
||||||
@ -257,7 +260,10 @@ export function inputText(props: {
|
|||||||
input: {
|
input: {
|
||||||
type: props.type,
|
type: props.type,
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
|
autocomplete: props.autocomplete,
|
||||||
default: props.default,
|
default: props.default,
|
||||||
|
minLength: props.minLength,
|
||||||
|
maxLength: props.maxLength,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
done: result => {
|
done: result => {
|
||||||
@ -271,6 +277,7 @@ export function inputNumber(props: {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
placeholder?: string | null;
|
placeholder?: string | null;
|
||||||
|
autocomplete?: string;
|
||||||
default?: number | null;
|
default?: number | null;
|
||||||
}): Promise<{ canceled: true; result: undefined; } | {
|
}): Promise<{ canceled: true; result: undefined; } | {
|
||||||
canceled: false; result: number;
|
canceled: false; result: number;
|
||||||
@ -282,6 +289,7 @@ export function inputNumber(props: {
|
|||||||
input: {
|
input: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
|
autocomplete: props.autocomplete,
|
||||||
default: props.default,
|
default: props.default,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
|
82
packages/frontend/src/pages/settings/2fa.qrdialog.vue
Normal file
82
packages/frontend/src/pages/settings/2fa.qrdialog.vue
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<MkModal
|
||||||
|
ref="dialogEl"
|
||||||
|
:prefer-type="'dialog'"
|
||||||
|
:z-priority="'low'"
|
||||||
|
@click="cancel"
|
||||||
|
@close="cancel"
|
||||||
|
@closed="emit('closed')"
|
||||||
|
>
|
||||||
|
<div :class="$style.root" class="_gaps_m">
|
||||||
|
<I18n :src="i18n.ts._2fa.step1" tag="div">
|
||||||
|
<template #a>
|
||||||
|
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
|
||||||
|
</template>
|
||||||
|
<template #b>
|
||||||
|
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
|
||||||
|
</template>
|
||||||
|
</I18n>
|
||||||
|
<div>
|
||||||
|
{{ i18n.ts._2fa.step2 }}<br>
|
||||||
|
{{ i18n.ts._2fa.step2Click }}
|
||||||
|
</div>
|
||||||
|
<a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
|
||||||
|
<MkKeyValue :copy="twoFactorData.url">
|
||||||
|
<template #key>{{ i18n.ts._2fa.step2Url }}</template>
|
||||||
|
<template #value>{{ twoFactorData.url }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<div class="_buttons">
|
||||||
|
<MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton>
|
||||||
|
<MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkModal from '@/components/MkModal.vue';
|
||||||
|
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
twoFactorData: {
|
||||||
|
qr: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'ok'): void;
|
||||||
|
(ev: 'cancel'): void;
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
emit('closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = () => {
|
||||||
|
emit('ok');
|
||||||
|
emit('closed');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
margin: auto;
|
||||||
|
padding: 32px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: calc(100svw - 64px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr {
|
||||||
|
width: 20em;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,216 +1,258 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<FormSection :first="first">
|
||||||
<MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton>
|
<template #label>{{ i18n.ts['2fa'] }}</template>
|
||||||
<template v-if="$i.twoFactorEnabled">
|
|
||||||
<p>{{ i18n.ts._2fa.alreadyRegistered }}</p>
|
|
||||||
<MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton>
|
|
||||||
|
|
||||||
<template v-if="supportsCredentials">
|
<div v-if="$i" class="_gaps_s">
|
||||||
<hr class="totp-method-sep">
|
<MkFolder>
|
||||||
|
<template #icon><i class="ti ti-shield-lock"></i></template>
|
||||||
<h2 class="heading">{{ i18n.ts.securityKey }}</h2>
|
<template #label>{{ i18n.ts.totp }}</template>
|
||||||
<p>{{ i18n.ts._2fa.securityKeyInfo }}</p>
|
<template #caption>{{ i18n.ts.totpDescription }}</template>
|
||||||
<div class="key-list">
|
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
|
||||||
<div v-for="key in $i.securityKeysList" class="key">
|
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
|
||||||
<h3>{{ key.name }}</h3>
|
<template v-if="$i.securityKeysList.length > 0">
|
||||||
<div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
|
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
|
||||||
<MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton>
|
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
|
||||||
</div>
|
</template>
|
||||||
|
<MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch>
|
<MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo>
|
<MkFolder>
|
||||||
<MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton>
|
<template #icon><i class="ti ti-key"></i></template>
|
||||||
|
<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkInfo>
|
||||||
|
{{ i18n.ts._2fa.securityKeyInfo }}<br>
|
||||||
|
<br>
|
||||||
|
{{ i18n.ts._2fa.chromePasskeyNotSupported }}
|
||||||
|
</MkInfo>
|
||||||
|
|
||||||
<ol v-if="registration && !registration.error">
|
<MkInfo v-if="!supportsCredentials" warn>
|
||||||
<li v-if="registration.stage >= 0">
|
{{ i18n.ts._2fa.securityKeyNotSupported }}
|
||||||
{{ i18n.ts.tapSecurityKey }}
|
</MkInfo>
|
||||||
<MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/>
|
|
||||||
</li>
|
<MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn>
|
||||||
<li v-if="registration.stage >= 1">
|
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
|
||||||
<MkForm :disabled="registration.stage != 1 || registration.saving">
|
</MkInfo>
|
||||||
<MkInput v-model="keyName" :max="30">
|
|
||||||
<template #label>{{ i18n.ts.securityKeyName }}</template>
|
<template v-else>
|
||||||
</MkInput>
|
<MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton>
|
||||||
<MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton>
|
<MkFolder v-for="key in $i.securityKeysList" :key="key.id">
|
||||||
<MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/>
|
<template #label>{{ key.name }}</template>
|
||||||
</MkForm>
|
<template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template>
|
||||||
</li>
|
<div class="_buttons">
|
||||||
</ol>
|
<MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton>
|
||||||
</template>
|
<MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton>
|
||||||
</template>
|
</div>
|
||||||
<div v-if="twoFactorData && !$i.twoFactorEnabled">
|
</MkFolder>
|
||||||
<ol style="margin: 0; padding: 0 0 0 1em;">
|
</template>
|
||||||
<li>
|
</div>
|
||||||
<I18n :src="i18n.ts._2fa.step1" tag="span">
|
</MkFolder>
|
||||||
<template #a>
|
|
||||||
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
|
<MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)">
|
||||||
</template>
|
<template #label>{{ i18n.ts.passwordLessLogin }}</template>
|
||||||
<template #b>
|
<template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template>
|
||||||
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
|
</MkSwitch>
|
||||||
</template>
|
|
||||||
</I18n>
|
|
||||||
</li>
|
|
||||||
<li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li>
|
|
||||||
<li>
|
|
||||||
{{ i18n.ts._2fa.step3 }}<br>
|
|
||||||
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput>
|
|
||||||
<MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref, defineAsyncComponent } from 'vue';
|
||||||
import { hostname } from '@/config';
|
import { hostname } from '@/config';
|
||||||
import { byteify, hexify, stringify } from '@/scripts/2fa';
|
import { byteify, hexify, stringify } from '@/scripts/2fa';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import FormSection from '@/components/form/section.vue';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
first?: boolean;
|
||||||
|
}>(), {
|
||||||
|
first: false,
|
||||||
|
});
|
||||||
|
|
||||||
const twoFactorData = ref<any>(null);
|
const twoFactorData = ref<any>(null);
|
||||||
const supportsCredentials = ref(!!navigator.credentials);
|
const supportsCredentials = ref(!!navigator.credentials);
|
||||||
const usePasswordLessLogin = ref($i!.usePasswordLessLogin);
|
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
|
||||||
const registration = ref<any>(null);
|
|
||||||
const keyName = ref('');
|
|
||||||
const token = ref(null);
|
|
||||||
|
|
||||||
function register() {
|
async function registerTOTP() {
|
||||||
|
const password = await os.inputText({
|
||||||
|
title: i18n.ts._2fa.registerTOTP,
|
||||||
|
text: i18n.ts._2fa.passwordToTOTP,
|
||||||
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
});
|
||||||
|
if (password.canceled) return;
|
||||||
|
|
||||||
|
const twoFactorData = await os.apiWithDialog('i/2fa/register', {
|
||||||
|
password: password.result,
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrdialog = await new Promise<boolean>(res => {
|
||||||
|
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
|
||||||
|
twoFactorData,
|
||||||
|
}, {
|
||||||
|
'ok': () => res(true),
|
||||||
|
'cancel': () => res(false),
|
||||||
|
}, 'closed');
|
||||||
|
});
|
||||||
|
if (!qrdialog) return;
|
||||||
|
|
||||||
|
const token = await os.inputNumber({
|
||||||
|
title: i18n.ts._2fa.step3Title,
|
||||||
|
text: i18n.ts._2fa.step3,
|
||||||
|
autocomplete: 'one-time-code',
|
||||||
|
});
|
||||||
|
if (token.canceled) return;
|
||||||
|
|
||||||
|
await os.apiWithDialog('i/2fa/done', {
|
||||||
|
token: token.result.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await os.alert({
|
||||||
|
type: 'success',
|
||||||
|
text: i18n.ts._2fa.step4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterTOTP() {
|
||||||
os.inputText({
|
os.inputText({
|
||||||
title: i18n.ts.password,
|
title: i18n.ts.password,
|
||||||
type: 'password',
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
}).then(({ canceled, result: password }) => {
|
}).then(({ canceled, result: password }) => {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
os.api('i/2fa/register', {
|
os.apiWithDialog('i/2fa/unregister', {
|
||||||
password: password,
|
password: password,
|
||||||
}).then(data => {
|
}).catch(error => {
|
||||||
twoFactorData.value = data;
|
os.alert({
|
||||||
});
|
type: 'error',
|
||||||
});
|
text: error,
|
||||||
}
|
|
||||||
|
|
||||||
function unregister() {
|
|
||||||
os.inputText({
|
|
||||||
title: i18n.ts.password,
|
|
||||||
type: 'password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
os.api('i/2fa/unregister', {
|
|
||||||
password: password,
|
|
||||||
}).then(() => {
|
|
||||||
usePasswordLessLogin.value = false;
|
|
||||||
updatePasswordLessLogin();
|
|
||||||
}).then(() => {
|
|
||||||
os.success();
|
|
||||||
$i!.twoFactorEnabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
os.api('i/2fa/done', {
|
|
||||||
token: token.value,
|
|
||||||
}).then(() => {
|
|
||||||
os.success();
|
|
||||||
$i!.twoFactorEnabled = true;
|
|
||||||
}).catch(err => {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: err,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerKey() {
|
|
||||||
registration.value.saving = true;
|
|
||||||
os.api('i/2fa/key-done', {
|
|
||||||
password: registration.value.password,
|
|
||||||
name: keyName.value,
|
|
||||||
challengeId: registration.value.challengeId,
|
|
||||||
// we convert each 16 bits to a string to serialise
|
|
||||||
clientDataJSON: stringify(registration.value.credential.response.clientDataJSON),
|
|
||||||
attestationObject: hexify(registration.value.credential.response.attestationObject),
|
|
||||||
}).then(key => {
|
|
||||||
registration.value = null;
|
|
||||||
key.lastUsed = new Date();
|
|
||||||
os.success();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function unregisterKey(key) {
|
|
||||||
os.inputText({
|
|
||||||
title: i18n.ts.password,
|
|
||||||
type: 'password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
return os.api('i/2fa/remove-key', {
|
|
||||||
password,
|
|
||||||
credentialId: key.id,
|
|
||||||
}).then(() => {
|
|
||||||
usePasswordLessLogin.value = false;
|
|
||||||
updatePasswordLessLogin();
|
|
||||||
}).then(() => {
|
|
||||||
os.success();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSecurityKey() {
|
|
||||||
os.inputText({
|
|
||||||
title: i18n.ts.password,
|
|
||||||
type: 'password',
|
|
||||||
}).then(({ canceled, result: password }) => {
|
|
||||||
if (canceled) return;
|
|
||||||
os.api('i/2fa/register-key', {
|
|
||||||
password,
|
|
||||||
}).then(reg => {
|
|
||||||
registration.value = {
|
|
||||||
password,
|
|
||||||
challengeId: reg!.challengeId,
|
|
||||||
stage: 0,
|
|
||||||
publicKeyOptions: {
|
|
||||||
challenge: byteify(reg!.challenge, 'base64'),
|
|
||||||
rp: {
|
|
||||||
id: hostname,
|
|
||||||
name: 'Misskey',
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
id: byteify($i!.id, 'ascii'),
|
|
||||||
name: $i!.username,
|
|
||||||
displayName: $i!.name,
|
|
||||||
},
|
|
||||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
|
||||||
timeout: 60000,
|
|
||||||
attestation: 'direct',
|
|
||||||
},
|
|
||||||
saving: true,
|
|
||||||
};
|
|
||||||
return navigator.credentials.create({
|
|
||||||
publicKey: registration.value.publicKeyOptions,
|
|
||||||
});
|
});
|
||||||
}).then(credential => {
|
|
||||||
registration.value.credential = credential;
|
|
||||||
registration.value.saving = false;
|
|
||||||
registration.value.stage = 1;
|
|
||||||
}).catch(err => {
|
|
||||||
console.warn('Error while registering?', err);
|
|
||||||
registration.value.error = err.message;
|
|
||||||
registration.value.stage = -1;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePasswordLessLogin() {
|
function renewTOTP() {
|
||||||
await os.api('i/2fa/password-less', {
|
os.confirm({
|
||||||
value: !!usePasswordLessLogin.value,
|
type: 'question',
|
||||||
|
title: i18n.ts._2fa.renewTOTP,
|
||||||
|
text: i18n.ts._2fa.renewTOTPConfirm,
|
||||||
|
okText: i18n.ts._2fa.renewTOTPOk,
|
||||||
|
cancelText: i18n.ts._2fa.renewTOTPCancel,
|
||||||
|
}).then(({ canceled }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
registerTOTP();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregisterKey(key) {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'question',
|
||||||
|
title: i18n.ts._2fa.removeKey,
|
||||||
|
text: i18n.t('_2fa.removeKeyConfirm', { name: key.name }),
|
||||||
|
});
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
|
||||||
|
const password = await os.inputText({
|
||||||
|
title: i18n.ts.password,
|
||||||
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
});
|
||||||
|
if (password.canceled) return;
|
||||||
|
|
||||||
|
await os.apiWithDialog('i/2fa/remove-key', {
|
||||||
|
password: password.result,
|
||||||
|
credentialId: key.id,
|
||||||
|
});
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameKey(key) {
|
||||||
|
const name = await os.inputText({
|
||||||
|
title: i18n.ts.rename,
|
||||||
|
default: key.name,
|
||||||
|
type: 'text',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 30,
|
||||||
|
});
|
||||||
|
if (name.canceled) return;
|
||||||
|
|
||||||
|
await os.apiWithDialog('i/2fa/update-key', {
|
||||||
|
name: name.result,
|
||||||
|
credentialId: key.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSecurityKey() {
|
||||||
|
const password = await os.inputText({
|
||||||
|
title: i18n.ts.password,
|
||||||
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
});
|
||||||
|
if (password.canceled) return;
|
||||||
|
|
||||||
|
const challenge: any = await os.apiWithDialog('i/2fa/register-key', {
|
||||||
|
password: password.result,
|
||||||
|
});
|
||||||
|
|
||||||
|
const name = await os.inputText({
|
||||||
|
title: i18n.ts._2fa.registerSecurityKey,
|
||||||
|
text: i18n.ts._2fa.securityKeyName,
|
||||||
|
type: 'text',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 30,
|
||||||
|
});
|
||||||
|
if (name.canceled) return;
|
||||||
|
|
||||||
|
const webAuthnCreation = navigator.credentials.create({
|
||||||
|
publicKey: {
|
||||||
|
challenge: byteify(challenge.challenge, 'base64'),
|
||||||
|
rp: {
|
||||||
|
id: hostname,
|
||||||
|
name: 'Misskey',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: byteify($i!.id, 'ascii'),
|
||||||
|
name: $i!.username,
|
||||||
|
displayName: $i!.name,
|
||||||
|
},
|
||||||
|
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
||||||
|
timeout: 60000,
|
||||||
|
attestation: 'direct',
|
||||||
|
},
|
||||||
|
}) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
|
||||||
|
|
||||||
|
const credential = await os.promiseDialog(
|
||||||
|
webAuthnCreation,
|
||||||
|
null,
|
||||||
|
() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない
|
||||||
|
i18n.ts._2fa.tapSecurityKey,
|
||||||
|
);
|
||||||
|
if (!credential) return;
|
||||||
|
|
||||||
|
await os.apiWithDialog('i/2fa/key-done', {
|
||||||
|
password: password.result,
|
||||||
|
name: name.result,
|
||||||
|
challengeId: challenge.challengeId,
|
||||||
|
// we convert each 16 bits to a string to serialise
|
||||||
|
clientDataJSON: stringify(credential.response.clientDataJSON),
|
||||||
|
attestationObject: hexify(credential.response.attestationObject),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePasswordLessLogin(value: boolean) {
|
||||||
|
await os.apiWithDialog('i/2fa/password-less', {
|
||||||
|
value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -5,11 +5,8 @@
|
|||||||
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton>
|
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection>
|
<X2fa/>
|
||||||
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
|
|
||||||
<X2fa/>
|
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label>{{ i18n.ts.signinHistory }}</template>
|
<template #label>{{ i18n.ts.signinHistory }}</template>
|
||||||
<MkPagination :pagination="pagination" disable-auto-load>
|
<MkPagination :pagination="pagination" disable-auto-load>
|
||||||
@ -56,18 +53,21 @@ async function change() {
|
|||||||
const { canceled: canceled1, result: currentPassword } = await os.inputText({
|
const { canceled: canceled1, result: currentPassword } = await os.inputText({
|
||||||
title: i18n.ts.currentPassword,
|
title: i18n.ts.currentPassword,
|
||||||
type: 'password',
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
});
|
});
|
||||||
if (canceled1) return;
|
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',
|
||||||
|
autocomplete: 'new-password',
|
||||||
});
|
});
|
||||||
if (canceled2) return;
|
if (canceled2) return;
|
||||||
|
|
||||||
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
|
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
|
||||||
title: i18n.ts.newPasswordRetype,
|
title: i18n.ts.newPasswordRetype,
|
||||||
type: 'password',
|
type: 'password',
|
||||||
|
autocomplete: 'new-password',
|
||||||
});
|
});
|
||||||
if (canceled3) return;
|
if (canceled3) return;
|
||||||
|
|
||||||
@ -109,7 +109,7 @@ definePageMetadata({
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.timnmucd {
|
.timnmucd {
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
border-top-left-radius: 6px;
|
border-top-left-radius: 6px;
|
||||||
|
@ -103,7 +103,6 @@ importers:
|
|||||||
'@types/semver': 7.3.13
|
'@types/semver': 7.3.13
|
||||||
'@types/sharp': 0.31.1
|
'@types/sharp': 0.31.1
|
||||||
'@types/sinonjs__fake-timers': 8.1.2
|
'@types/sinonjs__fake-timers': 8.1.2
|
||||||
'@types/speakeasy': 2.0.7
|
|
||||||
'@types/tinycolor2': 1.4.3
|
'@types/tinycolor2': 1.4.3
|
||||||
'@types/tmp': 0.2.3
|
'@types/tmp': 0.2.3
|
||||||
'@types/unzipper': 0.10.5
|
'@types/unzipper': 0.10.5
|
||||||
@ -164,6 +163,7 @@ importers:
|
|||||||
nsfwjs: 2.4.2
|
nsfwjs: 2.4.2
|
||||||
oauth: 0.10.0
|
oauth: 0.10.0
|
||||||
os-utils: 0.0.14
|
os-utils: 0.0.14
|
||||||
|
otpauth: ^9.0.2
|
||||||
parse5: 7.1.2
|
parse5: 7.1.2
|
||||||
pg: 8.9.0
|
pg: 8.9.0
|
||||||
private-ip: 3.0.0
|
private-ip: 3.0.0
|
||||||
@ -187,7 +187,6 @@ importers:
|
|||||||
seedrandom: 3.0.5
|
seedrandom: 3.0.5
|
||||||
semver: 7.3.8
|
semver: 7.3.8
|
||||||
sharp: 0.31.3
|
sharp: 0.31.3
|
||||||
speakeasy: 2.0.0
|
|
||||||
strict-event-emitter-types: 2.0.0
|
strict-event-emitter-types: 2.0.0
|
||||||
stringz: 2.1.0
|
stringz: 2.1.0
|
||||||
summaly: github:misskey-dev/summaly
|
summaly: github:misskey-dev/summaly
|
||||||
@ -268,6 +267,7 @@ importers:
|
|||||||
nsfwjs: 2.4.2_@tensorflow+tfjs@4.2.0
|
nsfwjs: 2.4.2_@tensorflow+tfjs@4.2.0
|
||||||
oauth: 0.10.0
|
oauth: 0.10.0
|
||||||
os-utils: 0.0.14
|
os-utils: 0.0.14
|
||||||
|
otpauth: 9.0.2
|
||||||
parse5: 7.1.2
|
parse5: 7.1.2
|
||||||
pg: 8.9.0
|
pg: 8.9.0
|
||||||
private-ip: 3.0.0
|
private-ip: 3.0.0
|
||||||
@ -291,10 +291,9 @@ importers:
|
|||||||
seedrandom: 3.0.5
|
seedrandom: 3.0.5
|
||||||
semver: 7.3.8
|
semver: 7.3.8
|
||||||
sharp: 0.31.3
|
sharp: 0.31.3
|
||||||
speakeasy: 2.0.0
|
|
||||||
strict-event-emitter-types: 2.0.0
|
strict-event-emitter-types: 2.0.0
|
||||||
stringz: 2.1.0
|
stringz: 2.1.0
|
||||||
summaly: github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36
|
summaly: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494
|
||||||
systeminformation: 5.17.8
|
systeminformation: 5.17.8
|
||||||
tinycolor2: 1.6.0
|
tinycolor2: 1.6.0
|
||||||
tmp: 0.2.1
|
tmp: 0.2.1
|
||||||
@ -352,7 +351,6 @@ importers:
|
|||||||
'@types/semver': 7.3.13
|
'@types/semver': 7.3.13
|
||||||
'@types/sharp': 0.31.1
|
'@types/sharp': 0.31.1
|
||||||
'@types/sinonjs__fake-timers': 8.1.2
|
'@types/sinonjs__fake-timers': 8.1.2
|
||||||
'@types/speakeasy': 2.0.7
|
|
||||||
'@types/tinycolor2': 1.4.3
|
'@types/tinycolor2': 1.4.3
|
||||||
'@types/tmp': 0.2.3
|
'@types/tmp': 0.2.3
|
||||||
'@types/unzipper': 0.10.5
|
'@types/unzipper': 0.10.5
|
||||||
@ -2801,12 +2799,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
|
resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/speakeasy/2.0.7:
|
|
||||||
resolution: {integrity: sha512-JEcOhN2SQCoX86ZfiZEe8px84sVJtivBXMZfOVyARTYEj0hrwwbj1nF0FwEL3nJSoEV6uTbcdLllMKBgAYHWCQ==}
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 18.13.0
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/stack-utils/2.0.1:
|
/@types/stack-utils/2.0.1:
|
||||||
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
|
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
|
||||||
dev: true
|
dev: true
|
||||||
@ -3849,10 +3841,6 @@ packages:
|
|||||||
pascalcase: 0.1.1
|
pascalcase: 0.1.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/base32.js/0.0.1:
|
|
||||||
resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/base64-js/1.5.1:
|
/base64-js/1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@ -8686,6 +8674,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==}
|
resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/jssha/3.3.0:
|
||||||
|
resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/jstransformer/1.0.0:
|
/jstransformer/1.0.0:
|
||||||
resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
|
resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -9870,6 +9862,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/otpauth/9.0.2:
|
||||||
|
resolution: {integrity: sha512-0TzpkJYg24VvIK3/K91HKpTtMlwm73UoThhcGY8fZsXcwHDrqf008rfdOjj3NnQuyuT11+vHyyO//qRzi6OZ9A==}
|
||||||
|
dependencies:
|
||||||
|
jssha: 3.3.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/p-cancelable/2.1.1:
|
/p-cancelable/2.1.1:
|
||||||
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -11785,13 +11783,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==}
|
resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/speakeasy/2.0.0:
|
|
||||||
resolution: {integrity: sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==}
|
|
||||||
engines: {node: '>= 0.10.0'}
|
|
||||||
dependencies:
|
|
||||||
base32.js: 0.0.1
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/split-string/3.1.0:
|
/split-string/3.1.0:
|
||||||
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
|
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -13445,8 +13436,8 @@ packages:
|
|||||||
version: 2.2.1-misskey.3
|
version: 2.2.1-misskey.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36:
|
github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494:
|
||||||
resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/5684f116c92f1fd122badc7aee062494bdb43b36}
|
resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/51f3870e1ff5e0b22102e804112b10cb72f3c494}
|
||||||
name: summaly
|
name: summaly
|
||||||
version: 3.0.4
|
version: 3.0.4
|
||||||
dependencies:
|
dependencies:
|
||||||
|
Loading…
Reference in New Issue
Block a user