Skip to content

Commit 80c0226

Browse files
committed
Refactored account model to store refresh tokens in their own MongoDB collection
1 parent 0b87372 commit 80c0226

File tree

6 files changed

+73
-69
lines changed

6 files changed

+73
-69
lines changed

_helpers/db.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ mongoose.connect(process.env.MONGODB_URI || config.connectionString, connectionO
55
mongoose.Promise = global.Promise;
66

77
module.exports = {
8-
Account: require('../accounts/account.model'),
8+
Account: require('accounts/account.model'),
9+
RefreshToken: require('accounts/refresh-token.model'),
910
isValidId
1011
};
1112

_helpers/send-email.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const config = require('config.json');
33

44
module.exports = sendEmail;
55

6-
function sendEmail({ to, subject, html, from = config.emailFrom }) {
6+
async function sendEmail({ to, subject, html, from = config.emailFrom }) {
77
const transporter = nodemailer.createTransport(config.smtpOptions);
8-
transporter.sendMail({ from, to, subject, html });
8+
await transporter.sendMail({ from, to, subject, html });
99
}

_middleware/authorize.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function authorize(roles = []) {
1818
// authorize based on user role
1919
async (req, res, next) => {
2020
const account = await db.Account.findById(req.user.id);
21+
const refreshTokens = await db.RefreshToken.find({ account: account.id });
2122

2223
if (!account || (roles.length && !roles.includes(account.role))) {
2324
// account no longer exists or role not authorized
@@ -26,7 +27,7 @@ function authorize(roles = []) {
2627

2728
// authentication and authorization successful
2829
req.user.role = account.role;
29-
req.user.ownsToken = token => !!account.refreshTokens.find(x => x.token === token);
30+
req.user.ownsToken = token => !!refreshTokens.find(x => x.token === token);
3031
next();
3132
}
3233
];

accounts/account.model.js

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,7 @@
11
const mongoose = require('mongoose');
22
const Schema = mongoose.Schema;
33

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({
4+
const schema = new Schema({
235
email: { type: String, unique: true, required: true },
246
passwordHash: { type: String, required: true },
257
title: { type: String, required: true },
@@ -35,15 +17,14 @@ const accountSchema = new Schema({
3517
},
3618
passwordReset: Date,
3719
created: { type: Date, default: Date.now },
38-
updated: Date,
39-
refreshTokens: [refreshTokenSchema]
20+
updated: Date
4021
});
4122

42-
accountSchema.virtual('isVerified').get(function () {
23+
schema.virtual('isVerified').get(function () {
4324
return !!(this.verified || this.passwordReset);
4425
});
4526

46-
accountSchema.set('toJSON', {
27+
schema.set('toJSON', {
4728
virtuals: true,
4829
versionKey: false,
4930
transform: function (doc, ret) {
@@ -53,4 +34,4 @@ accountSchema.set('toJSON', {
5334
}
5435
});
5536

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

accounts/account.service.js

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const crypto = require("crypto");
55
const sendEmail = require('_helpers/send-email');
66
const db = require('_helpers/db');
77
const Role = require('_helpers/role');
8-
const Account = db.Account;
98

109
module.exports = {
1110
authenticate,
@@ -24,19 +23,18 @@ module.exports = {
2423
};
2524

2625
async function authenticate({ email, password, ipAddress }) {
27-
const account = await Account.findOne({ email });
26+
const account = await db.Account.findOne({ email });
2827

2928
if (!account || !account.isVerified || !bcrypt.compareSync(password, account.passwordHash)) {
3029
throw 'Email or password is incorrect';
3130
}
3231

3332
// authentication successful so generate jwt and refresh tokens
3433
const jwtToken = generateJwtToken(account);
35-
const refreshToken = generateRefreshToken(ipAddress);
34+
const refreshToken = generateRefreshToken(account, ipAddress);
3635

3736
// save refresh token
38-
account.refreshTokens.push(refreshToken);
39-
account.save();
37+
await refreshToken.save();
4038

4139
// return basic details and tokens
4240
return {
@@ -47,15 +45,16 @@ async function authenticate({ email, password, ipAddress }) {
4745
}
4846

4947
async function refreshToken({ token, ipAddress }) {
50-
const { account, refreshToken } = await getRefreshToken(token);
48+
const refreshToken = await getRefreshToken(token);
49+
const { account } = refreshToken;
5150

5251
// replace old refresh token with a new one and save
53-
const newRefreshToken = generateRefreshToken(ipAddress);
52+
const newRefreshToken = generateRefreshToken(account, ipAddress);
5453
refreshToken.revoked = Date.now();
5554
refreshToken.revokedByIp = ipAddress;
5655
refreshToken.replacedByToken = newRefreshToken.token;
57-
account.refreshTokens.push(newRefreshToken);
58-
account.save();
56+
await refreshToken.save();
57+
await newRefreshToken.save();
5958

6059
// generate new jwt
6160
const jwtToken = generateJwtToken(account);
@@ -69,26 +68,26 @@ async function refreshToken({ token, ipAddress }) {
6968
}
7069

7170
async function revokeToken({ token, ipAddress }) {
72-
const { account, refreshToken } = await getRefreshToken(token);
71+
const refreshToken = await getRefreshToken(token);
7372

7473
// revoke token and save
7574
refreshToken.revoked = Date.now();
7675
refreshToken.revokedByIp = ipAddress;
77-
account.save()
76+
await refreshToken.save();
7877
}
7978

8079
async function register(params, origin) {
8180
// validate
82-
if (await Account.findOne({ email: params.email })) {
81+
if (await db.Account.findOne({ email: params.email })) {
8382
// send already registered error in email to prevent account enumeration
84-
return sendAlreadyRegisteredEmail(params.email, origin);
83+
return await sendAlreadyRegisteredEmail(params.email, origin);
8584
}
8685

8786
// create account object
88-
const account = new Account(params);
87+
const account = new db.Account(params);
8988

9089
// first registered account is an admin
91-
const isFirstAccount = (await Account.countDocuments({})) === 0;
90+
const isFirstAccount = (await db.Account.countDocuments({})) === 0;
9291
account.role = isFirstAccount ? Role.Admin : Role.User;
9392
account.verificationToken = randomTokenString();
9493

@@ -101,11 +100,11 @@ async function register(params, origin) {
101100
await account.save();
102101

103102
// send email
104-
sendVerificationEmail(account, origin);
103+
await sendVerificationEmail(account, origin);
105104
}
106105

107106
async function verifyEmail({ token }) {
108-
const account = await Account.findOne({ verificationToken: token });
107+
const account = await db.Account.findOne({ verificationToken: token });
109108

110109
if (!account) throw 'Verification failed';
111110

@@ -115,7 +114,7 @@ async function verifyEmail({ token }) {
115114
}
116115

117116
async function forgotPassword({ email }, origin) {
118-
const account = await Account.findOne({ email });
117+
const account = await db.Account.findOne({ email });
119118

120119
// always return ok response to prevent email enumeration
121120
if (!account) return;
@@ -125,14 +124,14 @@ async function forgotPassword({ email }, origin) {
125124
token: randomTokenString(),
126125
expires: new Date(Date.now() + 24*60*60*1000)
127126
};
128-
account.save();
127+
await account.save();
129128

130129
// send email
131-
sendPasswordResetEmail(account, origin);
130+
await sendPasswordResetEmail(account, origin);
132131
}
133132

134133
async function validateResetToken({ token }) {
135-
const account = await Account.findOne({
134+
const account = await db.Account.findOne({
136135
'resetToken.token': token,
137136
'resetToken.expires': { $gt: Date.now() }
138137
});
@@ -141,7 +140,7 @@ async function validateResetToken({ token }) {
141140
}
142141

143142
async function resetPassword({ token, password }) {
144-
const account = await Account.findOne({
143+
const account = await db.Account.findOne({
145144
'resetToken.token': token,
146145
'resetToken.expires': { $gt: Date.now() }
147146
});
@@ -156,7 +155,7 @@ async function resetPassword({ token, password }) {
156155
}
157156

158157
async function getAll() {
159-
const accounts = await Account.find();
158+
const accounts = await db.Account.find();
160159
return accounts.map(x => basicDetails(x));
161160
}
162161

@@ -167,11 +166,11 @@ async function getById(id) {
167166

168167
async function create(params) {
169168
// validate
170-
if (await Account.findOne({ email: params.email })) {
169+
if (await db.Account.findOne({ email: params.email })) {
171170
throw 'Email "' + params.email + '" is already registered';
172171
}
173172

174-
const account = new Account(params);
173+
const account = new db.Account(params);
175174
account.verified = Date.now();
176175

177176
// hash password
@@ -189,7 +188,7 @@ async function update(id, params) {
189188
const account = await getAccount(id);
190189

191190
// validate
192-
if (account.email !== params.email && await Account.findOne({ email: params.email })) {
191+
if (account.email !== params.email && await db.Account.findOne({ email: params.email })) {
193192
throw 'Email "' + params.email + '" is already taken';
194193
}
195194

@@ -215,17 +214,15 @@ async function _delete(id) {
215214

216215
async function getAccount(id) {
217216
if (!db.isValidId(id)) throw 'Account not found';
218-
const account = await Account.findById(id);
217+
const account = await db.Account.findById(id);
219218
if (!account) throw 'Account not found';
220219
return account;
221220
}
222221

223222
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 };
223+
const refreshToken = await db.RefreshToken.findOne({ token }).populate('account');
224+
if (!refreshToken || !refreshToken.isActive) throw 'Invalid token';
225+
return refreshToken;
229226
}
230227

231228
function hash(password) {
@@ -237,13 +234,14 @@ function generateJwtToken(account) {
237234
return jwt.sign({ sub: account.id, id: account.id }, config.secret, { expiresIn: '15m' });
238235
}
239236

240-
function generateRefreshToken(ipAddress) {
237+
function generateRefreshToken(account, ipAddress) {
241238
// create a refresh token that expires in 7 days
242-
return {
239+
return new db.RefreshToken({
240+
account: account.id,
243241
token: randomTokenString(),
244242
expires: new Date(Date.now() + 7*24*60*60*1000),
245243
createdByIp: ipAddress
246-
};
244+
});
247245
}
248246

249247
function randomTokenString() {
@@ -255,7 +253,7 @@ function basicDetails(account) {
255253
return { id, title, firstName, lastName, email, role, created, updated, isVerified };
256254
}
257255

258-
function sendVerificationEmail(account, origin) {
256+
async function sendVerificationEmail(account, origin) {
259257
let message;
260258
if (origin) {
261259
const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`;
@@ -266,7 +264,7 @@ function sendVerificationEmail(account, origin) {
266264
<p><code>${account.verificationToken}</code></p>`;
267265
}
268266

269-
sendEmail({
267+
await sendEmail({
270268
to: account.email,
271269
subject: 'Sign-up Verification API - Verify Email',
272270
html: `<h4>Verify Email</h4>
@@ -275,15 +273,15 @@ function sendVerificationEmail(account, origin) {
275273
});
276274
}
277275

278-
function sendAlreadyRegisteredEmail(email, origin) {
276+
async function sendAlreadyRegisteredEmail(email, origin) {
279277
let message;
280278
if (origin) {
281279
message = `<p>If you don't know your password please visit the <a href="${origin}/account/forgot-password">forgot password</a> page.</p>`;
282280
} else {
283281
message = `<p>If you don't know your password you can reset it via the <code>/account/forgot-password</code> api route.</p>`;
284282
}
285283

286-
sendEmail({
284+
await sendEmail({
287285
to: email,
288286
subject: 'Sign-up Verification API - Email Already Registered',
289287
html: `<h4>Email Already Registered</h4>
@@ -292,7 +290,7 @@ function sendAlreadyRegisteredEmail(email, origin) {
292290
});
293291
}
294292

295-
function sendPasswordResetEmail(account, origin) {
293+
async function sendPasswordResetEmail(account, origin) {
296294
let message;
297295
if (origin) {
298296
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken.token}`;
@@ -303,7 +301,7 @@ function sendPasswordResetEmail(account, origin) {
303301
<p><code>${account.resetToken.token}</code></p>`;
304302
}
305303

306-
sendEmail({
304+
await sendEmail({
307305
to: account.email,
308306
subject: 'Sign-up Verification API - Reset Password',
309307
html: `<h4>Reset Password Email</h4>

accounts/refresh-token.model.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const mongoose = require('mongoose');
2+
const Schema = mongoose.Schema;
3+
4+
const schema = new Schema({
5+
account: { type: Schema.Types.ObjectId, ref: 'Account' },
6+
token: String,
7+
expires: Date,
8+
created: { type: Date, default: Date.now },
9+
createdByIp: String,
10+
revoked: Date,
11+
revokedByIp: String,
12+
replacedByToken: String
13+
});
14+
15+
schema.virtual('isExpired').get(function () {
16+
return Date.now() >= this.expires;
17+
});
18+
19+
schema.virtual('isActive').get(function () {
20+
return !this.revoked && !this.isExpired;
21+
});
22+
23+
module.exports = mongoose.model('RefreshToken', schema);

0 commit comments

Comments
 (0)