From 75cf9cf5b40ae97cd34ebef01cde28b0925f1ffa Mon Sep 17 00:00:00 2001 From: Rory Shanks <6383578+rorylshanks@users.noreply.github.com> Date: Sat, 16 Mar 2024 23:30:12 +0100 Subject: [PATCH] Added new UI for error pages, and added ability to log user out of all sessions (#21) --- example-config.yaml | 6 + lib/http.js | 25 ++- lib/sso.js | 34 ++-- util/config.js | 11 +- util/errorpage.js | 48 ++++- util/redis.js | 58 +++++- views/error_fullpage.ejs | 357 ++++------------------------------- views/includes/head.ejs | 7 + views/includes/logo.ejs | 6 + views/includes/normalize.ejs | 243 ++++++++++++++++++++++++ views/includes/style.ejs | 114 +++++++++++ 11 files changed, 553 insertions(+), 356 deletions(-) create mode 100644 views/includes/head.ejs create mode 100644 views/includes/logo.ejs create mode 100644 views/includes/normalize.ejs create mode 100644 views/includes/style.ejs diff --git a/example-config.yaml b/example-config.yaml index 9690ca7..e3d09e2 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -1,4 +1,10 @@ --- +ui: + error_page_background: https://images.unsplash.com/photo-1702217172431-268c4f58e401?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=hector-john-periquin-Vz156fJNZzM-unsplash.jpg + error_page_footer_text: Internal Systems - Authorized Access Only + logo_image_src: https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/1200px-Google_2015_logo.svg.png + error_page_show_error_code: false + auth_listen_port: 3000 data_listen_port: 2080 service_url: https://veriflow.codemo.re diff --git a/lib/http.js b/lib/http.js index 3bf42fe..3825edb 100644 --- a/lib/http.js +++ b/lib/http.js @@ -3,18 +3,15 @@ const app = express(); import session from 'express-session'; import ssoController from './sso.js'; -import RedisStore from "connect-redis" + import redisHelper from "../util/redis.js" import log from '../util/logging.js' -import { getConfig } from '../util/config.js'; +import { getConfig, getRedirectBasepath } from '../util/config.js'; import { pem2jwk } from 'pem-jwk'; import crypto from 'crypto'; +import errorpages from '../util/errorpage.js' + -// Initialize store. -let redisStore = new RedisStore({ - client: redisHelper.getClient(), - prefix: "vfsession:", -}) var trusted_ranges = ["loopback"].concat(getConfig().trusted_ranges || []) @@ -29,7 +26,7 @@ if (trusted_ranges) { app.use( session({ name: "vfsession", - store: redisStore, + store: redisHelper.getRedisStore(), resave: false, saveUninitialized: false, secret: getConfig().cookie_secret, @@ -61,7 +58,7 @@ generateJwks() app.use(express.json()); app.use(express.urlencoded({ extended: true })) -var redirectBasePath = getConfig().redirect_base_path || "/.veriflow" +var redirectBasePath = getRedirectBasepath() app.get('/ping', (req, res) => { res.sendStatus(200) @@ -70,10 +67,12 @@ app.get('/ping', (req, res) => { app.get(redirectBasePath + '/verify', ssoController.verifyAuth) app.get(redirectBasePath + '/set', ssoController.setSessionCookie) -app.get(redirectBasePath + '/logout', (req, res) => { - log.info({ message: "Logged user out", user: req.session.userId }) - req.session.destroy() - res.send("Logged out") +app.get(redirectBasePath + '/logout', async (req, res) => { + if (req.session.loggedin) { + await redisHelper.logUserOutAllSessions(req.session.userId) + } + var html = await errorpages.renderErrorPage(200, "LOGOUT_SUCCESS", req) + res.send(html) }) app.get(redirectBasePath + '/auth', ssoController.redirectToSsoProvider) diff --git a/lib/sso.js b/lib/sso.js index 0af88bd..8d41930 100644 --- a/lib/sso.js +++ b/lib/sso.js @@ -1,6 +1,6 @@ import { Issuer } from 'openid-client'; import { decodeJWT, createJWT } from '../util/jwt.js'; -import { getConfig, getRouteFromRequest } from '../util/config.js'; +import { getConfig, getRouteFromRequest, getRedirectBasepath } from '../util/config.js'; import { URL, URLSearchParams } from 'url'; import log from '../util/logging.js' import authz from './authz.js' @@ -33,7 +33,7 @@ async function verifyAuth(req, res) { var route = getRouteFromRequest(req) if (!route) { log.warn({ message: "No route found for request", host: requestUrl }) - var html = await errorpages.renderErrorPage(404) + var html = await errorpages.renderErrorPage(404, null, req) res.status(404).send(html) return } @@ -65,7 +65,7 @@ async function verifyAuth(req, res) { if (req.session.loggedin) { let userIsAuthz = await authz.authZRequest(req, res, route) if (!userIsAuthz) { - var html = await errorpages.renderErrorPage(403) + var html = await errorpages.renderErrorPage(403, null, req) res.status(403).send(html) return } @@ -73,7 +73,7 @@ async function verifyAuth(req, res) { if (route.to.source == "veriflow_dynamic") { const backend = await dynamicBackend.checkDynamicBackend(req, res, route, requestUrl) if (!backend) { - var html = await errorpages.renderErrorPage(500, "ERR_DYNAMIC_BACKEND_FAILED") + var html = await errorpages.renderErrorPage(500, "ERR_DYNAMIC_BACKEND_FAILED", req) res.status(500).send(html) return } @@ -97,7 +97,7 @@ async function verifyAuth(req, res) { path: req.get('X-Forwarded-Path'), query: req.get('X-Forwarded-Query') } - var redirectBasePath = currentConfig.redirect_base_path || "/.veriflow" + var redirectBasePath = getRedirectBasepath() if (jwtPayload.path == redirectBasePath + "/verify") { jwtPayload.path = "/" jwtPayload.query = "" @@ -110,7 +110,7 @@ async function verifyAuth(req, res) { res.redirect(redirectUrl) } catch (error) { log.error({ message: "Unknown error occurred in verifyAuth", context: { error: error.message, stack: error.stack } }) - var html = await errorpages.renderErrorPage(500, "ERR_AUTH_FAILED") + var html = await errorpages.renderErrorPage(500, "ERR_AUTH_FAILED", req) res.status(500).send(html) } } @@ -134,7 +134,7 @@ async function setSessionCookie(req, res) { var decoded = await decodeJWT(req.query.token) if (!decoded) { log.error({ message: "Failed to decode JWT", context: { jwt: req.query.token } }) - var html = await errorpages.renderErrorPage(500, "ERR_SET_INVALID_JWT") + var html = await errorpages.renderErrorPage(500, "ERR_SET_INVALID_JWT", req) res.status(500).send(html) return } @@ -151,7 +151,7 @@ async function setSessionCookie(req, res) { var redirectPath = decoded.path var redirectQuery = decoded.query - var redirectBasePath = currentConfig.redirect_base_path || "/.veriflow" + var redirectBasePath = getRedirectBasepath() if (redirectPath == redirectBasePath + "/set") { redirectPath = "/" redirectQuery = {} @@ -161,13 +161,13 @@ async function setSessionCookie(req, res) { res.redirect(redirectUrl); return } else { - var html = await errorpages.renderErrorPage(400, "ERR_SET_NO_TOKEN") + var html = await errorpages.renderErrorPage(400, "ERR_SET_NO_TOKEN", req) res.status(400).send(html) } } catch (error) { log.error({ message: "Unknown error occurred in setSessionCookie", context: { error: error.message, stack: error.stack } }) - var html = await errorpages.renderErrorPage(500, "ERR_SET_FAILED") + var html = await errorpages.renderErrorPage(500, "ERR_SET_FAILED", req) res.status(500).send(html) } } @@ -180,7 +180,7 @@ async function setSessionCookie(req, res) { async function redirectToSsoProvider(req, res) { try { if (!req.query.token) { - var html = await errorpages.renderErrorPage(400, "ERR_REDIRECT_NO_TOKEN") + var html = await errorpages.renderErrorPage(400, "ERR_REDIRECT_NO_TOKEN", req) res.status(400).send(html) return } @@ -188,13 +188,13 @@ async function redirectToSsoProvider(req, res) { var redirectToken = await decodeJWT(req.query.token) if (!redirectToken) { - var html = await errorpages.renderErrorPage(400, "ERR_REDIRECT_BAD_TOKEN") + var html = await errorpages.renderErrorPage(400, "ERR_REDIRECT_BAD_TOKEN", req) res.status(400).send(html) return } var currentConfig = getConfig() - var redirectBasePath = currentConfig.redirect_base_path || "/.veriflow" + var redirectBasePath = getRedirectBasepath() if (req.session.loggedin) { let jwtPayload = { @@ -242,7 +242,7 @@ async function redirectToSsoProvider(req, res) { res.redirect(redirectUrl) } catch (error) { log.error({ message: "Unknown error occoured in redirectToSsoProvider", context: { error: error.message, trace: error.stack } }) - var html = await errorpages.renderErrorPage(500, "ERR_REDIRECT_FAILED") + var html = await errorpages.renderErrorPage(500, "ERR_REDIRECT_FAILED", req) res.status(500).send(html) } @@ -254,7 +254,7 @@ async function verifySsoCallback(req, res) { var currentConfig = getConfig() var oauthIssuer = await Issuer.discover(currentConfig.idp_provider_url) - var redirectBasePath = getConfig().redirect_base_path || "/.veriflow" + var redirectBasePath = getRedirectBasepath() var oauth_client = new oauthIssuer.Client({ client_id: currentConfig.idp_client_id, @@ -273,7 +273,7 @@ async function verifySsoCallback(req, res) { if (!userIdClaim) { log.warn({ error: "User does not have a userId included in the ID token claims. Check setting idp_provider_user_id_claim", claims: userClaims }) - var html = await errorpages.renderErrorPage(500, "ERR_NO_USERID_IN_TOKEN") + var html = await errorpages.renderErrorPage(500, "ERR_NO_USERID_IN_TOKEN", req) res.status(500).send(html) return } @@ -305,7 +305,7 @@ async function verifySsoCallback(req, res) { } catch (error) { log.error({ message: "Unknown error occoured in verifySsoCallback", context: { error: error.message, trace: error.stack } }) - var html = await errorpages.renderErrorPage(500, "ERR_CALLBACK_FAILED") + var html = await errorpages.renderErrorPage(500, "ERR_CALLBACK_FAILED", req) res.status(500).send(html) } } diff --git a/util/config.js b/util/config.js index dcfc660..77a6c31 100644 --- a/util/config.js +++ b/util/config.js @@ -47,10 +47,17 @@ function getRouteFromRequest(req) { } } - +function getRedirectBasepath() { + var redirectBasePath = getConfig().redirect_base_path || "/.veriflow" + if (!redirectBasePath.startsWith("/")) { + redirectBasePath = `/${redirectBasePath}` + } + return redirectBasePath +} export { reloadConfig, getConfig, - getRouteFromRequest + getRouteFromRequest, + getRedirectBasepath }; \ No newline at end of file diff --git a/util/errorpage.js b/util/errorpage.js index d393a00..bf5b4c8 100644 --- a/util/errorpage.js +++ b/util/errorpage.js @@ -1,13 +1,32 @@ import ejs from 'ejs'; import path from 'path'; +import { getConfig, getRedirectBasepath } from '../util/config.js'; -async function renderErrorPage(status_code, error_code_override) { + +async function renderErrorPage(status_code, error_code_override, req) { + var config = getConfig(); + var redirectBasePath = getRedirectBasepath() + var logoutUrlObj = new URL(`${config.service_url}${redirectBasePath}/logout`) + var logout_url = logoutUrlObj.href var header = "Internal Server Error" + + + // UI Tweaks for error page + var footer_text = config?.ui?.error_page_footer_text || "Veriflow Access Proxy" + var background_image_url = config?.ui?.error_page_background || false + var additional_css = config?.ui?.error_page_additional_css || false + var show_error_code = true + if (config?.ui?.error_page_show_error_code != null){ + var show_error_code = config?.ui?.error_page_show_error_code + } + var logo_image_src = config?.ui?.logo_image_src || false + var page_title = config?.ui?.page_title || "Veriflow" + var description = "An internal server error occoured. Please try again." var error_code = "ERR_INTERNAL_ERROR" if (status_code == 404) { - header = "Resource Not Found" - description = "The requested resource cannot be found. Please check and try again." + header = "Page Not Found" + description = "The requested page cannot be found. Please check and try again." error_code = "ERR_NOT_FOUND" } if (status_code == 403) { @@ -23,17 +42,32 @@ async function renderErrorPage(status_code, error_code_override) { if (error_code_override) { error_code = error_code_override } - var title = `${status_code} ${header}` - var html = await ejs.renderFile(path.join(process.cwd(), '/views/error_fullpage.ejs'), { + + if (error_code == "LOGOUT_SUCCESS") { + header = "Logged Out" + description = "You have been successfully logged out of Veriflow." + } + + var title = `${status_code} ${header} | ${page_title}` + var html = await ejs.renderFile(path.join(process.cwd(), '/views/error_fullpage.ejs'), + { title, header, status_code, description, - error_code + error_code, + footer_text, + user: req?.session?.userId, + logout_url: logout_url, + background_image_url: background_image_url, + additional_css: additional_css, + show_error_code: show_error_code, + logo_image_src: logo_image_src }); return html } + export default { renderErrorPage -} \ No newline at end of file +} diff --git a/util/redis.js b/util/redis.js index 345d414..d6d0aab 100644 --- a/util/redis.js +++ b/util/redis.js @@ -1,5 +1,7 @@ import { Redis } from "ioredis" import { getConfig } from '../util/config.js'; +import RedisStore from 'connect-redis'; +import log from './logging.js'; var currentConfig = getConfig() var redisConfig @@ -15,6 +17,11 @@ if (currentConfig.redis_connection_string) { const redis = new Redis(redisConfig) +let redisStore = new RedisStore({ + client: redis, + prefix: "vfsession:" +}) + function getClient() { return redis } @@ -23,7 +30,56 @@ function getRedisConfig() { return redisConfig } +function getRedisStore() { + return redisStore +} + +async function logUserOutAllSessions(userId) { + var sessions = await getAllSessionsForUser(userId) + for (const session of sessions) { + log.debug({ message: `Logged user ${userId} out of session ${session.sessionId}` }) + await redis.del(`vfsession:${session.sessionId}`); + } +} + +// FIXME This is a really inefficent way to handle these requests. Make this more efficent + +async function getAllSessionsForUser(userId) { + const allSessions = await getAllSessions(); + return allSessions.filter(session => session.userId === userId); +} + +async function getAllSessions() { + const keys = await scanKeys('vfsession:*'); + const sessions = []; + for (const key of keys) { + const sessionDataRaw = await redis.get(key); + if (sessionDataRaw) { + let sessionData = JSON.parse(sessionDataRaw); + // Extract session ID from key and add it to the session object + const sessionId = key.replace('vfsession:', ''); + sessionData.sessionId = sessionId; + sessions.push(sessionData); + } + } + return sessions; +} + +// 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 redis.scan(cursor, 'MATCH', pattern, 'COUNT', '100'); + cursor = reply[0]; + keys.push(...reply[1]); + } while (cursor !== '0'); + return keys; +} + export default { getClient, - getRedisConfig + getRedisConfig, + getRedisStore, + logUserOutAllSessions } \ No newline at end of file diff --git a/views/error_fullpage.ejs b/views/error_fullpage.ejs index 35ec827..cbf0f00 100644 --- a/views/error_fullpage.ejs +++ b/views/error_fullpage.ejs @@ -2,326 +2,51 @@ - - - - - <%= title %> - + <%- include('includes/head'); %> -
-

<%= header %> <%= status_code %>

-

<%= description %>

-

Error Code: <%= error_code %>

-
+
+
+ + <% if(user){ %> + + <% }%> +
+
+

<%= header %>

+
+

<%= description %>

+
+ +
+ \ No newline at end of file diff --git a/views/includes/head.ejs b/views/includes/head.ejs new file mode 100644 index 0000000..d153190 --- /dev/null +++ b/views/includes/head.ejs @@ -0,0 +1,7 @@ + + + + +<%= title %> +<%- include('normalize'); %> +<%- include('style'); %> \ No newline at end of file diff --git a/views/includes/logo.ejs b/views/includes/logo.ejs new file mode 100644 index 0000000..2b61611 --- /dev/null +++ b/views/includes/logo.ejs @@ -0,0 +1,6 @@ +<% if(logo_image_src){ %> + +<% } else { %> + + <% } %> + diff --git a/views/includes/normalize.ejs b/views/includes/normalize.ejs new file mode 100644 index 0000000..3317812 --- /dev/null +++ b/views/includes/normalize.ejs @@ -0,0 +1,243 @@ + \ No newline at end of file diff --git a/views/includes/style.ejs b/views/includes/style.ejs new file mode 100644 index 0000000..e224081 --- /dev/null +++ b/views/includes/style.ejs @@ -0,0 +1,114 @@ + + + \ No newline at end of file