Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Email verification #78

Merged
merged 24 commits into from
Apr 18, 2022
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d789fde
added basic email transport creation
luwol03 Jan 24, 2022
a20c1a3
added verified column and ejs+mjml
luwol03 Jan 24, 2022
1d71885
added email basic verification system
luwol03 Jan 25, 2022
fb4f04c
moved registration_locked option and added disabled to db migration
luwol03 Jan 26, 2022
9b91c90
added new config options to example config
luwol03 Jan 26, 2022
1969a00
improved email template
luwol03 Jan 27, 2022
a719c89
improved email template
luwol03 Jan 28, 2022
f2bec95
Merge branch 'experimental' into email-verification
luwol03 Jan 28, 2022
84908c5
send also as pure text
luwol03 Jan 28, 2022
8d74eea
improved verification template
luwol03 Jan 28, 2022
9487bb5
fixed typo
luwol03 Jan 28, 2022
32eef00
added dedicated request verification route
luwol03 Jan 29, 2022
7775be0
added logging and username to `to` field
luwol03 Jan 29, 2022
32e7e53
implemented verification level
luwol03 Jan 29, 2022
86fe2cd
improved joi schema
luwol03 Jan 29, 2022
2c2a6a8
Merge branch 'experimental' into email-verification
luwol03 Jan 29, 2022
5a7123c
added api changes to swagger.json
luwol03 Jan 29, 2022
75497fc
fixed schema
luwol03 Feb 4, 2022
db67722
improved example config
luwol03 Feb 11, 2022
85e573d
fixed jwt expiration
luwol03 Feb 11, 2022
a6a5226
Replaced ejs with eta and use layouts
luwol03 Apr 2, 2022
d25677a
Improved logging
luwol03 Apr 2, 2022
4eb68c2
Merge branch 'experimental' into email-verification
luwol03 Apr 17, 2022
1571145
added info message if base_url is not set
luwol03 Apr 17, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions app/Controllers/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ const {
validatePassword,
destroyUser,
changePassword,
sendAccountVerificationEmail,
} = require('../Services/AuthServiceProvider');
const { useInviteCode } = require('../Services/InviteCodeProvider');
const { generateJWT, deleteKeysFromObject } = require('../utils');
const catchAsync = require('../utils/catchAsync');
const { tokenTypes } = require('../utils/constants');
const ApiError = require('../utils/ApiError');

