diff --git a/docker/supervisord-dev.conf b/docker/supervisord-dev.conf index 7f646e3..3bd0a82 100644 --- a/docker/supervisord-dev.conf +++ b/docker/supervisord-dev.conf @@ -15,7 +15,7 @@ startsecs=2 priority=1 [program:auth] -command=npx nodemon app.js +command=npx nodemon app.js -e ejs,js,css,html,jpg,png,scss autostart=true autorestart=true redirect_stderr=true diff --git a/lib/adminpage.js b/lib/adminpage.js new file mode 100644 index 0000000..4eea1ff --- /dev/null +++ b/lib/adminpage.js @@ -0,0 +1,132 @@ +import ejs from 'ejs'; +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' +import authz from './authz.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 authz.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) { + try { + 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, + logo_image_src, + redirectBasePath + }); + res.send(html) + } catch (error) { + var html = await errorpages.renderErrorPage(500, null, req) + log.error({ message: "Unknown error occurred in renderSessionsPage", context: { error: error.message, stack: error.stack } }) + res.status(500).send(html) + } + +} + +async function renderUsersPage(req, res) { + try { + 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) + } catch (error) { + var html = await errorpages.renderErrorPage(500, null, req) + log.error({ message: "Unknown error occurred in renderUsersPage", context: { error: error.message, stack: error.stack } }) + res.status(500).send(html) + } +} + +async function renderUserDetailsPage(req, res) { + try { + 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) + } catch (error) { + var html = await errorpages.renderErrorPage(500, null, req) + log.error({ message: "Unknown error occurred in renderUserDetailsPage", context: { error: error.message, stack: error.stack } }) + res.status(500).send(html) + } + +} + +async function killSession(req, res) { + var sessionId = req.query.id + if (sessionId) { + await redisHelper.deleteSession(sessionId) + } + res.redirect(redirectBasePath + '/admin/sessions/') +} + +export default { + renderSessionsPage, + killSession, + verifyAdminJwt, + renderUsersPage, + renderUserDetailsPage +} \ No newline at end of file diff --git a/lib/authz.js b/lib/authz.js index d2626a1..72eaffb 100644 --- a/lib/authz.js +++ b/lib/authz.js @@ -122,5 +122,6 @@ async function getRequestHeaderMapConfig(user, route) { } export default { - authZRequest + authZRequest, + checkUserGroupMembership } \ No newline at end of file diff --git a/lib/http.js b/lib/http.js index c264d9f..5dc2cc4 100644 --- a/lib/http.js +++ b/lib/http.js @@ -11,8 +11,11 @@ import crypto from 'crypto'; import errorpages from '../util/errorpage.js' import metrics from '../util/metrics.js' import { randomUUID } from 'node:crypto' +import admin from './adminpage.js' -var trusted_ranges = ["loopback"].concat(getConfig().trusted_ranges || []) +const config = getConfig() + +var trusted_ranges = ["loopback"].concat(config.trusted_ranges || []) log.debug({ message: `Setting trusted proxies to ${trusted_ranges}` }) app.set('trust proxy', trusted_ranges) @@ -25,10 +28,10 @@ app.use( store: redisHelper.getRedisStore(), resave: false, saveUninitialized: false, - secret: getConfig().cookie_secret, + secret: config.cookie_secret, cookie: { ...defaultCookieOptions, - ...getConfig().cookie_settings + ...config.cookie_settings } }) ) @@ -41,13 +44,13 @@ const getPublicKeyFromPrivateKey = (privateKeyPem) => { }; function generateJwks() { - let signing_key = getConfig().signing_key + let signing_key = config.signing_key let buff = Buffer.from(signing_key, 'base64'); let pemPrivateKey = buff.toString('ascii'); const pemPublicKey = getPublicKeyFromPrivateKey(pemPrivateKey); jwks = pem2jwk(pemPublicKey); - jwks.kid = getConfig().kid_override || "0" - jwks.alg = getConfig().signing_key_algorithm || "RS256" + jwks.kid = config.kid_override || "0" + jwks.alg = config.signing_key_algorithm || "RS256" jwks.use = "sig" } @@ -73,6 +76,16 @@ app.get('/ping', (req, res) => { res.sendStatus(200) }) +if (config.enable_admin_panel !== false) { + 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) +} + + + app.get(redirectBasePath + '/verify', ssoController.verifyAuth) app.get(redirectBasePath + '/set', ssoController.setSessionCookie) @@ -80,6 +93,7 @@ app.get(redirectBasePath + '/logout', async (req, res) => { if (req.session.loggedin) { await redisHelper.logUserOutAllSessions(req.session.userId) } + req.session.destroy() var html = await errorpages.renderErrorPage(200, "LOGOUT_SUCCESS", req) res.send(html) }) @@ -87,7 +101,7 @@ app.get(redirectBasePath + '/logout', async (req, res) => { app.get(redirectBasePath + '/auth', ssoController.redirectToSsoProvider) app.get(redirectBasePath + '/callback', ssoController.verifySsoCallback) -app.get(getConfig().jwks_path, (req, res) => { +app.get(config.jwks_path, (req, res) => { res.json({ keys: [jwks], }); 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/lib/sso.js b/lib/sso.js index 3fac26c..66afe66 100644 --- a/lib/sso.js +++ b/lib/sso.js @@ -126,8 +126,6 @@ async function verifyAuth(req, res) { */ async function setSessionCookie(req, res) { try { - // First get the current config - var currentConfig = getConfig() // If the request contains a token param, verify it, and log the user in by setting a session variable. // Finally, redirect the user to the originally requested URL, which was also specified in the JWT if (req.query.token) { @@ -141,7 +139,8 @@ async function setSessionCookie(req, res) { req.session.loggedin = true req.session.userId = decoded.userId; - + await addSessionDetails(req) + req.session.details.parent_session_id = decoded.parentSession // This sets the cookie for the accessed domain to expire at the same time as the "main" veriflow cookie, to // prevent a user from being deauthenticated from Veriflow, but still authenticated on the subdomains. var expireDate = false @@ -210,14 +209,15 @@ async function redirectToSsoProvider(req, res) { var redirectBasePath = getRedirectBasepath() if (req.session.loggedin) { - req.session.touch() + await addSessionDetails(req) let jwtPayload = { protocol: redirectToken.protocol, host: redirectToken.host, path: redirectToken.path, query: redirectToken.query, userId: req.session.userId, - cookieExpires: req.session.cookie.expires + cookieExpires: req.session.cookie.expires, + parentSession: req.sessionID } var signedJwt = await createJWT(jwtPayload) @@ -296,6 +296,7 @@ async function verifySsoCallback(req, res) { req.session.loggedin = true; req.session.userId = userIdClaim; + await addSessionDetails(req) let jwtPayload = { protocol: req.session.redirect.protocol, @@ -303,7 +304,8 @@ async function verifySsoCallback(req, res) { path: req.session.redirect.path, query: req.session.redirect.query, userId: userIdClaim, - cookieExpires: req.session.cookie.expires + cookieExpires: req.session.cookie.expires, + parentSession: req.sessionID } var signedJwt = await createJWT(jwtPayload) @@ -325,6 +327,14 @@ async function verifySsoCallback(req, res) { } } +async function addSessionDetails(req) { + req.session.details = { + user_agent: req.get("user-agent"), + remote_ip: req.ip, + original_host: req.get('x-forwarded-host') + } +} + export default { verifySsoCallback, redirectToSsoProvider, diff --git a/package.json b/package.json index b805f97..cdeb651 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "app.js", "type": "module", "scripts": { - "start": "npx nodemon app.js" + "start": "npx nodemon app.js -e ejs,js,css,html,jpg,png,scss" }, "repository": { "type": "git", 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 9153609..b84aaad 100644 --- a/util/caddyModels.js +++ b/util/caddyModels.js @@ -1,4 +1,4 @@ -import { getConfig, getAuthListenPort } from "./config.js" +import { getConfig, getAuthListenPort, getRedirectBasepath } from "./config.js" import log from './logging.js'; import { writeFile, stat } from "fs/promises"; import axios from 'axios'; @@ -304,6 +304,26 @@ async function saturateAllRoutesFromConfig(config) { async function generateCaddyConfig() { log.debug("Generating new caddy config") var config = getConfig() + + 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(`${serviceUrl}${baseRedirectUrl}/admin`) + var adminPanelRoute = { + from: { + host: [adminUrl.hostname], + path: [getRedirectBasepath() + "/admin/*"] + }, + 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 = { @@ -367,6 +387,14 @@ async function generateCaddyConfig() { { "host": [ serviceUrl.hostname + ], + "path": [ + "/ping", + getRedirectBasepath() + "/verify", + getRedirectBasepath() + "/set", + getRedirectBasepath() + "/logout", + getRedirectBasepath() + "/auth", + getRedirectBasepath() + "/callback" ] } ], diff --git a/util/redis.js b/util/redis.js index d6d0aab..0e110b4 100644 --- a/util/redis.js +++ b/util/redis.js @@ -77,9 +77,15 @@ async function scanKeys(pattern) { return keys; } +async function deleteSession(sessionId) { + await redis.del(`vfsession:${sessionId}`); +} + export default { getClient, getRedisConfig, getRedisStore, - logUserOutAllSessions + logUserOutAllSessions, + getAllSessions, + deleteSession } \ No newline at end of file diff --git a/util/utils.js b/util/utils.js index a80669f..0000e66 100644 --- a/util/utils.js +++ b/util/utils.js @@ -18,6 +18,11 @@ function convertHeaderCase(str) { .join('-'); // Rejoin the words with hyphens } +async function addSessionMetadataToRequest(req) { + var userAgent + var requestingIp +} + export default { urlToCaddyUpstream, convertHeaderCase diff --git a/views/admin_sessions.ejs b/views/admin_sessions.ejs new file mode 100644 index 0000000..5069ef9 --- /dev/null +++ b/views/admin_sessions.ejs @@ -0,0 +1,60 @@ + + + + <%- include('includes/admin_head'); %> + + + + + +
+
+
Current Sessions
+
+ + + + + + + + + + + + + + + <% for (const session of sessions) { %> + + + + + + + + + + + <% } %> + + + +
Session IDParent Session IDUsernameAccessed HostSession ExpiresConnecting IPUser AgentActions
<%= session.sessionId %><%= session?.details?.parent_session_id %><%= session.userId %><%= session?.details?.original_host %><%= session.cookie.expires %><%= session?.details?.remote_ip %><%= session?.details?.user_agent %>
+
+
+ + + + + + 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 %>
+
+ + + + + + + + + <% 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..3c8504a --- /dev/null +++ b/views/includes/admin_style.ejs @@ -0,0 +1,75 @@ + \ 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