Skip to content

Commit 33f087d

Browse files
authored
feat: Refresh Token for improved Session Security (#927)
* 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
1 parent 75be9a3 commit 33f087d

31 files changed

+422
-234
lines changed

.env.example

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,8 +226,10 @@ ALLOW_SOCIAL_LOGIN=false
226226
ALLOW_SOCIAL_REGISTRATION=false
227227

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

232234
# Google:
233235
# 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=
260262
OPENID_IMAGE_URL=
261263

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

266270
# Github:
267271
# Get the Client ID and Secret from your Discord Application

.github/workflows/playwright.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jobs:
2727
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
2828
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
2929
JWT_SECRET: ${{ secrets.JWT_SECRET }}
30+
JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }}
3031
CREDS_KEY: ${{ secrets.CREDS_KEY }}
3132
CREDS_IV: ${{ secrets.CREDS_IV }}
3233
DOMAIN_CLIENT: ${{ secrets.DOMAIN_CLIENT }}

api/models/Session.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const mongoose = require('mongoose');
2+
const crypto = require('crypto');
3+
const jwt = require('jsonwebtoken');
4+
const { REFRESH_TOKEN_EXPIRY } = process.env ?? {};
5+
const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7;
6+
7+
const sessionSchema = mongoose.Schema({
8+
refreshTokenHash: {
9+
type: String,
10+
required: true,
11+
},
12+
expiration: {
13+
type: Date,
14+
required: true,
15+
expires: 0,
16+
},
17+
user: {
18+
type: mongoose.Schema.Types.ObjectId,
19+
ref: 'User',
20+
required: true,
21+
},
22+
});
23+
24+
sessionSchema.methods.generateRefreshToken = async function () {
25+
try {
26+
let expiresIn;
27+
if (this.expiration) {
28+
expiresIn = this.expiration.getTime();
29+
} else {
30+
expiresIn = Date.now() + expires;
31+
this.expiration = new Date(expiresIn);
32+
}
33+
34+
const refreshToken = jwt.sign(
35+
{
36+
id: this.user,
37+
},
38+
process.env.JWT_REFRESH_SECRET,
39+
{ expiresIn: Math.floor((expiresIn - Date.now()) / 1000) },
40+
);
41+
42+
const hash = crypto.createHash('sha256');
43+
this.refreshTokenHash = hash.update(refreshToken).digest('hex');
44+
45+
await this.save();
46+
47+
return refreshToken;
48+
} catch (error) {
49+
console.error(
50+
'Error generating refresh token. Have you set a JWT_REFRESH_SECRET in the .env file?\n\n',
51+
error,
52+
);
53+
throw error;
54+
}
55+
};
56+
57+
const Session = mongoose.model('Session', sessionSchema);
58+
59+
module.exports = Session;

api/models/User.js

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,14 @@ const jwt = require('jsonwebtoken');
44
const Joi = require('joi');
55
const DebugControl = require('../utils/debug.js');
66
const userSchema = require('./schema/userSchema.js');
7+
const { SESSION_EXPIRY } = process.env ?? {};
8+
const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15;
79

810
function log({ title, parameters }) {
911
DebugControl.log.functionName(title);
1012
DebugControl.log.parameters(parameters);
1113
}
1214

13-
//Remove refreshToken from the response
14-
userSchema.set('toJSON', {
15-
transform: function (_doc, ret) {
16-
delete ret.refreshToken;
17-
return ret;
18-
},
19-
});
20-
2115
userSchema.methods.toJSON = function () {
2216
return {
2317
id: this._id,
@@ -43,25 +37,11 @@ userSchema.methods.generateToken = function () {
4337
email: this.email,
4438
},
4539
process.env.JWT_SECRET,
46-
{ expiresIn: eval(process.env.SESSION_EXPIRY) },
40+
{ expiresIn: expires / 1000 },
4741
);
4842
return token;
4943
};
5044

