Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

// Load in all of our node modules. Their uses are explained below as they are called.
const express = require('express');
const cron = require('node-cron');
const fetch = require('node-fetch');
// const cron = require('node-cron');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these commented out?

// const fetch = require('node-fetch');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');

Expand Down Expand Up @@ -52,14 +52,18 @@ app.use(cookieParser());
app.use(morgan('dev'));

// WORKERS
const runOpenCheckinWorker = require('./workers/openCheckins')(cron, fetch);
const runCloseCheckinWorker = require('./workers/closeCheckins')(cron, fetch);
// const runOpenCheckinWorker = require('./workers/openCheckins')(cron, fetch);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these commected out?

// const runCloseCheckinWorker = require('./workers/closeCheckins')(cron, fetch);

const { createRecurringEvents } = require('./workers/createRecurringEvents');
const runCreateRecurringEventsWorker = createRecurringEvents(cron, fetch);
// const { createRecurringEvents } = require('./workers/createRecurringEvents');
// const runCreateRecurringEventsWorker = createRecurringEvents(cron, fetch);

// const runSlackBot = require("./workers/slackbot")(fetch);

// Run cleanup expired refresh token(s) on startup
const { cleanupExpiredTokens } = require('./workers/tokenCleanup');
cleanupExpiredTokens();

// MIDDLEWARE
const errorhandler = require('./middleware/errorhandler.middleware');

Expand Down
8 changes: 5 additions & 3 deletions backend/config/auth.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/*eslint-disable */
module.exports = {
SECRET:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be part of the code, this should be as the name implies a secret. This should be a env var.

'c0d7d0716e4cecffe9dcc77ff90476d98f5aace08ea40f5516bd982b06401021191f0f24cd6759f7d8ca41b64f68d0b3ad19417453bddfd1dbe8fcb197245079',
CUSTOM_REQUEST_HEADER: process.env.CUSTOM_REQUEST_HEADER,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling an env variable like this, if the var does not exist will cause a silent failure returning undefined.

TOKEN_EXPIRATION_SEC: 900,
// 15 minutes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the difference between these two ACCESS_TOKEN_EXPIRATION, and ACCESS_TOKEN_EXPIRATION_MS?

ACCESS_TOKEN_EXPIRATION: '15m',
ACCESS_TOKEN_EXPIRATION_MS: 15 * 60 * 1000,
// 30 days
REFRESH_TOKEN_EXPIRATION_MS: 30 * 24 * 60 * 60 * 1000,
};
/* eslint-enable */
60 changes: 44 additions & 16 deletions backend/controllers/user.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ const { ObjectId } = require('mongodb');
const EmailController = require('./email.controller');
const { CONFIG_AUTH } = require('../config');

const { User, Project } = require('../models');
const { User, Project, RefreshToken } = require('../models');
const {
generateRefreshToken,
getClientIp,
hashToken,
generateAccessToken,
} = require('../middleware/auth.middleware');

const expectedHeader = process.env.CUSTOM_REQUEST_HEADER;

Expand Down Expand Up @@ -193,17 +199,6 @@ UserController.delete = async function (req, res) {
}
};

function generateAccessToken(user, auth_origin) {
// expires after half and hour (1800 seconds = 30 minutes)
return jwt.sign(
{ id: user.id, role: user.accessLevel, auth_origin: auth_origin },
CONFIG_AUTH.SECRET,
{
expiresIn: `${CONFIG_AUTH.TOKEN_EXPIRATION_SEC}s`,
},
);
}

