From d5af7315cf8b968b330113975788d3f6faea8724 Mon Sep 17 00:00:00 2001 From: Rory Shanks Date: Fri, 22 Mar 2024 20:58:42 +0100 Subject: [PATCH] Added admin page --- lib/adminpage.js | 93 ++++++++++++++++++- lib/http.js | 5 +- lib/idp.js | 8 +- lib/idp_adapters/googleworkspace.js | 8 +- lib/idp_adapters/localfile.js | 8 +- lib/idp_adapters/msgraph.js | 7 +- lib/idp_adapters/tokenclaims.js | 31 ++++++- test/e2e/configs/dex.yaml | 7 +- test/e2e/configs/idp_output.json | 11 +++ test/e2e/configs/veriflow.yaml | 4 + test/e2e/tests/admin_page_test.js | 17 ++++ util/caddyModels.js | 25 +++-- views/admin_sessions.ejs | 77 +++------------ views/admin_user_details.ejs | 69 ++++++++++++++ views/admin_users.ejs | 48 ++++++++++ views/error_fullpage.ejs | 2 +- views/includes/admin_head.ejs | 6 ++ views/includes/admin_style.ejs | 74 +++++++++++++++ views/includes/{head.ejs => error_head.ejs} | 3 +- views/includes/{style.ejs => error_style.ejs} | 0 20 files changed, 421 insertions(+), 82 deletions(-) create mode 100644 test/e2e/tests/admin_page_test.js create mode 100644 views/admin_user_details.ejs create mode 100644 views/admin_users.ejs create mode 100644 views/includes/admin_head.ejs create mode 100644 views/includes/admin_style.ejs rename views/includes/{head.ejs => error_head.ejs} (66%) rename views/includes/{style.ejs => error_style.ejs} (100%) diff --git a/lib/adminpage.js b/lib/adminpage.js index 8549b41..c0e2843 100644 --- a/lib/adminpage.js +++ b/lib/adminpage.js @@ -3,14 +3,94 @@ import path from 'path'; import { getConfig, getRedirectBasepath } from '../util/config.js'; import redisHelper from '../util/redis.js' import idp from './idp.js'; +import errorpages from '../util/errorpage.js' +import { decodeJWT, createJWT } from '../util/jwt.js'; +import log from '../util/logging.js' var redirectBasePath = getRedirectBasepath() +const route = { + from: "ADMIN_INTERNAL", + to: "ADMIN_INTERNAL" +} + +async function verifyAdminJwt(req, res, next) { + try { + + var jwt = req.get("X-Veriflow-Admin-Jwt") + var decodedJwt = await decodeJWT(jwt) + if (!jwt || !decodedJwt) { + var html = await errorpages.renderErrorPage(403, null, req) + log.access("invalidJwtUsedToAccessAdminPage", route, jwt, req) + res.status(403).send(html) + return + } + var allowedGroups = getConfig().admin.allowed_groups + var foundGroups = await checkUserGroupMembership(decodedJwt, allowedGroups) + if (foundGroups.length > 0) { + next() + } else { + var html = await errorpages.renderErrorPage(403, null, req) + log.access("userIsDeniedToAdminPage", route, jwt, req) + res.status(403).send(html) + return + } + } catch (error) { + var html = await errorpages.renderErrorPage(403, null, req) + log.error({ message: "Unknown error occurred in verifyAdminJwt", context: { error: error.message, stack: error.stack } }) + res.status(403).send(html) + return + } +} + async function renderSessionsPage(req, res) { var sessions = await redisHelper.getAllSessions() + var config = getConfig() + var logo_image_src = config?.ui?.logo_image_src || false var html = await ejs.renderFile(path.join(process.cwd(), '/views/admin_sessions.ejs'), { - sessions + sessions, + logo_image_src, + redirectBasePath + }); + res.send(html) +} + +async function renderUsersPage(req, res) { + var usersObj = await idp.getAllUsers() + console.log(usersObj) + var users = [] + for (var user of Object.keys(usersObj)) { + users.push(usersObj[user]) + } + var config = getConfig() + var logo_image_src = config?.ui?.logo_image_src || false + var html = await ejs.renderFile(path.join(process.cwd(), '/views/admin_users.ejs'), + { + users, + logo_image_src, + redirectBasePath + }); + res.send(html) +} + +async function renderUserDetailsPage(req, res) { + var userId = req.query.id + if (!userId) { + res.redirect(redirectBasePath + "/asmin/users") + return + } + var user = await idp.getUserById(userId) + var userGroups = user.groups + delete user.groups + var config = getConfig() + var logo_image_src = config?.ui?.logo_image_src || false + var html = await ejs.renderFile(path.join(process.cwd(), '/views/admin_user_details.ejs'), + { + user, + userGroups, + logo_image_src, + redirectBasePath }); res.send(html) } @@ -23,7 +103,16 @@ async function killSession(req, res) { res.redirect(redirectBasePath + '/admin/sessions/') } +async function checkUserGroupMembership(user, groups) { + // FIXME Change this to be a set in memory as part of the idpUpdate + let set = new Set(user.groups); + return groups.filter(item => set.has(item)); +} + export default { renderSessionsPage, - killSession + killSession, + verifyAdminJwt, + renderUsersPage, + renderUserDetailsPage } \ No newline at end of file diff --git a/lib/http.js b/lib/http.js index c90f91a..bc81930 100644 --- a/lib/http.js +++ b/lib/http.js @@ -77,8 +77,11 @@ app.get('/ping', (req, res) => { }) if (config.enable_admin_panel !== false) { - app.get(redirectBasePath + '/admin/sessions/', admin.renderSessionsPage) + app.use(redirectBasePath + '/admin', admin.verifyAdminJwt) + app.get(redirectBasePath + '/admin/sessions', admin.renderSessionsPage) app.get(redirectBasePath + '/admin/sessions/kill', admin.killSession) + app.get(redirectBasePath + '/admin/users', admin.renderUsersPage) + app.get(redirectBasePath + '/admin/users/details', admin.renderUserDetailsPage) } diff --git a/lib/idp.js b/lib/idp.js index 40f408b..d47bd67 100644 --- a/lib/idp.js +++ b/lib/idp.js @@ -55,6 +55,11 @@ async function getUserById(userId) { return user } +async function getAllUsers() { + var users = await adapter.getAllUsers() + return users +} + async function addNewUserFromClaims(userClaims) { if (!adapter.addNewUserFromClaims) { log.debug({ message: `Adapter ${currentConfig.idp_provider} does not support adding new users via claims, returning`, context: { claims: userClaims } }) @@ -67,5 +72,6 @@ async function addNewUserFromClaims(userClaims) { export default { getUserById, scheduleUpdate, - addNewUserFromClaims + addNewUserFromClaims, + getAllUsers } \ No newline at end of file diff --git a/lib/idp_adapters/googleworkspace.js b/lib/idp_adapters/googleworkspace.js index c8b4550..7485e22 100644 --- a/lib/idp_adapters/googleworkspace.js +++ b/lib/idp_adapters/googleworkspace.js @@ -99,7 +99,13 @@ async function getUserById(id) { return config[id] } +async function getAllUsers() { + var config = await getIdpConfig() + return config +} + export default { runUpdate, - getUserById + getUserById, + getAllUsers }; diff --git a/lib/idp_adapters/localfile.js b/lib/idp_adapters/localfile.js index ce167cb..5767b7f 100644 --- a/lib/idp_adapters/localfile.js +++ b/lib/idp_adapters/localfile.js @@ -24,7 +24,13 @@ async function getUserById(id) { return config[id] } +async function getAllUsers() { + var config = await getLocalIdpConfig() + return config +} + export default { runUpdate, - getUserById + getUserById, + getAllUsers }; \ No newline at end of file diff --git a/lib/idp_adapters/msgraph.js b/lib/idp_adapters/msgraph.js index 6525fb7..66aacc0 100644 --- a/lib/idp_adapters/msgraph.js +++ b/lib/idp_adapters/msgraph.js @@ -120,8 +120,13 @@ async function getUserById(id) { return config[id] } +async function getAllUsers() { + var config = await getIdpConfig() + return config +} export default { runUpdate, - getUserById + getUserById, + getAllUsers }; \ No newline at end of file diff --git a/lib/idp_adapters/tokenclaims.js b/lib/idp_adapters/tokenclaims.js index ca879f6..424d6ed 100644 --- a/lib/idp_adapters/tokenclaims.js +++ b/lib/idp_adapters/tokenclaims.js @@ -43,8 +43,37 @@ async function addNewUserFromClaims(claims) { await redisClient.expire(`veriflow:users:${userId}`, 87000); // expire in 24 hours } +async function getAllUsers() { + const keys = await scanKeys('veriflow:users:*'); + const users = []; + for (const key of keys) { + const userDataRaw = await redisClient.get(key); + if (userDataRaw) { + let userData = JSON.parse(userDataRaw); + // Extract session ID from key and add it to the session object + const userId = key.replace('veriflow:users:', ''); + userData.userId = userId; + users.push(userData); + } + } + return users; +} + +// Utility function to use SCAN for fetching keys without blocking the server +async function scanKeys(pattern) { + let cursor = '0'; + const keys = []; + do { + const reply = await redisClient.scan(cursor, 'MATCH', pattern, 'COUNT', '100'); + cursor = reply[0]; + keys.push(...reply[1]); + } while (cursor !== '0'); + return keys; +} + export default { runUpdate, getUserById, - addNewUserFromClaims + addNewUserFromClaims, + getAllUsers }; \ No newline at end of file diff --git a/test/e2e/configs/dex.yaml b/test/e2e/configs/dex.yaml index 887a9b2..8b5009e 100644 --- a/test/e2e/configs/dex.yaml +++ b/test/e2e/configs/dex.yaml @@ -29,4 +29,9 @@ staticPasswords: # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" username: "test" - userID: "test@veriflow.dev" \ No newline at end of file + userID: "test@veriflow.dev" +- email: "denied@veriflow.dev" + # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "denied" + userID: "denied@veriflow.dev" \ No newline at end of file diff --git a/test/e2e/configs/idp_output.json b/test/e2e/configs/idp_output.json index 0ba37b6..a85e3e4 100644 --- a/test/e2e/configs/idp_output.json +++ b/test/e2e/configs/idp_output.json @@ -10,5 +10,16 @@ "All Users", "test-header-group" ] + }, + "denied@veriflow.dev": { + "displayName": "Veriflow Denied User", + "givenName": "Denied", + "preferredLanguage": "en", + "surname": "User", + "userPrincipalName": "denied@veriflow.dev", + "id": "denied@veriflow.dev", + "groups": [ + "No Users" + ] } } \ No newline at end of file diff --git a/test/e2e/configs/veriflow.yaml b/test/e2e/configs/veriflow.yaml index fdbe2e0..060fb52 100644 --- a/test/e2e/configs/veriflow.yaml +++ b/test/e2e/configs/veriflow.yaml @@ -1,4 +1,8 @@ --- +admin: + enable: true + allowed_groups: + - All Users data_listen_port: 80 metrics_listen_port: 9090 service_url: http://veriflow.localtest.me diff --git a/test/e2e/tests/admin_page_test.js b/test/e2e/tests/admin_page_test.js new file mode 100644 index 0000000..646e6a1 --- /dev/null +++ b/test/e2e/tests/admin_page_test.js @@ -0,0 +1,17 @@ +Feature('Admin Page').retry(3); + +Scenario('I can login to admin page', async ({ I }) => { + I.amOnPage('http://veriflow.localtest.me/.veriflow/admin/sessions'); + I.login(); + I.see("Sessions") +}); + +Scenario('I cant login to admin page as non admin', async ({ I }) => { + I.amOnPage('http://veriflow.localtest.me/.veriflow/admin/sessions'); + I.fillField('login', 'denied@veriflow.dev'); + I.fillField('password', 'password'); + I.click('Login'); + I.see("Unauthorized") +}); + + diff --git a/util/caddyModels.js b/util/caddyModels.js index 0e4776e..b84aaad 100644 --- a/util/caddyModels.js +++ b/util/caddyModels.js @@ -304,18 +304,26 @@ async function saturateAllRoutesFromConfig(config) { async function generateCaddyConfig() { log.debug("Generating new caddy config") var config = getConfig() - if (config.enable_admin_panel !== false) { + + if (config?.admin?.enable !== false && config?.admin?.allowed_groups) { + log.info("Admin panel will be enabled") var serviceUrl = config.service_url var baseRedirectUrl = getRedirectBasepath() - var adminUrl = new URL(`https://${serviceUrl}${baseRedirectUrl}/admin`) + var adminUrl = new URL(`${serviceUrl}${baseRedirectUrl}/admin`) var adminPanelRoute = { from: { - host: adminUrl.hostname, - path: adminUrl.pathname + host: [adminUrl.hostname], + path: [getRedirectBasepath() + "/admin/*"] }, - to: "localhost:" + getAuthListenPort() + to: "http://localhost:" + getAuthListenPort(), + allowed_groups: config.admin.allowed_groups, + claims_headers: { + "X-Veriflow-Admin-Jwt": "jwt" + } } + config.policy.unshift(adminPanelRoute) } + var routes = await saturateAllRoutesFromConfig(config) var requestIdRoute = { @@ -381,7 +389,12 @@ async function generateCaddyConfig() { serviceUrl.hostname ], "path": [ - + "/ping", + getRedirectBasepath() + "/verify", + getRedirectBasepath() + "/set", + getRedirectBasepath() + "/logout", + getRedirectBasepath() + "/auth", + getRedirectBasepath() + "/callback" ] } ], diff --git a/views/admin_sessions.ejs b/views/admin_sessions.ejs index b37e5d3..5069ef9 100644 --- a/views/admin_sessions.ejs +++ b/views/admin_sessions.ejs @@ -1,74 +1,23 @@ - - -Veriflow Admin Dashboard - - + <%- include('includes/admin_head'); %>
Current Sessions
+
@@ -86,13 +35,13 @@ <% for (const session of sessions) { %> - + - + - - - + + + <% } %> diff --git a/views/admin_user_details.ejs b/views/admin_user_details.ejs new file mode 100644 index 0000000..e32b3b8 --- /dev/null +++ b/views/admin_user_details.ejs @@ -0,0 +1,69 @@ + + + + <%- include('includes/admin_head'); %> + + + + + +
+
+
User Details - <%= user.id %>
+
+
<%= session.sessionId %><%= session.details.parent_session_id %><%= session?.details?.parent_session_id %> <%= session.userId %><%= session.details.original_host %><%= session?.details?.original_host %> <%= session.cookie.expires %><%= session.details.remote_ip %><%= session.details.user_agent %><%= session?.details?.remote_ip %><%= session?.details?.user_agent %>
+ + + + + + + + <% for (const d of Object.keys(user)) { %> + + + + + <% } %> + + + +
KeyValue
<%= d %><%= user[d] %>
+
+
+
User Groups
+
+ + + + + + + + <% for (const d of userGroups) { %> + + + + <% } %> + + + +
Group Name
<%= d %>
+
+
+ + + + + + diff --git a/views/admin_users.ejs b/views/admin_users.ejs new file mode 100644 index 0000000..70d9315 --- /dev/null +++ b/views/admin_users.ejs @@ -0,0 +1,48 @@ + + + + <%- include('includes/admin_head'); %> + + + + + +
+
+
Current Users
+
+ + + + + + + + + <% for (const user of users) { %> + + + + + <% } %> + + + +
User IDNumber Groups
<%= user.id %><%= user.groups.length %>
+
+
+ + + + + + diff --git a/views/error_fullpage.ejs b/views/error_fullpage.ejs index 236bf0b..86e833e 100644 --- a/views/error_fullpage.ejs +++ b/views/error_fullpage.ejs @@ -2,7 +2,7 @@ - <%- include('includes/head'); %> + <%- include('includes/error_head'); %> diff --git a/views/includes/admin_head.ejs b/views/includes/admin_head.ejs new file mode 100644 index 0000000..843a459 --- /dev/null +++ b/views/includes/admin_head.ejs @@ -0,0 +1,6 @@ + + +Veriflow Admin Dashboard + +<%- include('normalize'); %> +<%- include('admin_style'); %> \ No newline at end of file diff --git a/views/includes/admin_style.ejs b/views/includes/admin_style.ejs new file mode 100644 index 0000000..f3da5e2 --- /dev/null +++ b/views/includes/admin_style.ejs @@ -0,0 +1,74 @@ + \ No newline at end of file diff --git a/views/includes/head.ejs b/views/includes/error_head.ejs similarity index 66% rename from views/includes/head.ejs rename to views/includes/error_head.ejs index d153190..d6f5a6d 100644 --- a/views/includes/head.ejs +++ b/views/includes/error_head.ejs @@ -1,7 +1,6 @@ - <%= title %> <%- include('normalize'); %> -<%- include('style'); %> \ No newline at end of file +<%- include('error_style'); %> \ No newline at end of file diff --git a/views/includes/style.ejs b/views/includes/error_style.ejs similarity index 100% rename from views/includes/style.ejs rename to views/includes/error_style.ejs