const register = catchAsync(async (req, res) => {
Expand All @@ -21,32 +23,55 @@ const register = catchAsync(async (req, res) => {
throw new ApiError(httpStatus.BAD_REQUEST, 'password complexity failed', 'password');
}

const user = await createUser(req.body);
const token = generateJWT({ id: user.id }, config.server.jwt_secret);
const user = await createUser({
...req.body,
emailVerified: !config.service.email_confirm,
disabled: false,
});

const token = generateJWT(
{
id: user.id,
type: tokenTypes.ACCESS,
},
config.server.jwt_secret,
{ expiresIn: config.service.access_live_time }
);

// after everything is registered redeem the code
if (config.server.registration_locked) {
if (config.service.invite_code) {
await useInviteCode(req.query.inviteCode);
}

if (config.service.email_confirm) {
await sendAccountVerificationEmail({ ...user, email: req.body.email });
}

res.send({ token, user });
});

const login = catchAsync(async (req, res) => {
validateLogin(req, res);

const user = await loginUser(req.body, res);
const user = await loginUser(req.body);

if (user) {
// generate JWT with userId
const token = generateJWT({ id: user.id }, config.server.jwt_secret);
const token = generateJWT(
{
id: user.id,
type: tokenTypes.ACCESS,
},
config.server.jwt_secret,
{ expiresIn: config.service.access_live_time }
);

res.send({ token, user });
}
});

const profile = catchAsync(async (req, res) => {
res.send(deleteKeysFromObject(['roleId', 'password', 'createdAt', 'updatedAt'], req.user.toJSON()));
res.send(deleteKeysFromObject(['roleId', 'email', 'password', 'createdAt', 'updatedAt'], req.user.toJSON()));
});

const deleteUser = catchAsync(async (req, res) => {
Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/InfoController.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const sendInfo = catchAsync(async (_req, res) => {
res.send({
identifier: 'vocascan-server',
version: getVersion(),
locked: config.server.registration_locked,
locked: config.service.invite_code,
commitRef: gitDescribe === undefined ? (gitDescribe = await getGitDescribe()) : gitDescribe,
});
});
Expand Down
89 changes: 89 additions & 0 deletions app/Controllers/VerificationController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const httpStatus = require('http-status');
const config = require('../config/config/index.js');
const ApiError = require('../utils/ApiError.js');
const catchAsync = require('../utils/catchAsync.js');
const { verifyJWT, hashEmail } = require('../utils/index.js');
const { verifyUser, sendAccountVerificationEmail } = require('../Services/AuthServiceProvider.js');
const { tokenTypes } = require('../utils/constants.js');
const logger = require('../config/logger');

const requestEmailVerification = catchAsync(async (req, res) => {
if (!config.service.email_confirm) {
throw new ApiError(httpStatus.NOT_FOUND);
}

if (req.user.emailVerified) {
throw new ApiError(httpStatus.GONE, 'User is already verified');
}

if (hashEmail(req.body.email) !== req.user.email) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Email not valid', 'email');
}

await sendAccountVerificationEmail({ ...req.user.toJSON(), email: req.body.email });

res.status(httpStatus.NO_CONTENT).end();
});

const verifyEmail = catchAsync(async (req, res) => {
const { token } = req.query;
const { base_url: baseUrl } = config.server;

if (!token) {
return res.render('accountVerification', {
status: httpStatus.BAD_REQUEST,
error: 'No verification token provided',
baseUrl,
});
}

let payload = '';

try {
payload = await verifyJWT(token, config.server.jwt_secret);

if (payload.type !== tokenTypes.VERIFY_EMAIL) {
throw new Error();
}
} catch (error) {
return res.render('accountVerification', {
status: httpStatus.UNAUTHORIZED,
error: 'No valid verification token provided',
baseUrl,
});
}

let user = null;

try {
user = await verifyUser({ id: payload.id });
} catch (error) {
if (error instanceof ApiError) {
return res.render('accountVerification', {
status: httpStatus.GONE,
error: 'User is already verified',
baseUrl,
});
}

logger.error(error);

return res.render('accountVerification', {
status: httpStatus.INTERNAL_SERVER_ERROR,
error: 'Internal Server Error',
baseUrl,
});
}

return res.render('accountVerification', {
status: httpStatus.OK,
error: null,
user,
baseUrl,
});
});

module.exports = {
requestEmailVerification,
verifyEmail,
};
82 changes: 52 additions & 30 deletions app/Middleware/ProtectMiddleware.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,64 @@
const { parseTokenUserId } = require('../utils');
const { verifyJWT } = require('../utils');
const { User } = require('../../database');
const ApiError = require('../utils/ApiError.js');
const httpStatus = require('http-status');
const catchAsync = require('../utils/catchAsync');
const config = require('../config/config');
const { tokenTypes } = require('../utils/constants');

// Check for Authorization header and add user attribute to request object
const ProtectMiddleware = catchAsync(async (req, _res, next) => {
// Break if no Authorization header is set
if (!req.header('Authorization')) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Not Authorized');
}

let userId;

try {
// Read userId from token
userId = await parseTokenUserId(req, config.server.jwt_secret);
} catch (err) {
// Handle broken token
throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid auth token');
}

// Get user from database
const user = await User.findOne({
where: {
id: userId,
},
});
const ProtectMiddleware = (emailVerified = true) =>
catchAsync(async (req, _res, next) => {
// Break if no Authorization header is set
if (!req.header('Authorization')) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Not Authorized');
}

let payload = null;

try {
// Read userId from token
const token = req.header('Authorization').split(' ')[1];
payload = await verifyJWT(token, config.server.jwt_secret);

if (payload.type !== tokenTypes.ACCESS) {
throw new Error();
}
} catch (err) {
// Handle broken token
throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid auth token');
}

if (!user) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid auth token');
}
// Get user from database
const user = await User.findOne({
where: {
id: payload.id,
},
});

// Inject user into request object
req.user = user;
if (!user) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Invalid auth token');
}

next();
});
// Inject user into request object
req.user = user;

// check verification level
if (emailVerified && !req.user.emailVerified) {
// allow operations in time range
if (config.service.email_confirm_level === 'medium') {
if (new Date() - req.user.createdAt > config.service.email_confirm_time) {
throw new ApiError(httpStatus.FORBIDDEN, 'Email not verified');
}
}

// dont allow any operations
else if (config.service.email_confirm_level === 'high') {
throw new ApiError(httpStatus.FORBIDDEN, 'Email not verified');
}
}

next();
});

