diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..0c282f8a --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,172 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { jwtService } from '../services/jwt.service.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; +import bcrypt from 'bcrypt'; + +export function 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'; + } +} + +export function validatePassword(password) { + if (!password) { + return 'Password is required'; + } + + if (password.length < 6) { + return 'At least 6 characters'; + } +} + +const register = async (req, res, next) => { + const { name, email, password } = req.body; + + const errors = { + email: validateEmail(email), + password: validatePassword(password), + }; + + if (Object.values(errors).some((error) => error)) { + throw ApiError.badRequest('Validation error', errors); + } + + const hashedPass = await bcrypt.hash(password, 10); + + await userService.register(name, email, hashedPass); + + res.send({ + message: 'OK', + }); +}; + +const activate = async (req, res, next) => { + const { activationToken } = req.params; + const user = await userService.findByActivationToken(activationToken); + + if (!user) { + throw ApiError.notFound({ email: 'this email doesn`t exist' }); + } + + user.activationToken = null; + await user.save(); + + res.send({ + message: 'OK', + }); +}; + +const login = async (req, res, next) => { + const { email, password } = req.body; + + const user = await userService.findByEmail(email); + + if (!user) { + throw ApiError.unauthorized({ email: 'this email doesn`t exist' }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.unauthorized({ password: 'wrong password' }); + } + + if (user.activationToken) { + throw ApiError.unauthorized({ email: 'please activate the email' }); + } + + await generateTokens(res, user); +}; + +const refresh = async (req, res, next) => { + const { refreshToken } = req.cookies; + + const userData = jwtService.verifyRefresh(refreshToken); + const token = await tokenService.getByToken(refreshToken); + + if (!userData || !token) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByEmail(userData.email); + + await generateTokens(res, user); +}; + +const generateTokens = async (res, user) => { + const normalizedUser = userService.normalize(user); + const accessToken = jwtService.sign(normalizedUser); + const refreshToken = jwtService.signRefresh(normalizedUser); + + await tokenService.save(normalizedUser.id, refreshToken); + + res.cookie('refreshToken', refreshToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + }); + + res.send({ + user: normalizedUser, + accessToken, + }); +}; + +const sendResetMessage = async (req, res, next) => { + const { email } = req.body; + + await userService.sendResetMessage(email); + + res.send({ + message: 'OK', + }); +}; + +const resetPassword = async (req, res, next) => { + const { resetPasswordToken } = req.params; + const { newPassword, confirmPassword } = req.body; + + const errors = { + password: validatePassword(newPassword), + }; + + const user = await userService.findByResetPasswordToken(resetPasswordToken); + + if (newPassword !== confirmPassword) { + throw ApiError.badRequest('password and confirmation fields must be equal'); + } + + if (!user) { + throw ApiError.notFound({ email: 'this email doesn`t exist' }); + } + + if (Object.values(errors).some((error) => error)) { + throw ApiError.badRequest('Validation error', errors); + } + + const hashedPass = await bcrypt.hash(newPassword, 10); + + await userService.changePassword(user.email, hashedPass); + + user.resetPasswordToken = null; + await user.save(); + + res.send({ + message: 'OK', + }); +}; + +export const authController = { + register, + activate, + login, + refresh, + sendResetMessage, + resetPassword, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..7df30bb5 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,144 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; +import { validateEmail, validatePassword } from './auth.controller.js'; +import bcrypt from 'bcrypt'; + +const getOneActivated = async (req, res, next) => { + const id = req.user.id; + + const user = await userService.findActivatedById(id); + + res.send(user); +}; + +const changePasswordAuthenticated = async (req, res, next) => { + const { oldPassword, newPassword, confirmPassword } = req.body; + const userData = req.user; + + if (!userData) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByEmail(userData.email); + + if (newPassword !== confirmPassword) { + throw ApiError.badRequest('password and confirmation fields must be equal'); + } + + const isPasswordValid = await bcrypt.compare(oldPassword, user.password); + + if (!isPasswordValid) { + throw ApiError.unauthorized({ password: 'wrong password' }); + } + + const errors = { + newPassword: validatePassword(newPassword), + }; + + if (Object.values(errors).some((error) => error)) { + throw ApiError.badRequest('Validation error', errors); + } + + const hashedPass = await bcrypt.hash(newPassword, 10); + + await userService.changePassword(userData.email, hashedPass); + + res.send({ + message: 'OK', + }); +}; + +const changeName = async (req, res, next) => { + const { name } = req.body; + const userData = req.user; + + if (!userData) { + throw ApiError.unauthorized(); + } + + await userService.changeName(userData.email, name); + + res.send({ + message: 'OK', + }); +}; + +const changeEmail = async (req, res, next) => { + const { oldEmail, newEmail, password } = req.body; + const userData = req.user; + + if (!userData) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByEmail(userData.email); + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.unauthorized({ password: 'wrong password' }); + } + + const errors = { + email: validateEmail(newEmail), + }; + + if (Object.values(errors).some((error) => error)) { + throw ApiError.badRequest('Validation error', errors); + } + + if (oldEmail === newEmail) { + throw ApiError.badRequest('new email must be different'); + } + + await userService.changeEmail(userData.email, newEmail); + + res.send({ + message: 'OK', + }); +}; + +const activateChangedEmail = async (req, res, next) => { + const { emailChangeToken } = req.params; + const user = await userService.findByEmailChangeToken(emailChangeToken); + + if (!user) { + throw ApiError.notFound({ email: 'this email doesn`t exist' }); + } + + const existingUser = await userService.findByEmail(user.pendingEmail); + + if (existingUser) { + throw ApiError.badRequest('email already exists'); + } + + user.email = user.pendingEmail; + user.pendingEmail = null; + user.emailChangeToken = null; + + await user.save(); + + res.send({ + message: 'OK', + }); +}; + +const logout = async (req, res, next) => { + res.clearCookie('refreshToken'); + + await tokenService.remove(req.user.id); + + res.send({ + message: 'OK', + }); +}; + +export const userController = { + getOneActivated, + changeName, + changeEmail, + changePasswordAuthenticated, + activateChangedEmail, + logout, +}; diff --git a/src/exceptions/api.error.js b/src/exceptions/api.error.js new file mode 100644 index 00000000..48fdc66d --- /dev/null +++ b/src/exceptions/api.error.js @@ -0,0 +1,32 @@ +export class ApiError extends Error { + constructor({ message, status, errors = {} }) { + super(message); + + this.status = status; + this.errors = errors; + } + + static badRequest(message, errors = {}) { + return new ApiError({ + message, + errors, + status: 400, + }); + } + + static unauthorized(errors = {}) { + return new ApiError({ + message: 'unauthorized user', + errors, + status: 401, + }); + } + + static notFound(errors) { + return new ApiError({ + message: 'not found', + errors, + status: 404, + }); + } +} diff --git a/src/index.js b/src/index.js index ad9a93a7..53fb733f 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,31 @@ +/* eslint-disable no-console */ 'use strict'; + +import express from 'express'; +import 'dotenv/config'; +import { authRouter } from './routers/auth.route.js'; +import { userRouter } from './routers/user.route.js'; +import { errorMiddleware } from './middlewares/errorMiddleware.js'; +import cookieParser from 'cookie-parser'; + +const PORT = process.env.PORT || 3005; + +const app = express(); + +app.use(express.json()); +app.use(cookieParser()); + +app.use(authRouter); +app.use('/user', userRouter); + +app.use((req, res) => { + res.status(404).send(` +

Page not found

+ `); +}); + +app.use(errorMiddleware); + +app.listen(PORT, () => { + console.log('server is running'); +}); diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js new file mode 100644 index 00000000..6202077f --- /dev/null +++ b/src/middlewares/authMiddleware.js @@ -0,0 +1,21 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddleware = (req, res, next) => { + const authorization = req.headers['authorization'] || ''; + const [, token] = authorization.split(' '); + + if (!authorization || !token) { + throw ApiError.unauthorized(); + } + + const userData = jwtService.verify(token); + + if (!userData) { + throw ApiError.unauthorized(); + } + + req.user = userData; + + next(); +}; diff --git a/src/middlewares/errorMiddleware.js b/src/middlewares/errorMiddleware.js new file mode 100644 index 00000000..239913ba --- /dev/null +++ b/src/middlewares/errorMiddleware.js @@ -0,0 +1,18 @@ +import { ApiError } from '../exceptions/api.error.js'; + +export const errorMiddleware = (error, req, res, next) => { + if (res.headersSent) { + return next(error); + } + + if (error instanceof ApiError) { + return res.status(error.status || 400).json({ + message: error.message, + errors: error.errors || null, + }); + } + + return res.status(500).json({ + message: 'Server error', + }); +}; diff --git a/src/middlewares/guestMiddleware.js b/src/middlewares/guestMiddleware.js new file mode 100644 index 00000000..95944ccc --- /dev/null +++ b/src/middlewares/guestMiddleware.js @@ -0,0 +1,26 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { jwtService } from '../services/jwt.service.js'; + +export const guestMiddleware = (req, res, next) => { + const authorization = req.headers.authorization; + + if (!authorization) { + return next(); + } + + const [, token] = authorization.split(' '); + + if (!token) { + return next(); + } + + const userData = jwtService.verify(token); + + if (userData) { + throw ApiError.badRequest( + 'This function is only for non-authenticated users', + ); + } + + next(); +}; diff --git a/src/models/token.js b/src/models/token.js new file mode 100644 index 00000000..a9c33f91 --- /dev/null +++ b/src/models/token.js @@ -0,0 +1,13 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../utils/db.js'; +import { User } from './user.js'; + +export const Token = sequelize.define('token', { + refreshToken: { + type: DataTypes.STRING, + allowNull: false, + }, +}); + +Token.belongsTo(User); +User.hasOne(Token); diff --git a/src/models/user.js b/src/models/user.js new file mode 100644 index 00000000..e93184ae --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,30 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../utils/db.js'; + +export const User = sequelize.define('user', { + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + activationToken: { + type: DataTypes.STRING, + }, + resetPasswordToken: { + type: DataTypes.STRING, + }, + pendingEmail: { + type: DataTypes.STRING, + }, + emailChangeToken: { + type: DataTypes.STRING, + }, +}); diff --git a/src/routers/auth.route.js b/src/routers/auth.route.js new file mode 100644 index 00000000..44457e31 --- /dev/null +++ b/src/routers/auth.route.js @@ -0,0 +1,33 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { catchError } from '../utils/catchError.js'; +import { guestMiddleware } from '../middlewares/guestMiddleware.js'; + +export const authRouter = express.Router(); + +authRouter.post( + '/registration', + guestMiddleware, + catchError(authController.register), +); + +authRouter.get( + '/activation/:activationToken', + guestMiddleware, + catchError(authController.activate), +); +authRouter.post('/login', guestMiddleware, catchError(authController.login)); + +authRouter.post('/refresh', catchError(authController.refresh)); + +authRouter.post( + '/password/reset', + guestMiddleware, + catchError(authController.sendResetMessage), +); + +authRouter.post( + '/password/reset/:resetPasswordToken', + guestMiddleware, + catchError(authController.resetPassword), +); diff --git a/src/routers/user.route.js b/src/routers/user.route.js new file mode 100644 index 00000000..e78b71c4 --- /dev/null +++ b/src/routers/user.route.js @@ -0,0 +1,39 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; +import { catchError } from '../utils/catchError.js'; +import { guestMiddleware } from '../middlewares/guestMiddleware.js'; + +export const userRouter = express.Router(); + +userRouter.get( + '/profile', + authMiddleware, + catchError(userController.getOneActivated), +); + +userRouter.patch( + '/profile/name/change', + authMiddleware, + catchError(userController.changeName), +); + +userRouter.patch( + '/profile/password/change', + authMiddleware, + catchError(userController.changePasswordAuthenticated), +); + +userRouter.patch( + '/profile/email/change', + authMiddleware, + catchError(userController.changeEmail), +); + +userRouter.get( + '/profile/email/change/activation/:emailChangeToken', + guestMiddleware, + catchError(userController.activateChangedEmail), +); + +userRouter.post('/logout', authMiddleware, catchError(userController.logout)); diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..0ad34335 --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,64 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.EMAIL, + pass: process.env.EMAIL_PASSWORD, + }, +}); + +function send(email, subject, html) { + return transporter.sendMail({ + from: 'Auth API', + to: email, + subject, + html, + }); +} + +function sendActivationEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/activation/${token}`; + const html = ` +

