Skip to content

Commit 7c7f399

Browse files
committed
initial commit
0 parents  commit 7c7f399

15 files changed

+2215
-0
lines changed

.gitignore

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
6+
# Runtime data
7+
pids
8+
*.pid
9+
*.seed
10+
11+
# Directory for instrumented libs generated by jscoverage/JSCover
12+
lib-cov
13+
14+
# Coverage directory used by tools like istanbul
15+
coverage
16+
17+
# nyc test coverage
18+
.nyc_output
19+
20+
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21+
.grunt
22+
23+
# node-waf configuration
24+
.lock-wscript
25+
26+
# Compiled binary addons (http://nodejs.org/api/addons.html)
27+
build/Release
28+
29+
# Dependency directories
30+
node_modules
31+
jspm_packages
32+
typings
33+
34+
# Optional npm cache directory
35+
.npm
36+
37+
# Optional REPL history
38+
.node_repl_history

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 Jason Watmore
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# node-mongo-signup-verification-api
2+
3+
NodeJS + MongoDB API for Email Sign Up with Verification, Authentication & Forgot Password

_helpers/authorize.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const expressJwt = require('express-jwt');
2+
const { secret } = require('config.json');
3+
const accountService = require('accounts/account.service');
4+
5+
module.exports = authorize;
6+
7+
function authorize(roles = []) {
8+
// roles param can be a single role string (e.g. Role.User or 'User')
9+
// or an array of roles (e.g. [Role.Admin, Role.User] or ['Admin', 'User'])
10+
if (typeof roles === 'string') {
11+
roles = [roles];
12+
}
13+
14+
return [
15+
// authenticate JWT token and attach user to request object (req.user)
16+
expressJwt({ secret }),
17+
18+
// authorize based on user role
19+
async (req, res, next) => {
20+
const account = await accountService.getById(req.user.id);
21+
22+
if (!account || (roles.length && !roles.includes(account.role))) {
23+
// account no longer exists or role not authorized
24+
return res.status(401).json({ message: 'Unauthorized' });
25+
}
26+
27+
// authentication and authorization successful
28+
req.user.role = account.role;
29+
next();
30+
}
31+
];
32+
}

_helpers/db.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const config = require('config.json');
2+
const mongoose = require('mongoose');
3+
const connectionOptions = { useCreateIndex: true, useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false };
4+
mongoose.connect(process.env.MONGODB_URI || config.connectionString, connectionOptions);
5+
mongoose.Promise = global.Promise;
6+
7+
module.exports = {
8+
Account: require('../accounts/account.model')
9+
};

_helpers/error-handler.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module.exports = errorHandler;
2+
3+
function errorHandler(err, req, res, next) {
4+
if (typeof (err) === 'string') {
5+
// custom application error
6+
return res.status(400).json({ message: err });
7+
}
8+
9+
if (err.name === 'ValidationError') {
10+
// mongoose validation error
11+
return res.status(400).json({ message: err.message });
12+
}
13+
14+
if (err.name === 'UnauthorizedError') {
15+
// jwt authentication error
16+
return res.status(401).json({ message: 'Invalid Token' });
17+
}
18+
19+
// default to 500 server error
20+
return res.status(500).json({ message: err.message });
21+
}

_helpers/role.js

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
module.exports = {
2+
Admin: 'Admin',
3+
User: 'User'
4+
}

_helpers/send-email.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const nodemailer = require('nodemailer');
2+
const config = require('config.json');
3+
4+
module.exports = sendEmail;
5+
6+
function sendEmail({ to, subject, html, from = config.emailFrom }) {
7+
const transporter = nodemailer.createTransport(config.smtpOptions);
8+
transporter.sendMail({ from, to, subject, html });
9+
}

accounts/account.model.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const mongoose = require('mongoose');
2+
const Schema = mongoose.Schema;
3+
4+
const schema = new Schema({
5+
email: { type: String, unique: true, required: true },
6+
passwordHash: { type: String, required: true },
7+
title: { type: String, required: true },
8+
firstName: { type: String, required: true },
9+
lastName: { type: String, required: true },
10+
acceptTerms: { type: Boolean },
11+
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 }
18+
});
19+
20+
schema.set('toJSON', {
21+
virtuals: true,
22+
versionKey: false,
23+
transform: function (doc, ret) {
24+
// remove these props when object is serialized
25+
delete ret._id;
26+
delete ret.passwordHash;
27+
}
28+
});
29+
30+
module.exports = mongoose.model('Account', schema);

