feat: Refine 2fa (#11766)
* wip
* Update 2fa.qrdialog.vue
* Update 2fa.vue
* Update CHANGELOG.md
* tweak
* ✌️
This commit is contained in:
parent
39d9172a2f
commit
257c4fccf1
28 changed files with 267 additions and 99 deletions
|
@ -21,6 +21,7 @@
|
||||||
- お知らせのバナー表示やダイアログ表示が可能に
|
- お知らせのバナー表示やダイアログ表示が可能に
|
||||||
- お知らせのアイコンを設定可能に
|
- お知らせのアイコンを設定可能に
|
||||||
- チャンネルをセンシティブ指定できるようになりました
|
- チャンネルをセンシティブ指定できるようになりました
|
||||||
|
- 二要素認証のバックアップコードが生成されるようになりました
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- プロフィールにその人が作ったPlayの一覧出せるように
|
- プロフィールにその人が作ったPlayの一覧出せるように
|
||||||
|
|
9
locales/index.d.ts
vendored
9
locales/index.d.ts
vendored
|
@ -414,6 +414,7 @@ export interface Locale {
|
||||||
"administrator": string;
|
"administrator": string;
|
||||||
"token": string;
|
"token": string;
|
||||||
"2fa": string;
|
"2fa": string;
|
||||||
|
"setupOf2fa": string;
|
||||||
"totp": string;
|
"totp": string;
|
||||||
"totpDescription": string;
|
"totpDescription": string;
|
||||||
"moderator": string;
|
"moderator": string;
|
||||||
|
@ -1811,9 +1812,10 @@ export interface Locale {
|
||||||
"step1": string;
|
"step1": string;
|
||||||
"step2": string;
|
"step2": string;
|
||||||
"step2Click": string;
|
"step2Click": string;
|
||||||
"step2Url": string;
|
"step2Uri": string;
|
||||||
"step3Title": string;
|
"step3Title": string;
|
||||||
"step3": string;
|
"step3": string;
|
||||||
|
"setupCompleted": string;
|
||||||
"step4": string;
|
"step4": string;
|
||||||
"securityKeyNotSupported": string;
|
"securityKeyNotSupported": string;
|
||||||
"registerTOTPBeforeKey": string;
|
"registerTOTPBeforeKey": string;
|
||||||
|
@ -1829,6 +1831,11 @@ export interface Locale {
|
||||||
"renewTOTPConfirm": string;
|
"renewTOTPConfirm": string;
|
||||||
"renewTOTPOk": string;
|
"renewTOTPOk": string;
|
||||||
"renewTOTPCancel": string;
|
"renewTOTPCancel": string;
|
||||||
|
"checkBackupCodesBeforeCloseThisWizard": string;
|
||||||
|
"backupCodes": string;
|
||||||
|
"backupCodesDescription": string;
|
||||||
|
"backupCodeUsedWarning": string;
|
||||||
|
"backupCodesExhaustedWarning": string;
|
||||||
};
|
};
|
||||||
"_permissions": {
|
"_permissions": {
|
||||||
"read:account": string;
|
"read:account": string;
|
||||||
|
|
|
@ -411,6 +411,7 @@ aboutMisskey: "Misskeyについて"
|
||||||
administrator: "管理者"
|
administrator: "管理者"
|
||||||
token: "確認コード"
|
token: "確認コード"
|
||||||
2fa: "二要素認証"
|
2fa: "二要素認証"
|
||||||
|
setupOf2fa: "二要素認証のセットアップ"
|
||||||
totp: "認証アプリ"
|
totp: "認証アプリ"
|
||||||
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
|
totpDescription: "認証アプリを使ってワンタイムパスワードを入力"
|
||||||
moderator: "モデレーター"
|
moderator: "モデレーター"
|
||||||
|
@ -1729,10 +1730,11 @@ _2fa:
|
||||||
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。"
|
||||||
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
step2: "次に、表示されているQRコードをアプリでスキャンします。"
|
||||||
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。"
|
||||||
step2Url: "デスクトップアプリでは次のURIを入力します:"
|
step2Uri: "デスクトップアプリを使用する場合は次のURIを入力します"
|
||||||
step3Title: "確認コードを入力"
|
step3Title: "確認コードを入力"
|
||||||
step3: "アプリに表示されている確認コード(トークン)を入力して完了です。"
|
step3: "アプリに表示されている確認コード(トークン)を入力します。"
|
||||||
step4: "これからログインするときも、同じように確認コードを入力します。"
|
setupCompleted: "設定が完了しました"
|
||||||
|
step4: "これからログインするときも、同じようにコードを入力します。"
|
||||||
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。"
|
||||||
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。"
|
||||||
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。"
|
||||||
|
@ -1744,9 +1746,14 @@ _2fa:
|
||||||
removeKeyConfirm: "{name}を削除しますか?"
|
removeKeyConfirm: "{name}を削除しますか?"
|
||||||
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。"
|
||||||
renewTOTP: "認証アプリを再設定"
|
renewTOTP: "認証アプリを再設定"
|
||||||
renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります"
|
renewTOTPConfirm: "今までの認証アプリの確認コードおよびバックアップコードは使用できなくなります"
|
||||||
renewTOTPOk: "再設定する"
|
renewTOTPOk: "再設定する"
|
||||||
renewTOTPCancel: "やめておく"
|
renewTOTPCancel: "やめておく"
|
||||||
|
checkBackupCodesBeforeCloseThisWizard: "このウィザードを閉じる前に、以下のバックアップコードを確認してください。"
|
||||||
|
backupCodes: "バックアップコード"
|
||||||
|
backupCodesDescription: "認証アプリが使用できなくなった場合、以下のバックアップコードを使ってアカウントにアクセスできます。これらのコードは必ず安全な場所に保管してください。各コードは一回だけ使用できます。"
|
||||||
|
backupCodeUsedWarning: "バックアップコードが使用されました。認証アプリが使えなくなっている場合、なるべく早く認証アプリを再設定してください。"
|
||||||
|
backupCodesExhaustedWarning: "バックアップコードが全て使用されました。認証アプリを利用できない場合、これ以上アカウントにアクセスできなくなります。認証アプリを再登録してください。"
|
||||||
|
|
||||||
_permissions:
|
_permissions:
|
||||||
"read:account": "アカウントの情報を見る"
|
"read:account": "アカウントの情報を見る"
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export class User2faBackupCodes1690569881926 {
|
||||||
|
name = 'User2faBackupCodes1690569881926'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_profile" ADD "twoFactorBackupSecret" character varying array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "twoFactorBackupSecret"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -434,6 +434,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
preventAiLearning: profile!.preventAiLearning,
|
preventAiLearning: profile!.preventAiLearning,
|
||||||
isExplorable: user.isExplorable,
|
isExplorable: user.isExplorable,
|
||||||
isDeleted: user.isDeleted,
|
isDeleted: user.isDeleted,
|
||||||
|
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
|
||||||
hideOnlineStatus: user.hideOnlineStatus,
|
hideOnlineStatus: user.hideOnlineStatus,
|
||||||
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
|
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
|
||||||
where: { userId: user.id, isSpecified: true },
|
where: { userId: user.id, isSpecified: true },
|
||||||
|
|
|
@ -101,6 +101,11 @@ export class MiUserProfile {
|
||||||
})
|
})
|
||||||
public twoFactorSecret: string | null;
|
public twoFactorSecret: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
nullable: true, array: true,
|
||||||
|
})
|
||||||
|
public twoFactorBackupSecret: string[] | null;
|
||||||
|
|
||||||
@Column('boolean', {
|
@Column('boolean', {
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
|
|
|
@ -321,6 +321,11 @@ export const packedMeDetailedOnlySchema = {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
},
|
},
|
||||||
|
twoFactorBackupCodesStock: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['full', 'partial', 'none'],
|
||||||
|
nullable: false, optional: false,
|
||||||
|
},
|
||||||
hideOnlineStatus: {
|
hideOnlineStatus: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
|
|
|
@ -160,6 +160,13 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.twoFactorBackupSecret?.includes(token)) {
|
||||||
|
await this.userProfilesRepository.update({ userId: profile.userId }, {
|
||||||
|
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
|
||||||
|
});
|
||||||
|
return this.signinService.signin(request, reply, user);
|
||||||
|
}
|
||||||
|
|
||||||
const delta = OTPAuth.TOTP.validate({
|
const delta = OTPAuth.TOTP.validate({
|
||||||
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
|
||||||
digits: 6,
|
digits: 6,
|
||||||
|
|
|
@ -54,8 +54,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new Error('not verified');
|
throw new Error('not verified');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backupCodes = Array.from({ length: 5 }, () => new OTPAuth.Secret().base32);
|
||||||
|
|
||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
twoFactorSecret: profile.twoFactorTempSecret,
|
twoFactorSecret: profile.twoFactorTempSecret,
|
||||||
|
twoFactorBackupSecret: backupCodes,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,6 +67,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupCodes: backupCodes,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
await this.userProfilesRepository.update(me.id, {
|
await this.userProfilesRepository.update(me.id, {
|
||||||
twoFactorSecret: null,
|
twoFactorSecret: null,
|
||||||
|
twoFactorBackupSecret: null,
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
usePasswordLessLogin: false,
|
usePasswordLessLogin: false,
|
||||||
});
|
});
|
||||||
|
|
|
@ -191,7 +191,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const usersShowResponse = await api('/users/show', {
|
const usersShowResponse = await api('/users/show', {
|
||||||
username,
|
username,
|
||||||
|
@ -216,7 +216,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
password,
|
password,
|
||||||
|
@ -272,7 +272,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
password,
|
password,
|
||||||
|
@ -329,7 +329,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
password,
|
password,
|
||||||
|
@ -371,7 +371,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const registerKeyResponse = await api('/i/2fa/register-key', {
|
const registerKeyResponse = await api('/i/2fa/register-key', {
|
||||||
password,
|
password,
|
||||||
|
@ -423,7 +423,7 @@ describe('2要素認証', () => {
|
||||||
const doneResponse = await api('/i/2fa/done', {
|
const doneResponse = await api('/i/2fa/done', {
|
||||||
token: otpToken(registerResponse.body.secret),
|
token: otpToken(registerResponse.body.secret),
|
||||||
}, alice);
|
}, alice);
|
||||||
assert.strictEqual(doneResponse.status, 204);
|
assert.strictEqual(doneResponse.status, 200);
|
||||||
|
|
||||||
const usersShowResponse = await api('/users/show', {
|
const usersShowResponse = await api('/users/show', {
|
||||||
username,
|
username,
|
||||||
|
|
|
@ -152,6 +152,7 @@ describe('ユーザー', () => {
|
||||||
preventAiLearning: user.preventAiLearning,
|
preventAiLearning: user.preventAiLearning,
|
||||||
isExplorable: user.isExplorable,
|
isExplorable: user.isExplorable,
|
||||||
isDeleted: user.isDeleted,
|
isDeleted: user.isDeleted,
|
||||||
|
twoFactorBackupCodesStock: user.twoFactorBackupCodesStock,
|
||||||
hideOnlineStatus: user.hideOnlineStatus,
|
hideOnlineStatus: user.hideOnlineStatus,
|
||||||
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
|
||||||
hasUnreadMentions: user.hasUnreadMentions,
|
hasUnreadMentions: user.hasUnreadMentions,
|
||||||
|
@ -398,6 +399,7 @@ describe('ユーザー', () => {
|
||||||
assert.strictEqual(response.preventAiLearning, true);
|
assert.strictEqual(response.preventAiLearning, true);
|
||||||
assert.strictEqual(response.isExplorable, true);
|
assert.strictEqual(response.isExplorable, true);
|
||||||
assert.strictEqual(response.isDeleted, false);
|
assert.strictEqual(response.isDeleted, false);
|
||||||
|
assert.strictEqual(response.twoFactorBackupCodesStock, 'none');
|
||||||
assert.strictEqual(response.hideOnlineStatus, false);
|
assert.strictEqual(response.hideOnlineStatus, false);
|
||||||
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
|
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
|
||||||
assert.strictEqual(response.hasUnreadMentions, false);
|
assert.strictEqual(response.hasUnreadMentions, false);
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import type { entities } from 'misskey-js'
|
import type { entities } from 'misskey-js'
|
||||||
|
|
||||||
export function abuseUserReport() {
|
export function abuseUserReport() {
|
||||||
|
@ -110,6 +115,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
||||||
publicReactions: false,
|
publicReactions: false,
|
||||||
securityKeys: false,
|
securityKeys: false,
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
twoFactorBackupCodesStock: 'none',
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
uri: null,
|
uri: null,
|
||||||
url: null,
|
url: null,
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
import { basename, dirname } from 'node:path/posix';
|
import { basename, dirname } from 'node:path/posix';
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { addons } from '@storybook/manager-api';
|
import { addons } from '@storybook/manager-api';
|
||||||
import { create } from '@storybook/theming/create';
|
import { create } from '@storybook/theming/create';
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { type SharedOptions, rest } from 'msw';
|
import { type SharedOptions, rest } from 'msw';
|
||||||
|
|
||||||
export const onUnhandledRequest = ((req, print) => {
|
export const onUnhandledRequest = ((req, print) => {
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
import locales from '../../../locales/index.js';
|
import locales from '../../../locales/index.js';
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
import JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
import { addons } from '@storybook/addons';
|
import { addons } from '@storybook/addons';
|
||||||
import { FORCE_REMOUNT } from '@storybook/core-events';
|
import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||||
import { type Preview, setup } from '@storybook/vue3';
|
import { type Preview, setup } from '@storybook/vue3';
|
||||||
|
|
|
@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<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="one-time-code" :spellcheck="false" required>
|
<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" 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>
|
||||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<div :class="$style.root">
|
<div :class="$style.root">
|
||||||
<div :class="$style.editor" class="_panel">
|
<div :class="$style.editor" class="_panel">
|
||||||
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :lineNumbers="false"/>
|
<PrismEditor v-model="code" class="_monospace" :class="$style.code" :highlight="highlighter" :lineNumbers="false"/>
|
||||||
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
|
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -175,6 +175,14 @@ definePageMetadata({
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.ui {
|
.ui {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,45 +4,110 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MkModal
|
<MkModalWindow
|
||||||
ref="dialogEl"
|
ref="dialog"
|
||||||
:preferType="'dialog'"
|
:width="500"
|
||||||
:zPriority="'low'"
|
:height="550"
|
||||||
@click="cancel"
|
|
||||||
@close="cancel"
|
@close="cancel"
|
||||||
@closed="emit('closed')"
|
@closed="emit('closed')"
|
||||||
>
|
>
|
||||||
<div :class="$style.root" class="_gaps_m">
|
<template #header>{{ i18n.ts.setupOf2fa }}</template>
|
||||||
<I18n :src="i18n.ts._2fa.step1" tag="div">
|
|
||||||
<template #a>
|
<div style="overflow-x: clip;">
|
||||||
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
|
<Transition
|
||||||
|
mode="out-in"
|
||||||
|
:enterActiveClass="$style.transition_x_enterActive"
|
||||||
|
:leaveActiveClass="$style.transition_x_leaveActive"
|
||||||
|
:enterFromClass="$style.transition_x_enterFrom"
|
||||||
|
:leaveToClass="$style.transition_x_leaveTo"
|
||||||
|
>
|
||||||
|
<template v-if="page === 0">
|
||||||
|
<div style="height: 100cqh; overflow: auto; text-align: center;">
|
||||||
|
<MkSpacer :marginMin="20" :marginMax="28">
|
||||||
|
<div class="_gaps">
|
||||||
|
<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.step2Uri }}</template>
|
||||||
|
<template #value>{{ twoFactorData.url }}</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
</div>
|
||||||
|
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||||
|
<MkButton rounded @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||||
|
<MkButton primary rounded gradate @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #b>
|
<template v-else-if="page === 1">
|
||||||
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
|
<div style="height: 100cqh; overflow: auto;">
|
||||||
|
<MkSpacer :marginMin="20" :marginMax="28">
|
||||||
|
<div class="_gaps">
|
||||||
|
<div>{{ i18n.ts._2fa.step3Title }}</div>
|
||||||
|
<MkInput v-model="token" autocomplete="one-time-code" type="number"></MkInput>
|
||||||
|
<div>{{ i18n.ts._2fa.step3 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||||
|
<MkButton rounded @click="page--"><i class="ti ti-arrow-left"></i> {{ i18n.ts.goBack }}</MkButton>
|
||||||
|
<MkButton primary rounded gradate @click="tokenDone">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</I18n>
|
<template v-else-if="page === 2">
|
||||||
<div>
|
<div style="height: 100cqh; overflow: auto;">
|
||||||
{{ i18n.ts._2fa.step2 }}<br>
|
<MkSpacer :marginMin="20" :marginMax="28">
|
||||||
{{ i18n.ts._2fa.step2Click }}
|
<div class="_gaps">
|
||||||
</div>
|
<div style="text-align: center;">{{ i18n.ts._2fa.setupCompleted }}🎉</div>
|
||||||
<a :href="twoFactorData.url"><img :class="$style.qr" :src="twoFactorData.qr"></a>
|
<div style="text-align: center;">{{ i18n.ts._2fa.step4 }}</div>
|
||||||
<MkKeyValue :copy="twoFactorData.url">
|
<div style="text-align: center; font-weight: bold;">{{ i18n.ts._2fa.checkBackupCodesBeforeCloseThisWizard }}</div>
|
||||||
<template #key>{{ i18n.ts._2fa.step2Url }}</template>
|
|
||||||
<template #value>{{ twoFactorData.url }}</template>
|
<MkFolder :defaultOpen="true">
|
||||||
</MkKeyValue>
|
<template #icon><i class="ti ti-key"></i></template>
|
||||||
<div class="_buttons">
|
<template #label>{{ i18n.ts._2fa.backupCodes }}</template>
|
||||||
<MkButton primary @click="ok">{{ i18n.ts.next }}</MkButton>
|
|
||||||
<MkButton @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
<div class="_gaps">
|
||||||
</div>
|
<MkInfo warn>{{ i18n.ts._2fa.backupCodesDescription }}</MkInfo>
|
||||||
|
|
||||||
|
<div v-for="(code, i) in backupCodes" :key="code" class="_gaps_s">
|
||||||
|
<MkKeyValue :copy="code">
|
||||||
|
<template #key>#{{ i + 1 }}</template>
|
||||||
|
<template #value><code class="_monospace">{{ code }}</code></template>
|
||||||
|
</MkKeyValue>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
</div>
|
||||||
|
<div class="_buttonsCenter" style="margin-top: 16px;">
|
||||||
|
<MkButton primary rounded gradate @click="allDone">{{ i18n.ts.done }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</MkModal>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { shallowRef, ref } from 'vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkModal from '@/components/MkModal.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
import * as os from '@/os';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import { confetti } from '@/scripts/confetti';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
twoFactorData: {
|
twoFactorData: {
|
||||||
|
@ -52,36 +117,53 @@ defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'ok'): void;
|
|
||||||
(ev: 'cancel'): void;
|
|
||||||
(ev: 'closed'): void;
|
(ev: 'closed'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const cancel = () => {
|
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||||
emit('cancel');
|
const page = ref(0);
|
||||||
emit('closed');
|
const token = ref<string | number | null>(null);
|
||||||
};
|
const backupCodes = ref<string[]>();
|
||||||
|
|
||||||
const ok = () => {
|
function cancel() {
|
||||||
emit('ok');
|
dialog.value.close();
|
||||||
emit('closed');
|
}
|
||||||
};
|
|
||||||
|
async function tokenDone() {
|
||||||
|
const res = await os.apiWithDialog('i/2fa/done', {
|
||||||
|
token: token.value.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
backupCodes.value = res.backupCodes;
|
||||||
|
|
||||||
|
page.value++;
|
||||||
|
|
||||||
|
confetti({
|
||||||
|
duration: 1000 * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDone() {
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.root {
|
.transition_x_enterActive,
|
||||||
position: relative;
|
.transition_x_leaveActive {
|
||||||
margin: auto;
|
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||||
padding: 32px;
|
}
|
||||||
min-width: 320px;
|
.transition_x_enterFrom {
|
||||||
max-width: calc(100svw - 64px);
|
opacity: 0;
|
||||||
box-sizing: border-box;
|
transform: translateX(50px);
|
||||||
background: var(--panel);
|
}
|
||||||
border-radius: var(--radius);
|
.transition_x_leaveTo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr {
|
.qr {
|
||||||
width: 20em;
|
width: 200px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -8,20 +8,28 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #label>{{ i18n.ts['2fa'] }}</template>
|
<template #label>{{ i18n.ts['2fa'] }}</template>
|
||||||
|
|
||||||
<div v-if="$i" class="_gaps_s">
|
<div v-if="$i" class="_gaps_s">
|
||||||
<MkFolder>
|
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'partial'" warn>
|
||||||
|
{{ i18n.ts._2fa.backupCodeUsedWarning }}
|
||||||
|
</MkInfo>
|
||||||
|
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodesStock === 'none'" warn>
|
||||||
|
{{ i18n.ts._2fa.backupCodesExhaustedWarning }}
|
||||||
|
</MkInfo>
|
||||||
|
|
||||||
|
<MkFolder :defaultOpen="true">
|
||||||
<template #icon><i class="ti ti-shield-lock"></i></template>
|
<template #icon><i class="ti ti-shield-lock"></i></template>
|
||||||
<template #label>{{ i18n.ts.totp }}</template>
|
<template #label>{{ i18n.ts.totp }}</template>
|
||||||
<template #caption>{{ i18n.ts.totpDescription }}</template>
|
<template #caption>{{ i18n.ts.totpDescription }}</template>
|
||||||
|
|
||||||
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
|
<div v-if="$i.twoFactorEnabled" class="_gaps_s">
|
||||||
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
|
<div v-text="i18n.ts._2fa.alreadyRegistered"/>
|
||||||
<template v-if="$i.securityKeysList.length > 0">
|
<template v-if="$i.securityKeysList.length > 0">
|
||||||
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
|
<MkButton @click="renewTOTP">{{ i18n.ts._2fa.renewTOTP }}</MkButton>
|
||||||
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
|
<MkInfo>{{ i18n.ts._2fa.whyTOTPOnlyRenew }}</MkInfo>
|
||||||
</template>
|
</template>
|
||||||
<MkButton v-else @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
|
<MkButton v-else danger @click="unregisterTOTP">{{ i18n.ts.unregister }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MkButton v-else-if="!twoFactorData && !$i.twoFactorEnabled" @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
|
<MkButton v-else-if="!$i.twoFactorEnabled" primary gradate @click="registerTOTP">{{ i18n.ts._2fa.registerTOTP }}</MkButton>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder>
|
<MkFolder>
|
||||||
|
@ -85,7 +93,6 @@ withDefaults(defineProps<{
|
||||||
first: false,
|
first: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const twoFactorData = ref<any>(null);
|
|
||||||
const supportsCredentials = ref(!!navigator.credentials);
|
const supportsCredentials = ref(!!navigator.credentials);
|
||||||
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
|
const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
|
||||||
|
|
||||||
|
@ -102,31 +109,9 @@ async function registerTOTP() {
|
||||||
password: password.result,
|
password: password.result,
|
||||||
});
|
});
|
||||||
|
|
||||||
const qrdialog = await new Promise<boolean>(res => {
|
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
|
||||||
os.popup(defineAsyncComponent(() => import('./2fa.qrdialog.vue')), {
|
twoFactorData,
|
||||||
twoFactorData,
|
}, {}, 'closed');
|
||||||
}, {
|
|
||||||
'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() {
|
function unregisterTOTP() {
|
||||||
|
|
|
@ -400,15 +400,6 @@ hr {
|
||||||
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
|
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
._code {
|
|
||||||
@extend ._monospace;
|
|
||||||
background: #2d2d2d;
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prism-editor__textarea:focus {
|
.prism-editor__textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2462,6 +2462,7 @@ type MeDetailed = UserDetailed & {
|
||||||
receiveAnnouncementEmail: boolean;
|
receiveAnnouncementEmail: boolean;
|
||||||
usePasswordLessLogin: boolean;
|
usePasswordLessLogin: boolean;
|
||||||
unreadAnnouncements: Announcement[];
|
unreadAnnouncements: Announcement[];
|
||||||
|
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
|
||||||
[other: string]: any;
|
[other: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,7 @@ export type MeDetailed = UserDetailed & {
|
||||||
receiveAnnouncementEmail: boolean;
|
receiveAnnouncementEmail: boolean;
|
||||||
usePasswordLessLogin: boolean;
|
usePasswordLessLogin: boolean;
|
||||||
unreadAnnouncements: Announcement[];
|
unreadAnnouncements: Announcement[];
|
||||||
|
twoFactorBackupCodesStock: 'full' | 'partial' | 'none';
|
||||||
[other: string]: any;
|
[other: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue