Skip to content

Commit

Permalink
50 integrate csrf protection for login form (#53)
Browse files Browse the repository at this point in the history
* [Task] #50, create CSRF Validation for login form

* [Task] #43, added icon to repository for later use

* [Task] #50, cleanup cetntralized; rename token functions

* [Task] #50, reduced token length and improved error handling

* [Task] #50 csrf tests added to login

* [Task] #50, added test case for csrf, repaired integration
  • Loading branch information
Type-Style authored Mar 26, 2024
1 parent da13c77 commit 8ab8cba
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 51 deletions.
Binary file added httpdocs/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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, () => {
Expand Down
20 changes: 10 additions & 10 deletions src/controller/login.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
}
});
});
Expand Down
37 changes: 18 additions & 19 deletions src/middleware/limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -33,30 +35,17 @@ const baseRateLimitOptions: Partial<rateLimiterOptions> = {
}


/*
** 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);

Expand All @@ -69,4 +58,14 @@ export const loginLimiter = rateLimit({
...baseRateLimitOptions,
limit: 3,
message: 'Too many attempts without valid login',
});
});


export function cleanup() {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const ip in ipsThatReachedLimit) {
if (ipsThatReachedLimit[ip].time < oneHourAgo) {
delete ipsThatReachedLimit[ip];
}
}
}
4 changes: 2 additions & 2 deletions src/middleware/logged-in.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions src/scripts/crypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}


48 changes: 43 additions & 5 deletions src/scripts/token.ts
Original file line number Diff line number Diff line change
@@ -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<CSRFToken> = 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(' ') : "";
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
21 changes: 18 additions & 3 deletions src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
Expand All @@ -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'));
Expand Down
42 changes: 39 additions & 3 deletions src/tests/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -24,6 +31,10 @@ describe('Login', () => {

expect(serverStatus).toBe(200);
expect(response.data).toContain('<form');
const regex = /name="csrfToken" value="([^"]*)"/;
const match = response.data.match(regex);
csrfToken = match ? match[1] : '';
expect(csrfToken.length).toBeGreaterThan(4);
})

it('server is blocking requests with large body', async () => {
Expand All @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ namespace Models {
}
}

interface CSRFToken {
token: string;
expiry: number;
}

interface HttpError extends Error {
status?: number;
statusCode?: number;
Expand Down
6 changes: 2 additions & 4 deletions views/login-form.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</head>

<body>
<form class="login" action="/read/login/" method="post">
<form class="login" action="/login" method="post">
<h1 class="a color-main-l2 b">Text: <%= locals.text %></h1>
<label>
User:
Expand All @@ -24,9 +24,7 @@
Submit:
<button type="submit">Submit</button>
</label>
<textarea name="text"></textarea>
<input type="hidden" name="token" value="<%= locals.token %>">
<p>Token: <%= locals.token %></p>
<input type="hidden" name="csrfToken" value="<%= locals.csrfToken %>">
</form>
</body>

Expand Down

0 comments on commit 8ab8cba

Please sign in to comment.