Activate

+ ${href} + `; + + return send(email, 'Activate', html); +} + +function sendNotifyEmail(email) { + const html = ` +

Your email has been changed

+ `; + + return send(email, 'Your email has been changed', html); +} + +function sendResetPasswordEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/password/reset/${token}`; + const html = ` +

Reset password

+ ${href} + `; + + return send(email, 'Reset password', html); +} + +function sendChangeEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/user/profile/email/change/activation/${token}`; + const html = ` +

Confirm change email

+ ${href} + `; + + return send(email, 'Confirm change email', html); +} + +export const mailer = { + send, + sendActivationEmail, + sendResetPasswordEmail, + sendNotifyEmail, + sendChangeEmail, +}; diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..9d665c48 --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,38 @@ +import jwt from 'jsonwebtoken'; + +function sign(user) { + const token = jwt.sign(user, process.env.JWT_KEY, { + expiresIn: '900s', + }); + + return token; +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_KEY); + } catch (error) { + return null; + } +} + +function signRefresh(user) { + const token = jwt.sign(user, process.env.JWT_REFRESH_KEY); + + return token; +} + +function verifyRefresh(token) { + try { + return jwt.verify(token, process.env.JWT_REFRESH_KEY); + } catch (error) { + return null; + } +} + +export const jwtService = { + sign, + verify, + signRefresh, + verifyRefresh, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 00000000..01b8aab5 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,29 @@ +import { Token } from '../models/token.js'; + +async function save(userId, newToken) { + const token = await Token.findOne({ where: { userId } }); + + if (!token) { + await Token.create({ userId, refreshToken: newToken }); + + return; + } + + token.refreshToken = newToken; + + await token.save(); +} + +async function getByToken(refreshToken) { + return Token.findOne({ where: { refreshToken } }); +} + +async function remove(userId) { + return Token.destroy({ where: { userId } }); +} + +export const tokenService = { + save, + getByToken, + remove, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 00000000..02c5cd72 --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,117 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { User } from '../models/user.js'; +import { mailer } from '../services/email.service.js'; +import { v4 as uuidv4 } from 'uuid'; + +function findByEmail(email) { + return User.findOne({ where: { email } }); +} + +function findActivatedById(id) { + return User.findOne({ + where: { + id, + activationToken: null, + }, + }); +} + +function findByActivationToken(activationToken) { + return User.findOne({ where: { activationToken } }); +} + +function findByResetPasswordToken(resetPasswordToken) { + return User.findOne({ where: { resetPasswordToken } }); +} + +function findByEmailChangeToken(emailChangeToken) { + return User.findOne({ where: { emailChangeToken } }); +} + +function normalize({ id, email }) { + return { id, email }; +} + +async function register(name, email, password) { + const activationToken = uuidv4(); + + const existUser = await findByEmail(email); + + if (existUser) { + throw ApiError.badRequest('User already exist', { + email: 'User already exist', + }); + } + + await User.create({ + name, + email, + password, + activationToken, + }); + + await mailer.sendActivationEmail(email, activationToken); +} + +async function sendResetMessage(email) { + const resetPasswordToken = uuidv4(); + + const existUser = await findByEmail(email); + + if (!existUser) { + throw ApiError.badRequest('User does not exist, please sign up', { + email: 'User does not exist', + }); + } + + await User.update({ resetPasswordToken }, { where: { email } }); + + await mailer.sendResetPasswordEmail(email, resetPasswordToken); +} + +async function changePassword(email, password) { + await User.update({ password }, { where: { email } }); +} + +async function changeName(email, name) { + await User.update({ name }, { where: { email } }); +} + +async function changeEmail(oldEmail, newEmail) { + const existingUser = await findByEmail(newEmail); + + if (existingUser) { + throw ApiError.badRequest('this email is exist', { + email: 'this email is exist', + }); + } + + const emailChangeToken = uuidv4(); + + await mailer.sendChangeEmail(newEmail, emailChangeToken); + await mailer.sendNotifyEmail(oldEmail); + + await User.update( + { + pendingEmail: newEmail, + emailChangeToken, + }, + { + where: { email: oldEmail }, + }, + ); +} + +export const userService = { + normalize, + findByEmail, + findActivatedById, + findByActivationToken, + findByResetPasswordToken, + findByEmailChangeToken, + register, + sendResetMessage, + changePassword, + changeName, + changeEmail, +}; diff --git a/src/utils/catchError.js b/src/utils/catchError.js new file mode 100644 index 00000000..0e1e7d8f --- /dev/null +++ b/src/utils/catchError.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async function (req, res, next) { + try { + await action(req, res, next); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 00000000..c8819e03 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,21 @@ +'use strict'; + +import { Sequelize } from 'sequelize'; +import 'dotenv/config'; + +const { + POSTGRES_HOST, + POSTGRES_PORT, + POSTGRES_USER, + POSTGRES_PASSWORD, + POSTGRES_DB, +} = process.env; + +export const sequelize = new Sequelize({ + database: POSTGRES_DB || 'postgres', + username: POSTGRES_USER || 'postgres', + host: POSTGRES_HOST || 'localhost', + dialect: 'postgres', + port: POSTGRES_PORT || 5432, + password: POSTGRES_PASSWORD || '123', +});