From c2aef0f9a0608eee1269362e00d929d782faacb3 Mon Sep 17 00:00:00 2001 From: Tim van Oostrom Date: Thu, 30 Nov 2023 15:36:32 +0100 Subject: [PATCH] MIJN-7128 Add session blacklisting (#1055) --- package-lock.json | 23 +++ package.json | 1 + src/server/app.ts | 7 +- src/server/config.ts | 16 +- src/server/helpers/app.development.ts | 30 +--- src/server/helpers/app.test.ts | 166 ++++++++++++------ src/server/helpers/app.ts | 82 ++++++--- src/server/router-admin.ts | 33 ++++ src/server/router-development.ts | 10 +- src/server/router-oidc.ts | 5 +- src/server/router-protected.ts | 28 +-- src/server/router-public.ts | 33 +--- src/server/services/buurt/helpers.test.ts | 4 +- src/server/services/cron/jobs.ts | 10 ++ src/server/services/db/config.ts | 13 +- src/server/services/session-blacklist.test.ts | 70 ++++++++ src/server/services/session-blacklist.ts | 153 ++++++++++++++++ src/server/services/visitors.ts | 24 ++- src/setupTests.ts | 1 + src/universal/config/env.ts | 6 +- 20 files changed, 546 insertions(+), 169 deletions(-) create mode 100644 src/server/router-admin.ts create mode 100644 src/server/services/cron/jobs.ts create mode 100644 src/server/services/session-blacklist.test.ts create mode 100644 src/server/services/session-blacklist.ts diff --git a/package-lock.json b/package-lock.json index 7b2416446c..a5eab5760c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "cookie-parser": "^1.4.6", "cookie-session": "^2.0.0", "cors": "^2.8.5", + "cron": "^3.1.6", "cross-env": "^7.0.3", "date-fns": "^2.27.0", "dotenv": "^16.3.1", @@ -4457,6 +4458,11 @@ "@types/lodash": "*" } }, + "node_modules/@types/luxon": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.4.tgz", + "integrity": "sha512-H9OXxv4EzJwE75aTPKpiGXJq+y4LFxjpsdgKwSmr503P5DkWc3AG7VAFYrFNVvqemT5DfgZJV9itYhqBHSGujA==" + }, "node_modules/@types/memoizee": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.10.tgz", @@ -6394,6 +6400,15 @@ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, + "node_modules/cron": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz", + "integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -9634,6 +9649,14 @@ "es5-ext": "~0.10.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 28185cc604..60013c565b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "cookie-parser": "^1.4.6", "cookie-session": "^2.0.0", "cors": "^2.8.5", + "cron": "^3.1.6", "cross-env": "^7.0.3", "date-fns": "^2.27.0", "dotenv": "^16.3.1", diff --git a/src/server/app.ts b/src/server/app.ts index 6579ac5de8..e2b684a62e 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -43,6 +43,8 @@ import { authRouterDevelopment, relayDevRouter } from './router-development'; import { router as oidcRouter } from './router-oidc'; import { router as protectedRouter } from './router-protected'; import { router as publicRouter } from './router-public'; +import { adminRouter } from './router-admin'; +import { cleanupSessionBlacklistTable } from './services/cron/jobs'; const sentryOptions: Sentry.NodeOptions = { dsn: process.env.BFF_SENTRY_DSN, @@ -151,7 +153,7 @@ if (IS_OT && !IS_AP) { ///// Generic Router Method for All environments //////////////////////////////////////////////////////////////////////// // Mount the routers at the base path -app.use(BFF_BASE_PATH, nocache, protectedRouter); +app.use(BFF_BASE_PATH, nocache, protectedRouter, adminRouter); app.get(BffEndpoints.ROOT, (req, res) => { return res.redirect(`${BFF_BASE_PATH + BffEndpoints.ROOT}`); @@ -186,3 +188,6 @@ const server = app.listen(BFF_PORT, () => { // From https://shuheikagawa.com/blog/2019/04/25/keep-alive-timeout/ server.keepAliveTimeout = 60 * 1000; server.headersTimeout = 65 * 1000; // This should be bigger than `keepAliveTimeout + your server's expected response time` + +// Start Cron jobs +cleanupSessionBlacklistTable.start(); diff --git a/src/server/config.ts b/src/server/config.ts index 6c232c96ea..3ddc798a7b 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -83,9 +83,11 @@ export interface DataRequestConfig extends AxiosRequestConfig { maximumAmountOfPages?: number; } -const ONE_SECOND_MS = 1000; -const ONE_MINUTE_MS = 60 * ONE_SECOND_MS; -const ONE_HOUR_MS = 60 * ONE_MINUTE_MS; +export const ONE_SECOND_MS = 1000; +export const ONE_MINUTE_MS = 60 * ONE_SECOND_MS; +export const ONE_HOUR_MS = 60 * ONE_MINUTE_MS; + +export const OIDC_TOKEN_EXP = ONE_HOUR_MS * 24 * 3; // The TMA currently has a token expiration time of 3 hours export const DEFAULT_API_CACHE_TTL_MS = (IS_OT ? 65 : 45) * ONE_SECOND_MS; // This means that every request that depends on the response of another will use the cached version of the response for a maximum of 45 seconds. export const DEFAULT_CANCEL_TIMEOUT_MS = (IS_OT ? 60 : 20) * ONE_SECOND_MS; // This means a request will be aborted after 20 seconds without a response. @@ -229,7 +231,9 @@ export const ApiConfig: ApiDataRequestConfig = { httpsAgent: new https.Agent({ ca: IS_TAP ? getCert(process.env.BFF_SERVER_CLIENT_CERT) : [], }), - postponeFetch: !FeatureToggle.erfpachtV2EndpointActive, + postponeFetch: + !FeatureToggle.erfpachtV2EndpointActive || + !process.env.BFF_ERFPACHT_API_URL_ONT, headers: { 'X-HERA-REQUESTORIGIN': 'MijnAmsterdam', }, @@ -392,9 +396,10 @@ export const BffEndpoints = { CACHE_OVERVIEW: '/status/cache', LOGIN_STATS: '/status/logins/:authMethod?', LOGIN_RAW: '/status/logins/table', + SESSION_BLACKLIST_RAW: '/status/session-blacklist/table', STATUS_HEALTH: '/status/health', STATUS_HEALTH2: '/bff/status/health', - USER_DATA_OVERVIEW: '/status/user-data-overview', + TEST_ACCOUNTS_OVERVIEW: '/status/user-data-overview', LOODMETING_ATTACHMENTS: '/services/lood/:id/attachments', }; @@ -425,7 +430,6 @@ const oidcConfigBase: ConfigParams = { attemptSilentLogin: false, authorizationParams: { prompt: 'login', response_type: 'code' }, clockTolerance: 120, // 2 minutes - // @ts-ignore session: { rolling: true, rollingDuration: OIDC_SESSION_MAX_AGE_SECONDS, diff --git a/src/server/helpers/app.development.ts b/src/server/helpers/app.development.ts index 955f02345b..d3c4fa25f9 100644 --- a/src/server/helpers/app.development.ts +++ b/src/server/helpers/app.development.ts @@ -2,34 +2,17 @@ import * as jose from 'jose'; import { DEV_JWK_PRIVATE, DEV_JWK_PUBLIC, - OIDC_COOKIE_ENCRYPTION_KEY, OIDC_SESSION_MAX_AGE_SECONDS, OIDC_TOKEN_AUD_ATTRIBUTE_VALUE, TOKEN_ID_ATTRIBUTE, } from '../config'; -import type { AuthProfile } from './app'; -import { createSecretKey } from 'node:crypto'; - -const { encryption: deriveKey } = require('express-openid-connect/lib/crypto'); +import { encryptCookieValue, type AuthProfile } from './app'; /** * * Helpers for development */ -async function encryptDevSessionCookieValue(payload: string, headers: object) { - const alg = 'dir'; - const enc = 'A256GCM'; - const keySource = deriveKey(OIDC_COOKIE_ENCRYPTION_KEY); - const key = await createSecretKey(keySource); - - const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(payload)) - .setProtectedHeader({ alg, enc, ...headers }) - .encrypt(key); - - return jwe; -} - export async function getPrivateKeyForDevelopment() { return jose.importJWK(DEV_JWK_PRIVATE); } @@ -40,11 +23,13 @@ export function getPublicKeyForDevelopment(): Promise { export async function signDevelopmentToken( authMethod: AuthProfile['authMethod'], - userID: string + userID: string, + sessionID: string ) { const data = { [TOKEN_ID_ATTRIBUTE[authMethod]]: userID, aud: OIDC_TOKEN_AUD_ATTRIBUTE_VALUE[authMethod], + sid: sessionID, }; const alg = 'RS256'; try { @@ -61,15 +46,16 @@ export async function signDevelopmentToken( export async function generateDevSessionCookieValue( authMethod: AuthProfile['authMethod'], - userID: string + userID: string, + sessionID: string ) { const uat = (Date.now() / 1000) | 0; const iat = uat; const exp = iat + OIDC_SESSION_MAX_AGE_SECONDS; - const value = await encryptDevSessionCookieValue( + const value = await encryptCookieValue( JSON.stringify({ - id_token: await signDevelopmentToken(authMethod, userID), + id_token: await signDevelopmentToken(authMethod, userID, sessionID), }), { iat, diff --git a/src/server/helpers/app.test.ts b/src/server/helpers/app.test.ts index ae8b7ddecf..0d866c7548 100644 --- a/src/server/helpers/app.test.ts +++ b/src/server/helpers/app.test.ts @@ -25,8 +25,15 @@ import { verifyAuthenticated, verifyUserIdWithRemoteUserinfo, type TokenData, + encryptCookieValue, + decryptCookieValue, } from './app'; import { cache } from './source-api-request'; +import Mockdate from 'mockdate'; +import { + generateDevSessionCookieValue, + signDevelopmentToken, +} from './app.development'; const { oidcConfigDigid, @@ -49,32 +56,52 @@ vi.mock('../config', async (requireOriginal) => { describe('server/helpers/app', () => { const digidClientId = oidcConfigDigid.clientID; const eherkenningClientId = oidcConfigEherkenning.clientID; - const secret = config.OIDC_COOKIE_ENCRYPTION_KEY; - - const jweCookieString = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjIwMTMzLCJ1YXQiOjE2NTA2MjAxMzMsImV4cCI6MTY1MDYyMTAzM30..WJ7z2STbwGapmA7T.efCR3f_rH43BbxSzg7FhHE4zTpjOOA6TRG8KpKw7v_YsEJpmreToyymMPqpiavdHQWsYy13tdArS_B5C-rsTeXPwu53iHDj-RWJJKMt1ojipgB47tEW-T5VA1ZCE4mNRUxuYwHF8Q0S4vat4ZPT6M0Z_ktUznc7yaUtWyQOHsFSW39Ly9vF1cC4JydAfgDw8gosC-_DWSlWtLzSiTUSapH16VSznedPBISMxruukge2dLaCv-khKUKrtPUe3g8JSPO524iSphE47xFefzQNbrj-xQu9__uH31P_XKpxqoJ7O4PzQcgcq2EKxEqmvALRjh86pvSipSK5qVLv4wb1AHqnnd6O5fJkVT4n6W46W9g4B-4duYsFkM8OI6Z0YPUGhjx0DgurdVKLaBZM_gL782rEWDBjRAJD62Mn6MBxverk6Y8auFhontxUypKXh-2RmubkCgFJi473N3ozeeWGFAg550lNxIMY77YvGgKqPXXPUn9ye6l_8I1LpGEniyPnqZsJN8s0aeL2G6hcpChTgBErQ5liaf0XoyX3hEpi7cTNYwGxat1KuuVP5iQtdiWHxp6k-jhRxxLW96SYlpO56O5W3aMP5iJzPt-TVnwF2VnR-9AWzS_jtF3MSsvX35Pq_E-aRha7YHPeI9B4RmjDBx7GLAdYS5X7L33gR9hYZml30UJ0tpnJywvDT-UmBYrPzdns3U3ATiVrgPHgq3HR1n0HdALePCHzSd3sIriDZmKG2wWbwC51KzM5OG3vPmt19N75K.TJ6JzT9e18M_R9KgG9qzwg'; - let isOidcTokenVerificationEnabled = - config.OIDC_IS_TOKEN_EXP_VERIFICATION_ENABLED; + function getEncryptionHeaders() { + const uat = (Date.now() / 1000) | 0; + const iat = uat; + const exp = iat + config.OIDC_SESSION_MAX_AGE_SECONDS; + return { + uat, + iat, + exp, + }; + } beforeAll(() => { (config as any).OIDC_IS_TOKEN_EXP_VERIFICATION_ENABLED = false; oidcConfigEherkenning.clientID = 'test1'; oidcConfigDigid.clientID = 'test2'; - (config.OIDC_COOKIE_ENCRYPTION_KEY as any) = '123123123kjhkjhsdkjfhsd'; + + Mockdate.set('2023-11-23'); }); afterAll(() => { - (config as any).OIDC_IS_TOKEN_EXP_VERIFICATION_ENABLED = - isOidcTokenVerificationEnabled; oidcConfigEherkenning.clientID = digidClientId; oidcConfigDigid.clientID = eherkenningClientId; - (config.OIDC_COOKIE_ENCRYPTION_KEY as any) = secret; + + Mockdate.reset(); + }); + + test('enc-dec', async () => { + const payload = 'testje!!'; + + const cookieValueEncrypted = await encryptCookieValue( + payload, + getEncryptionHeaders() + ); + + const payload2 = await decryptCookieValue(cookieValueEncrypted); + + expect(payload).toBe(payload2); }); test('getAuth.eherkenning', async () => { - const cookieValue = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjIwMTMzLCJ1YXQiOjE2NTA2MjAxMzMsImV4cCI6MTY1MDYyMTAzM30..WJ7z2STbwGapmA7T.efCR3f_rH43BbxSzg7FhHE4zTpjOOA6TRG8KpKw7v_YsEJpmreToyymMPqpiavdHQWsYy13tdArS_B5C-rsTeXPwu53iHDj-RWJJKMt1ojipgB47tEW-T5VA1ZCE4mNRUxuYwHF8Q0S4vat4ZPT6M0Z_ktUznc7yaUtWyQOHsFSW39Ly9vF1cC4JydAfgDw8gosC-_DWSlWtLzSiTUSapH16VSznedPBISMxruukge2dLaCv-khKUKrtPUe3g8JSPO524iSphE47xFefzQNbrj-xQu9__uH31P_XKpxqoJ7O4PzQcgcq2EKxEqmvALRjh86pvSipSK5qVLv4wb1AHqnnd6O5fJkVT4n6W46W9g4B-4duYsFkM8OI6Z0YPUGhjx0DgurdVKLaBZM_gL782rEWDBjRAJD62Mn6MBxverk6Y8auFhontxUypKXh-2RmubkCgFJi473N3ozeeWGFAg550lNxIMY77YvGgKqPXXPUn9ye6l_8I1LpGEniyPnqZsJN8s0aeL2G6hcpChTgBErQ5liaf0XoyX3hEpi7cTNYwGxat1KuuVP5iQtdiWHxp6k-jhRxxLW96SYlpO56O5W3aMP5iJzPt-TVnwF2VnR-9AWzS_jtF3MSsvX35Pq_E-aRha7YHPeI9B4RmjDBx7GLAdYS5X7L33gR9hYZml30UJ0tpnJywvDT-UmBYrPzdns3U3ATiVrgPHgq3HR1n0HdALePCHzSd3sIriDZmKG2wWbwC51KzM5OG3vPmt19N75K.TJ6JzT9e18M_R9KgG9qzwg'; + const cookieValue = await generateDevSessionCookieValue( + 'eherkenning', + 'eh1', + 'eh1-session-id' + ); const req = { cookies: { @@ -88,24 +115,25 @@ describe('server/helpers/app', () => { { "profile": { "authMethod": "eherkenning", - "id": "123-eherkenning-321", + "id": "eh1", "profileType": "commercial", - "sid": undefined, + "sid": "eh1-session-id", }, - "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhZTjNwTkRVVXloby10UUIyNWFmcThES0NyeHQyVi1iUzZXOWdSazBjZ2sifQ.eyJ1cm46ZXRvZWdhbmc6MS45OkVudGl0eUNvbmNlcm5lZElEOkt2S25yIjoiMTIzLWVoZXJrZW5uaW5nLTMyMSIsImF1ZCI6InRlc3QxIiwiaWF0IjoxNjUwNjIwMTMzfQ.qF2JLBflk_ajk11jiyrZqcLklB618aSVjnazeDAyljdRJMN_vUUqVZBNLgLI0CBZ_jTYQwbl2OQsizGIdp9_yUadu1FhU4xGHYFBXvtLmdUk049bLccJoFIFYrvJq9yMAUhhRrBLjUUPJN3M8KijF7JKG74QYwyKyL-MzvsvKOqQNLJKUgQ4wUbsY2n9SjPcWGtB6rvkHrbfGGZZmdozIKXWmsQMYP41cEL9E0S15iF78Zko8jaWiV9oUHNqy3CfyZJz-K0dCbPAhs73q_7NqZQF1UoRgw8cQCVpfami521KpS7U6PK6oYlrigF1sHhsN_MuCwVHeOtu_BvBo_IFMQ", + "token": "eyJhbGciOiJSUzI1NiJ9.eyJ1cm46ZXRvZWdhbmc6Y29yZTpMZWdhbFN1YmplY3RJRCI6ImVoMSIsImF1ZCI6InRlc3QxIiwic2lkIjoiZWgxLXNlc3Npb24taWQiLCJpYXQiOjE3MDA2OTc2MDAsImV4cCI6MTcwMDcwNDgwMH0.g2TuVfherMgdHRxxHoRxWUSydRu4ETg-VOJW-jxLCdk8KejfPANkGNNbT7B_1BZVTwNVC7ThnaAKGzyBg9vL9xnaj2MStEX2HKwjkLWTSzX4voVrAwn7izm3KP7bh0cb68uTNVFZg7Zmy-PpYAJ86lyg0-bLyfImFMvtxkDrINXvGG02ukEZQcjcgL43MtJv3ksR3-lnFxAIMz8sn97gqLCxXEIBA4GkMeWXNkBwh2cjbEJba_b1wN_GybEo-ZoZ9Xh4C2oYJI9fcXc6re8kmZ5xegx1c2xYapy7JBqPfr6D_4vN_ZeR1Ut6PumR8ZzXAdCX5UIqy3WjO9YiKia8Jw", } `); const tokenData = await decodeOIDCToken(result.token); const attr = config.OIDC_TOKEN_ID_ATTRIBUTE.eherkenning(tokenData); - expect(tokenData[attr]).toBe('123-eherkenning-321'); + expect(tokenData[attr]).toBe('eh1'); }); test('getAuth.digid', async () => { - // const cookieValue = generateDevSessionCookieValue('digid', '000-digid-999'); - const cookieValue = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjE5ODQ1LCJ1YXQiOjE2NTA2MTk4NDUsImV4cCI6MTY1MDYyMDc0NX0..HUllPK31lXj_KBII.BqZ5b-8qiL8CYsV33qbiS2gBYJFvSOyshlKrWOQDNzXKAEf2Y3BtE-NOcJ8atUqIFSjmMaN-ZTUp7cXzpO3_i1RxBKUB99on7hVO3rinLk8gMbVGgNOE5PgTjzgQQ2gJTdJtssMR_uIKtytVOOF0tlQzaXh0bq-WydAyuHPV3xEDoyt3VrPR53qTjotM52u3jCDV39C4zKXNz9fS_eqHqiVMefxpgUtJNnKsIGiWRRYcIvAO3xFKBY_IA2Jv53gt0x7-sQ7lm5SRSe1WcsmwBBzAf3pYqqnwXtH1Y6RQZgtfWvTFypUFBCMoZ0i8j5JsTNRaCKuJxo3m5qbs8UfKL9oD7i41GkEI4GwFSQ6wnGqptlOwFNjIYt8IFHiTqJ6AIu3WAE_Z-WZ4MjEcLYZ_sTGHB_RfVx629U_fok9Uq0B7ZYFk_8btl3kPvQWDHbmhgxtXOddwHKBGlFEJJiNuPo7zYt3brKGJidZhhm8grwx_oy5Tpqtw_p1CBJyI-T6A-vo__iUuaxhhzLd_mcDa5Oq6kxYoTT-jkn1BK_N15rVE1FRsg3TU4fNZehKZ6CsdXjw7zxfhVUslidyesUP13T6WLAYwfDwM-4r4OAKtqj-ZOnYFffWFoDzJykZiieqeLvYnVJXw6INMqCFCUhBMFMu1uw8ly3onFwc8fqR6so2rhHt4P88ZWOc.m-3fNCjKs5A2seItvXarHQ'; - + const cookieValue = await generateDevSessionCookieValue( + 'digid', + 'digi1', + 'digi1-session-id' + ); const req = { cookies: { [OIDC_SESSION_COOKIE_NAME]: cookieValue, @@ -118,15 +146,15 @@ describe('server/helpers/app', () => { { "profile": { "authMethod": "digid", - "id": "000-digid-999", + "id": "digi1", "profileType": "private", - "sid": undefined, + "sid": "digi1-session-id", }, - "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjhZTjNwTkRVVXloby10UUIyNWFmcThES0NyeHQyVi1iUzZXOWdSazBjZ2sifQ.eyJzdWIiOiIwMDAtZGlnaWQtOTk5IiwiYXVkIjoidGVzdDIiLCJpYXQiOjE2NTA2MTk4NDV9.QvPW0CYDnHiX77VZVAUmXahrQeJW1D0IrR4GBTyayH83nv3xe-nHnUMsXIchuYozmDwnF36CBsd1mm-C16x0PK1QD6-Fu-2PAekMxKaWpRWcI6ICOgliEVyV6a2B_KI3ZHshjlXxLyh59VL_2NegKZBQWEvTsFazn0fzbPmoKM3SVj19IiLug8Us4n-jYvzD8kplGzvWVujl4-1VYeNvn0vSfBrcSdLtGPJI7fcJafPxJs6gY2mrpwyeQ3Pan7DEEhXOqucjs81x9cwRRf4_JbRkehLKCwxb4u1USSusqTEqGhGQm7JGJlD4nZIdScNG7Xyx9LQcGm0EfnrjXOTGcw", + "token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkaWdpMSIsImF1ZCI6InRlc3QyIiwic2lkIjoiZGlnaTEtc2Vzc2lvbi1pZCIsImlhdCI6MTcwMDY5NzYwMCwiZXhwIjoxNzAwNzA0ODAwfQ.TwnEE1aamO3P-_ElwF41jyHAe66caKtN8ysuIuYJ9oaHRuotA1BMp9dp_FDylAbAS8IW2_-WkTAbPBgdgGL_iex1qQqyrKqR8oz9wJ6TXbE84A4urkz8zCM3pdiR8mKoO8gaSA5zWg8iShTMMtfPuMQKqXjzsjbPGgRAmyfp_a-4bz1EiE6jr71xCg4du5ItaJOjP1LgwGnKU0x7xQKvIilWFMod0smXVunklHzo1hTgKyAKcc5Srbl2IW1GKcPPg6sLsjM_6QkNaIEfZF09AG9aGOVImyhJ4cfrDGx2tf6cw4DPEM7WVWr0Z8Pg5VAlqW2-vJs4cclYqIk3EJvDxw", } `); - expect((await decodeOIDCToken(result.token)).sub).toBe('000-digid-999'); + expect((await decodeOIDCToken(result.token)).sub).toBe('digi1'); }); test('getAuthProfile', () => { @@ -330,8 +358,11 @@ describe('server/helpers/app', () => { }); test('getProfileType', async () => { - const cookieValue = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjE5ODQ1LCJ1YXQiOjE2NTA2MTk4NDUsImV4cCI6MTY1MDYyMDc0NX0..HUllPK31lXj_KBII.BqZ5b-8qiL8CYsV33qbiS2gBYJFvSOyshlKrWOQDNzXKAEf2Y3BtE-NOcJ8atUqIFSjmMaN-ZTUp7cXzpO3_i1RxBKUB99on7hVO3rinLk8gMbVGgNOE5PgTjzgQQ2gJTdJtssMR_uIKtytVOOF0tlQzaXh0bq-WydAyuHPV3xEDoyt3VrPR53qTjotM52u3jCDV39C4zKXNz9fS_eqHqiVMefxpgUtJNnKsIGiWRRYcIvAO3xFKBY_IA2Jv53gt0x7-sQ7lm5SRSe1WcsmwBBzAf3pYqqnwXtH1Y6RQZgtfWvTFypUFBCMoZ0i8j5JsTNRaCKuJxo3m5qbs8UfKL9oD7i41GkEI4GwFSQ6wnGqptlOwFNjIYt8IFHiTqJ6AIu3WAE_Z-WZ4MjEcLYZ_sTGHB_RfVx629U_fok9Uq0B7ZYFk_8btl3kPvQWDHbmhgxtXOddwHKBGlFEJJiNuPo7zYt3brKGJidZhhm8grwx_oy5Tpqtw_p1CBJyI-T6A-vo__iUuaxhhzLd_mcDa5Oq6kxYoTT-jkn1BK_N15rVE1FRsg3TU4fNZehKZ6CsdXjw7zxfhVUslidyesUP13T6WLAYwfDwM-4r4OAKtqj-ZOnYFffWFoDzJykZiieqeLvYnVJXw6INMqCFCUhBMFMu1uw8ly3onFwc8fqR6so2rhHt4P88ZWOc.m-3fNCjKs5A2seItvXarHQ'; + const cookieValue = await generateDevSessionCookieValue( + 'digid', + 'digi1', + 'digi1-session-id' + ); const req = { cookies: { @@ -345,11 +376,13 @@ describe('server/helpers/app', () => { }); test('getOIDCToken.success', async () => { - const jweCookieString = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjIwMTMzLCJ1YXQiOjE2NTA2MjAxMzMsImV4cCI6MTY1MDYyMTAzM30..WJ7z2STbwGapmA7T.efCR3f_rH43BbxSzg7FhHE4zTpjOOA6TRG8KpKw7v_YsEJpmreToyymMPqpiavdHQWsYy13tdArS_B5C-rsTeXPwu53iHDj-RWJJKMt1ojipgB47tEW-T5VA1ZCE4mNRUxuYwHF8Q0S4vat4ZPT6M0Z_ktUznc7yaUtWyQOHsFSW39Ly9vF1cC4JydAfgDw8gosC-_DWSlWtLzSiTUSapH16VSznedPBISMxruukge2dLaCv-khKUKrtPUe3g8JSPO524iSphE47xFefzQNbrj-xQu9__uH31P_XKpxqoJ7O4PzQcgcq2EKxEqmvALRjh86pvSipSK5qVLv4wb1AHqnnd6O5fJkVT4n6W46W9g4B-4duYsFkM8OI6Z0YPUGhjx0DgurdVKLaBZM_gL782rEWDBjRAJD62Mn6MBxverk6Y8auFhontxUypKXh-2RmubkCgFJi473N3ozeeWGFAg550lNxIMY77YvGgKqPXXPUn9ye6l_8I1LpGEniyPnqZsJN8s0aeL2G6hcpChTgBErQ5liaf0XoyX3hEpi7cTNYwGxat1KuuVP5iQtdiWHxp6k-jhRxxLW96SYlpO56O5W3aMP5iJzPt-TVnwF2VnR-9AWzS_jtF3MSsvX35Pq_E-aRha7YHPeI9B4RmjDBx7GLAdYS5X7L33gR9hYZml30UJ0tpnJywvDT-UmBYrPzdns3U3ATiVrgPHgq3HR1n0HdALePCHzSd3sIriDZmKG2wWbwC51KzM5OG3vPmt19N75K.TJ6JzT9e18M_R9KgG9qzwg'; + const jweCookieString = await encryptCookieValue( + JSON.stringify({ id_token: 'foobar' }), + getEncryptionHeaders() + ); expect(await getOIDCToken(jweCookieString)).toMatchInlineSnapshot( - `"eyJhbGciOiJSUzI1NiIsImtpZCI6IjhZTjNwTkRVVXloby10UUIyNWFmcThES0NyeHQyVi1iUzZXOWdSazBjZ2sifQ.eyJ1cm46ZXRvZWdhbmc6MS45OkVudGl0eUNvbmNlcm5lZElEOkt2S25yIjoiMTIzLWVoZXJrZW5uaW5nLTMyMSIsImF1ZCI6InRlc3QxIiwiaWF0IjoxNjUwNjIwMTMzfQ.qF2JLBflk_ajk11jiyrZqcLklB618aSVjnazeDAyljdRJMN_vUUqVZBNLgLI0CBZ_jTYQwbl2OQsizGIdp9_yUadu1FhU4xGHYFBXvtLmdUk049bLccJoFIFYrvJq9yMAUhhRrBLjUUPJN3M8KijF7JKG74QYwyKyL-MzvsvKOqQNLJKUgQ4wUbsY2n9SjPcWGtB6rvkHrbfGGZZmdozIKXWmsQMYP41cEL9E0S15iF78Zko8jaWiV9oUHNqy3CfyZJz-K0dCbPAhs73q_7NqZQF1UoRgw8cQCVpfami521KpS7U6PK6oYlrigF1sHhsN_MuCwVHeOtu_BvBo_IFMQ"` + '"foobar"' ); }); @@ -367,16 +400,28 @@ describe('server/helpers/app', () => { }); test('decodeOIDCToken', async () => { - const jweCookieString = - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjUwNjIwMTMzLCJ1YXQiOjE2NTA2MjAxMzMsImV4cCI6MTY1MDYyMTAzM30..WJ7z2STbwGapmA7T.efCR3f_rH43BbxSzg7FhHE4zTpjOOA6TRG8KpKw7v_YsEJpmreToyymMPqpiavdHQWsYy13tdArS_B5C-rsTeXPwu53iHDj-RWJJKMt1ojipgB47tEW-T5VA1ZCE4mNRUxuYwHF8Q0S4vat4ZPT6M0Z_ktUznc7yaUtWyQOHsFSW39Ly9vF1cC4JydAfgDw8gosC-_DWSlWtLzSiTUSapH16VSznedPBISMxruukge2dLaCv-khKUKrtPUe3g8JSPO524iSphE47xFefzQNbrj-xQu9__uH31P_XKpxqoJ7O4PzQcgcq2EKxEqmvALRjh86pvSipSK5qVLv4wb1AHqnnd6O5fJkVT4n6W46W9g4B-4duYsFkM8OI6Z0YPUGhjx0DgurdVKLaBZM_gL782rEWDBjRAJD62Mn6MBxverk6Y8auFhontxUypKXh-2RmubkCgFJi473N3ozeeWGFAg550lNxIMY77YvGgKqPXXPUn9ye6l_8I1LpGEniyPnqZsJN8s0aeL2G6hcpChTgBErQ5liaf0XoyX3hEpi7cTNYwGxat1KuuVP5iQtdiWHxp6k-jhRxxLW96SYlpO56O5W3aMP5iJzPt-TVnwF2VnR-9AWzS_jtF3MSsvX35Pq_E-aRha7YHPeI9B4RmjDBx7GLAdYS5X7L33gR9hYZml30UJ0tpnJywvDT-UmBYrPzdns3U3ATiVrgPHgq3HR1n0HdALePCHzSd3sIriDZmKG2wWbwC51KzM5OG3vPmt19N75K.TJ6JzT9e18M_R9KgG9qzwg'; - expect(await decodeOIDCToken(await getOIDCToken(jweCookieString))) - .toMatchInlineSnapshot(` - { - "aud": "test1", - "iat": 1650620133, - "urn:etoegang:1.9:EntityConcernedID:KvKnr": "123-eherkenning-321", - } - `); + const jweCookieString = await encryptCookieValue( + JSON.stringify({ + id_token: await signDevelopmentToken( + 'eherkenning', + 'eh2', + 'eh2-session-id' + ), + }), + getEncryptionHeaders() + ); + + const token = await getOIDCToken(jweCookieString); + + expect(await decodeOIDCToken(token)).toMatchInlineSnapshot(` + { + "aud": "test1", + "exp": 1700704800, + "iat": 1700697600, + "sid": "eh2-session-id", + "urn:etoegang:core:LegalSubjectID": "eh2", + } + `); }); test('isRelayAllowed', () => { @@ -508,7 +553,7 @@ describe('server/helpers/app', () => { } as unknown as Response; expect( - await isAuthenticated()(req, res, vi.fn() as unknown as NextFunction) + await isAuthenticated(req, res, vi.fn() as unknown as NextFunction) ).toStrictEqual({ content: null, message: 'Unauthorized', @@ -519,6 +564,17 @@ describe('server/helpers/app', () => { }); test('isAuthenticated.true', async () => { + const jweCookieString = await encryptCookieValue( + JSON.stringify({ + id_token: await signDevelopmentToken( + 'eherkenning', + 'eh2', + 'eh2-session-id' + ), + }), + getEncryptionHeaders() + ); + const req = { cookies: { [OIDC_SESSION_COOKIE_NAME]: jweCookieString, @@ -534,16 +590,26 @@ describe('server/helpers/app', () => { const nextFn = vi.fn(); - await isAuthenticated()(req, res, nextFn); + await isAuthenticated(req, res, nextFn); expect(nextFn).toHaveBeenCalled(); }); test('verifyAuthenticated', async () => { + const jweCookieString = await encryptCookieValue( + JSON.stringify({ + id_token: await signDevelopmentToken( + 'digid', + '1234567890', + 'digi2-session-id' + ), + }), + getEncryptionHeaders() + ); + const req = { cookies: { - [OIDC_SESSION_COOKIE_NAME]: - 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNjg5MjM4MzY3LCJ1YXQiOjE2ODkyMzgzNjcsImV4cCI6MTY4OTIzOTI2N30..RQW1R1ZKYLncXIVW.yoK7RQtRDjLQzz3Xyy244R5cZC8DnVm7m8Z6CuNmbgXxoI7ZaMEUaHRegeLqMrmhbAQOw3J59HRvf5y_-G_rN577N1qnnCt0VruL2ey0LL5Mp3ElVuXHLkWCdhU0DeZuHBcHcCPEj3_5HZTAeTBYS1HBNijsXON5_q8WeBJP_lshd-7ZbENcAjsZPeKs9SXZYbNaJPMcD4YY0IcXjI4A1Ue_RzU7I5hkYHC1yUWuiHw7b4yFCnclFZ0WpsS7tPGLdQ_tjXHSjR2Pj57J8_r_M5Y_nOajfYcDmc-J4V0vng13gocm99lac_UvjlLjkHwNQ802IQRPUTZVZKXYtcynq7o4-l2wFFp0KO9K8flEnUxAbYIZzdogRS66sS3u6IbhTkGMdGa_ZD8lNSpNo9iKR0jKRZSV1CQ2YmZ6VLYhekm7cAWa-HTBDd_yLOVVLUTjzMmp8_1Nyxlc0If3ZNtykPNcQltsWcP3HC5EE__Q-mzsc0SgiK5FfjHs9cbFpBglP_v41TRdNGJ9XPWexPRvwU4Alm_5gQVx6IVrNxBT_t8HZHKVNw5ZFTutNtpyK8sSa-bhURBB1PwcUwlp2Lxe2G0Aho1q5VuFrOZNNA3Ok_KkQXBVHNV86dxOFlurh6AWsKMQ6-Apq5nHHXvmhn1jOSCpNGt2nCekq6D_nEIGCnwWMq2vJw.3emPjZyYwAvDkOR_Pyevog', + [OIDC_SESSION_COOKIE_NAME]: jweCookieString, }, oidc: { isAuthenticated: vi.fn().mockReturnValueOnce(true), @@ -569,7 +635,7 @@ describe('server/helpers/app', () => { }; const verify = verifyAuthenticated('digid', 'private'); - //// + // //// bffApi.get('/oidc/userinfo').times(1).reply(401, ''); expect(await verify(req, res)).toStrictEqual(responseUnauthorized); @@ -594,11 +660,11 @@ describe('server/helpers/app', () => { status: 'OK', }); - //// - const req2 = jsonCopy(req); - bffApi.get('/oidc/userinfo').times(1).reply(200, config.DEV_JWT); - req2.oidc.isAuthenticated = vi.fn().mockReturnValueOnce(true); - req2.cookies = {}; - expect(await verify(req2, res)).toStrictEqual(responseUnauthorized); + // //// + // const req2 = jsonCopy(req); + // bffApi.get('/oidc/userinfo').times(1).reply(200, config.DEV_JWT); + // req2.oidc.isAuthenticated = vi.fn().mockReturnValueOnce(true); + // req2.cookies = {}; + // expect(await verify(req2, res)).toStrictEqual(responseUnauthorized); }); }); diff --git a/src/server/helpers/app.ts b/src/server/helpers/app.ts index 226566ecac..a970ebd242 100644 --- a/src/server/helpers/app.ts +++ b/src/server/helpers/app.ts @@ -25,9 +25,9 @@ import { } from '../config'; import { getPublicKeyForDevelopment } from './app.development'; import { axiosRequest, clearSessionCache } from './source-api-request'; -import { createSecretKey } from 'node:crypto'; +import { createSecretKey, hkdfSync } from 'node:crypto'; -const { encryption: deriveKey } = require('express-openid-connect/lib/crypto'); +// const { encryption: deriveKey } = require('express-openid-connect/lib/crypto'); export interface AuthProfile { authMethod: 'eherkenning' | 'digid' | 'yivi'; @@ -166,28 +166,58 @@ export async function getProfileType(req: Request): Promise { return profileType || DEFAULT_PROFILE_TYPE; } -export async function getOIDCCookieData(jweCookieString: string): Promise<{ - id_token: string; -}> { - const key = await createSecretKey(deriveKey(OIDC_COOKIE_ENCRYPTION_KEY)); +export function createCookieEncriptionKey() { + const BYTE_LENGTH = 32; + const ENCRYPTION_INFO = 'JWE CEK'; + const DIGEST = 'sha256'; + + const k = Buffer.from( + hkdfSync( + DIGEST, + OIDC_COOKIE_ENCRYPTION_KEY, + Buffer.alloc(0), + ENCRYPTION_INFO, + BYTE_LENGTH + ) + ); + return createSecretKey(k); +} - const encryptOpts = { - alg: 'dir', - enc: 'A256GCM', - }; +const encryptOpts = { + alg: 'dir', + enc: 'A256GCM', +}; +export const encryptionKey = createCookieEncriptionKey(); + +export async function encryptCookieValue(payload: string, headers: object) { + const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(payload)) + .setProtectedHeader({ ...encryptOpts, ...headers }) + .encrypt(encryptionKey); + + return jwe; +} + +export async function decryptCookieValue(cookieValueEncrypted: string) { const options: jose.DecryptOptions = { contentEncryptionAlgorithms: [encryptOpts.enc], keyManagementAlgorithms: [encryptOpts.alg], }; const { plaintext, protectedHeader } = await jose.compactDecrypt( - jweCookieString, - key, + cookieValueEncrypted, + encryptionKey, options ); - return JSON.parse(new TextDecoder().decode(plaintext)); + return plaintext.toString(); +} + +export async function getOIDCCookieData(cookieValueEncrypted: string): Promise<{ + id_token: string; +}> { + const decryptedCookieValue = await decryptCookieValue(cookieValueEncrypted); + return JSON.parse(decryptedCookieValue); } export async function getOIDCToken(jweCookieString: string): Promise { @@ -303,6 +333,7 @@ export async function isRequestAuthenticated( ); } } catch (error) { + console.error(error); Sentry.captureException(error); } return false; @@ -340,15 +371,18 @@ export function nocache(_req: Request, res: Response, next: NextFunction) { next(); } -export const isAuthenticated = - () => async (req: Request, res: Response, next: NextFunction) => { - if (hasSessionCookie(req)) { - try { - await getAuth(req); - return next(); - } catch (error) { - Sentry.captureException(error); - } +export async function isAuthenticated( + req: Request, + res: Response, + next: NextFunction +) { + if (hasSessionCookie(req)) { + try { + await getAuth(req); + return next(); + } catch (error) { + Sentry.captureException(error); } - return sendUnauthorized(res); - }; + } + return sendUnauthorized(res); +} diff --git a/src/server/router-admin.ts b/src/server/router-admin.ts new file mode 100644 index 0000000000..8a3a314182 --- /dev/null +++ b/src/server/router-admin.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import basicAuth from 'express-basic-auth'; +import { BffEndpoints } from './config'; +import { generateOverview } from './generate-user-data-overview'; +import { loginStats, loginStatsTable } from './services/visitors'; +import { sessionBlacklistTable } from './services/session-blacklist'; + +export const adminRouter = express.Router(); + +if (process.env.BFF_LOGIN_COUNT_ADMIN_PW) { + const auth = basicAuth({ + users: { + admin: process.env.BFF_LOGIN_COUNT_ADMIN_PW, + }, + challenge: true, + }); + + adminRouter.use(auth); + + adminRouter.get(BffEndpoints.LOGIN_RAW, loginStatsTable); + adminRouter.get(BffEndpoints.LOGIN_STATS, loginStats); + adminRouter.get(BffEndpoints.SESSION_BLACKLIST_RAW, sessionBlacklistTable); + + // Currently this endpoint can only be used when running the application locally. + // Requesting the endpoint on Azure results in a Gateway timeout which cannot be prevented easily at this time. + adminRouter.get(BffEndpoints.TEST_ACCOUNTS_OVERVIEW, async (req, res) => { + generateOverview(req.query.fromCache == '1', `${__dirname}/cache`).then( + (fileName) => { + res.download(fileName); + } + ); + }); +} diff --git a/src/server/router-development.ts b/src/server/router-development.ts index 44cde2fc67..ee3fd3459b 100644 --- a/src/server/router-development.ts +++ b/src/server/router-development.ts @@ -22,6 +22,8 @@ import STADSPAS_TRANSACTIES from './mock-data/json/stadspas-transacties.json'; import VERGUNNINGEN_LIST_DOCUMENTS from './mock-data/json/vergunningen-documenten.json'; import { countLoggedInVisit } from './services/visitors'; import { generateDevSessionCookieValue } from './helpers/app.development'; +import { addToBlackList } from './services/session-blacklist'; +import UID from 'uid-safe'; const DevelopmentRoutes = { DEV_LOGIN: '/api/v1/auth/:authMethod/login/:user?', @@ -51,9 +53,11 @@ authRouterDevelopment.get( ? req.params.user : Object.keys(testAccounts)[0]; const userId = testAccounts[userName]; + const sessionID = UID.sync(18); const appSessionCookieValue = await generateDevSessionCookieValue( authMethod, - userId + userId, + sessionID ); countLoggedInVisit(userId, authMethod); @@ -89,7 +93,9 @@ authRouterDevelopment.get( authRouterDevelopment.get(DevelopmentRoutes.DEV_LOGOUT, async (req, res) => { const auth = await getAuth(req); - + if (auth.profile.sid) { + await addToBlackList(auth.profile.sid); + } res.clearCookie(OIDC_SESSION_COOKIE_NAME); let redirectUrl = `${process.env.MA_FRONTEND_URL}`; diff --git a/src/server/router-oidc.ts b/src/server/router-oidc.ts index 5cff732abd..7748e84b5f 100644 --- a/src/server/router-oidc.ts +++ b/src/server/router-oidc.ts @@ -21,6 +21,7 @@ import { verifyAuthenticated, } from './helpers/app'; import { countLoggedInVisit } from './services/visitors'; +import { addToBlackList } from './services/session-blacklist'; export const router = express.Router(); @@ -301,7 +302,9 @@ function logout(postLogoutRedirectUrl: string) { } const auth = await getAuth(req); - + if (auth.profile.sid) { + await addToBlackList(auth.profile.sid); + } res.oidc.logout({ returnTo: postLogoutRedirectUrl, logoutParams: { diff --git a/src/server/router-protected.ts b/src/server/router-protected.ts index 76f8c418b8..30e071f29d 100644 --- a/src/server/router-protected.ts +++ b/src/server/router-protected.ts @@ -1,31 +1,33 @@ import * as Sentry from '@sentry/react'; import express, { NextFunction, Request, Response } from 'express'; import proxy from 'express-http-proxy'; -import { jsonCopy, pick } from '../universal/helpers/utils'; +import { IS_AP } from '../universal/config/env'; +import { pick } from '../universal/helpers/utils'; import { BffEndpoints } from './config'; import { getAuth, isAuthenticated, isProtectedRoute } from './helpers/app'; import { fetchBezwaarDocument } from './services/bezwaren/bezwaren'; import { fetchLoodMetingDocument } from './services/bodem/loodmetingen'; -import { - loadServicesAll, - loadServicesSSE, -} from './services/controller'; +import { loadServicesAll, loadServicesSSE } from './services/controller'; +import { isBlacklistedHandler } from './services/session-blacklist'; import { fetchSignalAttachments, fetchSignalHistory, fetchSignalsListByStatus, } from './services/sia'; -import { IS_AP } from '../universal/config/env'; export const router = express.Router(); -router.use((req: Request, res: Response, next: NextFunction) => { - // Skip router if we've entered a public route. - if (!isProtectedRoute(req.path)) { - return next('router'); - } - return next(); -}, isAuthenticated()); +router.use( + (req: Request, res: Response, next: NextFunction) => { + // Skip router if we've entered a public route. + if (!isProtectedRoute(req.path)) { + return next('router'); + } + return next(); + }, + isAuthenticated, + isBlacklistedHandler +); router.get( BffEndpoints.SERVICES_ALL, diff --git a/src/server/router-public.ts b/src/server/router-public.ts index 9721b409f8..0f31b23273 100644 --- a/src/server/router-public.ts +++ b/src/server/router-public.ts @@ -1,12 +1,6 @@ import express, { NextFunction, Request, Response } from 'express'; -import basicAuth from 'express-basic-auth'; -import { - DATASETS, - IS_OT, - OTAP_ENV, - getDatasetCategoryId, -} from '../universal/config'; -import { ApiResponse, apiSuccessResult, jsonCopy } from '../universal/helpers'; +import { DATASETS, OTAP_ENV, getDatasetCategoryId } from '../universal/config'; +import { ApiResponse, apiSuccessResult } from '../universal/helpers'; import { BffEndpoints, RELEASE_VERSION } from './config'; import { queryParams } from './helpers/app'; import { cacheOverview } from './helpers/file-cache'; @@ -20,8 +14,6 @@ import { } from './services'; import { getDatasetEndpointConfig } from './services/buurt/helpers'; import { fetchMaintenanceNotificationsActual } from './services/cms-maintenance-notifications'; -import { loginStats, rawDataTable } from './services/visitors'; -import { generateOverview } from './generate-user-data-overview'; export const router = express.Router(); @@ -143,27 +135,6 @@ router.get( } ); -if (process.env.BFF_LOGIN_COUNT_ADMIN_PW) { - const auth = basicAuth({ - users: { - admin: process.env.BFF_LOGIN_COUNT_ADMIN_PW, - }, - challenge: true, - }); - router.get(BffEndpoints.LOGIN_RAW, auth, rawDataTable); - router.get(BffEndpoints.LOGIN_STATS, auth, loginStats); - - // Currently this endpoint can only be used when running the application locally. - // Requesting the endpoint on Azure results in a Gateway timeout which cannot be prevented easily at this time. - router.get(BffEndpoints.USER_DATA_OVERVIEW, auth, async (req, res) => { - generateOverview(req.query.fromCache == '1', `${__dirname}/cache`).then( - (fileName) => { - res.download(fileName); - } - ); - }); -} - router.get( [BffEndpoints.ROOT, BffEndpoints.STATUS_HEALTH], (req: Request, res: Response, next: NextFunction) => { diff --git a/src/server/services/buurt/helpers.test.ts b/src/server/services/buurt/helpers.test.ts index c9319a32e3..1b721befc3 100644 --- a/src/server/services/buurt/helpers.test.ts +++ b/src/server/services/buurt/helpers.test.ts @@ -1,11 +1,11 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ApiResponse } from '../../../universal/helpers'; import { DatasetConfig, - datasetEndpoints, DatasetResponse, MaPointFeature, MaPolylineFeature, + datasetEndpoints, } from './datasets'; import { createDynamicFilterConfig, diff --git a/src/server/services/cron/jobs.ts b/src/server/services/cron/jobs.ts new file mode 100644 index 0000000000..ec010fb021 --- /dev/null +++ b/src/server/services/cron/jobs.ts @@ -0,0 +1,10 @@ +import { CronJob } from 'cron'; +import { cleanupSessionIds } from '../session-blacklist'; + +// Runs at midnight. See: https://github.com/kelektiv/node-cron/blob/main/examples/at_midnight.js +export const cleanupSessionBlacklistTable = new CronJob( + '00 00 00 * * *', + function () { + cleanupSessionIds(); + } +); diff --git a/src/server/services/db/config.ts b/src/server/services/db/config.ts index a5677c0d0c..dc9dac3b38 100644 --- a/src/server/services/db/config.ts +++ b/src/server/services/db/config.ts @@ -1,8 +1,17 @@ -import { IS_AP, IS_OT, IS_PRODUCTION } from '../../../universal/config'; +import { + APP_MODE, + IS_AP, + IS_OT, + IS_PRODUCTION, +} from '../../../universal/config'; export const IS_PG = IS_AP; -export const IS_VERBOSE = IS_OT; +export const IS_VERBOSE = IS_OT && APP_MODE !== 'unittest'; export const tableNameLoginCount = process.env.BFF_LOGIN_COUNT_TABLE ?? (IS_PRODUCTION ? 'prod_login_count' : 'acc_login_count'); + +export const tableNameSessionBlacklist = + process.env.BFF_LOGIN_SESSION_BLACKLIST_TABLE ?? + (IS_PRODUCTION ? 'prod_session_blacklist' : 'acc_session_blacklist'); diff --git a/src/server/services/session-blacklist.test.ts b/src/server/services/session-blacklist.test.ts new file mode 100644 index 0000000000..df1fd99697 --- /dev/null +++ b/src/server/services/session-blacklist.test.ts @@ -0,0 +1,70 @@ +import express, { Response, Request } from 'express'; +import { + OIDC_SESSION_COOKIE_NAME, + OIDC_SESSION_MAX_AGE_SECONDS, +} from '../config'; +import { generateDevSessionCookieValue } from '../helpers/app.development'; +import { + addToBlackList, + isBlacklisted, + cleanupSessionIds, + isBlacklistedHandler, +} from './session-blacklist'; +import { CompactEncrypt, DecryptOptions, compactDecrypt } from 'jose'; +import { + createCookieEncriptionKey, + decryptCookieValue, + encryptCookieValue, + encryptionKey, +} from '../helpers/app'; + +// Uses SQLITE :memory: database +describe('Session-blacklist', () => { + const sessionID = 'test123'; + + test('Is NOT blacklisted', async () => { + const rs = await isBlacklisted(sessionID); + + expect(rs).toBe(false); + }); + + test('Add to black list', async () => { + await addToBlackList(sessionID); + }); + + test('Is blacklisted', async () => { + const rs = await isBlacklisted(sessionID); + expect(rs).toBe(true); + }); + + test('Clean up session ids', async () => { + await cleanupSessionIds(-1); + const rs = await isBlacklisted(sessionID); + expect(rs).toBe(false); + }); + + test('isBlacklistedHandler', async () => { + const cookieValue = await generateDevSessionCookieValue( + 'digid', + '000-digid-999', + sessionID + ); + const req = { + cookies: { + [OIDC_SESSION_COOKIE_NAME]: cookieValue, + }, + } as unknown as Request; + + const next = vi.fn(); + const res = { send: vi.fn(), status: vi.fn() } as unknown as Response; + const rs = await isBlacklistedHandler(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + + await addToBlackList(sessionID); + const rs2 = await isBlacklistedHandler(req, res, next); + + expect(res.send).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/src/server/services/session-blacklist.ts b/src/server/services/session-blacklist.ts new file mode 100644 index 0000000000..a7b8213dc4 --- /dev/null +++ b/src/server/services/session-blacklist.ts @@ -0,0 +1,153 @@ +import { sub } from 'date-fns'; +import { NextFunction, Request, Response } from 'express'; +import { OIDC_TOKEN_EXP, ONE_MINUTE_MS } from '../config'; +import { getAuth, sendUnauthorized } from '../helpers/app'; +import { IS_PG, tableNameSessionBlacklist } from './db/config'; +import { db } from './db/db'; +import { execDB } from './db/sqlite3'; + +const MIN_HOURS_TO_KEEP_SESSIONS_BLACKLISTED = OIDC_TOKEN_EXP + ONE_MINUTE_MS; + +export const queriesPG = (tableNameSessionBlacklist: string) => ({ + addToBlackList: `INSERT INTO ${tableNameSessionBlacklist} (session_id) VALUES ($1) RETURNING id`, + isBlacklisted: `SELECT EXISTS(SELECT 1 FROM ${tableNameSessionBlacklist} WHERE session_id = $1) AS count`, + cleanupSessionIds: `DELETE FROM ${tableNameSessionBlacklist} WHERE date_created <= $1`, + rawOverview: `SELECT * FROM ${tableNameSessionBlacklist} ORDER BY date_created ASC`, +}); + +export const queriesSQLITE = (tableNameSessionBlacklist: string) => ({ + addToBlackList: `INSERT INTO ${tableNameSessionBlacklist} (session_id) VALUES (?)`, + isBlacklisted: `SELECT EXISTS(SELECT 1 FROM ${tableNameSessionBlacklist} WHERE session_id = ?) AS count`, + cleanupSessionIds: `DELETE FROM ${tableNameSessionBlacklist} WHERE date_created <= ?`, + rawOverview: `SELECT * FROM ${tableNameSessionBlacklist} ORDER BY date_created ASC`, +}); + +function getQueries() { + return (IS_PG ? queriesPG : queriesSQLITE)(tableNameSessionBlacklist); +} + +const queries = getQueries(); + +async function setupTables() { + const { query } = await db(); + + if (IS_PG) { + const createTableQuery = ` + -- Sequence and defined type + CREATE SEQUENCE IF NOT EXISTS ${tableNameSessionBlacklist}_id_seq; + + -- Table Definition + CREATE TABLE IF NOT EXISTS "public"."${tableNameSessionBlacklist}" ( + "id" int4 NOT NULL DEFAULT nextval('${tableNameSessionBlacklist}_id_seq'::regclass), + "session_id" varchar(256) NOT NULL, + "date_created" timestamp NOT NULL DEFAULT now(), + PRIMARY KEY ("id") + ); + `; + + await query(createTableQuery); + } else { + // Create the table + try { + execDB(` + CREATE TABLE IF NOT EXISTS "${tableNameSessionBlacklist}" ( + "id" INTEGER PRIMARY KEY, + "session_id" VARCHAR(256) NOT NULL, + "date_created" DATETIME NOT NULL DEFAULT (datetime(CURRENT_TIMESTAMP, 'localtime')) + ); + `); + } catch (err) { + console.error(err); + } + } +} + +setupTables(); + +export async function cleanupSessionIds( + minHoursToKeepSessionsBlacklisted: number = MIN_HOURS_TO_KEEP_SESSIONS_BLACKLISTED +) { + const { query } = await db(); + // Deletes all session ids added to the blacklist more than $MIN_HOURS_TO_KEEP_SESSIONS_BLACKLISTED hours ago. + const dateCreatedBefore = sub(new Date(), { + hours: minHoursToKeepSessionsBlacklisted, + }).toISOString(); + + return query(queries.cleanupSessionIds, [dateCreatedBefore]); +} + +export async function addToBlackList(sessionID: string) { + const { query } = await db(); + return query(queries.addToBlackList, [sessionID]); +} + +export async function isBlacklisted(sessionID: string) { + const { queryGET } = await db(); + const result = (await queryGET(queries.isBlacklisted, [sessionID])) as { + count: number; + }; + + return result ? !!result.count : false; +} + +export async function isBlacklistedHandler( + req: Request, + res: Response, + next: NextFunction +) { + const auth = await getAuth(req); + if (auth.profile.sid) { + const isOnList = await isBlacklisted(auth.profile.sid); + if (isOnList) { + return sendUnauthorized(res); + } + } + return next(); +} + +export async function sessionBlacklistTable(req: Request, res: Response) { + const { queryALL } = await db(); + + function generateHtmlTable(rows: any[]) { + if (rows.length === 0) { + return '

No data found.

'; + } + + const tableHeader = Object.keys(rows[0]) + .map((columnName) => `${columnName}`) + .join(''); + + const tableRows = rows + .map( + (row) => + `${Object.values(row) + .map((value) => `${value}`) + .join('')}` + ) + .join(''); + + const htmlTable = ` + + + ${tableHeader} + + + ${tableRows} + +
+ `; + + return htmlTable; + } + + // SQLite3 query to select all data from the specified table + const query = queries.rawOverview; + + // Execute the query and retrieve the results + const rows = (await queryALL(query)) as any[]; + + // Generate and display the HTML table + const htmlTable = generateHtmlTable(rows); + + return res.send(htmlTable); +} diff --git a/src/server/services/visitors.ts b/src/server/services/visitors.ts index a777fea2ba..e0688d222d 100644 --- a/src/server/services/visitors.ts +++ b/src/server/services/visitors.ts @@ -50,9 +50,8 @@ const queriesPG = (tableNameLoginCount: string) => ({ async function setupTables() { const { query } = await db(); - if (IS_PRODUCTION) { - if (IS_PG) { - const createTableQuery = ` + if (IS_PG) { + const createTableQuery = ` -- Sequence and defined type CREATE SEQUENCE IF NOT EXISTS ${tableNameLoginCount}_id_seq; @@ -66,16 +65,16 @@ async function setupTables() { ); `; - const alterTableQuery1 = ` + const alterTableQuery1 = ` ALTER TABLE IF EXISTS "public"."${tableNameLoginCount}" ADD IF NOT EXISTS "authMethod" VARCHAR(100); `; - await query(createTableQuery); - await query(alterTableQuery1); - } else { - // Create the table - execDB(` + await query(createTableQuery); + await query(alterTableQuery1); + } else { + // Create the table + execDB(` CREATE TABLE IF NOT EXISTS ${tableNameLoginCount} ( "id" INTEGER PRIMARY KEY, "uid" VARCHAR(100) NOT NULL, @@ -83,7 +82,6 @@ async function setupTables() { "auth_method" VARCHAR(100) DEFAULT NULL ); `); - } } } @@ -121,7 +119,7 @@ export async function loginStats(req: Request, res: Response) { } const queries = await getQueries(); - const { queryGET, queryALL } = await db(); + const { queryGET } = await db(); let authMethodSelected = ''; @@ -289,8 +287,8 @@ export async function loginStats(req: Request, res: Response) { }); } -export async function rawDataTable(req: Request, res: Response) { - const { queryGET, queryALL } = await db(); +export async function loginStatsTable(req: Request, res: Response) { + const { queryALL } = await db(); const queries = await getQueries(); function generateHtmlTable(rows: any[]) { diff --git a/src/setupTests.ts b/src/setupTests.ts index 0eaac6c880..ebafa6a442 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -39,6 +39,7 @@ export const bffApiHost = 'http://bff-api-host'; export const frontentHost = 'http://frontend-host'; export const remoteApiHost = 'http://remote-api-host'; +process.env.BFF_DB_FILE = ':memory:'; process.env.REACT_APP_BFF_API_URL = bffApiHost; process.env.BFF_DISABLE_MOCK_ADAPTER = 'true'; diff --git a/src/universal/config/env.ts b/src/universal/config/env.ts index 652421cd67..11018221bc 100644 --- a/src/universal/config/env.ts +++ b/src/universal/config/env.ts @@ -15,9 +15,11 @@ export function getOtapEnv(): OtapEnvName { export const OTAP_ENV = getOtapEnv(); -getAppMode() !== 'unittest' && +export const APP_MODE = getAppMode(); + +APP_MODE !== 'unittest' && console.info( - `App running in ${getAppMode()} mode on the ${OTAP_ENV} environment.` + `App running in ${APP_MODE} mode on the ${OTAP_ENV} environment.` ); export const IS_ACCEPTANCE = OTAP_ENV === 'acceptance';