diff --git a/src/controller/auth.controller.js b/src/controller/auth.controller.js new file mode 100644 index 00000000..c9559b70 --- /dev/null +++ b/src/controller/auth.controller.js @@ -0,0 +1,226 @@ +/* eslint-disable no-useless-return */ +import { userServices } from '../services/user.services.js'; +import { emailServices } from '../services/email.services.js'; +import { jwtService } from '../services/jwt.services.js'; +import bcrypt, { compare } from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { User } from '../models/User.model.js'; + +function validateEmail(value) { + const EMAIL_PATTERN = /^[\w.+-]+@([\w-]+\.){1,3}[\w-]{2,}$/; + + if (!value) { + return 'Email is required'; + } + + if (!EMAIL_PATTERN.test(value)) { + return 'Email is not valid'; + } +} + +function validateName(value) { + if (!value) { + return 'Name is required'; + } + + if (value.trim().length < 4) { + return 'Name length must be more than 4 symbols'; + } +} + +function validatePassword(value) { + if (!value) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'At least 6 characters'; + } +} + +const registerUser = async (req, res) => { + try { + const { name, email, password } = req.body; + const activationToken = uuidv4(); + + if (!name || !email || !password) { + res.status(400).json({ message: 'All fields are required' }); + + return; + } + + const errors = { + email: validateEmail(email), + password: validatePassword(password), + name: validateName(name), + }; + + if (errors.email || errors.password || errors.name) { + res.status(400).json(errors); + + return; + } + + const hashPassword = bcrypt.hashSync(password, 10); + + await userServices.registerUser(name, email, hashPassword, activationToken); + + await emailServices.sendActivationEmail(email, activationToken); + + res + .status(201) + .json({ message: 'User registered. Check your email for activation.' }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}; + +const activateUser = async (req, res) => { + const { activationToken } = req.params; + + const user = await User.findOne({ + where: { activationToken }, + }); + + if (!user) { + res.status(404).json({ message: 'User not found' }); + + return; + } + + user.activationToken = null; + await user.save(); + + res.redirect('/profile'); +}; + +const loginUser = async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).send({ message: 'All fields are required' }); + } + + const user = await userServices.findUser(email); + + if (!user) { + res.status(401).json({ message: 'User not found' }); + + return; + } + + if (user.activationToken !== null) { + res.status(403).json({ message: 'Please activate your email' }); + + return; + } + + const isPasswordValid = await compare(password, user.password); + + if (!isPasswordValid) { + res.status(401).json({ message: 'Invalid credentials' }); + + return; + } + + await generateTokens(res, user); + res.redirect('/profile'); +}; + +const refresh = (req, res) => { + const { refreshToken } = req.cookies; + + const user = jwtService.verifyRefresh(refreshToken); + + if (!user) { + res.sendStatus(401); + + return; + } + + generateTokens(res, user); +}; + +const generateTokens = async (res, user) => { + const normilizeUser = userServices.normilizeUser(user); + const accessToken = jwtService.sign(normilizeUser); + const refreshToken = jwtService.signRefresh(normilizeUser); + + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: true, + maxAge: 30 * 24 * 60 * 60 * 1000, + }); + + res.send({ + user: normilizeUser, + accessToken, + }); +}; + +const logout = (req, res) => { + res.clearCookie('refreshToken').redirect('/login'); +}; + +const forgot = async (req, res) => { + const { email } = req.body; + + if (!email) { + res.status(400).json({ message: 'Email is required' }); + + return; + } + + const user = await userServices.findUser(email); + + if (!user) { + res.sendStatus(200); + + return; + } + + const resetToken = uuidv4(); + + user.resetToken = resetToken; + + await user.save(); + await emailServices.sendResetPasswordEmail(email, resetToken); + + res.send({ message: 'Password reset email sent' }); +}; + +const resetPassword = async (req, res) => { + const { resetToken } = req.params; + const { password, confirmation } = req.body; + + if (!password || password !== confirmation) { + res.status(400).json({ message: 'Passwords do not match' }); + + return; + } + + const user = await User.findOne({ where: { resetToken } }); + + if (!user) { + res.status(400).json({ message: 'Invalid reset token' }); + + return; + } + + user.password = bcrypt.hashSync(password, 10); + user.resetToken = null; + + await user.save(); + + res.send({ message: 'Password successfully changed' }); +}; + +export const authController = { + registerUser, + activateUser, + loginUser, + refresh, + logout, + forgot, + resetPassword, +}; diff --git a/src/controller/user.controller.js b/src/controller/user.controller.js new file mode 100644 index 00000000..1c8a541c --- /dev/null +++ b/src/controller/user.controller.js @@ -0,0 +1,108 @@ +import { User } from '../models/User.model.js'; +import { userServices } from '../services/user.services.js'; +import { emailServices } from '../services/email.services.js'; +import bcrypt from 'bcrypt'; + +const getAllUsers = async (req, res) => { + const users = await User.findAll(); + + res.send(users); +}; + +const getUserById = async (req, res) => { + const { userId } = req.params; + + const user = await userServices.findUserById(userId); + + res.send(user); +}; + +const updateName = async (req, res) => { + try { + const userId = req.user.userId; + const { name } = req.body; + + if (!name || !name.trim()) { + res.status(400).json({ message: 'Name is required' }); + + return; + } + + const user = await userServices.updateNameService(userId, name); + + res.send(user); + } catch (error) { + res.status(500).send(error); + } +}; + +const updatePassword = async (req, res) => { + const { oldPassword, newPassword, confirmation } = req.body; + const userId = req.user.userId; + + if (newPassword !== confirmation) { + res.status(400).json({ message: 'Passwords do not match' }); + + return; + } + + const user = await userServices.findUserById(userId); + const isValid = await bcrypt.compare(oldPassword, user.password); + + if (!isValid) { + res.status(401).json({ message: 'Old password is incorrect' }); + + return; + } + + user.password = bcrypt.hashSync(newPassword, 10); + await user.save(); + + res.send({ message: 'Password updated successfully' }); +}; + +const updateEmail = async (req, res) => { + const { password, newEmail, confirmation } = req.body; + const userId = req.user.userId; + + if (!newEmail || !confirmation) { + res + .status(400) + .json({ message: 'New email and confirmation are required' }); + + return; + } + + if (newEmail !== confirmation) { + res.status(400).json({ message: 'Emails do not match' }); + + return; + } + + const user = await userServices.findUserById(userId); + + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + res.status(401).json({ message: 'Invalid password' }); + + return; + } + + const oldEmail = user.email; + + user.email = newEmail; + + await user.save(); + await emailServices.sendEmailChangedNotification(oldEmail, newEmail); + + res.send({ message: 'Email updated successfully' }); +}; + +export const userController = { + getAllUsers, + getUserById, + updateName, + updateEmail, + updatePassword, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7..f2b5e971 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,23 @@ -'use strict'; +import express from 'express'; +import authRouter from './router/auth.router.js'; +import userRouter from './router/user.router.js'; +import cookieParser from 'cookieParser'; +import { authMiddleware } from './middlewares/auth.mildware.js'; + +const app = express(); +const port = process.env.PORT || 3000; + +app.use(express.json()); +app.use(cookieParser()); + +app.use('/', authRouter); +app.use('/user', authMiddleware, userRouter); + +app.use('*', (req, res) => { + res.status(404).json({ message: 'Not found' }); +}); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Server running at http://localhost:${port}`); +}); diff --git a/src/middlewares/auth.mildware.js b/src/middlewares/auth.mildware.js new file mode 100644 index 00000000..a3eeef16 --- /dev/null +++ b/src/middlewares/auth.mildware.js @@ -0,0 +1,24 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddleware = (req, res, next) => { + const authorization = req.headers.authorization; + + if (!authorization) { + return res.sendStatus(401); + } + + 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/models/Token.model.js b/src/models/Token.model.js new file mode 100644 index 00000000..b0cfe255 --- /dev/null +++ b/src/models/Token.model.js @@ -0,0 +1,21 @@ +import { DataTypes } from 'sequelize'; +import client from '../util/db.js'; +import { User } from './User.model.js'; + +export const Token = client.define( + 'Token', + { + refreshToken: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'tokens', + timestamps: true, + underscored: true, + }, +); + +Token.belongsTo(User, { foreignKey: 'user_id', onDelete: 'CASCADE' }); +User.hasOne(Token, { foreignKey: 'user_id' }); diff --git a/src/models/User.model.js b/src/models/User.model.js new file mode 100644 index 00000000..562e363a --- /dev/null +++ b/src/models/User.model.js @@ -0,0 +1,40 @@ +import { DataTypes } from 'sequelize'; +import client from '../util/db.js'; + +export const User = client.define( + 'User', + { + userId: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + field: 'user_id', + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + activationToken: { + type: DataTypes.STRING, + field: 'activation_token', + }, + resetToken: { + type: DataTypes.STRING, + field: 'reset_token', + }, + }, + { + tableName: 'users', + timestamps: true, + underscored: true, + }, +); diff --git a/src/router/auth.router.js b/src/router/auth.router.js new file mode 100644 index 00000000..0a3f26a8 --- /dev/null +++ b/src/router/auth.router.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { authController } from '../controller/auth.controller.js'; + +const router = express.Router(); + +router.post('/auth', authController.registerUser); +router.get('/activate/:activationToken', authController.activateUser); +router.post('/login', authController.loginUser); +router.get('/refresh', authController.refresh); +router.get('/logout', authController.logout); +router.post('/forgot', authController.forgot); +router.post('/password-reset/:resetToken', authController.resetPassword); + +export default router; diff --git a/src/router/user.router.js b/src/router/user.router.js new file mode 100644 index 00000000..35e6475a --- /dev/null +++ b/src/router/user.router.js @@ -0,0 +1,11 @@ +import express from 'express'; +import { userController } from '../controller/user.controller.js'; + +const router = express.Router(); + +router.get('/', userController.getAllUsers); +router.patch('/name', userController.updateName); +router.patch('/email', userController.updateEmail); +router.patch('/password', userController.updatePassword); + +export default router; diff --git a/src/services/email.services.js b/src/services/email.services.js new file mode 100644 index 00000000..9a6ab837 --- /dev/null +++ b/src/services/email.services.js @@ -0,0 +1,61 @@ +/* eslint-disable no-console */ +import 'dotenv/config'; +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +function sendEmail(email, subject, html) { + return transporter.sendMail({ + to: email, + subject: subject, + html, + }); +} + +function sendActivationEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/activate/${token}`; + + const html = ` + Account activation, + ${href} + `; + + return sendEmail(email, 'Account activation', html); +} + +function sendResetPasswordEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/password-reset/${token}`; + const html = ` +
Click the link below to reset your password:
+ ${href} + `; + + return sendEmail(email, 'Reset Your Password', html); +} + +async function sendEmailChangedNotification(oldEmail, newEmail) { + await transporter.sendMail({ + to: oldEmail, + subject: 'Your email was changed', + html: ` +Your account email was changed.
+New email: ${newEmail}
+If this was not you — contact support immediately.
+ `, + }); +} + +export const emailServices = { + sendActivationEmail, + sendEmail, + sendResetPasswordEmail, + sendEmailChangedNotification, +}; diff --git a/src/services/jwt.services.js b/src/services/jwt.services.js new file mode 100644 index 00000000..b3fcaf36 --- /dev/null +++ b/src/services/jwt.services.js @@ -0,0 +1,40 @@ +import jwt from 'jsonwebtoken'; + +function sign(user) { + const token = jwt.sign(user, process.env.JWT_SECRET, { + expiresIn: '1d', + }); + + return token; +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch (error) { + return null; + } +} + +function signRefresh(user) { + const token = jwt.sign(user, process.env.JWT_REFRESH_SECRET, { + expiresIn: '1d', + }); + + return token; +} + +function verifyRefresh(token) { + try { + return jwt.verify(token, process.env.JWT_REFRESH_SECRET); + } catch (error) { + return null; + } +} + +export const jwtService = { + sign, + verify, + signRefresh, + verifyRefresh, +}; diff --git a/src/services/user.services.js b/src/services/user.services.js new file mode 100644 index 00000000..868770a7 --- /dev/null +++ b/src/services/user.services.js @@ -0,0 +1,62 @@ +import { User } from '../models/User.model.js'; + +async function registerUser(name, email, password, activationToken) { + const user = await findUser(email); + + if (user) { + throw new Error('User already exists'); + } + + await User.create({ + name, + email, + password, + activationToken, + }); +} + +const findUser = async (email) => { + const user = await User.findOne({ + where: { + email, + }, + }); + + return user; +}; + +const findUserById = async (userId) => { + const user = await User.findOne({ + where: { + userId, + }, + }); + + return user; +}; + +function normilizeUser(user) { + return { + userId: user.userId, + email: user.email, + }; +} + +const updateNameService = (id, name) => + User.update({ name }, { where: { userId: id } }); + +const updateEmailService = (id, email) => + User.update({ email }, { where: { userId: id } }); + +const updatePasswordService = (id, password) => + User.update({ password }, { where: { userId: id } }); + +export const userServices = { + registerUser, + findUser, + normilizeUser, + findUserById, + updateNameService, + updateEmailService, + updatePasswordService, +}; diff --git a/src/util/db.js b/src/util/db.js new file mode 100644 index 00000000..ab1b0f57 --- /dev/null +++ b/src/util/db.js @@ -0,0 +1,14 @@ +import { Sequelize } from 'sequelize'; +import 'dotenv/config'; + +const client = new Sequelize( + process.env.POSTGRES_DB, + process.env.POSTGRES_USER, + process.env.POSTGRES_PASSWORD, + { + host: process.env.POSTGRES_HOST, + dialect: 'postgres', + }, +); + +export default client;