Skip to content

Commit

Permalink
feat: Refresh Token for improved Session Security (danny-avila#927)
Browse files Browse the repository at this point in the history
* feat(api): refresh token logic

* feat(client): refresh token logic

* feat(data-provider): refresh token logic

* fix: SSE uses esm

* chore: add default refresh token expiry to AuthService, add message about env var not set when generating a token

* chore: update scripts to more compatible bun methods, ran bun install again

* chore: update env.example and playwright workflow with JWT_REFRESH_SECRET

* chore: update breaking changes docs

* chore: add timeout to url visit

* chore: add default SESSION_EXPIRY in generateToken logic, add act script for testing github actions

* fix(e2e): refresh automatically in development environment to pass e2e tests
danny-avila authored Sep 11, 2023
1 parent 75be9a3 commit 33f087d
Showing 31 changed files with 422 additions and 234 deletions.
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -226,8 +226,10 @@ ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false

# JWT Secrets
JWT_SECRET=secret
JWT_REFRESH_SECRET=secret
# You should use secure values. The examples given are 32-byte keys (64 characters in hex)
# Use this replit to generate some quickly: https://replit.com/@daavila/crypto#index.js
JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418

# Google:
# Add your Google Client ID and Secret here, you must register an app with Google Cloud to get these values
@@ -260,8 +262,10 @@ OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=

# Set the expiration delay for the secure cookie with the JWT token
# Recommend session expiry to be 15 minutes
# Delay is in millisecond e.g. 7 days is 1000*60*60*24*7
SESSION_EXPIRY=(1000 * 60 * 60 * 24) * 7
SESSION_EXPIRY=1000 * 60 * 15
REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7

# Github:
# Get the Client ID and Secret from your Discord Application
1 change: 1 addition & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ jobs:
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
CREDS_KEY: ${{ secrets.CREDS_KEY }}
CREDS_IV: ${{ secrets.CREDS_IV }}
DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}
59 changes: 59 additions & 0 deletions api/models/Session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;

