diff --git a/httpdocs/icon.png b/httpdocs/icon.png new file mode 100644 index 00000000..827ec59b Binary files /dev/null and b/httpdocs/icon.png differ diff --git a/src/app.ts b/src/app.ts index d7e10840..b11cfdb6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,7 +12,8 @@ import readRouter from '@src/controller/read'; import loginRouter from '@src/controller/login'; import path from 'path'; import logger from '@src/scripts/logger'; -import { baseRateLimiter } from './middleware/limit'; +import { baseRateLimiter, cleanup as cleanupRateLimitedIps } from './middleware/limit'; +import { cleanupCSRF } from "@src/scripts/token"; // configurations config(); // dotenv @@ -43,8 +44,8 @@ app.use(compression()) app.use(hpp()); app.use(baseRateLimiter); app.use((req, res, next) => { // limit body for specific http methods - if(['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { - return express.urlencoded({ limit: '0.5kb', extended: true })(req, res, next); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { + return express.urlencoded({ limit: '0.5kb', extended: true })(req, res, next); } next(); }); @@ -75,6 +76,12 @@ const server = app.listen(80, () => { logger.log(`Server running //localhost:80, ENV: ${process.env.NODE_ENV}`, true); }); +// scheduled cleanup +setInterval(() => { + cleanupCSRF(); + cleanupRateLimitedIps(); +}, 1000 * 60 * 5); + // catching shutdowns ['SIGINT', 'SIGTERM', 'exit'].forEach((signal) => { process.on(signal, () => { diff --git a/src/controller/login.ts b/src/controller/login.ts index 7c712364..6a47f4cc 100644 --- a/src/controller/login.ts +++ b/src/controller/login.ts @@ -1,29 +1,29 @@ import express, { Request, Response, NextFunction } from 'express'; import { create as createError } from '@src/middleware/error'; -import logger from '@src/scripts/logger'; import { crypt, compare } from '@src/scripts/crypt'; import { loginSlowDown, loginLimiter, baseSlowDown, baseRateLimiter } from '@src/middleware/limit'; -import { createToken } from '@src/scripts/token'; +import { createJWT, createCSRF, validateCSRF } from '@src/scripts/token'; + const router = express.Router(); -router.get("/", baseSlowDown, baseRateLimiter, async function login(req: Request, res: Response) { - res.locals.text = "start"; +router.get("/", baseSlowDown, baseRateLimiter, async function login(req: Request, res: Response, next: NextFunction) { loginLimiter(req, res, () => { + const csrfToken = createCSRF(res, next); + res.locals = {...res.locals, text: 'start', csrfToken: csrfToken}; res.render("login-form"); }); }); router.post("/", loginSlowDown, async function postLogin(req: Request, res: Response, next: NextFunction) { - logger.log(req.body); loginLimiter(req, res, async () => { let validLogin = false; + const token = req.body.csrfToken; const user = req.body.user; const password = req.body.password; let userFound = false; - if (!user || !password) { - return createError(res, 422, "Body does not contain all expected information", next); - } + if (!user || !password) { return createError(res, 422, "Body does not contain all expected information", next); } + if (!token || !validateCSRF(req.body.csrfToken)) { return createError(res, 403, "Invalid CSRF Token", next); } // Loop through all environment variables for (const key in process.env) { @@ -43,13 +43,13 @@ router.post("/", loginSlowDown, async function postLogin(req: Request, res: Resp } if (validLogin) { - const token = createToken(req, res); + const token = createJWT(req, res); res.json({ "token": token }); } else { if (!userFound) { await crypt(password); // If no matching user is found, perform a dummy password comparison to prevent timing attacks } - return createError(res, 403, `invalid login credentials`, next); + return createError(res, 403, `Invalid credentials`, next); } }); }); diff --git a/src/middleware/limit.ts b/src/middleware/limit.ts index eb9aea52..13010713 100644 --- a/src/middleware/limit.ts +++ b/src/middleware/limit.ts @@ -3,6 +3,8 @@ import { rateLimit, Options as rateLimiterOptions } from 'express-rate-limit'; import { slowDown, Options as slowDownOptions } from 'express-slow-down'; import logger from '@src/scripts/logger'; +const ipsThatReachedLimit: RateLimit.obj = {}; // prevent logs from flooding + /* ** configurations */ @@ -33,30 +35,17 @@ const baseRateLimitOptions: Partial = { } -/* -** cleanup -*/ -const ipsThatReachedLimit: RateLimit.obj = {}; // prevent logs from flooding -setInterval(() => { - const oneHourAgo = Date.now() - 60 * 60 * 1000; - for (const ip in ipsThatReachedLimit) { - if (ipsThatReachedLimit[ip].time < oneHourAgo) { - delete ipsThatReachedLimit[ip]; - } - } -}, 60 * 60 * 1000); - /* ** exported section */ export const baseSlowDown = slowDown(baseSlowDownOptions); -export const loginSlowDown = slowDown({ - ...baseSlowDownOptions, - delayAfter: 1, // no delay for amount of attempts - delayMs: (used: number) => (used - 1) * 250, // Add delay after delayAfter is reached - }); +export const loginSlowDown = slowDown({ + ...baseSlowDownOptions, + delayAfter: 1, // no delay for amount of attempts + delayMs: (used: number) => (used - 1) * 250, // Add delay after delayAfter is reached +}); export const baseRateLimiter = rateLimit(baseRateLimitOptions); @@ -69,4 +58,14 @@ export const loginLimiter = rateLimit({ ...baseRateLimitOptions, limit: 3, message: 'Too many attempts without valid login', -}); \ No newline at end of file +}); + + +export function cleanup() { + const oneHourAgo = Date.now() - 60 * 60 * 1000; + for (const ip in ipsThatReachedLimit) { + if (ipsThatReachedLimit[ip].time < oneHourAgo) { + delete ipsThatReachedLimit[ip]; + } + } +} \ No newline at end of file diff --git a/src/middleware/logged-in.ts b/src/middleware/logged-in.ts index eddca045..f6546ba9 100644 --- a/src/middleware/logged-in.ts +++ b/src/middleware/logged-in.ts @@ -1,10 +1,10 @@ import { Request, Response, NextFunction } from 'express'; -import { validateToken } from '@src/scripts/token'; +import { validateJWT } from '@src/scripts/token'; import { create as createError } from '@src/middleware/error'; export function isLoggedIn(req: Request, res: Response, next: NextFunction) { - const result = validateToken(req); + const result = validateJWT(req); if (!result.success) { createError(res, result.status, result.message || "", next) } else { diff --git a/src/scripts/crypt.ts b/src/scripts/crypt.ts index 1928c521..6bef0df6 100644 --- a/src/scripts/crypt.ts +++ b/src/scripts/crypt.ts @@ -16,5 +16,3 @@ function pepper(password: string) { if (!key) { throw new Error('KEYA is not defined in the environment variables'); } return password + crypto.createHmac('sha256', key).digest("base64"); } - - diff --git a/src/scripts/token.ts b/src/scripts/token.ts index f26a7181..f9f4d7ab 100644 --- a/src/scripts/token.ts +++ b/src/scripts/token.ts @@ -1,9 +1,48 @@ import jwt from 'jsonwebtoken'; -import logger from '@src/scripts/logger'; -import {Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; +import crypto from 'crypto'; +import { create as createError } from '@src/middleware/error'; -export function validateToken(req: Request) { +const csrfTokens: Set = new Set(); + +export function createCSRF(res: Response, next: NextFunction): string { + if (csrfTokens.size > 100) { // Max Number of Tokens in memory + res.set('Retry-After', '300'); // 5 minutes + createError(res, 503, "Too many tokens", next); + } + + const token = crypto.randomBytes(16).toString('hex'); + const expiry = Date.now() + (5 * 60 * 1000); // Token expires in 5 minutes + const csrfToken: CSRFToken = { token, expiry }; + csrfTokens.add(csrfToken); + + return token; +} + +export function validateCSRF(token: string): boolean { + const currentTime = Date.now(); + let valid: boolean = false; + for (const entry of csrfTokens) { + if (entry.token === token) { + valid = entry.expiry > currentTime; + csrfTokens.delete(entry); + } + } + + return valid; +} + +export function cleanupCSRF() { + const currentTime = Date.now(); + for (const entry of csrfTokens) { + if (entry.expiry < currentTime) { + csrfTokens.delete(entry); + } + } +} + +export function validateJWT(req: Request) { const key = process.env.KEYA; const header = req.header('Authorization'); const [type, token] = header ? header.split(' ') : ""; @@ -33,7 +72,7 @@ export function validateToken(req: Request) { return { success: true }; } -export function createToken(req: Request, res: Response) { +export function createJWT(req: Request, res: Response) { const key = process.env.KEYA; if (!key) { throw new Error('Configuration is wrong'); } const today = new Date(); @@ -44,6 +83,5 @@ export function createToken(req: Request, res: Response) { }; const token = jwt.sign(payload, key, { expiresIn: 60 * 2 }); res.locals.token = token; - logger.log(JSON.stringify(payload), true); return token; } diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 4fd5f4e0..067d3eae 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -231,10 +231,25 @@ describe('API calls', () => { describe('read and login', () => { let token = ""; - const testData = qs.stringify({ + const testData = { user: "TEST", password: "test", - }); + csrfToken: "" + } + + it('form available / get Token', async () => { + let response = {data:""}; + try { + response = await axios.get('http://localhost:80/login'); + } catch (error) { + console.error(error); + } + + const regex = /name="csrfToken" value="([^"]*)"/; + const match = response.data.match(regex); + testData.csrfToken = match ? match[1] : '-'; + }) + test(`redirect without logged in`, async () => { try { await axios.get("http://localhost:80/read/"); @@ -249,7 +264,7 @@ describe('read and login', () => { }); it('test user can login', async () => { - const response = await axios.post('http://localhost:80/login', testData); + const response = await axios.post('http://localhost:80/login', qs.stringify(testData)); expect(response.status).toBe(200); expect(response.headers['content-type']).toEqual(expect.stringContaining('application/json')); diff --git a/src/tests/login.test.ts b/src/tests/login.test.ts index 6fbc3404..e1778269 100644 --- a/src/tests/login.test.ts +++ b/src/tests/login.test.ts @@ -6,11 +6,18 @@ const userDataLarge = qs.stringify({ password: "pass", kilobyte: 'BPSwVu5vcvhWB17HcfIdyQK83mHJZKChv7zDihBJoifWK9EJFzK7VYf3kUgIqkc0io8DnSdewzc9U0GpzodQUFz0KLMaogsJruEbNSKvxnzUxS5UqSR64lLOmGumoPcn2InC0Ebpqfdiw90HFVZVlE3AY6Lhgbx8ILHi55RvpuGefDjBsePgow8Jh9sc8uVMCDglLmHQ0zk3PumMj0KlOszbMmX9fG0pPUsvLLc40biPBv9t97K3BFjYd3fGriRAQ3bFhGHBz2wzGbNQfHjKFDHuSvXOw8KReM7Wwd4Cl02QQ3RnDJVwH6cayh4BqFRXlP3i6uXw0l9qxdTv0q1CtV9rJho6zwo04gkGLvsS3AoYJQtHnOtUDdHPExu7l3nMKnPoRUwl7K2ePfHRuppFGqa43Q49bI04VjEhrB9k5S2uZJoxZdm63rIUrydmkZWdvBLVVZUIXwwIRnwLmoa26htKOz9FPKwWIPOM0NZj4jAoPhKqLDJwziNZn5UupzxBXoUM3BIyEk3K8GXs7eBduH9GCK2z2HPF0fJNtGiHASe7jCOC2mhSC5zGf9k0Yu1Ey63oQQZUtT7L57lp7UzPE2p6wzKDlbJZOn0Ho5OUfq3hE2C8fQRO1M6jDvRTiUIKhhxSHYd75Pvh4SG9lD8w5OHASusLDxmzKBUuG4GrGrQYpd0awJkqnKp5lk7psLD22YTtjTuDgI500tQLXSslxI1kIuB8RnN1LsxHyRQMVtXmNFOKKZV2U2frWpImIz2wSHCYrwRGygwDtiFfwtVwTapjhQqUMyb1vrWWi3EL1Y50fDCjDDHlvLI4N2tr2DULFf3a9m2SYWSoE6CYP4og5YyqjhqFQFm9urREInyZi9L0iQoMYxEqxTjGiVJfKmaSChSd0kQz6z2OdsxFbkMWJ2CAHOL1XNK8iFFSp93fIspaNMIonRVDCj4ZIP1LaPHDmIYcYTNU4k3Uz6VBHSIc1VjiG3sc2MZpKw9An0tJVlWbtVSk2RGYWIANAYyr5pQS' }); -const userData = qs.stringify({ +const userDataWithoutToken = qs.stringify({ user: "user", password: "pass" }); +let csrfToken = "-"; +const userDataWithToken = { + user: "user", + password: "pass", + csrfToken: "" +}; + describe('Login', () => { it('form available', async () => { let serverStatus = {}; @@ -24,6 +31,10 @@ describe('Login', () => { expect(serverStatus).toBe(200); expect(response.data).toContain(' { @@ -39,13 +50,38 @@ describe('Login', () => { } }) - it('invalid login verification test', async () => { + it('invalid csrf shows correct error', async () => { + try { + await axios.post('http://localhost:80/login', userDataWithoutToken); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + expect(axiosError.response.status).toBe(403); + if (axiosError.response.data) { + expect(JSON.stringify(axiosError.response.data)).toContain('Invalid CSRF'); + } else { + throw Error("fail"); + } + } else { + console.error(axiosError); + } + } + }) + + + it('test invalid credentials to return error', async () => { try { - await axios.post('http://localhost:80/login', userData); + userDataWithToken.csrfToken = csrfToken + await axios.post('http://localhost:80/login', qs.stringify(userDataWithToken)); } catch (error) { const axiosError = error as AxiosError; if (axiosError.response) { expect(axiosError.response.status).toBe(403); + if (axiosError.response.data) { + expect(JSON.stringify(axiosError.response.data)).toContain('Invalid credentials'); + } else { + throw Error("fail"); + } } else { console.error(axiosError); } diff --git a/types.d.ts b/types.d.ts index 6bb5b4cc..3cde1ce3 100644 --- a/types.d.ts +++ b/types.d.ts @@ -118,6 +118,11 @@ namespace Models { } } +interface CSRFToken { + token: string; + expiry: number; +} + interface HttpError extends Error { status?: number; statusCode?: number; diff --git a/views/login-form.ejs b/views/login-form.ejs index 802a7c95..34bf5517 100644 --- a/views/login-form.ejs +++ b/views/login-form.ejs @@ -10,7 +10,7 @@ -