From bb3da6b80197d73aa78dc60c368ded47ccced0ce Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:51:30 +0200 Subject: [PATCH] feat: implement RdClient login functionality and enhance authentication flow - Added a dedicated login page for the Remote Desktop client, allowing operators to sign in when their session expires. - Implemented safe return URL handling for the login process. - Updated authentication middleware to support RdClient-specific routes and permissions. - Enhanced localization support for the new login feature across multiple languages. - Improved user experience by redirecting to the appropriate pages based on authentication status. --- CHANGELOG.md | 1 + web-nodejs/lang/ar.json | 19 ++ web-nodejs/lang/cs.json | 19 ++ web-nodejs/lang/da.json | 19 ++ web-nodejs/lang/de.json | 19 ++ web-nodejs/lang/en.json | 19 ++ web-nodejs/lang/es.json | 19 ++ web-nodejs/lang/fi.json | 19 ++ web-nodejs/lang/fr.json | 19 ++ web-nodejs/lang/hi.json | 19 ++ web-nodejs/lang/hu.json | 19 ++ web-nodejs/lang/id.json | 19 ++ web-nodejs/lang/it.json | 19 ++ web-nodejs/lang/ja.json | 19 ++ web-nodejs/lang/ko.json | 19 ++ web-nodejs/lang/nb.json | 19 ++ web-nodejs/lang/nl.json | 19 ++ web-nodejs/lang/pl.json | 19 ++ web-nodejs/lang/pt.json | 19 ++ web-nodejs/lang/ro.json | 19 ++ web-nodejs/lang/sv.json | 19 ++ web-nodejs/lang/th.json | 19 ++ web-nodejs/lang/tr.json | 19 ++ web-nodejs/lang/uk.json | 19 ++ web-nodejs/lang/vi.json | 19 ++ web-nodejs/lang/zh-TW.json | 19 ++ web-nodejs/lang/zh.json | 19 ++ web-nodejs/middleware/auth.js | 78 ++++++ web-nodejs/public/css/rdclient-login.css | 103 ++++++++ web-nodejs/public/js/rdclient-login.js | 261 ++++++++++++++++++++ web-nodejs/public/js/rdclientSecureStore.js | 213 ++++++++++++++++ web-nodejs/public/js/utils.js | 9 +- web-nodejs/routes/remote.routes.js | 62 ++--- web-nodejs/tests/middleware.auth.test.js | 72 +++++- web-nodejs/views/rdclient-login.ejs | 124 ++++++++++ 35 files changed, 1374 insertions(+), 43 deletions(-) create mode 100644 web-nodejs/public/css/rdclient-login.css create mode 100644 web-nodejs/public/js/rdclient-login.js create mode 100644 web-nodejs/public/js/rdclientSecureStore.js create mode 100644 web-nodejs/views/rdclient-login.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2b2a16..72b3487c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Added - **RdClient dashboard (web):** **Tools → Remote Desktop** opens `/remote` in a new browser tab — standalone operator device list with branding, search/filters, and **Connect** (same `/remote/:id` viewer as Devices). Devices → Web Remote unchanged. +- **RdClient login (web):** `/remote/login` — dedicated operator sign-in when the panel session expires (redirects to `/remote` or viewer instead of admin `/login`); optional **Remember me** stores encrypted credentials in IndexedDB (Web Crypto AES-GCM), separate from the peer password vault planned for Phase B. ### Changed - _(none yet)_ diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index ad0d7408..e54f9703 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -3819,5 +3819,24 @@ "open_panel": "فتح لوحة التحكم", "devices_count": "{count} أجهزة", "retry": "إعادة المحاولة" + }, + "rdclient_login": { + "title": "عميل سطح المكتب البعيد", + "subtitle": "سجّل الدخول للاتصال بأجهزتك", + "username_placeholder": "اسم المستخدم", + "password_placeholder": "كلمة المرور", + "remember_me": "تذكرني على هذا الجهاز", + "sign_in": "تسجيل الدخول", + "totp_title": "المصادقة الثنائية", + "totp_subtitle": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة", + "verify": "تحقق", + "back_to_login": "العودة إلى تسجيل الدخول", + "open_console_login": "فتح تسجيل دخول وحدة التحكم", + "session_expired": "انتهت الجلسة. يرجى تسجيل الدخول مرة أخرى.", + "fill_all_fields": "يرجى ملء جميع الحقول", + "invalid_credentials": "بيانات اعتماد غير صالحة", + "network_error": "خطأ في الشبكة", + "enter_6_digits": "أدخل جميع الأرقام الستة", + "invalid_code": "رمز غير صالح" } } diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index a5bafab3..7b512ba7 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -3812,5 +3812,24 @@ "open_panel": "Otevřít konzoli", "devices_count": "{count} zařízení", "retry": "Zkusit znovu" + }, + "rdclient_login": { + "title": "Klient vzdálené plochy", + "subtitle": "Přihlaste se pro připojení k vašim zařízením", + "username_placeholder": "Uživatelské jméno", + "password_placeholder": "Heslo", + "remember_me": "Zapamatovat si mě na tomto zařízení", + "sign_in": "Přihlásit se", + "totp_title": "Dvoufaktorové ověření", + "totp_subtitle": "Zadejte 6místný kód z vaší autentizační aplikace", + "verify": "Ověřit", + "back_to_login": "Zpět k přihlášení", + "open_console_login": "Otevřít přihlášení konzole", + "session_expired": "Relace vypršela. Přihlaste se znovu.", + "fill_all_fields": "Vyplňte všechna pole", + "invalid_credentials": "Neplatné přihlašovací údaje", + "network_error": "Chyba sítě", + "enter_6_digits": "Zadejte všech 6 číslic", + "invalid_code": "Neplatný kód" } } diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index 28c9fc0c..303ea810 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -3818,5 +3818,24 @@ "open_panel": "Åbn konsol", "devices_count": "{count} enheder", "retry": "Prøv igen" + }, + "rdclient_login": { + "title": "Remote Desktop-klient", + "subtitle": "Log ind for at oprette forbindelse til dine enheder", + "username_placeholder": "Brugernavn", + "password_placeholder": "Adgangskode", + "remember_me": "Husk mig på denne enhed", + "sign_in": "Log ind", + "totp_title": "To-faktor-godkendelse", + "totp_subtitle": "Indtast den 6-cifrede kode fra din godkendelsesapp", + "verify": "Bekræft", + "back_to_login": "Tilbage til login", + "open_console_login": "Åbn konsol-login", + "session_expired": "Session udløbet. Log ind igen.", + "fill_all_fields": "Udfyld alle felter", + "invalid_credentials": "Ugyldige loginoplysninger", + "network_error": "Netværksfejl", + "enter_6_digits": "Indtast alle 6 cifre", + "invalid_code": "Ugyldig kode" } } diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index 7861641f..3489a3f2 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -3812,5 +3812,24 @@ "open_panel": "Konsole öffnen", "devices_count": "{count} Geräte", "retry": "Erneut versuchen" + }, + "rdclient_login": { + "title": "Remote-Desktop-Client", + "subtitle": "Melden Sie sich an, um eine Verbindung zu Ihren Geräten herzustellen", + "username_placeholder": "Benutzername", + "password_placeholder": "Passwort", + "remember_me": "Auf diesem Gerät merken", + "sign_in": "Anmelden", + "totp_title": "Zwei-Faktor-Authentifizierung", + "totp_subtitle": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein", + "verify": "Bestätigen", + "back_to_login": "Zurück zur Anmeldung", + "open_console_login": "Konsolen-Anmeldung öffnen", + "session_expired": "Sitzung abgelaufen. Bitte erneut anmelden.", + "fill_all_fields": "Bitte alle Felder ausfüllen", + "invalid_credentials": "Ungültige Anmeldedaten", + "network_error": "Netzwerkfehler", + "enter_6_digits": "Alle 6 Ziffern eingeben", + "invalid_code": "Ungültiger Code" } } diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index c6e67161..9a90d9a1 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -3812,5 +3812,24 @@ "save": "Save notification settings", "saved": "Notification settings saved" } + }, + "rdclient_login": { + "title": "Remote Desktop Client", + "subtitle": "Sign in to connect to your devices", + "username_placeholder": "Username", + "password_placeholder": "Password", + "remember_me": "Remember me on this device", + "sign_in": "Sign in", + "totp_title": "Two-factor authentication", + "totp_subtitle": "Enter the 6-digit code from your authenticator app", + "verify": "Verify", + "back_to_login": "Back to sign in", + "open_console_login": "Open console login", + "session_expired": "Session expired. Please sign in again.", + "fill_all_fields": "Please fill in all fields", + "invalid_credentials": "Invalid credentials", + "network_error": "Network error", + "enter_6_digits": "Enter all 6 digits", + "invalid_code": "Invalid code" } } diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index 20c7d606..68e5ec84 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -3812,5 +3812,24 @@ "open_panel": "Abrir consola", "devices_count": "{count} dispositivos", "retry": "Reintentar" + }, + "rdclient_login": { + "title": "Cliente de escritorio remoto", + "subtitle": "Inicie sesión para conectarse a sus dispositivos", + "username_placeholder": "Usuario", + "password_placeholder": "Contraseña", + "remember_me": "Recordarme en este dispositivo", + "sign_in": "Iniciar sesión", + "totp_title": "Autenticación de dos factores", + "totp_subtitle": "Introduzca el código de 6 dígitos de su aplicación de autenticación", + "verify": "Verificar", + "back_to_login": "Volver al inicio de sesión", + "open_console_login": "Abrir inicio de sesión de consola", + "session_expired": "Sesión expirada. Inicie sesión de nuevo.", + "fill_all_fields": "Complete todos los campos", + "invalid_credentials": "Credenciales inválidas", + "network_error": "Error de red", + "enter_6_digits": "Introduzca los 6 dígitos", + "invalid_code": "Código inválido" } } diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 9c966078..142bede3 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -3818,5 +3818,24 @@ "open_panel": "Avaa konsoli", "devices_count": "{count} laitetta", "retry": "Yritä uudelleen" + }, + "rdclient_login": { + "title": "Etätyöpöytäasiakas", + "subtitle": "Kirjaudu sisään yhdistääksesi laitteisiisi", + "username_placeholder": "Käyttäjätunnus", + "password_placeholder": "Salasana", + "remember_me": "Muista minut tällä laitteella", + "sign_in": "Kirjaudu sisään", + "totp_title": "Kaksivaiheinen tunnistautuminen", + "totp_subtitle": "Syötä 6-numeroinen koodi tunnistussovelluksestasi", + "verify": "Vahvista", + "back_to_login": "Takaisin kirjautumiseen", + "open_console_login": "Avaa konsolin kirjautuminen", + "session_expired": "Istunto vanhentui. Kirjaudu uudelleen.", + "fill_all_fields": "Täytä kaikki kentät", + "invalid_credentials": "Virheelliset tunnistetiedot", + "network_error": "Verkkovirhe", + "enter_6_digits": "Syötä kaikki 6 numeroa", + "invalid_code": "Virheellinen koodi" } } diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 3ae643e5..2d7204a2 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -3816,5 +3816,24 @@ "open_panel": "Ouvrir la console", "devices_count": "{count} appareils", "retry": "Réessayer" + }, + "rdclient_login": { + "title": "Client Bureau à distance", + "subtitle": "Connectez-vous pour accéder à vos appareils", + "username_placeholder": "Nom d'utilisateur", + "password_placeholder": "Mot de passe", + "remember_me": "Se souvenir de moi sur cet appareil", + "sign_in": "Se connecter", + "totp_title": "Authentification à deux facteurs", + "totp_subtitle": "Entrez le code à 6 chiffres de votre application d'authentification", + "verify": "Vérifier", + "back_to_login": "Retour à la connexion", + "open_console_login": "Ouvrir la connexion console", + "session_expired": "Session expirée. Veuillez vous reconnecter.", + "fill_all_fields": "Veuillez remplir tous les champs", + "invalid_credentials": "Identifiants invalides", + "network_error": "Erreur réseau", + "enter_6_digits": "Entrez les 6 chiffres", + "invalid_code": "Code invalide" } } diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index 7c109748..8f031e52 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -3818,5 +3818,24 @@ "open_panel": "कंसोल खोलें", "devices_count": "{count} डिवाइस", "retry": "पुनः प्रयास" + }, + "rdclient_login": { + "title": "रिमोट डेस्कटॉप क्लाइंट", + "subtitle": "अपने उपकरणों से कनेक्ट करने के लिए साइन इन करें", + "username_placeholder": "उपयोगकर्ता नाम", + "password_placeholder": "पासवर्ड", + "remember_me": "इस उपकरण पर मुझे याद रखें", + "sign_in": "साइन इन", + "totp_title": "दो-कारक प्रमाणीकरण", + "totp_subtitle": "अपने प्रमाणीकरण ऐप से 6-अंकीय कोड दर्ज करें", + "verify": "सत्यापित करें", + "back_to_login": "साइन इन पर वापस", + "open_console_login": "कंसोल लॉगिन खोलें", + "session_expired": "सत्र समाप्त हो गया। कृपया फिर से साइन इन करें।", + "fill_all_fields": "कृपया सभी फ़ील्ड भरें", + "invalid_credentials": "अमान्य क्रेडेंशियल", + "network_error": "नेटवर्क त्रुटि", + "enter_6_digits": "सभी 6 अंक दर्ज करें", + "invalid_code": "अमान्य कोड" } } diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index 2fb37ae5..a7e10e99 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -3818,5 +3818,24 @@ "open_panel": "Konzol megnyitása", "devices_count": "{count} eszköz", "retry": "Újra" + }, + "rdclient_login": { + "title": "Távoli asztal kliens", + "subtitle": "Jelentkezzen be az eszközeihez való csatlakozáshoz", + "username_placeholder": "Felhasználónév", + "password_placeholder": "Jelszó", + "remember_me": "Emlékezzen rám ezen az eszközön", + "sign_in": "Bejelentkezés", + "totp_title": "Kétlépcsős hitelesítés", + "totp_subtitle": "Adja meg a 6 számjegyű kódot a hitelesítő alkalmazásból", + "verify": "Ellenőrzés", + "back_to_login": "Vissza a bejelentkezéshez", + "open_console_login": "Konzol bejelentkezés megnyitása", + "session_expired": "A munkamenet lejárt. Jelentkezzen be újra.", + "fill_all_fields": "Töltse ki az összes mezőt", + "invalid_credentials": "Érvénytelen hitelesítő adatok", + "network_error": "Hálózati hiba", + "enter_6_digits": "Adja meg mind a 6 számjegyet", + "invalid_code": "Érvénytelen kód" } } diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 294e7582..4829c0f8 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -3818,5 +3818,24 @@ "open_panel": "Buka konsol", "devices_count": "{count} perangkat", "retry": "Coba lagi" + }, + "rdclient_login": { + "title": "Klien Desktop Jarak Jauh", + "subtitle": "Masuk untuk terhubung ke perangkat Anda", + "username_placeholder": "Nama pengguna", + "password_placeholder": "Kata sandi", + "remember_me": "Ingat saya di perangkat ini", + "sign_in": "Masuk", + "totp_title": "Autentikasi dua faktor", + "totp_subtitle": "Masukkan kode 6 digit dari aplikasi autentikator Anda", + "verify": "Verifikasi", + "back_to_login": "Kembali ke masuk", + "open_console_login": "Buka login konsol", + "session_expired": "Sesi berakhir. Silakan masuk lagi.", + "fill_all_fields": "Harap isi semua bidang", + "invalid_credentials": "Kredensial tidak valid", + "network_error": "Kesalahan jaringan", + "enter_6_digits": "Masukkan semua 6 digit", + "invalid_code": "Kode tidak valid" } } diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index ed78857f..020b7e64 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -3812,5 +3812,24 @@ "open_panel": "Apri console", "devices_count": "{count} dispositivi", "retry": "Riprova" + }, + "rdclient_login": { + "title": "Client desktop remoto", + "subtitle": "Accedi per connetterti ai tuoi dispositivi", + "username_placeholder": "Nome utente", + "password_placeholder": "Password", + "remember_me": "Ricordami su questo dispositivo", + "sign_in": "Accedi", + "totp_title": "Autenticazione a due fattori", + "totp_subtitle": "Inserisci il codice a 6 cifre dalla tua app di autenticazione", + "verify": "Verifica", + "back_to_login": "Torna all'accesso", + "open_console_login": "Apri accesso console", + "session_expired": "Sessione scaduta. Accedi di nuovo.", + "fill_all_fields": "Compila tutti i campi", + "invalid_credentials": "Credenziali non valide", + "network_error": "Errore di rete", + "enter_6_digits": "Inserisci tutte le 6 cifre", + "invalid_code": "Codice non valido" } } diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 01a467a7..108ad314 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -3812,5 +3812,24 @@ "open_panel": "コンソールを開く", "devices_count": "{count} 台", "retry": "再試行" + }, + "rdclient_login": { + "title": "リモートデスクトップクライアント", + "subtitle": "デバイスに接続するにはサインインしてください", + "username_placeholder": "ユーザー名", + "password_placeholder": "パスワード", + "remember_me": "このデバイスでログイン情報を保存", + "sign_in": "サインイン", + "totp_title": "二要素認証", + "totp_subtitle": "認証アプリの6桁のコードを入力してください", + "verify": "確認", + "back_to_login": "サインインに戻る", + "open_console_login": "コンソールログインを開く", + "session_expired": "セッションの有効期限が切れました。再度サインインしてください。", + "fill_all_fields": "すべての項目を入力してください", + "invalid_credentials": "認証情報が無効です", + "network_error": "ネットワークエラー", + "enter_6_digits": "6桁すべてを入力してください", + "invalid_code": "無効なコード" } } diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 8edbd676..7b854431 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -3812,5 +3812,24 @@ "open_panel": "콘솔 열기", "devices_count": "{count}개 장치", "retry": "다시 시도" + }, + "rdclient_login": { + "title": "원격 데스크톱 클라이언트", + "subtitle": "기기에 연결하려면 로그인하세요", + "username_placeholder": "사용자 이름", + "password_placeholder": "비밀번호", + "remember_me": "이 기기에서 로그인 정보 저장", + "sign_in": "로그인", + "totp_title": "2단계 인증", + "totp_subtitle": "인증 앱의 6자리 코드를 입력하세요", + "verify": "확인", + "back_to_login": "로그인으로 돌아가기", + "open_console_login": "콘솔 로그인 열기", + "session_expired": "세션이 만료되었습니다. 다시 로그인하세요.", + "fill_all_fields": "모든 항목을 입력하세요", + "invalid_credentials": "잘못된 자격 증명", + "network_error": "네트워크 오류", + "enter_6_digits": "6자리 모두 입력하세요", + "invalid_code": "잘못된 코드" } } diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index e0e223c1..5d8ec449 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -3818,5 +3818,24 @@ "open_panel": "Åpne konsoll", "devices_count": "{count} enheter", "retry": "Prøv igjen" + }, + "rdclient_login": { + "title": "Remote Desktop-klient", + "subtitle": "Logg inn for å koble til enhetene dine", + "username_placeholder": "Brukernavn", + "password_placeholder": "Passord", + "remember_me": "Husk meg på denne enheten", + "sign_in": "Logg inn", + "totp_title": "Tofaktorautentisering", + "totp_subtitle": "Skriv inn 6-sifret kode fra autentiseringsappen", + "verify": "Bekreft", + "back_to_login": "Tilbake til innlogging", + "open_console_login": "Åpne konsollinnlogging", + "session_expired": "Økten har utløpt. Logg inn på nytt.", + "fill_all_fields": "Fyll ut alle felt", + "invalid_credentials": "Ugyldige påloggingsdetaljer", + "network_error": "Nettverksfeil", + "enter_6_digits": "Skriv inn alle 6 sifre", + "invalid_code": "Ugyldig kode" } } diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 23f828df..8387aa4f 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -3812,5 +3812,24 @@ "open_panel": "Console openen", "devices_count": "{count} apparaten", "retry": "Opnieuw" + }, + "rdclient_login": { + "title": "Remote Desktop-client", + "subtitle": "Meld u aan om verbinding te maken met uw apparaten", + "username_placeholder": "Gebruikersnaam", + "password_placeholder": "Wachtwoord", + "remember_me": "Onthoud mij op dit apparaat", + "sign_in": "Aanmelden", + "totp_title": "Tweefactorauthenticatie", + "totp_subtitle": "Voer de 6-cijferige code uit uw authenticator-app in", + "verify": "Verifiëren", + "back_to_login": "Terug naar aanmelden", + "open_console_login": "Console-aanmelding openen", + "session_expired": "Sessie verlopen. Meld u opnieuw aan.", + "fill_all_fields": "Vul alle velden in", + "invalid_credentials": "Ongeldige inloggegevens", + "network_error": "Netwerkfout", + "enter_6_digits": "Voer alle 6 cijfers in", + "invalid_code": "Ongeldige code" } } diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 0034b337..e521fc8e 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -3812,5 +3812,24 @@ "open_panel": "Otwórz konsolę", "devices_count": "{count} urządzeń", "retry": "Ponów" + }, + "rdclient_login": { + "title": "Klient pulpitu zdalnego", + "subtitle": "Zaloguj się, aby połączyć się ze swoimi urządzeniami", + "username_placeholder": "Nazwa użytkownika", + "password_placeholder": "Hasło", + "remember_me": "Zapamiętaj mnie na tym urządzeniu", + "sign_in": "Zaloguj się", + "totp_title": "Uwierzytelnianie dwuskładnikowe", + "totp_subtitle": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej", + "verify": "Weryfikuj", + "back_to_login": "Wróć do logowania", + "open_console_login": "Otwórz logowanie do konsoli", + "session_expired": "Sesja wygasła. Zaloguj się ponownie.", + "fill_all_fields": "Wypełnij wszystkie pola", + "invalid_credentials": "Nieprawidłowe dane logowania", + "network_error": "Błąd sieci", + "enter_6_digits": "Wprowadź wszystkie 6 cyfr", + "invalid_code": "Nieprawidłowy kod" } } diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index e37629a2..f7338203 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -3812,5 +3812,24 @@ "open_panel": "Abrir consola", "devices_count": "{count} dispositivos", "retry": "Tentar novamente" + }, + "rdclient_login": { + "title": "Cliente de ambiente de trabalho remoto", + "subtitle": "Inicie sessão para ligar aos seus dispositivos", + "username_placeholder": "Nome de utilizador", + "password_placeholder": "Palavra-passe", + "remember_me": "Lembrar-me neste dispositivo", + "sign_in": "Iniciar sessão", + "totp_title": "Autenticação de dois fatores", + "totp_subtitle": "Introduza o código de 6 dígitos da sua aplicação de autenticação", + "verify": "Verificar", + "back_to_login": "Voltar ao início de sessão", + "open_console_login": "Abrir início de sessão da consola", + "session_expired": "Sessão expirada. Inicie sessão novamente.", + "fill_all_fields": "Preencha todos os campos", + "invalid_credentials": "Credenciais inválidas", + "network_error": "Erro de rede", + "enter_6_digits": "Introduza os 6 dígitos", + "invalid_code": "Código inválido" } } diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 4fd08109..a0f2e656 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -3818,5 +3818,24 @@ "open_panel": "Deschide consola", "devices_count": "{count} dispozitive", "retry": "Reîncearcă" + }, + "rdclient_login": { + "title": "Client desktop la distanță", + "subtitle": "Autentificați-vă pentru a vă conecta la dispozitive", + "username_placeholder": "Nume utilizator", + "password_placeholder": "Parolă", + "remember_me": "Ține-mă minte pe acest dispozitiv", + "sign_in": "Autentificare", + "totp_title": "Autentificare cu doi factori", + "totp_subtitle": "Introduceți codul de 6 cifre din aplicația de autentificare", + "verify": "Verifică", + "back_to_login": "Înapoi la autentificare", + "open_console_login": "Deschide autentificarea consolă", + "session_expired": "Sesiunea a expirat. Autentificați-vă din nou.", + "fill_all_fields": "Completați toate câmpurile", + "invalid_credentials": "Date de autentificare invalide", + "network_error": "Eroare de rețea", + "enter_6_digits": "Introduceți toate cele 6 cifre", + "invalid_code": "Cod invalid" } } diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index d4e2cc16..167b9cd4 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -3818,5 +3818,24 @@ "open_panel": "Öppna konsol", "devices_count": "{count} enheter", "retry": "Försök igen" + }, + "rdclient_login": { + "title": "Remote Desktop-klient", + "subtitle": "Logga in för att ansluta till dina enheter", + "username_placeholder": "Användarnamn", + "password_placeholder": "Lösenord", + "remember_me": "Kom ihåg mig på den här enheten", + "sign_in": "Logga in", + "totp_title": "Tvåfaktorsautentisering", + "totp_subtitle": "Ange den 6-siffriga koden från din autentiseringsapp", + "verify": "Verifiera", + "back_to_login": "Tillbaka till inloggning", + "open_console_login": "Öppna konsolinloggning", + "session_expired": "Sessionen har gått ut. Logga in igen.", + "fill_all_fields": "Fyll i alla fält", + "invalid_credentials": "Ogiltiga inloggningsuppgifter", + "network_error": "Nätverksfel", + "enter_6_digits": "Ange alla 6 siffror", + "invalid_code": "Ogiltig kod" } } diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index f62468e6..a5ab3720 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -3818,5 +3818,24 @@ "open_panel": "เปิดคอนโซล", "devices_count": "{count} อุปกรณ์", "retry": "ลองอีกครั้ง" + }, + "rdclient_login": { + "title": "ไคลเอนต์เดสก์ท็อประยะไกล", + "subtitle": "ลงชื่อเข้าใช้เพื่อเชื่อมต่อกับอุปกรณ์ของคุณ", + "username_placeholder": "ชื่อผู้ใช้", + "password_placeholder": "รหัสผ่าน", + "remember_me": "จดจำฉันบนอุปกรณ์นี้", + "sign_in": "ลงชื่อเข้าใช้", + "totp_title": "การยืนยันตัวตนสองขั้นตอน", + "totp_subtitle": "ป้อนรหัส 6 หลักจากแอปยืนยันตัวตนของคุณ", + "verify": "ยืนยัน", + "back_to_login": "กลับไปลงชื่อเข้าใช้", + "open_console_login": "เปิดการเข้าสู่ระบบคอนโซล", + "session_expired": "เซสชันหมดอายุ กรุณาลงชื่อเข้าใช้อีกครั้ง", + "fill_all_fields": "กรุณากรอกข้อมูลให้ครบทุกช่อง", + "invalid_credentials": "ข้อมูลรับรองไม่ถูกต้อง", + "network_error": "ข้อผิดพลาดเครือข่าย", + "enter_6_digits": "ป้อนตัวเลขครบ 6 หลัก", + "invalid_code": "รหัสไม่ถูกต้อง" } } diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index 46635b97..3e6b729a 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -3818,5 +3818,24 @@ "open_panel": "Konsolu aç", "devices_count": "{count} cihaz", "retry": "Yeniden dene" + }, + "rdclient_login": { + "title": "Uzak Masaüstü İstemcisi", + "subtitle": "Cihazlarınıza bağlanmak için oturum açın", + "username_placeholder": "Kullanıcı adı", + "password_placeholder": "Parola", + "remember_me": "Bu cihazda beni hatırla", + "sign_in": "Oturum aç", + "totp_title": "İki faktörlü kimlik doğrulama", + "totp_subtitle": "Kimlik doğrulama uygulamanızdaki 6 haneli kodu girin", + "verify": "Doğrula", + "back_to_login": "Oturum açmaya dön", + "open_console_login": "Konsol oturum açmayı aç", + "session_expired": "Oturum süresi doldu. Lütfen tekrar oturum açın.", + "fill_all_fields": "Lütfen tüm alanları doldurun", + "invalid_credentials": "Geçersiz kimlik bilgileri", + "network_error": "Ağ hatası", + "enter_6_digits": "6 hanenin tamamını girin", + "invalid_code": "Geçersiz kod" } } diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index be56d22c..8c055383 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -3818,5 +3818,24 @@ "open_panel": "Відкрити консоль", "devices_count": "{count} пристроїв", "retry": "Повторити" + }, + "rdclient_login": { + "title": "Клієнт віддаленого робочого столу", + "subtitle": "Увійдіть, щоб підключитися до ваших пристроїв", + "username_placeholder": "Ім'я користувача", + "password_placeholder": "Пароль", + "remember_me": "Запам'ятати мене на цьому пристрої", + "sign_in": "Увійти", + "totp_title": "Двофакторна автентифікація", + "totp_subtitle": "Введіть 6-значний код з вашого додатку автентифікації", + "verify": "Підтвердити", + "back_to_login": "Назад до входу", + "open_console_login": "Відкрити вхід у консоль", + "session_expired": "Сесію закінчено. Увійдіть знову.", + "fill_all_fields": "Заповніть усі поля", + "invalid_credentials": "Невірні облікові дані", + "network_error": "Помилка мережі", + "enter_6_digits": "Введіть усі 6 цифр", + "invalid_code": "Невірний код" } } diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index c5e4356e..cd212888 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -3818,5 +3818,24 @@ "open_panel": "Mở bảng điều khiển", "devices_count": "{count} thiết bị", "retry": "Thử lại" + }, + "rdclient_login": { + "title": "Máy khách Desktop từ xa", + "subtitle": "Đăng nhập để kết nối với thiết bị của bạn", + "username_placeholder": "Tên người dùng", + "password_placeholder": "Mật khẩu", + "remember_me": "Ghi nhớ tôi trên thiết bị này", + "sign_in": "Đăng nhập", + "totp_title": "Xác thực hai yếu tố", + "totp_subtitle": "Nhập mã 6 chữ số từ ứng dụng xác thực của bạn", + "verify": "Xác minh", + "back_to_login": "Quay lại đăng nhập", + "open_console_login": "Mở đăng nhập bảng điều khiển", + "session_expired": "Phiên đã hết hạn. Vui lòng đăng nhập lại.", + "fill_all_fields": "Vui lòng điền đầy đủ các trường", + "invalid_credentials": "Thông tin đăng nhập không hợp lệ", + "network_error": "Lỗi mạng", + "enter_6_digits": "Nhập đủ 6 chữ số", + "invalid_code": "Mã không hợp lệ" } } diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index f44a9a2a..811b47b0 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -3816,5 +3816,24 @@ "open_panel": "開啟主控台", "devices_count": "{count} 台裝置", "retry": "重試" + }, + "rdclient_login": { + "title": "遠端桌面用戶端", + "subtitle": "登入以連線至您的裝置", + "username_placeholder": "使用者名稱", + "password_placeholder": "密碼", + "remember_me": "在此裝置上記住我", + "sign_in": "登入", + "totp_title": "雙因素驗證", + "totp_subtitle": "輸入驗證器應用程式中的6位數代碼", + "verify": "驗證", + "back_to_login": "返回登入", + "open_console_login": "開啟主控台登入", + "session_expired": "工作階段已過期,請重新登入。", + "fill_all_fields": "請填寫所有欄位", + "invalid_credentials": "憑證無效", + "network_error": "網路錯誤", + "enter_6_digits": "請輸入全部6位數字", + "invalid_code": "代碼無效" } } diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index a75a1e3d..0b23bbec 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -3812,5 +3812,24 @@ "open_panel": "打开控制台", "devices_count": "{count} 台设备", "retry": "重试" + }, + "rdclient_login": { + "title": "远程桌面客户端", + "subtitle": "登录以连接您的设备", + "username_placeholder": "用户名", + "password_placeholder": "密码", + "remember_me": "在此设备上记住我", + "sign_in": "登录", + "totp_title": "双因素认证", + "totp_subtitle": "输入身份验证应用中的6位代码", + "verify": "验证", + "back_to_login": "返回登录", + "open_console_login": "打开控制台登录", + "session_expired": "会话已过期,请重新登录。", + "fill_all_fields": "请填写所有字段", + "invalid_credentials": "凭据无效", + "network_error": "网络错误", + "enter_6_digits": "请输入全部6位数字", + "invalid_code": "代码无效" } } diff --git a/web-nodejs/middleware/auth.js b/web-nodejs/middleware/auth.js index 5f00ca37..ede58e00 100644 --- a/web-nodejs/middleware/auth.js +++ b/web-nodejs/middleware/auth.js @@ -91,6 +91,81 @@ function roleHasPermission(role, permission) { return perms.has(permission); } +/** + * Safe relative return URL for RdClient pages only (/remote…). + */ +function isSafeRdClientReturnUrl(u) { + if (typeof u !== 'string' || u.length === 0) return false; + if (/[\r\n\x00]/.test(u)) return false; + if (!u.startsWith('/remote')) return false; + if (u.startsWith('//') || u.startsWith('/\\')) return false; + if (u.startsWith('/remote/login')) return false; + return true; +} + +function rdClientLoginRedirect(req) { + const requested = req.originalUrl || req.url || '/remote'; + const returnParam = isSafeRdClientReturnUrl(requested) + ? requested + : (isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote'); + return `/remote/login?return=${encodeURIComponent(returnParam)}`; +} + +/** + * RdClient auth — redirects to /remote/login instead of panel /login. + * @param {string} [permission] - optional permission (e.g. device.connect) + */ +function requireRdClientAuth(permission) { + return function (req, res, next) { + if (!req.session || !req.session.userId) { + if (req.path.startsWith('/api/')) { + return res.status(401).json({ success: false, error: 'Unauthorized. Please log in.' }); + } + return res.redirect(rdClientLoginRedirect(req)); + } + + if (req.session.user && req.session.user.role === 'pro') { + req.session.destroy(); + if (req.path.startsWith('/api/')) { + return res.status(403).json({ + success: false, + error: 'Pro accounts can only access the RustDesk desktop client API' + }); + } + return res.redirect(rdClientLoginRedirect(req)); + } + + if (permission) { + const role = req.session.user && req.session.user.role; + if (!roleHasPermission(role, permission)) { + if (req.path.startsWith('/api/')) { + return res.status(403).json({ success: false, error: `Permission denied: ${permission}` }); + } + return res.status(403).render('errors/403', { + title: 'Forbidden', + message: 'You do not have permission to access this resource' + }); + } + } + + res.locals.user = req.session.user; + res.locals.emergencyMode = !!req.session.emergencyMode; + next(); + }; +} + +/** + * RdClient login page — redirect away if already authenticated with device.connect. + */ +function rdClientGuestOnly(req, res, next) { + const role = req.session && req.session.user && req.session.user.role; + if (req.session && req.session.userId && role !== 'pro' && roleHasPermission(role, 'device.connect')) { + const dest = isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote'; + return res.redirect(dest); + } + next(); +} + /** * Require authentication middleware * Redirects to login page for HTML requests, returns 401 for API requests @@ -247,6 +322,9 @@ module.exports = { requireRole, requireAdmin, requirePermission, + requireRdClientAuth, + rdClientGuestOnly, + isSafeRdClientReturnUrl, optionalAuth, guestOnly, roleHasPermission, diff --git a/web-nodejs/public/css/rdclient-login.css b/web-nodejs/public/css/rdclient-login.css new file mode 100644 index 00000000..cd57a753 --- /dev/null +++ b/web-nodejs/public/css/rdclient-login.css @@ -0,0 +1,103 @@ +/** + * RdClient operator login — extends desktop-login styles + */ + +.rdclient-login-page .dl-login-layer { + display: none; +} + +.rdclient-login-page .dl-lock-screen { + display: none; +} + +.rdclient-login-shell { + position: relative; + z-index: 2; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px 32px; +} + +.rdclient-login-wallpaper { + position: fixed; + inset: 0; + z-index: 0; + background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a1a2e 70%, #0f3460 100%); +} + +.rdclient-login-brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 24px; + color: var(--text-primary, #e6edf3); + font-size: 1.125rem; + font-weight: 600; +} + +.rdclient-login-brand .material-icons { + font-size: 28px; + color: var(--accent-blue, #388bfd); +} + +.rdclient-login-card-wrap { + width: 100%; + max-width: 420px; +} + +.rdclient-login-heading { + text-align: center; + margin-bottom: 8px; +} + +.rdclient-login-heading h1 { + margin: 0 0 6px; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #e6edf3); +} + +.rdclient-login-heading p { + margin: 0 0 16px; + font-size: 0.8125rem; + color: var(--text-secondary, #8b949e); +} + +.rdclient-remember { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 12px; + font-size: 0.8125rem; + color: var(--text-secondary, #8b949e); + cursor: pointer; + user-select: none; +} + +.rdclient-remember input { + width: 16px; + height: 16px; + accent-color: var(--accent-blue, #388bfd); +} + +.rdclient-login-footer { + margin-top: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + font-size: 0.75rem; + color: var(--text-secondary, #8b949e); +} + +.rdclient-login-console-link { + color: var(--accent-blue, #388bfd); + text-decoration: none; +} + +.rdclient-login-console-link:hover { + text-decoration: underline; +} diff --git a/web-nodejs/public/js/rdclient-login.js b/web-nodejs/public/js/rdclient-login.js new file mode 100644 index 00000000..4d459f36 --- /dev/null +++ b/web-nodejs/public/js/rdclient-login.js @@ -0,0 +1,261 @@ +/** + * BetterDesk RdClient — operator login (/remote/login) + */ + +(function () { + 'use strict'; + + var DEFAULT_GRADIENT = 'linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a1a2e 70%, #0f3460 100%)'; + + function t(key, fallback) { + if (typeof _ === 'function') { + var v = _(key); + if (v && v !== key) return v; + } + return fallback || key; + } + + function returnUrl() { + var u = (window.BetterDesk && window.BetterDesk.returnUrl) || '/remote'; + if (typeof u !== 'string' || !u.startsWith('/remote')) return '/remote'; + return u; + } + + function redirectAfterLogin() { + window.location.href = returnUrl(); + } + + function showError(el, textEl, msg) { + if (el) el.classList.remove('hidden'); + if (textEl) textEl.textContent = msg; + } + + function hideError(el) { + if (el) el.classList.add('hidden'); + } + + function preloadWallpaper() { + var el = document.getElementById('rdclient-wallpaper'); + if (!el) return; + el.style.background = DEFAULT_GRADIENT; + } + + async function prefillCredentials() { + var store = window.RdClientSecureStore; + if (!store) return; + var usernameInput = document.getElementById('rdclient-username'); + var passwordInput = document.getElementById('rdclient-password'); + var rememberInput = document.getElementById('rdclient-remember'); + if (!usernameInput || !passwordInput) return; + + try { + var creds = await store.loadCredentials(); + if (creds.username) usernameInput.value = creds.username; + if (creds.password) { + passwordInput.value = creds.password; + if (rememberInput) rememberInput.checked = true; + } else if (rememberInput) { + rememberInput.checked = await store.hasStoredPassword(); + } + } catch (_) { /* ignore */ } + } + + function checkSessionExpired() { + if (!(window.BetterDesk && window.BetterDesk.sessionExpired)) return; + showError( + document.getElementById('rdclient-error'), + document.getElementById('rdclient-error-text'), + t('rdclient_login.session_expired', 'Session expired. Please sign in again.') + ); + } + + function bindLoginForm() { + var form = document.getElementById('rdclient-login-form'); + var submitBtn = document.getElementById('rdclient-submit-btn'); + var errorEl = document.getElementById('rdclient-error'); + var errorText = document.getElementById('rdclient-error-text'); + var passwordToggle = document.getElementById('rdclient-password-toggle'); + var passwordInput = document.getElementById('rdclient-password'); + var csrfToken = (window.BetterDesk && window.BetterDesk.csrfToken) || ''; + + if (passwordToggle && passwordInput) { + passwordToggle.addEventListener('click', function () { + var isPassword = passwordInput.type === 'password'; + passwordInput.type = isPassword ? 'text' : 'password'; + passwordToggle.querySelector('.material-icons').textContent = + isPassword ? 'visibility_off' : 'visibility'; + }); + } + + if (!form) return; + + form.addEventListener('submit', function (e) { + e.preventDefault(); + var username = (document.getElementById('rdclient-username').value || '').trim(); + var password = (document.getElementById('rdclient-password').value || ''); + var remember = document.getElementById('rdclient-remember'); + var rememberChecked = remember ? remember.checked : false; + + if (!username || !password) { + showError(errorEl, errorText, t('rdclient_login.fill_all_fields', 'Please fill in all fields')); + return; + } + + submitBtn.classList.add('loading'); + submitBtn.disabled = true; + hideError(errorEl); + + fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ username: username, password: password }) + }) + .then(function (r) { return r.json().then(function (data) { return { data: data }; }); }) + .then(function (result) { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + + if (result.data.success && result.data.totpRequired) { + showTotpForm(); + return; + } + + if (result.data.success) { + var store = window.RdClientSecureStore; + if (store) { + store.saveCredentials(username, password, rememberChecked) + .finally(redirectAfterLogin); + } else { + redirectAfterLogin(); + } + return; + } + + showError(errorEl, errorText, result.data.error || t('rdclient_login.invalid_credentials', 'Invalid credentials')); + }) + .catch(function () { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + showError(errorEl, errorText, t('rdclient_login.network_error', 'Network error')); + }); + }); + } + + function showTotpForm() { + document.getElementById('rdclient-login-form').classList.add('hidden'); + var totpForm = document.getElementById('rdclient-totp-form'); + if (totpForm) { + totpForm.classList.remove('hidden'); + var first = totpForm.querySelector('.dl-totp-digit[data-idx="0"]'); + if (first) setTimeout(function () { first.focus(); }, 100); + } + } + + function hideTotpForm() { + document.getElementById('rdclient-login-form').classList.remove('hidden'); + document.getElementById('rdclient-totp-form').classList.add('hidden'); + document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { d.value = ''; }); + } + + function getTotpCode() { + var code = ''; + document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { code += d.value; }); + return code; + } + + function bindTotpForm() { + var form = document.getElementById('rdclient-totp-form'); + var submitBtn = document.getElementById('rdclient-totp-submit'); + var errorEl = document.getElementById('rdclient-totp-error'); + var errorText = document.getElementById('rdclient-totp-error-text'); + var backLink = document.getElementById('rdclient-totp-back-link'); + var csrfToken = (window.BetterDesk && window.BetterDesk.csrfToken) || ''; + + if (!form) return; + + var digits = form.querySelectorAll('.dl-totp-digit'); + digits.forEach(function (digit, idx) { + digit.addEventListener('input', function () { + var val = digit.value.replace(/\D/g, ''); + digit.value = val.substring(0, 1); + if (val && idx < 5) digits[idx + 1].focus(); + if (getTotpCode().length === 6) submitTotp(); + }); + digit.addEventListener('keydown', function (e) { + if (e.key === 'Backspace' && !digit.value && idx > 0) { + digits[idx - 1].focus(); + digits[idx - 1].value = ''; + } + }); + }); + + form.addEventListener('submit', function (e) { + e.preventDefault(); + submitTotp(); + }); + + function submitTotp() { + var code = getTotpCode(); + if (code.length !== 6) { + showError(errorEl, errorText, t('rdclient_login.enter_6_digits', 'Enter all 6 digits')); + return; + } + submitBtn.classList.add('loading'); + submitBtn.disabled = true; + hideError(errorEl); + + fetch('/api/auth/totp/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + credentials: 'same-origin', + body: JSON.stringify({ code: code }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + if (data.success) { + redirectAfterLogin(); + return; + } + showError(errorEl, errorText, data.error || t('rdclient_login.invalid_code', 'Invalid code')); + document.querySelectorAll('#rdclient-totp-form .dl-totp-digit').forEach(function (d) { d.value = ''; }); + if (digits[0]) digits[0].focus(); + }) + .catch(function () { + submitBtn.classList.remove('loading'); + submitBtn.disabled = false; + showError(errorEl, errorText, t('rdclient_login.network_error', 'Network error')); + }); + } + + if (backLink) { + backLink.addEventListener('click', function (e) { + e.preventDefault(); + hideTotpForm(); + hideError(errorEl); + }); + } + } + + function init() { + preloadWallpaper(); + prefillCredentials(); + checkSessionExpired(); + bindLoginForm(); + bindTotpForm(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/web-nodejs/public/js/rdclientSecureStore.js b/web-nodejs/public/js/rdclientSecureStore.js new file mode 100644 index 00000000..3e868b7c --- /dev/null +++ b/web-nodejs/public/js/rdclientSecureStore.js @@ -0,0 +1,213 @@ +/** + * BetterDesk RdClient — encrypted credential storage (browser). + * Passwords are stored in IndexedDB with AES-GCM (Web Crypto); never plaintext in localStorage. + */ +var RdClientSecureStore = (function () { + 'use strict'; + + var DB_NAME = 'betterdesk-rdclient'; + var DB_VERSION = 1; + var STORE = 'vault'; + var KEY_ID = 'device-key'; + var CRED_ID = 'credentials'; + var USERNAME_KEY = 'bd_rdclient_last_user'; + + function openDb() { + return new Promise(function (resolve, reject) { + if (!window.indexedDB) { + reject(new Error('IndexedDB unavailable')); + return; + } + var req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = function () { reject(req.error || new Error('IndexedDB open failed')); }; + req.onupgradeneeded = function (ev) { + var db = ev.target.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE); + } + }; + req.onsuccess = function () { resolve(req.result); }; + }); + } + + function idbGet(db, key) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE, 'readonly'); + var req = tx.objectStore(STORE).get(key); + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function idbPut(db, key, value) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE, 'readwrite'); + var req = tx.objectStore(STORE).put(value, key); + req.onsuccess = function () { resolve(); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function idbDelete(db, key) { + return new Promise(function (resolve, reject) { + var tx = db.transaction(STORE, 'readwrite'); + var req = tx.objectStore(STORE).delete(key); + req.onsuccess = function () { resolve(); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function bufToB64(buf) { + var bytes = new Uint8Array(buf); + var bin = ''; + for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); + } + + function b64ToBuf(b64) { + var bin = atob(b64); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + async function getOrCreateDeviceKey(db) { + var existing = await idbGet(db, KEY_ID); + if (existing && existing.key) { + return crypto.subtle.importKey( + 'raw', + b64ToBuf(existing.key), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + var raw = crypto.getRandomValues(new Uint8Array(32)); + await idbPut(db, KEY_ID, { key: bufToB64(raw.buffer) }); + return crypto.subtle.importKey( + 'raw', + raw, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + async function encryptPassword(key, password) { + var iv = crypto.getRandomValues(new Uint8Array(12)); + var enc = new TextEncoder().encode(password); + var cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, enc); + return { iv: bufToB64(iv.buffer), data: bufToB64(cipher) }; + } + + async function decryptPassword(key, payload) { + if (!payload || !payload.iv || !payload.data) return ''; + var iv = new Uint8Array(b64ToBuf(payload.iv)); + var data = b64ToBuf(payload.data); + var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, data); + return new TextDecoder().decode(plain); + } + + function saveLastUsername(username) { + try { + if (username) localStorage.setItem(USERNAME_KEY, username); + else localStorage.removeItem(USERNAME_KEY); + } catch (_) { /* quota / private mode */ } + } + + function loadLastUsername() { + try { + return localStorage.getItem(USERNAME_KEY) || ''; + } catch (_) { + return ''; + } + } + + async function saveCredentials(username, password, rememberPassword) { + saveLastUsername(username); + if (!rememberPassword || !password) { + await clearStoredPassword(); + return; + } + if (!window.crypto || !window.crypto.subtle) return; + var db = await openDb(); + try { + var key = await getOrCreateDeviceKey(db); + var encrypted = await encryptPassword(key, password); + await idbPut(db, CRED_ID, { + username: username, + password: encrypted, + rememberPassword: true, + updatedAt: Date.now() + }); + } finally { + db.close(); + } + } + + async function clearStoredPassword() { + if (!window.indexedDB) return; + var db = await openDb(); + try { + await idbDelete(db, CRED_ID); + } finally { + db.close(); + } + } + + async function clearCredentials() { + saveLastUsername(''); + await clearStoredPassword(); + } + + async function loadCredentials() { + var username = loadLastUsername(); + var result = { username: username, password: '', rememberPassword: false }; + if (!window.crypto || !window.crypto.subtle || !window.indexedDB) { + return result; + } + var db = await openDb(); + try { + var record = await idbGet(db, CRED_ID); + if (!record || !record.rememberPassword || !record.password) { + return result; + } + var key = await getOrCreateDeviceKey(db); + var password = await decryptPassword(key, record.password); + result.username = record.username || username; + result.password = password; + result.rememberPassword = true; + return result; + } catch (_) { + return result; + } finally { + db.close(); + } + } + + async function hasStoredPassword() { + if (!window.indexedDB) return false; + var db = await openDb(); + try { + var record = await idbGet(db, CRED_ID); + return !!(record && record.rememberPassword && record.password); + } catch (_) { + return false; + } finally { + db.close(); + } + } + + return { + saveCredentials: saveCredentials, + loadCredentials: loadCredentials, + clearCredentials: clearCredentials, + clearStoredPassword: clearStoredPassword, + hasStoredPassword: hasStoredPassword, + loadLastUsername: loadLastUsername + }; +})(); + +if (typeof window !== 'undefined') { + window.RdClientSecureStore = RdClientSecureStore; +} diff --git a/web-nodejs/public/js/utils.js b/web-nodejs/public/js/utils.js index 2701abde..27f33216 100644 --- a/web-nodejs/public/js/utils.js +++ b/web-nodejs/public/js/utils.js @@ -252,8 +252,13 @@ const Utils = { return data; } catch (error) { if (error.status === 401) { - // Redirect to login on auth error - window.location.href = '/login'; + var path = window.location.pathname || ''; + if (path.startsWith('/remote') && !path.startsWith('/remote/login')) { + window.location.href = '/remote/login?return=' + + encodeURIComponent(path + (window.location.search || '')) + '&expired=1'; + } else { + window.location.href = '/login'; + } } throw error; } diff --git a/web-nodejs/routes/remote.routes.js b/web-nodejs/routes/remote.routes.js index 197e4ef8..c82f5f43 100644 --- a/web-nodejs/routes/remote.routes.js +++ b/web-nodejs/routes/remote.routes.js @@ -8,7 +8,7 @@ const router = express.Router(); const fs = require('fs'); const db = require('../services/database'); const config = require('../config/config'); -const { requireAuth, requirePermission } = require('../middleware/auth'); +const { requireRdClientAuth, rdClientGuestOnly, isSafeRdClientReturnUrl } = require('../middleware/auth'); // Lazy-loaded relay helper — avoid circular require at module load time function getRemoteRelay() { @@ -25,10 +25,24 @@ try { console.warn('Warning: Could not read server public key:', err.message); } +/** + * GET /remote/login - RdClient operator login (when panel session expired) + */ +router.get('/remote/login', rdClientGuestOnly, (req, res) => { + const returnUrl = isSafeRdClientReturnUrl(req.query.return) ? req.query.return : '/remote'; + const sessionExpired = req.query.expired === '1' || req.query.expired === 'true'; + res.render('rdclient-login', { + title: req.t('rdclient_login.title'), + activePage: 'remote', + returnUrl, + sessionExpired, + }); +}); + /** * GET /remote - RdClient operator dashboard (device list + connect) */ -router.get('/remote', requireAuth, requirePermission('device.connect'), (req, res) => { +router.get('/remote', requireRdClientAuth('device.connect'), (req, res) => { res.render('remote-dashboard', { title: req.t('remote_dashboard.title'), activePage: 'remote', @@ -37,25 +51,11 @@ router.get('/remote', requireAuth, requirePermission('device.connect'), (req, re /** * GET /remote/:deviceId - Unified remote desktop viewer (single entry point). - * - * Phase 2.1 of the unification plan: this route is now the only canonical - * URL for browser-based remote desktop. The transport (RustDesk relay vs. - * CDAP WebSocket) is auto-detected on the server by probing the Go server - * for `device_type` and `cdap_connected`. The decision is then passed to - * the appropriate template. - * - * Query overrides: - * ?transport=cdap → force CDAP transport (skip auto-probe) - * ?transport=rd → force RustDesk transport - * - * Until the unified `remote.ejs` shell lands (PR 2.2 / 2.3) we still render - * the existing two templates underneath. Operators get a single URL and - * shareable links work regardless of which transport is active. */ -router.get('/remote/:deviceId', requireAuth, async (req, res) => { +router.get('/remote/:deviceId', requireRdClientAuth('device.connect'), async (req, res) => { const deviceId = req.params.deviceId; - if (!deviceId || !/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) { + if (!deviceId || deviceId === 'login' || !/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) { return res.redirect('/devices'); } @@ -66,8 +66,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => { // Database lookup failure is non-blocking - viewer can still work } - // Probe Go server for authoritative transport hint. Local panel DB - // does not carry `device_type` or `cdap_connected`. let isOsAgent = false; let isCdapConnected = false; let goPeer = null; @@ -80,7 +78,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => { } } catch { /* non-fatal: degrade to standard viewer */ } - // Resolve transport: explicit query param wins, then auto-detect. const forced = String(req.query.transport || '').toLowerCase(); let transport; if (forced === 'cdap' || forced === 'rd') { @@ -91,8 +88,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => { transport = 'rd'; } - // Capability hints exposed to the browser so the unified UI can light - // up the right toolbar buttons. const capabilities = { transport, os_agent: isOsAgent, @@ -100,11 +95,6 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => { device_type: goPeer && goPeer.device_type ? String(goPeer.device_type) : '', }; - // PR 2.2/2.3 unification: a single canonical web client (`remote.ejs`) - // serves both transports. The browser branches on - // `window.__capabilities.transport`. The legacy `remote-cdap` template - // is no longer rendered; its inline widget remains usable from - // device-detail panels via `cdap-desktop.js` directly. res.render('remote', { title: `${req.t('remote.title')} - ${deviceId}`, activePage: 'remote', @@ -118,12 +108,8 @@ router.get('/remote/:deviceId', requireAuth, async (req, res) => { /** * GET /remote-cdap/:deviceId - Legacy alias, redirects to unified entry. - * - * Kept for backwards compatibility with existing bookmarks, deep links, and - * the `devices.js` "Connect" button. New code should link to - * `/remote/:deviceId` directly. */ -router.get('/remote-cdap/:deviceId', requireAuth, (req, res) => { +router.get('/remote-cdap/:deviceId', requireRdClientAuth('device.connect'), (req, res) => { const deviceId = req.params.deviceId; if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) { return res.redirect(`/remote/${encodeURIComponent(deviceId)}?transport=cdap`); @@ -133,12 +119,8 @@ router.get('/remote-cdap/:deviceId', requireAuth, (req, res) => { /** * GET /remote-desktop/:deviceId - Legacy route, redirects to unified /remote/:deviceId - * - * Previously served a separate JPEG stream viewer. The web remote client has - * been unified: `/remote/:deviceId` is now the only canonical entry point for - * browser-based remote desktop. */ -router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => { +router.get('/remote-desktop/:deviceId', requireRdClientAuth('device.connect'), (req, res) => { const deviceId = req.params.deviceId; if (deviceId && /^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) { return res.redirect(`/remote/${encodeURIComponent(deviceId)}`); @@ -149,7 +131,7 @@ router.get('/remote-desktop/:deviceId', requireAuth, (req, res) => { /** * GET /api/remote/sessions - List active native remote sessions */ -router.get('/api/remote/sessions', requireAuth, (req, res) => { +router.get('/api/remote/sessions', requireRdClientAuth(), (req, res) => { const relay = getRemoteRelay(); if (!relay) return res.json({ sessions: [] }); const sessions = relay.getActiveSessions(); @@ -159,7 +141,7 @@ router.get('/api/remote/sessions', requireAuth, (req, res) => { /** * GET /api/remote/session/:deviceId - Get state of a single native remote session */ -router.get('/api/remote/session/:deviceId', requireAuth, (req, res) => { +router.get('/api/remote/session/:deviceId', requireRdClientAuth(), (req, res) => { const relay = getRemoteRelay(); if (!relay) return res.status(404).json({ error: 'Remote relay not available' }); const state = relay.getSessionState(req.params.deviceId); diff --git a/web-nodejs/tests/middleware.auth.test.js b/web-nodejs/tests/middleware.auth.test.js index 00e55838..867c5ace 100644 --- a/web-nodejs/tests/middleware.auth.test.js +++ b/web-nodejs/tests/middleware.auth.test.js @@ -5,7 +5,7 @@ const request = require('supertest'); const { createTestApp } = require('./helpers'); -const { requireAuth, requireRole, guestOnly } = require('../middleware/auth'); +const { requireAuth, requireRole, guestOnly, requireRdClientAuth, rdClientGuestOnly, isSafeRdClientReturnUrl } = require('../middleware/auth'); describe('Auth Middleware', () => { describe('requireAuth', () => { @@ -141,4 +141,74 @@ describe('Auth Middleware', () => { expect(res.headers.location).toBe('/'); }); }); + + describe('requireRdClientAuth', () => { + it('should redirect unauthenticated HTML requests to /remote/login', async () => { + const app = createTestApp(); + app.get('/remote', requireRdClientAuth('device.connect'), (_req, res) => { + res.send('OK'); + }); + + const res = await request(app).get('/remote'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/remote/login?return=%2Fremote'); + }); + + it('should return 401 for unauthenticated API requests', async () => { + const app = createTestApp(); + app.get('/api/remote/sessions', requireRdClientAuth(), (_req, res) => { + res.json({ sessions: [] }); + }); + + const res = await request(app).get('/api/remote/sessions'); + + expect(res.status).toBe(401); + }); + + it('should pass through for authenticated operator with permission', async () => { + const app = createTestApp(); + app.use((req, _res, next) => { + req.session.userId = 2; + req.session.user = { id: 2, username: 'operator1', role: 'operator' }; + next(); + }); + app.get('/remote', requireRdClientAuth('device.connect'), (_req, res) => { + res.send('OK'); + }); + + const res = await request(app).get('/remote'); + + expect(res.status).toBe(200); + }); + }); + + describe('isSafeRdClientReturnUrl', () => { + it('should accept /remote paths only', () => { + expect(isSafeRdClientReturnUrl('/remote')).toBe(true); + expect(isSafeRdClientReturnUrl('/remote/abc123')).toBe(true); + expect(isSafeRdClientReturnUrl('/login')).toBe(false); + expect(isSafeRdClientReturnUrl('/remote/login')).toBe(false); + expect(isSafeRdClientReturnUrl('//evil.com/remote')).toBe(false); + }); + }); + + describe('rdClientGuestOnly', () => { + it('should redirect authenticated operator to return URL', async () => { + const app = createTestApp(); + app.use((req, _res, next) => { + req.session.userId = 2; + req.session.user = { id: 2, username: 'operator1', role: 'operator' }; + next(); + }); + app.get('/remote/login', rdClientGuestOnly, (_req, res) => { + res.send('login'); + }); + + const res = await request(app).get('/remote/login?return=%2Fremote%2Fdev1'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/remote/dev1'); + }); + }); }); diff --git a/web-nodejs/views/rdclient-login.ejs b/web-nodejs/views/rdclient-login.ejs new file mode 100644 index 00000000..279eef22 --- /dev/null +++ b/web-nodejs/views/rdclient-login.ejs @@ -0,0 +1,124 @@ + + + + + + + <%= _('rdclient_login.title') %> - <%= appName %> + + + + + + + + + +
+
+
+ +
+
+ connected_tv + <%= appName %> +
+ +
+
+ + +
+ person +
+ +
+
+ +
+
+
+ + +
+
+ + + + + + +
+ + +
+
+ + +
+ + + + + + +