module.exports = ProtectMiddleware;
61 changes: 56 additions & 5 deletions app/Services/AuthServiceProvider.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
const bcrypt = require('bcrypt');
const crypto = require('crypto');

const { deleteKeysFromObject, hashEmail } = require('../utils');
const { deleteKeysFromObject, hashEmail, generateJWT } = require('../utils');
const { User, Role } = require('../../database');
const ApiError = require('../utils/ApiError.js');
const { bytesLength } = require('../utils/index.js');
const httpStatus = require('http-status');
const { validateInviteCode } = require('../Services/InviteCodeProvider.js');
const config = require('../config/config');
const { tokenTypes } = require('../utils/constants');
const { sendMail } = require('../config/mailer');

// Validate inputs from /register and /login route
function validateAuth(req) {
Expand Down Expand Up @@ -38,7 +40,7 @@ const checkIfAdmin = async (id) => {
// Validate inputs from /register route
async function validateRegister(req, res) {
// if server is locked check for invite codes
if (config.server.registration_locked) {
if (config.service.invite_code) {
if (!req.query.inviteCode) {
throw new ApiError(httpStatus.BAD_REQUEST, 'Locked Server! Invite Code is missing');
}
Expand Down Expand Up @@ -104,7 +106,7 @@ function validatePassword(password) {
}

// Create new user and store into database
async function createUser({ username, email, password }) {
async function createUser({ username, email, password, emailVerified, disabled }) {
// Hash password
const hash = await bcrypt.hash(password, config.server.salt_rounds);
const emailHash = hashEmail(email);
Expand All @@ -121,6 +123,8 @@ async function createUser({ username, email, password }) {
email: emailHash,
password: hash,
roleId: role.id,
emailVerified,
disabled,
});

// add flag if user is admin
Expand All @@ -136,7 +140,6 @@ async function loginUser({ email, password }) {
const emailHash = hashEmail(email);

const user = await User.findOne({
attributes: ['id', 'username', 'password'],
where: {
email: emailHash,
},
Expand All @@ -156,7 +159,7 @@ async function loginUser({ email, password }) {
const isAdmin = await checkIfAdmin(user.id);
const tempUser = { ...user.toJSON(), isAdmin };

return deleteKeysFromObject(['roleId', 'password', 'createdAt', 'updatedAt'], tempUser);
return deleteKeysFromObject(['roleId', 'email', 'password', 'createdAt', 'updatedAt'], tempUser);
}

async function destroyUser(userId) {
Expand Down Expand Up @@ -212,6 +215,52 @@ async function changePassword(id, oldPassword, newPassword) {
return true;
}

const sendAccountVerificationEmail = async (user) => {
const token = generateJWT(
{
id: user.id,
type: tokenTypes.VERIFY_EMAIL,
},
config.server.jwt_secret,
{ expiresIn: config.service.email_confirm_live_time }
);

const text = `Hello ${user.username},
you have recently registered an account on ${config.server.base_url}.
Please verify your Email address by clicking on the link below.
${config.server.base_url}/p/verifyEmail?token=${token}`;

await sendMail({
to: `"${user.username}" <${user.email}>`,
subject: 'Account Verification',
template: 'accountVerification.ejs',
text,
ctx: {
user,
token,
baseUrl: config.server.base_url,
},
});
};

const verifyUser = async ({ id }) => {
const user = await User.findOne({
where: {
id,
},
});

if (user.emailVerified) {
throw new ApiError(httpStatus.GONE, 'User is already verified');
}

user.emailVerified = true;

await user.save();

return user;
};

module.exports = {
createUser,
loginUser,
Expand All @@ -222,4 +271,6 @@ module.exports = {
changePassword,
checkPasswordValid,
checkIfAdmin,
sendAccountVerificationEmail,
verifyUser,
};
Loading