diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..f02e21a7 --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,183 @@ +import { User } from '../models/user.js'; + +import { userService } from '../services/user.services.js'; +import { tokenService } from '../services/token.service.js'; +import { jwtService } from '../services/jwt.service.js'; +import { ApiError } from '../exeptions/api.error.js'; +import bcrypt from 'bcrypt'; + +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'; + } +} + +const validatePassword = (value) => { + if (!value) { + return 'Password is required'; + } + + if (value.length < 6) { + return 'At least 6 characters'; + } +}; +const validateName = (value) => { + if (!value) { + return 'Name is last'; + } + + if (value.length < 2) { + return 'At least 2 characters'; + } +}; + +const register = async (req, res, next) => { + const { name, email, password } = req.body; + const errors = { + name: validateName(name), + email: validateEmail(email), + password: validatePassword(password), + }; + + if (errors.email || errors.password || errors.name) { + throw ApiError.badRequest('Bad request', errors); + } + + const hashedPass = await bcrypt.hash(password, 10); + + await userService.register(name, email, hashedPass); + res.send({ message: 'OK' }); +}; + +const activate = async (req, res) => { + const { activationToken } = req.params; + const user = await User.findOne({ where: { activationToken } }); + + if (!user) { + res.sendStatus(404); + + return; + } + user.activationToken = null; + + await user.save(); + res.send(user); +}; + +const login = async (req, res) => { + const { email, password } = req.body; + const user = await userService.findByEmail(email); + + if (!user) { + throw ApiError.badRequest('No such user'); + } + + if (user.activationToken !== null) { + throw ApiError.badRequest('you must activated your email check your box'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Wrong password'); + } + await generateTokens(res, user); +}; +const refresh = async (req, res) => { + const { refreshToken } = req.cookies; + + if (!refreshToken) { + throw ApiError.unauthorized(); + } + + const payload = await jwtService.verifyRefresh(refreshToken); + + if (!payload) { + throw ApiError.unauthorized(); + } + + const tokenRecord = await tokenService.getByToken(refreshToken); + + if (!tokenRecord) { + throw ApiError.unauthorized(); + } + + const { user: payloadUser } = payload; + + const user = await userService.findByEmail(payloadUser.email); + + if (!user) { + throw ApiError.unauthorized(); + } + + generateTokens(res, user); +}; + +const generateTokens = async (res, user) => { + const normalizeUser = userService.normalize(user); + const accessToken = jwtService.sign(normalizeUser); + const refreshAccessToken = jwtService.signRefresh(normalizeUser); + + await tokenService.save(normalizeUser.id, refreshAccessToken); + + res.cookie('refreshToken', refreshAccessToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + }); + + res.send({ + user: normalizeUser, + accessToken, + }); +}; + +const logout = async (req, res) => { + const { refreshToken } = req.cookies; + const payload = await jwtService.verifyRefresh(refreshToken); + + if (!refreshToken || !payload) { + throw ApiError.unauthorized(); + } + + await tokenService.remove(payload.user.id); + res.clearCookie('refreshToken', { httpOnly: true }); + res.sendStatus(204); +}; + +const resetPassword = async (req, res) => { + const { email } = req.body; + const errors = { + email: validateEmail(email), + }; + + if (errors.email) { + throw ApiError.badRequest('Bad request', errors); + } + + await userService.resetPassword(email); + res.send({ message: 'email send' }); +}; + +const confirm = async (req, res) => { + const { password, reppassword, token } = req.body; + + await userService.confirmReset(password, reppassword, token); + res.sendStatus(204); +}; + +export const authController = { + register, + activate, + login, + refresh, + logout, + resetPassword, + confirm, + validateEmail, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..8b57b8e5 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,154 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { User } from '../models/user.js'; +import { userService } from '../services/user.services.js'; +import bcrypt from 'bcrypt'; +import { authController } from './auth.controller.js'; +import { emailServices } from '../services/mail.services.js'; +import { v4 as uuidv4 } from 'uuid'; + +const getAllActivated = async (req, res) => { + const user = await userService.getAllActivated(); + + res.send(user.map(userService.normalize)); +}; + +const changeName = async (req, res) => { + const { name } = req.body; + const userId = req.user.id; + + if (name.length === 0) { + throw ApiError.badRequest('name is empty'); + } + + if (!userId) { + throw ApiError.badRequest('unautorized'); + } + await User.update({ name }, { where: { id: userId } }); + + res.json({ message: 'Name updated successfully' }); +}; + +const changePassword = async (req, res) => { + const { password, newPassword, confirmPassword } = req.body; + + const userId = req.user.id; + + if (!password || !newPassword || !confirmPassword) { + throw ApiError.badRequest('some passwords are empty'); + } + + if (newPassword !== confirmPassword) { + throw ApiError.badRequest('New password and confirmation do not match'); + } + + if (!userId) { + throw ApiError.badRequest('user unatorization'); + } + + const user = await User.findOne({ where: { id: userId } }); + + if (!user) { + throw ApiError.unauthorized('user unauthorized'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('Wrong password'); + } + + const hashedPass = await bcrypt.hash(newPassword, 10); + + user.password = hashedPass; + await user.save(); + res.json({ message: 'Password updated successfully' }); +}; + +const changeEmail = async (req, res) => { + const { email, password } = req.body; + const userId = req.user.id; + + if (!email || !password) { + throw ApiError.badRequest('empty email or password'); + } + + const errors = { + email: authController.validateEmail(email), + }; + + if (errors.email) { + throw ApiError.badRequest('error email'); + } + + const user = await User.findOne({ where: { id: userId } }); + + if (!user) { + throw ApiError.unauthorized('unauthorized'); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + throw ApiError.badRequest('wrong password'); + } + + const token = uuidv4(); + + user.resetEmailToken = token; + user.newEmail = email; + await user.save(); + + await emailServices.sendChangeNewEmail(user.email, email, token); + + res.send({ message: 'email sent' }); +}; + +const confirmNewEmail = async (req, res) => { + const { token } = req.query; + const activeToken = uuidv4(); + + if (!token) { + throw ApiError.badRequest('invalid token'); + } + + const findUser = await User.findOne({ where: { resetEmailToken: token } }); + + if (!findUser) { + throw ApiError.badRequest(' user not found'); + } + + findUser.resetEmailToken = null; + findUser.activationToken = activeToken; + await findUser.save(); + await emailServices.sendNewEmail(findUser.newEmail, activeToken); + + res.send(200); +}; + +const finallConfirm = async (req, res) => { + const { token } = req.query; + + if (!token) { + throw ApiError.badRequest('invalid token'); + } + + const findUser = await User.findOne({ where: { activationToken: token } }); + + if (!findUser) { + throw ApiError.badRequest('no user'); + } + findUser.email = findUser.newEmail; + findUser.newEmail = null; + findUser.activationToken = null; + await findUser.save(); + res.json('email is active now '); +}; + +export const userController = { + getAllActivated, + changeName, + changePassword, + changeEmail, + confirmNewEmail, + finallConfirm, +}; diff --git a/src/createServer.js b/src/createServer.js new file mode 100644 index 00000000..dc7572ca --- /dev/null +++ b/src/createServer.js @@ -0,0 +1,35 @@ +import express from 'express'; +import 'dotenv/config'; +import { authRouter } from './routes/auth.route.js'; +import cors from 'cors'; +import { userRouter } from './routes/user.route.js'; +import { errorMiddlewares } from './middlewares/errorMiddlewares.js'; +import cookieParser from 'cookie-parser'; + +export function createServer() { + 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('/', (req, res) => { + res.send('hello'); + }); + app.use(errorMiddlewares); + + app.use('*', (req, res) => { + res.status(404).json({ message: 'Not Found' }); + }); + + return app; +} diff --git a/src/exeptions/api.error.js b/src/exeptions/api.error.js new file mode 100644 index 00000000..bd9e30d4 --- /dev/null +++ b/src/exeptions/api.error.js @@ -0,0 +1,29 @@ +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 user', + errors, + status: 404, + }); + } +} diff --git a/src/index.js b/src/index.js index ad9a93a7..a2dee405 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,17 @@ +/* eslint-disable no-console */ 'use strict'; +import 'dotenv/config'; +import { createServer } from './createServer.js'; + +const PORT = process.env.PORT || 3004; + +async function start() { + try { + createServer().listen(PORT, () => { + console.log(`server runing on ${PORT}`); + }); + } catch { + console.log('error start server'); + } +} +start(); diff --git a/src/middlewares/authMiddlewares.js b/src/middlewares/authMiddlewares.js new file mode 100644 index 00000000..9f189303 --- /dev/null +++ b/src/middlewares/authMiddlewares.js @@ -0,0 +1,22 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddlewares = (req, res, next) => { + const authorization = req.headers['authorization'] || ''; + const [, token] = authorization.split(' '); + + if (!authorization || !token) { + res.sendStatus(401); + + return; + } + + const userData = jwtService.verify(token); + + if (!userData) { + res.sendStatus(401); + + return; + } + req.user = userData.user; + next(); +}; diff --git a/src/middlewares/errorMiddlewares.js b/src/middlewares/errorMiddlewares.js new file mode 100644 index 00000000..e5b99db6 --- /dev/null +++ b/src/middlewares/errorMiddlewares.js @@ -0,0 +1,18 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const errorMiddlewares = (error, req, res, next) => { + if (error instanceof ApiError) { + res.status(error.status).send({ + message: error.message, + error: error.errors, + }); + + return; + } + + res.statusCode = 500; + + res.send({ + message: 'server errorooooooooooooooo', + }); +}; diff --git a/src/models/token.js b/src/models/token.js new file mode 100644 index 00000000..3ae1fdca --- /dev/null +++ b/src/models/token.js @@ -0,0 +1,11 @@ +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..68a7b2f8 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,34 @@ +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, + }, + resetToken: { + type: DataTypes.STRING, + allowNull: true, + }, + resetEmailToken: { + type: DataTypes.STRING, + allowNull: true, + }, + newEmail: { + type: DataTypes.STRING, + allowNull: true, + unique: true, + }, +}); diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..ce12e07e --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,17 @@ +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/:email/: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', catchError(authController.resetPassword)); +authRouter.post('/confirm-reset', catchError(authController.confirm)); diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 00000000..0f069fcf --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,33 @@ +import express from 'express'; +import { userController } from '../controllers/user.controller.js'; +import { authMiddlewares } from '../middlewares/authMiddlewares.js'; +import { catchError } from '../utils/catchError.js'; + +export const userRouter = new express.Router(); + +userRouter.get( + '/', + authMiddlewares, + catchError(userController.getAllActivated), +); + +userRouter.patch( + '/change-name', + authMiddlewares, + catchError(userController.changeName), +); + +userRouter.post( + '/change-password', + authMiddlewares, + catchError(userController.changePassword), +); + +userRouter.post( + '/change-email', + authMiddlewares, + catchError(userController.changeEmail), +); + +userRouter.get('/confirm-email', catchError(userController.confirmNewEmail)); +userRouter.get('/confirm-finall', catchError(userController.finallConfirm)); diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 00000000..aa061bed --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,38 @@ +import jwt from 'jsonwebtoken'; +import 'dotenv/config'; + +function sign(user) { + const token = jwt.sign({ user }, process.env.JWT_KEY, { + expiresIn: '15m', + }); + + return token; +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_KEY); + } catch { + 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 { + return null; + } +} +export const jwtService = { + sign, + verify, + verifyRefresh, + signRefresh, +}; diff --git a/src/services/mail.services.js b/src/services/mail.services.js new file mode 100644 index 00000000..d1cf0aa0 --- /dev/null +++ b/src/services/mail.services.js @@ -0,0 +1,79 @@ +import nodemailer from 'nodemailer'; +import 'dotenv/config'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: true, // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +export function send({ email, subject, html }) { + return transporter.sendMail({ + to: email, + subject: subject, + html: html, + }); +} + +function sendActivationEmail(name, email, token) { + const href = `${process.env.CLIENT_HOST}/activate/${encodeURIComponent(email)}/${token}`; + const html = `

