Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISSUE-365: Destroy JWT tokens on logout #412

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# Ignore all node_modules folders
node_modules
node_modules
dump.rdb
538 changes: 423 additions & 115 deletions backend/package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@
"dependencies": {
"@typegoose/typegoose": "^9.8.1",
"@types/bcrypt": "^5.0.0",
"axios": "^1.2.1",
"bcrypt": "^5.0.1",
"body-parser": "^1.20.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"express-jwt": "^7.7.0",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.3.2",
"nodemon": "^2.0.16",
"prettier": "^2.6.2",
"redis": "^4.2.0",
"socket.io": "^4.4.1",
"uuid": "^8.3.2"
},
Expand All @@ -27,10 +29,12 @@
"version": "1.0.0",
"main": "server.js",
"devDependencies": {
"@types/cookie-parser": "^1.4.3",
"@types/crypto-js": "^4.1.1",
"@types/express": "^4.17.13",
"@types/jsonwebtoken": "^8.5.8",
"@types/node": "^17.0.31",
"@types/redis": "^4.0.11",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
Expand Down
49 changes: 41 additions & 8 deletions backend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { Router } from 'express';
import { sign } from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import dalUser from '../repository/dalUser';
import {
addHours,
generateHashedSsoPayload,
generateSsoPayload,
getJWTSecret,
getParamMap,
isAuthenticated,
isCorrectHashedSsoPayload,
isSsoEnabled,
isValidNonce,
logoutSCORE,
signInUserWithSso,
userToToken,
} from '../utils/auth';
import { UserModel } from '../models/User';
import { addToken, destroyToken, sign } from '../utils/jwt';

const router = Router();

Expand All @@ -37,9 +37,17 @@ router.post('/login', async (req, res) => {
}

const user = userToToken(foundUser);
const token = sign(user, getJWTSecret(), { expiresIn: '2h' });
const token = sign(user);
const expiresAt = addHours(2);

await addToken(foundUser.userID, token);

res.cookie('CK_SESSION', token, {
httpOnly: true,
domain: process.env.APP_DOMAIN || 'localhost',
expires: expiresAt,
secure: true,
});
res.status(200).send({ token, user, expiresAt });
});

Expand All @@ -52,12 +60,35 @@ router.post('/register', async (req, res) => {
const savedUser = await dalUser.create(body);

const user = userToToken(savedUser);
const token = sign(user, getJWTSecret(), { expiresIn: '2h' });
const token = sign(user);
const expiresAt = addHours(2);

await addToken(savedUser.userID, token);

res.cookie('CK_SESSION', token, {
httpOnly: true,
domain: process.env.APP_DOMAIN || 'localhost',
expires: expiresAt,
secure: true,
});
res.status(200).send({ token, user, expiresAt });
});

router.post('/logout', isAuthenticated, async (req, res) => {
if (!req.headers.authorization) {
return res.status(400).end('No authorization header found!');
}

const token = req.headers.authorization.replace('Bearer ', '');
await destroyToken(res.locals.user.userID, token);

if (req.query.score) {
await logoutSCORE(req);
}

res.status(200).end();
});

