From e1a197f2be810f867201d1f2cbfab852e5d70bef Mon Sep 17 00:00:00 2001 From: Yuri Date: Thu, 1 Jan 2026 15:36:38 -0500 Subject: [PATCH] implement authentication and authorization --- package-lock.json | 234 ++++++++++++++++++++++++ package.json | 6 + public/css/main.css | 71 +++++++- public/index.html | 49 +++++ public/js/api.js | 20 +++ public/js/app.js | 80 +++++++++ public/js/auth.js | 144 +++++++++++++++ public/js/components/VideoPlayer.js | 5 + public/js/login.js | 14 ++ public/js/pages/Settings.js | 109 ++++++++++++ public/login.html | 265 ++++++++++++++++++++++++++++ server/auth.js | 151 ++++++++++++++++ server/db.js | 105 ++++++++++- server/index.js | 6 + server/routes/auth.js | 261 +++++++++++++++++++++++++++ server/routes/users.js | 186 +++++++++++++++++++ 16 files changed, 1704 insertions(+), 2 deletions(-) create mode 100644 public/js/auth.js create mode 100644 public/js/login.js create mode 100644 public/login.html create mode 100644 server/auth.js create mode 100644 server/routes/auth.js create mode 100644 server/routes/users.js diff --git a/package-lock.json b/package-lock.json index c62c745..1cb44e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,13 @@ "version": "1.0.0", "license": "GPL-3.0-only", "dependencies": { + "bcryptjs": "^3.0.3", "express": "^4.18.2", + "express-session": "^1.18.2", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "sax": "^1.4.3", "xml2js": "^0.6.2" }, @@ -100,6 +106,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -124,6 +139,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -270,6 +291,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -386,6 +416,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ffmpeg-static": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", @@ -617,6 +666,97 @@ "node": ">= 0.10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -725,6 +865,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parse-cache-control": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", @@ -740,12 +889,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -784,6 +985,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -855,6 +1065,18 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1026,6 +1248,18 @@ "license": "MIT", "optional": true }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 799d410..0614e56 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,13 @@ "dev": "node --watch server/index.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "express": "^4.18.2", + "express-session": "^1.18.2", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "sax": "^1.4.3", "xml2js": "^0.6.2" }, diff --git a/public/css/main.css b/public/css/main.css index f51b7bf..a1dff48 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -3062,4 +3062,73 @@ kbd { .spin { animation: spin 1s linear infinite; -} \ No newline at end of file +} +/* User Management Styles */ +.user-list-container { + margin: var(--space-lg) 0; + overflow-x: auto; +} + +.user-table { + width: 100%; + border-collapse: collapse; + background: var(--color-bg-secondary); + border-radius: var(--radius-md); + overflow: hidden; +} + +.user-table thead { + background: var(--color-bg-tertiary); +} + +.user-table th { + padding: var(--space-md); + text-align: left; + font-weight: 600; + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border); +} + +.user-table td { + padding: var(--space-md); + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); +} + +.user-table tbody tr:last-child td { + border-bottom: none; +} + +.user-table tbody tr:hover { + background: var(--color-bg-hover); +} + +.user-table .btn { + padding: var(--space-xs) var(--space-sm); + margin: 0 var(--space-xs); + font-size: 14px; +} + +.add-user-section { + margin-top: var(--space-2xl); + padding: var(--space-lg); + background: var(--color-bg-secondary); + border-radius: var(--radius-md); +} + +.user-form { + display: grid; + gap: var(--space-md); + max-width: 500px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + margin-bottom: var(--space-xs); + color: var(--color-text-primary); + font-weight: 500; +} diff --git a/public/index.html b/public/index.html index 1f59960..3558bd2 100644 --- a/public/index.html +++ b/public/index.html @@ -280,6 +280,7 @@ + @@ -475,6 +476,54 @@ + + +
+
+

User Management

+

Manage user accounts and permissions

