diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..bb1704fc --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,206 @@ +import { ApiError } from '../exceptions/api.error.js'; +import { User } from '../models/user.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'; + +function validateEmail(email) { + if (!email) { + return 'Email is required'; + } + + const emailPattern = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!emailPattern.test(email)) { + return 'Email is not valid'; + } +} + +function validatePassword(password) { + if (!password) { + return 'Password is required'; + } + + if (password.length < 6) { + return 'At least 6 characters'; + } + + const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/; + + if (!passwordPattern.test(password)) { + return ( + 'Password must contain minimum 8 characters, 1 uppercase letter, ' + + '1 lowercase letter and one number' + ); + } +} + +async function register(request, response, next) { + const { name, email, password } = request.body; + + if (!name) { + throw ApiError.badRequest('Name is required'); + } + + const errors = { + email: validateEmail(email), + password: validatePassword(password), + }; + + if (errors.email || errors.password) { + throw ApiError.badRequest('Bad request', errors); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + await userService.register(name, email, hashedPassword); + + response.send({ message: 'OK' }); +} + +async function activate(request, response) { + const { activationToken } = request.params; + const user = await User.findOne({ where: { activationToken } }); + + if (!user) { + response.sendStatus(404); + + return; + } + + user.activationToken = null; + await user.save(); + + response.send(userService.normalize(user)); +} + +async function login(request, response) { + const { email, password } = request.body; + + const user = await userService.findByEmail(email); + + if (!user) { + throw ApiError.unauthorized('Invalid email or password'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.unauthorized('Invalid email or password'); + } + + if (user.activationToken) { + throw ApiError.forbidden('Please activate your email before logging in'); + } + + await generateTokens(response, user); +} + +async function refresh(request, response) { + const { refreshToken } = request.cookies; + + const userData = await jwtService.verifyRefresh(refreshToken); + const token = await tokenService.getByToken(refreshToken); + + if (!userData || !token) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByEmail(userData.email); + + await generateTokens(response, user); +} + +async function generateTokens(response, user) { + const normalizedUser = userService.normalize(user); + + const accessToken = jwtService.sign(normalizedUser); + const refreshToken = jwtService.signRefresh(normalizedUser); + + await tokenService.save(normalizedUser.id, refreshToken); + + response.cookie('refreshToken', refreshToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + HttpOnly: true, + }); + + response.send({ + user: normalizedUser, + accessToken, + }); +} + +async function logout(request, response) { + const { refreshToken } = request.cookies; + const userData = await jwtService.verifyRefresh(refreshToken); + + if (!userData || !refreshToken) { + throw ApiError.unauthorized(); + } + + await tokenService.remove(userData.id); + + response.sendStatus(204); +} + +async function resetPasswordRequest(request, response) { + const { email } = request.body; + + if (!email) { + throw ApiError.badRequest('Email is required'); + } + + await userService.resetPasswordRequest(email); + + response.send({ + message: 'Check your email for reset password instructions', + }); +} + +async function resetPassword(request, response) { + const { resetToken } = request.params; + const { password, confirmation } = request.body; + + if (!password || !confirmation) { + throw ApiError.badRequest('Password and confirmation are required'); + } + + if (password !== confirmation) { + throw ApiError.badRequest('Passwords do not match', { + confirmation: 'Passwords do not match', + }); + } + + const passwordError = validatePassword(password); + + if (passwordError) { + throw ApiError.badRequest('Invalid password', { password: passwordError }); + } + + await userService.resetPassword(resetToken, password); + + response.send({ message: 'Password reset successfully' }); +} + +async function confirmNewEmail(request, response) { + const { newEmailToken } = request.params; + + const user = await userService.confirmEmailChange(newEmailToken); + + response.send({ + message: 'Email confirmed', + user: userService.normalize(user), + }); +} + +export const authController = { + register, + activate, + login, + refresh, + logout, + resetPasswordRequest, + resetPassword, + confirmNewEmail, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..d16ff3dc --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,70 @@ +import { userService } from '../services/user.service.js'; +import { ApiError } from '../exceptions/api.error.js'; + +const getAllActivated = async (request, response) => { + const users = await userService.getAllActivated(); + + response.send(users.map(userService.normalize)); +}; + +const getProfile = async (request, response) => { + const user = await userService.findById(request.user.id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + response.send(userService.normalize(user)); +}; + +const changeName = async (request, response) => { + const { name } = request.body; + + if (!name) { + throw ApiError.badRequest('Name is required'); + } + + const user = await userService.updateName(request.user.id, name); + + response.send(userService.normalize(user)); +}; + +const changePassword = async (request, response) => { + const { oldPassword, password, confirmation } = request.body; + + if (!oldPassword || !password || !confirmation) { + throw ApiError.badRequest( + 'Old password, password and confirmation are required', + ); + } + + if (password !== confirmation) { + throw ApiError.badRequest('Passwords do not match', { + confirmation: 'Passwords do not match', + }); + } + + await userService.changePassword(request.user.id, oldPassword, password); + + response.send({ message: 'Password changed successfully' }); +}; + +const changeEmail = async (request, response) => { + const { password, newEmail } = request.body; + + if (!password || !newEmail) { + throw ApiError.badRequest('Password and new email are required'); + } + + await userService.requestEmailChange(request.user.id, newEmail, password); + + response.send({ message: 'Check your new email for confirmation' }); +}; + +export const userController = { + getAllActivated, + getProfile, + changeName, + changePassword, + changeEmail, +}; diff --git a/src/exceptions/api.error.js b/src/exceptions/api.error.js new file mode 100644 index 00000000..c561b8b1 --- /dev/null +++ b/src/exceptions/api.error.js @@ -0,0 +1,40 @@ +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 forbidden(message = 'forbidden', errors) { + return new ApiError({ + message, + errors, + status: 403, + }); + } + + 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/http/index.js b/src/http/index.js new file mode 100644 index 00000000..3ec79389 --- /dev/null +++ b/src/http/index.js @@ -0,0 +1,40 @@ +'use strict'; +import 'dotenv/config'; +import express from 'express'; +import { authRouter } from '../routes/auth.route.js'; +import cors from 'cors'; +import { userRouter } from '../routes/user.route.js'; +import { errorMiddleware } from '../middlewares/errorMiddleware.js'; +import cookieParser from 'cookie-parser'; + +const PORT = process.env.PORT || 3004; + +const app = express(); + +app.use(express.json()); +app.use(cookieParser()); + +app.use( + cors({ + origin: process.env.CLIENT_HOST, + credentials: true, + }), +); + +app.use(authRouter); +app.use('/users', userRouter); + +app.get('/', (request, response) => { + response.send('Hello'); +}); + +app.use((request, response) => { + response.sendStatus(404); +}); + +app.use(errorMiddleware); + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log('server is running'); +}); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index ad9a93a7..00000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -'use strict'; diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js new file mode 100644 index 00000000..27d4c185 --- /dev/null +++ b/src/middlewares/authMiddleware.js @@ -0,0 +1,24 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddleware = (request, response, next) => { + const authorization = request.headers['authorization'] || ''; + const [, token] = authorization.split(' '); + + if (!authorization || !token) { + response.sendStatus(401); + + return; + } + + const userData = jwtService.verify(token); + + if (!userData) { + response.sendStatus(401); + + return; + } + + request.user = userData; + + next(); +}; diff --git a/src/middlewares/errorMiddleware.js b/src/middlewares/errorMiddleware.js new file mode 100644 index 00000000..62393719 --- /dev/null +++ b/src/middlewares/errorMiddleware.js @@ -0,0 +1,18 @@ +import { ApiError } from '../exceptions/api.error.js'; + +export const errorMiddleware = (error, request, response, next) => { + if (error instanceof ApiError) { + response.status(error.status).send({ + message: error.message, + errors: error.errors, + }); + + return; + } + + response.statusCode = 500; + + response.send({ + message: 'Server error', + }); +}; 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..c5121c17 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,30 @@ +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, + }, + resetPasswordToken: { + type: DataTypes.STRING, + }, + newEmail: { + type: DataTypes.STRING, + }, + newEmailToken: { + type: DataTypes.STRING, + }, +}); diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..dd8f122e --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,30 @@ +import express from 'express'; +import { authController } from '../controllers/auth.controller.js'; +import { catchError } from '../utils/catchError.js'; + +export const authRouter = new express.Router(); + +authRouter.post('/registration', catchError(authController.register)); + +authRouter.get( + '/activation/:activationToken', + catchError(authController.activate), +); +authRouter.post('/login', catchError(authController.login)); +authRouter.get('/refresh', catchError(authController.refresh)); +authRouter.post('/logout', catchError(authController.logout)); + +authRouter.post( + '/reset-password', + catchError(authController.resetPasswordRequest), +); + +authRouter.put( + '/reset-password/:resetToken', + catchError(authController.resetPassword), +); + +authRouter.get( + '/confirm-new-email/:newEmailToken', + catchError(authController.confirmNewEmail), +); diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 00000000..d63b6a74 --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,32 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; +import { catchError } from '../utils/catchError.js'; + +export const userRouter = new express.Router(); + +userRouter.get('/', authMiddleware, catchError(userController.getAllActivated)); + +userRouter.get( + '/profile', + authMiddleware, + catchError(userController.getProfile), +); + +userRouter.put( + '/profile/name', + authMiddleware, + catchError(userController.changeName), +); + +userRouter.put( + '/profile/password', + authMiddleware, + catchError(userController.changePassword), +); + +userRouter.put( + '/profile/email', + authMiddleware, + catchError(userController.changeEmail), +); diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..32f9590d --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,83 @@ +import nodemailer from 'nodemailer'; + +// Create a transporter using SMTP +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +function sendActivationEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/activate/${token}`; + const html = ` +

