Skip to content

Commit 0b87372

Browse files
committed
Added support for JWT authentication with refresh tokens
1 parent f663191 commit 0b87372

9 files changed

+348
-65
lines changed

_middleware/authorize.js

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function authorize(roles = []) {
2626

2727
// authentication and authorization successful
2828
req.user.role = account.role;
29+
req.user.ownsToken = token => !!account.refreshTokens.find(x => x.token === token);
2930
next();
3031
}
3132
];

_middleware/error-handler.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function errorHandler(err, req, res, next) {
1212
return res.status(400).json({ message: err.message });
1313
case err.name === 'UnauthorizedError':
1414
// jwt authentication error
15-
return res.status(401).json({ message: 'Invalid Token' });
15+
return res.status(401).json({ message: 'Unauthorized' });
1616
default:
1717
return res.status(500).json({ message: err.message });
1818
}

accounts/account.model.js

+36-10
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,49 @@
11
const mongoose = require('mongoose');
22
const Schema = mongoose.Schema;
33

4-
const schema = new Schema({
4+
const refreshTokenSchema = new Schema({
5+
token: String,
6+
expires: Date,
7+
created: { type: Date, default: Date.now },
8+
createdByIp: String,
9+
revoked: Date,
10+
revokedByIp: String,
11+
replacedByToken: String
12+
});
13+
14+
refreshTokenSchema.virtual('isExpired').get(function () {
15+
return Date.now() >= this.expires;
16+
});
17+
18+
refreshTokenSchema.virtual('isActive').get(function () {
19+
return !this.revoked && !this.isExpired;
20+
});
21+
22+
const accountSchema = new Schema({
523
email: { type: String, unique: true, required: true },
624
passwordHash: { type: String, required: true },
725
title: { type: String, required: true },
826
firstName: { type: String, required: true },
927
lastName: { type: String, required: true },
10-
acceptTerms: { type: Boolean },
28+
acceptTerms: Boolean,
1129
role: { type: String, required: true },
12-
verificationToken: { type: String },
13-
isVerified: { type: Boolean, default: false },
14-
resetToken: { type: String },
15-
resetTokenExpiry: { type: Date },
16-
dateCreated: { type: Date, default: Date.now },
17-
dateUpdated: { type: Date }
30+
verificationToken: String,
31+
verified: Date,
32+
resetToken: {
33+
token: String,
34+
expires: Date
35+
},
36+
passwordReset: Date,
37+
created: { type: Date, default: Date.now },
38+
updated: Date,
39+
refreshTokens: [refreshTokenSchema]
40+
});
41+
42+
accountSchema.virtual('isVerified').get(function () {
43+
return !!(this.verified || this.passwordReset);
1844
});
1945

20-
schema.set('toJSON', {
46+
accountSchema.set('toJSON', {
2147
virtuals: true,
2248
versionKey: false,
2349
transform: function (doc, ret) {
@@ -27,4 +53,4 @@ schema.set('toJSON', {
2753
}
2854
});
2955

30-
module.exports = mongoose.model('Account', schema);
56+
module.exports = mongoose.model('Account', accountSchema);

accounts/account.service.js

+95-25
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const Account = db.Account;
99

1010
module.exports = {
1111
authenticate,
12+
refreshToken,
13+
revokeToken,
1214
register,
1315
verifyEmail,
1416
forgotPassword,
@@ -21,13 +23,58 @@ module.exports = {
2123
delete: _delete
2224
};
2325

24-
async function authenticate({ email, password }) {
25-
const account = await Account.findOne({ email, isVerified: true });
26-
if (account && bcrypt.compareSync(password, account.passwordHash)) {
27-
// return basic details and auth token
28-
const token = jwt.sign({ sub: account.id, id: account.id }, config.secret);
29-
return { ...basicDetails(account), token };
26+
async function authenticate({ email, password, ipAddress }) {
27+
const account = await Account.findOne({ email });
28+
29+
if (!account || !account.isVerified || !bcrypt.compareSync(password, account.passwordHash)) {
30+
throw 'Email or password is incorrect';
3031
}
32+
33+
// authentication successful so generate jwt and refresh tokens
34+
const jwtToken = generateJwtToken(account);
35+
const refreshToken = generateRefreshToken(ipAddress);
36+
37+
// save refresh token
38+
account.refreshTokens.push(refreshToken);
39+
account.save();
40+
41+
// return basic details and tokens
42+
return {
43+
...basicDetails(account),
44+
jwtToken,
45+
refreshToken: refreshToken.token
46+
};
47+
}
48+
49+
async function refreshToken({ token, ipAddress }) {
50+
const { account, refreshToken } = await getRefreshToken(token);
51+
52+
// replace old refresh token with a new one and save
53+
const newRefreshToken = generateRefreshToken(ipAddress);
54+
refreshToken.revoked = Date.now();
55+
refreshToken.revokedByIp = ipAddress;
56+
refreshToken.replacedByToken = newRefreshToken.token;
57+
account.refreshTokens.push(newRefreshToken);
58+
account.save();
59+
60+
// generate new jwt
61+
const jwtToken = generateJwtToken(account);
62+
63+
// return basic details and tokens
64+
return {
65+
...basicDetails(account),
66+
jwtToken,
67+
refreshToken: newRefreshToken.token
68+
};
69+
}
70+
71+
async function revokeToken({ token, ipAddress }) {
72+
const { account, refreshToken } = await getRefreshToken(token);
73+
74+
// revoke token and save
75+
refreshToken.revoked = Date.now();
76+
refreshToken.revokedByIp = ipAddress;
77+
account.save()
3178
}
3279

3380
async function register(params, origin) {
@@ -43,8 +90,7 @@ async function register(params, origin) {
4390
// first registered account is an admin
4491
const isFirstAccount = (await Account.countDocuments({})) === 0;
4592
account.role = isFirstAccount ? Role.Admin : Role.User;
46-
account.verificationToken = generateToken();
47-
account.isVerified = false;
93+
account.verificationToken = randomTokenString();
4894

4995
// hash password
5096
if (params.password) {
@@ -63,7 +109,8 @@ async function verifyEmail({ token }) {
63109

64110
if (!account) throw 'Verification failed';
65111

66-
account.isVerified = true;
112+
account.verified = Date.now();
113+
account.verificationToken = undefined;
67114
await account.save();
68115
}
69116

@@ -74,36 +121,37 @@ async function forgotPassword({ email }, origin) {
74121
if (!account) return;
75122

76123
// create reset token that expires after 24 hours
77-
account.resetToken = generateToken();
78-
account.resetTokenExpiry = new Date(Date.now() + 24*60*60*1000).toISOString();
124+
account.resetToken = {
125+
token: randomTokenString(),
126+
expires: new Date(Date.now() + 24*60*60*1000)
127+
};
79128
account.save();
80129

81130
// send email
82131
sendPasswordResetEmail(account, origin);
83132
}
84133

85134
async function validateResetToken({ token }) {
86-
const account = await Account.findOne({
87-
resetToken: token,
88-
resetTokenExpiry: { $gt: new Date() }
135+
const account = await Account.findOne({
136+
'resetToken.token': token,
137+
'resetToken.expires': { $gt: Date.now() }
89138
});
90139

91140
if (!account) throw 'Invalid token';
92141
}
93142

94143
async function resetPassword({ token, password }) {
95144
const account = await Account.findOne({
96-
resetToken: token,
97-
resetTokenExpiry: { $gt: new Date() }
145+
'resetToken.token': token,
146+
'resetToken.expires': { $gt: Date.now() }
98147
});
99148

100149
if (!account) throw 'Invalid token';
101150

102151
// update password and remove reset token
103152
account.passwordHash = hash(password);
104-
account.isVerified = true;
153+
account.passwordReset = Date.now();
105154
account.resetToken = undefined;
106-
account.resetTokenExpiry = undefined;
107155
await account.save();
108156
}
109157

@@ -124,7 +172,7 @@ async function create(params) {
124172
}
125173

126174
const account = new Account(params);
127-
account.isVerified = true;
175+
account.verified = Date.now();
128176

129177
// hash password
130178
if (params.password) {
@@ -152,7 +200,7 @@ async function update(id, params) {
152200

153201
// copy params to account and save
154202
Object.assign(account, params);
155-
account.dateUpdated = Date.now();
203+
account.updated = Date.now();
156204
await account.save();
157205

158206
return basicDetails(account);
@@ -172,17 +220,39 @@ async function getAccount(id) {
172220
return account;
173221
}
174222

223+
async function getRefreshToken(token) {
224+
const account = await Account.findOne().elemMatch('refreshTokens', { token });
225+
if (!account) throw 'Invalid token';
226+
const refreshToken = account.refreshTokens.find(x => x.token === token);
227+
if (!refreshToken.isActive) throw 'Invalid token';
228+
return { account, refreshToken };
229+
}
230+
175231
function hash(password) {
176232
return bcrypt.hashSync(password, 10);
177233
}
178234

179-
function generateToken() {
235+
function generateJwtToken(account) {
236+
// create a jwt token containing the account id that expires in 15 minutes
237+
return jwt.sign({ sub: account.id, id: account.id }, config.secret, { expiresIn: '15m' });
238+
}
239+
240+
function generateRefreshToken(ipAddress) {
241+
// create a refresh token that expires in 7 days
242+
return {
243+
token: randomTokenString(),
244+
expires: new Date(Date.now() + 7*24*60*60*1000),
245+
createdByIp: ipAddress
246+
};
247+
}
248+
249+
function randomTokenString() {
180250
return crypto.randomBytes(40).toString('hex');
181251
}
182252

183253
function basicDetails(account) {
184-
const { id, title, firstName, lastName, email, role, dateCreated, dateUpdated } = account;
185-
return { id, title, firstName, lastName, email, role, dateCreated, dateUpdated };
254+
const { id, title, firstName, lastName, email, role, created, updated, isVerified } = account;
255+
return { id, title, firstName, lastName, email, role, created, updated, isVerified };
186256
}
187257

188258
function sendVerificationEmail(account, origin) {
@@ -225,12 +295,12 @@ function sendAlreadyRegisteredEmail(email, origin) {
225295
function sendPasswordResetEmail(account, origin) {
226296
let message;
227297
if (origin) {
228-
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken}`;
298+
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken.token}`;
229299
message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
230300
<p><a href="${resetUrl}">${resetUrl}</a></p>`;
231301
} else {
232302
message = `<p>Please use the below token to reset your password with the <code>/account/reset-password</code> api route:</p>
233-
<p><code>${account.resetToken}</code></p>`;
303+
<p><code>${account.resetToken.token}</code></p>`;
234304
}
235305

236306
sendEmail({

0 commit comments

Comments
 (0)