feat: 🔒 Improve 2FA/keypass experience

Co-authored-by: Tamania <tamaina@hotmail.co.jp>
Co-authored-by: Syuilo <syuilotan@yahoo.co.jp>
This commit is contained in:
ThatOneCalculator 2023-06-15 16:12:32 -07:00
parent fbce5d819f
commit 46af585cf7
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
41 changed files with 937 additions and 582 deletions

View File

@ -1049,8 +1049,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين."
registerDevice: "سجّل جهازًا جديدًا"
registerKey: "تسجيل مفتاح أمان جديد"
registerTOTP: "سجّل جهازًا جديدًا"
registerSecurityKey: "تسجيل مفتاح أمان جديد"
step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})."
step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة."
step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت."

View File

@ -1130,8 +1130,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷"
registerDevice: "নতুন ডিভাইস নিবন্ধন করুন"
registerKey: "সিকিউরিটি কী নিবন্ধন করুন"
registerTOTP: "নতুন ডিভাইস নিবন্ধন করুন"
registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন"
step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷"
step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।"
step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:"

View File

@ -319,13 +319,13 @@ _sfx:
_2fa:
step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:"
alreadyRegistered: Ja heu registrat un dispositiu d'autenticació de dos factors.
registerDevice: Registrar un dispositiu nou
registerTOTP: Registrar un dispositiu nou
securityKeyInfo: A més de l'autenticació d'empremta digital o PIN, també podeu configurar
l'autenticació mitjançant claus de seguretat de maquinari compatibles amb FIDO2
per protegir encara més el vostre compte.
step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest
token d'inici de sessió.
registerKey: Registra una clau de seguretat
registerSecurityKey: Registra una clau de seguretat
step1: En primer lloc, instal·la una aplicació d'autenticació (com ara {a} o {b})
al dispositiu.
step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla.

View File

@ -698,8 +698,8 @@ _time:
minute: "Minut"
hour: "Hodin"
_2fa:
registerDevice: "Přidat zařízení"
registerKey: "Přidat bezpečnostní klíč"
registerTOTP: "Přidat zařízení"
registerSecurityKey: "Přidat bezpečnostní klíč"
_weekday:
sunday: "Neděle"
monday: "Pondělí"

View File

@ -1371,8 +1371,8 @@ _tutorial:
_2fa:
alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung
registriert."
registerDevice: "Neues Gerät registrieren"
registerKey: "Neuen Sicherheitsschlüssel registrieren"
registerTOTP: "Neues Gerät registrieren"
registerSecurityKey: "Neuen Sicherheitsschlüssel registrieren"
step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem
Gerät."
step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät."

View File

@ -1487,16 +1487,28 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "You have already registered a 2-factor authentication device."
registerDevice: "Register a new device"
registerKey: "Register a security key"
registerTOTP: "Register authenticator app"
step1: "First, install an authentication app (such as {a} or {b}) on your device."
step2: "Then, scan the QR code displayed on this screen."
step2Click: "Clicking on this QR code will allow you to register 2FA to your security key or phone authenticator app."
step2Url: "You can also enter this URL if you're using a desktop program:"
step3Title: "Enter an authentication code"
step3: "Enter the token provided by your app to finish setup."
step4: "From now on, any future login attempts will ask for such a login token."
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup
authentication via hardware security keys that support FIDO2 to further secure
your account."
securityKeyNotSupported: "Your browser does not support security keys."
registerTOTPBeforeKey: "Please set up an authenticator app to register a security or pass key."
securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup authentication via hardware security keys that support FIDO2 to further secure your account."
chromePasskeyNotSupported: "Chrome passkeys are currently not supported."
registerSecurityKey: "Register a security or pass key"
securityKeyName: "Enter a key name"
tapSecurityKey: "Please follow your browser to register the security or pass key"
removeKey: "Remove security key"
removeKeyConfirm: "Really delete the {name} key?"
whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security key is registered."
renewTOTP: "Reconfigure authenticator app"
renewTOTPConfirm: "This will cause verification codes from your previous app to stop working"
renewTOTPOk: "Reconfigure"
renewTOTPCancel: "Cancel"
_permissions:
"read:account": "View your account information"
"write:account": "Edit your account information"
@ -2058,3 +2070,7 @@ _experiments:
postImportsCaption: "Allows users to import their posts from past Calckey,\
\ Misskey, Mastodon, Akkoma, and Pleroma accounts. It may cause slowdowns during\
\ load if your queue is bottlenecked."
_dialog:
charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}"
charactersBelow: "Not enough characters! Current: {current}/Limit: {min}"

View File

@ -1331,8 +1331,8 @@ _tutorial:
step6_4: "¡Ahora ve, explora y diviértete!"
_2fa:
alreadyRegistered: "Ya has completado la configuración."
registerDevice: "Registrar dispositivo"
registerKey: "Registrar clave"
registerTOTP: "Registrar dispositivo"
registerSecurityKey: "Registrar clave"
step1: "Primero, instale en su dispositivo la aplicación de autenticación {a} o\
\ {b} u otra."
step2: "Luego, escanee con la aplicación el código QR mostrado en pantalla."

View File

@ -1262,8 +1262,8 @@ _tutorial:
step6_4: "Maintenant, allez-y, explorez et amusez-vous !"
_2fa:
alreadyRegistered: "Configuration déjà achevée."
registerDevice: "Ajouter un nouvel appareil"
registerKey: "Enregistrer une clef"
registerTOTP: "Ajouter un nouvel appareil"
registerSecurityKey: "Enregistrer une clef"
step1: "Tout d'abord, installez une application d'authentification, telle que {a}\
\ ou {b}, sur votre appareil."
step2: "Ensuite, scannez le code QR affiché sur lécran."