Activate account

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

Reset your password

+ ${href} + `; + + return send({ + email, + subject: 'Reset your password', + html, + }); +} + +function sendNewEmailConfirmation(newEmail, token, oldEmail) { + const href = `${process.env.CLIENT_HOST}/confirm-new-email/${token}`; + const html = ` +

Confirm your new email

+

You requested to change your email to ${newEmail}

+ ${href} + `; + + return send({ + email: newEmail, + subject: 'Confirm your new email', + html, + }); +} + +function sendEmailChangeNotification(email) { + const html = ` +

Email changed

+

Your email has been changed. If this was not you, please contact support.

+ `; + + return send({ + email, + subject: 'Your email has been changed', + html, + }); +} + +function send({ email, subject, html }) { + return transporter.sendMail({ + to: email, + subject, + html, + }); +} + +export const emailService = { + sendActivationEmail, + sendResetPasswordEmail, + sendNewEmailConfirmation, + sendEmailChangeNotification, + send, +}; diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..3c51b584 --- /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: '5s', + }); + + 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..50f50ac5 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,27 @@ +import { Token } from '../models/token.js'; + +async function save(userId, newToken) { + let token = await Token.findOne({ where: { userId } }); + + if (!token) { + token = await Token.create({ userId, refreshToken: newToken }); + } + + token.refreshToken = newToken; + + 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..ed9e5c25 --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,182 @@ +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { ApiError } from '../exceptions/api.error.js'; +import { User } from '../models/user.js'; +import { emailService } from '../services/email.service.js'; + +export async function getAllActivated() { + return User.findAll({ + where: { + activationToken: null, + }, + }); +} + +function normalize({ id, email, name }) { + return { id, email, name }; +} + +function findByEmail(email) { + return User.findOne({ where: { email } }); +} + +function findById(id) { + return User.findByPk(id); +} + +async function register(name, email, password) { + const activationToken = uuidv4(); + + const existUser = await findByEmail(email); + + if (existUser) { + throw ApiError.badRequest('User already exists', { + email: 'User already exists', + }); + } + + await User.create({ + name, + email, + password, + activationToken, + }); + + await emailService.sendActivationEmail(email, activationToken); +} + +async function resetPasswordRequest(email) { + const user = await findByEmail(email); + + if (!user) { + throw ApiError.badRequest('User not found', { + email: 'User not found', + }); + } + + const resetPasswordToken = uuidv4(); + + user.resetPasswordToken = resetPasswordToken; + await user.save(); + + await emailService.sendResetPasswordEmail(email, resetPasswordToken); +} + +async function resetPassword(token, password) { + const user = await User.findOne({ where: { resetPasswordToken: token } }); + + if (!user) { + throw ApiError.badRequest('Invalid or expired token'); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + user.password = hashedPassword; + user.resetPasswordToken = null; + await user.save(); +} + +async function updateName(id, name) { + const user = await findById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + user.name = name; + await user.save(); + + return user; +} + +async function changePassword(id, oldPassword, newPassword) { + const user = await findById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const isPasswordValid = await bcrypt.compare(oldPassword, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Invalid password', { + oldPassword: 'Invalid password', + }); + } + + const hashedPassword = await bcrypt.hash(newPassword, 10); + + user.password = hashedPassword; + await user.save(); + + return user; +} + +async function requestEmailChange(id, newEmail, password) { + const user = await findById(id); + + if (!user) { + throw ApiError.notFound('User not found'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Invalid password', { + password: 'Invalid password', + }); + } + + const existingEmail = await findByEmail(newEmail); + + if (existingEmail) { + throw ApiError.badRequest('Email already in use', { + newEmail: 'Email already in use', + }); + } + + const newEmailToken = uuidv4(); + + user.newEmail = newEmail; + user.newEmailToken = newEmailToken; + await user.save(); + + await emailService.sendNewEmailConfirmation( + newEmail, + newEmailToken, + user.email, + ); +} + +async function confirmEmailChange(token) { + const user = await User.findOne({ where: { newEmailToken: token } }); + + if (!user) { + throw ApiError.badRequest('Invalid or expired token'); + } + + const oldEmail = user.email; + + user.email = user.newEmail; + user.newEmail = null; + user.newEmailToken = null; + await user.save(); + + await emailService.sendEmailChangeNotification(oldEmail); + + return user; +} + +export const userService = { + getAllActivated, + normalize, + findByEmail, + findById, + register, + resetPasswordRequest, + resetPassword, + updateName, + changePassword, + requestEmailChange, + confirmEmailChange, +}; diff --git a/src/utils/catchError.js b/src/utils/catchError.js new file mode 100644 index 00000000..a3c0d728 --- /dev/null +++ b/src/utils/catchError.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async function (request, response, next) { + try { + await action(request, response, 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', +});