Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added new UI for error pages #21

Merged
merged 1 commit into from
Mar 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading