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',
+});