router.post('/multiple', async (req, res) => {
const ids = req.body;
const users = await dalUser.findByUserIDs(ids);
Expand All @@ -71,12 +102,14 @@ router.get('/is-sso-enabled', async (req, res) => {
});

router.get('/sso/handshake', async (req, res) => {
const scoreSsoEndpoint = process.env.SCORE_SSO_ENDPOINT;
const payload = await generateSsoPayload();
const hashedPayload = generateHashedSsoPayload(payload);
if (!scoreSsoEndpoint) {
const ssoEndpoint = process.env.SCORE_SSO_ENDPOINT;
const scoreAddress = process.env.SCORE_SERVER_ADDRESS || 'http://localhost';
if (!ssoEndpoint) {
throw new Error('No SCORE SSO endpoint environment variable defined!');
}
const scoreSsoEndpoint = `${scoreAddress + ssoEndpoint}`;
const payload = await generateSsoPayload();
const hashedPayload = generateHashedSsoPayload(payload);
res.status(200).send({
scoreSsoEndpoint: scoreSsoEndpoint,
sig: hashedPayload,
Expand Down
26 changes: 25 additions & 1 deletion backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express from 'express';
import http from 'http';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
Expand All @@ -18,20 +19,43 @@ import trace from './api/trace';
import groups from './api/groups';
import todoItems from './api/todoItem';
import { isAuthenticated } from './utils/auth';
import redis from './utils/redis';
dotenv.config();

const port = process.env.PORT || 8001;
const ckAddr = process.env.CKBOARD_SERVER_ADDRESS || 'http://localhost:4201';
const scoreAddr = process.env.SCORE_SERVER_ADDRESS || 'http://localhost';
const dbUsername = process.env.DB_USER;
const dbPassword = process.env.DB_PASSWORD;
const dbUrl = process.env.DB_URL;
const dbName = process.env.DB_NAME;
const dbURI = `mongodb+srv://${dbUsername}:${dbPassword}@${dbUrl}.mongodb.net/${dbName}?retryWrites=true&w=majority`;

const app = express();
app.use(cors());
app.use(cookieParser());
app.use(
cors({
credentials: true,
origin: (origin, callback) => {
if (!origin) return callback(null, true);

if (origin != ckAddr && origin != scoreAddr) {
const msg = `This site ${origin} does not have an access. Only specific domains are allowed to access it.`;
return callback(new Error(msg), false);
}

return callback(null, true);
},
})
);
app.use(bodyParser.json());
const server = http.createServer(app);

(async () => {
await redis.connect();
return redis;
})();

const socket = Socket.Instance;
socket.init();

Expand Down
3 changes: 2 additions & 1 deletion backend/src/socket/events/post.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ class PostCommentRemove {
): Promise<object> {
const comment = input.eventData;
const commentAmount = await dalComment.getAmountByPost(comment.postID);
await postTrace.commentRemove(input, this.type);
if (input.trace.allowTracing)
await postTrace.commentRemove(input, this.type);

WorkflowManager.Instance.updateTask(
comment.userID,
Expand Down
43 changes: 33 additions & 10 deletions backend/src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from 'express';
import { sign, verify } from 'jsonwebtoken';
import axios from 'axios';
import { Role, UserModel } from '../models/User';
import { v4 as uuidv4 } from 'uuid';
import hmacSHA256 from 'crypto-js/hmac-sha256';
Expand All @@ -11,6 +11,7 @@ import { ProjectModel } from '../models/Project';
import { NotFoundError } from '../errors/client.errors';
import { addUserToProject } from './project.helpers';
import { ApplicationError } from '../errors/base.errors';
import { addToken, checkToken, sign, verify } from './jwt';

export interface Token {
email: string;
Expand All @@ -19,11 +20,6 @@ export interface Token {
role: string;
}

export const addHours = (numOfHours: number, date = new Date()) => {
date.setTime(date.getTime() + numOfHours * 60 * 60 * 1000);
return date;
};

export const getJWTSecret = (): string => {
const secret = process.env.JWT_SECRET;

Expand All @@ -34,6 +30,11 @@ export const getJWTSecret = (): string => {
return secret;
};

export const addHours = (numOfHours: number, date = new Date()) => {
date.setTime(date.getTime() + numOfHours * 60 * 60 * 1000);
return date;
};

export const userToToken = (user: UserModel): Token => {
return {
email: user.email,
Expand All @@ -54,11 +55,15 @@ export const isAuthenticated = async (
}

const token = req.headers.authorization.replace('Bearer ', '');
res.locals.user = verify(token, getJWTSecret()) as Token;
res.locals.user = verify(token);

const cachedToken = await checkToken(res.locals.user.userID, token);
if (cachedToken == null || cachedToken == 'invalid' || cachedToken == 'nil')
return res.status(401).end('Invalid token!');

next();
} catch (e) {
return res.status(403).end('Unable to authenticate!');
return res.status(401).end('Unable to authenticate!');
}
};

Expand Down Expand Up @@ -216,14 +221,32 @@ export const signInUserWithSso = async (
return res.status(500).end('Internal Server Error');
}
}
const sessionToken = generateSessionToken(userModel);
const sessionToken = await generateSessionToken(userModel);
sessionToken.redirectUrl = redirectUrl;
await addToken(sessionToken.user.userID, sessionToken.token);

res.cookie('CK_SESSION', sessionToken.token, {
httpOnly: true,
domain: process.env.APP_DOMAIN || 'localhost',
expires: sessionToken.expiresAt,
secure: true,
});
return res.status(200).send(sessionToken);
};

export const generateSessionToken = (userModel: UserModel): any => {
const user = userToToken(userModel);
const token = sign(user, getJWTSecret(), { expiresIn: '2h' });
const token = sign(user);
const expiresAt = addHours(2);
return { token, user, expiresAt };
};

export const logoutSCORE = async (req: Request) => {
const scoreAddress = process.env.SCORE_SERVER_ADDRESS || 'http://localhost';
return await axios.get(
`${scoreAddress + process.env.SCORE_LOGOUT_ENDPOINT}`,
{
headers: { Cookie: `SESSION=${req.cookies['SESSION']};` },
}
);
};
39 changes: 39 additions & 0 deletions backend/src/utils/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import redis from './redis';
import jwt from 'jsonwebtoken';
import { Token } from './auth';

export const sign = (payload: Token, date = new Date()) => {
return jwt.sign(payload, 'secret', {
expiresIn: date.setTime(date.getTime() + 2 * 60 * 60 * 1000),
});
};

export const verify = (token: string): Token => {
return jwt.verify(token, 'secret') as Token;
};

export const addToken = async (
id: string,
token: string,
date = new Date()
) => {
const key = `${id}_${token}`;
const check = await redis.EXISTS(key); // check if key exists in cache
if (check == 1) return;

await redis.SET(key, 'valid'); // set key value to be 'valid'
await redis.EXPIREAT(key, date.setTime(date.getTime() + 2 * 60 * 60 * 1000)); // set expiry date for the key in the cache
return;
};

export const checkToken = async (id: string, token: string) => {
const key = `${id}_${token}`;
const status = redis.GET(key); // get the token from the cache and return its value
return status;
};

export const destroyToken = async (id: string, token: string) => {
const key = `${id}_${token}`;
await redis.DEL(key); // deletes token from cache
return;
};
35 changes: 35 additions & 0 deletions backend/src/utils/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createClient } from 'redis';

class Redis {
client: any;
connected: boolean;

constructor() {
this.client = null;
this.connected = false;
}

getConnection() {
if (this.connected) return this.client;

this.client = createClient();
console.log(this.client);

this.client.on('connect', () => {
console.log('Client connected to Redis...');
});
this.client.on('ready', () => {
console.log('Redis ready to use');
});
this.client.on('error', (err: string) => {
console.error('Redis Client', err);
});
this.client.on('end', () => {
console.log('Redis disconnected successfully');
});

return this.client;
}
}

export default new Redis().getConnection();
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<button
mat-icon-button
[matMenuTriggerFor]="addMenu"
*ngIf="user && user.role == Role.TEACHER"
*ngIf="user && user.role === Role.TEACHER"
matTooltip="Create Project or Board"
>
<mat-icon>add</mat-icon>
Expand All @@ -17,7 +17,7 @@
<button
mat-menu-item
(click)="openCreateBoardDialog()"
*ngIf="yourProjects.length > 0"
*ngIf="yourProjects && yourProjects.length > 0"
>
Create Board
</button>
Expand Down
Loading