diff --git a/package-lock.json b/package-lock.json index 2386ca0..783638f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,23 @@ { "name": "fullsend", - "version": "1.7.3", + "version": "1.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "fullsend", - "version": "1.7.3", + "version": "1.8.0", "license": "MIT", "dependencies": { "bcryptjs": "^2.4.3", "body-parser": "^1.20.0", "dotenv": "^16.0.1", "express": "^4.18.1", + "express-session": "^1.17.3", + "jose": "^4.15.0", "mariadb": "^3.0.0", "nodemon": "^3.1.10", + "openid-client": "^5.2.0", "path": "^0.12.7", "twilio": "^5.10.3" } @@ -507,6 +510,37 @@ "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==", + "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/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -836,6 +870,14 @@ "node": ">=0.12.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -927,6 +969,17 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mariadb": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.0.0.tgz", @@ -1123,6 +1176,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1135,6 +1196,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1146,6 +1215,28 @@ "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1235,6 +1326,14 @@ "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==", + "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", @@ -1553,6 +1652,17 @@ "node": ">= 0.6" } }, + "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==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1603,6 +1713,11 @@ "engines": { "node": ">=6.0" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } }, "dependencies": { @@ -1960,6 +2075,33 @@ "vary": "~1.1.2" } }, + "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==", + "requires": { + "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" + }, + "dependencies": { + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + } + } + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2170,6 +2312,11 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" }, + "jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" + }, "jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2248,6 +2395,14 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "mariadb": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.0.0.tgz", @@ -2378,11 +2533,21 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, + "oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==" + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2391,6 +2556,22 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==" + }, + "openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "requires": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2455,6 +2636,11 @@ "side-channel": "^1.0.6" } }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2671,6 +2857,14 @@ "mime-types": "~2.1.24" } }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2710,6 +2904,11 @@ "version": "13.0.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } } diff --git a/package.json b/package.json index 45c0fda..06ee37c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fullsend", - "version": "1.7.3", + "version": "1.8.0", "description": "Fullsend allows allowed users to send bulk text messages to groups of recipients", "main": "server.js", "scripts": { @@ -21,6 +21,9 @@ "body-parser": "^1.20.0", "dotenv": "^16.0.1", "express": "^4.18.1", + "express-session": "^1.17.3", + "openid-client": "^5.2.0", + "jose": "^4.15.0", "mariadb": "^3.0.0", "nodemon": "^3.1.10", "path": "^0.12.7", diff --git a/public/help.html b/public/help.html index ccd8608..cdcc5a3 100644 --- a/public/help.html +++ b/public/help.html @@ -44,8 +44,8 @@ @@ -63,6 +63,10 @@

Fullsend

Changelog


+

v1.8.0

+

+ Adds (finally!) authentication via OpenID Connect (OIDC) and Keycloak. Users must have the fullsend_access role in Keycloak to use the application and fullsend_admin to administer it. +

v1.7.3

Adds blocking and timeouts to discourage users from sending the same message multiple times before the server has completed ingesting the message/recipient pairs diff --git a/public/index.html b/public/index.html index a1a9dc0..f915a7b 100644 --- a/public/index.html +++ b/public/index.html @@ -44,7 +44,7 @@

diff --git a/public/js/changepassword.js b/public/js/changepassword.js index 233d2ab..d33813d 100644 --- a/public/js/changepassword.js +++ b/public/js/changepassword.js @@ -1,12 +1,5 @@ const loadUsers = async () => { - const session = getCookie("fullsend_session"); - const users = ( - await ( - await fetch(`/auth/api/users`, { - headers: { session: session }, - }) - ).json() - ).data; + const users = (await (await fetch(`/auth/api/users`)).json()).data; for (const user of users) { document.getElementById( "changePasswordUsername" @@ -40,11 +33,9 @@ const changePassword = async () => { } if (error) return -1; - const session = getCookie("fullsend_session"); - const result = await fetch("/auth/api/users/update/password", { method: "POST", - headers: { "Content-Type": "application/json", session: session }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: userId, password: password, diff --git a/public/js/fullsend.js b/public/js/fullsend.js index b2d975f..1d4eb74 100644 --- a/public/js/fullsend.js +++ b/public/js/fullsend.js @@ -1,41 +1,35 @@ -//TODO: this doesn't actually need isLoggedIn here, I don't think... -//Pretty sure the redirect if not logged in (line 73ish) will cover that +const safeJsonFetch = async (url, options = {}) => { + try { + const resp = await fetch(url, options); + if (!resp.ok) { + console.error('Fetch failed', url, resp.status, resp.statusText); + return null; + } + return await resp.json(); + } catch (e) { + console.error('Network error fetching', url, e && e.message); + return null; + } +}; + const getGroups = async () => { - const session = getCookie("fullsend_session"); - return ( - await ( - await fetch("/auth/api/groups/insequence", { - headers: { session: session }, - }) - ).json() - ).data; + const resp = await safeJsonFetch("/auth/api/groups/insequence"); + return resp ? resp.data : []; }; const getContactNumbersInGroup = async (group) => { - const session = getCookie("fullsend_session"); let numbers = []; - const contacts = ( - await ( - await fetch(`/auth/api/group/${group}/contacts`, { - headers: { session: session }, - }) - ).json() - ).data; + const resp = await safeJsonFetch(`/auth/api/group/${group}/contacts`); + const contacts = resp ? resp.data : []; for (const contact of contacts) { numbers.push(contact.phone_number); } + return numbers; }; const getContacts = async () => { - const session = getCookie("fullsend_session"); - const contacts = ( - await ( - await fetch("/auth/api/contacts?active=1&filtered=1", { - headers: { session: session }, - }) - ).json() - ).data; - return contacts; + const resp = await safeJsonFetch("/auth/api/contacts?active=1&filtered=1"); + return resp ? resp.data : []; }; const getSelectedGroups = (categories = false) => { @@ -71,7 +65,6 @@ const getSelectedIndividuals = () => { const handleSwitch = async (e) => { handleMessagePreview(); - const session = getCookie("fullsend_session"); const switches = document.getElementsByClassName("recipientSwitch"); const modalBody = document.getElementById("recipientModalBody"); const viewListButton = document.getElementById("viewRecipientList"); @@ -85,16 +78,8 @@ const handleSwitch = async (e) => { } if (switchList.length > 0) { - const contactList = ( - await ( - await fetch( - `/auth/api/groups/contacts?groups=${switchList.join(",")}`, - { - headers: { session: session }, - } - ) - ).json() - ).data; + const contactResp = await safeJsonFetch(`/auth/api/groups/contacts?groups=${switchList.join(",")}`); + const contactList = contactResp ? contactResp.data : []; if (contactList.length > 0) { modalBody.innerHTML = ` @@ -142,14 +127,14 @@ const sendMessage = async () => { } if (error) return -1; - const session = getCookie("fullsend_session"); + document.getElementById("sendButton").disabled = true; document.getElementById("fullsendMessage").disabled = true; const result = await fetch("/auth/api/messages/send", { method: "POST", - headers: { "Content-Type": "application/json", session: session }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: message, groups: selectedGroups, diff --git a/public/js/group-management.js b/public/js/group-management.js index 1c1bba8..42b000e 100644 --- a/public/js/group-management.js +++ b/public/js/group-management.js @@ -1,27 +1,10 @@ let recipientListReady = false; let currentGroup; -const getGroups = async () => { - const session = getCookie("fullsend_session"); - return ( - await ( - await fetch("/auth/api/groups/insequence", { - headers: { session: session }, - }) - ).json() - ).data; -}; +const getGroups = async () => (await (await fetch("/auth/api/groups/insequence")).json()).data; const loadContacts = async () => { - const session = getCookie("fullsend_session"); - - const contacts = await ( - await ( - await fetch("/auth/api/contacts?active=1", { - headers: { session: session }, - }) - ).json() - ).data; + const contacts = (await (await fetch("/auth/api/contacts?active=1")).json()).data; document.getElementById("groupManagementRecipientsLabel").style.display = "block"; @@ -38,15 +21,7 @@ const loadContacts = async () => { const setGroupContacts = async (groupId) => { recipientListReady = false; - const session = getCookie("fullsend_session"); - - const contacts = await ( - await ( - await fetch(`/auth/api/group/${groupId}/contacts`, { - headers: { session: session }, - }) - ).json() - ).data; + const contacts = (await (await fetch(`/auth/api/group/${groupId}/contacts`)).json()).data; const checkboxes = document.getElementsByClassName("recipient-switch"); for (const checkbox of checkboxes) { @@ -62,8 +37,7 @@ const setGroupContacts = async (groupId) => { }; const handleSwitch = async (e) => { - const session = getCookie("fullsend_session"); - + if (!recipientListReady) return; const userId = e.target.value; const action = e.target.checked ? "add" : "remove"; @@ -71,7 +45,7 @@ const handleSwitch = async (e) => { if (action == "add") { const result = await fetch("/auth/api/groups/update/addcontact", { method: "POST", - headers: { "Content-Type": "application/json", session: session }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contactId: userId, groupId: currentGroup, @@ -81,7 +55,7 @@ const handleSwitch = async (e) => { } else if (action == "remove") { const result = await fetch("/auth/api/groups/update/removecontact", { method: "POST", - headers: { "Content-Type": "application/json", session: session }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contactId: userId, groupId: currentGroup, diff --git a/public/js/login.js b/public/js/login.js index 372191b..962e597 100644 --- a/public/js/login.js +++ b/public/js/login.js @@ -4,30 +4,8 @@ const handle403 = () => { }; const login = async () => { - const username = document.getElementById("loginUsername").value; - const password = document.getElementById("loginPassword").value; - - const session = await ( - await fetch("/api/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: username, - password: password, - }), - }) - ).json(); - - if (session.code && session.code == 403) { - return handle403(); - } - - const days = 5; - const expires = new Date(Date.now() + days * 86400 * 1000).toUTCString(); - - document.cookie = `fullsend_session=${await session.session}; expires=${expires}`; - - window.location.href = "/fullsend"; + // Redirect to server which initiates Keycloak login + window.location.href = "/api/login"; }; const pageOnLoadFunctions = async () => { diff --git a/public/js/main.js b/public/js/main.js index 2652345..22afe1c 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,18 +1,4 @@ -const getCookie = (cname) => { - let name = cname + "="; - let decodedCookie = decodeURIComponent(document.cookie); - let ca = decodedCookie.split(";"); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) == " ") { - c = c.substring(1); - } - if (c.indexOf(name) == 0) { - return c.substring(name.length, c.length); - } - } - return undefined; -}; +// cookie helper removed — server now manages sessions via OIDC const getVersion = async () => { const version = (await fetch("/api/version")).text(); @@ -24,64 +10,45 @@ const printVersionInNav = async () => { }; const checkLogin = async () => { - const session = getCookie("fullsend_session"); - const login = ( - await fetch("/auth/api/session/" + session, { - headers: { session: session }, - }) - ).json(); - if ((await login).code == 401) { - logout(); - } - return login; + // Ask the server for session info (server reads token from session or Authorization) + const resp = await fetch('/auth/api/session/info'); + if (resp.status === 200) return resp.json(); + return { success: false }; }; const isLoggedIn = async () => { - const session = getCookie("fullsend_session"); - if (!session) return false; - const login = ( - await fetch("/auth/api/session/" + session, { - headers: { session: session }, - }) - ).json(); - if ((await login).code == 401) { - return false; - } - - const days = 5; - const expires = new Date(Date.now() + days * 86400 * 1000).toUTCString(); - - document.cookie = `fullsend_session=${session}; expires=${expires}`; - - return true; + const info = await checkLogin(); + return info && info.success; }; const isAdmin = async (userId = null) => { - const session = getCookie("fullsend_session"); - if (!session) return; - - if (!userId) { - userId = (await checkLogin()).data[0].user_id; + const info = await checkLogin(); + if (!info || !info.success) return false; + // If server returned localUser, respect that flag; otherwise inspect claims + if (info.data && info.data.localUser && info.data.localUser.admin) return true; + const claims = info.data && info.data.sessionInfo && info.data.sessionInfo.claims; + if (!claims) return false; + const realmRoles = (claims.realm_access && claims.realm_access.roles) || []; + // Server should expose which role name represents admin; fall back to 'admin' + const adminRole = (info.data && info.data.sessionInfo && info.data.sessionInfo.adminRole) || 'admin'; + if (realmRoles.includes(adminRole)) return true; + if (claims.resource_access) { + // Try to find admin role in any client resource_access entry + for (const clientKey of Object.keys(claims.resource_access)) { + const ra = claims.resource_access[clientKey]; + if (ra && ra.roles && ra.roles.includes(adminRole)) return true; + } } - const userInfo = ( - await ( - await fetch(`/auth/api/user/${userId}`, { - headers: { session: session }, - }) - ).json() - ).data[0]; - return userInfo.admin; + return false; }; const logout = async () => { - const session = getCookie("fullsend_session"); try { - await fetch("/api/logout", { headers: { session } }); + window.location.href = '/api/logout'; } catch (e) { - console.error("Logout request failed:", e); + console.error('Logout failed', e); + window.location.href = '/'; } - document.cookie = "fullsend_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC;"; - window.location.href = "/"; }; const checkForRedirect = async () => { @@ -101,7 +68,6 @@ const checkForRedirect = async () => { for (const page of [authPages, adminPages]) { if (window.location.pathname == page && !isLoggedInVar) { - logout(); window.location.href = "/"; return; } diff --git a/public/no-access.html b/public/no-access.html new file mode 100644 index 0000000..7b19aed --- /dev/null +++ b/public/no-access.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + Fullsend | SMS notification from the web + + + +
+

Access denied

+

It looks like your account does not have permission to use Fullsend.

+

If you believe this is an error, please contact your administrator to request the fullsend_access role.

+

Return to home

+
+ + \ No newline at end of file diff --git a/public/privacy.html b/public/privacy.html index 75e6c6d..01d13d3 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -44,8 +44,8 @@ diff --git a/public/terms.html b/public/terms.html index 3010a4f..9255bd6 100644 --- a/public/terms.html +++ b/public/terms.html @@ -44,8 +44,8 @@ diff --git a/server.js b/server.js index 570d87d..68622e0 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,9 @@ const groups = require("./src/groups.js"); const titles = require("./src/titles.js"); const users = require("./src/users.js"); const sessions = require("./src/sessions.js"); +const auth = require("./src/auth.js"); const messages = require("./src/messages.js"); +const session = require('express-session'); const { version } = require("./package.json"); const { response } = require("express"); @@ -28,35 +30,22 @@ const pool = mariadb.createPool({ // Initialize express app const PORT = process.env.PORT || 8080; -const isLoggedIn = async (req, res, next) => { - //"Checking session... - if (req.headers.session) { - //A session token was passed back, now checking if it is valid... - const session = await sessions.getSession(pool, req.headers.session); - if (session.data[0]) { - req.body.sessionInfo = session.data[0]; - // Valid session token found - sessions.sessionUpdate(pool, req.headers.session); - next(); - } else { - //The token passed back is invalid - res.status(401).send({ code: 401, error: "Unauthorized" }); - } - } else { - // "No session token passed back - res.status(401).send({ code: 401, error: "Unauthorized" }); - } -}; +// Session middleware (required for server-side login flow) +app.use(session({ + secret: process.env.SESSION_SECRET || 'a very long secret', + resave: false, + saveUninitialized: false, + cookie: { secure: false }, // set secure: true if using HTTPS +})); -const isAdmin = async (req, res, next) => { - const userInfo = (await users.getUser(pool, req.body.sessionInfo.user_id)) - .data[0]; - if (userInfo.admin == 1) { - next(); - } else { - res.status(403).send({ code: 403, error: "Forbidden" }); - } -}; +// Initialize OIDC discovery (will be awaited before server starts) +// Note: initOidc is async; we'll call it before starting the server below. + +const isLoggedIn = auth.isLoggedIn; + +// Use Keycloak roles for admin checks. If you want to rely on local DB admin flag +// instead, change this to query users.getUserByUsername. +const isAdmin = auth.isAdmin; // auth router, anything on this router requires signin const authRouter = express.Router(); @@ -101,6 +90,11 @@ app.get("/group-management", (req, res) => { res.sendFile(path.join(__dirname, "public/group-management.html")); }); +app.get("/no-access", (req, res) => { + res.sendFile(path.join(__dirname, "public/no-access.html")); +}); + + app.get("/api", async (req, res) => { res.send(`fullsend server is online
v${version}`); }); @@ -164,31 +158,117 @@ authRouter.get("/api/user/:user", async ({ params: { user: user } }, res) => { res.send(response_data); }); -authRouter.get( - "/api/session/:session", - async ({ params: { session: session } }, res) => { - const response_data = await sessions.getSession(pool, session); - res.send(response_data); +authRouter.get("/api/session/info", async (req, res) => { + if (req.body.sessionInfo) { + const sessionInfo = req.body.sessionInfo; + // Expose the configured admin role name to the client so browser-side checks + // don't need to rely on Node-only process.env variables. + sessionInfo.adminRole = process.env.KEYCLOAK_ADMIN_ROLE || 'admin'; + // Try to map to a local user record for convenience + let localUser = null; + try { + if (sessionInfo.username) { + const userResp = await users.getUserByUsername(pool, sessionInfo.username); + if (userResp && userResp.data && userResp.data[0]) { + localUser = userResp.data[0]; + } + } + } catch (e) { + console.error('local user lookup failed', e && e.message); + } + // If we have a localUser stored in session (created during callback), prefer that + const sessionLocalUser = req.session && req.session.localUser ? req.session.localUser : localUser; + res.send({ success: true, data: { sessionInfo, localUser: sessionLocalUser } }); + } else { + res.status(404).send({ success: false, error: "No session info" }); } -); +}); -app.get("/api/logout", async (req, res) => { - const response_data = await sessions.logout(pool, req.headers.session); - if (response_data.success) { - response_data.data.insertId = response_data.data.insertId; - res.send(response_data); +app.get('/api/login', async (req, res) => { + try { + const url = await auth.getAuthorizationUrl(req); + return res.redirect(url); + } catch (err) { + console.error('login redirect failed', err && err.message); + return res.status(500).send({ error: 'Login redirect failed' }); } }); -app.post("/api/login", async (req, res) => { - const sessionId = await sessions.login( - pool, - req.body.username, - req.body.password - ); - sessionId - ? res.send({ session: sessionId }) - : res.status(403).send({ code: 403, error: "Invalid login" }); +// Debug endpoint (no role enforcement) to inspect session info during development +app.get('/api/debug/session', async (req, res) => { + try { + if (req.session && req.session.tokenSet) { + const sessionInfo = req.session.claims || (req.session.tokenSet.claims && req.session.tokenSet.claims()); + return res.send({ success: true, data: { sessionInfo, localUser: req.session.localUser || null } }); + } + return res.status(404).send({ success: false, error: 'No session' }); + } catch (e) { + console.error('debug session failed', e && e.message); + return res.status(500).send({ success: false, error: 'Server error' }); + } +}); + +app.get('/api/callback', async (req, res) => { + try { + await auth.handleCallback(req); + // Ensure a local user exists for the logged in Keycloak user + try { + const claims = req.session && req.session.claims; + if (claims) { + const addResp = await users.addUserIfNotExists(pool, claims); + if (addResp && addResp.success && addResp.user) { + // store local user on session for convenience + req.session.localUser = addResp.user; + } + } + } catch (e) { + console.error('addUserIfNotExists failed', e && e.message); + } + + // After login, ensure the user has the required Fullsend role. If not, + // redirect to a friendly page explaining lack of access. + try { + const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access'; + const accessClaims = req.session && req.session.accessClaims ? req.session.accessClaims : (req.session && req.session.claims ? req.session.claims : null); + let hasRole = false; + if (accessClaims) { + const realmRoles = (accessClaims.realm_access && accessClaims.realm_access.roles) || []; + if (realmRoles.includes(requiredRole)) hasRole = true; + if (!hasRole && accessClaims.resource_access) { + for (const k of Object.keys(accessClaims.resource_access)) { + const ra = accessClaims.resource_access[k]; + if (ra && ra.roles && ra.roles.includes(requiredRole)) { hasRole = true; break; } + } + } + } + if (!hasRole) { + return res.redirect('/no-access'); + } + } catch (e) { + console.error('post-login role check failed', e && e.message); + } + + // Redirect to app home or post-login page + return res.redirect('/fullsend'); + } catch (err) { + console.error('callback handling failed', err && err.message); + return res.status(500).send({ error: 'Callback processing failed' }); + } +}); + +app.get('/api/logout', (req, res) => { + try { + const logoutUrl = auth.getLogoutUrl(req); + // destroy local session + if (req.session) { + req.session.destroy(() => {}); + } + if (logoutUrl) return res.redirect(logoutUrl); + return res.redirect('/'); + } catch (err) { + console.error('logout failed', err && err.message); + return res.status(500).send({ error: 'Logout failed' }); + } }); authRouter.post("/api/users/update/password", isAdmin, async (req, res) => { @@ -221,10 +301,21 @@ authRouter.post( ); authRouter.post("/api/messages/send", async (req, res) => { - const userId = await sessions.getSession(pool, req.headers.session); + // Derive local user id from Keycloak username + const username = req.body.sessionInfo && req.body.sessionInfo.username; + let userId; + if (username) { + const userResp = await users.getUserByUsername(pool, username); + if (userResp && userResp.data && userResp.data[0]) { + userId = userResp.data[0].id; + } + } + + if (!userId) return res.status(403).send({ code: 403, error: "Forbidden" }); + const response_data = await messages.sendMessage( pool, - userId.data[0].user_id, + userId, req.body.message, req.body.groups, req.body.individuals @@ -232,6 +323,14 @@ authRouter.post("/api/messages/send", async (req, res) => { res.send(response_data); }); -server.listen(PORT, () => { - console.log("Fullsend is up!"); -}); +(async () => { + try { + await auth.initOidc(); + server.listen(PORT, () => { + console.log("Fullsend is up!"); + }); + } catch (err) { + console.error('Failed to initialize OIDC:', err && err.message); + process.exit(1); + } +})(); diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..e03f524 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,258 @@ +const { Issuer, generators } = require("openid-client"); +const { createRemoteJWKSet, jwtVerify } = require("jose"); + +let oidc = null; +let client = null; + +async function initOidc() { + if (oidc) return oidc; + const issuerUrl = process.env.KEYCLOAK_ISSUER || process.env.KEYCLOAK_URL; + if (!issuerUrl) throw new Error("KEYCLOAK_ISSUER or KEYCLOAK_URL must be set (example: https://auth.example.com/realms/realmname)"); + + const issuer = await Issuer.discover(issuerUrl); + oidc = { + issuerUrl: issuer.issuer, + jwksUri: issuer.metadata.jwks_uri, + issuerObj: issuer, + }; + return oidc; +} + +async function initClient() { + if (client) return client; + const oidcCfg = await initOidc(); + const issuer = oidcCfg.issuerObj; + const clientId = process.env.KEYCLOAK_CLIENT; + const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET; + const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback"; + + if (!clientId) throw new Error("KEYCLOAK_CLIENT must be set for Authorization Code flow"); + + client = new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [redirectUri], + response_types: ["code"], + }); + + return client; +} + +async function getAuthorizationUrl(req) { + const oidcClient = await initClient(); + const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback"; + + const codeVerifier = generators.codeVerifier(); + const codeChallenge = await generators.codeChallenge(codeVerifier); + const state = generators.random(); + + // store verifier+state in session + if (!req.session) throw new Error("Session middleware required for login flow"); + req.session.code_verifier = codeVerifier; + req.session.state = state; + + const url = oidcClient.authorizationUrl({ + scope: "openid profile email", + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: "S256", + state, + }); + return url; +} + +async function handleCallback(req) { + const oidcClient = await initClient(); + const redirectUri = process.env.KEYCLOAK_REDIRECT_URI || "http://localhost:8080/api/callback"; + if (!req.session) throw new Error("Session middleware required for callback"); + const params = oidcClient.callbackParams(req); + const codeVerifier = req.session.code_verifier; + const state = req.session.state; + const tokenSet = await oidcClient.callback(redirectUri, params, { code_verifier: codeVerifier, state }); + + // store tokenSet and claims in session + req.session.tokenSet = tokenSet; + req.session.claims = tokenSet.claims(); + + // Also verify / decode the access token to capture roles (Keycloak typically puts roles in the access token) + try { + const oidcCfg = await initOidc(); + const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri)); + const verifyOpts = { issuer: oidcCfg.issuerUrl }; + if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT; + const accessToken = tokenSet.access_token; + if (accessToken) { + const { payload } = await jwtVerify(accessToken, JWKS, verifyOpts); + req.session.accessClaims = payload; + } + } catch (e) { + // Fallback: don't block login if access token verification fails; try to decode without verification + try { + const { decodeJwt } = require('jose'); + if (tokenSet && tokenSet.access_token) { + req.session.accessClaims = decodeJwt(tokenSet.access_token); + } + } catch (e2) { + console.warn('access token decode failed', e2 && e2.message); + } + } + return tokenSet; +} + +function getLogoutUrl(req) { + if (!oidc) return null; + const endSession = oidc.issuerObj.metadata.end_session_endpoint; + if (!endSession) return null; + const postLogout = process.env.KEYCLOAK_POST_LOGOUT_REDIRECT || "http://localhost:8080/"; + const idToken = req.session && req.session.tokenSet && req.session.tokenSet.id_token; + const url = new URL(endSession); + if (idToken) url.searchParams.set('id_token_hint', idToken); + url.searchParams.set('post_logout_redirect_uri', postLogout); + return url.toString(); +} + +// Middleware: prefer session token, otherwise Authorization Bearer header +async function isLoggedIn(req, res, next) { + try { + // If there's a token in session (server-side flow), use its claims + if (req.session && req.session.tokenSet) { + const oidcClient = await initClient(); + const accessToken = req.session.tokenSet.access_token; + // Prefer the already-decoded accessClaims stored at login + let accessClaims = req.session.accessClaims || {}; + + // Try to verify the access token via JWKS (fast, no client secret required) + try { + const oidcCfg = await initOidc(); + const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri)); + const verifyOpts = { issuer: oidcCfg.issuerUrl }; + if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT; + if (accessToken) { + const { payload } = await jwtVerify(accessToken, JWKS, verifyOpts); + accessClaims = payload || accessClaims; + } + } catch (e) { + // If jwt verification fails, don't immediately destroy session. + // If introspection is available (and client secret configured), use it to check active status. + try { + if (process.env.KEYCLOAK_CLIENT_SECRET && typeof oidcClient.introspect === 'function') { + const introspectResp = await oidcClient.introspect(accessToken); + if (!introspectResp || introspectResp.active !== true) { + try { req.session.destroy(() => {}); } catch (e2) {} + return res.status(401).send({ code: 401, error: 'Unauthorized' }); + } + } else { + // As a last resort, try userinfo but do not destroy session if it fails; fall back to stored claims + try { + const userinfo = await oidcClient.userinfo(accessToken); + if (userinfo) accessClaims = userinfo; + } catch (e2) { + // ignore; we'll use whatever we have in session + } + } + } catch (e2) { + // ignore and continue with stored claims + } + } + + // normalize sessionInfo to include access token claims (so downstream checks are uniform) + const idClaims = req.session.claims || (req.session.tokenSet.claims && req.session.tokenSet.claims()); + req.body.sessionInfo = { + token: accessToken, + user_id: idClaims && idClaims.sub, + username: (idClaims && (idClaims.preferred_username || idClaims.username)), + email: idClaims && idClaims.email, + realm_access: accessClaims && accessClaims.realm_access, + resource_access: accessClaims && accessClaims.resource_access, + // expose the access token claims directly for easier checks + claims: accessClaims || {}, + }; + + // Enforce fullsend_access role for all users + const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access'; + const hasRealm = (req.body.sessionInfo.realm_access && req.body.sessionInfo.realm_access.roles && req.body.sessionInfo.realm_access.roles.includes(requiredRole)); + const hasResource = req.body.sessionInfo.resource_access && Object.values(req.body.sessionInfo.resource_access).some(r => r && r.roles && r.roles.includes(requiredRole)); + if (!hasRealm && !hasResource) { + return res.status(403).send({ code: 403, error: `Forbidden - missing required role: ${requiredRole}` }); + } + + return next(); + } + + // otherwise fallback to Authorization header verification + const authHeader = req.headers.authorization || req.headers.Authorization; + if (!authHeader) return res.status(401).send({ code: 401, error: "Unauthorized" }); + const match = String(authHeader).match(/Bearer (.+)/i); + if (!match) return res.status(401).send({ code: 401, error: "Unauthorized" }); + const token = match[1]; + + const oidcCfg = await initOidc(); + const JWKS = createRemoteJWKSet(new URL(oidcCfg.jwksUri)); + + const verifyOpts = { + issuer: oidcCfg.issuerUrl, + }; + if (process.env.KEYCLOAK_CLIENT) verifyOpts.audience = process.env.KEYCLOAK_CLIENT; + + const { payload } = await jwtVerify(token, JWKS, verifyOpts); + req.body.sessionInfo = { + token, + user_id: payload.sub, + username: payload.preferred_username || payload.username, + email: payload.email, + realm_access: payload.realm_access, + resource_access: payload.resource_access, + claims: payload, + }; + + // Enforce fullsend_access role for bearer tokens as well + const requiredRole = process.env.KEYCLOAK_FULLSEND_ROLE || 'fullsend_access'; + const hasRealm = (payload.realm_access && payload.realm_access.roles && payload.realm_access.roles.includes(requiredRole)); + const hasResource = payload.resource_access && Object.values(payload.resource_access).some(r => r && r.roles && r.roles.includes(requiredRole)); + if (!hasRealm && !hasResource) { + return res.status(403).send({ code: 403, error: `Forbidden - missing required role: ${requiredRole}` }); + } + return next(); + } catch (e) { + console.error("isLoggedIn error", e && e.message); + return res.status(401).send({ code: 401, error: "Unauthorized" }); + } +} + +function isAdmin(req, res, next) { + // sessionInfo.claims is normalized to access token claims in isLoggedIn + const claims = req.body.sessionInfo && req.body.sessionInfo.claims; + if (!claims) return res.status(403).send({ code: 403, error: "Forbidden" }); + + const realmRoles = (claims.realm_access && claims.realm_access.roles) || []; + const clientRoles = []; + if (claims.resource_access) { + // Try to extract roles from configured client first + if (process.env.KEYCLOAK_CLIENT && claims.resource_access[process.env.KEYCLOAK_CLIENT]) { + const ra = claims.resource_access[process.env.KEYCLOAK_CLIENT]; + if (ra && ra.roles) clientRoles.push(...ra.roles); + } else { + // otherwise flatten all client roles + for (const k of Object.keys(claims.resource_access)) { + const ra = claims.resource_access[k]; + if (ra && ra.roles) clientRoles.push(...ra.roles); + } + } + } + + const roles = [...realmRoles, ...clientRoles]; + const adminRole = (req.body.sessionInfo && req.body.sessionInfo.adminRole) || process.env.KEYCLOAK_ADMIN_ROLE || "admin"; + if (roles.includes(adminRole)) return next(); + + return res.status(403).send({ code: 403, error: "Forbidden" }); +} + +module.exports = { + initOidc, + initClient, + getAuthorizationUrl, + handleCallback, + getLogoutUrl, + isLoggedIn, + isAdmin, +}; diff --git a/src/db.js b/src/db.js index 686e086..5f682f9 100644 --- a/src/db.js +++ b/src/db.js @@ -1,8 +1,12 @@ const execQuery = async (pool, q, args = null, db = null) => { let conn; try { - conn = await pool.getConnection(); - await conn.query(`USE ${db || process.env.PRIMARY_DB_NAME};`); + conn = await pool.getConnection(); + const dbName = db || process.env.PRIMARY_DB_NAME; + // Wrap database name in backticks to allow hyphens and other chars. + // Also escape any backticks that might be present in the name. + const safeDbName = dbName ? `\`${String(dbName).replace(/`/g, '``')}\`` : null; + if (safeDbName) await conn.query(`USE ${safeDbName};`); const results = await (args != null ? conn.query(q, args) : conn.query(q)); const response = { success: true }; if (results) { diff --git a/src/sessions.js b/src/sessions.js index 42a5693..b5cc6cf 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -1,53 +1,22 @@ -const { execQuery } = require("./db"); -const bcrypt = require("bcryptjs"); -const crypto = require("crypto"); +// Sessions are now managed by Keycloak OIDC. Keep compatibility exports +// so other modules that import `sessions` won't immediately break. -const AUTHENTICATE = "SELECT id, password FROM users WHERE username = ?"; -const SESSION_CREATE = - "INSERT INTO sessions (`id`, `user_id`, `login`, `last_seen`, `expiration`) VALUES (?, ?, NOW(), NOW(), NOW() + INTERVAL 5 DAY)"; -const SESSION_DESTROY = "DELETE FROM sessions WHERE `id` = ?"; -const SESSION_UPDATE = - "UPDATE sessions SET `last_seen` = NOW(), `expiration` = NOW() + INTERVAL 5 DAY WHERE `id` = ?"; -const SESSION_GET = "SELECT * FROM sessions WHERE id = ?"; -const SESSION_EXPIRED_DELETE = "DELETE FROM sessions WHERE expiration < NOW()"; - -const deleteExpiredSessions = (pool) => { - execQuery(pool, SESSION_EXPIRED_DELETE, null); -}; - -exports.getUsers = async (pool, sessionId) => { - return execQuery(pool, SESSION_GET, sessionId); -}; - -exports.login = async (pool, username, password) => { - deleteExpiredSessions(pool); - - const authedUser = await execQuery(pool, AUTHENTICATE, username); - if (authedUser.data[0]) { - const id = authedUser.data[0].id; - const saved_hash = authedUser.data[0].password; - const matches = bcrypt.compareSync(password, saved_hash); - if (matches) { - const session_id = crypto.randomBytes(20).toString("hex"); - const session_results = await execQuery(pool, SESSION_CREATE, [ - session_id, - id, - ]); - return session_results.success ? session_id : undefined; - } - } - return undefined; +exports.getSession = async () => { + // Not applicable: session information is carried in the Keycloak grant on req.kauth + return { success: false, error: "Use Keycloak sessions via req.kauth" }; }; -exports.sessionUpdate = async (pool, sessionId) => { - return execQuery(pool, SESSION_UPDATE, sessionId); +exports.login = async () => { + // Local login is deprecated. Use Keycloak login flow. + return { success: false, error: "Use Keycloak OIDC login" }; }; -exports.logout = async (pool, sessionId) => { - return execQuery(pool, SESSION_DESTROY, sessionId); +exports.logout = async () => { + // Keycloak logout will be handled via Keycloak endpoint and middleware + return { success: false, error: "Use Keycloak logout endpoint" }; }; -exports.getSession = (pool, sessionId) => { - deleteExpiredSessions(pool); - return execQuery(pool, SESSION_GET, sessionId); +exports.sessionUpdate = async () => { + // No-op under Keycloak + return { success: false, error: "Session updates handled by Keycloak" }; }; diff --git a/src/users.js b/src/users.js index 0801a44..963c1f3 100644 --- a/src/users.js +++ b/src/users.js @@ -5,13 +5,19 @@ const USERS_GET = "SELECT id, first_name, last_name, username, title, admin FROM users ORDER BY last_name"; const USER_GET = "SELECT first_name, last_name, username, admin FROM users WHERE id = ?"; +const USER_GET_BY_USERNAME = + "SELECT id, first_name, last_name, username, admin FROM users WHERE username = ?"; const USER_ID_PHONE_GET = "SELECT phone_number FROM contacts WHERE user_id = ?"; const PASSWORD_UPDATE = "UPDATE users SET password = ? WHERE id = ?"; +const USER_CREATE = "INSERT INTO users (first_name, last_name, username, password, admin) VALUES (?, ?, ?, ?, 0)"; exports.getUsers = async (pool) => execQuery(pool, USERS_GET, null); exports.getUser = async (pool, user) => execQuery(pool, USER_GET, user); +exports.getUserByUsername = async (pool, username) => + execQuery(pool, USER_GET_BY_USERNAME, username); + exports.getUserPhoneNumber = async (pool, user) => { const result = await execQuery(pool, USER_ID_PHONE_GET, user); if (result.data[0]) { @@ -25,3 +31,32 @@ exports.changePassword = async (pool, user, plaintextPassword) => { const hashedPassword = bcrypt.hashSync(plaintextPassword, 10); return await execQuery(pool, PASSWORD_UPDATE, [hashedPassword, user]); }; + +// Creates a local user record from OIDC claims if the username does not already exist. +exports.addUserIfNotExists = async (pool, claims) => { + if (!claims) return { success: false, error: 'No claims' }; + const username = claims.preferred_username || claims.username || (claims.email ? claims.email.split('@')[0] : undefined); + if (!username) return { success: false, error: 'No username claim' }; + + // Check if user already exists + const existing = await exports.getUserByUsername(pool, username); + if (existing && existing.data && existing.data[0]) { + return { success: true, user: existing.data[0] }; + } + + // Build name fields from claims + const firstName = claims.given_name || (claims.name ? claims.name.split(' ')[0] : ''); + const lastName = claims.family_name || (claims.name ? claims.name.split(' ').slice(1).join(' ') : ''); + + // Create a random password hash since authentication is via Keycloak + const randomPassword = Math.random().toString(36) + Date.now().toString(36); + const hashedPassword = bcrypt.hashSync(randomPassword, 10); + + const createResp = await execQuery(pool, USER_CREATE, [firstName || '', lastName || '', username, hashedPassword]); + if (createResp && createResp.success) { + // Return the inserted user record (fetch by username to include id) + const newUser = await exports.getUserByUsername(pool, username); + return { success: true, user: newUser.data && newUser.data[0] }; + } + return { success: false }; +};