mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Voer verifikasiekode in",
|
||||
"verify": "Verifieer Kode"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-verifikasie",
|
||||
"on": "2FA is geaktiveer",
|
||||
"off": "Voeg ekstra aanmeldsekuriteit by",
|
||||
"enable": "Aktiveer",
|
||||
"disable": "Deaktiveer",
|
||||
"addDevice": "Voeg toestel by",
|
||||
"addTitle": "Voeg verifikasietoestel by",
|
||||
"addScanInstructions": "Skandeer hierdie QR-kode met die bykomende verifikator. Bestaande verifikators sal aanhou werk.",
|
||||
"addSuccess": "Bykomende outentiseerder ingeskryf",
|
||||
"addFailed": "Kon nie verifieerder byvoeg nie",
|
||||
"setupTitle": "Stel TOTP op",
|
||||
"setupFailed": "Kon nie TOTP-opstelling begin nie",
|
||||
"qrCode": "QR-kode",
|
||||
"secretCopied": "Geheim gekopieer na knipbord",
|
||||
"continueVerify": "Gaan voort om te verifieer",
|
||||
"verifyTitle": "Verifieer Kode",
|
||||
"verify": "Verifieer",
|
||||
"enter6Digits": "Voer 'n 6-syferkode in",
|
||||
"enabledSuccess": "Tweefaktor-verifikasie geaktiveer",
|
||||
"invalidCode": "Ongeldige kode, probeer asseblief weer",
|
||||
"disableTitle": "Deaktiveer 2FA",
|
||||
"disabledSuccess": "Tweefaktor-verifikasie gedeaktiveer",
|
||||
"disableFailed": "Kon nie 2FA deaktiveer nie",
|
||||
"backupTitle": "Rugsteunkodes",
|
||||
"downloadBackup": "Laai rugsteunkodes af",
|
||||
"done": "Klaar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "أدخل رمز التحقق",
|
||||
"verify": "التحقق من الرمز"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "مُوثِّق TOTP",
|
||||
"on": "التحقق بخطوتين ممكّن",
|
||||
"off": "إضافة أمان إضافي لتسجيل الدخول",
|
||||
"enable": "تمكين",
|
||||
"disable": "تعطيل",
|
||||
"addDevice": "إضافة جهاز",
|
||||
"addTitle": "إضافة جهاز المصادقة",
|
||||
"addScanInstructions": "امسح رمز الاستجابة السريعة هذا باستخدام أداة المصادقة الإضافية. ستستمر أدوات المصادقة الحالية في العمل.",
|
||||
"addSuccess": "تم تسجيل مصادق إضافي",
|
||||
"addFailed": "فشل إضافة المصادقة",
|
||||
"setupTitle": "إعداد TOTP",
|
||||
"setupFailed": "فشل بدء إعداد TOTP",
|
||||
"qrCode": "رمز الاستجابة السريعة",
|
||||
"secretCopied": "تم نسخ السر إلى الحافظة",
|
||||
"continueVerify": "متابعة للتحقق",
|
||||
"verifyTitle": "التحقق من الرمز",
|
||||
"verify": "تحقق",
|
||||
"enter6Digits": "أدخل رمزًا من 6 أرقام",
|
||||
"enabledSuccess": "تم تمكين المصادقة الثنائية",
|
||||
"invalidCode": "رمز غير صالح، يرجى المحاولة مرة أخرى",
|
||||
"disableTitle": "تعطيل المصادقة الثنائية",
|
||||
"disabledSuccess": "تم تعطيل المصادقة الثنائية",
|
||||
"disableFailed": "فشل تعطيل المصادقة الثنائية",
|
||||
"backupTitle": "رموز النسخ الاحتياطي",
|
||||
"downloadBackup": "تنزيل رموز النسخ الاحتياطي",
|
||||
"done": "تم"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Въведете код за потвърждение",
|
||||
"verify": "Код за проверка"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP удостоверител",
|
||||
"on": "2FA е активирана",
|
||||
"off": "Добавете допълнителна сигурност за вход",
|
||||
"enable": "Активиране",
|
||||
"disable": "Деактивиране",
|
||||
"addDevice": "Добавяне на устройство",
|
||||
"addTitle": "Добавяне на устройство за удостоверяване",
|
||||
"addScanInstructions": "Сканирайте този QR код с допълнителния автентификатор. Съществуващите автентификатори ще продължат да работят.",
|
||||
"addSuccess": "Допълнителен регистриран удостоверител",
|
||||
"addFailed": "Добавянето на удостоверител не бе успешно",
|
||||
"setupTitle": "Настройка на TOTP",
|
||||
"setupFailed": "Неуспешно стартиране на настройката на TOTP",
|
||||
"qrCode": "QR код",
|
||||
"secretCopied": "Тайната е копирана в клипборда",
|
||||
"continueVerify": "Продължете с проверката",
|
||||
"verifyTitle": "Код за проверка",
|
||||
"verify": "Проверка",
|
||||
"enter6Digits": "Въведете 6-цифрен код",
|
||||
"enabledSuccess": "Двуфакторно удостоверяване е активирано",
|
||||
"invalidCode": "Невалиден код, моля опитайте отново",
|
||||
"disableTitle": "Деактивиране на 2FA",
|
||||
"disabledSuccess": "Двуфакторното удостоверяване е деактивирано",
|
||||
"disableFailed": "Деактивирането на 2FA не бе успешно",
|
||||
"backupTitle": "Резервни кодове",
|
||||
"downloadBackup": "Изтегляне на резервни кодове",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "যাচাইকরণ কোড প্রবেশ করান",
|
||||
"verify": "কোড যাচাই করুন"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP প্রমাণীকরণকারী",
|
||||
"on": "2FA সক্রিয় করা হয়েছে",
|
||||
"off": "অতিরিক্ত লগইন নিরাপত্তা যোগ করুন",
|
||||
"enable": "সক্ষম করুন",
|
||||
"disable": "অক্ষম করুন",
|
||||
"addDevice": "ডিভাইস যোগ করুন",
|
||||
"addTitle": "প্রমাণীকরণ ডিভাইস যোগ করুন",
|
||||
"addScanInstructions": "অতিরিক্ত অথেন্টিকেটর দিয়ে এই কিউআর কোডটি স্ক্যান করুন। বিদ্যমান অথেন্টিকেটরগুলো কাজ করতে থাকবে।",
|
||||
"addSuccess": "অতিরিক্ত প্রমাণীকরণকারী নথিভুক্ত করা হয়েছে",
|
||||
"addFailed": "প্রমাণীকরণকারী যোগ করতে ব্যর্থ হয়েছে",
|
||||
"setupTitle": "TOTP সেটআপ করুন",
|
||||
"setupFailed": "TOTP সেটআপ শুরু করতে ব্যর্থ হয়েছে",
|
||||
"qrCode": "কিউআর কোড",
|
||||
"secretCopied": "গোপনীয় তথ্য ক্লিপবোর্ডে কপি করা হয়েছে",
|
||||
"continueVerify": "যাচাই চালিয়ে যান",
|
||||
"verifyTitle": "কোড যাচাই করুন",
|
||||
"verify": "যাচাই করুন",
|
||||
"enter6Digits": "একটি ৬-সংখ্যার কোড প্রবেশ করান",
|
||||
"enabledSuccess": "দ্বি-স্তর প্রমাণীকরণ সক্রিয় করা হয়েছে",
|
||||
"invalidCode": "কোডটি ভুল, অনুগ্রহ করে আবার চেষ্টা করুন।",
|
||||
"disableTitle": "2FA নিষ্ক্রিয় করুন",
|
||||
"disabledSuccess": "দ্বি-স্তর প্রমাণীকরণ নিষ্ক্রিয় করা হয়েছে",
|
||||
"disableFailed": "2FA নিষ্ক্রিয় করতে ব্যর্থ হয়েছে",
|
||||
"backupTitle": "ব্যাকআপ কোড",
|
||||
"downloadBackup": "ব্যাকআপ কোড ডাউনলোড করুন",
|
||||
"done": "সম্পন্ন হয়েছে"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Introduïu el codi de verificació",
|
||||
"verify": "Verifica el codi"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"on": "2FA està activat",
|
||||
"off": "Afegeix seguretat d'inici de sessió addicional",
|
||||
"enable": "Activa",
|
||||
"disable": "Desactiva",
|
||||
"addDevice": "Afegeix un dispositiu",
|
||||
"addTitle": "Afegeix un dispositiu d'autenticació",
|
||||
"addScanInstructions": "Escaneja aquest codi QR amb l'autenticador addicional. Els autenticadors existents continuaran funcionant.",
|
||||
"addSuccess": "Autenticador addicional inscrit",
|
||||
"addFailed": "No s'ha pogut afegir l'autenticador",
|
||||
"setupTitle": "Configura TOTP",
|
||||
"setupFailed": "No s'ha pogut iniciar la configuració del TOTP",
|
||||
"qrCode": "Codi QR",
|
||||
"secretCopied": "Secret copiat al porta-retalls",
|
||||
"continueVerify": "Continua a verificar",
|
||||
"verifyTitle": "Verifica el codi",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Introduïu un codi de 6 dígits",
|
||||
"enabledSuccess": "Autenticació de dos factors activada",
|
||||
"invalidCode": "Codi no vàlid, si us plau, torna-ho a intentar.",
|
||||
"disableTitle": "Desactiva la 2FA",
|
||||
"disabledSuccess": "Autenticació de dos factors desactivada",
|
||||
"disableFailed": "No s'ha pogut desactivar la 2FA",
|
||||
"backupTitle": "Codis de còpia de seguretat",
|
||||
"downloadBackup": "Baixa els codis de còpia de seguretat",
|
||||
"done": "Fet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Zadejte ověřovací kód",
|
||||
"verify": "Ověřte kód"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Ověřovač TOTP",
|
||||
"on": "2FA je povoleno",
|
||||
"off": "Přidejte další zabezpečení přihlášení",
|
||||
"enable": "Umožnit",
|
||||
"disable": "Zakázat",
|
||||
"addDevice": "Přidat zařízení",
|
||||
"addTitle": "Přidat ověřovací zařízení",
|
||||
"addScanInstructions": "Naskenujte tento QR kód pomocí dalšího ověřovače. Stávající ověřovače budou i nadále fungovat.",
|
||||
"addSuccess": "Další ověřovatel zaregistrován",
|
||||
"addFailed": "Přidání ověřovatele se nezdařilo",
|
||||
"setupTitle": "Nastavení TOTP",
|
||||
"setupFailed": "Nepodařilo se spustit nastavení TOTP",
|
||||
"qrCode": "QR kód",
|
||||
"secretCopied": "Tajný kód zkopírován do schránky",
|
||||
"continueVerify": "Pokračovat k ověřování",
|
||||
"verifyTitle": "Ověřte kód",
|
||||
"verify": "Ověřit",
|
||||
"enter6Digits": "Zadejte 6místný kód",
|
||||
"enabledSuccess": "Dvoufázové ověřování povoleno",
|
||||
"invalidCode": "Neplatný kód, zkuste to prosím znovu.",
|
||||
"disableTitle": "Zakázat 2FA",
|
||||
"disabledSuccess": "Dvoufázové ověřování zakázáno",
|
||||
"disableFailed": "Nepodařilo se zakázat 2FA",
|
||||
"backupTitle": "Záložní kódy",
|
||||
"downloadBackup": "Stáhnout záložní kódy",
|
||||
"done": "Hotovo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Indtast bekræftelseskode",
|
||||
"verify": "Bekræft kode"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-godkendelse",
|
||||
"on": "2FA er aktiveret",
|
||||
"off": "Tilføj ekstra loginsikkerhed",
|
||||
"enable": "Aktivér",
|
||||
"disable": "Deaktiver",
|
||||
"addDevice": "Tilføj enhed",
|
||||
"addTitle": "Tilføj godkendelsesenhed",
|
||||
"addScanInstructions": "Scan denne QR-kode med den ekstra godkendelsesfunktion. Eksisterende godkendelsesfunktioner vil fortsat virke.",
|
||||
"addSuccess": "Yderligere godkendelsesfunktion tilmeldt",
|
||||
"addFailed": "Kunne ikke tilføje godkendelse",
|
||||
"setupTitle": "Opsæt TOTP",
|
||||
"setupFailed": "Kunne ikke starte TOTP-opsætningen",
|
||||
"qrCode": "QR-kode",
|
||||
"secretCopied": "Hemmelighed kopieret til udklipsholder",
|
||||
"continueVerify": "Fortsæt med at bekræfte",
|
||||
"verifyTitle": "Bekræft kode",
|
||||
"verify": "Verificere",
|
||||
"enter6Digits": "Indtast en 6-cifret kode",
|
||||
"enabledSuccess": "Tofaktorgodkendelse aktiveret",
|
||||
"invalidCode": "Ugyldig kode, prøv igen",
|
||||
"disableTitle": "Deaktiver 2FA",
|
||||
"disabledSuccess": "Tofaktorgodkendelse deaktiveret",
|
||||
"disableFailed": "Kunne ikke deaktivere 2FA",
|
||||
"backupTitle": "Backupkoder",
|
||||
"downloadBackup": "Download backupkoder",
|
||||
"done": "Færdig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Bestätigungscode eingeben",
|
||||
"verify": "Code überprüfen"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-Authentifikator",
|
||||
"on": "2FA ist aktiviert",
|
||||
"off": "Zusätzliche Anmeldesicherheit hinzufügen",
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"addDevice": "Gerät hinzufügen",
|
||||
"addTitle": "Authentifizierungsgerät hinzufügen",
|
||||
"addScanInstructions": "Scannen Sie diesen QR-Code mit dem zusätzlichen Authentifikator. Bestehende Authentifikatoren funktionieren weiterhin.",
|
||||
"addSuccess": "Zusätzlicher Authentifizierungsalgorithmus registriert",
|
||||
"addFailed": "Authentifikator konnte nicht hinzugefügt werden",
|
||||
"setupTitle": "TOTP einrichten",
|
||||
"setupFailed": "Fehler beim Starten der TOTP-Einrichtung",
|
||||
"qrCode": "QR-Code",
|
||||
"secretCopied": "Secret in die Zwischenablage kopiert",
|
||||
"continueVerify": "Weiter zur Verifizierung",
|
||||
"verifyTitle": "Code verifizieren",
|
||||
"verify": "Verifizieren",
|
||||
"enter6Digits": "Geben Sie einen 6-stelligen Code ein",
|
||||
"enabledSuccess": "Zwei-Faktor-Authentifizierung aktiviert",
|
||||
"invalidCode": "Ungültiger Code, bitte versuchen Sie es erneut",
|
||||
"disableTitle": "2FA deaktivieren",
|
||||
"disabledSuccess": "Zwei-Faktor-Authentifizierung deaktiviert",
|
||||
"disableFailed": "Fehler beim Deaktivieren der 2FA",
|
||||
"backupTitle": "Backup-Codes",
|
||||
"downloadBackup": "Backup-Codes herunterladen",
|
||||
"done": "Fertig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Εισαγάγετε τον κωδικό επαλήθευσης",
|
||||
"verify": "Επαλήθευση Κωδικού"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Επαληθευτής TOTP",
|
||||
"on": "Το 2FA είναι ενεργοποιημένο",
|
||||
"off": "Προσθέστε επιπλέον ασφάλεια σύνδεσης",
|
||||
"enable": "Καθιστώ ικανό",
|
||||
"disable": "Καθιστώ ανίκανο",
|
||||
"addDevice": "Προσθήκη συσκευής",
|
||||
"addTitle": "Προσθήκη συσκευής ελέγχου ταυτότητας",
|
||||
"addScanInstructions": "Σαρώστε αυτόν τον κωδικό QR με τον πρόσθετο έλεγχο ταυτότητας. Οι υπάρχοντες έλεγχοι ταυτότητας θα συνεχίσουν να λειτουργούν.",
|
||||
"addSuccess": "Εγγράφηκε επιπλέον υπεύθυνος ελέγχου ταυτότητας",
|
||||
"addFailed": "Αποτυχία προσθήκης ελέγχου ταυτότητας",
|
||||
"setupTitle": "Ρύθμιση TOTP",
|
||||
"setupFailed": "Αποτυχία έναρξης ρύθμισης TOTP",
|
||||
"qrCode": "Κωδικός QR",
|
||||
"secretCopied": "Το μυστικό αντιγράφηκε στο πρόχειρο",
|
||||
"continueVerify": "Συνέχεια για επαλήθευση",
|
||||
"verifyTitle": "Επαλήθευση Κωδικού",
|
||||
"verify": "Επαληθεύω",
|
||||
"enter6Digits": "Εισαγάγετε έναν 6ψήφιο κωδικό",
|
||||
"enabledSuccess": "Ενεργοποιήθηκε ο έλεγχος ταυτότητας δύο παραγόντων",
|
||||
"invalidCode": "Μη έγκυρος κωδικός, δοκιμάστε ξανά",
|
||||
"disableTitle": "Απενεργοποίηση 2FA",
|
||||
"disabledSuccess": "Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε",
|
||||
"disableFailed": "Αποτυχία απενεργοποίησης 2FA",
|
||||
"backupTitle": "Κωδικοί αντιγράφων ασφαλείας",
|
||||
"downloadBackup": "Λήψη αντιγράφων ασφαλείας κωδικών",
|
||||
"done": "Γινώμενος"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Introduzca el código de verificación",
|
||||
"verify": "Verificar código"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"on": "2FA activada",
|
||||
"off": "Añadir seguridad adicional al inicio de sesión",
|
||||
"enable": "Activar",
|
||||
"disable": "Desactivar",
|
||||
"addDevice": "Agregar dispositivo",
|
||||
"addTitle": "Agregar dispositivo de autenticación",
|
||||
"addScanInstructions": "Escanee este código QR con el autenticador adicional. Los autenticadores existentes seguirán funcionando.",
|
||||
"addSuccess": "Autenticador adicional registrado",
|
||||
"addFailed": "No se pudo agregar el autenticador.",
|
||||
"setupTitle": "Configurar TOTP",
|
||||
"setupFailed": "No se pudo iniciar la configuración de TOTP",
|
||||
"qrCode": "Código QR",
|
||||
"secretCopied": "Secreto copiado al portapapeles",
|
||||
"continueVerify": "Continuar para verificar",
|
||||
"verifyTitle": "Verificar código",
|
||||
"verify": "Verificar",
|
||||
"enter6Digits": "Introduce un código de 6 dígitos",
|
||||
"enabledSuccess": "Autenticación de dos factores activada",
|
||||
"invalidCode": "Código no válido, inténtalo de nuevo",
|
||||
"disableTitle": "Desactivar 2FA",
|
||||
"disabledSuccess": "Autenticación de dos factores desactivada",
|
||||
"disableFailed": "No se pudo desactivar 2FA",
|
||||
"backupTitle": "Códigos de respaldo",
|
||||
"downloadBackup": "Descargar códigos de respaldo",
|
||||
"done": "Listo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Syötä vahvistuskoodi",
|
||||
"verify": "Vahvista koodi"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-todentaja",
|
||||
"on": "2FA on käytössä",
|
||||
"off": "Lisää kirjautumisen turvallisuutta",
|
||||
"enable": "Ota käyttöön",
|
||||
"disable": "Poista käytöstä",
|
||||
"addDevice": "Lisää laite",
|
||||
"addTitle": "Lisää todennuslaite",
|
||||
"addScanInstructions": "Skannaa tämä QR-koodi lisätodentajalla. Nykyiset todentajat toimivat edelleen.",
|
||||
"addSuccess": "Lisätodentaja rekisteröity",
|
||||
"addFailed": "Todentajan lisääminen epäonnistui",
|
||||
"setupTitle": "TOTP:n määrittäminen",
|
||||
"setupFailed": "TOTP-asennuksen aloittaminen epäonnistui",
|
||||
"qrCode": "QR-koodi",
|
||||
"secretCopied": "Salaisuus kopioitu leikepöydälle",
|
||||
"continueVerify": "Jatka vahvistamiseen",
|
||||
"verifyTitle": "Vahvista koodi",
|
||||
"verify": "Vahvista",
|
||||
"enter6Digits": "Syötä 6-numeroinen koodi",
|
||||
"enabledSuccess": "Kaksivaiheinen todennus käytössä",
|
||||
"invalidCode": "Virheellinen koodi, yritä uudelleen",
|
||||
"disableTitle": "Poista 2FA käytöstä",
|
||||
"disabledSuccess": "Kaksivaiheinen todennus poistettu käytöstä",
|
||||
"disableFailed": "2FA:n poistaminen käytöstä epäonnistui",
|
||||
"backupTitle": "Varakoodit",
|
||||
"downloadBackup": "Lataa varakoodit",
|
||||
"done": "Tehty"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Saisir le code",
|
||||
"verify": "Vérifier le code"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Authentificateur TOTP",
|
||||
"on": "2FA activée",
|
||||
"off": "Ajouter une sécurité de connexion supplémentaire",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"addDevice": "Ajouter un appareil",
|
||||
"addTitle": "Ajouter un dispositif d'authentification",
|
||||
"addScanInstructions": "Scannez ce code QR avec l'authentificateur supplémentaire. Les authentificateurs existants continueront de fonctionner.",
|
||||
"addSuccess": "Authentificateur supplémentaire enregistré",
|
||||
"addFailed": "Échec de l'ajout de l'authentificateur",
|
||||
"setupTitle": "Configurer TOTP",
|
||||
"setupFailed": "Échec du démarrage de la configuration TOTP",
|
||||
"qrCode": "Code QR",
|
||||
"secretCopied": "Secret copié dans le presse-papiers",
|
||||
"continueVerify": "Poursuivre la vérification",
|
||||
"verifyTitle": "Vérifier le code",
|
||||
"verify": "Vérifier",
|
||||
"enter6Digits": "Saisissez un code à 6 chiffres",
|
||||
"enabledSuccess": "Authentification à deux facteurs activée",
|
||||
"invalidCode": "Code invalide, veuillez réessayer",
|
||||
"disableTitle": "Désactiver l’A2F",
|
||||
"disabledSuccess": "Authentification à deux facteurs désactivée",
|
||||
"disableFailed": "Échec de la désactivation de la 2FA",
|
||||
"backupTitle": "Codes de sauvegarde",
|
||||
"downloadBackup": "Télécharger les codes de sauvegarde",
|
||||
"done": "Terminé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "הזן קוד אימות",
|
||||
"verify": "אימות קוד"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "מאמת TOTP",
|
||||
"on": "2FA מופעל",
|
||||
"off": "הוסף אבטחת התחברות נוספת",
|
||||
"enable": "לְאַפשֵׁר",
|
||||
"disable": "השבת",
|
||||
"addDevice": "הוסף מכשיר",
|
||||
"addTitle": "הוסף מכשיר אימות",
|
||||
"addScanInstructions": "סרוק את קוד ה-QR הזה עם המאמת הנוסף. המאמתים הקיימים ימשיכו לפעול.",
|
||||
"addSuccess": "מאמת נוסף נרשם",
|
||||
"addFailed": "נכשלה הוספת המאמת",
|
||||
"setupTitle": "הגדר TOTP",
|
||||
"setupFailed": "נכשל בהתחלת הגדרת TOTP",
|
||||
"qrCode": "קוד QR",
|
||||
"secretCopied": "סוד הועתק ללוח",
|
||||
"continueVerify": "המשך לאימות",
|
||||
"verifyTitle": "אימות קוד",
|
||||
"verify": "לְאַמֵת",
|
||||
"enter6Digits": "הזן קוד בן 6 ספרות",
|
||||
"enabledSuccess": "אימות דו-שלבי מופעל",
|
||||
"invalidCode": "קוד לא תקין, אנא נסה שוב",
|
||||
"disableTitle": "השבת 2FA",
|
||||
"disabledSuccess": "אימות דו-שלבי מושבת",
|
||||
"disableFailed": "נכשל בהשבתת 2FA",
|
||||
"backupTitle": "קודי גיבוי",
|
||||
"downloadBackup": "הורד קודי גיבוי",
|
||||
"done": "נַעֲשָׂה"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "सत्यापन कोड दर्ज करें",
|
||||
"verify": "कोड सत्यापित करें"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP प्रमाणक",
|
||||
"on": "2FA सक्षम है",
|
||||
"off": "अतिरिक्त लॉगिन सुरक्षा जोड़ें",
|
||||
"enable": "सक्षम करें",
|
||||
"disable": "अक्षम करें",
|
||||
"addDevice": "डिवाइस जोडे",
|
||||
"addTitle": "प्रमाणीकरण उपकरण जोड़ें",
|
||||
"addScanInstructions": "इस क्यूआर कोड को अतिरिक्त प्रमाणीकरण उपकरण से स्कैन करें। मौजूदा प्रमाणीकरण उपकरण काम करते रहेंगे।",
|
||||
"addSuccess": "अतिरिक्त प्रमाणीकरणकर्ता नामांकित किया गया",
|
||||
"addFailed": "प्रमाणीकरणकर्ता जोड़ने में विफल",
|
||||
"setupTitle": "TOTP सेटअप करें",
|
||||
"setupFailed": "TOTP सेटअप शुरू करने में विफल",
|
||||
"qrCode": "QR कोड",
|
||||
"secretCopied": "सीक्रेट क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"continueVerify": "सत्यापन जारी रखें",
|
||||
"verifyTitle": "कोड सत्यापित करें",
|
||||
"verify": "सत्यापित करें",
|
||||
"enter6Digits": "6-अंकीय कोड दर्ज करें",
|
||||
"enabledSuccess": "दो-कारक प्रमाणीकरण सक्षम किया गया",
|
||||
"invalidCode": "अमान्य कोड, कृपया पुनः प्रयास करें",
|
||||
"disableTitle": "2FA अक्षम करें",
|
||||
"disabledSuccess": "दो-कारक प्रमाणीकरण अक्षम किया गया",
|
||||
"disableFailed": "2FA अक्षम करने में विफल",
|
||||
"backupTitle": "बैकअप कोड",
|
||||
"downloadBackup": "बैकअप कोड डाउनलोड करें",
|
||||
"done": "पूर्ण"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Írja be az ellenőrző kódot",
|
||||
"verify": "Kód ellenőrzése"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP hitelesítő",
|
||||
"on": "2FA engedélyezve",
|
||||
"off": "Fokozott bejelentkezési biztonság",
|
||||
"enable": "Engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"addDevice": "Eszköz hozzáadása",
|
||||
"addTitle": "Hitelesítő eszköz hozzáadása",
|
||||
"addScanInstructions": "Olvasd be ezt a QR-kódot a további hitelesítővel. A meglévő hitelesítők továbbra is működni fognak.",
|
||||
"addSuccess": "További hitelesítő regisztrálva",
|
||||
"addFailed": "Nem sikerült hozzáadni a hitelesítőt",
|
||||
"setupTitle": "TOTP beállítása",
|
||||
"setupFailed": "Nem sikerült elindítani a TOTP beállítását",
|
||||
"qrCode": "QR-kód",
|
||||
"secretCopied": "Titkos kód a vágólapra másolva",
|
||||
"continueVerify": "Tovább az ellenőrzéshez",
|
||||
"verifyTitle": "Kód ellenőrzése",
|
||||
"verify": "Ellenőrzés",
|
||||
"enter6Digits": "Adjon meg egy 6 jegyű kódot",
|
||||
"enabledSuccess": "Kétfaktoros hitelesítés engedélyezve",
|
||||
"invalidCode": "Érvénytelen kód, kérjük, próbálja újra",
|
||||
"disableTitle": "2FA letiltása",
|
||||
"disabledSuccess": "Kétfaktoros hitelesítés letiltva",
|
||||
"disableFailed": "Nem sikerült letiltani a 2FA-t",
|
||||
"backupTitle": "Biztonsági kódok",
|
||||
"downloadBackup": "Tartalékkódok letöltése",
|
||||
"done": "Kész"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Masukkan kode verifikasi",
|
||||
"verify": "Verifikasi Kode"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Otentikator TOTP",
|
||||
"on": "Otentikasi dua faktor (2FA) diaktifkan.",
|
||||
"off": "Tambahkan keamanan login ekstra",
|
||||
"enable": "Memungkinkan",
|
||||
"disable": "Cacat",
|
||||
"addDevice": "Tambahkan perangkat",
|
||||
"addTitle": "Tambahkan perangkat otentikasi",
|
||||
"addScanInstructions": "Pindai kode QR ini dengan otentikator tambahan. Otentikator yang sudah ada akan tetap berfungsi.",
|
||||
"addSuccess": "Autentikator tambahan terdaftar",
|
||||
"addFailed": "Gagal menambahkan autentikator",
|
||||
"setupTitle": "Pengaturan TOTP",
|
||||
"setupFailed": "Gagal memulai pengaturan TOTP",
|
||||
"qrCode": "Kode QR",
|
||||
"secretCopied": "Rahasia disalin ke papan klip",
|
||||
"continueVerify": "Lanjutkan Verifikasi",
|
||||
"verifyTitle": "Verifikasi Kode",
|
||||
"verify": "Memeriksa",
|
||||
"enter6Digits": "Masukkan kode 6 digit",
|
||||
"enabledSuccess": "Autentikasi dua faktor diaktifkan",
|
||||
"invalidCode": "Kode tidak valid, silakan coba lagi.",
|
||||
"disableTitle": "Nonaktifkan otentikasi dua faktor (2FA).",
|
||||
"disabledSuccess": "Autentikasi dua faktor dinonaktifkan",
|
||||
"disableFailed": "Gagal menonaktifkan 2FA.",
|
||||
"backupTitle": "Kode Cadangan",
|
||||
"downloadBackup": "Unduh Kode Cadangan",
|
||||
"done": "Selesai"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Inserisci codice di verifica",
|
||||
"verify": "Verifica codice"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticatore TOTP",
|
||||
"on": "2FA è attivata",
|
||||
"off": "Aggiungi sicurezza aggiuntiva all'accesso",
|
||||
"enable": "Abilita",
|
||||
"disable": "Disabilita",
|
||||
"addDevice": "Aggiungi dispositivo",
|
||||
"addTitle": "Aggiungi dispositivo di autenticazione",
|
||||
"addScanInstructions": "Scansiona questo codice QR con l'autenticatore aggiuntivo. Gli autenticatori già in uso continueranno a funzionare.",
|
||||
"addSuccess": "Autenticatore aggiuntivo registrato",
|
||||
"addFailed": "Impossibile aggiungere l'autenticatore",
|
||||
"setupTitle": "Configura TOTP",
|
||||
"setupFailed": "Impossibile avviare la configurazione TOTP",
|
||||
"qrCode": "Codice QR",
|
||||
"secretCopied": "Segreto copiato negli appunti",
|
||||
"continueVerify": "Procedi alla verifica",
|
||||
"verifyTitle": "Verifica codice",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Inserisci un codice a 6 cifre",
|
||||
"enabledSuccess": "Autenticazione a due fattori abilitata",
|
||||
"invalidCode": "Codice non valido, riprova",
|
||||
"disableTitle": "Disabilita 2FA",
|
||||
"disabledSuccess": "Autenticazione a due fattori disabilitata",
|
||||
"disableFailed": "Impossibile disabilitare la 2FA",
|
||||
"backupTitle": "Codici di backup",
|
||||
"downloadBackup": "Scarica i codici di backup",
|
||||
"done": "Fatto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "認証コードを入力",
|
||||
"verify": "コードを確認"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP認証",
|
||||
"on": "2FAが有効です",
|
||||
"off": "ログインのセキュリティを強化",
|
||||
"enable": "有効にする",
|
||||
"disable": "無効にする",
|
||||
"addDevice": "デバイスを追加する",
|
||||
"addTitle": "認証デバイスを追加する",
|
||||
"addScanInstructions": "追加の認証デバイスでこのQRコードをスキャンしてください。既存の認証デバイスは引き続きご利用いただけます。",
|
||||
"addSuccess": "追加の認証デバイスを登録しました",
|
||||
"addFailed": "認証システムの追加に失敗しました",
|
||||
"setupTitle": "TOTPを設定",
|
||||
"setupFailed": "TOTPのセットアップを開始できませんでした",
|
||||
"qrCode": "QRコード",
|
||||
"secretCopied": "シークレットをクリップボードにコピーしました",
|
||||
"continueVerify": "確認に進む",
|
||||
"verifyTitle": "コードの確認",
|
||||
"verify": "確認",
|
||||
"enter6Digits": "6桁のコードを入力してください",
|
||||
"enabledSuccess": "二要素認証が有効になりました",
|
||||
"invalidCode": "コードが無効です。もう一度お試しください",
|
||||
"disableTitle": "2FAを無効にする",
|
||||
"disabledSuccess": "二要素認証が無効になりました",
|
||||
"disableFailed": "2FAの無効化に失敗しました",
|
||||
"backupTitle": "バックアップコード",
|
||||
"downloadBackup": "バックアップコードをダウンロード",
|
||||
"done": "完了"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "인증 코드 입력",
|
||||
"verify": "코드 확인"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 인증기",
|
||||
"on": "2FA가 활성화되었습니다",
|
||||
"off": "추가 로그인 보안을 설정하세요",
|
||||
"enable": "활성화",
|
||||
"disable": "비활성화",
|
||||
"addDevice": "기기 추가",
|
||||
"addTitle": "인증 장치 추가",
|
||||
"addScanInstructions": "추가 인증 수단으로 이 QR 코드를 스캔하세요. 기존 인증 수단도 계속 사용할 수 있습니다.",
|
||||
"addSuccess": "추가 인증자가 등록되었습니다",
|
||||
"addFailed": "인증자 추가에 실패했습니다.",
|
||||
"setupTitle": "TOTP 설정",
|
||||
"setupFailed": "TOTP 설정 시작 실패",
|
||||
"qrCode": "QR 코드",
|
||||
"secretCopied": "시크릿이 클립보드에 복사됨",
|
||||
"continueVerify": "인증 계속하기",
|
||||
"verifyTitle": "코드 확인",
|
||||
"verify": "확인",
|
||||
"enter6Digits": "6자리 코드 입력",
|
||||
"enabledSuccess": "2단계 인증이 활성화되었습니다.",
|
||||
"invalidCode": "유효하지 않은 코드입니다. 다시 시도하세요.",
|
||||
"disableTitle": "2FA 비활성화",
|
||||
"disabledSuccess": "2단계 인증이 비활성화되었습니다.",
|
||||
"disableFailed": "2FA 비활성화 실패",
|
||||
"backupTitle": "백업 코드",
|
||||
"downloadBackup": "백업 코드 다운로드",
|
||||
"done": "완료"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Voer de verificatiecode in.",
|
||||
"verify": "Code verifiëren"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-authenticator",
|
||||
"on": "Tweefactorauthenticatie (2FA) is ingeschakeld.",
|
||||
"off": "Voeg extra inlogbeveiliging toe.",
|
||||
"enable": "Inschakelen",
|
||||
"disable": "Uitzetten",
|
||||
"addDevice": "Apparaat toevoegen",
|
||||
"addTitle": "Authenticatorapparaat toevoegen",
|
||||
"addScanInstructions": "Scan deze QR-code met de extra authenticatie-app. Bestaande authenticatie-apps blijven gewoon werken.",
|
||||
"addSuccess": "Extra authenticatie-app geregistreerd",
|
||||
"addFailed": "Authenticator toevoegen mislukt",
|
||||
"setupTitle": "TOTP instellen",
|
||||
"setupFailed": "Het starten van de TOTP-configuratie is mislukt.",
|
||||
"qrCode": "QR-code",
|
||||
"secretCopied": "Geheim gekopieerd naar het klembord",
|
||||
"continueVerify": "Doorgaan met verifiëren",
|
||||
"verifyTitle": "Code verifiëren",
|
||||
"verify": "Verifiëren",
|
||||
"enter6Digits": "Voer een 6-cijferige code in",
|
||||
"enabledSuccess": "Tweefactorauthenticatie ingeschakeld",
|
||||
"invalidCode": "Ongeldige code, probeer het opnieuw.",
|
||||
"disableTitle": "Schakel 2FA uit",
|
||||
"disabledSuccess": "Tweefactorauthenticatie uitgeschakeld",
|
||||
"disableFailed": "Het uitschakelen van 2FA is mislukt.",
|
||||
"backupTitle": "Back-upcodes",
|
||||
"downloadBackup": "Back-upcodes downloaden",
|
||||
"done": "Klaar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Skriv inn bekreftelseskoden",
|
||||
"verify": "Bekreft kode"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-autentisering",
|
||||
"on": "2FA er aktivert",
|
||||
"off": "Legg til ekstra sikkerhet for pålogging",
|
||||
"enable": "Aktiver",
|
||||
"disable": "Deaktiver",
|
||||
"addDevice": "Legg til enhet",
|
||||
"addTitle": "Legg til autentiseringsenhet",
|
||||
"addScanInstructions": "Skann denne QR-koden med den ekstra autentiseringsfunksjonen. Eksisterende autentiseringsfunksjoner vil fortsette å fungere.",
|
||||
"addSuccess": "Ekstra autentiseringsfunksjon registrert",
|
||||
"addFailed": "Kunne ikke legge til autentiseringsfunksjonen",
|
||||
"setupTitle": "Konfigurer TOTP",
|
||||
"setupFailed": "Kunne ikke starte TOTP-oppsettet",
|
||||
"qrCode": "QR-kode",
|
||||
"secretCopied": "Hemmelighet kopiert til utklippstavlen",
|
||||
"continueVerify": "Fortsett å bekrefte",
|
||||
"verifyTitle": "Bekreft kode",
|
||||
"verify": "Verifisere",
|
||||
"enter6Digits": "Skriv inn en 6-sifret kode",
|
||||
"enabledSuccess": "Tofaktorautentisering aktivert",
|
||||
"invalidCode": "Ugyldig kode, prøv på nytt",
|
||||
"disableTitle": "Deaktiver 2FA",
|
||||
"disabledSuccess": "Tofaktorautentisering deaktivert",
|
||||
"disableFailed": "Kunne ikke deaktivere 2FA",
|
||||
"backupTitle": "Sikkerhetskopieringskoder",
|
||||
"downloadBackup": "Last ned sikkerhetskoder",
|
||||
"done": "Ferdig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Wprowadź kod weryfikacyjny",
|
||||
"verify": "Zweryfikuj kod"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Aplikacja TOTP",
|
||||
"on": "Włączono uwierzytelnianie dwuskładnikowe",
|
||||
"off": "Dodaj dodatkowe zabezpieczenia logowania",
|
||||
"enable": "Włącz",
|
||||
"disable": "Wyłącz",
|
||||
"addDevice": "Dodaj urządzenie",
|
||||
"addTitle": "Dodaj urządzenie uwierzytelniające",
|
||||
"addScanInstructions": "Zeskanuj ten kod QR za pomocą dodatkowego uwierzytelniacza. Istniejące uwierzytelniacze będą nadal działać.",
|
||||
"addSuccess": "Zarejestrowano dodatkowego uwierzytelniacza",
|
||||
"addFailed": "Nie udało się dodać uwierzytelniacza",
|
||||
"setupTitle": "Skonfiguruj TOTP",
|
||||
"setupFailed": "Nie udało się uruchomić konfiguracji TOTP",
|
||||
"qrCode": "Kod QR",
|
||||
"secretCopied": "Skopiowano sekret do schowka",
|
||||
"continueVerify": "Kontynuuj weryfikację",
|
||||
"verifyTitle": "Zweryfikuj kod",
|
||||
"verify": "Zweryfikuj",
|
||||
"enter6Digits": "Wprowadź 6-cyfrowy kod",
|
||||
"enabledSuccess": "Włączono uwierzytelnianie dwuskładnikowe",
|
||||
"invalidCode": "Nieprawidłowy kod, spróbuj ponownie",
|
||||
"disableTitle": "Wyłącz 2FA",
|
||||
"disabledSuccess": "Wyłączono uwierzytelnianie dwuskładnikowe",
|
||||
"disableFailed": "Nie udało się wyłączyć uwierzytelniania dwuskładnikowego",
|
||||
"backupTitle": "Kody zapasowe",
|
||||
"downloadBackup": "Pobierz kody zapasowe",
|
||||
"done": "Gotowe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Insira o código de verificação",
|
||||
"verify": "Código de verificação"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"on": "A autenticação de dois fatores está ativada.",
|
||||
"off": "Adicione segurança extra ao login.",
|
||||
"enable": "Habilitar",
|
||||
"disable": "Desativar",
|
||||
"addDevice": "Adicionar dispositivo",
|
||||
"addTitle": "Adicionar dispositivo autenticador",
|
||||
"addScanInstructions": "Escaneie este código QR com o autenticador adicional. Os autenticadores existentes continuarão funcionando.",
|
||||
"addSuccess": "Autenticador adicional cadastrado",
|
||||
"addFailed": "Falha ao adicionar autenticador",
|
||||
"setupTitle": "Configurar TOTP",
|
||||
"setupFailed": "Falha ao iniciar a configuração TOTP",
|
||||
"qrCode": "Código QR",
|
||||
"secretCopied": "Segredo copiado para a área de transferência",
|
||||
"continueVerify": "Continuar a verificar",
|
||||
"verifyTitle": "Código de verificação",
|
||||
"verify": "Verificar",
|
||||
"enter6Digits": "Digite um código de 6 dígitos",
|
||||
"enabledSuccess": "Autenticação de dois fatores ativada",
|
||||
"invalidCode": "Código inválido, tente novamente.",
|
||||
"disableTitle": "Desative a autenticação de dois fatores (2FA).",
|
||||
"disabledSuccess": "Autenticação de dois fatores desativada",
|
||||
"disableFailed": "Falha ao desativar a autenticação de dois fatores (2FA).",
|
||||
"backupTitle": "Códigos de backup",
|
||||
"downloadBackup": "Baixar códigos de backup",
|
||||
"done": "Feito"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Introduza o código de verificação",
|
||||
"verify": "Verificar código"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"on": "2FA está ativada",
|
||||
"off": "Adicionar segurança extra de início de sessão",
|
||||
"enable": "Ativar",
|
||||
"disable": "Desativar",
|
||||
"addDevice": "Adicionar dispositivo",
|
||||
"addTitle": "Adicionar dispositivo autenticador",
|
||||
"addScanInstructions": "Digitalize este código QR com o autenticador adicional. Os autenticadores existentes continuarão a funcionar.",
|
||||
"addSuccess": "Autenticador adicional registado",
|
||||
"addFailed": "Falha ao adicionar autenticador",
|
||||
"setupTitle": "Configurar TOTP",
|
||||
"setupFailed": "Falha ao iniciar a configuração de TOTP",
|
||||
"qrCode": "Código QR",
|
||||
"secretCopied": "Segredo copiado para a área de transferência",
|
||||
"continueVerify": "Continuar para Verificação",
|
||||
"verifyTitle": "Verificar Código",
|
||||
"verify": "Verificar",
|
||||
"enter6Digits": "Insira um código de 6 dígitos",
|
||||
"enabledSuccess": "Autenticação de dois fatores ativada",
|
||||
"invalidCode": "Código inválido, tente novamente",
|
||||
"disableTitle": "Desativar 2FA",
|
||||
"disabledSuccess": "Autenticação de dois fatores desativada",
|
||||
"disableFailed": "Falha ao desativar 2FA",
|
||||
"backupTitle": "Códigos de Backup",
|
||||
"downloadBackup": "Descarregar Códigos de Backup",
|
||||
"done": "Concluído"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Introduceți codul de verificare",
|
||||
"verify": "Verificați codul"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autentificator TOTP",
|
||||
"on": "2FA este activat",
|
||||
"off": "Adăugați securitate suplimentară la conectare",
|
||||
"enable": "Permite",
|
||||
"disable": "Dezactivare",
|
||||
"addDevice": "Adăugați dispozitiv",
|
||||
"addTitle": "Adăugați un dispozitiv de autentificare",
|
||||
"addScanInstructions": "Scanați acest cod QR cu ajutorul autentificatorului suplimentar. Autentificatoarele existente vor continua să funcționeze.",
|
||||
"addSuccess": "Autentificator suplimentar înscris",
|
||||
"addFailed": "Nu s-a putut adăuga autentificatorul",
|
||||
"setupTitle": "Configurați TOTP",
|
||||
"setupFailed": "Nu s-a putut porni configurarea TOTP",
|
||||
"qrCode": "Cod QR",
|
||||
"secretCopied": "Secret copiat în clipboard",
|
||||
"continueVerify": "Continuați verificarea",
|
||||
"verifyTitle": "Verificați codul",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Introduceți un cod de 6 cifre",
|
||||
"enabledSuccess": "Autentificarea cu doi factori activată",
|
||||
"invalidCode": "Cod nevalid, vă rugăm să încercați din nou",
|
||||
"disableTitle": "Dezactivați 2FA",
|
||||
"disabledSuccess": "Autentificarea cu doi factori este dezactivată",
|
||||
"disableFailed": "Nu s-a putut dezactiva 2FA",
|
||||
"backupTitle": "Coduri de rezervă",
|
||||
"downloadBackup": "Descărcați coduri de rezervă",
|
||||
"done": "Făcut"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Введите проверочный код",
|
||||
"verify": "Проверить код"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Аутентификатор TOTP",
|
||||
"on": "2FA включена",
|
||||
"off": "Добавить дополнительную защиту",
|
||||
"enable": "Включить",
|
||||
"disable": "Отключить",
|
||||
"addDevice": "Добавить устройство",
|
||||
"addTitle": "Добавить устройство аутентификации",
|
||||
"addScanInstructions": "Отсканируйте этот QR-код с помощью дополнительного аутентификатора. Существующие аутентификаторы продолжат работать.",
|
||||
"addSuccess": "Зарегистрирован дополнительный аутентификатор.",
|
||||
"addFailed": "Не удалось добавить аутентификатор.",
|
||||
"setupTitle": "Настроить TOTP",
|
||||
"setupFailed": "Не удалось запустить настройку TOTP",
|
||||
"qrCode": "QR-код",
|
||||
"secretCopied": "Секрет скопирован в буфер обмена",
|
||||
"continueVerify": "Продолжить проверку",
|
||||
"verifyTitle": "Проверка кода",
|
||||
"verify": "Проверить",
|
||||
"enter6Digits": "Введите 6-значный код",
|
||||
"enabledSuccess": "Двухфакторная аутентификация включена",
|
||||
"invalidCode": "Неверный код, попробуйте снова",
|
||||
"disableTitle": "Отключить 2FA",
|
||||
"disabledSuccess": "Двухфакторная аутентификация отключена",
|
||||
"disableFailed": "Не удалось отключить 2FA",
|
||||
"backupTitle": "Резервные коды",
|
||||
"downloadBackup": "Скачать резервные коды",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Унесите верификациони код",
|
||||
"verify": "Верификујте код"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP аутентификатор",
|
||||
"on": "2FA је омогућена",
|
||||
"off": "Додајте додатну безбедност пријављивања",
|
||||
"enable": "Омогући",
|
||||
"disable": "Онемогући",
|
||||
"addDevice": "Додај уређај",
|
||||
"addTitle": "Додај уређај за аутентификовање",
|
||||
"addScanInstructions": "Скенирајте овај QR код додатним аутентификатором. Постојећи аутентификатори ће наставити да раде.",
|
||||
"addSuccess": "Додатни аутентификатор је регистрован",
|
||||
"addFailed": "Додавање аутентификатора није успело",
|
||||
"setupTitle": "Подешавање TOTP-а",
|
||||
"setupFailed": "Покретање подешавања TOTP-а није успело",
|
||||
"qrCode": "QR код",
|
||||
"secretCopied": "Тајни токен је копиран у међуспремник",
|
||||
"continueVerify": "Настави са верификацијом",
|
||||
"verifyTitle": "Верификујте код",
|
||||
"verify": "Верификуј",
|
||||
"enter6Digits": "Унесите шестоцифрени код",
|
||||
"enabledSuccess": "Омогућена је двофакторска аутентификација",
|
||||
"invalidCode": "Неважећи код, покушајте поново",
|
||||
"disableTitle": "Онемогући 2FA",
|
||||
"disabledSuccess": "Двофакторска аутентификација је онемогућена",
|
||||
"disableFailed": "Онемогућавање 2FA није успело",
|
||||
"backupTitle": "Резервни кодови",
|
||||
"downloadBackup": "Преузмите резервне кодове",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Ange verifieringskod",
|
||||
"verify": "Verifiera kod"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-autentiserare",
|
||||
"on": "2FA är aktiverat",
|
||||
"off": "Lägg till extra inloggningssäkerhet",
|
||||
"enable": "Aktivera",
|
||||
"disable": "Inaktivera",
|
||||
"addDevice": "Lägg till enhet",
|
||||
"addTitle": "Lägg till autentiseringsenhet",
|
||||
"addScanInstructions": "Skanna den här QR-koden med den extra autentiseringsfunktionen. Befintliga autentiseringsfunktioner kommer att fortsätta fungera.",
|
||||
"addSuccess": "Ytterligare autentiserare registrerad",
|
||||
"addFailed": "Misslyckades med att lägga till autentiserare",
|
||||
"setupTitle": "Konfigurera TOTP",
|
||||
"setupFailed": "Misslyckades med att starta TOTP-installationen",
|
||||
"qrCode": "QR-kod",
|
||||
"secretCopied": "Hemlighet kopierad till urklipp",
|
||||
"continueVerify": "Fortsätt att verifiera",
|
||||
"verifyTitle": "Verifiera kod",
|
||||
"verify": "Kontrollera",
|
||||
"enter6Digits": "Ange en 6-siffrig kod",
|
||||
"enabledSuccess": "Tvåfaktorsautentisering aktiverad",
|
||||
"invalidCode": "Ogiltig kod, försök igen",
|
||||
"disableTitle": "Inaktivera 2FA",
|
||||
"disabledSuccess": "Tvåfaktorsautentisering inaktiverad",
|
||||
"disableFailed": "Misslyckades med att inaktivera 2FA",
|
||||
"backupTitle": "Reservkoder",
|
||||
"downloadBackup": "Ladda ner reservkoder",
|
||||
"done": "Gjort"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "ป้อนรหัสยืนยัน",
|
||||
"verify": "ยืนยันรหัส"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "ตัวตรวจสอบสิทธิ์ TOTP",
|
||||
"on": "เปิดใช้งาน 2FA แล้ว",
|
||||
"off": "เพิ่มระบบรักษาความปลอดภัยในการเข้าสู่ระบบเพิ่มเติม",
|
||||
"enable": "เปิดใช้งาน",
|
||||
"disable": "ปิดใช้งาน",
|
||||
"addDevice": "เพิ่มอุปกรณ์",
|
||||
"addTitle": "เพิ่มอุปกรณ์ยืนยันตัวตน",
|
||||
"addScanInstructions": "สแกนคิวอาร์โค้ดนี้ด้วยอุปกรณ์ยืนยันตัวตนเพิ่มเติม อุปกรณ์ยืนยันตัวตนที่มีอยู่เดิมยังคงใช้งานได้ต่อไป",
|
||||
"addSuccess": "ลงทะเบียนตัวยืนยันตัวตนเพิ่มเติมแล้ว",
|
||||
"addFailed": "ไม่สามารถเพิ่มตัวตรวจสอบสิทธิ์ได้",
|
||||
"setupTitle": "ตั้งค่า TOTP",
|
||||
"setupFailed": "ไม่สามารถเริ่มการตั้งค่า TOTP ได้",
|
||||
"qrCode": "รหัส QR",
|
||||
"secretCopied": "คัดลอกข้อมูลลับไปยังคลิปบอร์ดแล้ว",
|
||||
"continueVerify": "ดำเนินการตรวจสอบต่อไป",
|
||||
"verifyTitle": "ยืนยันรหัส",
|
||||
"verify": "ตรวจสอบ",
|
||||
"enter6Digits": "ป้อนรหัส 6 หลัก",
|
||||
"enabledSuccess": "เปิดใช้งานการตรวจสอบสิทธิ์แบบสองขั้นตอน",
|
||||
"invalidCode": "รหัสไม่ถูกต้อง โปรดลองอีกครั้ง",
|
||||
"disableTitle": "ปิดใช้งาน 2FA",
|
||||
"disabledSuccess": "ปิดใช้งานการตรวจสอบสิทธิ์แบบสองขั้นตอน",
|
||||
"disableFailed": "ไม่สามารถปิดใช้งาน 2FA ได้",
|
||||
"backupTitle": "รหัสสำรอง",
|
||||
"downloadBackup": "ดาวน์โหลดรหัสสำรอง",
|
||||
"done": "เสร็จแล้ว"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Doğrulama kodunu girin",
|
||||
"verify": "Kodu Doğrula"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Kimlik Doğrulayıcı",
|
||||
"on": "2FA etkin",
|
||||
"off": "Ek giriş güvenliği ekleyin",
|
||||
"enable": "Etkinleştir",
|
||||
"disable": "Devre Dışı Bırak",
|
||||
"addDevice": "Cihaz ekle",
|
||||
"addTitle": "Kimlik doğrulama cihazı ekle",
|
||||
"addScanInstructions": "Bu QR kodunu ek kimlik doğrulayıcı ile tarayın. Mevcut kimlik doğrulayıcılar çalışmaya devam edecektir.",
|
||||
"addSuccess": "Ek kimlik doğrulayıcı kaydedildi.",
|
||||
"addFailed": "Kimlik doğrulayıcı eklenemedi.",
|
||||
"setupTitle": "TOTP Kurulumu",
|
||||
"setupFailed": "TOTP kurulumu başlatılamadı",
|
||||
"qrCode": "QR Kodu",
|
||||
"secretCopied": "Gizli anahtar panoya kopyalandı",
|
||||
"continueVerify": "Doğrulamaya Devam Et",
|
||||
"verifyTitle": "Kodu Doğrula",
|
||||
"verify": "Doğrula",
|
||||
"enter6Digits": "6 haneli bir kod girin",
|
||||
"enabledSuccess": "İki faktörlü kimlik doğrulama etkinleştirildi",
|
||||
"invalidCode": "Geçersiz kod, lütfen tekrar deneyin",
|
||||
"disableTitle": "2FA'yı Devre Dışı Bırak",
|
||||
"disabledSuccess": "İki faktörlü kimlik doğrulama devre dışı bırakıldı",
|
||||
"disableFailed": "2FA devre dışı bırakılamadı",
|
||||
"backupTitle": "Yedek Kodlar",
|
||||
"downloadBackup": "Yedek Kodları İndir",
|
||||
"done": "Tamam"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Введіть код підтвердження",
|
||||
"verify": "Підтвердити код"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Аутентифікатор TOTP",
|
||||
"on": "Двофакторна аутентифікація (2FA) увімкнена",
|
||||
"off": "Додайте додаткову безпеку входу",
|
||||
"enable": "Увімкнути",
|
||||
"disable": "Вимкнути",
|
||||
"addDevice": "Додати пристрій",
|
||||
"addTitle": "Додати пристрій автентифікатора",
|
||||
"addScanInstructions": "Відскануйте цей QR-код за допомогою додаткового автентифікатора. Існуючі автентифікатори продовжуватимуть працювати.",
|
||||
"addSuccess": "Зареєстровано додатковий автентифікатор",
|
||||
"addFailed": "Не вдалося додати автентифікатор",
|
||||
"setupTitle": "Налаштування TOTP",
|
||||
"setupFailed": "Не вдалося розпочати налаштування TOTP",
|
||||
"qrCode": "QR-код",
|
||||
"secretCopied": "Секрет скопійовано в буфер обміну",
|
||||
"continueVerify": "Продовжити перевірку",
|
||||
"verifyTitle": "Підтвердити код",
|
||||
"verify": "Перевірити",
|
||||
"enter6Digits": "Введіть 6-значний код",
|
||||
"enabledSuccess": "Двофакторна автентифікація ввімкнена",
|
||||
"invalidCode": "Недійсний код, спробуйте ще раз",
|
||||
"disableTitle": "Вимкнути 2FA",
|
||||
"disabledSuccess": "Двофакторну автентифікацію вимкнено",
|
||||
"disableFailed": "Не вдалося вимкнути 2FA",
|
||||
"backupTitle": "Резервні коди",
|
||||
"downloadBackup": "Завантажити резервні коди",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "Nhập mã xác minh",
|
||||
"verify": "Xác minh mã"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Trình xác thực TOTP",
|
||||
"on": "2FA đã được bật",
|
||||
"off": "Thêm bảo mật đăng nhập bổ sung",
|
||||
"enable": "Bật",
|
||||
"disable": "Tắt",
|
||||
"addDevice": "Thêm thiết bị",
|
||||
"addTitle": "Thêm thiết bị xác thực",
|
||||
"addScanInstructions": "Quét mã QR này bằng thiết bị xác thực bổ sung. Các thiết bị xác thực hiện có vẫn sẽ hoạt động bình thường.",
|
||||
"addSuccess": "Đã đăng ký thêm trình xác thực.",
|
||||
"addFailed": "Thêm trình xác thực không thành công",
|
||||
"setupTitle": "Thiết lập TOTP",
|
||||
"setupFailed": "Không khởi động được cài đặt TOTP",
|
||||
"qrCode": "Mã QR",
|
||||
"secretCopied": "Đã sao chép mã bí mật vào clipboard",
|
||||
"continueVerify": "Tiếp tục xác minh",
|
||||
"verifyTitle": "Xác minh mã",
|
||||
"verify": "Xác minh",
|
||||
"enter6Digits": "Nhập mã 6 chữ số",
|
||||
"enabledSuccess": "Đã bật xác thực hai yếu tố",
|
||||
"invalidCode": "Mã không hợp lệ, vui lòng thử lại",
|
||||
"disableTitle": "Tắt 2FA",
|
||||
"disabledSuccess": "Đã tắt xác thực hai yếu tố",
|
||||
"disableFailed": "Không tắt được 2FA",
|
||||
"backupTitle": "Mã dự phòng",
|
||||
"downloadBackup": "Tải mã dự phòng",
|
||||
"done": "Xong"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "输入验证码",
|
||||
"verify": "验证"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 身份验证器",
|
||||
"on": "双因素认证已启用",
|
||||
"off": "添加额外的登录安全",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"addDevice": "添加设备",
|
||||
"addTitle": "添加身份验证器设备",
|
||||
"addScanInstructions": "请使用新增的验证器扫描此二维码。现有验证器将继续有效。",
|
||||
"addSuccess": "已注册其他身份验证器",
|
||||
"addFailed": "添加身份验证器失败",
|
||||
"setupTitle": "设置 TOTP",
|
||||
"setupFailed": "启动 TOTP 设置失败",
|
||||
"qrCode": "二维码",
|
||||
"secretCopied": "密钥已复制到剪贴板",
|
||||
"continueVerify": "继续验证",
|
||||
"verifyTitle": "验证验证码",
|
||||
"verify": "验证",
|
||||
"enter6Digits": "输入 6 位验证码",
|
||||
"enabledSuccess": "双因素认证已启用",
|
||||
"invalidCode": "验证码无效,请重试",
|
||||
"disableTitle": "禁用 2FA",
|
||||
"disabledSuccess": "双因素认证已禁用",
|
||||
"disableFailed": "禁用 2FA 失败",
|
||||
"backupTitle": "备份验证码",
|
||||
"downloadBackup": "下载备份验证码",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"challenge": {
|
||||
"enterCode": "輸入驗證碼",
|
||||
"verify": "驗證代碼"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 驗證器",
|
||||
"on": "雙因素驗證已啟用",
|
||||
"off": "新增額外的登入安全性",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"addDevice": "新增設備",
|
||||
"addTitle": "新增身份驗證器設備",
|
||||
"addScanInstructions": "請使用新增的驗證器掃描此二維碼。現有驗證器將繼續有效。",
|
||||
"addSuccess": "已註冊其他身份驗證器",
|
||||
"addFailed": "新增身份驗證器失敗",
|
||||
"setupTitle": "設定 TOTP",
|
||||
"setupFailed": "無法啟動 TOTP 設定",
|
||||
"qrCode": "QR 碼",
|
||||
"secretCopied": "密鑰已複製到剪貼簿",
|
||||
"continueVerify": "繼續驗證",
|
||||
"verifyTitle": "驗證碼",
|
||||
"verify": "驗證",
|
||||
"enter6Digits": "輸入 6 位數驗證碼",
|
||||
"enabledSuccess": "雙因素驗證已啟用",
|
||||
"invalidCode": "驗證碼無效,請再試一次",
|
||||
"disableTitle": "停用雙因素驗證",
|
||||
"disabledSuccess": "雙因素驗證已停用",
|
||||
"disableFailed": "無法停用雙因素驗證",
|
||||
"backupTitle": "備用驗證碼",
|
||||
"downloadBackup": "下載備用驗證碼",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "totp",
|
||||
"name": "TOTP",
|
||||
"version": "1.0.0",
|
||||
"description": "Ask for a code from an authenticator app after signing in, with backup codes for when the phone is not at hand.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "ShieldCheck",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"secondFactors": ["totp"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_totp_enrollments` (
|
||||
`user_id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`secret` text,
|
||||
`pending_secret` text,
|
||||
`backup_codes` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_totp_enrollments" (
|
||||
"user_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"secret" text,
|
||||
"pending_secret" text,
|
||||
"backup_codes" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"enrollments": {
|
||||
"columns": {
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true,
|
||||
"primaryKey": true
|
||||
},
|
||||
"secret": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"pendingSecret": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"backupCodes": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_totp_enrollments" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"secret" text,
|
||||
"pending_secret" text,
|
||||
"backup_codes" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@termix-plugin/totp",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/speakeasy": "^2.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { enrollments } from "./tables.js";
|
||||
import { createEnrollmentRepository } from "./repository.js";
|
||||
import { createTotpService, FACTOR_ID, normalizeCode } from "./totp.js";
|
||||
import { registerTotpRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(enrollments);
|
||||
const totp = createTotpService(
|
||||
ctx,
|
||||
createEnrollmentRepository(ctx.db, table),
|
||||
);
|
||||
|
||||
ctx.auth.registerSecondFactor({
|
||||
id: FACTOR_ID,
|
||||
labelKey: "factor",
|
||||
isEnrolled: (userId) => totp.isEnrolled(userId),
|
||||
verify: async (userId, body) => {
|
||||
const result = await totp.checkCode(
|
||||
userId,
|
||||
normalizeCode(body.totp_code ?? body.code),
|
||||
);
|
||||
if (result === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Your authenticator could not be read. Contact an admin to reset your second factor.",
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
reset: (userId) => totp.remove(userId),
|
||||
});
|
||||
|
||||
registerTotpRoutes(ctx.http.router<Router>(), ctx, totp);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface EnrollmentRecord {
|
||||
userId: string;
|
||||
secret: string | null;
|
||||
pendingSecret: string | null;
|
||||
backupCodes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type EnrollmentRepository = ReturnType<
|
||||
typeof createEnrollmentRepository
|
||||
>;
|
||||
|
||||
/** Sealed values only: nothing here ever sees a plaintext secret. */
|
||||
export function createEnrollmentRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function find(userId: string): Promise<EnrollmentRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.limit(1);
|
||||
return (rows[0] as EnrollmentRecord) ?? null;
|
||||
}
|
||||
|
||||
async function save(
|
||||
userId: string,
|
||||
values: Partial<
|
||||
Pick<EnrollmentRecord, "secret" | "pendingSecret" | "backupCodes">
|
||||
>,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
if (await find(userId)) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...values, updatedAt: now })
|
||||
.where(eq(table.userId, userId));
|
||||
} else {
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, ...values, createdAt: now, updatedAt: now });
|
||||
}
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
find,
|
||||
save,
|
||||
|
||||
async remove(userId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.userId, userId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { FACTOR_ID, normalizeCode, type TotpService } from "./totp.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function registerTotpRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
totp: TotpService,
|
||||
): void {
|
||||
const actor = () => ctx.currentActor()!;
|
||||
|
||||
async function accountLabel(userId: string): Promise<string> {
|
||||
try {
|
||||
const { users } = await ctx.db.refs<{ users: Table }>();
|
||||
const drizzle = await ctx.db.client<Drizzle>();
|
||||
const rows = await drizzle
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
if (rows[0]?.username) return `Termix (${rows[0].username})`;
|
||||
} catch {
|
||||
// The label is cosmetic.
|
||||
}
|
||||
return "Termix";
|
||||
}
|
||||
|
||||
const fail = (res: Response, message: string, error: unknown) => {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/status:
|
||||
* get:
|
||||
* summary: TOTP status for the caller
|
||||
* tags:
|
||||
* - TOTP
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether the caller has TOTP on.
|
||||
*/
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ enabled: await totp.isEnrolled(actor()) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to read TOTP status", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/setup:
|
||||
* post:
|
||||
* summary: Start TOTP setup
|
||||
* description: Returns a new secret and QR code. With TOTP already on, a current code or backup code in `credential` returns the existing secret instead, to add another authenticator.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* credential:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Secret and QR code.
|
||||
* 400:
|
||||
* description: A code is required.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/setup", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
try {
|
||||
const label = await accountLabel(userId);
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
const credential = normalizeCode(req.body?.credential);
|
||||
if (!credential) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
if (!(await totp.checkCode(userId, credential))) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
const secret = await totp.activeSecret(userId);
|
||||
if (!secret) {
|
||||
return res.status(409).json({ error: "TOTP secret is unavailable" });
|
||||
}
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret,
|
||||
label,
|
||||
encoding: "base32",
|
||||
});
|
||||
return res.json({
|
||||
secret,
|
||||
qr_code: await QRCode.toDataURL(url),
|
||||
additional: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { secret, otpauthUrl } = await totp.startSetup(userId, label);
|
||||
res.json({ secret, qr_code: await QRCode.toDataURL(otpauthUrl) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to setup TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/enable:
|
||||
* post:
|
||||
* summary: Turn TOTP on
|
||||
* description: Checks a code from the secret /setup returned, turns TOTP on and returns backup codes. Signs the caller out everywhere else.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP on, with backup codes.
|
||||
* 400:
|
||||
* description: Missing code, already on, or setup not started.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
* 409:
|
||||
* description: Core does not allow second factors right now.
|
||||
*/
|
||||
router.post("/enable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) return res.status(400).json({ error: "TOTP code is required" });
|
||||
try {
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
const pending = await totp.pendingSecretFor(userId, code);
|
||||
if (pending.status === "not-started") {
|
||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||
}
|
||||
if (pending.status === "invalid") {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
// Core's policy first, so a refusal leaves nothing half enabled.
|
||||
await ctx.auth.recordEnrollment(userId, FACTOR_ID);
|
||||
const backupCodes = await totp.activate(userId, pending.secret);
|
||||
res.json({
|
||||
message: "TOTP enabled successfully",
|
||||
backup_codes: backupCodes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
fail(res, "Failed to enable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/disable:
|
||||
* post:
|
||||
* summary: Turn TOTP off
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP off.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/disable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
await totp.remove(userId);
|
||||
await ctx.auth.removeEnrollment(userId, FACTOR_ID);
|
||||
ctx.log.info(`Two-factor authentication disabled for ${userId}`);
|
||||
res.json({ message: "TOTP disabled successfully" });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to disable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/backup-codes:
|
||||
* post:
|
||||
* summary: Replace the backup codes
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New backup codes.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/backup-codes", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
res.json({ backup_codes: await totp.replaceBackupCodes(userId) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to generate backup codes", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
refUser,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One row per user who started or finished setup. The secret columns hold
|
||||
* values sealed with ctx.secrets.seal, so they can be read during login
|
||||
* without an acting user. A non-null secret means the user is enrolled.
|
||||
*/
|
||||
export const enrollments = defineTable("enrollments", {
|
||||
userId: refUser().primaryKey(),
|
||||
secret: encryptedText(),
|
||||
// Set by /setup, promoted to secret once a code from it verifies.
|
||||
pendingSecret: encryptedText(),
|
||||
// A sealed JSON array of the unused backup codes.
|
||||
backupCodes: encryptedText(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const tables = [enrollments];
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from "node:crypto";
|
||||
import speakeasy from "speakeasy";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { EnrollmentRepository } from "./repository.js";
|
||||
|
||||
export const FACTOR_ID = "totp";
|
||||
|
||||
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const BACKUP_CODE_COUNT = 8;
|
||||
|
||||
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
|
||||
export function generateBackupCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function generateBackupCodes(): string[] {
|
||||
return Array.from({ length: BACKUP_CODE_COUNT }, () => generateBackupCode());
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
return speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
window: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCode(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
}
|
||||
|
||||
export type TotpService = ReturnType<typeof createTotpService>;
|
||||
|
||||
/**
|
||||
* Reads and checks a user's enrolment. Secrets are sealed with the
|
||||
* installation key, so this works during login with no acting user.
|
||||
*/
|
||||
export function createTotpService(
|
||||
ctx: PluginContext,
|
||||
repository: EnrollmentRepository,
|
||||
) {
|
||||
const unseal = (value: string | null) =>
|
||||
value ? ctx.secrets.unseal(value) : Promise.resolve(null);
|
||||
|
||||
async function readBackupCodes(sealed: string | null): Promise<string[]> {
|
||||
const raw = await unseal(sealed);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const codes = JSON.parse(raw);
|
||||
return Array.isArray(codes) ? codes : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function sealBackupCodes(codes: string[]): Promise<string> {
|
||||
return ctx.secrets.seal(JSON.stringify(codes));
|
||||
}
|
||||
|
||||
return {
|
||||
async isEnrolled(userId: string): Promise<boolean> {
|
||||
return !!(await repository.find(userId))?.secret;
|
||||
},
|
||||
|
||||
/** The active secret, or null when not enrolled or unreadable. */
|
||||
async activeSecret(userId: string): Promise<string | null> {
|
||||
return unseal((await repository.find(userId))?.secret ?? null);
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a current code from the authenticator or an unused backup
|
||||
* code, which is burnt. Null when the user has no readable secret.
|
||||
*/
|
||||
async checkCode(userId: string, code: string): Promise<boolean | null> {
|
||||
const row = await repository.find(userId);
|
||||
const secret = await unseal(row?.secret ?? null);
|
||||
if (!row || !secret) return null;
|
||||
if (!code) return false;
|
||||
if (verifyTotpCode(secret, code)) return true;
|
||||
|
||||
const codes = await readBackupCodes(row.backupCodes);
|
||||
const index = codes.indexOf(code);
|
||||
if (index === -1) return false;
|
||||
codes.splice(index, 1);
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Starts setup with a new secret, replacing any unfinished one. */
|
||||
async startSetup(userId: string, label: string) {
|
||||
const secret = speakeasy.generateSecret({ name: label, length: 32 });
|
||||
await repository.save(userId, {
|
||||
pendingSecret: await ctx.secrets.seal(secret.base32),
|
||||
});
|
||||
return { secret: secret.base32, otpauthUrl: secret.otpauth_url ?? "" };
|
||||
},
|
||||
|
||||
/** The unfinished setup's secret, when a code from it verifies. */
|
||||
async pendingSecretFor(userId: string, code: string) {
|
||||
const pending = await unseal(
|
||||
(await repository.find(userId))?.pendingSecret ?? null,
|
||||
);
|
||||
if (!pending) return { status: "not-started" as const };
|
||||
if (!verifyTotpCode(pending, code)) return { status: "invalid" as const };
|
||||
return { status: "ok" as const, secret: pending };
|
||||
},
|
||||
|
||||
/** Makes the verified pending secret active and issues backup codes. */
|
||||
async activate(userId: string, secret: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
secret: await ctx.secrets.seal(secret),
|
||||
pendingSecret: null,
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
async replaceBackupCodes(userId: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
remove: (userId: string) => repository.remove(userId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { isValidTotpInput, normalizeTotpInput } from "./totp-input";
|
||||
|
||||
const primaryButton =
|
||||
"w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold";
|
||||
|
||||
export function TotpChallenge({
|
||||
disabled,
|
||||
verify,
|
||||
cancel,
|
||||
}: SecondFactorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isValidTotpInput(code)) {
|
||||
toast.error(t("challenge.enterCode"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await verify({ totp_code: code });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("challenge.verify")}
|
||||
</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
value={code}
|
||||
onChange={(e) => setCode(normalizeTotpInput(e.target.value))}
|
||||
disabled={disabled || loading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("challenge.backupCodeHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading || !isValidTotpInput(code)}
|
||||
>
|
||||
{loading ? t("common.loading") : t("challenge.verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={cancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Copy, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { apiErrorMessage } from "./totp-input";
|
||||
|
||||
type Step = "idle" | "setup" | "verify" | "backup";
|
||||
|
||||
interface SetupResponse {
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
additional?: boolean;
|
||||
}
|
||||
|
||||
const accentButton =
|
||||
"text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand";
|
||||
const destructiveButton =
|
||||
"text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||||
const box = "border border-border bg-muted/20 p-3 flex flex-col gap-3";
|
||||
const boxTitle =
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
/** Settings > Security: turn TOTP on and off, add devices, backup codes. */
|
||||
export function TotpEnrollment() {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
const [disableInput, setDisableInput] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addInput, setAddInput] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ enabled: boolean }>("status")
|
||||
.then(({ data }) => setEnabled(!!data.enabled))
|
||||
.catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
async function startSetup() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setCode("");
|
||||
setStep("setup");
|
||||
} catch {
|
||||
toast.error(t("enrollment.setupFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAuthenticator() {
|
||||
if (!addInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {
|
||||
credential: addInput,
|
||||
});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setAdding(true);
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
setStep("setup");
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.addFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (code.length !== 6) {
|
||||
toast.error(t("enrollment.enter6Digits"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<{ backup_codes?: string[] }>("enable", {
|
||||
totp_code: code,
|
||||
});
|
||||
setBackupCodes(data.backup_codes ?? []);
|
||||
setEnabled(true);
|
||||
setStep("backup");
|
||||
toast.success(t("enrollment.enabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.invalidCode")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
if (!disableInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("disable", { totp_code: disableInput });
|
||||
setEnabled(false);
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
toast.success(t("enrollment.disabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.disableFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const blob = new Blob([backupCodes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-backup-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{t("enrollment.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/totp"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("enrollment.docs")}
|
||||
</a>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{enabled ? t("enrollment.on") : t("enrollment.off")}
|
||||
</span>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<div className="ml-3 flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${accentButton}`}
|
||||
onClick={() => setShowAdd((open) => !open)}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.addDevice")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${destructiveButton}`}
|
||||
onClick={() => setShowDisable((open) => !open)}
|
||||
>
|
||||
{t("enrollment.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`shrink-0 ml-3 h-7 ${accentButton}`}
|
||||
onClick={startSetup}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.enable")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabled && showAdd && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.addTitle")}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("enrollment.addDescription")}
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={addInput}
|
||||
onChange={(e) => setAddInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAuthenticator()}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={addAuthenticator}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && showDisable && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.disableTitle")}</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={disableInput}
|
||||
onChange={(e) => setDisableInput(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${destructiveButton}`}
|
||||
onClick={disable}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("enrollment.disableTitle")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "setup" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.setupTitle")}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep("idle");
|
||||
setAdding(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
||||
{qrCode ? (
|
||||
<img
|
||||
src={qrCode}
|
||||
alt={t("enrollment.qrCode")}
|
||||
className="size-32"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-24 bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
|
||||
{t("enrollment.qrCode")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
||||
<span className="text-[10px] font-mono flex-1 tracking-widest select-all truncate">
|
||||
{secret}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(secret);
|
||||
toast.info(t("enrollment.secretCopied"));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground text-center">
|
||||
{t(
|
||||
adding
|
||||
? "enrollment.addScanInstructions"
|
||||
: "enrollment.instructions",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={() => {
|
||||
if (adding) {
|
||||
setAdding(false);
|
||||
setStep("idle");
|
||||
toast.success(t("enrollment.addSuccess"));
|
||||
} else {
|
||||
setStep("verify");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(adding ? "enrollment.done" : "enrollment.continueVerify")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && step === "verify" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.verifyTitle")}</span>
|
||||
<button
|
||||
onClick={() => setStep("setup")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="000000"
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && verify()}
|
||||
className="text-center font-mono tracking-widest text-lg h-10"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setStep("setup")}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={verify}
|
||||
disabled={loading || code.length !== 6}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{t("enrollment.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "backup" && backupCodes.length > 0 && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.backupTitle")}</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{backupCodes.map((backupCode) => (
|
||||
<span
|
||||
key={backupCode}
|
||||
className="text-[10px] font-mono bg-muted border border-border px-2 py-1 text-center"
|
||||
>
|
||||
{backupCode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
{t("enrollment.downloadBackup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setStep("idle")}
|
||||
>
|
||||
{t("enrollment.done")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { TotpChallenge } from "./TotpChallenge";
|
||||
import { TotpEnrollment } from "./TotpEnrollment";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSecondFactorUI({
|
||||
id: "totp",
|
||||
titleKey: "title",
|
||||
component: TotpChallenge,
|
||||
enrollment: TotpEnrollment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Six-digit codes, or a backup code of up to eight letters and digits. */
|
||||
export function normalizeTotpInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
export function isValidTotpInput(value: string): boolean {
|
||||
return /^[A-Z0-9]{6,8}$/.test(value);
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
return typeof data?.error === "string" && data.error ? data.error : fallback;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
users?: string[];
|
||||
capabilities?: string[];
|
||||
refuseEnrollment?: string;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
refuseEnrollment: options.refuseEnrollment,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import speakeasy from "speakeasy";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
import { generateBackupCode, normalizeCode } from "../../src/backend/totp.js";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const codeFor = (secret: string) =>
|
||||
speakeasy.totp({ secret, encoding: "base32" });
|
||||
|
||||
async function enrol(target: TestServer, user = "user-1") {
|
||||
const setup = await target.request("POST", "/setup", { user, body: {} });
|
||||
expect(setup.status).toBe(200);
|
||||
const secret: string = setup.body.secret;
|
||||
const enable = await target.request("POST", "/enable", {
|
||||
user,
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, backupCodes: enable.body.backup_codes as string[] };
|
||||
}
|
||||
|
||||
function factor(target: TestServer) {
|
||||
const registered = target.mock.auth.secondFactors.find(
|
||||
(entry) => entry.id === "totp",
|
||||
);
|
||||
if (!registered) throw new Error("totp factor not registered");
|
||||
return registered;
|
||||
}
|
||||
|
||||
describe("enrolment", () => {
|
||||
it("sets up, enables, records the enrolment and reports status", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/status")).body).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const setup = await server.request("POST", "/setup", { body: {} });
|
||||
expect(setup.body.qr_code).toMatch(/^data:image\/png;base64,/);
|
||||
expect(setup.body.secret).toMatch(/^[A-Z2-7]+$/);
|
||||
|
||||
const wrong = await server.request("POST", "/enable", {
|
||||
body: { totp_code: "000000" },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
|
||||
const enable = await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(setup.body.secret) },
|
||||
});
|
||||
expect(enable.status).toBe(200);
|
||||
expect(enable.body.backup_codes).toHaveLength(8);
|
||||
expect(server.mock.auth.enrollments.has("user-1:totp")).toBe(true);
|
||||
expect((await server.request("GET", "/status")).body).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the secret sealed, never in the clear", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'user-1'")
|
||||
.get() as Record<string, string | null>;
|
||||
expect(row.secret).not.toContain(secret);
|
||||
expect(row.secret).toMatch(/^sealed:/);
|
||||
expect(row.pending_secret).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses enable before setup and when already on", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/enable", {
|
||||
body: { totp_code: "123456" },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
const { secret } = await enrol(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("leaves nothing enabled when core refuses the enrolment", async () => {
|
||||
server = await startServer({
|
||||
refuseEnrollment: "Cannot enable 2FA while password login is disabled.",
|
||||
});
|
||||
const setup = await server.request("POST", "/setup", { body: {} });
|
||||
const enable = await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(setup.body.secret) },
|
||||
});
|
||||
expect(enable.status).toBe(409);
|
||||
expect(enable.body.error).toMatch(/password login/);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds another authenticator only with a valid code", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect((await server.request("POST", "/setup", { body: {} })).status).toBe(
|
||||
400,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/setup", {
|
||||
body: { credential: "000000" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
const again = await server.request("POST", "/setup", {
|
||||
body: { credential: codeFor(secret) },
|
||||
});
|
||||
expect(again.body).toMatchObject({ secret, additional: true });
|
||||
});
|
||||
|
||||
it("disables with a code and removes the enrolment", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/disable", {
|
||||
body: { totp_code: "000000" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
const off = await server.request("POST", "/disable", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(off.status).toBe(200);
|
||||
expect(server.mock.auth.enrollments.has("user-1:totp")).toBe(false);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces backup codes", async () => {
|
||||
server = await startServer();
|
||||
const { secret, backupCodes } = await enrol(server);
|
||||
const fresh = await server.request("POST", "/backup-codes", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(fresh.body.backup_codes).toHaveLength(8);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: backupCodes[0] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await factor(server).verify("user-1", {
|
||||
totp_code: fresh.body.backup_codes[0],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps users apart", async () => {
|
||||
server = await startServer();
|
||||
await enrol(server, "user-1");
|
||||
expect(
|
||||
(await server.request("GET", "/status", { user: "user-2" })).body,
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("verifies a current code and refuses a wrong one", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect(await factor(server).verify("user-1", { totp_code: "000000" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: codeFor(secret) }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each backup code once, typed in any case", async () => {
|
||||
server = await startServer();
|
||||
const { backupCodes } = await enrol(server);
|
||||
const [first] = backupCodes;
|
||||
expect(
|
||||
await factor(server).verify("user-1", {
|
||||
totp_code: first.toLowerCase(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(await factor(server).verify("user-1", { totp_code: first })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: backupCodes[1] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses with a message when the user has no readable secret", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: "123456" }),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("reset removes the enrolment, as the admin reset calls it", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
await factor(server).reset!("user-1");
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: codeFor(secret) }),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("drops the enrolment with the user", async () => {
|
||||
server = await startServer();
|
||||
await enrol(server);
|
||||
server.db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("fails closed without auth:provide", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: ["db:own", "secrets:own", "network:serve", "ui:surface"],
|
||||
}),
|
||||
).rejects.toThrow(/auth:provide/);
|
||||
});
|
||||
|
||||
it("fails closed without db:own", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: [
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/db:own/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("makes backup codes from the unambiguous alphabet", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateBackupCode()).toMatch(/^[A-HJ-NP-Z2-9]{8}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("normalises codes", () => {
|
||||
expect(normalizeCode(" abcd1234 ")).toBe("ABCD1234");
|
||||
expect(normalizeCode(42)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import {
|
||||
isValidTotpInput,
|
||||
normalizeTotpInput,
|
||||
} from "../../src/frontend/totp-input";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the second factor with an enrolment section", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.secondFactors()).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("sends the typed code, backup codes included", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const verify = vi.fn(async () => {});
|
||||
rendered.renderSecondFactor("totp", { verify });
|
||||
fireEvent.change(document.getElementById("totp-code")!, {
|
||||
target: { value: "ab-cd 12 34" },
|
||||
});
|
||||
fireEvent.submit(document.getElementById("totp-code")!.closest("form")!);
|
||||
await waitFor(() =>
|
||||
expect(verify).toHaveBeenCalledWith({ totp_code: "ABCD1234" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the enrolment state from the plugin API", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { enabled: true } })),
|
||||
} as never,
|
||||
});
|
||||
rendered.renderEnrollment("totp");
|
||||
expect(await screen.findByText(locales.enrollment.on)).toBeTruthy();
|
||||
expect(screen.getByText(locales.enrollment.addDevice)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts setup and shows the QR code", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
data: { secret: "JBSWY3DPEHPK3PXP", qr_code: "data:image/png;base64,x" },
|
||||
}));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { enabled: false } })),
|
||||
post,
|
||||
} as never,
|
||||
});
|
||||
rendered.renderEnrollment("totp");
|
||||
fireEvent.click(await screen.findByText(locales.enrollment.enable));
|
||||
expect(await screen.findByText("JBSWY3DPEHPK3PXP")).toBeTruthy();
|
||||
expect(post).toHaveBeenCalledWith("setup", {});
|
||||
});
|
||||
|
||||
it("removes the factor on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.secondFactors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totp input", () => {
|
||||
it("normalises and validates codes", () => {
|
||||
expect(normalizeTotpInput("12 34-56")).toBe("123456");
|
||||
expect(normalizeTotpInput("abcd1234ef")).toBe("ABCD1234");
|
||||
expect(isValidTotpInput("123456")).toBe(true);
|
||||
expect(isValidTotpInput("ABCD1234")).toBe(true);
|
||||
expect(isValidTotpInput("12345")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Sign in with passkey",
|
||||
"loginFailed": "Passkey login failed",
|
||||
"description": "Use WebAuthn/FIDO2 credentials to sign in without a password",
|
||||
"namePlaceholder": "Passkey name",
|
||||
"defaultName": "Passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferred",
|
||||
"required": "Required",
|
||||
"discouraged": "Discouraged"
|
||||
},
|
||||
"add": "Add Passkey",
|
||||
"none": "No passkeys registered.",
|
||||
"added": "Passkey added",
|
||||
"addFailed": "Failed to add passkey",
|
||||
"delete": "Delete passkey",
|
||||
"deleted": "Passkey deleted",
|
||||
"deleteFailed": "Failed to delete passkey",
|
||||
"unknownDevice": "unknown",
|
||||
"synced": "synced"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Wagwoorde",
|
||||
"signIn": "Teken aan met wagwoord",
|
||||
"loginFailed": "Wagwoord-aanmelding het misluk",
|
||||
"description": "Gebruik WebAuthn/FIDO2-bewyse om aan te meld sonder 'n wagwoord",
|
||||
"namePlaceholder": "Wagwoordnaam",
|
||||
"uv": {
|
||||
"preferred": "Verkies",
|
||||
"required": "Vereis",
|
||||
"discouraged": "Ontmoedig"
|
||||
},
|
||||
"add": "Voeg wagwoord by",
|
||||
"none": "Geen wagwoorde geregistreer nie.",
|
||||
"added": "Wagwoord bygevoeg",
|
||||
"addFailed": "Kon nie wagwoord byvoeg nie",
|
||||
"deleted": "Wagwoord verwyder",
|
||||
"deleteFailed": "Kon nie wagwoord verwyder nie"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "مفاتيح المرور",
|
||||
"signIn": "تسجيل الدخول باستخدام مفتاح المرور",
|
||||
"loginFailed": "فشل تسجيل الدخول بمفتاح المرور",
|
||||
"description": "استخدم بيانات اعتماد WebAuthn/FIDO2 لتسجيل الدخول بدون كلمة مرور",
|
||||
"namePlaceholder": "اسم مفتاح المرور",
|
||||
"uv": {
|
||||
"preferred": "مفضل",
|
||||
"required": "مطلوب",
|
||||
"discouraged": "غير مشجع"
|
||||
},
|
||||
"add": "إضافة مفتاح مرور",
|
||||
"none": "لا توجد مفاتيح مرور مسجلة.",
|
||||
"added": "تمت إضافة مفتاح المرور",
|
||||
"addFailed": "فشل إضافة مفتاح المرور",
|
||||
"deleted": "تم حذف مفتاح المرور",
|
||||
"deleteFailed": "فشل حذف مفتاح المرور"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Пароли",
|
||||
"signIn": "Влезте с парола",
|
||||
"loginFailed": "Влизането с парола не бе успешно",
|
||||
"description": "Използвайте WebAuthn/FIDO2 идентификационни данни за влизане без парола",
|
||||
"namePlaceholder": "Име на паролата",
|
||||
"uv": {
|
||||
"preferred": "Предпочитан",
|
||||
"required": "Задължително",
|
||||
"discouraged": "Обезкуражен"
|
||||
},
|
||||
"add": "Добавяне на парола",
|
||||
"none": "Няма регистрирани пароли.",
|
||||
"added": "Добавен е ключ за достъп",
|
||||
"addFailed": "Добавянето на ключ за достъп не бе успешно",
|
||||
"deleted": "Ключът за достъп е изтрит",
|
||||
"deleteFailed": "Изтриването на ключа за достъп не бе успешно"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "পাসকি",
|
||||
"signIn": "পাসকি দিয়ে সাইন ইন করুন",
|
||||
"loginFailed": "পাসকি লগইন ব্যর্থ হয়েছে",
|
||||
"description": "পাসওয়ার্ড ছাড়া সাইন ইন করতে WebAuthn/FIDO2 ক্রেডেনশিয়াল ব্যবহার করুন।",
|
||||
"namePlaceholder": "পাসকি নাম",
|
||||
"uv": {
|
||||
"preferred": "পছন্দের",
|
||||
"required": "প্রয়োজনীয়",
|
||||
"discouraged": "হতাশ"
|
||||
},
|
||||
"add": "পাসকি যোগ করুন",
|
||||
"none": "কোনো পাসকি নিবন্ধিত নেই।",
|
||||
"added": "পাসকি যোগ করা হয়েছে",
|
||||
"addFailed": "পাসকি যোগ করতে ব্যর্থ হয়েছে",
|
||||
"deleted": "পাসকি মুছে ফেলা হয়েছে",
|
||||
"deleteFailed": "পাসকি মুছে ফেলতে ব্যর্থ হয়েছে"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Claus d'accés",
|
||||
"signIn": "Inicia la sessió amb la clau d'accés",
|
||||
"loginFailed": "Error d'inici de sessió amb contrasenya",
|
||||
"description": "Utilitzeu les credencials de WebAuthn/FIDO2 per iniciar la sessió sense contrasenya",
|
||||
"namePlaceholder": "Nom de la clau d'accés",
|
||||
"uv": {
|
||||
"preferred": "Preferit",
|
||||
"required": "Obligatori",
|
||||
"discouraged": "Desanimat"
|
||||
},
|
||||
"add": "Afegeix una contrasenya",
|
||||
"none": "No hi ha cap clau d'accés registrada.",
|
||||
"added": "Clau d'accés afegida",
|
||||
"addFailed": "No s'ha pogut afegir la contrasenya",
|
||||
"deleted": "Clau d'accés suprimida",
|
||||
"deleteFailed": "No s'ha pogut suprimir la clau d'accés"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Přístupové klíče",
|
||||
"signIn": "Přihlásit se pomocí hesla",
|
||||
"loginFailed": "Přihlášení k heslu se nezdařilo",
|
||||
"description": "Pro přihlášení bez hesla použijte přihlašovací údaje WebAuthn/FIDO2",
|
||||
"namePlaceholder": "Název přístupového klíče",
|
||||
"uv": {
|
||||
"preferred": "Preferované",
|
||||
"required": "Požadovaný",
|
||||
"discouraged": "Odradit"
|
||||
},
|
||||
"add": "Přidat přístupový klíč",
|
||||
"none": "Nejsou registrovány žádné přístupové klíče.",
|
||||
"added": "Přístupový klíč byl přidán",
|
||||
"addFailed": "Nepodařilo se přidat přístupový klíč",
|
||||
"deleted": "Přístupový klíč smazán",
|
||||
"deleteFailed": "Nepodařilo se smazat přístupový klíč"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Adgangsnøgler",
|
||||
"signIn": "Log ind med adgangsnøgle",
|
||||
"loginFailed": "Login med adgangsnøgle mislykkedes",
|
||||
"description": "Brug WebAuthn/FIDO2-legitimationsoplysninger til at logge ind uden en adgangskode",
|
||||
"namePlaceholder": "Adgangsnøglenavn",
|
||||
"uv": {
|
||||
"preferred": "Foretrukket",
|
||||
"required": "Påkrævet",
|
||||
"discouraged": "Modløs"
|
||||
},
|
||||
"add": "Tilføj adgangsnøgle",
|
||||
"none": "Ingen adgangsnøgler registreret.",
|
||||
"added": "Adgangsnøgle tilføjet",
|
||||
"addFailed": "Kunne ikke tilføje adgangsnøgle",
|
||||
"deleted": "Adgangsnøgle slettet",
|
||||
"deleteFailed": "Kunne ikke slette adgangsnøglen"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Mit Passkey anmelden",
|
||||
"loginFailed": "Passkey-Anmeldung fehlgeschlagen",
|
||||
"description": "WebAuthn/FIDO2-Anmeldedaten für eine Anmeldung ohne Passwort verwenden",
|
||||
"namePlaceholder": "Passkey-Name",
|
||||
"uv": {
|
||||
"preferred": "Bevorzugt",
|
||||
"required": "Erforderlich",
|
||||
"discouraged": "Nicht empfohlen"
|
||||
},
|
||||
"add": "Passkey hinzufügen",
|
||||
"none": "Keine Passkeys registriert.",
|
||||
"added": "Passkey hinzugefügt",
|
||||
"addFailed": "Fehler beim Hinzufügen des Passkeys",
|
||||
"deleted": "Passkey gelöscht",
|
||||
"deleteFailed": "Löschen des Passkeys fehlgeschlagen"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Κλειδιά πρόσβασης",
|
||||
"signIn": "Σύνδεση με κλειδί πρόσβασης",
|
||||
"loginFailed": "Η σύνδεση με κωδικό πρόσβασης απέτυχε",
|
||||
"description": "Χρησιμοποιήστε διαπιστευτήρια WebAuthn/FIDO2 για να συνδεθείτε χωρίς κωδικό πρόσβασης",
|
||||
"namePlaceholder": "Όνομα κλειδιού πρόσβασης",
|
||||
"uv": {
|
||||
"preferred": "Προνομιούχος",
|
||||
"required": "Υποχρεούμαι",
|
||||
"discouraged": "Αποθαρρυμένος"
|
||||
},
|
||||
"add": "Προσθήκη κλειδιού πρόσβασης",
|
||||
"none": "Δεν έχουν καταχωρηθεί κλειδιά πρόσβασης.",
|
||||
"added": "Προστέθηκε κλειδί πρόσβασης",
|
||||
"addFailed": "Αποτυχία προσθήκης κλειδιού πρόσβασης",
|
||||
"deleted": "Ο κωδικός πρόσβασης διαγράφηκε",
|
||||
"deleteFailed": "Η διαγραφή του κλειδιού πρόσβασης απέτυχε"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Iniciar sesión con clave de acceso",
|
||||
"loginFailed": "Error al iniciar sesión con clave de acceso",
|
||||
"description": "Usa credenciales WebAuthn/FIDO2 para iniciar sesión sin contraseña",
|
||||
"namePlaceholder": "Nombre de la passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferida",
|
||||
"required": "Requerida",
|
||||
"discouraged": "Desaconsejada"
|
||||
},
|
||||
"add": "Añadir passkey",
|
||||
"none": "No hay passkeys registradas.",
|
||||
"added": "Passkey añadida",
|
||||
"addFailed": "No se pudo añadir la passkey",
|
||||
"deleted": "Passkey eliminada",
|
||||
"deleteFailed": "Error al eliminar la clave de acceso"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Salasanat",
|
||||
"signIn": "Kirjaudu sisään salasanalla",
|
||||
"loginFailed": "Salasanan kirjautuminen epäonnistui",
|
||||
"description": "Kirjaudu sisään ilman salasanaa WebAuthn/FIDO2-tunnuksilla",
|
||||
"namePlaceholder": "Salasanan nimi",
|
||||
"uv": {
|
||||
"preferred": "Ensisijainen",
|
||||
"required": "Pakollinen",
|
||||
"discouraged": "Lannistunut"
|
||||
},
|
||||
"add": "Lisää salasana",
|
||||
"none": "Ei rekisteröityjä salasanoja.",
|
||||
"added": "Salasana lisätty",
|
||||
"addFailed": "Salasanan lisääminen epäonnistui",
|
||||
"deleted": "Salasana poistettu",
|
||||
"deleteFailed": "Salasanan poistaminen epäonnistui"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Clés d’accès",
|
||||
"signIn": "Se connecter avec une clé d'accès",
|
||||
"loginFailed": "Échec de la connexion par clé d'accès",
|
||||
"description": "Utilisez des identifiants WebAuthn/FIDO2 pour vous connecter sans mot de passe",
|
||||
"namePlaceholder": "Nom de la clé d’accès",
|
||||
"uv": {
|
||||
"preferred": "Préféré",
|
||||
"required": "Requis",
|
||||
"discouraged": "Déconseillé"
|
||||
},
|
||||
"add": "Ajouter une clé d’accès",
|
||||
"none": "Aucune clé d’accès enregistrée.",
|
||||
"added": "Clé d’accès ajoutée",
|
||||
"addFailed": "Échec de l’ajout de la clé d’accès",
|
||||
"deleted": "Clé d’accès supprimée",
|
||||
"deleteFailed": "Échec de la suppression de la clé d'accès"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "מפתחות סיסמה",
|
||||
"signIn": "כניסה עם סיסמה",
|
||||
"loginFailed": "כניסה עם סיסמה נכשלה",
|
||||
"description": "השתמש בפרטי הכניסה של WebAuthn/FIDO2 ללא סיסמה",
|
||||
"namePlaceholder": "שם המפתח",
|
||||
"uv": {
|
||||
"preferred": "מועדף",
|
||||
"required": "דָרוּשׁ",
|
||||
"discouraged": "מיואש"
|
||||
},
|
||||
"add": "הוסף סיסמה",
|
||||
"none": "אין סיסמות רשומות.",
|
||||
"added": "סיסמה נוספת",
|
||||
"addFailed": "נכשלה הוספת סיסמה",
|
||||
"deleted": "סיסמת הגישה נמחקה",
|
||||
"deleteFailed": "מחיקת המפתח נכשלה"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "पासकीज़",
|
||||
"signIn": "पासकी से साइन इन करें",
|
||||
"loginFailed": "पासकी लॉगिन विफल",
|
||||
"description": "बिना पासवर्ड साइन इन करने के लिए WebAuthn/FIDO2 क्रेडेंशियल का उपयोग करें",
|
||||
"namePlaceholder": "पासकी नाम",
|
||||
"uv": {
|
||||
"preferred": "पसंदीदा",
|
||||
"required": "आवश्यक",
|
||||
"discouraged": "हतोत्साहित"
|
||||
},
|
||||
"add": "पासकी जोड़ें",
|
||||
"none": "कोई पासकी पंजीकृत नहीं है।",
|
||||
"added": "पासकी जोड़ी गई",
|
||||
"addFailed": "पासकी जोड़ने में विफल",
|
||||
"deleted": "पासकी हटाई गई",
|
||||
"deleteFailed": "पासकी हटाने में विफल"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Jelszavak",
|
||||
"signIn": "Bejelentkezés jelszóval",
|
||||
"loginFailed": "Jelszóval történő bejelentkezés sikertelen",
|
||||
"description": "Jelszó nélküli bejelentkezéshez használja a WebAuthn/FIDO2 hitelesítő adatokat",
|
||||
"namePlaceholder": "Jelszó neve",
|
||||
"uv": {
|
||||
"preferred": "Előnyben részesített",
|
||||
"required": "Kívánt",
|
||||
"discouraged": "Elbátortalanodva"
|
||||
},
|
||||
"add": "Jelszó hozzáadása",
|
||||
"none": "Nincsenek regisztrált jelszók.",
|
||||
"added": "Hozzáadott jelszó",
|
||||
"addFailed": "Nem sikerült hozzáadni a jelszót",
|
||||
"deleted": "Jelszó törölve",
|
||||
"deleteFailed": "Nem sikerült törölni a jelszót"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Kode akses",
|
||||
"signIn": "Masuk dengan kata sandi",
|
||||
"loginFailed": "Login dengan kata sandi gagal.",
|
||||
"description": "Gunakan kredensial WebAuthn/FIDO2 untuk masuk tanpa kata sandi.",
|
||||
"namePlaceholder": "Nama kata sandi",
|
||||
"uv": {
|
||||
"preferred": "Lebih disukai",
|
||||
"required": "Diperlukan",
|
||||
"discouraged": "Berkecil hati"
|
||||
},
|
||||
"add": "Tambahkan Kata Sandi",
|
||||
"none": "Tidak ada kata sandi yang terdaftar.",
|
||||
"added": "Kata sandi telah ditambahkan",
|
||||
"addFailed": "Gagal menambahkan kata sandi.",
|
||||
"deleted": "Kata sandi telah dihapus.",
|
||||
"deleteFailed": "Gagal menghapus kata sandi."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Accedi con passkey",
|
||||
"loginFailed": "Accesso con passkey non riuscito",
|
||||
"description": "Utilizza credenziali WebAuthn/FIDO2 per accedere senza password",
|
||||
"namePlaceholder": "Nome passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferita",
|
||||
"required": "Obbligatoria",
|
||||
"discouraged": "Sconsigliata"
|
||||
},
|
||||
"add": "Aggiungi passkey",
|
||||
"none": "Nessuna passkey registrata.",
|
||||
"added": "Passkey aggiunta",
|
||||
"addFailed": "Impossibile aggiungere la passkey",
|
||||
"deleted": "Passkey eliminata",
|
||||
"deleteFailed": "Impossibile eliminare la passkey"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "パスキー",
|
||||
"signIn": "パスキーでサインイン",
|
||||
"loginFailed": "パスキーログインに失敗しました",
|
||||
"description": "WebAuthn/FIDO2認証情報を使用してパスワードなしでサインインできます",
|
||||
"namePlaceholder": "パスキー名",
|
||||
"uv": {
|
||||
"preferred": "優先",
|
||||
"required": "必須",
|
||||
"discouraged": "非推奨"
|
||||
},
|
||||
"add": "パスキーを追加",
|
||||
"none": "登録されたパスキーはありません。",
|
||||
"added": "パスキーが追加されました",
|
||||
"addFailed": "パスキーの追加に失敗しました",
|
||||
"deleted": "パスキーが削除されました",
|
||||
"deleteFailed": "パスキーの削除に失敗しました"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "패스키",
|
||||
"signIn": "Passkey로 로그인",
|
||||
"loginFailed": "패스키 로그인 실패",
|
||||
"description": "WebAuthn/FIDO2 자격 증명을 사용하여 비밀번호 없이 로그인하세요.",
|
||||
"namePlaceholder": "패스키 이름",
|
||||
"uv": {
|
||||
"preferred": "선호",
|
||||
"required": "필수",
|
||||
"discouraged": "권장 안 함"
|
||||
},
|
||||
"add": "패스키 추가",
|
||||
"none": "등록된 패스키가 없습니다.",
|
||||
"added": "패스키가 추가되었습니다.",
|
||||
"addFailed": "패스키 추가 실패",
|
||||
"deleted": "패스키가 삭제되었습니다.",
|
||||
"deleteFailed": "패스키 삭제 실패"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Wachtwoorden",
|
||||
"signIn": "Aanmelden met wachtwoord",
|
||||
"loginFailed": "Aanmelden met wachtwoord mislukt",
|
||||
"description": "Gebruik WebAuthn/FIDO2-gegevens om zonder wachtwoord in te loggen.",
|
||||
"namePlaceholder": "Wachtwoordnaam",
|
||||
"uv": {
|
||||
"preferred": "Voorkeur",
|
||||
"required": "Vereist",
|
||||
"discouraged": "Ontmoedigd"
|
||||
},
|
||||
"add": "Voeg wachtwoord toe",
|
||||
"none": "Er zijn geen wachtwoorden geregistreerd.",
|
||||
"added": "Wachtwoord toegevoegd",
|
||||
"addFailed": "Wachtwoord toevoegen mislukt",
|
||||
"deleted": "Wachtwoord verwijderd",
|
||||
"deleteFailed": "Het verwijderen van de toegangscode is mislukt."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passord",
|
||||
"signIn": "Logg inn med passord",
|
||||
"loginFailed": "Innlogging med passord mislyktes",
|
||||
"description": "Bruk WebAuthn/FIDO2-legitimasjon for å logge på uten passord",
|
||||
"namePlaceholder": "Navn på passord",
|
||||
"uv": {
|
||||
"preferred": "Foretrukket",
|
||||
"required": "Obligatorisk",
|
||||
"discouraged": "Motløs"
|
||||
},
|
||||
"add": "Legg til tilgangsnøkkel",
|
||||
"none": "Ingen passord registrert.",
|
||||
"added": "Tilgangsnøkkel lagt til",
|
||||
"addFailed": "Kunne ikke legge til passord",
|
||||
"deleted": "Tilgangsnøkkel slettet",
|
||||
"deleteFailed": "Kunne ikke slette passordet"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Klucze dostępu",
|
||||
"signIn": "Zaloguj się za pomocą klucza dostępu",
|
||||
"loginFailed": "Nie udało się zalogować za pomocą klucza dostępu",
|
||||
"description": "Używaj poświadczeń WebAuthn/FIDO2, aby logować się bez hasła",
|
||||
"namePlaceholder": "Nazwa klucza dostępu",
|
||||
"uv": {
|
||||
"preferred": "Preferowane",
|
||||
"required": "Wymagane",
|
||||
"discouraged": "Odradzane"
|
||||
},
|
||||
"add": "Dodaj klucz dostępu",
|
||||
"none": "Nie zarejestrowano żadnych kluczy dostępu.",
|
||||
"added": "Dodano klucz dostępu",
|
||||
"addFailed": "Nie udało się dodać klucza dostępu",
|
||||
"deleted": "Usunięto klucz dostępu",
|
||||
"deleteFailed": "Nie udało się usunąć klucza dostępu"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Senhas",
|
||||
"signIn": "Faça login com a chave de acesso.",
|
||||
"loginFailed": "Falha no login com a chave de acesso",
|
||||
"description": "Use as credenciais WebAuthn/FIDO2 para fazer login sem senha.",
|
||||
"namePlaceholder": "Nome da chave de acesso",
|
||||
"uv": {
|
||||
"preferred": "Preferido",
|
||||
"required": "Obrigatório",
|
||||
"discouraged": "Desencorajado"
|
||||
},
|
||||
"add": "Adicionar chave de acesso",
|
||||
"none": "Nenhuma chave de acesso registrada.",
|
||||
"added": "Senha adicionada",
|
||||
"addFailed": "Falha ao adicionar a chave de acesso.",
|
||||
"deleted": "Senha excluída",
|
||||
"deleteFailed": "Falha ao excluir a chave de acesso."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Iniciar sessão com passkey",
|
||||
"loginFailed": "Falha no início de sessão com passkey",
|
||||
"description": "Utilize credenciais WebAuthn/FIDO2 para iniciar sessão sem palavra-passe",
|
||||
"namePlaceholder": "Nome da passkey",
|
||||
"uv": {
|
||||
"preferred": "Preferencial",
|
||||
"required": "Obrigatório",
|
||||
"discouraged": "Desaconselhado"
|
||||
},
|
||||
"add": "Adicionar Passkey",
|
||||
"none": "Nenhuma passkey registada.",
|
||||
"added": "Passkey adicionada",
|
||||
"addFailed": "Falha ao adicionar passkey",
|
||||
"deleted": "Passkey eliminada",
|
||||
"deleteFailed": "Falha ao eliminar chave de acesso"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Chei de acces",
|
||||
"signIn": "Conectați-vă cu cheia de acces",
|
||||
"loginFailed": "Conectarea cu parola a eșuat",
|
||||
"description": "Folosește acreditările WebAuthn/FIDO2 pentru a te conecta fără parolă",
|
||||
"namePlaceholder": "Numele cheii de acces",
|
||||
"uv": {
|
||||
"preferred": "Preferat",
|
||||
"required": "Necesar",
|
||||
"discouraged": "Descurajat"
|
||||
},
|
||||
"add": "Adăugați o cheie de acces",
|
||||
"none": "Nicio cheie de acces înregistrată.",
|
||||
"added": "Cheie de acces adăugată",
|
||||
"addFailed": "Nu s-a putut adăuga parola",
|
||||
"deleted": "Cheia de acces a fost ștearsă",
|
||||
"deleteFailed": "Nu s-a putut șterge parola"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Ключи доступа",
|
||||
"signIn": "Войти с ключом доступа",
|
||||
"loginFailed": "Вход по ключу не удался",
|
||||
"description": "Используйте учётные данные WebAuthn/FIDO2 для входа без пароля",
|
||||
"namePlaceholder": "Название ключа доступа",
|
||||
"uv": {
|
||||
"preferred": "Предпочтительно",
|
||||
"required": "Обязательно",
|
||||
"discouraged": "Не рекомендуется"
|
||||
},
|
||||
"add": "Добавить ключ доступа",
|
||||
"none": "Нет зарегистрированных ключей доступа.",
|
||||
"added": "Ключ доступа добавлен",
|
||||
"addFailed": "Не удалось добавить ключ доступа",
|
||||
"deleted": "Ключ доступа удалён",
|
||||
"deleteFailed": "Ошибка удаления ключа доступа"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Лозинке",
|
||||
"signIn": "Пријавите се помоћу лозинке",
|
||||
"loginFailed": "Пријава лозинком није успела",
|
||||
"description": "Користите WebAuthn/FIDO2 акредитиве за пријаву без лозинке",
|
||||
"namePlaceholder": "Назив лозинке",
|
||||
"uv": {
|
||||
"preferred": "Преферирано",
|
||||
"required": "Обавезно",
|
||||
"discouraged": "Обесхрабрено"
|
||||
},
|
||||
"add": "Додај лозинку",
|
||||
"none": "Није регистрована ниједна лозинка.",
|
||||
"added": "Лозинка је додата",
|
||||
"addFailed": "Додавање лозинке није успело",
|
||||
"deleted": "Лозинка је обрисана",
|
||||
"deleteFailed": "Брисање лозинке није успело"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Lösenord",
|
||||
"signIn": "Logga in med lösenord",
|
||||
"loginFailed": "Inloggning med lösenordsnyckel misslyckades",
|
||||
"description": "Använd WebAuthn/FIDO2-inloggningsuppgifter för att logga in utan lösenord",
|
||||
"namePlaceholder": "Lösenordsnamn",
|
||||
"uv": {
|
||||
"preferred": "Föredragen",
|
||||
"required": "Nödvändig",
|
||||
"discouraged": "Avskräckt"
|
||||
},
|
||||
"add": "Lägg till lösenord",
|
||||
"none": "Inga lösennycklar registrerade.",
|
||||
"added": "Lösenord tillagt",
|
||||
"addFailed": "Misslyckades med att lägga till lösenord",
|
||||
"deleted": "Lösenordet raderat",
|
||||
"deleteFailed": "Misslyckades med att ta bort lösenordet"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "รหัสผ่าน",
|
||||
"signIn": "ลงชื่อเข้าใช้ด้วยรหัสผ่าน",
|
||||
"loginFailed": "การเข้าสู่ระบบด้วยรหัสผ่านล้มเหลว",
|
||||
"description": "ใช้ข้อมูลรับรอง WebAuthn/FIDO2 เพื่อเข้าสู่ระบบโดยไม่ต้องใช้รหัสผ่าน",
|
||||
"namePlaceholder": "ชื่อรหัสผ่าน",
|
||||
"uv": {
|
||||
"preferred": "ที่ต้องการ",
|
||||
"required": "ที่จำเป็น",
|
||||
"discouraged": "ท้อใจ"
|
||||
},
|
||||
"add": "เพิ่มรหัสผ่าน",
|
||||
"none": "ไม่มีการลงทะเบียนรหัสผ่านใดๆ",
|
||||
"added": "เพิ่มรหัสผ่านแล้ว",
|
||||
"addFailed": "ไม่สามารถเพิ่มรหัสผ่านได้",
|
||||
"deleted": "รหัสผ่านถูกลบแล้ว",
|
||||
"deleteFailed": "ไม่สามารถลบรหัสผ่านได้"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Geçiş Anahtarları",
|
||||
"signIn": "passkey ile oturum aç",
|
||||
"loginFailed": "Passkey girişi başarısız oldu",
|
||||
"description": "Parola olmadan oturum açmak için WebAuthn/FIDO2 kimlik bilgilerini kullanın",
|
||||
"namePlaceholder": "Geçiş anahtarı adı",
|
||||
"uv": {
|
||||
"preferred": "Tercih edilen",
|
||||
"required": "Gerekli",
|
||||
"discouraged": "Önerilmeyen"
|
||||
},
|
||||
"add": "Geçiş Anahtarı Ekle",
|
||||
"none": "Kayıtlı geçiş anahtarı yok.",
|
||||
"added": "Geçiş anahtarı eklendi",
|
||||
"addFailed": "Geçiş anahtarı eklenemedi",
|
||||
"deleted": "Geçiş anahtarı silindi",
|
||||
"deleteFailed": "Geçiş anahtarı silinemedi"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Ключі доступу",
|
||||
"signIn": "Увійти за допомогою ключа доступу",
|
||||
"loginFailed": "Не вдалося ввійти з ключем доступу",
|
||||
"description": "Використовуйте облікові дані WebAuthn/FIDO2 для входу без пароля",
|
||||
"namePlaceholder": "Ім'я ключа доступу",
|
||||
"uv": {
|
||||
"preferred": "Бажаний",
|
||||
"required": "Обов'язково",
|
||||
"discouraged": "Знеохочений"
|
||||
},
|
||||
"add": "Додати пароль",
|
||||
"none": "Не зареєстровано ключів доступу.",
|
||||
"added": "Ключ доступу додано",
|
||||
"addFailed": "Не вдалося додати ключ доступу",
|
||||
"deleted": "Ключ доступу видалено",
|
||||
"deleteFailed": "Не вдалося видалити ключ доступу"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "Passkeys",
|
||||
"signIn": "Đăng nhập bằng khóa truy cập",
|
||||
"loginFailed": "Đăng nhập bằng passkey thất bại",
|
||||
"description": "Sử dụng thông tin đăng nhập WebAuthn/FIDO2 để đăng nhập không cần mật khẩu",
|
||||
"namePlaceholder": "Tên passkey",
|
||||
"uv": {
|
||||
"preferred": "Ưu tiên",
|
||||
"required": "Bắt buộc",
|
||||
"discouraged": "Không khuyến khích"
|
||||
},
|
||||
"add": "Thêm Passkey",
|
||||
"none": "Chưa đăng ký passkey nào.",
|
||||
"added": "Đã thêm Passkey",
|
||||
"addFailed": "Không thêm được Passkey",
|
||||
"deleted": "Đã xóa Passkey",
|
||||
"deleteFailed": "Xóa passkey thất bại"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "通行密钥",
|
||||
"signIn": "使用通行密钥登录",
|
||||
"loginFailed": "通行密钥登录失败",
|
||||
"description": "使用 WebAuthn/FIDO2 凭据进行无密码登录",
|
||||
"namePlaceholder": "通行密钥名称",
|
||||
"uv": {
|
||||
"preferred": "首选",
|
||||
"required": "必需",
|
||||
"discouraged": "不鼓励"
|
||||
},
|
||||
"add": "添加通行密钥",
|
||||
"none": "未注册通行密钥。",
|
||||
"added": "通行密钥已添加",
|
||||
"addFailed": "添加通行密钥失败",
|
||||
"deleted": "通行密钥已删除",
|
||||
"deleteFailed": "删除通行密钥失败"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"title": "通行金鑰",
|
||||
"signIn": "使用通行密鑰登入",
|
||||
"loginFailed": "Passkey 登入失敗",
|
||||
"description": "使用 WebAuthn/FIDO2 憑證無需密碼即可登入",
|
||||
"namePlaceholder": "通行金鑰名稱",
|
||||
"uv": {
|
||||
"preferred": "偏好",
|
||||
"required": "需要",
|
||||
"discouraged": "不鼓勵"
|
||||
},
|
||||
"add": "新增通行金鑰",
|
||||
"none": "尚未註冊通行金鑰。",
|
||||
"added": "通行金鑰已新增",
|
||||
"addFailed": "無法新增通行金鑰",
|
||||
"deleted": "通行金鑰已刪除",
|
||||
"deleteFailed": "刪除通行密鑰失敗"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "webauthn",
|
||||
"name": "Passkeys",
|
||||
"version": "1.0.0",
|
||||
"description": "Sign in with a passkey or security key instead of a password. A passkey that checks your PIN or fingerprint also counts as the second factor.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "Fingerprint",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"loginMethods": ["passkey"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `webauthn_credentials` (
|
||||
`id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`credential_id` varchar(255) NOT NULL UNIQUE,
|
||||
`public_key` text NOT NULL,
|
||||
`counter` int NOT NULL DEFAULT 0,
|
||||
`device_type` text,
|
||||
`backed_up` boolean NOT NULL DEFAULT false,
|
||||
`transports` text,
|
||||
`user_verification` text NOT NULL DEFAULT ('preferred'),
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`last_used_at` text,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `webauthn_credentials` TO `p_webauthn_credentials`;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "webauthn_credentials" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credential_id" varchar(255) NOT NULL UNIQUE,
|
||||
"public_key" text NOT NULL,
|
||||
"counter" integer NOT NULL DEFAULT 0,
|
||||
"device_type" text,
|
||||
"backed_up" boolean NOT NULL DEFAULT false,
|
||||
"transports" text,
|
||||
"user_verification" text NOT NULL DEFAULT 'preferred',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "webauthn_credentials" RENAME TO "p_webauthn_credentials";
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"credentials": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"credentialId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"notNull": true,
|
||||
"unique": true
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"counter": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
},
|
||||
"deviceType": {
|
||||
"type": "text"
|
||||
},
|
||||
"backedUp": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
},
|
||||
"transports": {
|
||||
"type": "text"
|
||||
},
|
||||
"userVerification": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "preferred"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"type": "timestamp"
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "webauthn_credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- webauthn 0001: adopt_webauthn_credentials
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "webauthn_credentials" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"credential_id" text NOT NULL UNIQUE,
|
||||
"public_key" text NOT NULL,
|
||||
"counter" integer NOT NULL DEFAULT 0,
|
||||
"device_type" text,
|
||||
"backed_up" integer NOT NULL DEFAULT 0,
|
||||
"transports" text,
|
||||
"user_verification" text NOT NULL DEFAULT 'preferred',
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_used_at" text,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "webauthn_credentials" RENAME TO "p_webauthn_credentials";
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@termix-plugin/webauthn",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { credentials } from "./tables.js";
|
||||
import { createCredentialRepository } from "./repository.js";
|
||||
import { createPasskeyService } from "./passkeys.js";
|
||||
import { PUBLIC_PATHS, registerPasskeyRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(credentials);
|
||||
const repository = createCredentialRepository(ctx.db, table);
|
||||
const passkeys = createPasskeyService(ctx, repository);
|
||||
|
||||
// No describe: a passkey only helps a user who already registered one, so
|
||||
// it never counts for the password lockout guard.
|
||||
ctx.auth.registerLoginMethod({
|
||||
id: "passkey",
|
||||
labelKey: "signIn",
|
||||
icon: "fingerprint",
|
||||
kind: "form",
|
||||
verify: (request) => passkeys.verifyLogin(request.body ?? {}),
|
||||
});
|
||||
|
||||
registerPasskeyRoutes(
|
||||
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
|
||||
ctx,
|
||||
repository,
|
||||
passkeys,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
Base64URLString,
|
||||
RegistrationResponseJSON,
|
||||
WebAuthnCredential,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
type PluginVerifiedIdentity,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { CredentialRecord, CredentialRepository } from "./repository.js";
|
||||
|
||||
export type UserVerification = "discouraged" | "preferred" | "required";
|
||||
|
||||
interface ChallengeRecord {
|
||||
challenge: string;
|
||||
userId?: string;
|
||||
rpID: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export function normalizeUserVerification(value: unknown): UserVerification {
|
||||
return value === "discouraged" || value === "required" ? value : "preferred";
|
||||
}
|
||||
|
||||
function header(
|
||||
headers: Record<string, unknown>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = headers[name];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** The browser's origin, or the one a proxy reports. */
|
||||
export function requestOrigin(headers: Record<string, unknown>): string {
|
||||
const origin = header(headers, "origin");
|
||||
if (origin) return origin;
|
||||
const proto = header(headers, "x-forwarded-proto") || "http";
|
||||
const host =
|
||||
header(headers, "x-forwarded-host") ||
|
||||
header(headers, "host") ||
|
||||
"localhost";
|
||||
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
|
||||
}
|
||||
|
||||
export function parseTransports(
|
||||
value: string | null,
|
||||
): AuthenticatorTransportFuture[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toCredential(record: CredentialRecord): WebAuthnCredential {
|
||||
return {
|
||||
id: record.credentialId as Base64URLString,
|
||||
publicKey: Uint8Array.from(
|
||||
Buffer.from(record.publicKey, "base64url"),
|
||||
) as WebAuthnCredential["publicKey"],
|
||||
counter: record.counter,
|
||||
transports: parseTransports(record.transports),
|
||||
};
|
||||
}
|
||||
|
||||
export type PasskeyService = ReturnType<typeof createPasskeyService>;
|
||||
|
||||
/**
|
||||
* Registration and sign-in ceremonies. Challenges live in memory for five
|
||||
* minutes and are made in activate, so a disabled plugin forgets them.
|
||||
*/
|
||||
export function createPasskeyService(
|
||||
ctx: PluginContext,
|
||||
repository: CredentialRepository,
|
||||
) {
|
||||
const registrations = new Map<string, ChallengeRecord>();
|
||||
const authentications = new Map<string, ChallengeRecord>();
|
||||
|
||||
const prune = (map: Map<string, ChallengeRecord>) => {
|
||||
const now = Date.now();
|
||||
for (const [id, record] of map) {
|
||||
if (now - record.createdAt > CHALLENGE_TTL_MS) map.delete(id);
|
||||
}
|
||||
};
|
||||
const put = (
|
||||
map: Map<string, ChallengeRecord>,
|
||||
record: Omit<ChallengeRecord, "createdAt">,
|
||||
) => {
|
||||
prune(map);
|
||||
const id = randomUUID();
|
||||
map.set(id, { ...record, createdAt: Date.now() });
|
||||
return id;
|
||||
};
|
||||
const take = (map: Map<string, ChallengeRecord>, id: unknown) => {
|
||||
if (typeof id !== "string") return null;
|
||||
prune(map);
|
||||
const record = map.get(id) ?? null;
|
||||
map.delete(id);
|
||||
return record;
|
||||
};
|
||||
|
||||
async function rpName(): Promise<string> {
|
||||
try {
|
||||
return (await ctx.settings.readCore("app_name")) || "Termix";
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async registrationOptions(input: {
|
||||
userId: string;
|
||||
username: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
}) {
|
||||
const rpID = new URL(input.origin).hostname;
|
||||
const existing = await repository.listByUserId(input.userId);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: await rpName(),
|
||||
rpID,
|
||||
userID: Buffer.from(input.userId, "utf8"),
|
||||
userName: input.username,
|
||||
userDisplayName: input.username,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
userVerification: input.userVerification,
|
||||
},
|
||||
});
|
||||
const challengeId = put(registrations, {
|
||||
challenge: options.challenge,
|
||||
userId: input.userId,
|
||||
rpID,
|
||||
origin: input.origin,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
return { options, challengeId };
|
||||
},
|
||||
|
||||
/** Stores the passkey. False when the challenge or response is bad. */
|
||||
async register(input: {
|
||||
userId: string;
|
||||
challengeId: unknown;
|
||||
response: RegistrationResponseJSON | undefined;
|
||||
name: unknown;
|
||||
}): Promise<boolean | "expired"> {
|
||||
const challenge = take(registrations, input.challengeId);
|
||||
if (!challenge || challenge.userId !== input.userId) return "expired";
|
||||
if (!input.response) return false;
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: input.response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
});
|
||||
if (!verification.verified) return false;
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
const name =
|
||||
typeof input.name === "string" && input.name.trim()
|
||||
? input.name.trim().slice(0, 80)
|
||||
: "Passkey";
|
||||
await repository.create({
|
||||
id: randomUUID(),
|
||||
userId: input.userId,
|
||||
name,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey).toString("base64url"),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: JSON.stringify(input.response.response?.transports ?? []),
|
||||
userVerification: challenge.userVerification,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async authenticationOptions(input: {
|
||||
userId?: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
}) {
|
||||
const rpID = new URL(input.origin).hostname;
|
||||
let allowCredentials:
|
||||
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
|
||||
| undefined;
|
||||
if (input.userId) {
|
||||
const credentials = await repository.listByUserId(input.userId);
|
||||
if (credentials.length === 0) return null;
|
||||
allowCredentials = credentials.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
}));
|
||||
}
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
const challengeId = put(authentications, {
|
||||
challenge: options.challenge,
|
||||
userId: input.userId,
|
||||
rpID,
|
||||
origin: input.origin,
|
||||
userVerification: input.userVerification,
|
||||
});
|
||||
return { options, challengeId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks an assertion and says who signed in. A passkey that verified
|
||||
* the user (PIN or biometric) counts as the second factor too.
|
||||
*/
|
||||
async verifyLogin(
|
||||
body: Record<string, unknown>,
|
||||
): Promise<PluginVerifiedIdentity> {
|
||||
const challenge = take(authentications, body.challengeId);
|
||||
if (!challenge) {
|
||||
throw new LoginMethodError("Authentication challenge expired", 400);
|
||||
}
|
||||
const response = body.response as AuthenticationResponseJSON | undefined;
|
||||
if (!response?.id) {
|
||||
throw new LoginMethodError("Invalid passkey response", 400);
|
||||
}
|
||||
const credential = await repository.findByCredentialId(response.id);
|
||||
if (
|
||||
!credential ||
|
||||
(challenge.userId && challenge.userId !== credential.userId)
|
||||
) {
|
||||
throw new LoginMethodError("Passkey not recognized", 401);
|
||||
}
|
||||
|
||||
let verification: Awaited<
|
||||
ReturnType<typeof verifyAuthenticationResponse>
|
||||
>;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: toCredential(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: { userVerification: challenge.userVerification },
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Passkey authentication failed for ${credential.userId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
throw new LoginMethodError("Passkey authentication failed", 401);
|
||||
}
|
||||
|
||||
await repository.updateAuthState(credential.id, {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "user",
|
||||
userId: credential.userId,
|
||||
mfaSatisfied: verification.authenticationInfo.userVerified === true,
|
||||
rememberMe: !!body.rememberMe,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
credentialId: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
deviceType: string | null;
|
||||
backedUp: boolean;
|
||||
transports: string | null;
|
||||
userVerification: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type CredentialRepository = ReturnType<
|
||||
typeof createCredentialRepository
|
||||
>;
|
||||
|
||||
export function createCredentialRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async listByUserId(userId: string): Promise<CredentialRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle.select().from(table).where(eq(table.userId, userId));
|
||||
},
|
||||
|
||||
async findByCredentialId(
|
||||
credentialId: string,
|
||||
): Promise<CredentialRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.credentialId, credentialId))
|
||||
.limit(1);
|
||||
return (rows[0] as CredentialRecord) ?? null;
|
||||
},
|
||||
|
||||
async create(
|
||||
input: Omit<CredentialRecord, "lastUsedAt" | "createdAt">,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ ...input, createdAt: new Date().toISOString() });
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async updateAuthState(
|
||||
id: string,
|
||||
state: {
|
||||
counter: number;
|
||||
backedUp: boolean;
|
||||
deviceType: string;
|
||||
lastUsedAt: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.update(table).set(state).where(eq(table.id, id));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async deleteForUser(userId: string, id: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeUserVerification,
|
||||
parseTransports,
|
||||
requestOrigin,
|
||||
type PasskeyService,
|
||||
} from "./passkeys.js";
|
||||
import type { CredentialRepository } from "./repository.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const PUBLIC_PATHS = ["/authenticate/options"];
|
||||
|
||||
export function registerPasskeyRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
repository: CredentialRepository,
|
||||
passkeys: PasskeyService,
|
||||
): void {
|
||||
async function findUser(
|
||||
column: "id" | "username",
|
||||
value: string,
|
||||
): Promise<{ id: string; username: string } | null> {
|
||||
const { users } = await ctx.db.refs<{ users: Table }>();
|
||||
const drizzle = await ctx.db.client<Drizzle>();
|
||||
const rows = await drizzle
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users[column], value))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/credentials:
|
||||
* get:
|
||||
* summary: List passkeys
|
||||
* description: Lists the caller's registered passkeys.
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of passkeys.
|
||||
*/
|
||||
router.get("/credentials", async (_req: Request, res: Response) => {
|
||||
const credentials = await repository.listByUserId(ctx.currentActor()!);
|
||||
res.json({
|
||||
credentials: credentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: !!credential.backedUp,
|
||||
transports: parseTransports(credential.transports),
|
||||
userVerification: credential.userVerification,
|
||||
createdAt: credential.createdAt,
|
||||
lastUsedAt: credential.lastUsedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/register/options:
|
||||
* post:
|
||||
* summary: Start passkey registration
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Registration options and challenge id.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
*/
|
||||
router.post("/register/options", async (req: Request, res: Response) => {
|
||||
const user = await findUser("id", ctx.currentActor()!);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
res.json(
|
||||
await passkeys.registrationOptions({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
origin: requestOrigin(req.headers),
|
||||
userVerification: normalizeUserVerification(req.body?.userVerification),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/register/verify:
|
||||
* post:
|
||||
* summary: Finish passkey registration
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey registered.
|
||||
* 400:
|
||||
* description: Registration failed or challenge expired.
|
||||
*/
|
||||
router.post("/register/verify", async (req: Request, res: Response) => {
|
||||
const userId = ctx.currentActor()!;
|
||||
try {
|
||||
const result = await passkeys.register({
|
||||
userId,
|
||||
challengeId: req.body?.challengeId,
|
||||
response: req.body?.response as RegistrationResponseJSON | undefined,
|
||||
name: req.body?.name,
|
||||
});
|
||||
if (result === "expired") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Registration challenge expired" });
|
||||
}
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Passkey registration failed for ${userId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/authenticate/options:
|
||||
* post:
|
||||
* summary: Start passkey login
|
||||
* description: Public. Authentication options, optionally scoped to a username. The assertion is then sent to /users/auth/passkey/verify.
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Authentication options and challenge id.
|
||||
* 404:
|
||||
* description: No passkeys found for the user.
|
||||
*/
|
||||
router.post("/authenticate/options", async (req: Request, res: Response) => {
|
||||
const username =
|
||||
typeof req.body?.username === "string" ? req.body.username.trim() : "";
|
||||
let userId: string | undefined;
|
||||
if (username) {
|
||||
const user = await findUser("username", username);
|
||||
if (!user) return res.status(404).json({ error: "No passkeys found" });
|
||||
userId = user.id;
|
||||
}
|
||||
const result = await passkeys.authenticationOptions({
|
||||
userId,
|
||||
origin: requestOrigin(req.headers),
|
||||
userVerification: normalizeUserVerification(req.body?.userVerification),
|
||||
});
|
||||
if (!result) return res.status(404).json({ error: "No passkeys found" });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/webauthn/credentials/{credentialId}:
|
||||
* delete:
|
||||
* summary: Delete a passkey
|
||||
* tags:
|
||||
* - Passkeys
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: credentialId
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Passkey deleted.
|
||||
*/
|
||||
router.delete(
|
||||
"/credentials/:credentialId",
|
||||
async (req: Request, res: Response) => {
|
||||
await repository.deleteForUser(
|
||||
ctx.currentActor()!,
|
||||
String(req.params.credentialId),
|
||||
);
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user