diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..c4a9c8d8 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,268 @@ +import 'dotenv/config'; +import { normalizeUser } from '../dto/user.dto.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; +import { jwt } from '../utils/jwt.util.js'; +import { mailer } from '../utils/mailer.js'; +import { passwordUtils } from '../utils/password.util.js'; +import { authService } from '../services/auth.service.js'; +import { ApiError } from '../exeptions/api.error.js'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const register = async (req, res) => { + const { email, password, name } = req?.body || {}; + + const errors = { + email: userService.validateEmail(email), + password: userService.validatePassword(password), + name: userService.validateUserName(name), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + const existingUser = await userService.getUserByEmail(email); + + if (existingUser) { + throw ApiError.badRequest('Validation error', { + email: 'Email is already taken', + }); + } + + const hashedPassword = await passwordUtils.getHashedPassword(password); + const activationToken = authService.generateActivationToken(); + + const user = await userService.createUser({ + email, + password: hashedPassword, + name, + activationToken, + }); + + await mailer.sendActivationLink(email, activationToken); + + res.status(201).send(normalizeUser(user)); +}; + +const sendAuthentication = async (res, user) => { + const userData = normalizeUser(user); + const accessToken = jwt.generateAccessToken(userData); + const refreshToken = jwt.generateRefreshToken(userData); + + await tokenService.deleteByUserId(userData.id); + await tokenService.create({ userId: userData.id, refreshToken }); + + res.cookie('refreshToken', refreshToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'none', + secure: isProduction, + }); + + res.send({ + user: userData, + accessToken, + }); +}; + +const activate = async (req, res) => { + const { email, activationToken } = req.params; + + const user = await userService.getUserByEmail(email); + + if (!user) { + throw ApiError.notFound('No such email exists'); + } else if (user.activationToken === null) { + throw ApiError.badRequest('User account already active'); + } else if (user.activationToken !== activationToken) { + throw ApiError.badRequest('Incorrect activation link'); + } + + const activatedUser = await userService.activate(email); + + await sendAuthentication(res, activatedUser); +}; + +const login = async (req, res) => { + const { email, password } = req.body || {}; + + const user = await userService.getUserByEmail(email); + const isPasswordCorrect = await passwordUtils.comparePassword({ + hashedPassword: user?.password || '', + password, + }); + + if (!user || !isPasswordCorrect) { + throw ApiError.unauthorized('Invalid credentials'); + } + + if (user.activationToken) { + throw ApiError.forbidden(); + } + + await sendAuthentication(res, user); +}; + +const resendActivationLink = async (req, res) => { + const { email } = req.body || {}; + + const errors = { + email: userService.validateEmail(email), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('User account already active', errors); + } + + const user = await userService.getUserByEmail(email); + + if (!user) { + throw ApiError.notFound('Validation error', { + email: 'No such user with this email', + }); + } + + if (!user.activationToken) { + throw ApiError.badRequest('User is already active'); + } + + const isResendAvaliable = authService.canResendActivation(user); + + if (!isResendAvaliable) { + throw ApiError.tooManyRequests(); + } + + const activationToken = authService.generateActivationToken(); + await userService.updateUser(user.id, { + activationToken, + lastActivationEmailSentAt: new Date(), + }); + + await mailer.sendActivationLink(email, activationToken); + + res.status(200).send({ + message: 'The email was sent', + }); +}; + +const refresh = async (req, res) => { + const { refreshToken = null } = req?.cookies || {}; + const userDataFromToken = jwt.validateRefreshToken(refreshToken); + const user = await userService.getUserById(userDataFromToken?.id); + const token = await tokenService.getByToken(refreshToken); + + if (!userDataFromToken || !user || !token || user.id !== token.userId) { + res.clearCookie('refreshToken'); + + throw ApiError.unauthorized(); + } + + await sendAuthentication(res, user); +}; + +const logout = async (req, res) => { + const { refreshToken = null } = req?.cookies || {}; + + if (refreshToken) { + await tokenService.deleteByToken(refreshToken); + } + + res.clearCookie('refreshToken'); + res.sendStatus(204); +}; + +const passwordResetRequest = async (req, res) => { + const { email } = req?.body || {}; + + const errors = { + email: userService.validateEmail(email), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + const user = await userService.getUserByEmail(email); + + if (!user) { + return res.status(200).send({ + message: 'If the email exists, reset instructions were sent.', + }); + } + + const resetPasswordToken = authService.generateActivationToken(); + await userService.updateUser(user.id, { + resetPasswordToken, + resetPasswordExpiresAt: authService.getPasswordExpiresAt(), + }); + + await mailer.sendResetPasswordLink(email, resetPasswordToken); + + res.status(200).send({ + message: 'If the email exists, reset instructions were sent.', + }); +}; + +const passwordReset = async (req, res) => { + const { token, newPassword, confirmPassword } = req.body || {}; + + const errors = { + password: userService.validatePassword(newPassword), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + if (newPassword !== confirmPassword) { + throw ApiError.badRequest('Validation error', { + passwords: 'Passwords do not match', + }); + } + + const user = await userService.getUserByResetPasswordToken(token); + + if (!user) { + throw ApiError.badRequest('Invalid or expired token'); + } + + if ( + token !== user.resetPasswordToken || + user.resetPasswordExpiresAt < Date.now() + ) { + throw ApiError.badRequest('Invalid or expired token'); + } + + const hashedPassword = await passwordUtils.getHashedPassword(newPassword); + + await userService.updateUser(user.id, { + password: hashedPassword, + resetPasswordToken: null, + resetPasswordExpiresAt: null, + passwordChangedAt: new Date(), + }); + await tokenService.deleteByUserId(user.id); + + res.sendStatus(204); +}; + +export const authController = { + register, + activate, + login, + resendActivationLink, + refresh, + logout, + passwordResetRequest, + passwordReset, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..22021d27 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,145 @@ +import { normalizeUser } from '../dto/user.dto.js'; +import { ApiError } from '../exeptions/api.error.js'; +import { authService } from '../services/auth.service.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; +import { mailer } from '../utils/mailer.js'; +import { passwordUtils } from '../utils/password.util.js'; + +const getAllActiveUsers = async (req, res) => { + const activeUsers = await userService.getActiveUsers(); + + res.status(200).send(activeUsers.map(normalizeUser)); +}; + +const updateUser = async (req, res) => { + const { id } = req.params; + const { name } = req?.body || {}; + + const errors = { + name: userService.validateUserName(name), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + const user = await userService.getUserById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const updatedUser = await userService.updateUser(user.id, { name }); + + res.send(normalizeUser(updatedUser)); +}; + +export const changePassword = async (req, res) => { + const { id } = req.params; + const { oldPassword, newPassword, confirmPassword } = req?.body || {}; + + const errors = { + newPassword: userService.validatePassword(newPassword), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + const user = await userService.getUserById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const isPasswordValid = await passwordUtils.comparePassword({ + hashedPassword: user.password, + password: oldPassword, + }); + + if (!isPasswordValid) { + throw ApiError.badRequest('Validation error', { + oldPassword: 'The password is wrong', + }); + } + + if (newPassword !== confirmPassword) { + throw ApiError.badRequest('Validation error', { + passwords: 'Passwords do not match', + }); + } + + const hashedPassword = await passwordUtils.getHashedPassword(newPassword); + await userService.updateUser(user.id, { + password: hashedPassword, + passwordChangedAt: new Date(), + }); + await tokenService.deleteByUserId(user.id); + + res.sendStatus(204); +}; + +export const changeEmail = async (req, res) => { + const { id } = req.params; + const { password, newEmail } = req?.body || {}; + + const errors = { + password: userService.validatePassword(password), + newEmail: userService.validateEmail(newEmail), + }; + + const isErrorExists = Object.values(errors).some(Boolean); + + if (isErrorExists) { + throw ApiError.badRequest('Validation error', errors); + } + + const existingUser = await userService.getUserByEmail(newEmail); + + if (existingUser) { + throw ApiError.badRequest('This email is already taken'); + } + + const user = await userService.getUserById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const isPasswordValid = await passwordUtils.comparePassword({ + hashedPassword: user.password, + password, + }); + + if (!isPasswordValid) { + throw ApiError.badRequest('Validation error', { + password: 'The password is wrong', + }); + } + + const activationToken = await authService.generateActivationToken(); + await userService.updateUser(user.id, { + activationToken, + email: newEmail, + }); + await tokenService.deleteByUserId(user.id); + await mailer.sendActivationLink(newEmail, activationToken); + await mailer.confirmationOfEmailChange(user.email, newEmail); + + res.status(200).send({ + message: + 'Email has been changed. Activate new email. The letter has been sent to the new email.', + }); +}; + +export const userController = { + getAllActiveUsers, + updateUser, + changePassword, + changeEmail, +}; diff --git a/src/dto/user.dto.js b/src/dto/user.dto.js new file mode 100644 index 00000000..8eb928ef --- /dev/null +++ b/src/dto/user.dto.js @@ -0,0 +1,6 @@ +export const normalizeUser = ({ id, name, email, activationToken }) => ({ + id, + name, + email, + isActive: !Boolean(activationToken), +}); diff --git a/src/exeptions/api.error.js b/src/exeptions/api.error.js new file mode 100644 index 00000000..f0e298ea --- /dev/null +++ b/src/exeptions/api.error.js @@ -0,0 +1,56 @@ +const DEFAULT_ERRORS = { + BAD_REQUEST: 'Bad request', + UNAUTHORIZED: 'Unauthorized user', + FORBIDDEN: 'Forbidden', + NOT_FOUND: 'Not found', + TOO_MANY_REQUESTS: 'Too many requests. Try again later.', +}; + +export class ApiError extends Error { + constructor({ message, status, errors = {} }) { + super(message); + + this.status = status; + this.errors = errors; + } + + static badRequest(message = DEFAULT_ERRORS.BAD_REQUEST, errors) { + return new ApiError({ + message, + errors, + status: 400, + }); + } + + static unauthorized(message = DEFAULT_ERRORS.UNAUTHORIZED, errors) { + return new ApiError({ + message, + errors, + status: 401, + }); + } + + static forbidden(message = DEFAULT_ERRORS.FORBIDDEN, errors) { + return new ApiError({ + message, + errors, + status: 403, + }); + } + + static notFound(message = DEFAULT_ERRORS.NOT_FOUND, errors) { + return new ApiError({ + message, + errors, + status: 404, + }); + } + + static tooManyRequests(message = DEFAULT_ERRORS.TOO_MANY_REQUESTS, errors) { + return new ApiError({ + message, + errors, + status: 429, + }); + } +} diff --git a/src/index.js b/src/index.js index ad9a93a7..2c9610dc 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,32 @@ -'use strict'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; + +import { authRouter } from './routers/auth.router.js'; +import { userRouter } from './routers/user.router.js'; +import { notFoundMiddleware } from './middlewares/notFoundMiddleware.js'; +import { errorMiddleware } from './middlewares/errorMiddleware.js'; + +const PORT = process.env.PORT || 3000; + +const createServer = () => { + const app = express(); + + app.use(cors()); + app.use(express.json()); + app.use(cookieParser()); + + app.use('/auth', authRouter); + app.use('/users', userRouter); + + app.use(notFoundMiddleware); + app.use(errorMiddleware); + + app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server is running on port ${PORT}`); + }); +}; + +createServer(); diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js new file mode 100644 index 00000000..a52ed8e5 --- /dev/null +++ b/src/middlewares/authMiddleware.js @@ -0,0 +1,40 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { userService } from '../services/user.service.js'; +import { jwt } from '../utils/jwt.util.js'; + +export const authMiddleware = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw ApiError.unauthorized(); + } + + const accessToken = authHeader.split(' ')[1]; + const userData = jwt.validateAccessToken(accessToken); + const user = await userService.getUserById(userData?.id); + + if (!user) { + throw ApiError.unauthorized('Invalid token'); + } + + if (user.passwordChangedAt) { + const passwordChangedAtInSeconds = Math.floor( + new Date(user.passwordChangedAt).getTime() / 1000, + ); + + if (passwordChangedAtInSeconds > userData.iat) { + throw ApiError.unauthorized('Token invalid. Password was changed'); + } + } + + req.user = userData; + next(); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw ApiError.unauthorized('Invalid or expired token'); + } +}; diff --git a/src/middlewares/errorMiddleware.js b/src/middlewares/errorMiddleware.js new file mode 100644 index 00000000..835172c3 --- /dev/null +++ b/src/middlewares/errorMiddleware.js @@ -0,0 +1,12 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const errorMiddleware = (error, req, res) => { + if (error instanceof ApiError) { + res.status(error.status).send({ + message: error.message, + errors: error.errors, + }); + } else { + res.status(500).send({ message: 'Server error' }); + } +}; diff --git a/src/middlewares/notFoundMiddleware.js b/src/middlewares/notFoundMiddleware.js new file mode 100644 index 00000000..4d2d6c51 --- /dev/null +++ b/src/middlewares/notFoundMiddleware.js @@ -0,0 +1,5 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const notFoundMiddleware = () => { + throw ApiError.notFound('API does not exist'); +}; diff --git a/src/middlewares/requireOwnershipMiddleware.js b/src/middlewares/requireOwnershipMiddleware.js new file mode 100644 index 00000000..c36ba1a4 --- /dev/null +++ b/src/middlewares/requireOwnershipMiddleware.js @@ -0,0 +1,11 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const requireOwnershipMiddleware = (req, res, next) => { + const paramValue = Number(req.params.id); + + if (paramValue !== req.user.id) { + throw ApiError.forbidden(); + } + + next(); +}; diff --git a/src/models/Token.model.js b/src/models/Token.model.js new file mode 100644 index 00000000..8740d721 --- /dev/null +++ b/src/models/Token.model.js @@ -0,0 +1,27 @@ +import { DataTypes } from 'sequelize'; +import { User } from './User.model.js'; +import { sequelize } from '../utils/db.js'; + +export const Token = sequelize.define( + 'Token', + { + refreshToken: { + type: DataTypes.STRING, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + }, + { + tableName: 'tokens', + timestamps: true, + }, +); + +Token.belongsTo(User, { foreignKey: 'userId' }); +User.hasOne(Token, { foreignKey: 'userId', onDelete: 'CASCADE' }); diff --git a/src/models/User.model.js b/src/models/User.model.js new file mode 100644 index 00000000..4792aa0e --- /dev/null +++ b/src/models/User.model.js @@ -0,0 +1,43 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../utils/db.js'; + +export const User = sequelize.define( + 'User', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + activationToken: { + type: DataTypes.STRING, + }, + lastActivationEmailSentAt: { + type: DataTypes.DATE, + allowNull: true, + }, + resetPasswordToken: { + type: DataTypes.STRING, + }, + resetPasswordExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, + passwordChangedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + tableName: 'users', + timestamps: true, + }, +); diff --git a/src/routers/auth.router.js b/src/routers/auth.router.js new file mode 100644 index 00000000..99bf7092 --- /dev/null +++ b/src/routers/auth.router.js @@ -0,0 +1,16 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; + +const router = new express.Router(); + +router.post('/registration', authController.register); +router.post('/activate/:email/:activationToken', authController.activate); +router.post('/login', authController.login); +router.post('/activation-link', authController.resendActivationLink); +router.post('/refresh', authController.refresh); +router.post('/logout', authMiddleware, authController.logout); +router.post('/password-reset-requests', authController.passwordResetRequest); +router.post('/password-reset', authController.passwordReset); + +export const authRouter = router; diff --git a/src/routers/user.router.js b/src/routers/user.router.js new file mode 100644 index 00000000..0f91b107 --- /dev/null +++ b/src/routers/user.router.js @@ -0,0 +1,29 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; +import { requireOwnershipMiddleware } from '../middlewares/requireOwnershipMiddleware.js'; + +const router = new express.Router(); + +router.get('/', authMiddleware, userController.getAllActiveUsers); +router.patch( + '/:id', + authMiddleware, + requireOwnershipMiddleware, + userController.updateUser, +); +router.post( + '/:id/password', + authMiddleware, + requireOwnershipMiddleware, + userController.changePassword, +); + +router.patch( + '/:id/change-email', + authMiddleware, + requireOwnershipMiddleware, + userController.changeEmail, +); + +export const userRouter = router; diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 00000000..53380eaa --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,28 @@ +import { v4 as uuidv4 } from 'uuid'; + +const FIFTEEN_MINUTES = 15 * 60 * 1000; + +const canResendActivation = (user) => { + if (!user.lastActivationEmailSentAt) { + return true; + } + + const now = Date.now(); + const lastUpdate = new Date(user.lastActivationEmailSentAt).getTime(); + + return now - lastUpdate >= FIFTEEN_MINUTES; +}; + +const generateActivationToken = () => { + return uuidv4(); +}; + +const getPasswordExpiresAt = () => { + return new Date(Date.now() + FIFTEEN_MINUTES); +}; + +export const authService = { + canResendActivation, + generateActivationToken, + getPasswordExpiresAt, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 00000000..426e4fdb --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,30 @@ +import { Token } from '../models/Token.model.js'; + +const getByToken = async (refreshToken) => { + const token = await Token.findOne({ where: { refreshToken } }); + + if (token) { + return token.get({ plain: true }); + } + + return null; +}; + +const create = async ({ userId, refreshToken }) => { + await Token.create({ userId, refreshToken }); +}; + +const deleteByUserId = async (userId) => { + await Token.destroy({ where: { userId } }); +}; + +const deleteByToken = async (refreshToken) => { + await Token.destroy({ where: { refreshToken } }); +}; + +export const tokenService = { + getByToken, + create, + deleteByUserId, + deleteByToken, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 00000000..36f1d810 --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,128 @@ +import { User } from '../models/User.model.js'; + +const validateEmail = (email) => { + const emailPattern = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!email) { + return 'Email is required'; + } + + if (!emailPattern.test(email)) { + return 'Email is not valid'; + } +}; +const validatePassword = (password) => { + if (!password) { + return 'Password is required'; + } + + const errors = []; + + if (password.length < 8) { + errors.push('At least 8 characters'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('At least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('At least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('At least one number'); + } + + if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { + errors.push('At least one special character'); + } + + if (errors.length > 0) { + return errors.join(', '); + } +}; + +const validateUserName = (name) => { + if (!name) { + return 'User name is required'; + } + + if (name.length < 2) { + return 'User name must be at least 2 characters'; + } +}; + +const getActiveUsers = async () => { + const users = await User.findAll({ where: { activationToken: null } }); + + return users.map((user) => user.get({ plain: true })); +}; + +const getUserById = async (id) => { + const user = await User.findByPk(id); + + if (!user) { + return null; + } + + return user.get({ plain: true }); +}; + +const getUserByEmail = async (email) => { + const user = await User.findOne({ where: { email } }); + + if (!user) { + return null; + } + + return user.get({ plain: true }); +}; + +const getUserByResetPasswordToken = async (resetPasswordToken) => { + const user = await User.findOne({ where: { resetPasswordToken } }); + + if (!user) { + return null; + } + + return user.get({ plain: true }); +}; + +const activate = async (email) => { + await User.update( + { activationToken: null, lastActivationEmailSentAt: null }, + { where: { email } }, + ); + + return getUserByEmail(email); +}; + +const createUser = async (userData) => { + const user = await User.create(userData); + + if (user) { + return getUserById(user.id); + } + + return null; +}; + +const updateUser = async (id, dataToUpdate) => { + await User.update(dataToUpdate, { where: { id } }); + + return getUserById(id); +}; + +export const userService = { + validateEmail, + validatePassword, + validateUserName, + getActiveUsers, + getUserById, + getUserByEmail, + getUserByResetPasswordToken, + activate, + createUser, + updateUser, +}; diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 00000000..a5653eeb --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,29 @@ +/* eslint-disable no-console */ +import Sequelize from 'sequelize'; +import 'dotenv/config'; + +const { DATABASE, DB_USER, DB_HOST, DB_PORT, DB_PASSWORD } = process.env; + +const sequelize = new Sequelize({ + database: DATABASE, + username: DB_USER, + host: DB_HOST, + port: DB_PORT, + password: DB_PASSWORD, + dialect: 'postgres', +}); + +const checkConnection = async () => { + try { + await sequelize.sync({ alter: true }); + + await sequelize.authenticate(); + console.log('✅ Connection has been established successfully.'); + } catch (error) { + console.error('❌ Unable to connect to the database:', error); + } +}; + +checkConnection(); + +export { sequelize }; diff --git a/src/utils/jwt.util.js b/src/utils/jwt.util.js new file mode 100644 index 00000000..01c39ef2 --- /dev/null +++ b/src/utils/jwt.util.js @@ -0,0 +1,37 @@ +import jsonwebtoken from 'jsonwebtoken'; +import 'dotenv/config'; + +const generateAccessToken = (user) => { + return jsonwebtoken.sign(user, process.env.ACCESS_TOKEN_SECRET, { + expiresIn: '10m', + }); +}; + +const validateAccessToken = (token) => { + try { + return jsonwebtoken.verify(token, process.env.ACCESS_TOKEN_SECRET); + } catch (error) { + return null; + } +}; + +const generateRefreshToken = (user) => { + return jsonwebtoken.sign(user, process.env.REFRESH_TOKEN_SECRET, { + expiresIn: '30d', + }); +}; + +const validateRefreshToken = (token) => { + try { + return jsonwebtoken.verify(token, process.env.REFRESH_TOKEN_SECRET); + } catch (error) { + return null; + } +}; + +export const jwt = { + generateAccessToken, + validateAccessToken, + generateRefreshToken, + validateRefreshToken, +}; diff --git a/src/utils/mailer.js b/src/utils/mailer.js new file mode 100644 index 00000000..4565486d --- /dev/null +++ b/src/utils/mailer.js @@ -0,0 +1,55 @@ +import nodemailer from 'nodemailer'; +import 'dotenv/config'; + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +export function send(email, subject, html) { + return transporter.sendMail({ + from: 'Auth API', + to: email, + subject, + html, + }); +} + +export function sendActivationLink(email, activationToken) { + const link = `${process.env.CLIENT_URL}/auth/activate/${email}/${activationToken}`; + const html = ` +

Account activation

+ ${link} + `; + + return send(email, 'Account activation', html); +} + +export function sendResetPasswordLink(email, resetPasswordToken) { + const link = `${process.env.CLIENT_URL}/auth/password-reset?token=${resetPasswordToken}`; + const html = ` +

Reset password for user: ${email}

+

Click this link and follow the instructions

+ `; + + return send(email, 'Reset password', html); +} + +export function confirmationOfEmailChange(oldEmail, newEmail) { + const html = ` +

Your email has been changed successfully.

+

Your email has been changed from current to ${newEmail}. Navigate to the new email and activate your account.

+ `; + + return send(oldEmail, 'Your email has been changed successfully', html); +} + +export const mailer = { + send, + sendActivationLink, + sendResetPasswordLink, + confirmationOfEmailChange, +}; diff --git a/src/utils/password.util.js b/src/utils/password.util.js new file mode 100644 index 00000000..98f4c004 --- /dev/null +++ b/src/utils/password.util.js @@ -0,0 +1,20 @@ +import bcrypt from 'bcrypt'; +import 'dotenv/config'; + +const getHashedPassword = async (password) => { + const saltRounds = Number(process.env.PASSWORD_SALT); + const hashedPassword = await bcrypt.hash(password, saltRounds); + + return hashedPassword; +}; + +const comparePassword = async ({ hashedPassword, password }) => { + const isEqual = await bcrypt.compare(password, hashedPassword); + + return isEqual; +}; + +export const passwordUtils = { + getHashedPassword, + comparePassword, +};