From 7ce92335bb25486e2f190b292dce6d9d79b496cc Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Fri, 30 Aug 2024 17:23:53 +0100 Subject: [PATCH] move auth to backend and use jwt for frontend --- .env-example | 8 +- README.md | 13 +- package-lock.json | 255 +++++++++++++++--------- package.json | 7 +- src/app.ts | 12 +- src/interfaces/jwt-payload-with-user.ts | 7 + src/interfaces/user.interface.ts | 6 + src/middleware/ensure-authenticated.ts | 26 ++- src/middleware/session.ts | 2 +- src/resources/locales/en-GB.json | 13 +- src/routes/auth.ts | 88 ++++---- src/routes/healthcheck.ts | 2 +- src/routes/publish.ts | 2 +- src/routes/view.ts | 2 +- src/{controllers => services}/api.ts | 41 ++-- src/utils/check-config.ts | 11 + src/utils/logger.ts | 4 +- src/views/auth/login.ejs | 31 +++ src/views/login.ejs | 14 -- test/.jest/setEnvVars.ts | 4 +- 20 files changed, 323 insertions(+), 225 deletions(-) create mode 100644 src/interfaces/jwt-payload-with-user.ts create mode 100644 src/interfaces/user.interface.ts rename src/{controllers => services}/api.ts (77%) create mode 100644 src/utils/check-config.ts create mode 100644 src/views/auth/login.ejs delete mode 100644 src/views/login.ejs diff --git a/.env-example b/.env-example index 6ace7ec..e14912a 100644 --- a/.env-example +++ b/.env-example @@ -1,7 +1,5 @@ -BACKEND_SERVER=localhost -BACKEND_PROTOCOL=http -BACKEND_PORT=3001 +FRONTEND_URL=http://localhost:3000 +BACKEND_URL=http://localhost:3001 -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= SESSION_SECRET= +JWT_SECRET= diff --git a/README.md b/README.md index 4f3de26..ace00fe 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,9 @@ - Node 20+ - An Instance of the StatsWales backend Service -## To get going +## Configuration -You'll need to define the following environment variables either in to the environment or in to a `.env` file: - -```env -BACKEND_SERVER -BACKEND_PROTOCOL -BACKEND_PORT -SESSION_SECRET -GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET -``` +Copy the [.env-example](.env-example) file to `.env` and provide the missing values. To run the app should be as simple as: diff --git a/package-lock.json b/package-lock.json index fdf64f8..8a820ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/express-session": "^1.18.0", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", "express": "^4.19.2", @@ -22,11 +23,10 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", "pino": "^8.21.0", "pino-http": "^10.2.0", "ts-node-dev": "^2.0.0", @@ -35,13 +35,14 @@ "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", "@types/better-sqlite3": "^7.6.9", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", - "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", @@ -2270,6 +2271,16 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2385,6 +2396,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -2435,15 +2456,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/oauth": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.5.tgz", - "integrity": "sha512-+oQ3C2Zx6ambINOcdIARF5Z3Tu3x//HipE889/fqo3sgpQZbe9c6ExdQFtN6qlhpR7p83lTZfPJt0tCAW29dog==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/passport": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", @@ -2452,17 +2464,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-google-oauth20": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.16.tgz", - "integrity": "sha512-ayXK2CJ7uVieqhYOc6k/pIr5pcQxOLB6kBev+QUGS7oEZeTgIs1odDobXRqgfBPvXzl0wXCQHftV5220czZCPA==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-local": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", @@ -2473,17 +2474,6 @@ "@types/passport-strategy": "*" } }, - "node_modules/@types/passport-oauth2": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz", - "integrity": "sha512-ODiAHvso6JcWJ6ZkHHroVp05EHGhqQN533PtFNBkg8Fy5mERDqsr030AX81M0D69ZcaMvhF92SRckEk2B0HYYg==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" - } - }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -3444,14 +3434,6 @@ } ] }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3594,6 +3576,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3952,6 +3940,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "license": "MIT", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -4338,6 +4348,15 @@ "xtend": "^4.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8936,6 +8955,40 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8951,6 +9004,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9073,6 +9147,42 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9085,6 +9195,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9312,8 +9428,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msw": { "version": "2.3.1", @@ -9778,11 +9893,6 @@ "node": ">=8" } }, - "node_modules/oauth": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", - "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10090,6 +10200,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10103,47 +10214,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-oauth2": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", - "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", - "dependencies": { - "base64url": "3.x.x", - "oauth": "0.10.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -12245,11 +12315,6 @@ "node": ">= 0.8" } }, - "node_modules/uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index b427344..51b95aa 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,14 @@ "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", "@types/better-sqlite3": "^7.6.9", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", - "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", @@ -62,6 +63,7 @@ "@types/express-session": "^1.18.0", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", "express": "^4.19.2", @@ -72,11 +74,10 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", "pino": "^8.21.0", "pino-http": "^10.2.0", "ts-node-dev": "^2.0.0", diff --git a/src/app.ts b/src/app.ts index 17edf1f..a6f86bf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,20 @@ import path from 'node:path'; import express, { Application, Request, Response } from 'express'; +import cookieParser from 'cookie-parser'; -import passport, { auth } from './routes/auth'; +import { checkConfig } from './utils/check-config'; +import { httpLogger } from './utils/logger'; import session from './middleware/session'; import { ensureAuthenticated } from './middleware/ensure-authenticated'; import { rateLimiter } from './middleware/rate-limiter'; import { i18next, i18nextMiddleware } from './middleware/translation'; +import { auth } from './routes/auth'; import { healthcheck } from './routes/healthcheck'; import { publish } from './routes/publish'; import { view } from './routes/view'; -import { httpLogger } from './utils/logger'; + +checkConfig(); const app: Application = express(); @@ -22,11 +26,11 @@ if (app.get('env') === 'production') { // enable middleware app.use(express.urlencoded({ extended: true })); +app.use(express.json()); app.use(httpLogger); app.use(i18nextMiddleware.handle(i18next)); app.use(session); -app.use(passport.initialize()); -app.use(passport.session()); +app.use(cookieParser()); // configure the view engine app.set('views', path.join(__dirname, 'views')); diff --git a/src/interfaces/jwt-payload-with-user.ts b/src/interfaces/jwt-payload-with-user.ts new file mode 100644 index 0000000..902551e --- /dev/null +++ b/src/interfaces/jwt-payload-with-user.ts @@ -0,0 +1,7 @@ +import { JwtPayload } from 'jsonwebtoken'; + +import { User } from './user.interface'; + +export interface JWTPayloadWithUser extends JwtPayload { + user: User; +} diff --git a/src/interfaces/user.interface.ts b/src/interfaces/user.interface.ts new file mode 100644 index 0000000..2c8ea60 --- /dev/null +++ b/src/interfaces/user.interface.ts @@ -0,0 +1,6 @@ +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; +} diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts index 7e97dae..1492a4a 100644 --- a/src/middleware/ensure-authenticated.ts +++ b/src/middleware/ensure-authenticated.ts @@ -1,9 +1,27 @@ import { RequestHandler } from 'express'; +import JWT from 'jsonwebtoken'; + +import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; +import { logger } from '../utils/logger'; export const ensureAuthenticated: RequestHandler = (req, res, next) => { - if (req.isAuthenticated()) { - return next(); + logger.debug('checking if user is authenticated...'); + + if (!req.cookies.jwt) { + logger.error('JWT cookie not found'); + return res.redirect('/auth/login'); } - // If not authenticated, redirect to login page - return res.redirect('/auth/login'); + + const secret = process.env.JWT_SECRET || ''; + const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + + if (decoded.exp && decoded.exp <= Date.now() / 1000) { + logger.error('JWT token has expired'); + return res.redirect('/auth/login'); + } + + req.user = decoded.user; + logger.info('user is authenticated'); + + return next(); }; diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 96d4a29..054e79e 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -1,7 +1,7 @@ import session from 'express-session'; export default session({ - secret: process.env.SESSION_SECRET === undefined ? 'default' : process.env.SESSION_SECRET, + secret: process.env.SESSION_SECRET || 'default', resave: false, saveUninitialized: false, cookie: { diff --git a/src/resources/locales/en-GB.json b/src/resources/locales/en-GB.json index c61dc82..c79e8b8 100644 --- a/src/resources/locales/en-GB.json +++ b/src/resources/locales/en-GB.json @@ -15,14 +15,23 @@ "buttons": { "continue": "Continue", "back": "Back", - "cancel": "Cancel", - "login-google": "Login with Google" + "cancel": "Cancel" }, "pagination": { "previous": "Previous", "next": "Next", "update": "Update" }, + "login": { + "heading": "Login", + "buttons": { + "google": "Login with Google" + }, + "error": { + "summary-title": "There is a problem", + "message": "You could not be logged in. Please try again later." + } + }, "homepage": { "title": "Welcome to StatsWales Beta", "welcome": "We're going to use the GovUK Design system until we're able to get hold of the Welsh Government GEL", diff --git a/src/routes/auth.ts b/src/routes/auth.ts index f459667..605abb3 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,61 +1,49 @@ -import { Router, Request, Response, NextFunction } from 'express'; -import passport from 'passport'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; - -export interface User { - id: string; - displayName: string; -} - -const users: User[] = []; - -passport.use( - new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID || 'client_id', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client_secret', - callbackURL: '/auth/google/callback' - }, - (accessToken, refreshToken, profile, done) => { - let user = users.find((usr) => usr.id === profile.id); - if (!user) { - user = { id: profile.id, displayName: profile.displayName }; - users.push(user); - } - return done(null, user); - } - ) -); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -passport.serializeUser((user: any, done) => { - done(null, user.id); -}); +import { Router, Request, Response } from 'express'; +import JWT from 'jsonwebtoken'; -passport.deserializeUser((id: string, done) => { - const user = users.find((usr) => usr.id === id); - done(null, user); -}); +import { logger } from '../utils/logger'; +import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; -export default passport; export const auth = Router(); -auth.get('/login', (req, res) => { - res.render('login'); +auth.get('/login', (req: Request, res: Response) => { + res.render('auth/login'); }); -auth.get('/logout', (req: Request, res: Response, next: NextFunction) => { - // eslint-disable-next-line consistent-return - req.logout((err): void => { - if (err) { - return next(err); - } - res.redirect('/'); - }); +auth.get('/google', (req: Request, res: Response) => { + logger.debug('Sending user to backend for authentication'); + res.redirect(`${process.env.BACKEND_URL}/auth/google`); }); -auth.get('/google', passport.authenticate('google', { scope: ['profile'] })); +auth.get('/callback', (req: Request, res: Response) => { + logger.debug('returning from auth backend'); -auth.get('/google/callback', passport.authenticate('google', { failureRedirect: '/login' }), (req, res) => { + try { + if (!req.cookies.jwt) { + logger.error('JWT cookie not found'); + throw new Error('JWT cookie not found'); + } + + if (req.query.error) { + logger.error(`Error from auth backend: ${req.query.error}`); + throw new Error(`Error from auth backend: ${req.query.error}`); + } + + const secret = process.env.JWT_SECRET || ''; + const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + req.user = decoded.user; + } catch (err) { + logger.error(`Error verifying JWT: ${err}`); + res.status(400); + res.render('auth/login', { errors: ['login.error.message'] }); + return; + } + console.log(req.user); + logger.debug('User successfully logged in'); res.redirect('/'); }); + +auth.get('/logout', (req: Request, res: Response) => { + res.clearCookie('jwt'); + res.redirect('/auth/login'); +}); diff --git a/src/routes/healthcheck.ts b/src/routes/healthcheck.ts index a87419a..8e2cd9f 100644 --- a/src/routes/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; import { logger } from '../utils/logger'; const APIInstance = new API(); diff --git a/src/routes/publish.ts b/src/routes/publish.ts index 201583e..d9f910e 100644 --- a/src/routes/publish.ts +++ b/src/routes/publish.ts @@ -6,7 +6,7 @@ import multer from 'multer'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; const t = i18next.t; const storage = multer.memoryStorage(); diff --git a/src/routes/view.ts b/src/routes/view.ts index 94be1be..e865924 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; import { FileList } from '../dtos/filelist'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; diff --git a/src/controllers/api.ts b/src/services/api.ts similarity index 77% rename from src/controllers/api.ts rename to src/services/api.ts index 706ea18..846a5fc 100644 --- a/src/controllers/api.ts +++ b/src/services/api.ts @@ -1,5 +1,3 @@ -import { env } from 'node:process'; - import { FileListError, FileList } from '../dtos/filelist'; import { ViewDTO, ViewErrDTO } from '../dtos/view-dto'; import { Healthcheck } from '../dtos/healthcehck'; @@ -21,27 +19,15 @@ class HttpError extends Error { } export class API { - private readonly backend_server: string | undefined; - private readonly backend_port: string | undefined; - private readonly backend_protocol: string; + private readonly backendUrl: string | undefined; constructor() { - this.backend_server = env.BACKEND_SERVER; - this.backend_port = env.BACKEND_PORT; - if (env.BACKEND_PROTOCOL === 'https') { - this.backend_protocol = 'https'; - } else { - this.backend_protocol = 'http'; - } + this.backendUrl = process.env.BACKEND_URL; } public async getFileList(lang: string) { - logger.debug( - `Fetching file list from ${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` - ); - const filelist: FileList = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` - ) + logger.debug(`Fetching file list from ${this.backendUrl}/${lang}/dataset`); + const filelist: FileList = await fetch(`${this.backendUrl}/${lang}/dataset`) .then((response) => { if (response.ok) { return response.json(); @@ -62,7 +48,7 @@ export class API { public async getFileData(lang: string, file_id: string, page_number: number, page_size: number) { const file = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` + `${this.backendUrl}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` ) .then((response) => { if (response.ok) { @@ -106,13 +92,10 @@ export class API { formData.append('csv', file, filename); formData.append('internal_name', filename); - const processedCSV = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/`, - { - method: 'POST', - body: formData - } - ) + const processedCSV = await fetch(`${this.backendUrl}/${lang}/dataset/`, { + method: 'POST', + body: formData + }) .then((response) => { if (response.ok) { return response.json(); @@ -151,10 +134,14 @@ export class API { } public async ping() { - const health = await fetch(`${this.backend_protocol}://${this.backend_server}:${this.backend_port}/healthcheck`) + const health = await fetch(`${this.backendUrl}/healthcheck`) .then((api_res) => api_res.json()) .then((api_res) => { return api_res as Healthcheck; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { status: 'App is not running' } as Healthcheck; }); return health.status === 'App is running'; } diff --git a/src/utils/check-config.ts b/src/utils/check-config.ts new file mode 100644 index 0000000..22bc1ca --- /dev/null +++ b/src/utils/check-config.ts @@ -0,0 +1,11 @@ +export const checkConfig = () => { + if (process.env.NODE_ENV === 'test') return; + + const requiredEnvVars = ['FRONTEND_URL', 'BACKEND_URL', 'SESSION_SECRET', 'JWT_SECRET']; + + requiredEnvVars.forEach((variable) => { + if (!process.env[variable]) { + throw new Error(`Environment variable ${variable} is missing`); + } + }); +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 128a95c..8e4f234 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -10,7 +10,7 @@ export const httpLogger = pinoHttp({ logger, autoLogging: { ignore: (req) => { - const ignorePathsRx = /^\/css|\/public|\/assets/; + const ignorePathsRx = /^\/css|\/public|\/assets|\/favicon/; return ignorePathsRx.test(req.url || ''); } }, @@ -19,8 +19,6 @@ export const httpLogger = pinoHttp({ return 'warn'; } else if (res.statusCode >= 500 || err) { return 'error'; - } else if (res.statusCode >= 300 && res.statusCode < 400) { - return 'silent'; } return 'info'; }, diff --git a/src/views/auth/login.ejs b/src/views/auth/login.ejs new file mode 100644 index 0000000..d273fcb --- /dev/null +++ b/src/views/auth/login.ejs @@ -0,0 +1,31 @@ +<%- include("../partials/top"); %> + +
+
+

<%= t('login.heading') %>

+ + <% if (locals?.errors) { %> +
+
+

+ <%= t('errors.problem') %> +

+
+ +
+
+
+ <% } %> + + +
+
+ +<%- include("../partials/bottom"); %> diff --git a/src/views/login.ejs b/src/views/login.ejs deleted file mode 100644 index 56a897f..0000000 --- a/src/views/login.ejs +++ /dev/null @@ -1,14 +0,0 @@ -<%- include("partials/top"); %> - -
- -
-

Login

- -
-
- -<%- include("partials/bottom"); %> diff --git a/test/.jest/setEnvVars.ts b/test/.jest/setEnvVars.ts index ac2d7b3..a603395 100644 --- a/test/.jest/setEnvVars.ts +++ b/test/.jest/setEnvVars.ts @@ -1,3 +1 @@ -process.env.BACKEND_SERVER = 'somehost.com'; -process.env.BACKEND_PORT = '3001'; -process.env.BACKEND_PROTOCOL = 'http'; +process.env.BACKEND_URL = 'http://somehost.com:3001';