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

allow user choose to use TOTP (Time-based one-time password) #39

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion config/config.env.env
Original file line number Diff line number Diff line change
@@ -18,4 +18,6 @@ SMTP_PORT=2525
SMTP_EMAIL=
SMTP_PASSWORD=
FROM_EMAIL=
FROM_NAME=
FROM_NAME=

SECRET_KEY_OTP=
109 changes: 108 additions & 1 deletion controllers/auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const crypto = require('crypto');
const Speakeasy = require("speakeasy");
const Cryptr = require('cryptr');
const cryptr = new Cryptr(process.env.SECRET_KEY_OTP);
const ErrorResponse = require('../utils/errorResponse');
const asyncHandler = require('../middleware/async');
const sendEmail = require('../utils/sendEmail');
@@ -53,6 +56,10 @@ exports.login = asyncHandler(async (req, res, next) => {
// Check for user
const user = await User.findOne({ email }).select('+password');

if (user.otp) {
return next(new ErrorResponse('Please login using One-time password token', 400));
}

if (!user) {
return next(new ErrorResponse('Invalid credentials', 401));
}
@@ -121,6 +128,11 @@ exports.updateDetails = asyncHandler(async (req, res, next) => {
exports.updatePassword = asyncHandler(async (req, res, next) => {
const user = await User.findById(req.user.id).select('+password');

// prevent user from updating their password if otp is enabled
if (user.otp) {
return next(new ErrorResponse('Account OTP is turned on', 400));
}

// Check current password
if (!(await user.matchPassword(req.body.currentPassword))) {
return next(new ErrorResponse('Password is incorrect', 401));
@@ -142,9 +154,26 @@ exports.forgotPassword = asyncHandler(async (req, res, next) => {
return next(new ErrorResponse('There is no user with that email', 404));
}

if (user.otp) {
// if turned on, generate a new authenticator key and save the user
const secret = Speakeasy.generateSecret({ length: 20 });
user.otpKey = cryptr.encrypt(secret.base32);
await user.save();

// send an email along with the authenticator key
await sendEmail({
email: user.email,
subject: 'Dev Camper New One-Time Password Authenticator Key',
message: `Here is your new authenticator key: ${secret.base32}. On you authenticator app, Please make sure that you choose 'Time-Based' as a type of key.`
});

return res
.status(200)
.json({ sucess: true, data: "Account OTP is enabled. Hence, OTP Authenticator key reset was done instead. Please check your email" });
}

// Get reset token
const resetToken = user.getResetPasswordToken();

await user.save({ validateBeforeSave: false });

// Create reset url
@@ -241,6 +270,84 @@ exports.confirmEmail = asyncHandler(async (req, res, next) => {
sendTokenResponse(user, 200, res);
});


/**
* @desc Toggle OTP
* @route PUT /api/v1/auth/otp
* @access Private
*/
exports.toggleOtp = asyncHandler(async (req, res, next) => {
const user = await User.findById(req.user.id);

// prevent login via otp if email is not confirmed
if (!user.isEmailConfirmed) {
return next(new ErrorResponse('Please confirm your email first', 400));
}

// toggle otp
user.otp = !user.otp

// if turned off
if (!user.otp) {
await user.save();
return res
.cookie('token', 'none', {
expires: new Date(Date.now() + 10 * 1000),
httpOnly: true,
})
.status(200)
.json({ sucess: true, data: "Turned off OTP. Please login again. Setup your password again by using forgot password unless your remembered your old password" })
}

// if turned on, generate an authenticator and save the user
const secret = Speakeasy.generateSecret({ length: 20 });
user.otpKey = cryptr.encrypt(secret.base32);
await user.save();

// send an email along with the authenticator key
await sendEmail({
email: user.email,
subject: 'Dev Camper One-Time Password Activated',
message: `Here is your authenticator key: ${secret.base32}. On you authenticator app, Please make sure that you choose 'Time-Based' as a type of key.`
});

// logout the user
return res
.cookie('token', 'none', {
expires: new Date(Date.now() + 10 * 1000),
httpOnly: true,
})
.status(200)
.json({ sucess: true, data: "Turned on OTP. Please login again" });

});


/**
* @desc Login via OTP
* @route POST /api/v1/auth/otp
* @access Public
*/
exports.loginOtp = asyncHandler(async (req, res, next) => {
const user = await User.findOne({ email: req.body.email }).select('+otpKey');

if (!user) { return next(new ErrorResponse("There is no user with that email", 404)) }
if (!user.otp) { return next(new ErrorResponse("Account OTP is not enabled", 400)) }
if (!req.body.token) { return next(new ErrorResponse("Invalid token", 400)) }

const isVerified = Speakeasy.totp.verify({
secret: cryptr.decrypt(user.otpKey),
token: req.body.token,
encoding: "base32",
window: 0
})

if (!isVerified) { return next(new ErrorResponse("Invalid token", 400)) }

sendTokenResponse(user, 200, res);
});


// Get token from model, create cookie and send response
const sendTokenResponse = (user, statusCode, res) => {
// Create token
14 changes: 8 additions & 6 deletions models/User.js
Original file line number Diff line number Diff line change
@@ -23,6 +23,14 @@ const UserSchema = new mongoose.Schema({
enum: ['user', 'publisher'],
default: 'user',
},
otp: {
type: Boolean,
default: false
},
otpKey: {
type: String,
select: false
},
password: {
type: String,
required: [true, 'Please add a password'],
@@ -36,12 +44,6 @@ const UserSchema = new mongoose.Schema({
type: Boolean,
default: false,
},
twoFactorCode: String,
twoFactorCodeExpire: Date,
twoFactorEnable: {
type: Boolean,
default: false,
},
createdAt: {
type: Date,
default: Date.now,
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@
"colors": "^1.4.0",
"cookie-parser": "^1.4.4",
"cors": "^2.8.5",
"cryptr": "^6.0.2",
"dotenv": "^8.1.0",
"express": "^4.17.1",
"express-fileupload": "^1.2.0",
@@ -28,9 +29,10 @@
"nodemailer": "^6.3.0",
"randomatic": "^3.1.1",
"slugify": "^1.3.5",
"speakeasy": "^2.0.0",
"xss-clean": "^0.1.1"
},
"devDependencies": {
"nodemon": "^1.19.2"
}
}
}
4 changes: 4 additions & 0 deletions routes/auth.js
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ const {
login,
logout,
getMe,
toggleOtp,
loginOtp,
forgotPassword,
resetPassword,
updateDetails,
@@ -24,5 +26,7 @@ router.put('/updatedetails', protect, updateDetails);
router.put('/updatepassword', protect, updatePassword);
router.post('/forgotpassword', forgotPassword);
router.put('/resetpassword/:resettoken', resetPassword);
router.put('/otp', protect, toggleOtp);
router.post('/otp', loginOtp);

module.exports = router;