Activate Account from ${name}

+ ${href} `; + + return send({ + email, + html, + subject: 'activate', + }); +} + +function sendResetEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/reset/${encodeURIComponent(email)}/${token}`; + const html = `

Reset password for ${email}

+ ${href} `; + + return send({ + email, + html, + subject: 'resetPassword', + }); +} + +function sendChangeNewEmail(email, newEmail, token) { + const href = `${process.env.CLIENT_HOST}/change/${encodeURIComponent(email)}/${token}`; + + const html = `

change of email ${email}

+

ON ${newEmail}

+ ${href} `; + + return send({ + email, + html, + subject: 'changeEmail', + }); +} + +function sendNewEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/confirm/${encodeURIComponent(email)}/${token}`; + + const html = `

your new email for login ${email}

+

ON ${email}

+ ${href} `; + + return send({ + email, + html, + subject: 'New Email', + }); +} +export const emailServices = { + send, + sendActivationEmail, + sendResetEmail, + sendChangeNewEmail, + sendNewEmail, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 00000000..046474a3 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,26 @@ +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(); +} + +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.services.js b/src/services/user.services.js new file mode 100644 index 00000000..eca9505d --- /dev/null +++ b/src/services/user.services.js @@ -0,0 +1,78 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { User } from '../models/user.js'; +import { emailServices } from '../services/mail.services.js'; +import { v4 as uuidv4 } from 'uuid'; +import bycrypt from 'bcrypt'; + +function getAllActivated() { + return User.findAll({ + where: { activationToken: null }, + }); +} + +function normalize({ id, email }) { + return { id, email }; +} + +function findByEmail(email) { + return User.findOne({ where: { 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 emailServices.sendActivationEmail(name, email, activationToken); +} + +async function resetPassword(email) { + const resToken = uuidv4(); + const findUser = await findByEmail(email); + + if (!findUser) { + throw ApiError.badRequest('sorry'); + } + findUser.resetToken = resToken; + await findUser.save(); + await emailServices.sendResetEmail(email, resToken); +} + +async function confirmReset(password1, password2, resetToken) { + if (password1 !== password2 || !resetToken) { + throw ApiError.badRequest('Password do not match'); + } + + const findUser = await User.findOne({ where: { resetToken } }); + + if (!findUser) { + throw ApiError.badRequest('try again'); + } + + const hashedPass = await bycrypt.hash(password1, 10); + + findUser.resetToken = null; + findUser.password = hashedPass; + await findUser.save(); +} + +export const userService = { + getAllActivated, + normalize, + findByEmail, + register, + resetPassword, + confirmReset, +}; 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..668b0887 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,10 @@ +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', + port: process.env.DB_PORT || 5432, +});