View File

@ -1254,8 +1254,8 @@ _tutorial:
step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680"
_2fa:
alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor."
registerDevice: "Daftarkan perangkat baru"
registerKey: "Daftarkan kunci keamanan baru"
registerTOTP: "Daftarkan perangkat baru"
registerSecurityKey: "Daftarkan kunci keamanan baru"
step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\
\ kamu."
step2: "Lalu, pindai kode QR yang ada di layar."

View File

@ -1139,7 +1139,7 @@ _tutorial:
Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo"
step6_4: "Ora andate, esplorate e divertitevi!"
_2fa:
registerDevice: "Aggiungi dispositivo"
registerTOTP: "Aggiungi dispositivo"
_permissions:
"read:account": "Visualizzare le informazioni dell'account"
"write:account": "Modificare le informazioni dell'account"

View File

@ -1314,14 +1314,28 @@ _tutorial:
step6_4: "これで完了です。お楽しみください!"
_2fa:
alreadyRegistered: "既に設定は完了しています。"
registerDevice: "デバイスを登録"
registerKey: "キーを登録"
registerTOTP: "認証アプリの設定を開始"
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": "アカウントの情報を見る"
"write:account": "アカウントの情報を変更する"
@ -1898,3 +1912,6 @@ antennasDesc: "アンテナでは指定した条件に合致する投稿が表
expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。
expandOnNoteClick: クリックで投稿の詳細を開く
clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。
_dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}"

View File

@ -1179,8 +1179,8 @@ _time:
day: "일"
_2fa:
alreadyRegistered: "이미 설정이 완료되었습니다."
registerDevice: "디바이스 등록"
registerKey: "키를 등록"
registerTOTP: "디바이스 등록"
registerSecurityKey: "키를 등록"
step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다."
step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다."
step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:"

View File

@ -1260,8 +1260,8 @@ _tutorial:
step6_4: "A teraz idź, odkrywaj i baw się dobrze!"
_2fa:
alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego."
registerDevice: "Zarejestruj nowe urządzenie"
registerKey: "Zarejestruj klucz bezpieczeństwa"
registerTOTP: "Zarejestruj nowe urządzenie"
registerSecurityKey: "Zarejestruj klucz bezpieczeństwa"
step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b})
na swoim urządzeniu."
step2: "Następnie, zeskanuje kod QR z ekranu."

View File

@ -1249,8 +1249,8 @@ _tutorial:
step6_4: "Теперь идите, изучайте и развлекайтесь!"
_2fa:
alreadyRegistered: "Двухфакторная аутентификация уже настроена."
registerDevice: "Зарегистрируйте ваше устройство"
registerKey: "Зарегистрировать ключ"
registerTOTP: "Зарегистрируйте ваше устройство"
registerSecurityKey: "Зарегистрировать ключ"
step1: "Прежде всего, установите на устройство приложение для аутентификации, например,\
\ {a} или {b}."
step2: "Далее отсканируйте отображаемый QR-код при помощи приложения."

View File

@ -1196,8 +1196,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie."
registerDevice: "Registrovať nové zariadenie"
registerKey: "Registrovať bezpečnostný kľúč"
registerTOTP: "Registrovať nové zariadenie"
registerSecurityKey: "Registrovať bezpečnostný kľúč"
step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie."
step2: "Potom, naskenujte QR kód zobrazený na obrazovke."
step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:"

View File

@ -959,7 +959,7 @@ _tutorial:
step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Calckey. Але цей працює! Це трохи складно, але ви швидко розберетеся"
step6_4: "Тепер ідіть, вивчайте і розважайтеся!"
_2fa:
registerKey: "Зареєструвати новий ключ безпеки"
registerSecurityKey: "Зареєструвати новий ключ безпеки"
_permissions:
"read:account": "Переглядати дані профілю"
"write:account": "Змінити дані акаунту"

View File

@ -1201,8 +1201,8 @@ _tutorial:
step6_4: "Now go, explore, and have fun!"
_2fa:
alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước."
registerDevice: "Đăng ký một thiết bị"
registerKey: "Đăng ký một mã bảo vệ"
registerTOTP: "Đăng ký một thiết bị"
registerSecurityKey: "Đăng ký một mã bảo vệ"
step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn."
step2: "Sau đó, quét mã QR hiển thị trên màn hình này."
step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:"

View File

@ -1210,8 +1210,8 @@ _tutorial:
step6_4: "现在去学习并享受乐趣!"
_2fa:
alreadyRegistered: "此设备已被注册"
registerDevice: "注册设备"
registerKey: "注册密钥"
registerTOTP: "注册设备"
registerSecurityKey: "注册密钥"
step1: "首先,在您的设备上安装验证应用,例如{a}或{b}。"
step2: "然后,扫描屏幕上显示的二维码。"
step2Url: "在桌面应用程序中输入以下URL"

View File

@ -1219,8 +1219,8 @@ _tutorial:
step6_4: "現在開始探索吧!"
_2fa:
alreadyRegistered: "你已註冊過一個雙重認證的裝置。"
registerDevice: "註冊裝置"
registerKey: "註冊鍵"
registerTOTP: "註冊裝置"
registerSecurityKey: "註冊鍵"
step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。"
step2: "然後掃描螢幕上的QR code。"
step2Url: "在桌面版應用中請輸入以下的URL"