accounts/account.service.js

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
const config = require('config.json');
2+
const jwt = require('jsonwebtoken');
3+
const bcrypt = require('bcryptjs');
4+
const crypto = require("crypto");
5+
const sendEmail = require('_helpers/send-email');
6+
const db = require('_helpers/db');
7+
const Role = require('_helpers/role');
8+
const Account = db.Account;
9+
10+
module.exports = {
11+
authenticate,
12+
register,
13+
verifyEmail,
14+
forgotPassword,
15+
validateResetToken,
16+
resetPassword,
17+
getAll,
18+
getById,
19+
create,
20+
update,
21+
delete: _delete
22+
};
23+
24+
async function authenticate({ email, password }) {
25+
const account = await Account.findOne({ email, isVerified: true });
26+
if (account && bcrypt.compareSync(password, account.passwordHash)) {
27+
const token = jwt.sign({ sub: account.id, id: account.id }, config.secret);
28+
return {
29+
...account.toJSON(),
30+
token
31+
};
32+
}
33+
}
34+
35+
async function register(params, origin) {
36+
// validate
37+
if (await Account.findOne({ email: params.email })) {
38+
// send already registered notification in email to prevent account enumeration
39+
return sendEmail({
40+
to: params.email,
41+
subject: 'Sign-up Verification API - Email Already Registered',
42+
html: `<h4>Email Already Registered</h4>
43+
<p>Your email <strong>${params.email}</strong> is already registered.</p>
44+
<p>If you don't know your password please visit the <a href="${origin}/account/forgot-password">forgot password</a> page.</p>`
45+
});
46+
}
47+
48+
const account = new Account(params);
49+
50+
// first registered account is an admin
51+
const isFirstAccount = (await Account.countDocuments({})) === 0;
52+
account.role = isFirstAccount ? Role.Admin : Role.User;
53+
account.verificationToken = generateToken();
54+
account.isVerified = false;
55+
56+
// hash password
57+
if (params.password) {
58+
account.passwordHash = hash(params.password);
59+
}
60+
61+
// save account
62+
await account.save();
63+
64+
// send verification email
65+
const verifyUrl = `${origin}/account/verify-email?token=${account.verificationToken}`;
66+
return sendEmail({
67+
to: params.email,
68+
subject: 'Sign-up Verification API - Verify Email',
69+
html: `<h4>Verify Email</h4>
70+
<p>Thanks for registering!</p>
71+
<p>Please click the below link to verify your email address:</p>
72+
<p><a href="${verifyUrl}">${verifyUrl}</a></p>`
73+
});
74+
}
75+
76+
async function verifyEmail({ token }) {
77+
const account = await Account.findOne({ verificationToken: token });
78+
79+
if (!account) throw 'Verification failed';
80+
81+
account.isVerified = true;
82+
await account.save();
83+
}
84+
85+
async function forgotPassword({ email }, origin) {
86+
const account = await Account.findOne({ email });
87+
88+
// always return ok response to prevent email enumeration
89+
if (!account) return;
90+
91+
// create reset token that expires after 24 hours
92+
account.resetToken = generateToken();
93+
account.resetTokenExpiry = new Date(Date.now() + 24*60*60*1000).toISOString();
94+
account.save();
95+
96+
// send password reset email
97+
const resetUrl = `${origin}/account/reset-password?token=${account.resetToken}`;
98+
sendEmail({
99+
to: email,
100+
subject: 'Sign-up Verification API - Reset Password',
101+
html: `<h4>Reset Password Email</h4>
102+
<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
103+
<p><a href="${resetUrl}">${resetUrl}</a></p>`
104+
})
105+
}
106+
107+
async function validateResetToken({ token }) {
108+
const account = await Account.findOne({
109+
resetToken: token,
110+
resetTokenExpiry: { $gt: new Date() }
111+
});
112+
113+
if (!account) throw 'Invalid token';
114+
}
115+
116+
async function resetPassword({ token, password }) {
117+
const account = await Account.findOne({
118+
resetToken: token,
119+
resetTokenExpiry: { $gt: new Date() }
120+
});
121+
122+
if (!account) throw 'Invalid token';
123+
124+
// update password and remove reset token
125+
account.passwordHash = hash(password);
126+
account.isVerified = true;
127+
account.resetToken = undefined;
128+
account.resetTokenExpiry = undefined;
129+
await account.save();
130+
}
131+
132+
async function getAll() {
133+
return await Account.find();
134+
}
135+
136+
async function getById(id) {
137+
return await Account.findById(id);
138+
}
139+
140+
async function create(params) {
141+
// validate
142+
if (await Account.findOne({ email: params.email })) {
143+
throw 'Email "' + params.email + '" is already registered';
144+
}
145+
146+
const account = new Account(params);
147+
account.isVerified = true;
148+
149+
// hash password
150+
if (params.password) {
151+
account.passwordHash = hash(params.password);
152+
}
153+
154+
// save account
155+
await account.save();
156+
}
157+
158+
async function update(id, params) {
159+
const account = await Account.findById(id);
160+
161+
// validate
162+
if (!account) throw 'Account not found';
163+
if (account.email !== params.email && await Account.findOne({ email: params.email })) {
164+
throw 'Email "' + params.email + '" is already taken';
165+
}
166+
167+
// hash password if it was entered
168+
if (params.password) {
169+
params.passwordHash = hash(params.password);
170+
}
171+
172+
// copy params to account and save
173+
Object.assign(account, params);
174+
await account.save();
175+
return account.toJSON();
176+
}
177+
178+
async function _delete(id) {
179+
await Account.findByIdAndRemove(id);
180+
}
181+
182+
// helper functions
183+
184+
function hash(password) {
185+
return bcrypt.hashSync(password, 10);
186+
}
187+
188+
function generateToken() {
189+
return crypto.randomBytes(40).toString('hex');
190+
}

0 commit comments

Comments
 (0)