const sessionSchema = mongoose.Schema({
refreshTokenHash: {
type: String,
required: true,
},
expiration: {
type: Date,
required: true,
expires: 0,
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
});

sessionSchema.methods.generateRefreshToken = async function () {
try {
let expiresIn;
if (this.expiration) {
expiresIn = this.expiration.getTime();
} else {
expiresIn = Date.now() + expires;
this.expiration = new Date(expiresIn);
}

const refreshToken = jwt.sign(
{
id: this.user,
},
process.env.JWT_REFRESH_SECRET,
{ expiresIn: Math.floor((expiresIn - Date.now()) / 1000) },
);

const hash = crypto.createHash('sha256');
this.refreshTokenHash = hash.update(refreshToken).digest('hex');

await this.save();

return refreshToken;
} catch (error) {
console.error(
'Error generating refresh token. Have you set a JWT_REFRESH_SECRET in the .env file?\n\n',
error,
);
throw error;
}
};

const Session = mongoose.model('Session', sessionSchema);

module.exports = Session;
26 changes: 3 additions & 23 deletions api/models/User.js
Original file line number Diff line number Diff line change
@@ -4,20 +4,14 @@ const jwt = require('jsonwebtoken');
const Joi = require('joi');
const DebugControl = require('../utils/debug.js');
const userSchema = require('./schema/userSchema.js');
const { SESSION_EXPIRY } = process.env ?? {};
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;

function log({ title, parameters }) {
DebugControl.log.functionName(title);
DebugControl.log.parameters(parameters);
}

//Remove refreshToken from the response
userSchema.set('toJSON', {
transform: function (_doc, ret) {
delete ret.refreshToken;
return ret;
},
});

userSchema.methods.toJSON = function () {
return {
id: this._id,
@@ -43,25 +37,11 @@ userSchema.methods.generateToken = function () {
email: this.email,
},
process.env.JWT_SECRET,
{ expiresIn: eval(process.env.SESSION_EXPIRY) },
{ expiresIn: expires / 1000 },
);
return token;
};

userSchema.methods.generateRefreshToken = function () {
const refreshToken = jwt.sign(
{
id: this._id,
username: this.username,
provider: this.provider,
email: this.email,
},
process.env.JWT_REFRESH_SECRET,
{ expiresIn: eval(process.env.REFRESH_TOKEN_EXPIRY) },
);
return refreshToken;
};

userSchema.methods.comparePassword = function (candidatePassword, callback) {
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
if (err) {
114 changes: 55 additions & 59 deletions api/server/controllers/AuthController.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
const { registerUser, requestPasswordReset, resetPassword } = require('../services/AuthService');

const isProduction = process.env.NODE_ENV === 'production';
const {
registerUser,
requestPasswordReset,
resetPassword,
setAuthTokens,
} = require('../services/AuthService');
const jwt = require('jsonwebtoken');
const Session = require('../../models/Session');
const User = require('../../models/User');
const crypto = require('crypto');
const cookies = require('cookie');

const registrationController = async (req, res) => {
try {
const response = await registerUser(req.body);
if (response.status === 200) {
const { status, user } = response;
const token = user.generateToken();
//send token for automatic login
res.cookie('token', token, {
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
httpOnly: false,
secure: isProduction,
});
let newUser = await User.findOne({ _id: user._id });
if (!newUser) {
newUser = new User(user);
await newUser.save();
}
const token = await setAuthTokens(user._id, res);
res.setHeader('Authorization', `Bearer ${token}`);
res.status(status).send({ user });
} else {
const { status, message } = response;
@@ -61,59 +69,47 @@ const resetPasswordController = async (req, res) => {
}
};

// const refreshController = async (req, res, next) => {
// const { signedCookies = {} } = req;
// const { refreshToken } = signedCookies;
// TODO
// if (refreshToken) {
// try {
// const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
// const userId = payload._id;
// User.findOne({ _id: userId }).then(
// (user) => {
// if (user) {
// // Find the refresh token against the user record in database
// const tokenIndex = user.refreshToken.findIndex(item => item.refreshToken === refreshToken);
const refreshController = async (req, res) => {
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
if (!refreshToken) {
return res.status(200).send('Refresh token not provided');
}

try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const userId = payload.id;
const user = await User.findOne({ _id: userId });
if (!user) {
return res.status(401).send('User not found');
}

if (process.env.NODE_ENV === 'development') {
const token = await setAuthTokens(userId, res);
const userObj = user.toJSON();
return res.status(200).send({ token, user: userObj });
}

// if (tokenIndex === -1) {
// res.statusCode = 401;
// res.send('Unauthorized');
// } else {
// const token = req.user.generateToken();
// // If the refresh token exists, then create new one and replace it.
// const newRefreshToken = req.user.generateRefreshToken();
// user.refreshToken[tokenIndex] = { refreshToken: newRefreshToken };
// user.save((err) => {
// if (err) {
// res.statusCode = 500;
// res.send(err);
// } else {
// // setTokenCookie(res, newRefreshToken);
// const user = req.user.toJSON();
// res.status(200).send({ token, user });
// }
// });
// }
// } else {
// res.statusCode = 401;
// res.send('Unauthorized');
// }
// },
// err => next(err)
// );
// } catch (err) {
// res.statusCode = 401;
// res.send('Unauthorized');
// }
// } else {
// res.statusCode = 401;
// res.send('Unauthorized');
// }
// };
// Hash the refresh token
const hash = crypto.createHash('sha256');
const hashedToken = hash.update(refreshToken).digest('hex');

// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
const userObj = user.toJSON();
res.status(200).send({ token, user: userObj });
} else {
res.status(401).send('Refresh token expired or not found for this user');
}
} catch (err) {
res.status(401).send('Invalid refresh token');
}
};

module.exports = {
getUserController,
// refreshController,
refreshController,
registrationController,
resetPasswordRequestController,
resetPasswordController,
11 changes: 2 additions & 9 deletions api/server/controllers/auth/LoginController.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const User = require('../../../models/User');
const { setAuthTokens } = require('../../services/AuthService');

const loginController = async (req, res) => {
try {
@@ -10,15 +11,7 @@ const loginController = async (req, res) => {
return res.status(400).json({ message: 'Invalid credentials' });
}

const token = req.user.generateToken();
const expires = eval(process.env.SESSION_EXPIRY);

// Add token to cookie
res.cookie('token', token, {
expires: new Date(Date.now() + expires),
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
});
const token = await setAuthTokens(user._id, res);

return res.status(200).send({ token, user });
} catch (err) {
7 changes: 3 additions & 4 deletions api/server/controllers/auth/LogoutController.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
const { logoutUser } = require('../../services/AuthService');
const cookies = require('cookie');

const logoutController = async (req, res) => {
const { signedCookies = {} } = req;
const { refreshToken } = signedCookies;
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
try {
const logout = await logoutUser(req.user, refreshToken);
const logout = await logoutUser(req.user._id, refreshToken);
const { status, message } = logout;
res.clearCookie('token');
res.clearCookie('refreshToken');
return res.status(status).send({ message });
} catch (err) {
4 changes: 2 additions & 2 deletions api/server/routes/auth.js
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ const express = require('express');
const {
resetPasswordRequestController,
resetPasswordController,
// refreshController,
refreshController,
registrationController,
} = require('../controllers/AuthController');
const { loginController } = require('../controllers/auth/LoginController');
@@ -20,7 +20,7 @@ const router = express.Router();
//Local
router.post('/logout', requireJwtAuth, logoutController);
router.post('/login', loginLimiter, requireLocalAuth, loginController);
// router.post('/refresh', requireJwtAuth, refreshController);
router.post('/refresh', refreshController);
router.post('/register', registerLimiter, validateRegistration, registrationController);
router.post('/requestPasswordReset', resetPasswordRequestController);
router.post('/resetPassword', resetPasswordController);
Loading
Oops, something went wrong.

0 comments on commit 33f087d

Please sign in to comment.