Skip to content

Commit

Permalink
Added new UI for error pages, and added ability to log user out of al…
Browse files Browse the repository at this point in the history
…l sessions (#21)
  • Loading branch information
rorylshanks authored Mar 16, 2024
1 parent a426910 commit 75cf9cf
Show file tree
Hide file tree
Showing 11 changed files with 553 additions and 356 deletions.
6 changes: 6 additions & 0 deletions example-config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 12 additions & 13 deletions lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [])

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
34 changes: 17 additions & 17 deletions lib/sso.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -65,15 +65,15 @@ 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
}
// If user is AuthN and AuthZ, check dynamic backend config
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
}
Expand All @@ -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 = ""
Expand All @@ -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)
}
}
Expand All @@ -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
}
Expand All @@ -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 = {}
Expand All @@ -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)
}
}
Expand All @@ -180,21 +180,21 @@ 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
}
// FIXME: Verify that redirect URL is allowed in config
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 = {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
11 changes: 9 additions & 2 deletions util/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
48 changes: 41 additions & 7 deletions util/errorpage.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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
}
}
58 changes: 57 additions & 1 deletion util/redis.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
Expand All @@ -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
}
Loading

0 comments on commit 75cf9cf

Please sign in to comment.