UserController.createUser = function (req, res) {
const { firstName, lastName, email } = req.body;
const { origin } = req.headers;
Expand Down Expand Up @@ -267,7 +262,22 @@ UserController.verifySignIn = async function (req, res) {
try {
const payload = jwt.verify(token, CONFIG_AUTH.SECRET);
const user = await User.findById(payload.id);
res.cookie('token', token, { httpOnly: true });
const refreshToken = generateRefreshToken();
const accessToken = generateAccessToken(user, payload.auth_origin);
const ipAddress = getClientIp(req);

await RefreshToken.create({
userId: user._id,
hash: hashToken(refreshToken),
deviceInfo: {
deviceType: req.headers['user-agent'],
ipAddress: ipAddress,
},
});

res.cookie('token', accessToken, { httpOnly: true });
res.cookie('refresh_token', refreshToken, { httpOnly: true });

return res.send(user);
} catch (err) {
console.error(err);
Expand All @@ -276,12 +286,30 @@ UserController.verifySignIn = async function (req, res) {
};

UserController.verifyMe = async function (req, res) {
const user = await User.findById(req.userId);
return res.status(200).send(user);
return res.status(200).send(req.user);
};

UserController.logout = async function (req, res) {
return res.clearCookie('token').status(200).send('Successfully logged out.');
try {
await RefreshToken.deleteOne({ _id: req.refreshToken._id });
return res.clearCookie('token').status(200).send('Successfully logged out.');
} catch (err) {
console.error(err);
return res.status(500).send('Error occurred while logging out.');
}
};

UserController.refreshAccessToken = async function (req, res) {
const accessToken = generateAccessToken(req.user, req.auth_origin);
const decoded = jwt.decode(accessToken);

return res
.cookie('token', accessToken, { httpOnly: true })
.status(200)
.json({
user: req.user,
expiresAt: decoded.exp * 1000, // Convert JWT exp (seconds) to milliseconds
});
};

// Update user's managedProjects
Expand Down
171 changes: 153 additions & 18 deletions backend/middleware/auth.middleware.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,156 @@
const jwt = require('jsonwebtoken');
const { CONFIG_AUTH } = require('../config');

function verifyToken(req, res, next) {
// Allow users to set token
// eslint-disable-next-line dot-notation
let token = req.headers['x-access-token'] || req.headers['authorization'];
if (token.startsWith('Bearer ')) {
// Remove Bearer from string
token = token.slice(7, token.length);
const { RefreshToken, User } = require('../models');
const crypto = require('crypto');
const AuthUtils = require('../../shared/authorizationUtils');

const SECRET_KEY = process.env.JWT_SECRET;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid calling these blindly, they will silently fail if they do not exist.


// Utility functions

function generateAccessToken(user, auth_origin) {
return jwt.sign(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there no experation time on the signature of the access token?

{
id: user._id,
email: user.email,
role: user.accessLevel,
accessLevel: user.accessLevel,
auth_origin: auth_origin,
},
SECRET_KEY,
{ expiresIn: CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION },
);
}

function generateRefreshToken() {
return crypto.randomBytes(32).toString('hex');
}

function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}

function getClientIp(req) {
// Check X-Forwarded-For header (most common)
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
// Takes the first IP if there are multiple
return forwarded.split(',')[0].trim();
}
if (!token) {
return res.sendStatus(403);

// Check other common headers
return (
req.headers['x-real-ip'] || req.connection.remoteAddress || req.socket.remoteAddress || req.ip
);
}

async function authenticateAccessToken(req, res, next) {
try {
// Extract token from Authorization header
let accessToken =
req.cookies.token || req.headers['x-access-token'] || req.headers['authorization'];

if (!accessToken) {
return res.status(401).json({ error: 'Access token required' });
}

if (accessToken.startsWith('Bearer ')) {
accessToken = accessToken.slice(7, accessToken.length);
}

const decoded = jwt.verify(accessToken, SECRET_KEY);
// Attach user info to request
req.user = decoded;

next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}

if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}

return res.status(401).json({ error: 'Authentication failed' });
}
}

// shorthand for authenticateAccessToken
const authUser = authenticateAccessToken;

async function authenticateRefreshToken(req, res, next) {
try {
const decoded = jwt.verify(token, CONFIG_AUTH.SECRET);
res.cookie('token', token, { httpOnly: true });
req.userId = decoded.id;
return next();
} catch (err) {
return res.sendStatus(401);
const refreshToken = req.cookies?.refresh_token;

if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}

const tokenHash = hashToken(refreshToken);

const tokenDoc = await RefreshToken.findOne({
hash: tokenHash,
expiresAt: { $gt: new Date() },
});

if (!tokenDoc) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}

const user = await User.findById(tokenDoc.userId);
if (!user) {
return res.status(401).json({ error: 'User not found for this token' });
}

// Attach user & refresh token to request for downstream handlers
req.user = user;
req.refreshToken = tokenDoc;

next();
} catch (error) {
console.error('Refresh token validation error:', error);
return res.status(401).json({ error: 'Authentication failed' });
}
}

function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}

if (!AuthUtils.hasAnyRole(req.user, roles)) {
return res.status(403).json({
error: 'Insufficient permissions',
required_role: roles,
your_role: req.user.accessLevel,
});
}

next();
};
}

function requireMinimumRole(role) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}

const user = req.user;
if (!AuthUtils.hasMinimumRole(user, role)) {
return res.status(403).json({
error: 'Insufficient permissions',
required_minimum_role: role,
your_role: req.user.accessLevel,
});
}
next();
};
}

function verifyCookie(req, res, next) {
jwt.verify(req.cookies.token, CONFIG_AUTH.SECRET, (err, decoded) => {
if (err) {
Expand All @@ -35,8 +163,15 @@ function verifyCookie(req, res, next) {
});
}

const AuthUtil = {
verifyToken,
module.exports = {
authenticateAccessToken,
authUser,
authenticateRefreshToken,
requireRole,
requireMinimumRole,
generateAccessToken,
generateRefreshToken,
getClientIp,
hashToken,
verifyCookie,
};
module.exports = AuthUtil;
6 changes: 2 additions & 4 deletions backend/middleware/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
const AuthUtil = require('./auth.middleware');
const Auth = require('./auth.middleware');
const verifyUser = require('./user.middleware');
const verifyToken = require('./token.middleware');

module.exports = {
AuthUtil,
Auth,
verifyUser,
verifyToken,
};
27 changes: 0 additions & 27 deletions backend/middleware/token.middleware.js

This file was deleted.

4 changes: 3 additions & 1 deletion backend/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ const { Question } = require('./question.model');
const { RecurringEvent } = require('./recurringEvent.model');
const { Role } = require('./role.model');
const { User } = require('./user.model');
const { RefreshToken } = require('./refreshToken.model');

const mongoose = require("mongoose");
const mongoose = require('mongoose');
mongoose.Promise = global.Promise;

module.exports = {
Expand All @@ -19,4 +20,5 @@ module.exports = {
RecurringEvent,
Role,
User,
RefreshToken,
};
Loading