From 310f24d750d76555ea50486fdc70f84bf60224d2 Mon Sep 17 00:00:00 2001 From: Doeke Leeuwis Date: Fri, 16 Apr 2021 17:42:05 +0200 Subject: [PATCH] feat: added jwt authentication --- package.json | 3 + src/api/__tests__/loginHandler.test.ts | 35 ++++---- .../__tests__/passwordCheckHandler.test.ts | 18 +++- src/api/loginHandler.ts | 7 +- src/api/passwordCheckHandler.ts | 22 +++-- yarn.lock | 82 +++++++++++++++++++ 6 files changed, 140 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 2926845..4eae6b7 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "cookie": "^0.4.1", + "jsonwebtoken": "^8.5.1", "safe-compare": "^1.1.4" }, "devDependencies": { @@ -61,9 +62,11 @@ "@types/express": "4.17.11", "@types/jest": "26.0.20", "@types/jest-axe": "^3.5.1", + "@types/jsonwebtoken": "^8.5.1", "@types/node": "14.14.21", "@types/react": "17.0.0", "@types/react-dom": "17.0.0", + "@types/safe-compare": "^1.1.0", "@types/testing-library__jest-dom": "5.9.5", "@typescript-eslint/eslint-plugin": "4.13.0", "@typescript-eslint/parser": "4.13.0", diff --git a/src/api/__tests__/loginHandler.test.ts b/src/api/__tests__/loginHandler.test.ts index aedc63d..fb2013f 100644 --- a/src/api/__tests__/loginHandler.test.ts +++ b/src/api/__tests__/loginHandler.test.ts @@ -13,10 +13,11 @@ describe('[api] loginHandler', () => { await loginHandler('password')(req as any, res as any); expect(res._getStatusCode()).toBe(200); - expect(res._getHeaders()).toHaveProperty( - 'set-cookie', - 'next-password-protect=cGFzc3dvcmQ%3D; Path=/; HttpOnly', - ); + expect(res._getHeaders()).toMatchObject({ + 'set-cookie': expect.stringMatching( + /^next-password-protect=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\..+\..+; Path=\/; HttpOnly$/, + ), + }); jest.restoreAllMocks(); }); @@ -36,12 +37,17 @@ describe('[api] loginHandler', () => { ); expect(res._getStatusCode()).toBe(200); - expect(res._getHeaders()).toHaveProperty( - 'set-cookie', - `next-password-protect=cGFzc3dvcmQ%3D; Max-Age=${ - maxAge / 1000 - }; Path=/; Expires=${new Date(now + maxAge).toUTCString()}; HttpOnly`, - ); + expect(res._getHeaders()).toMatchObject({ + 'set-cookie': expect.stringMatching( + new RegExp( + `^next-password-protect=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\..+\\..+; Max-Age=${ + maxAge / 1000 + }; Path=\\/; Expires=${new Date( + now + maxAge, + ).toUTCString()}; HttpOnly$`, + ), + ), + }); jest.restoreAllMocks(); }); @@ -58,10 +64,11 @@ describe('[api] loginHandler', () => { ); expect(res._getStatusCode()).toBe(200); - expect(res._getHeaders()).toHaveProperty( - 'set-cookie', - `next-password-protect=cGFzc3dvcmQ%3D; Path=/; HttpOnly; Secure`, - ); + expect(res._getHeaders()).toMatchObject({ + 'set-cookie': expect.stringMatching( + /^next-password-protect=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\..+\..+; Path=\/; HttpOnly; Secure$/, + ), + }); jest.restoreAllMocks(); }); diff --git a/src/api/__tests__/passwordCheckHandler.test.ts b/src/api/__tests__/passwordCheckHandler.test.ts index 5b806bd..2a1ce54 100644 --- a/src/api/__tests__/passwordCheckHandler.test.ts +++ b/src/api/__tests__/passwordCheckHandler.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import jwt from 'jsonwebtoken'; import { createMocks } from 'node-mocks-http'; import { passwordCheckHandler } from '../passwordCheckHandler'; @@ -9,7 +10,10 @@ describe('[api] passwordCheckHandler', () => { { method: 'GET', headers: { - cookie: 'next-password-protect=cGFzc3dvcmQ%3D; Path=/; HttpOnly', + cookie: `next-password-protect=${jwt.sign( + {}, + 'password', + )}; Path=/; HttpOnly`, }, }, { eventEmitter: EventEmitter }, @@ -42,12 +46,15 @@ describe('[api] passwordCheckHandler', () => { jest.restoreAllMocks(); }); - it('should fail with incorrect cookie', async () => { + it('should fail with incorrect JWT', async () => { const { req, res } = createMocks( { method: 'GET', headers: { - cookie: 'next-password-protect=incorrect; Path=/; HttpOnly', + cookie: `next-password-protect=${jwt.sign( + {}, + 'incorrect', + )}; Path=/; HttpOnly`, }, }, { eventEmitter: EventEmitter }, @@ -78,7 +85,10 @@ describe('[api] passwordCheckHandler', () => { { method: 'GET', headers: { - cookie: 'next-password-protect=cGFzc3dvcmQ%3D; Path=/; HttpOnly', + cookie: `next-password-protect=${jwt.sign( + {}, + 'password', + )}; Path=/; HttpOnly`, }, }, { eventEmitter: EventEmitter }, diff --git a/src/api/loginHandler.ts b/src/api/loginHandler.ts index 3b54790..222591f 100644 --- a/src/api/loginHandler.ts +++ b/src/api/loginHandler.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; import compare from 'safe-compare'; import { sendJson } from './sendJson'; @@ -33,7 +34,11 @@ export const loginHandler = ( setCookie( res, options?.cookieName || 'next-password-protect', - Buffer.from(password).toString('base64'), + /* NOTE: It's not usual to use the password as JWT secret, but since you already + * have access to the environment when you know the password, in this specific + * use case it doesn't add any value for an intruder if the secret is known. + */ + jwt.sign({}, password), { httpOnly: true, sameSite: options?.cookieSameSite || false, diff --git a/src/api/passwordCheckHandler.ts b/src/api/passwordCheckHandler.ts index 567c84e..f4dadca 100644 --- a/src/api/passwordCheckHandler.ts +++ b/src/api/passwordCheckHandler.ts @@ -1,6 +1,6 @@ import cookie from 'cookie'; import { Request, Response } from 'express'; -import compare from 'safe-compare'; +import jwt from 'jsonwebtoken'; import { sendJson } from './sendJson'; @@ -27,17 +27,23 @@ export const passwordCheckHandler = ( const cookies = cookie.parse(req.headers.cookie); const cookieName = options?.cookieName || 'next-password-protect'; - if ( - cookies?.[cookieName] && - compare(cookies?.[cookieName], Buffer.from(password).toString('base64')) - ) { - sendJson(res, 200); - return; - } + /* NOTE: It's not usual to use the password as JWT secret, but since you already + * have access to the environment when you know the password, in this specific + * use case it doesn't add any value for an intruder if the secret is known. + */ + jwt.verify(cookies?.[cookieName], password); + + sendJson(res, 200); + return; } sendJson(res, 401); } catch (err) { + if (err.name === 'JsonWebTokenError') { + sendJson(res, 401); + return; + } + sendJson(res, 500, { message: err?.message || 'An error has occured.' }); } }; diff --git a/yarn.lock b/yarn.lock index 1af4228..d946dcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,6 +2485,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.5.1": + version "8.5.1" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#56958cb2d80f6d74352bd2e501a018e2506a8a84" + integrity sha512-rNAPdomlIUX0i0cg2+I+Q1wOUr531zHBQ+cV/28PJ39bSPKjahatZZ2LMuhiguETkCgLVzfruw/ZvNMNkKoSzw== + dependencies: + "@types/node" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -2582,6 +2589,11 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== +"@types/safe-compare@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/safe-compare/-/safe-compare-1.1.0.tgz#47ed9b9ca51a3a791b431cd59b28f47fa9bf1224" + integrity sha512-1ri+LJhh0gRxIa37IpGytdaW7yDEHeJniBSMD1BmitS07R1j63brcYCzry+l0WJvGdEKQNQ7DYXO2epgborWPw== + "@types/serve-static@*": version "1.13.9" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" @@ -3781,6 +3793,11 @@ buffer-alloc@^1.2.0: buffer-alloc-unsafe "^1.1.0" buffer-fill "^1.0.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" @@ -5218,6 +5235,13 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + editor@1.0.0, editor@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742" @@ -7951,6 +7975,22 @@ jsonparse@^1.2.0, jsonparse@^1.3.1: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + 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 "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -7969,6 +8009,23 @@ jsprim@^1.2.2: array-includes "^3.1.2" object.assign "^4.1.2" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -8369,11 +8426,31 @@ lodash.escaperegexp@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -8394,6 +8471,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"