diff --git a/setup.js b/setup.js new file mode 100644 index 00000000..5477a419 --- /dev/null +++ b/setup.js @@ -0,0 +1,4 @@ +import 'dotenv/config'; +import { client } from './src/utils/db.js'; + +await client.sync(); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..18bcbf60 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,403 @@ +import bcrypt from 'bcrypt'; +import { User } from '../models/user.js'; +import { userService } from '../services/user.service.js'; +import { jwtService } from '../services/jwt.service.js'; +import { ApiError } from '../exeptions/api.error.js'; +import { tokenService } from '../services/token.service.js'; +import { emailService } from '../services/email.service.js'; + +function validateEmail(value) { + if (!value) { + return 'Email is required'; + } + + const emailPattern = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!emailPattern.test(value)) { + return 'Email is not valid'; + } + + return null; +} + +function validatePassword(value) { + if (!value) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'Password must be at least 6 characters'; + } + + if (!/[A-Z]/.test(value)) { + return 'Password must contain at least one uppercase letter'; + } + + if (!/[a-z]/.test(value)) { + return 'Password must contain at least one lowercase letter'; + } + + if (!/\d/.test(value)) { + return 'Password must contain at least one digit'; + } + + return null; +} + +async function generateTokens(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 register = async (req, res) => { + const { name, email, password } = req.body; + + const errors = { + name: !name ? 'Name is required' : null, + email: validateEmail(email), + password: validatePassword(password), + }; + + if (errors.name || errors.email || errors.password) { + throw ApiError.badRequest('Bad request', errors); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + await userService.register(name, email, hashedPassword); + + res.status(201).send({ + message: 'Activation email has been sent', + }); +}; + +const activate = async (req, res) => { + const { activationToken } = req.params; + + const user = await User.findOne({ + where: { activationToken }, + }); + + if (!user) { + throw ApiError.notFound({ + activationToken: 'Activation token is invalid', + }); + } + + user.activationToken = null; + user.isActivated = true; + await user.save(); + + await generateTokens(res, user); +}; + +const login = async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + throw ApiError.badRequest('Email and password are required'); + } + + const user = await userService.findByEmail(email); + + if (!user) { + throw ApiError.badRequest('No such user'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Wrong password'); + } + + if (!user.isActivated) { + throw ApiError.badRequest('Please activate your email first'); + } + + await generateTokens(res, user); +}; + +const refresh = async (req, res) => { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + throw ApiError.unauthorized(); + } + + const userData = jwtService.verifyRefresh(refreshToken); + const token = await tokenService.getByToken(refreshToken); + + if (!userData || !token) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByEmail(userData.email); + + if (!user) { + throw ApiError.unauthorized(); + } + + await generateTokens(res, user); +}; + +const logout = async (req, res) => { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + return res.sendStatus(204); + } + + const userData = jwtService.verifyRefresh(refreshToken); + + if (userData) { + await tokenService.remove(userData.id); + } + + res.clearCookie('refreshToken'); + + return res.sendStatus(204); +}; + +const forgotPassword = async (req, res) => { + const { email } = req.body; + + const emailError = validateEmail(email); + + if (emailError) { + throw ApiError.badRequest('Bad request', { + email: emailError, + }); + } + + await userService.createResetToken(email); + + res.send({ + message: 'If the email exists, reset instructions were sent', + }); +}; + +const checkResetToken = async (req, res) => { + const { resetToken } = req.params; + + const user = await userService.findByResetToken(resetToken); + + if (!user) { + throw ApiError.notFound({ + resetToken: 'Reset token is invalid', + }); + } + + if ( + !user.resetTokenExpiresAt || + new Date(user.resetTokenExpiresAt) < new Date() + ) { + throw ApiError.badRequest('Reset token expired'); + } + + res.send({ + message: 'Token is valid', + }); +}; + +const resetPassword = async (req, res) => { + const { resetToken } = req.params; + const { password, confirmation } = req.body; + + const passwordError = validatePassword(password); + + if (passwordError) { + throw ApiError.badRequest('Bad request', { + password: passwordError, + }); + } + + if (password !== confirmation) { + throw ApiError.badRequest('Bad request', { + confirmation: 'Passwords do not match', + }); + } + + const user = await userService.findByResetToken(resetToken); + + if (!user) { + throw ApiError.notFound({ + resetToken: 'Reset token is invalid', + }); + } + + if ( + !user.resetTokenExpiresAt || + new Date(user.resetTokenExpiresAt) < new Date() + ) { + throw ApiError.badRequest('Reset token expired'); + } + + user.password = await bcrypt.hash(password, 10); + user.resetToken = null; + user.resetTokenExpiresAt = null; + + await user.save(); + + res.send({ + message: 'Password was changed successfully', + }); +}; + +const getProfile = async (req, res) => { + const user = await userService.findById(req.user.id); + + if (!user) { + throw ApiError.unauthorized(); + } + + res.send({ + user: userService.normalize(user), + }); +}; + +const updateName = async (req, res) => { + const { name } = req.body; + + if (!name) { + throw ApiError.badRequest('Bad request', { + name: 'Name is required', + }); + } + + const user = await userService.findById(req.user.id); + + if (!user) { + throw ApiError.unauthorized(); + } + + user.name = name; + await user.save(); + + res.send({ + user: userService.normalize(user), + }); +}; + +const updatePassword = async (req, res) => { + const { oldPassword, newPassword, confirmation } = req.body; + + const user = await userService.findById(req.user.id); + + if (!user) { + throw ApiError.unauthorized(); + } + + const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password); + + if (!isOldPasswordValid) { + throw ApiError.badRequest('Bad request', { + oldPassword: 'Old password is incorrect', + }); + } + + const passwordError = validatePassword(newPassword); + + if (passwordError) { + throw ApiError.badRequest('Bad request', { + newPassword: passwordError, + }); + } + + if (newPassword !== confirmation) { + throw ApiError.badRequest('Bad request', { + confirmation: 'Passwords do not match', + }); + } + + user.password = await bcrypt.hash(newPassword, 10); + await user.save(); + + res.send({ + message: 'Password updated successfully', + }); +}; + +const updateEmail = async (req, res) => { + const { newEmail, confirmation, password } = req.body; + + if (!newEmail) { + throw ApiError.badRequest('Bad request', { + newEmail: 'New email is required', + }); + } + + if (newEmail !== confirmation) { + throw ApiError.badRequest('Bad request', { + confirmation: 'Emails do not match', + }); + } + + const emailError = validateEmail(newEmail); + + if (emailError) { + throw ApiError.badRequest('Bad request', { + newEmail: emailError, + }); + } + + const user = await userService.findById(req.user.id); + + if (!user) { + throw ApiError.unauthorized(); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Bad request', { + password: 'Password is incorrect', + }); + } + + const existingUser = await userService.findByEmail(newEmail); + + if (existingUser && existingUser.id !== user.id) { + throw ApiError.badRequest('Bad request', { + newEmail: 'Email is already in use', + }); + } + + const oldEmail = user.email; + + user.email = newEmail; + await user.save(); + + await emailService.sendEmailChangedNotification(oldEmail, newEmail); + + res.send({ + user: userService.normalize(user), + }); +}; + +export const authController = { + register, + activate, + login, + refresh, + logout, + forgotPassword, + checkResetToken, + resetPassword, + getProfile, + updateName, + updatePassword, + updateEmail, +}; diff --git a/src/exeptions/api.error.js b/src/exeptions/api.error.js new file mode 100644 index 00000000..faddba1f --- /dev/null +++ b/src/exeptions/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, + status: 400, + errors, + }); + } + + static unauthorized(errors = {}) { + return new ApiError({ + message: 'Unauthorized user', + status: 401, + errors, + }); + } + + static notFound(errors = {}) { + return new ApiError({ + message: 'Not found', + status: 404, + errors, + }); + } +} diff --git a/src/index.js b/src/index.js index ad9a93a7..15a10c80 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,42 @@ -'use strict'; +import 'dotenv/config'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import { authRouter } from './routes/auth.route.js'; +import { errorMiddleweare } from './middlewares/errorMiddleware.js'; +import { client } from './utils/db.js'; +import './models/user.js'; +import './models/token.js'; + +const PORT = process.env.PORT || 3005; + +const app = express(); + +app.use(express.json()); +app.use(cookieParser()); + +app.use('/auth', authRouter); + +app.use((req, res) => { + res.status(404).send({ + message: 'Route not found', + }); +}); + +app.use(errorMiddleweare); + +async function start() { + try { + await client.authenticate(); + await client.sync({ alter: true }); + + app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server is running on port ${PORT}`); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to start server:', error); + } +} + +start(); diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js new file mode 100644 index 00000000..1e83a80e --- /dev/null +++ b/src/middlewares/authMiddleware.js @@ -0,0 +1,19 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddleware = (req, res, next) => { + const authorization = req.headers.authorization || ''; + const [, token] = authorization.split(' '); + + if (!token) { + return res.sendStatus(401); + } + + const userData = jwtService.verify(token); + + if (!userData) { + return res.sendStatus(401); + } + + req.user = userData; + next(); +}; diff --git a/src/middlewares/errorMiddleware.js b/src/middlewares/errorMiddleware.js new file mode 100644 index 00000000..92dd03d0 --- /dev/null +++ b/src/middlewares/errorMiddleware.js @@ -0,0 +1,14 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const errorMiddleweare = (error, req, res, next) => { + if (error instanceof ApiError) { + return res.status(error.status).send({ + message: error.message, + errors: error.errors, + }); + } + + return res.status(500).send({ + message: 'Server error', + }); +}; diff --git a/src/middlewares/guestMiddleware.js b/src/middlewares/guestMiddleware.js new file mode 100644 index 00000000..a32f0995 --- /dev/null +++ b/src/middlewares/guestMiddleware.js @@ -0,0 +1,18 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const guestMiddleware = (req, res, next) => { + const authorization = req.headers.authorization || ''; + const [, token] = authorization.split(' '); + + if (token) { + const userData = jwtService.verify(token); + + if (userData) { + return res.status(403).send({ + message: 'Already authenticated', + }); + } + } + + next(); +}; diff --git a/src/models/token.js b/src/models/token.js new file mode 100644 index 00000000..5e009918 --- /dev/null +++ b/src/models/token.js @@ -0,0 +1,13 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; +import { User } from './user.js'; + +export const Token = client.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..bc434fb2 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,41 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; + +export const User = client.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, + allowNull: true, + }, + + isActivated: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + + resetToken: { + type: DataTypes.STRING, + allowNull: true, + }, + + resetTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true, + }, +}); diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..a6739e75 --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,67 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { catchError } from '../utils/catchError.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; +import { guestMiddleware } from '../middlewares/guestMiddleware.js'; + +export const authRouter = new express.Router(); + +authRouter.post( + '/registration', + guestMiddleware, + catchError(authController.register), +); + +authRouter.post('/login', guestMiddleware, catchError(authController.login)); + +authRouter.get( + '/activate/:activationToken', + guestMiddleware, + catchError(authController.activate), +); + +authRouter.get('/refresh', catchError(authController.refresh)); + +authRouter.post('/logout', authMiddleware, catchError(authController.logout)); + +authRouter.post( + '/forgot-password', + guestMiddleware, + catchError(authController.forgotPassword), +); + +authRouter.get( + '/reset-password/:resetToken', + guestMiddleware, + catchError(authController.checkResetToken), +); + +authRouter.post( + '/reset-password/:resetToken', + guestMiddleware, + catchError(authController.resetPassword), +); + +authRouter.get( + '/profile', + authMiddleware, + catchError(authController.getProfile), +); + +authRouter.patch( + '/profile/name', + authMiddleware, + catchError(authController.updateName), +); + +authRouter.patch( + '/profile/password', + authMiddleware, + catchError(authController.updatePassword), +); + +authRouter.patch( + '/profile/email', + authMiddleware, + catchError(authController.updateEmail), +); diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..1a0b202c --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,73 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +export function send({ email, subject, html }) { + return transporter.sendMail({ + from: process.env.SMTP_USER, + to: email, + subject, + html, + }); +} + +function sendActivationEmail(email, activationToken) { + const href = `${process.env.CLIENT_HOST}/activate/${activationToken}`; + + const html = ` +

Activate account

+

Click the link below to activate your account:

+ ${href} + `; + + return send({ + email, + subject: 'Activate account', + html, + }); +} + +function sendResetPasswordEmail(email, resetToken) { + const href = `${process.env.CLIENT_HOST}/reset-password/${resetToken}`; + + const html = ` +

Reset password

+

Click the link below to reset your password:

+ ${href} + `; + + return send({ + email, + subject: 'Reset password', + html, + }); +} + +function sendEmailChangedNotification(oldEmail, newEmail) { + const html = ` +

Email changed

+

Your account email was changed.

+

New email: ${newEmail}

+ `; + + return send({ + email: oldEmail, + subject: 'Your email was changed', + html, + }); +} + +export const emailService = { + send, + sendActivationEmail, + sendResetPasswordEmail, + sendEmailChangedNotification, +}; diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..5d35159f --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; + +function sign(user) { + return jwt.sign(user, process.env.JWT_KEY, { + expiresIn: '15m', + }); +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_KEY); + } catch (e) { + return null; + } +} + +function signRefresh(user) { + return jwt.sign(user, process.env.JWT_REFRESH_KEY, { + expiresIn: '30d', + }); +} + +function verifyRefresh(token) { + try { + return jwt.verify(token, process.env.JWT_REFRESH_KEY); + } catch (e) { + 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..e60a73c6 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,37 @@ +import { Token } from '../models/token.js'; + +async function save(userId, newRefreshToken) { + const token = await Token.findOne({ + where: { userId }, + }); + + if (!token) { + await Token.create({ + userId, + refreshToken: newRefreshToken, + }); + + return; + } + + token.refreshToken = newRefreshToken; + await token.save(); +} + +function getByToken(refreshToken) { + return Token.findOne({ + where: { refreshToken }, + }); +} + +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..ee69bc46 --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,76 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { User } from '../models/user.js'; +import { emailService } from './email.service.js'; +import { v4 as uuidv4 } from 'uuid'; + +function normalize({ id, name, email, isActivated }) { + return { + id, + name, + email, + isActivated, + }; +} + +function findByEmail(email) { + return User.findOne({ + where: { email }, + }); +} + +function findById(id) { + return User.findByPk(id); +} + +async function register(name, email, password) { + const existingUser = await findByEmail(email); + + if (existingUser) { + throw ApiError.badRequest('User already exists', { + email: 'User already exists', + }); + } + + const activationToken = uuidv4(); + + const user = await User.create({ + name, + email, + password, + activationToken, + }); + + await emailService.sendActivationEmail(email, activationToken); + + return user; +} + +async function createResetToken(email) { + const user = await findByEmail(email); + + if (!user) { + return; + } + + user.resetToken = uuidv4(); + user.resetTokenExpiresAt = new Date(Date.now() + 1000 * 60 * 60); + + await user.save(); + + await emailService.sendResetPasswordEmail(email, user.resetToken); +} + +async function findByResetToken(resetToken) { + return User.findOne({ + where: { resetToken }, + }); +} + +export const userService = { + normalize, + findByEmail, + findById, + register, + createResetToken, + findByResetToken, +}; 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..1be0c452 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,9 @@ +import { Sequelize } from 'sequelize'; + +export const client = new Sequelize({ + host: process.env.DB_HOST, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + dialect: 'postgres', +});