diff --git a/11-auth-workflow/final/server/controllers/authController.js b/11-auth-workflow/final/server/controllers/authController.js index d8cf692d50..7e84e53acd 100644 --- a/11-auth-workflow/final/server/controllers/authController.js +++ b/11-auth-workflow/final/server/controllers/authController.js @@ -3,198 +3,224 @@ const Token = require('../models/Token'); const { StatusCodes } = require('http-status-codes'); const CustomError = require('../errors'); const { - attachCookiesToResponse, - createTokenUser, - sendVerificationEmail, - sendResetPasswordEmail, - createHash, + attachCookiesToResponse, + createTokenUser, + sendVerificationEmail, + sendResetPasswordEmail, + createHash, } = require('../utils'); const crypto = require('crypto'); const register = async (req, res) => { - const { email, name, password } = req.body; - - const emailAlreadyExists = await User.findOne({ email }); - if (emailAlreadyExists) { - throw new CustomError.BadRequestError('Email already exists'); - } - - // first registered user is an admin - const isFirstAccount = (await User.countDocuments({})) === 0; - const role = isFirstAccount ? 'admin' : 'user'; - - const verificationToken = crypto.randomBytes(40).toString('hex'); - - const user = await User.create({ - name, - email, - password, - role, - verificationToken, - }); - const origin = 'http://localhost:3000'; - // const newOrigin = 'https://react-node-user-workflow-front-end.netlify.app'; - - // const tempOrigin = req.get('origin'); - // const protocol = req.protocol; - // const host = req.get('host'); - // const forwardedHost = req.get('x-forwarded-host'); - // const forwardedProtocol = req.get('x-forwarded-proto'); - - await sendVerificationEmail({ - name: user.name, - email: user.email, - verificationToken: user.verificationToken, - origin, - }); - // send verification token back only while testing in postman!!! - res.status(StatusCodes.CREATED).json({ - msg: 'Success! Please check your email to verify account', - }); + const { email, name, password } = req.body; + + const emailAlreadyExists = await User.findOne({ email }); + if (emailAlreadyExists) { + throw new CustomError.BadRequestError('Email already exists'); + } + + // first registered user is an admin + const isFirstAccount = (await User.countDocuments({})) === 0; + const role = isFirstAccount ? 'admin' : 'user'; + + const verificationToken = crypto.randomBytes(40).toString('hex'); + + const user = await User.create({ + name, + email, + password, + role, + verificationToken, + }); + const origin = 'http://localhost:3000'; + // const newOrigin = 'https://react-node-user-workflow-front-end.netlify.app'; + + // const tempOrigin = req.get('origin'); + // const protocol = req.protocol; + // const host = req.get('host'); + // const forwardedHost = req.get('x-forwarded-host'); + // const forwardedProtocol = req.get('x-forwarded-proto'); + + await sendVerificationEmail({ + name: user.name, + email: user.email, + verificationToken: user.verificationToken, + origin, + }); + // send verification token back only while testing in postman!!! + res.status(StatusCodes.CREATED).json({ + msg: 'Success! Please check your email to verify account', + }); }; const verifyEmail = async (req, res) => { - const { verificationToken, email } = req.body; - const user = await User.findOne({ email }); + const { verificationToken, email } = req.body; + const user = await User.findOne({ email }); - if (!user) { - throw new CustomError.UnauthenticatedError('Verification Failed'); - } + if (!user) { + throw new CustomError.UnauthenticatedError('Verification Failed'); + } - if (user.verificationToken !== verificationToken) { - throw new CustomError.UnauthenticatedError('Verification Failed'); - } + if (user.verificationToken !== verificationToken) { + throw new CustomError.UnauthenticatedError('Verification Failed'); + } - (user.isVerified = true), (user.verified = Date.now()); - user.verificationToken = ''; + (user.isVerified = true), (user.verified = Date.now()); + user.verificationToken = ''; - await user.save(); + await user.save(); - res.status(StatusCodes.OK).json({ msg: 'Email Verified' }); + res.status(StatusCodes.OK).json({ msg: 'Email Verified' }); }; const login = async (req, res) => { - const { email, password } = req.body; - - if (!email || !password) { - throw new CustomError.BadRequestError('Please provide email and password'); - } - const user = await User.findOne({ email }); - - if (!user) { - throw new CustomError.UnauthenticatedError('Invalid Credentials'); - } - const isPasswordCorrect = await user.comparePassword(password); - - if (!isPasswordCorrect) { - throw new CustomError.UnauthenticatedError('Invalid Credentials'); - } - if (!user.isVerified) { - throw new CustomError.UnauthenticatedError('Please verify your email'); - } - const tokenUser = createTokenUser(user); - - // create refresh token - let refreshToken = ''; - // check for existing token - const existingToken = await Token.findOne({ user: user._id }); - - if (existingToken) { - const { isValid } = existingToken; - if (!isValid) { - throw new CustomError.UnauthenticatedError('Invalid Credentials'); - } - refreshToken = existingToken.refreshToken; - attachCookiesToResponse({ res, user: tokenUser, refreshToken }); - res.status(StatusCodes.OK).json({ user: tokenUser }); - return; - } - - refreshToken = crypto.randomBytes(40).toString('hex'); - const userAgent = req.headers['user-agent']; - const ip = req.ip; - const userToken = { refreshToken, ip, userAgent, user: user._id }; - - await Token.create(userToken); - - attachCookiesToResponse({ res, user: tokenUser, refreshToken }); - - res.status(StatusCodes.OK).json({ user: tokenUser }); + const { email, password } = req.body; + + if (!email || !password) { + throw new CustomError.BadRequestError('Please provide email and password'); + } + const user = await User.findOne({ email }); + + if (!user) { + throw new CustomError.UnauthenticatedError('Invalid Credentials'); + } + const isPasswordCorrect = await user.comparePassword(password); + + if (!isPasswordCorrect) { + throw new CustomError.UnauthenticatedError('Invalid Credentials'); + } + if (!user.isVerified) { + throw new CustomError.UnauthenticatedError('Please verify your email'); + } + const tokenUser = createTokenUser(user); + + // create refresh token + let refreshToken = ''; + // check for existing token + const existingToken = await Token.findOne({ + user: user._id, + userAgent: req.headers['user-agent'], // add userAgent so, different devices will get different token + }); + + if (existingToken) { + const { isValid } = existingToken; + if (!isValid) { + throw new CustomError.UnauthenticatedError('Invalid Credentials'); + } + //since this is login, expired or not dosen't matter, so just renew the exiredIn + existingToken.expiredIn = Date.now() + 1000 * 60 * 60 * 24 * 30; //30days + //also update the refreshToken for safe + existingToken.refreshToken = crypto.randomBytes(40).toString('hex'); + const token = await existingToken.save(); + refreshToken = token.refreshToken; + await attachCookiesToResponse({ + res, + user: tokenUser, + refreshToken, + expiresIn: token.expiredIn, + }); + return res.status(StatusCodes.OK).json({ user: tokenUser }); + } + + refreshToken = crypto.randomBytes(40).toString('hex'); + const userAgent = req.headers['user-agent']; + const ip = req.ip; + const userToken = { + refreshToken, + ip, + userAgent, + user: user._id, + expiredIn: Date.now() + 1000 * 60 * 60 * 24 * 30, + }; + + const token = await Token.create(userToken); + + await attachCookiesToResponse({ + res, + user: tokenUser, + refreshToken, + expiresIn: token.expiredIn, + }); + res.status(StatusCodes.OK).json({ user: tokenUser }); }; + const logout = async (req, res) => { - await Token.findOneAndDelete({ user: req.user.userId }); - - res.cookie('accessToken', 'logout', { - httpOnly: true, - expires: new Date(Date.now()), - }); - res.cookie('refreshToken', 'logout', { - httpOnly: true, - expires: new Date(Date.now()), - }); - res.status(StatusCodes.OK).json({ msg: 'user logged out!' }); + await Token.findOneAndDelete({ + user: req.user.userId, + userAgent: req.headers['user-agent'], + }); + + res.cookie('accessToken', 'logout', { + httpOnly: true, + expires: new Date(Date.now()), + }); + res.cookie('refreshToken', 'logout', { + httpOnly: true, + expires: new Date(Date.now()), + }); + res.status(StatusCodes.OK).json({ msg: 'user logged out!' }); }; const forgotPassword = async (req, res) => { - const { email } = req.body; - if (!email) { - throw new CustomError.BadRequestError('Please provide valid email'); - } - - const user = await User.findOne({ email }); - - if (user) { - const passwordToken = crypto.randomBytes(70).toString('hex'); - // send email - const origin = 'http://localhost:3000'; - await sendResetPasswordEmail({ - name: user.name, - email: user.email, - token: passwordToken, - origin, - }); - - const tenMinutes = 1000 * 60 * 10; - const passwordTokenExpirationDate = new Date(Date.now() + tenMinutes); - - user.passwordToken = createHash(passwordToken); - user.passwordTokenExpirationDate = passwordTokenExpirationDate; - await user.save(); - } - - res - .status(StatusCodes.OK) - .json({ msg: 'Please check your email for reset password link' }); + const { email } = req.body; + if (!email) { + throw new CustomError.BadRequestError('Please provide valid email'); + } + + const user = await User.findOne({ email }); + + if (user) { + const passwordToken = crypto.randomBytes(70).toString('hex'); + // send email + const origin = 'http://localhost:3000'; + await sendResetPasswordEmail({ + name: user.name, + email: user.email, + token: passwordToken, + origin, + }); + + const tenMinutes = 1000 * 60 * 10; + const passwordTokenExpirationDate = new Date(Date.now() + tenMinutes); + + user.passwordToken = createHash(passwordToken); + user.passwordTokenExpirationDate = passwordTokenExpirationDate; + await user.save(); + } + + res + .status(StatusCodes.OK) + .json({ msg: 'Please check your email for reset password link' }); }; const resetPassword = async (req, res) => { - const { token, email, password } = req.body; - if (!token || !email || !password) { - throw new CustomError.BadRequestError('Please provide all values'); - } - const user = await User.findOne({ email }); - - if (user) { - const currentDate = new Date(); - - if ( - user.passwordToken === createHash(token) && - user.passwordTokenExpirationDate > currentDate - ) { - user.password = password; - user.passwordToken = null; - user.passwordTokenExpirationDate = null; - await user.save(); - } - } - - res.send('reset password'); + const { token, email, password } = req.body; + if (!token || !email || !password) { + throw new CustomError.BadRequestError('Please provide all values'); + } + const user = await User.findOne({ email }); + + if (user) { + const currentDate = new Date(); + + if ( + user.passwordToken === createHash(token) && + user.passwordTokenExpirationDate > currentDate + ) { + user.password = password; + user.passwordToken = null; + user.passwordTokenExpirationDate = null; + await user.save(); + } + } + + res.send('reset password'); }; module.exports = { - register, - login, - logout, - verifyEmail, - forgotPassword, - resetPassword, + register, + login, + logout, + verifyEmail, + forgotPassword, + resetPassword, }; diff --git a/11-auth-workflow/final/server/middleware/authentication.js b/11-auth-workflow/final/server/middleware/authentication.js index 2dc4121a21..5c0e78f703 100644 --- a/11-auth-workflow/final/server/middleware/authentication.js +++ b/11-auth-workflow/final/server/middleware/authentication.js @@ -3,50 +3,51 @@ const { isTokenValid } = require('../utils'); const Token = require('../models/Token'); const { attachCookiesToResponse } = require('../utils'); const authenticateUser = async (req, res, next) => { - const { refreshToken, accessToken } = req.signedCookies; + const { refreshToken, accessToken } = req.signedCookies; - try { - if (accessToken) { - const payload = isTokenValid(accessToken); - req.user = payload.user; - return next(); - } - const payload = isTokenValid(refreshToken); + try { + if (accessToken) { + const payload = await isTokenValid(accessToken); + req.user = payload.user; + return next(); + } + const payload = await isTokenValid(refreshToken); - const existingToken = await Token.findOne({ - user: payload.user.userId, - refreshToken: payload.refreshToken, - }); + const existingToken = await Token.findOne({ + user: payload.user.userId, + refreshToken: payload.refreshToken, + }); - if (!existingToken || !existingToken?.isValid) { - throw new CustomError.UnauthenticatedError('Authentication Invalid'); - } + if (!existingToken || !existingToken?.isValid) { + throw new CustomError.UnauthenticatedError('Authentication Invalid'); + } - attachCookiesToResponse({ - res, - user: payload.user, - refreshToken: existingToken.refreshToken, - }); + await attachCookiesToResponse({ + res, + user: payload.user, + refreshToken: existingToken.refreshToken, + expiresIn: existingToken.expiredIn, + }); - req.user = payload.user; - next(); - } catch (error) { - throw new CustomError.UnauthenticatedError('Authentication Invalid'); - } + req.user = payload.user; + next(); + } catch (error) { + throw new CustomError.UnauthenticatedError('Authentication Invalid'); + } }; const authorizePermissions = (...roles) => { - return (req, res, next) => { - if (!roles.includes(req.user.role)) { - throw new CustomError.UnauthorizedError( - 'Unauthorized to access this route' - ); - } - next(); - }; + return (req, res, next) => { + if (!roles.includes(req.user.role)) { + throw new CustomError.UnauthorizedError( + 'Unauthorized to access this route' + ); + } + next(); + }; }; module.exports = { - authenticateUser, - authorizePermissions, + authenticateUser, + authorizePermissions, }; diff --git a/11-auth-workflow/final/server/models/Token.js b/11-auth-workflow/final/server/models/Token.js index afc40f1282..e0484dc729 100644 --- a/11-auth-workflow/final/server/models/Token.js +++ b/11-auth-workflow/final/server/models/Token.js @@ -1,18 +1,19 @@ -const mongoose = require('mongoose'); +const mongoose = require('mongoose') const TokenSchema = new mongoose.Schema( - { - refreshToken: { type: String, required: true }, - ip: { type: String, required: true }, - userAgent: { type: String, required: true }, - isValid: { type: Boolean, default: true }, - user: { - type: mongoose.Types.ObjectId, - ref: 'User', - required: true, - }, - }, - { timestamps: true } -); + { + refreshToken: { type: String, required: true }, + ip: { type: String, required: true }, + userAgent: { type: String, required: true }, + isValid: { type: Boolean, default: true }, + user: { + type: mongoose.Types.ObjectId, + ref: 'User', + required: true, + }, + expiredIn: { type: Date, required: true }, + }, + { timestamps: true } +) -module.exports = mongoose.model('Token', TokenSchema); +module.exports = mongoose.model('Token', TokenSchema) diff --git a/11-auth-workflow/final/server/utils/jwt.js b/11-auth-workflow/final/server/utils/jwt.js index c7c60783d9..79f84cff52 100644 --- a/11-auth-workflow/final/server/utils/jwt.js +++ b/11-auth-workflow/final/server/utils/jwt.js @@ -1,32 +1,50 @@ const jwt = require('jsonwebtoken'); +const { promisify } = require('util'); +const jwtVerify = promisify(jwt.verify); +const jwtSign = promisify(jwt.sign); -const createJWT = ({ payload }) => { - const token = jwt.sign(payload, process.env.JWT_SECRET); - return token; -}; +const createJWT = async ({ payload }) => + await jwtSign(payload, process.env.JWT_SECRET, { + expiresIn: payload.expiresIn, + }); -const isTokenValid = (token) => jwt.verify(token, process.env.JWT_SECRET); +const isTokenValid = async token => + await jwtVerify(token, process.env.JWT_SECRET); -const attachCookiesToResponse = ({ res, user, refreshToken }) => { - const accessTokenJWT = createJWT({ payload: { user } }); - const refreshTokenJWT = createJWT({ payload: { user, refreshToken } }); +const attachCookiesToResponse = async ({ + res, + user, + refreshToken, + expiresIn, +}) => { + const accessTokenJWT = await createJWT({ + payload: { user, expiresIn: 60 * 15 }, + }); //set accessJWT 15 mins valid + const refreshTokenJWT = await createJWT({ + payload: { + user, + refreshToken, + expiresIn: new Date(expiresIn).getTime() - Date.now() - 5 * 60, + // refreshJWT will be expired 5min ahead the expiredTime saved in DB + }, + }); - const oneDay = 1000 * 60 * 60 * 24; - const longerExp = 1000 * 60 * 60 * 24 * 30; + const oneDay = 1000 * 60 * 60 * 24; + const longerExp = 1000 * 60 * 60 * 24 * 30; - res.cookie('accessToken', accessTokenJWT, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - signed: true, - expires: new Date(Date.now() + oneDay), - }); + res.cookie('accessToken', accessTokenJWT, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + signed: true, + expires: new Date(Date.now() + 1000 * 60 * 15), //15min + }); - res.cookie('refreshToken', refreshTokenJWT, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - signed: true, - expires: new Date(Date.now() + longerExp), - }); + res.cookie('refreshToken', refreshTokenJWT, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + signed: true, + expires: new Date(expiresIn - 1000 * 60 * 5), // if use Date.now() as reference, the refreshToken will never be expired, which is a bug, so add an expiredIn attribute in Token Schema, and use that as reference, whenever the refreshToken refresh, it's live can also be refreshed correct + }); }; // const attachSingleCookieToResponse = ({ res, user }) => { // const token = createJWT({ payload: user }); @@ -42,7 +60,7 @@ const attachCookiesToResponse = ({ res, user, refreshToken }) => { // }; module.exports = { - createJWT, - isTokenValid, - attachCookiesToResponse, + createJWT, + isTokenValid, + attachCookiesToResponse, };