+ +
+ + + + + + + + + + + + + + +
UsernameRoleCreatedActions
Loading users...
+
+ +
+

Add New User

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
diff --git a/public/js/api.js b/public/js/api.js index dfaae9c..b99fb45 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -13,6 +13,12 @@ const API = { 'Content-Type': 'application/json' } }; + + // Add authentication token if available + const token = localStorage.getItem('authToken'); + if (token) { + options.headers['Authorization'] = `Bearer ${token}`; + } if (data) { options.body = JSON.stringify(data); @@ -30,6 +36,12 @@ const API = { } if (!response.ok) { + // If unauthorized, redirect to login + if (response.status === 401) { + localStorage.removeItem('authToken'); + window.location.href = '/login.html'; + return; + } throw new Error(result.error || `Server responded with ${response.status}`); } @@ -120,6 +132,14 @@ const API = { update: (data) => API.request('PUT', '/settings', data), reset: () => API.request('DELETE', '/settings'), getDefaults: () => API.request('GET', '/settings/defaults') + }, + + // Users (admin only) + users: { + getAll: () => API.request('GET', '/auth/users'), + create: (data) => API.request('POST', '/auth/users', data), + update: (id, data) => API.request('PUT', `/auth/users/${id}`, data), + delete: (id) => API.request('DELETE', `/auth/users/${id}`) } }; diff --git a/public/js/app.js b/public/js/app.js index 4e3b0db..b4edda5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -6,6 +6,7 @@ class App { constructor() { this.currentPage = 'home'; this.pages = {}; + this.currentUser = null; // Initialize components this.player = new VideoPlayer(); @@ -24,6 +25,9 @@ class App { } async init() { + // Check authentication first + await this.checkAuth(); + // Mobile menu toggle const mobileMenuToggle = document.getElementById('mobile-menu-toggle'); const navbarMenu = document.getElementById('navbar-menu'); @@ -110,6 +114,82 @@ class App { console.log('NodeCast TV initialized'); } + async checkAuth() { + const token = localStorage.getItem('authToken'); + + if (!token) { + // No token, redirect to login + window.location.href = '/login.html'; + return; + } + + try { + // Verify token with server + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + throw new Error('Invalid token'); + } + + this.currentUser = await response.json(); + + // Hide settings for viewers + if (this.currentUser.role === 'viewer') { + const settingsLink = document.querySelector('.nav-link[data-page="settings"]'); + if (settingsLink) { + settingsLink.style.display = 'none'; + } + } + + // Add logout button to navbar + this.addLogoutButton(); + + } catch (err) { + console.error('Authentication error:', err); + localStorage.removeItem('authToken'); + window.location.href = '/login.html'; + } + } + + addLogoutButton() { + const navbar = document.querySelector('.navbar-menu'); + if (!navbar || document.getElementById('logout-btn')) return; + + const logoutLink = document.createElement('a'); + logoutLink.href = '#'; + logoutLink.className = 'nav-link'; + logoutLink.id = 'logout-btn'; + logoutLink.innerHTML = ` + + + + Logout + `; + + logoutLink.addEventListener('click', async (e) => { + e.preventDefault(); + + const token = localStorage.getItem('authToken'); + if (token) { + await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + } + + localStorage.removeItem('authToken'); + window.location.href = '/login.html'; + }); + + navbar.appendChild(logoutLink); + } + navigateTo(pageName) { // Update nav document.querySelectorAll('.nav-link').forEach(link => { diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..fe25b7b --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,144 @@ +/** + * Auth Manager - Frontend authentication state management + */ + +const Auth = { + currentUser: null, + + /** + * Initialize auth - check setup status and current user + */ + async init() { + try { + // Check if setup is required + const setupStatus = await API.auth.checkSetup(); + if (setupStatus.setupRequired) { + this.showSetup(); + return false; + } + + // Check if user is logged in + if (API.getToken()) { + try { + this.currentUser = await API.auth.me(); + return true; + } catch (error) { + // Token invalid, clear it + console.log('Token invalid, showing login'); + API.setToken(null); + this.showLogin(); + return false; + } + } else { + this.showLogin(); + return false; + } + } catch (error) { + console.error('Auth initialization failed:', error); + // On any error, show login (don't loop) + this.showLogin(); + return false; + } + }, + + /** + * Show setup screen + */ + showSetup() { + document.getElementById('setup-screen').classList.add('active'); + document.getElementById('login-screen').classList.remove('active'); + document.getElementById('app').classList.remove('active'); + }, + + /** + * Show login screen + */ + showLogin() { + document.getElementById('setup-screen').classList.remove('active'); + document.getElementById('login-screen').classList.add('active'); + document.getElementById('app').classList.remove('active'); + }, + + /** + * Show main app + */ + showApp() { + document.getElementById('setup-screen').classList.remove('active'); + document.getElementById('login-screen').classList.remove('active'); + document.getElementById('app').classList.add('active'); + + // Hide settings tab if viewer + if (!this.isAdmin()) { + const settingsLink = document.querySelector('[data-page="settings"]'); + if (settingsLink) { + settingsLink.parentElement.style.display = 'none'; + } + } + }, + + /** + * Setup initial admin user + */ + async setup(username, password) { + try { + const result = await API.auth.setup(username, password); + API.setToken(result.token); + this.currentUser = result.user; + this.showApp(); + return true; + } catch (error) { + throw error; + } + }, + + /** + * Login user + */ + async login(username, password) { + try { + const result = await API.auth.login(username, password); + API.setToken(result.token); + this.currentUser = result.user; + this.showApp(); + return true; + } catch (error) { + throw error; + } + }, + + /** + * Logout user + */ + async logout() { + try { + await API.auth.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + API.setToken(null); + this.currentUser = null; + this.showLogin(); + } + }, + + /** + * Check if current user is admin + */ + isAdmin() { + return this.currentUser && this.currentUser.role === 'admin'; + }, + + /** + * Check if current user is viewer + */ + isViewer() { + return this.currentUser && this.currentUser.role === 'viewer'; + }, + + /** + * Get current user + */ + getCurrentUser() { + return this.currentUser; + } +}; diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 1fafab2..d642a32 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -3,6 +3,11 @@ * Handles HLS video playback with custom controls */ +// Check if device is mobile +function isMobile() { + return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); +} + class VideoPlayer { constructor() { this.video = document.getElementById('video-player'); diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..a246d10 --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,14 @@ +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const response = await API.request('POST', '/auth/login', { username, password }); + localStorage.setItem('sessionToken', response.token); + window.location.href = '/'; + } catch (err) { + document.getElementById('login-error').textContent = 'Login failed: ' + err.message; + } +}); \ No newline at end of file diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index b61e238..bb2f21c 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -19,6 +19,9 @@ class SettingsPage { // Player settings this.initPlayerSettings(); + + // User management (admin only) + this.initUserManagement(); } initPlayerSettings() { @@ -130,6 +133,99 @@ class SettingsPage { } } + initUserManagement() { + // User tab visibility is handled in show() method + // when currentUser is available + + // Handle add user form + const addUserForm = document.getElementById('add-user-form'); + if (addUserForm) { + addUserForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const username = document.getElementById('new-username').value; + const password = document.getElementById('new-password').value; + const role = document.getElementById('new-role').value; + + try { + await API.users.create({ username, password, role }); + alert('User created successfully!'); + addUserForm.reset(); + this.loadUsers(); + } catch (err) { + alert('Error creating user: ' + err.message); + } + }); + } + } + + async loadUsers() { + const userList = document.getElementById('user-list'); + if (!userList) return; + + try { + const users = await API.users.getAll(); + + if (users.length === 0) { + userList.innerHTML = 'No users found'; + return; + } + + userList.innerHTML = users.map(user => ` + + ${user.username} + ${user.role} + ${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'} + + + + + + `).join(''); + } catch (err) { + console.error('Error loading users:', err); + userList.innerHTML = 'Error loading users'; + } + } + + async editUser(userId) { + const username = prompt('Enter new username (leave blank to keep current):'); + const password = prompt('Enter new password (leave blank to keep current):'); + const role = prompt('Enter role (admin or viewer, leave blank to keep current):'); + + const updates = {}; + if (username) updates.username = username; + if (password) updates.password = password; + if (role) updates.role = role; + + if (Object.keys(updates).length === 0) { + alert('No changes made'); + return; + } + + try { + await API.users.update(userId, updates); + alert('User updated successfully!'); + this.loadUsers(); + } catch (err) { + alert('Error updating user: ' + err.message); + } + } + + async deleteUser(userId, username) { + if (!confirm(`Are you sure you want to delete user "${username}"?`)) { + return; + } + + try { + await API.users.delete(userId); + alert('User deleted successfully!'); + this.loadUsers(); + } catch (err) { + alert('Error deleting user: ' + err.message); + } + } + switchTab(tabName) { this.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName)); this.tabContents.forEach(c => c.classList.toggle('active', c.id === `tab-${tabName}`)); @@ -138,9 +234,22 @@ class SettingsPage { if (tabName === 'content') { this.app.sourceManager.loadContentSources(); } + + // Load users when switching to users tab + if (tabName === 'users') { + this.loadUsers(); + } } async show() { + // Show users tab for admin + if (this.app.currentUser && this.app.currentUser.role === 'admin') { + const usersTab = document.getElementById('users-tab'); + if (usersTab) { + usersTab.style.display = 'block'; + } + } + // Load sources when page is shown await this.app.sourceManager.loadSources(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..58ed322 --- /dev/null +++ b/public/login.html @@ -0,0 +1,265 @@ + + + + + + Login - NodeCast TV + + + + + +
+ +
+ + + + diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..e87dc7a --- /dev/null +++ b/server/auth.js @@ -0,0 +1,151 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const passport = require('passport'); +const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { Strategy: LocalStrategy } = require('passport-local'); + +/** + * Authentication and Authorization Module + * Handles user authentication, session management, and role-based access control + * Using Passport.js with JWT tokens + */ + +// JWT Secret - In production, use environment variable +const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production'; +const JWT_EXPIRY = '24h'; + +/** + * Hash password using bcrypt + */ +async function hashPassword(password) { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +/** + * Verify password against hash + */ +async function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); +} + +/** + * Generate JWT token + */ +function generateToken(user) { + return jwt.sign( + { + id: user.id, + username: user.username, + role: user.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRY } + ); +} + +/** + * Verify JWT token + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (err) { + return null; + } +} + +/** + * Configure Passport Local Strategy for username/password authentication + */ +function configureLocalStrategy(getUserByUsername, verifyUserPassword) { + passport.use(new LocalStrategy( + async (username, password, done) => { + try { + const user = await getUserByUsername(username); + + if (!user) { + return done(null, false, { message: 'Invalid credentials' }); + } + + const isValid = await verifyUserPassword(password, user.passwordHash); + + if (!isValid) { + return done(null, false, { message: 'Invalid credentials' }); + } + + return done(null, user); + } catch (err) { + return done(err); + } + } + )); +} + +/** + * Configure Passport JWT Strategy for token-based authentication + */ +function configureJwtStrategy(getUserById) { + const options = { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: JWT_SECRET + }; + + passport.use(new JwtStrategy(options, async (payload, done) => { + try { + const user = await getUserById(payload.id); + + if (!user) { + return done(null, false); + } + + return done(null, { + id: user.id, + username: user.username, + role: user.role + }); + } catch (err) { + return done(err, false); + } + })); +} + +/** + * Middleware: Require authentication using Passport JWT + */ +const requireAuth = passport.authenticate('jwt', { session: false }); + +/** + * Middleware: Require admin role + */ +function requireAdmin(req, res, next) { + if (!req.user || req.user.role !== 'admin') { + return res.status(403).json({ error: 'Forbidden - Admin access required' }); + } + next(); +} + +/** + * Middleware: Check for specific role + */ +function requireRole(role) { + return (req, res, next) => { + if (!req.user || req.user.role !== role) { + return res.status(403).json({ error: `Forbidden - ${role} access required` }); + } + next(); + }; +} + +module.exports = { + passport, + hashPassword, + verifyPassword, + generateToken, + verifyToken, + configureLocalStrategy, + configureJwtStrategy, + requireAuth, + requireAdmin, + requireRole +}; diff --git a/server/db.js b/server/db.js index 999e599..cb5765f 100644 --- a/server/db.js +++ b/server/db.js @@ -22,6 +22,7 @@ async function loadDb() { hiddenItems: data.hiddenItems || [], favorites: data.favorites || [], settings: data.settings || getDefaultSettings(), + users: data.users || [], nextId: data.nextId || 1 }; } catch (error) { @@ -32,6 +33,7 @@ async function loadDb() { hiddenItems: [], favorites: [], settings: getDefaultSettings(), + users: [], nextId: 1 }; } @@ -45,6 +47,7 @@ async function loadDb() { hiddenItems: [], favorites: [], settings: getDefaultSettings(), + users: [], nextId: 1 }; } @@ -311,4 +314,104 @@ const settings = { } }; -module.exports = { sources, hiddenItems, favorites, settings, getDefaultSettings }; +// User operations +const users = { + async getAll() { + const db = await loadDb(); + return db.users || []; + }, + + async getById(id) { + const db = await loadDb(); + return db.users?.find(u => u.id === parseInt(id)); + }, + + async getByUsername(username) { + const db = await loadDb(); + return db.users?.find(u => u.username === username); + }, + + async create(userData) { + const db = await loadDb(); + if (!db.users) { + db.users = []; + } + + // Check if username already exists + if (db.users.some(u => u.username === userData.username)) { + throw new Error('Username already exists'); + } + + const newUser = { + id: db.nextId++, + username: userData.username, + passwordHash: userData.passwordHash, + role: userData.role || 'viewer', + createdAt: new Date().toISOString() + }; + + db.users.push(newUser); + await saveDb(db); + + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = newUser; + return userWithoutPassword; + }, + + async update(id, updates) { + const db = await loadDb(); + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); + } + + // Check if username is being changed and if it already exists + if (updates.username && updates.username !== db.users[userIndex].username) { + if (db.users.some(u => u.username === updates.username)) { + throw new Error('Username already exists'); + } + } + + db.users[userIndex] = { + ...db.users[userIndex], + ...updates, + updatedAt: new Date().toISOString() + }; + + await saveDb(db); + + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = db.users[userIndex]; + return userWithoutPassword; + }, + + async delete(id) { + const db = await loadDb(); + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); + } + + // Prevent deleting the last admin + const user = db.users[userIndex]; + if (user.role === 'admin') { + const adminCount = db.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + throw new Error('Cannot delete the last admin user'); + } + } + + db.users.splice(userIndex, 1); + await saveDb(db); + return true; + }, + + async count() { + const db = await loadDb(); + return db.users?.length || 0; + } +}; + +module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings }; diff --git a/server/index.js b/server/index.js index c542ed8..e72218a 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,6 @@ const express = require('express'); const path = require('path'); +const passport = require('passport'); // Initialize database require('./db'); @@ -13,6 +14,10 @@ app.set('trust proxy', true); // Middleware app.use(express.json({ limit: '50mb' })); + +// Initialize Passport +app.use(passport.initialize()); + app.use(express.static(path.join(__dirname, '..', 'public'))); // FFMPEG Configuration (optional - for transcoding support) @@ -49,6 +54,7 @@ function findFFmpeg() { app.locals.ffmpegPath = findFFmpeg(); // API Routes +app.use('/api/auth', require('./routes/auth')); app.use('/api/sources', require('./routes/sources')); app.use('/api/proxy', require('./routes/proxy')); app.use('/api/channels', require('./routes/channels')); diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..9d3ba96 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,261 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const auth = require('../auth'); + +// Configure Passport strategies +auth.configureLocalStrategy( + async (username) => await db.users.getByUsername(username), + async (password, hash) => await auth.verifyPassword(password, hash) +); + +auth.configureJwtStrategy( + async (id) => await db.users.getById(id) +); + +/** + * Check if initial setup is required + * GET /api/auth/setup-required + */ +router.get('/setup-required', async (req, res) => { + try { + const userCount = await db.users.count(); + res.json({ setupRequired: userCount === 0 }); + } catch (err) { + console.error('Error in /setup-required:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Initial setup - Create admin user + * POST /api/auth/setup + */ +router.post('/setup', async (req, res) => { + try { + const userCount = await db.users.count(); + + // Check if setup already done + if (userCount > 0) { + return res.status(400).json({ error: 'Setup already completed' }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + // Create admin user + const passwordHash = await auth.hashPassword(password); + const adminUser = await db.users.create({ + username, + passwordHash, + role: 'admin' + }); + + // Generate token for immediate login + const token = auth.generateToken(adminUser); + + res.status(201).json({ + message: 'Admin user created successfully', + token, + user: adminUser + }); + } catch (err) { + console.error('Error in /setup:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Login with Passport Local Strategy + * POST /api/auth/login + */ +router.post('/login', (req, res, next) => { + auth.passport.authenticate('local', { session: false }, (err, user, info) => { + if (err) { + console.error('Login error:', err); + return res.status(500).json({ error: 'Server error' }); + } + + if (!user) { + return res.status(401).json({ error: info?.message || 'Invalid credentials' }); + } + + // Generate JWT token + const token = auth.generateToken(user); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + role: user.role + } + }); + })(req, res, next); +}); + +/** + * Logout (client-side handles token removal) + * POST /api/auth/logout + */ +router.post('/logout', (req, res) => { + // With JWT, logout is handled client-side by removing the token + // This endpoint exists for consistency and future server-side token blacklisting + res.json({ success: true, message: 'Logged out successfully' }); +}); + +/** + * Get current user + * GET /api/auth/me + */ +router.get('/me', auth.requireAuth, async (req, res) => { + try { + const user = await db.users.getById(req.user.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ + id: user.id, + username: user.username, + role: user.role + }); + } catch (err) { + console.error('Error in /me:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Get all users (admin only) + * GET /api/auth/users + */ +router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const allUsers = await db.users.getAll(); + + // Remove password hashes + const users = allUsers.map(u => { + const { passwordHash, ...userWithoutPassword } = u; + return userWithoutPassword; + }); + + res.json(users); + } catch (err) { + console.error('Error fetching users:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Create a new user (admin only) + * POST /api/auth/users + */ +router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { username, password, role } = req.body; + + if (!username || !password || !role) { + return res.status(400).json({ error: 'Username, password, and role are required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' }); + } + + const passwordHash = await auth.hashPassword(password); + const newUser = await db.users.create({ + username, + passwordHash, + role + }); + + res.status(201).json(newUser); + } catch (err) { + console.error('Error creating user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Update a user (admin only) + * PUT /api/auth/users/:id + */ +router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const { username, password, role } = req.body; + + const updates = {}; + + if (username) { + updates.username = username; + } + + if (password) { + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + updates.passwordHash = await auth.hashPassword(password); + } + + if (role) { + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' }); + } + + // Prevent removing admin role from the last admin + const user = await db.users.getById(id); + if (user && user.role === 'admin' && role !== 'admin') { + const allUsers = await db.users.getAll(); + const adminCount = allUsers.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' }); + } + } + + updates.role = role; + } + + const updatedUser = await db.users.update(id, updates); + res.json(updatedUser); + } catch (err) { + console.error('Error updating user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +/** + * Delete a user (admin only) + * DELETE /api/auth/users/:id + */ +router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { id } = req.params; + + // Prevent deleting yourself + if (parseInt(id) === req.user.id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + await db.users.delete(id); + res.json({ success: true, message: 'User deleted successfully' }); + } catch (err) { + console.error('Error deleting user:', err); + res.status(500).json({ error: err.message || 'Server error' }); + } +}); + +module.exports = router; diff --git a/server/routes/users.js b/server/routes/users.js new file mode 100644 index 0000000..3393afb --- /dev/null +++ b/server/routes/users.js @@ -0,0 +1,186 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const auth = require('../auth'); + +/** + * Get all users (admin only) + * GET /api/users + */ +router.get('/', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const data = await db.loadDb(); + const users = (data.users || []).map(u => ({ + id: u.id, + username: u.username, + role: u.role, + createdAt: u.createdAt + })); + res.json(users); + } catch (err) { + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Create user (admin only) + * POST /api/users + */ +router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const { username, password, role } = req.body; + + if (!username || !password || !role) { + return res.status(400).json({ error: 'Username, password, and role required' }); + } + + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be admin or viewer' }); + } + + const data = await db.loadDb(); + + // Check if username exists + if (data.users?.some(u => u.username === username)) { + return res.status(400).json({ error: 'Username already exists' }); + } + + // Create user + const passwordHash = await auth.hashPassword(password); + const newUser = { + id: data.nextUserId || (data.users?.length || 0) + 1, + username, + passwordHash, + role, + createdAt: new Date().toISOString() + }; + + data.users = data.users || []; + data.users.push(newUser); + data.nextUserId = newUser.id + 1; + + await db.saveDb(data); + + res.json({ + id: newUser.id, + username: newUser.username, + role: newUser.role, + createdAt: newUser.createdAt + }); + } catch (err) { + console.error('Create user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Update user (admin only) + * PUT /api/users/:id + */ +router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const userId = parseInt(req.params.id); + const { username, password, role } = req.body; + + const data = await db.loadDb(); + const userIndex = data.users?.findIndex(u => u.id === userId); + + if (userIndex === -1 || userIndex === undefined) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = data.users[userIndex]; + + // Update username if provided + if (username && username !== user.username) { + // Check if new username exists + if (data.users.some(u => u.username === username && u.id !== userId)) { + return res.status(400).json({ error: 'Username already exists' }); + } + user.username = username; + } + + // Update password if provided + if (password) { + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } + user.passwordHash = await auth.hashPassword(password); + } + + // Update role if provided + if (role) { + if (!['admin', 'viewer'].includes(role)) { + return res.status(400).json({ error: 'Role must be admin or viewer' }); + } + + // Prevent removing last admin + if (user.role === 'admin' && role !== 'admin') { + const adminCount = data.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot remove last admin user' }); + } + } + + user.role = role; + } + + await db.saveDb(data); + + res.json({ + id: user.id, + username: user.username, + role: user.role, + createdAt: user.createdAt + }); + } catch (err) { + console.error('Update user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +/** + * Delete user (admin only) + * DELETE /api/users/:id + */ +router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => { + try { + const userId = parseInt(req.params.id); + + const data = await db.loadDb(); + const userIndex = data.users?.findIndex(u => u.id === userId); + + if (userIndex === -1 || userIndex === undefined) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = data.users[userIndex]; + + // Prevent deleting yourself + if (user.id === req.session.userId) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + // Prevent deleting last admin + if (user.role === 'admin') { + const adminCount = data.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot delete last admin user' }); + } + } + + data.users.splice(userIndex, 1); + await db.saveDb(data); + + res.json({ success: true }); + } catch (err) { + console.error('Delete user error:', err); + res.status(500).json({ error: 'Server error' }); + } +}); + +module.exports = router;