51-
userSchema.methods.generateRefreshToken = function () {
52-
const refreshToken = jwt.sign(
53-
{
54-
id: this._id,
55-
username: this.username,
56-
provider: this.provider,
57-
email: this.email,
58-
},
59-
process.env.JWT_REFRESH_SECRET,
60-
{ expiresIn: eval(process.env.REFRESH_TOKEN_EXPIRY) },
61-
);
62-
return refreshToken;
63-
};
64-
6545
userSchema.methods.comparePassword = function (candidatePassword, callback) {
6646
bcrypt.compare(candidatePassword, this.password, (err, isMatch) => {
6747
if (err) {

api/server/controllers/AuthController.js

Lines changed: 55 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
const { registerUser, requestPasswordReset, resetPassword } = require('../services/AuthService');
2-
3-
const isProduction = process.env.NODE_ENV === 'production';
1+
const {
2+
registerUser,
3+
requestPasswordReset,
4+
resetPassword,
5+
setAuthTokens,
6+
} = require('../services/AuthService');
7+
const jwt = require('jsonwebtoken');
8+
const Session = require('../../models/Session');
9+
const User = require('../../models/User');
10+
const crypto = require('crypto');
11+
const cookies = require('cookie');
412

513
const registrationController = async (req, res) => {
614
try {
715
const response = await registerUser(req.body);
816
if (response.status === 200) {
917
const { status, user } = response;
10-
const token = user.generateToken();
11-
//send token for automatic login
12-
res.cookie('token', token, {
13-
expires: new Date(Date.now() + eval(process.env.SESSION_EXPIRY)),
14-
httpOnly: false,
15-
secure: isProduction,
16-
});
18+
let newUser = await User.findOne({ _id: user._id });
19+
if (!newUser) {
20+
newUser = new User(user);
21+
await newUser.save();
22+
}
23+
const token = await setAuthTokens(user._id, res);
24+
res.setHeader('Authorization', `Bearer ${token}`);
1725
res.status(status).send({ user });
1826
} else {
1927
const { status, message } = response;
@@ -61,59 +69,47 @@ const resetPasswordController = async (req, res) => {
6169
}
6270
};
6371

64-
// const refreshController = async (req, res, next) => {
65-
// const { signedCookies = {} } = req;
66-
// const { refreshToken } = signedCookies;
67-
// TODO
68-
// if (refreshToken) {
69-
// try {
70-
// const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
71-
// const userId = payload._id;
72-
// User.findOne({ _id: userId }).then(
73-
// (user) => {
74-
// if (user) {
75-
// // Find the refresh token against the user record in database
76-
// const tokenIndex = user.refreshToken.findIndex(item => item.refreshToken === refreshToken);
72+
const refreshController = async (req, res) => {
73+
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
74+
if (!refreshToken) {
75+
return res.status(200).send('Refresh token not provided');
76+
}
77+
78+
try {
79+
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
80+
const userId = payload.id;
81+
const user = await User.findOne({ _id: userId });
82+
if (!user) {
83+
return res.status(401).send('User not found');
84+
}
85+
86+
if (process.env.NODE_ENV === 'development') {
87+
const token = await setAuthTokens(userId, res);
88+
const userObj = user.toJSON();
89+
return res.status(200).send({ token, user: userObj });
90+
}
7791

78-
// if (tokenIndex === -1) {
79-
// res.statusCode = 401;
80-
// res.send('Unauthorized');
81-
// } else {
82-
// const token = req.user.generateToken();
83-
// // If the refresh token exists, then create new one and replace it.
84-
// const newRefreshToken = req.user.generateRefreshToken();
85-
// user.refreshToken[tokenIndex] = { refreshToken: newRefreshToken };
86-
// user.save((err) => {
87-
// if (err) {
88-
// res.statusCode = 500;
89-
// res.send(err);
90-
// } else {
91-
// // setTokenCookie(res, newRefreshToken);
92-
// const user = req.user.toJSON();
93-
// res.status(200).send({ token, user });
94-
// }
95-
// });
96-
// }
97-
// } else {
98-
// res.statusCode = 401;
99-
// res.send('Unauthorized');
100-
// }
101-
// },
102-
// err => next(err)
103-
// );
104-
// } catch (err) {
105-
// res.statusCode = 401;
106-
// res.send('Unauthorized');
107-
// }
108-
// } else {
109-
// res.statusCode = 401;
110-
// res.send('Unauthorized');
111-
// }
112-
// };
92+
// Hash the refresh token
93+
const hash = crypto.createHash('sha256');
94+
const hashedToken = hash.update(refreshToken).digest('hex');
95+
96+
// Find the session with the hashed refresh token
97+
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
98+
if (session && session.expiration > new Date()) {
99+
const token = await setAuthTokens(userId, res, session._id);
100+
const userObj = user.toJSON();
101+
res.status(200).send({ token, user: userObj });
102+
} else {
103+
res.status(401).send('Refresh token expired or not found for this user');
104+
}
105+
} catch (err) {
106+
res.status(401).send('Invalid refresh token');
107+
}
108+
};
113109

114110
module.exports = {
115111
getUserController,
116-
// refreshController,
112+
refreshController,
117113
registrationController,
118114
resetPasswordRequestController,
119115
resetPasswordController,

api/server/controllers/auth/LoginController.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const User = require('../../../models/User');
2+
const { setAuthTokens } = require('../../services/AuthService');
23

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

13-
const token = req.user.generateToken();
14-
const expires = eval(process.env.SESSION_EXPIRY);
15-
16-
// Add token to cookie
17-
res.cookie('token', token, {
18-
expires: new Date(Date.now() + expires),
19-
httpOnly: false,
20-
secure: process.env.NODE_ENV === 'production',
21-
});
14+
const token = await setAuthTokens(user._id, res);
2215

2316
return res.status(200).send({ token, user });
2417
} catch (err) {

api/server/controllers/auth/LogoutController.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
const { logoutUser } = require('../../services/AuthService');
2+
const cookies = require('cookie');
23

34
const logoutController = async (req, res) => {
4-
const { signedCookies = {} } = req;
5-
const { refreshToken } = signedCookies;
5+
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
66
try {
7-
const logout = await logoutUser(req.user, refreshToken);
7+
const logout = await logoutUser(req.user._id, refreshToken);
88
const { status, message } = logout;
9-
res.clearCookie('token');
109
res.clearCookie('refreshToken');
1110
return res.status(status).send({ message });
1211
} catch (err) {

api/server/routes/auth.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const express = require('express');
22
const {
33
resetPasswordRequestController,
44
resetPasswordController,
5-
// refreshController,
5+
refreshController,
66
registrationController,
77
} = require('../controllers/AuthController');
88
const { loginController } = require('../controllers/auth/LoginController');
@@ -20,7 +20,7 @@ const router = express.Router();
2020
//Local
2121
router.post('/logout', requireJwtAuth, logoutController);
2222
router.post('/login', loginLimiter, requireLocalAuth, loginController);
23-
// router.post('/refresh', requireJwtAuth, refreshController);
23+
router.post('/refresh', refreshController);
2424
router.post('/register', registerLimiter, validateRegistration, registrationController);
2525
router.post('/requestPasswordReset', resetPasswordRequestController);
2626
router.post('/resetPassword', resetPasswordController);

0 commit comments

Comments
 (0)