diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2023-02-20 16:40:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-02-20 16:40:24 +0900 |
| commit | 980bf1306e2d097782958f024a86391fc28278a0 (patch) | |
| tree | e1190b5fa0b8b18a425dee0dcdbf580ce2235c5f | |
| parent | refactor: 型エラー修正 / Fix type errors backend (#9983) (diff) | |
| download | misskey-980bf1306e2d097782958f024a86391fc28278a0.tar.gz misskey-980bf1306e2d097782958f024a86391fc28278a0.tar.bz2 misskey-980bf1306e2d097782958f024a86391fc28278a0.zip | |
:art: 2FA設定のデザイン向上 / セキュリティキーの名前を変更できるように (#9985)
* wip
* fix
* wip
* wip
* :v:
* rename key
* :art:
* update CHANGELOG.md
* パスワードレスログインの判断はサーバーで
* 日本語
* 日本語
* 日本語
* 日本語
* :v:
* fix
* refactor
* トークン→確認コード
* fix password-less / qr click
* use otpauth
* 日本語
* autocomplete
* パスワードレス設定は外に出す
* :art:
* :art:
---------
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
23 files changed, 622 insertions, 249 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d81f5de0..1255d614a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ You should also include the user name that made the change. ### Improvements - Server: URLプレビュー(summaly)はプロキシを通すように +- Client: 2FA設定のUIをまともにした +- セキュリティキーの名前を変更できるように ### Bugfixes - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b1f484f6b2..ec1c5d81a2 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -392,17 +392,20 @@ userList: "リスト" about: "情報" aboutMisskey: "Misskeyについて" administrator: "管理者" -token: "トークン" -twoStepAuthentication: "二段階認証" +token: "確認コード" +2fa: "二要素認証" +totp: "認証アプリ" +totpDescription: "認証アプリを使ってワンタイムパスワードを入力" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" +securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" -securityKeyName: "キーの名前" -registerSecurityKey: "セキュリティキーを登録する" lastUsed: "最後の使用" +lastUsedAt: "最後の使用: {t}" unregister: "登録を解除" -passwordLessLogin: "パスワード無しログイン" +passwordLessLogin: "パスワードレスログイン" +passwordLessLoginDescription: "パスワードを使用せず、セキュリティキーやパスキーなどのみでログインします" resetPassword: "パスワードをリセット" newPasswordIs: "新しいパスワードは「{password}」です" reduceUiAnimation: "UIのアニメーションを減らす" @@ -447,7 +450,6 @@ passwordMatched: "一致しました" passwordNotMatched: "一致していません" signinWith: "{x}でログイン" signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" -tapSecurityKey: "セキュリティキーにタッチ" or: "もしくは" language: "言語" uiLanguage: "UIの表示言語" @@ -1519,14 +1521,29 @@ _tutorial: _2fa: alreadyRegistered: "既に設定は完了しています。" - registerDevice: "デバイスを登録" - registerKey: "キーを登録" + registerTOTP: "認証アプリの設定を開始" + passwordToTOTP: "パスワードを入力してください" step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" step2: "次に、表示されているQRコードをアプリでスキャンします。" - step2Url: "デスクトップアプリでは次のURLを入力します:" - step3: "アプリに表示されているトークンを入力して完了です。" - step4: "これからログインするときも、同じようにトークンを入力します。" - securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキーもしくは端末の指紋認証やPINを使用してログインするように設定できます。" + step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" + step2Url: "デスクトップアプリでは次のURIを入力します:" + step3Title: "確認コードを入力" + step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" + step4: "これからログインするときも、同じように確認コードを入力します。" + securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" + registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" + securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" + chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" + registerSecurityKey: "セキュリティキー・パスキーを登録する" + securityKeyName: "キーの名前を入力" + tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" + removeKey: "セキュリティキーを削除" + removeKeyConfirm: "{name}を削除しますか?" + whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" + renewTOTP: "認証アプリを再設定" + renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" + renewTOTPOk: "再設定する" + renewTOTPCancel: "やめておく" _permissions: "read:account": "アカウントの情報を見る" @@ -1861,3 +1878,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + +_dialog: + charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" + charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" diff --git a/packages/backend/package.json b/packages/backend/package.json index 52b0d74ad0..8d2f509056 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -85,6 +85,7 @@ "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", + "otpauth": "^9.0.2", "parse5": "7.1.2", "pg": "8.9.0", "private-ip": "3.0.0", @@ -108,7 +109,6 @@ "seedrandom": "3.0.5", "semver": "7.3.8", "sharp": "0.31.3", - "speakeasy": "2.0.0", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", @@ -167,7 +167,6 @@ "@types/semver": "7.3.13", "@types/sharp": "0.31.1", "@types/sinonjs__fake-timers": "8.1.2", - "@types/speakeasy": "2.0.7", "@types/tinycolor2": "1.4.3", "@types/tmp": "0.2.3", "@types/unzipper": "0.10.5", diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 30101a2c60..d27f926ef8 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -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_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_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_unregister from './endpoints/i/2fa/unregister.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_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_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_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 }; @@ -806,6 +808,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, @@ -1120,6 +1123,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_2fa_passwordLess, $i_2fa_registerKey, $i_2fa_register, + $i_2fa_updateKey, $i_2fa_removeKey, $i_2fa_unregister, $i_apps, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index f1164b9957..6451d7c2a9 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from "otpauth"; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.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({ - secret: profile.twoFactorSecret, - encoding: 'base32', - token: token, - window: 2, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret), + digits: 6, + token, + window: 1, }); - if (verified) { - return this.signinService.signin(request, reply, user); - } else { + if (delta === null) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); + } else { + return this.signinService.signin(request, reply, user); } } else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) { if (!same && !profile.usePasswordLessLogin) { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index d05005b078..f7f2b1f376 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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_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_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_unregister from './endpoints/i/2fa/unregister.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/register-key', ep___i_2fa_registerKey], ['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/unregister', ep___i_2fa_unregister], ['i/apps', ep___i_apps], diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts index ec9ac1ef90..6c31075e05 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -1,7 +1,10 @@ -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.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'; export const meta = { @@ -22,8 +25,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const token = ps.token.replace(/\s/g, ''); @@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { throw new Error('二段階認証の設定が開始されていません'); } - const verified = (speakeasy as any).totp.verify({ - secret: profile.twoFactorTempSecret, - encoding: 'base32', - token: token, + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), + digits: 6, + token, + window: 1, }); - if (!verified) { + if (delta === null) { throw new Error('not verified'); } @@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { twoFactorSecret: profile.twoFactorTempSecret, twoFactorEnabled: true, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 6e0849f2b2..ad33398da6 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -25,7 +25,7 @@ export const paramDef = { attestationObject: { type: 'string' }, password: { type: 'string' }, challengeId: { type: 'string' }, - name: { type: 'string' }, + name: { type: 'string', minLength: 1, maxLength: 30 }, }, required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'], } as const; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts index 0655a86350..0ee9f556a8 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -1,12 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; 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 { ApiError } from '../../../error.js'; export const meta = { requireCredential: true, secure: true, + + errors: { + noKey: { + message: 'No security key.', + code: 'NO_SECURITY_KEY', + id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512', + }, + }, } as const; export const paramDef = { @@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { 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, { usePasswordLessLogin: ps.value, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a539c5c221..eb4d7f9c14 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import * as speakeasy from 'speakeasy'; +import * as OTPAuth from 'otpauth'; import * as QRCode from 'qrcode'; import { Inject, Injectable } from '@nestjs/common'; 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 - const secret = speakeasy.generateSecret({ - length: 32, - }); + const secret = new OTPAuth.Secret(); await this.userProfilesRepository.update(me.id, { twoFactorTempSecret: secret.base32, }); // Get the data URL of the authenticator URL - const url = speakeasy.otpauthURL({ - secret: secret.base32, - encoding: 'base32', + const totp = new OTPAuth.TOTP({ + secret, + digits: 6, label: me.username, issuer: this.config.host, }); - const dataUrl = await QRCode.toDataURL(url); + const url = totp.toString(); + const qr = await QRCode.toDataURL(url); return { - qr: dataUrl, + qr, url, secret: secret.base32, label: me.username, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index 0f2b0fd7ee..4b726aed80 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { 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 this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { detail: true, diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index 4c5b151f78..e0e7ba6658 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -1,7 +1,9 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserProfilesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { constructor( @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { 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, { twoFactorSecret: null, twoFactorEnabled: false, + usePasswordLessLogin: false, }); + + // Publish meUpdated event + this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts new file mode 100644 index 0000000000..d98f60fa5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -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 {}; + }); + } +} diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 9690353432..863ea702cd 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -14,8 +14,12 @@ </div> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <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 #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> <MkSelect v-if="select" v-model="selectedValue" autofocus> <template v-if="select.items"> @@ -28,7 +32,7 @@ </template> </MkSelect> <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> </div> <div v-if="actions" :class="$style.buttons"> @@ -47,9 +51,12 @@ import MkSelect from '@/components/MkSelect.vue'; import { i18n } from '@/i18n'; type Input = { - type: HTMLInputElement['type']; + type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; placeholder?: string | null; - default: any | null; + autocomplete?: string; + default: string | number | null; + minLength?: number; + maxLength?: number; }; type Select = { @@ -98,8 +105,28 @@ const emit = defineEmits<{ const modal = shallowRef<InstanceType<typeof MkModal>>(); -const inputValue = ref(props.input?.default || null); -const selectedValue = ref(props.select?.default || null); +const inputValue = ref<string | number | null>(props.input?.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?) { emit('done', { canceled, result }); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index a1d7210d7e..b97e36cd5f 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,13 +1,20 @@ <template> <div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]"> <div :class="$style.header" class="_button" @click="toggle"> - <span :class="$style.headerIcon"><slot name="icon"></slot></span> - <span :class="$style.headerText"><slot name="label"></slot></span> - <span :class="$style.headerRight"> + <div :class="$style.headerIcon"><slot name="icon"></slot></div> + <div :class="$style.headerText"> + <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> <i v-if="opened" class="ti ti-chevron-up icon"></i> <i v-else class="ti ti-chevron-down icon"></i> - </span> + </div> </div> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }"> <Transition @@ -139,6 +146,17 @@ onMounted(() => { } } +.headerUpper { + display: flex; + align-items: center; +} + +.headerLower { + color: var(--fgTransparentWeak); + font-size: .85em; + padding-left: 4px; +} + .headerIcon { margin-right: 0.75em; flex-shrink: 0; @@ -161,6 +179,15 @@ onMounted(() => { padding-right: 12px; } +.headerTextMain { + +} + +.headerTextSub { + color: var(--fgTransparentWeak); + font-size: .85em; +} + .headerRight { margin-left: auto; opacity: 0.7; diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index da6177c2f9..0f99bf9aad 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; const props = defineProps<{ - modelValue: string | number; + modelValue: string | number | null; type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local'; required?: boolean; readonly?: boolean; @@ -49,7 +49,7 @@ const props = defineProps<{ pattern?: string; placeholder?: string; autofocus?: boolean; - autocomplete?: boolean; + autocomplete?: string; spellcheck?: boolean; step?: any; datalist?: string[]; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index cb64b1e484..2de890186a 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -34,7 +34,7 @@ import { useInterval } from '@/scripts/use-interval'; import { i18n } from '@/i18n'; const props = defineProps<{ - modelValue: string; + modelValue: string | null; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -48,7 +48,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; - (ev: 'update:modelValue', value: string): void; + (ev: 'update:modelValue', value: string | null): void; }>(); const slots = useSlots(); diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index ae4f38e56c..ffc5e82b56 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -10,7 +10,7 @@ <template #prefix>@</template> <template #suffix>@{{ host }}</template> </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 #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> </MkInput> @@ -28,11 +28,11 @@ </div> <div class="twofa-group totp-group"> <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 #prefix><i class="ti ti-lock"></i></template> </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 #prefix><i class="ti ti-123"></i></template> </MkInput> diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6bff12661f..a69fe73f30 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -246,7 +246,10 @@ export function inputText(props: { title?: string | null; text?: string | null; placeholder?: string | null; + autocomplete?: string; default?: string | null; + minLength?: number; + maxLength?: number; }): Promise<{ canceled: true; result: undefined; } | { canceled: false; result: string; }> { @@ -257,7 +260,10 @@ export function inputText(props: { input: { type: props.type, placeholder: props.placeholder, + autocomplete: props.autocomplete, default: props.default, + minLength: props.minLength, + maxLength: props.maxLength, }, }, { done: result => { @@ -271,6 +277,7 @@ export function inputNumber(props: { title?: string | null; text?: string | null; placeholder?: string | null; + autocomplete?: string; default?: number | null; }): Promise<{ canceled: true; result: undefined; } | { canceled: false; result: number; @@ -282,6 +289,7 @@ export function inputNumber(props: { input: { type: 'number', placeholder: props.placeholder, + autocomplete: props.autocomplete, default: props.default, }, }, { diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue new file mode 100644 index 0000000000..1d836db5f5 --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue @@ -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> diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index e6ef09668c..891934d706 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -1,216 +1,258 @@ <template> -<div> - <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> - <template v-if="$i.twoFactorEnabled"> - <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> - <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> +<FormSection :first="first"> + <template #label>{{ i18n.ts['2fa'] }}</template> - <template v-if="supportsCredentials"> - <hr class="totp-method-sep"> - - <h2 class="heading">{{ i18n.ts.securityKey }}</h2> - <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> - <div class="key-list"> - <div v-for="key in $i.securityKeysList" class="key"> - <h3>{{ key.name }}</h3> - <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> - <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> - </div> + <div v-if="$i" class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-shield-lock"></i></template> + <template #label>{{ i18n.ts.totp }}</template> + <template #caption>{{ i18n.ts.totpDescription }}</template> + <div v-if="$i.twoFactorEnabled" class="_gaps_s"> + <div v-text="i18n.ts._2fa.alreadyRegistered"/> + <template v-if="$i.securityKeysList.length > 0"> + <MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton> + <MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo> + </template> + <MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton> </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> + + <MkFolder> + <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> - <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> - <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + <MkInfo v-if="!supportsCredentials" warn> + {{ i18n.ts._2fa.securityKeyNotSupported }} + </MkInfo> - <ol v-if="registration && !registration.error"> - <li v-if="registration.stage >= 0"> - {{ i18n.ts.tapSecurityKey }} - <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> - </li> - <li v-if="registration.stage >= 1"> - <MkForm :disabled="registration.stage != 1 || registration.saving"> - <MkInput v-model="keyName" :max="30"> - <template #label>{{ i18n.ts.securityKeyName }}</template> - </MkInput> - <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> - <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> - </MkForm> - </li> - </ol> - </template> - </template> - <div v-if="twoFactorData && !$i.twoFactorEnabled"> - <ol style="margin: 0; padding: 0 0 0 1em;"> - <li> - <I18n :src="i18n.ts._2fa.step1" tag="span"> - <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> - </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> + <MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn> + {{ i18n.ts._2fa.registerTOTPBeforeKey }} + </MkInfo> + + <template v-else> + <MkButton primary @click="addSecurityKey">{{ i18n.ts._2fa.registerSecurityKey }}</MkButton> + <MkFolder v-for="key in $i.securityKeysList" :key="key.id"> + <template #label>{{ key.name }}</template> + <template #suffix><I18n :src="i18n.ts.lastUsedAt"><template #t><MkTime :time="key.lastUsed"/></template></I18n></template> + <div class="_buttons"> + <MkButton @click="renameKey(key)"><i class="ti ti-forms"></i> {{ i18n.ts.rename }}</MkButton> + <MkButton danger @click="unregisterKey(key)"><i class="ti ti-trash"></i> {{ i18n.ts.unregister }}</MkButton> + </div> + </MkFolder> + </template> + </div> + </MkFolder> + + <MkSwitch :disabled="!$i.twoFactorEnabled || $i.securityKeysList.length === 0" :model-value="usePasswordLessLogin" @update:model-value="v => updatePasswordLessLogin(v)"> + <template #label>{{ i18n.ts.passwordLessLogin }}</template> + <template #caption>{{ i18n.ts.passwordLessLoginDescription }}</template> + </MkSwitch> </div> -</div> +</FormSection> </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, defineAsyncComponent } from 'vue'; import { hostname } from '@/config'; import { byteify, hexify, stringify } from '@/scripts/2fa'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import MkInput from '@/components/MkInput.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 { $i } from '@/account'; import { i18n } from '@/i18n'; +// メモ: 各エンドポイントはmeUpdatedを発行するため、refreshAccountは不要 + +withDefaults(defineProps<{ + first?: boolean; +}>(), { + first: false, +}); + const twoFactorData = ref<any>(null); const supportsCredentials = ref(!!navigator.credentials); -const usePasswordLessLogin = ref($i!.usePasswordLessLogin); -const registration = ref<any>(null); -const keyName = ref(''); -const token = ref(null); +const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin); -function register() { - os.inputText({ - title: i18n.ts.password, +async function registerTOTP() { + const password = await os.inputText({ + title: i18n.ts._2fa.registerTOTP, + text: i18n.ts._2fa.passwordToTOTP, type: 'password', - }).then(({ canceled, result: password }) => { - if (canceled) return; - os.api('i/2fa/register', { - password: password, - }).then(data => { - twoFactorData.value = data; - }); + 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 unregister() { +function unregisterTOTP() { os.inputText({ title: i18n.ts.password, type: 'password', + autocomplete: 'current-password', }).then(({ canceled, result: password }) => { if (canceled) return; - os.api('i/2fa/unregister', { + os.apiWithDialog('i/2fa/unregister', { password: password, - }).then(() => { - usePasswordLessLogin.value = false; - updatePasswordLessLogin(); - }).then(() => { - os.success(); - $i!.twoFactorEnabled = false; + }).catch(error => { + os.alert({ + type: 'error', + text: error, + }); }); }); } -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 renewTOTP() { + os.confirm({ + 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(); }); } -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(); +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; -function unregisterKey(key) { - os.inputText({ + const password = await 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(); - }); + autocomplete: 'current-password', + }); + if (password.canceled) return; + + await os.apiWithDialog('i/2fa/remove-key', { + password: password.result, + credentialId: key.id, }); + os.success(); } -function addSecurityKey() { - os.inputText({ +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', - }).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; - }); + 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() { - await os.api('i/2fa/password-less', { - value: !!usePasswordLessLogin.value, +async function updatePasswordLessLogin(value: boolean) { + await os.apiWithDialog('i/2fa/password-less', { + value, }); } </script> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue index b09c4ffd2f..0cc2df09c5 100644 --- a/packages/frontend/src/pages/settings/security.vue +++ b/packages/frontend/src/pages/settings/security.vue @@ -5,11 +5,8 @@ <MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> </FormSection> - <FormSection> - <template #label>{{ i18n.ts.twoStepAuthentication }}</template> - <X2fa/> - </FormSection> - + <X2fa/> + <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> <MkPagination :pagination="pagination" disable-auto-load> @@ -56,18 +53,21 @@ async function change() { const { canceled: canceled1, result: currentPassword } = await os.inputText({ title: i18n.ts.currentPassword, type: 'password', + autocomplete: 'current-password', }); if (canceled1) return; const { canceled: canceled2, result: newPassword } = await os.inputText({ title: i18n.ts.newPassword, type: 'password', + autocomplete: 'new-password', }); if (canceled2) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, type: 'password', + autocomplete: 'new-password', }); if (canceled3) return; @@ -109,7 +109,7 @@ definePageMetadata({ <style lang="scss" scoped> .timnmucd { - padding: 16px; + padding: 12px; &:first-child { border-top-left-radius: 6px; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5e52ef9d2..2a202a3291 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,6 @@ importers: '@types/semver': 7.3.13 '@types/sharp': 0.31.1 '@types/sinonjs__fake-timers': 8.1.2 - '@types/speakeasy': 2.0.7 '@types/tinycolor2': 1.4.3 '@types/tmp': 0.2.3 '@types/unzipper': 0.10.5 @@ -164,6 +163,7 @@ importers: nsfwjs: 2.4.2 oauth: 0.10.0 os-utils: 0.0.14 + otpauth: ^9.0.2 parse5: 7.1.2 pg: 8.9.0 private-ip: 3.0.0 @@ -187,7 +187,6 @@ importers: seedrandom: 3.0.5 semver: 7.3.8 sharp: 0.31.3 - speakeasy: 2.0.0 strict-event-emitter-types: 2.0.0 stringz: 2.1.0 summaly: github:misskey-dev/summaly @@ -268,6 +267,7 @@ importers: nsfwjs: 2.4.2_@tensorflow+tfjs@4.2.0 oauth: 0.10.0 os-utils: 0.0.14 + otpauth: 9.0.2 parse5: 7.1.2 pg: 8.9.0 private-ip: 3.0.0 @@ -291,10 +291,9 @@ importers: seedrandom: 3.0.5 semver: 7.3.8 sharp: 0.31.3 - speakeasy: 2.0.0 strict-event-emitter-types: 2.0.0 stringz: 2.1.0 - summaly: github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36 + summaly: github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494 systeminformation: 5.17.8 tinycolor2: 1.6.0 tmp: 0.2.1 @@ -352,7 +351,6 @@ importers: '@types/semver': 7.3.13 '@types/sharp': 0.31.1 '@types/sinonjs__fake-timers': 8.1.2 - '@types/speakeasy': 2.0.7 '@types/tinycolor2': 1.4.3 '@types/tmp': 0.2.3 '@types/unzipper': 0.10.5 @@ -2801,12 +2799,6 @@ packages: resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==} 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: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true @@ -3849,10 +3841,6 @@ packages: pascalcase: 0.1.1 dev: false - /base32.js/0.0.1: - resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==} - dev: false - /base64-js/1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -8686,6 +8674,10 @@ packages: resolution: {integrity: sha512-emiQ05haY9CRj1Ho/LiuCqr/+8RgJuWdiHYNglIg2Qjfz0n+pnUq9I2QHplXuOMO2EnAW1oCGC1++aU5VoWSlw==} dev: false + /jssha/3.3.0: + resolution: {integrity: sha512-w9OtT4ALL+fbbwG3gw7erAO0jvS5nfvrukGPMWIAoea359B26ALXGpzy4YJSp9yGnpUvuvOw1nSjSoHDfWSr1w==} + dev: false + /jstransformer/1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} dependencies: @@ -9870,6 +9862,12 @@ packages: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} 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: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -11785,13 +11783,6 @@ packages: resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==} 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: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} @@ -13445,8 +13436,8 @@ packages: version: 2.2.1-misskey.3 dev: false - github.com/misskey-dev/summaly/5684f116c92f1fd122badc7aee062494bdb43b36: - resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/5684f116c92f1fd122badc7aee062494bdb43b36} + github.com/misskey-dev/summaly/51f3870e1ff5e0b22102e804112b10cb72f3c494: + resolution: {tarball: https://codeload.github.com/misskey-dev/summaly/tar.gz/51f3870e1ff5e0b22102e804112b10cb72f3c494} name: summaly version: 3.0.4 dependencies: |