View File

@ -1,6 +1,6 @@
{
"name": "calckey",
"version": "14.0.0-dev46",
"version": "14.0.0-dev51",
"codename": "aqua",
"repository": {
"type": "git",

View File

@ -101,6 +101,7 @@
"nsfwjs": "2.4.2",
"oauth": "^0.10.0",
"os-utils": "0.0.14",
"otpauth": "^9.1.2",
"parse5": "7.1.2",
"pg": "8.11.0",
"private-ip": "2.3.4",
@ -123,7 +124,6 @@
"semver": "7.5.1",
"sharp": "0.32.1",
"sonic-channel": "^1.3.1",
"speakeasy": "2.0.0",
"stringz": "2.1.0",
"summaly": "2.7.0",
"syslog-pro": "1.0.0",
@ -181,7 +181,6 @@
"@types/semver": "7.5.0",
"@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/uuid": "8.3.4",

View File

@ -174,6 +174,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";
@ -528,6 +529,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],

View File

@ -1,6 +1,7 @@
import * as speakeasy from "speakeasy";
import { publishMainStream } from "@/services/stream.js";
import * as OTPAuth from "otpauth";
import define from "../../../define.js";
import { UserProfiles } from "@/models/index.js";
import { Users, UserProfiles } from "@/models/index.js";
export const meta = {
requireCredential: true,
@ -25,13 +26,14 @@ export default define(meta, paramDef, async (ps, user) => {
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");
}
@ -39,4 +41,11 @@ export default define(meta, paramDef, async (ps, user) => {
twoFactorSecret: profile.twoFactorTempSecret,
twoFactorEnabled: true,
});
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
});

View File

@ -28,7 +28,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",

View File

@ -1,10 +1,20 @@
import define from "../../../define.js";
import { UserProfiles } from "@/models/index.js";
import { Users, UserProfiles, UserSecurityKeys } from "@/models/index.js";
import { publishMainStream } from "@/services/stream.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 = {
@ -16,7 +26,36 @@ export const paramDef = {
} as const;
export default define(meta, paramDef, async (ps, user) => {
if (ps.value === true) {
// セキュリティキーがなければパスワードレスを有効にはできない
const keyCount = await UserSecurityKeys.count({
where: {
userId: user.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await UserProfiles.update(user.id, {
usePasswordLessLogin: false,
});
throw new ApiError(meta.errors.noKey);
}
}
await UserProfiles.update(user.id, {
usePasswordLessLogin: ps.value,
});
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
});

View File

@ -1,4 +1,4 @@
import * as speakeasy from "speakeasy";
import * as OTPAuth from "otpauth";
import * as QRCode from "qrcode";
import config from "@/config/index.js";
import { UserProfiles } from "@/models/index.js";
@ -30,25 +30,24 @@ export default define(meta, paramDef, async (ps, user) => {
}
// Generate user's secret key
const secret = speakeasy.generateSecret({
length: 32,
});
const secret = new OTPAuth.Secret();
await UserProfiles.update(user.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: user.username,
issuer: 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: user.username,

View File

@ -34,6 +34,24 @@ export default define(meta, paramDef, async (ps, user) => {
id: ps.credentialId,
});
// 使われているキーがなくなったらパスワードレスログインをやめる
const keyCount = await UserSecurityKeys.count({
where: {
userId: user.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await UserProfiles.update(me.id, {
usePasswordLessLogin: false,
});
}
// Publish meUpdated event
publishMainStream(
user.id,

View File

@ -1,5 +1,6 @@
import { publishMainStream } from "@/services/stream.js";
import define from "../../../define.js";
import { UserProfiles } from "@/models/index.js";
import { Users, UserProfiles } from "@/models/index.js";
import { comparePassword } from "@/misc/password.js";
export const meta = {
@ -29,5 +30,13 @@ export default define(meta, paramDef, async (ps, user) => {
await UserProfiles.update(user.id, {
twoFactorSecret: null,
twoFactorEnabled: false,
usePasswordLessLogin: false,
});
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
});

View File

@ -0,0 +1,58 @@
import { publishMainStream } from "@/services/stream.js";
import define from "../../../define.js";
import { Users, UserSecurityKeys } from "@/models/index.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;
export default define(meta, paramDef, async (ps, user) => {
const key = await UserSecurityKeys.findOneBy({
id: ps.credentialId,
});
if (key == null) {
throw new ApiError(meta.errors.noSuchKey);
}
if (key.userId !== user.id) {
throw new ApiError(meta.errors.accessDenied);
}
await UserSecurityKeys.update(key.id, {
name: ps.name,
});
const iObj = await Users.pack(user.id, user, {
detail: true,
includeSecrets: true,
});
publishMainStream(user.id, "meUpdated", iObj);
});

View File

@ -1,5 +1,5 @@
import type Koa from "koa";
import * as speakeasy from "speakeasy";
import * as OTPAuth from "otpauth";
import signin from "../common/signin.js";
import config from "@/config/index.js";
import {
@ -136,14 +136,18 @@ export default async (ctx: Koa.Context) => {
return;
}
const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorSecret,
encoding: "base32",
token: token,
window: 2,
if (profile.twoFactorSecret == null) {
throw new Error("Attempted 2FA signin without 2FA enabled.");
}
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret),
digits: 6,
token,
window: 1,
});
if (verified) {
if (delta != null) {
signin(ctx, user);
return;
} else {

View File

@ -725,6 +725,7 @@ export type Endpoints = {
"i/2fa/password-less": { req: TODO; res: TODO };
"i/2fa/register-key": { req: TODO; res: TODO };
"i/2fa/register": { req: TODO; res: TODO };
"i/2fa/update-key": { req: TODO; res: TODO };
"i/2fa/remove-key": { req: TODO; res: TODO };
"i/2fa/unregister": { req: TODO; res: TODO };

View File

@ -53,12 +53,15 @@
>
<Mfm :text="i18n.ts.password" />
</header>
<div v-if="text" :class="$style.text"><Mfm :text="text" /></div>
<div v-if="text" :class="$style.text">
<Mfm :text="text" />
</div>
<MkInput
ref="inputEl"
v-if="input && input.type !== 'paragraph'"
v-model="inputValue"
autofocus
:autocomplete="input.autocomplete"
:type="input.type == 'search' ? 'search' : input.type || 'text'"
:placeholder="input.placeholder || undefined"
@keydown="onInputKeydown"
@ -69,6 +72,22 @@
<template v-if="input.type === 'password'" #prefix
><i class="ph-password ph-bold ph-lg"></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>
<template v-if="input.type === 'search'" #suffix>
<button
class="_buttonIcon"
@ -118,6 +137,7 @@
inline
primary
:autofocus="!input && !select"
:disabled="okButtonDisabled"
@click="ok"
>{{
showCancelButton || input || select
@ -139,8 +159,8 @@
primary
:autofocus="!input && !select"
@click="ok"
>{{ i18n.ts.yes }}</MkButton
>
>{{ i18n.ts.yes }}
</MkButton>
<MkButton
v-if="showCancelButton || input || select"
inline
@ -182,7 +202,10 @@ import * as Acct from "calckey-js/built/acct";
type Input = {
type: HTMLInputElement["type"];
placeholder?: string | null;
default: any | null;
autocomplete?: string;
default: string | number | null;
minLength?: number;
maxLength?: number;
};
type Select = {
@ -245,8 +268,35 @@ const emit = defineEmits<{
const modal = shallowRef<InstanceType<typeof MkModal>>();
const inputValue = ref(props.input?.default || "");
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;
});
const inputEl = ref<typeof MkInput>();

View File

@ -39,6 +39,7 @@
:placeholder="i18n.ts.password"
type="password"
:with-password-toggle="true"
autocomplete="current-password"
required
data-cy-signin-password
>
@ -90,6 +91,7 @@
v-model="password"
type="password"
:with-password-toggle="true"
autocomplete="current-password"
required
>
<template #label>{{ i18n.ts.password }}</template>
@ -101,7 +103,7 @@
v-model="token"
type="text"
pattern="^[0-9]{6}$"
autocomplete="off"
autocomplete="one-time-code"
:spellcheck="false"
required
>
@ -383,10 +385,11 @@ function showSuspendedDialog() {
margin: 0 auto 0 auto;
width: 64px;
height: 64px;
background: #ddd;
background: var(--accentedBg);
background-position: center;
background-size: cover;
border-radius: 100%;
transition: background-image 0.2s ease-in;
}
}
}

View File

@ -61,7 +61,7 @@ import { useInterval } from "@/scripts/use-interval";
import { i18n } from "@/i18n";
const props = defineProps<{
modelValue: string | number;
modelValue: string | number | null;
type?:
| "text"
| "number"
@ -77,7 +77,7 @@ const props = defineProps<{
pattern?: string;
placeholder?: string;
autofocus?: boolean;
autocomplete?: boolean;
autocomplete?: string;
spellcheck?: boolean;
step?: any;
datalist?: string[];

View File

@ -1,38 +1,39 @@
<template>
<div class="vblkjoeq">
<label>
<div class="label"><slot name="label"></slot></div>
<div
ref="container"
class="input"
:class="{ inline, disabled, focused }"
@click.prevent="onClick"
tabindex="-1"
<div class="label" @click="focus"><slot name="label"></slot></div>
<div
ref="container"
class="input"
:class="{ inline, disabled, focused }"
@mousedown.prevent="show"
>
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
class="select"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
>
<div ref="prefixEl" class="prefix">
<slot name="prefix"></slot>
</div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
class="select"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
>
<slot></slot>
</select>
<div ref="suffixEl" class="suffix">
<i class="ph-caret-down ph-bold ph-lg"></i>
</div>
<slot></slot>
</select>
<div ref="suffixEl" class="suffix">
<i
class="ph-caret-down ph-bold ph-lg"
:class="[
$style.chevron,
{ [$style.chevronOpening]: opening },
]"
></i>
</div>
<div class="caption"><slot name="caption"></slot></div>
</label>
</div>
<div class="caption"><slot name="caption"></slot></div>
<MkButton v-if="manualSave && changed" primary @click="updated"
><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
@ -44,7 +45,6 @@
<script lang="ts" setup>
import {
onMounted,
onUnmounted,
nextTick,
ref,
watch,
@ -59,7 +59,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;
@ -73,7 +73,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();
@ -81,6 +81,7 @@ const slots = useSlots();
const { modelValue, autofocus } = toRefs(props);
const v = ref(modelValue.value);
const focused = ref(false);
const opening = ref(false);
const changed = ref(false);
const invalid = ref(false);
const filled = computed(() => v.value !== "" && v.value != null);
@ -88,7 +89,7 @@ const inputEl = ref(null);
const prefixEl = ref(null);
const suffixEl = ref(null);
const container = ref(null);
const height = props.small ? 36 : props.large ? 40 : 38;
const height = props.small ? 33 : props.large ? 39 : 36;
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
@ -145,8 +146,9 @@ onMounted(() => {
});
});
const onClick = (ev: MouseEvent) => {
function show(ev: MouseEvent) {
focused.value = true;
opening.value = true;
const menu = [];
let options = slots.default!();
@ -154,7 +156,7 @@ const onClick = (ev: MouseEvent) => {
const pushOption = (option: VNode) => {
menu.push({
text: option.children,
active: v.value === option.props.value,
active: computed(() => v.value === option.props.value),
action: () => {
v.value = option.props.value;
},
@ -188,127 +190,136 @@ const onClick = (ev: MouseEvent) => {
os.popupMenu(menu, container.value, {
width: container.value.offsetWidth,
onClosing: () => {
opening.value = false;
},
}).then(() => {
focused.value = false;
});
};
}
</script>
<style lang="scss" scoped>
.vblkjoeq {
> label {
> .label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
> .label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
&:empty {
display: none;
}
&:empty {
display: none;
}
}
> .caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
> .caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--fgTransparentWeak);
&:empty {
display: none;
}
&:empty {
display: none;
}
}
> .input {
position: relative;
cursor: pointer;
margin-left: 0.2rem;
margin-right: 0.2rem;
&:hover {
> .select {
border-color: var(--inputBorderHover) !important;
}
}
> .input {
position: relative;
cursor: pointer;
&:hover {
> .select {
appearance: none;
-webkit-appearance: none;
display: block;
height: v-bind("height + 'px'");
width: 100%;
margin: 0;
padding: 0 12px;
font: inherit;
font-weight: normal;
font-size: 1em;
color: var(--fg);
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px;
outline: none;
box-shadow: none;
box-sizing: border-box;
cursor: pointer;
transition: border-color 0.1s ease-out;
pointer-events: none;
user-select: none;
border-color: var(--inputBorderHover) !important;
}
}
> .select {
appearance: none;
-webkit-appearance: none;
display: block;
height: v-bind("height + 'px'");
width: 100%;
margin: 0;
padding: 0 12px;
font: inherit;
font-weight: normal;
font-size: 1em;
color: var(--fg);
background: var(--panel);
border: solid 1px var(--panel);
border-radius: 6px;
outline: none;
box-shadow: none;
box-sizing: border-box;
cursor: pointer;
transition: border-color 0.1s ease-out;
pointer-events: none;
user-select: none;
}
> .prefix,
> .suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
pointer-events: none;
&:empty {
display: none;
}
> .prefix,
> .suffix {
display: flex;
align-items: center;
position: absolute;
z-index: 1;
top: 0;
padding: 0 12px;
font-size: 1em;
height: v-bind("height + 'px'");
pointer-events: none;
&:empty {
display: none;
}
> * {
display: inline-block;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> .prefix {
left: 0;
padding-right: 6px;
}
> .suffix {
right: 0;
padding-left: 6px;
}
&.inline {
> * {
display: inline-block;
margin: 0;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&.focused {
> select {
border-color: var(--accent) !important;
}
> .prefix {
left: 0;
padding-right: 6px;
}
> .suffix {
right: 0;
padding-left: 6px;
}
&.inline {
display: inline-block;
margin: 0;
}
&.focused {
> select {
border-color: var(--accent) !important;
}
}
&.disabled {
opacity: 0.7;
&.disabled {
opacity: 0.7;
&,
* {
cursor: not-allowed !important;
}
&,
* {
cursor: not-allowed !important;
}
}
}
}
</style>
<style lang="scss" module>
.chevron {
transition: transform 0.1s ease-out;
}
.chevronOpening {
transform: rotateX(180deg);
}
</style>

View File

@ -22,7 +22,7 @@ const apiClient = new Misskey.api.APIClient({
export const api = ((
endpoint: string,
data: Record<string, any> = {},
token?: string | null | undefined,
token?: string | null | undefined
) => {
pendingApiRequestsCount.value++;
@ -36,13 +36,16 @@ export const api = ((
: undefined;
const promise = new Promise((resolve, reject) => {
fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
method: "POST",
body: JSON.stringify(data),
credentials: "omit",
cache: "no-cache",
headers: authorization ? { authorization } : {},
})
fetch(
endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`,
{
method: "POST",
body: JSON.stringify(data),
credentials: "omit",
cache: "no-cache",
headers: authorization ? { authorization } : {},
}
)
.then(async (res) => {
const body = res.status === 204 ? null : await res.json();
@ -65,7 +68,7 @@ export const api = ((
export const apiGet = ((
endpoint: string,
data: Record<string, any> = {},
token?: string | null | undefined,
token?: string | null | undefined
) => {
pendingApiRequestsCount.value++;
@ -110,7 +113,7 @@ export const apiGet = ((
export const apiWithDialog = ((
endpoint: string,
data: Record<string, any> = {},
token?: string | null | undefined,
token?: string | null | undefined
) => {
const promise = api(endpoint, data, token);
promiseDialog(promise, null, (err) => {
@ -127,7 +130,7 @@ export function promiseDialog<T extends Promise<any>>(
promise: T,
onSuccess?: ((res: any) => void) | null,
onFailure?: ((err: Error) => void) | null,
text?: string,
text?: string
): T {
const showing = ref(true);
const success = ref(false);
@ -165,7 +168,7 @@ export function promiseDialog<T extends Promise<any>>(
text: text,
},
{},
"closed",
"closed"
);
return promise;
@ -186,7 +189,7 @@ const zIndexes = {
high: 3000000,
};
export function claimZIndex(
priority: "low" | "middle" | "high" = "low",
priority: "low" | "middle" | "high" = "low"
): number {
zIndexes[priority] += 100;
return zIndexes[priority];
@ -201,7 +204,7 @@ export async function popup(
component: Component,
props: Record<string, any>,
events = {},
disposeEvent?: string,
disposeEvent?: string
) {
markRaw(component);
@ -242,7 +245,7 @@ export function pageWindow(path: string) {
initialPath: path,
},
{},
"closed",
"closed"
);
}
@ -257,7 +260,7 @@ export function modalPageWindow(path: string) {
initialPath: path,
},
{},
"closed",
"closed"
);
}
@ -268,7 +271,7 @@ export function toast(message: string) {
message,
},
{},
"closed",
"closed"
);
}
@ -289,7 +292,7 @@ export function alert(props: {
resolve();
},
},
"closed",
"closed"
);
});
}
@ -313,7 +316,7 @@ export function confirm(props: {
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
@ -340,7 +343,7 @@ export function yesno(props: {
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
@ -350,7 +353,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 }
| {
@ -360,19 +366,17 @@ export function inputText(props: {
> {
return new Promise((resolve, reject) => {
popup(
defineAsyncComponent({
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
MkDialog,
{
type: props.type,
title: props.title,
text: props.text,
input: {
type: props.type,
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default,
minLength: props.minLength,
maxLength: props.maxLength,
},
},
{
@ -380,7 +384,7 @@ export function inputText(props: {
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
@ -418,7 +422,7 @@ export function inputParagraph(props: {
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
@ -428,6 +432,7 @@ export function inputNumber(props: {
text?: string | null;
placeholder?: string | null;
default?: number | null;
autocomplete?: string;
}): Promise<
| { canceled: true; result: undefined }
| {
@ -448,6 +453,7 @@ export function inputNumber(props: {
input: {
type: "number",
placeholder: props.placeholder,
autocomplete: props.autocomplete,
default: props.default,
},
},
@ -456,7 +462,7 @@ export function inputNumber(props: {
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
@ -475,11 +481,7 @@ export function inputDate(props: {
> {
return new Promise((resolve, reject) => {
popup(
defineAsyncComponent({
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
MkDialog,
{
title: props.title,
text: props.text,
@ -492,13 +494,16 @@ export function inputDate(props: {
{
done: (result) => {
resolve(
(result && isFinite(new Date(result.result)))
? { result: new Date(result.result), canceled: false }
: { canceled: true },
result
? {
result: new Date(result.result),
canceled: false,
}
: { canceled: true }
);
},
},
"closed",
"closed"
);
});
}
@ -524,7 +529,7 @@ export function select<C = any>(
}[];
}[];
}
),
)
): Promise<
| { canceled: true; result: undefined }
| {
@ -534,11 +539,7 @@ export function select<C = any>(
> {
return new Promise((resolve, reject) => {
popup(
defineAsyncComponent({
loader: () => import("@/components/MkDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
MkDialog,
{
title: props.title,
text: props.text,
@ -553,23 +554,19 @@ export function select<C = any>(
resolve(result ? result : { canceled: true });
},
},
"closed",
"closed"
);
});
}
export function success() {
export function success(): Promise<void> {
return new Promise((resolve, reject) => {
const showing = ref(true);
window.setTimeout(() => {
showing.value = false;
}, 1000);
popup(
defineAsyncComponent({
loader: () => import("@/components/MkWaitingDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
MkWaitingDialog,
{
success: true,
showing: showing,
@ -577,20 +574,16 @@ export function success() {
{
done: () => resolve(),
},
"closed",
"closed"
);
});
}
export function waiting() {
export function waiting(): Promise<void> {
return new Promise((resolve, reject) => {
const showing = ref(true);
popup(
defineAsyncComponent({
loader: () => import("@/components/MkWaitingDialog.vue"),
loadingComponent: MkWaitingDialog,
delay: 1000,
}),
MkWaitingDialog,
{
success: false,
showing: showing,
@ -598,7 +591,7 @@ export function waiting() {
{
done: () => resolve(),
},
"closed",
"closed"
);
});
}
@ -617,7 +610,7 @@ export function form(title, form) {
resolve(result);
},
},
"closed",
"closed"
);
});
}
@ -636,7 +629,7 @@ export async function selectUser() {
resolve(user);
},
},
"closed",
"closed"
);
});
}
@ -655,7 +648,7 @@ export async function selectInstance(): Promise<Misskey.entities.Instance> {
resolve(instance);
},
},
"closed",
"closed"
);
});
}
@ -679,7 +672,7 @@ export async function selectDriveFile(multiple: boolean) {
}
},
},
"closed",
"closed"
);
});
}
@ -703,7 +696,7 @@ export async function selectDriveFolder(multiple: boolean) {
}
},
},
"closed",
"closed"
);
});
}
@ -725,7 +718,7 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
resolve(emoji);
},
},
"closed",
"closed"
);
});
}
@ -734,7 +727,7 @@ export async function cropImage(
image: Misskey.entities.DriveFile,
options: {
aspectRatio: number;
},
}
): Promise<Misskey.entities.DriveFile> {
return new Promise((resolve, reject) => {
popup(
@ -752,7 +745,7 @@ export async function cropImage(
resolve(x);
},
},
"closed",
"closed"
);
});
}
@ -767,7 +760,7 @@ let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
export async function openEmojiPicker(
src?: HTMLElement,
opts,
initialTextarea: typeof activeTextarea,
initialTextarea: typeof activeTextarea
) {
if (openingEmojiPicker) return;
@ -783,13 +776,14 @@ export async function openEmojiPicker(
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of Array.from(record.addedNodes).filter(
(node) => node instanceof HTMLElement,
(node) => node instanceof HTMLElement
) as HTMLElement[]) {
const textareas = node.querySelectorAll("textarea, input");
for (const textarea of Array.from(textareas).filter(
(textarea) => textarea.dataset.preventEmojiInsert == null,
(textarea) => textarea.dataset.preventEmojiInsert == null
)) {
if (document.activeElement === textarea) activeTextarea = textarea;
if (document.activeElement === textarea)
activeTextarea = textarea;
textarea.addEventListener("focus", () => {
activeTextarea = textarea;
});
@ -827,7 +821,7 @@ export async function openEmojiPicker(
openingEmojiPicker = null;
observer.disconnect();
},
},
}
);
}
@ -839,7 +833,7 @@ export function popupMenu(
width?: number;
viaKeyboard?: boolean;
noReturnFocus?: boolean;
},
}
) {
return new Promise((resolve, reject) => {
let dispose;
@ -862,7 +856,7 @@ export function popupMenu(
resolve();
dispose();
},
},
}
).then((res) => {
dispose = res.dispose;
});
@ -871,7 +865,7 @@ export function popupMenu(
export function contextMenu(
items: MenuItem[] | Ref<MenuItem[]>,
ev: MouseEvent,
ev: MouseEvent
) {
ev.preventDefault();
return new Promise((resolve, reject) => {
@ -891,7 +885,7 @@ export function contextMenu(
resolve();
dispose();
},
},
}
).then((res) => {
dispose = res.dispose;
});

View File

@ -0,0 +1,96 @@
<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://authpass.app/"
rel="noopener"
target="_blank"
class="_link"
>AuthPass</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>

View File

@ -1,300 +1,310 @@
<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>
</template>
<FormSection :first="first">
<template #label>{{ i18n.ts["2fa"] }}</template>
<template v-if="supportsCredentials && $i.twoFactorEnabled">
<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)">{{
<div v-if="$i" class="_gaps_s">
<MkFolder>
<template #icon
><i class="ph-shield-check ph-bold ph-lg"></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>
</div>
<MkButton
v-else-if="!twoFactorData && !$i.twoFactorEnabled"
@click="registerTOTP"
>{{ i18n.ts._2fa.registerTOTP }}</MkButton
>
</MkFolder>
<MkFolder>
<template #icon><i class="ph-key ph-bold ph-lg"></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="!supportsCredentials" warn>
{{ i18n.ts._2fa.securityKeyNotSupported }}
</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="ph-pencil-line ph-bold ph-lg"
></i>
{{ i18n.ts.rename }}</MkButton
>
<MkButton danger @click="unregisterKey(key)"
><i class="ph-trash ph-bold ph-lg"></i>
{{ i18n.ts.unregister }}</MkButton
>
</div>
</MkFolder>
</template>
</div>
</MkFolder>
<MkSwitch
v-if="$i.securityKeysList.length > 0"
v-model="usePasswordLessLogin"
@update:modelValue="updatePasswordLessLogin"
>{{ i18n.ts.passwordLessLogin }}</MkSwitch
:disabled="
!$i.twoFactorEnabled || $i.securityKeysList.length === 0
"
:modelValue="usePasswordLessLogin"
@update:modelValue="(v) => updatePasswordLessLogin(v)"
>
<MkInfo
v-if="registration && registration.error"
style="margin-bottom: 1rem"
warn
>{{ i18n.ts.error }}: {{ registration.error }}</MkInfo
>
<MkButton
v-if="!registration || registration.error"
@click="addSecurityKey"
>{{ i18n.ts._2fa.registerKey }}</MkButton
>
<ol v-if="registration && !registration.error">
<li v-if="registration.stage >= 0">
{{ i18n.ts.tapSecurityKey }}
<i
v-if="registration.saving && registration.stage == 0"
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
></i>
</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
>
<i
v-if="
registration.saving && registration.stage == 1
"
class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"
></i>
</MkForm>
</li>
</ol>
</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://authpass.app/"
rel="noopener"
target="_blank"
class="_link"
>AuthPass</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>
{{ i18n.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>
<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/form/input.vue";
import MkSwitch from "@/components/form/switch.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";
// : meUpdatedrefreshAccount
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.currentPassword,
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;
});
});
}
function submit() {
os.api("i/2fa/done", {
token: token.value,
})
.then(() => {
os.success();
$i!.twoFactorEnabled = true;
})
.catch((err) => {
}).catch((error) => {
os.alert({
type: "error",
text: err,
text: error,
});
});
});
}
function registerKey() {
registration.value.saving = true;
os.api("i/2fa/key-done", {
password: registration.value.password,
name: keyName.value,
challengeId: registration.value.challengeId,
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();
});
}
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(
registration.value.credential.response.clientDataJSON
),
attestationObject: hexify(
registration.value.credential.response.attestationObject
),
}).then((key) => {
registration.value = null;
key!.lastUsed = new Date();
os.success();
clientDataJSON: stringify(credential.response.clientDataJSON),
attestationObject: hexify(credential.response.attestationObject),
});
}
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: "Calckey",
},
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() {
await os.api("i/2fa/password-less", {
value: !!usePasswordLessLogin.value,
async function updatePasswordLessLogin(value: boolean) {
await os.apiWithDialog("i/2fa/password-less", {
value,
});
}
</script>

View File

@ -2,15 +2,12 @@
<div class="_formRoot">
<FormSection>
<template #label>{{ i18n.ts.password }}</template>
<FormButton primary @click="change()">{{
<MkButton primary @click="change()">{{
i18n.ts.changePassword
}}</FormButton>
}}</MkButton>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts.twoStepAuthentication }}</template>
<X2fa />
</FormSection>
<X2fa />
<FormSection>
<template #label>{{ i18n.ts.signinHistory }}</template>
@ -43,9 +40,9 @@
<FormSection>
<FormSlot>
<FormButton danger @click="regenerateToken"
<MkButton danger @click="regenerateToken"
><i class="ph-arrows-clockwise ph-bold ph-lg"></i>
{{ i18n.ts.regenerateLoginToken }}</FormButton
{{ i18n.ts.regenerateLoginToken }}</MkButton
>
<template #caption>{{
i18n.ts.regenerateLoginTokenDescription
@ -59,7 +56,7 @@
import X2fa from "./2fa.vue";
import FormSection from "@/components/form/section.vue";
import FormSlot from "@/components/form/slot.vue";
import FormButton from "@/components/MkButton.vue";
import MkButton from "@/components/MkButton.vue";
import MkPagination from "@/components/MkPagination.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
@ -70,11 +67,12 @@ const pagination = {
limit: 5,
};
async function change(): Promise<void> {
async function change() {
const { canceled: canceled1, result: currentPassword } = await os.inputText(
{
title: i18n.ts.currentPassword,
type: "password",
autocomplete: "current-password",
}
);
if (canceled1) return;
@ -82,12 +80,14 @@ async function change(): Promise<void> {
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;
@ -105,13 +105,13 @@ async function change(): Promise<void> {
});
}
function regenerateToken(): void {
function regenerateToken() {
os.inputText({
title: i18n.ts.password,
type: "password",
}).then(({ canceled, result: password }) => {
if (canceled) return;
os.api("i/regenerate_token", {
os.api("i/regenerate-token", {
password: password,
});
});
@ -129,7 +129,7 @@ definePageMetadata({
<style lang="scss" scoped>
.timnmucd {
padding: 16px;
padding: 12px;
&:first-child {
border-top-left-radius: 6px;

View File

@ -287,6 +287,34 @@ hr {
}
}
._panel {
background: var(--panel);
border-radius: var(--radius);
overflow: clip;
}
._margin {
margin: var(--margin) 0;
}
._gaps_m {
display: flex;
flex-direction: column;
gap: 1.5em;
}
._gaps_s {
display: flex;
flex-direction: column;
gap: 0.75em;
}
._gaps {
display: flex;
flex-direction: column;
gap: var(--margin);
}
._inputs {
display: flex;
margin: 32px 0;

View File

@ -294,6 +294,9 @@ importers:
os-utils:
specifier: 0.0.14
version: 0.0.14
otpauth:
specifier: ^9.1.2
version: 9.1.2
parse5:
specifier: 7.1.2
version: 7.1.2
@ -360,9 +363,6 @@ importers:
sonic-channel:
specifier: ^1.3.1
version: 1.3.1
speakeasy:
specifier: 2.0.0
version: 2.0.0
stringz:
specifier: 2.1.0
version: 2.1.0
@ -536,9 +536,6 @@ importers:
'@types/sinonjs__fake-timers':
specifier: 8.1.2
version: 8.1.2
'@types/speakeasy':
specifier: 2.0.7
version: 2.0.7
'@types/tinycolor2':
specifier: 1.4.3
version: 1.4.3
@ -2649,6 +2646,7 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [android]
requiresBuild: true
dependencies:
'@swc/wasm': 1.2.130
@ -2755,6 +2753,7 @@ packages:
/@swc/wasm@1.2.130:
resolution: {integrity: sha512-rNcJsBxS70+pv8YUWwf5fRlWX6JoY/HJc25HD/F8m6Kv7XhJdqPPMhyX6TKkUBPAG7TWlZYoxa+rHAjPy4Cj3Q==}
requiresBuild: true
/@syuilo/aiscript@0.11.1:
resolution: {integrity: sha512-chwOIA3yLUKvOB0G611hjLArKTeOWNmTm3lHERSaDW1d+dS6do56naX6Lkwy2UpnwWC0qzeNSgg35elk6t2gZg==}
@ -3641,12 +3640,6 @@ packages:
resolution: {integrity: sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==}
dev: true
/@types/speakeasy@2.0.7:
resolution: {integrity: sha512-JEcOhN2SQCoX86ZfiZEe8px84sVJtivBXMZfOVyARTYEj0hrwwbj1nF0FwEL3nJSoEV6uTbcdLllMKBgAYHWCQ==}
dependencies:
'@types/node': 18.11.18
dev: true
/@types/stack-utils@2.0.1:
resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==}
dev: true
@ -4893,10 +4886,6 @@ packages:
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
/base32.js@0.0.1:
resolution: {integrity: sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==}
dev: false
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -10060,6 +10049,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:
@ -11676,6 +11669,12 @@ packages:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
dev: true
/otpauth@9.1.2:
resolution: {integrity: sha512-iI5nlVvMFP3aTPdjG/fnC4mhVJ/KZOSnBrvo/VnYHUwlTp9jVLjAe2B3i3pyCH+3/E5jYQRSvuHk/8oas3870g==}
dependencies:
jssha: 3.3.0
dev: false
/p-cancelable@2.1.1:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
@ -13731,13 +13730,6 @@ packages:
resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==}
dev: true
/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'}