diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js new file mode 100644 index 00000000..24cbde6e --- /dev/null +++ b/src/controllers/auth.controller.js @@ -0,0 +1,111 @@ +'use strict'; + +import * as authService from '../services/auth.service.js'; + +const COOKIE_OPTIONS = { + httpOnly: true, + signed: true, + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // cookie dura 7 dias +}; + +export const register = async (req, res) => { + const { name, email, password } = req.body; + + if (!name || !email || !password) { + return res + .status(400) + .json({ message: 'Name, email and password are required' }); + } + + try { + await authService.register({ name, email, password }); + + return res.status(201).json({ + message: 'Registration successful. Please check your email.', + }); + } catch (err) { + return res.status(err.status || 500).json({ + message: err.message, + errors: err.errors, + }); + } +}; + +export const activate = async (req, res) => { + const { token } = req.params; + + try { + await authService.activate(token); + return res.json({ + message: 'Account activated successfully. You can now log in.', + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const login = async (req, res) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ message: 'Email and password are required' }); + } + try { + const user = await authService.login({ email, password }); + + res.cookie('userId', user.id, COOKIE_OPTIONS); + return res.json({ + user: { id: user.id, name: user.name, email: user.email }, + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const logout = async (req, res) => { + res.clearCookie('userId'); + + return res.json({ message: 'Logged out successfully' }); +}; + +export const requestPasswordReset = async (req, res) => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: 'Email is required' }); + } + + try { + await authService.requestPasswordReset(email); + + return res.json({ + message: + 'If that email is registered, a password reset link has been sent.', + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const confirmPasswordReset = async (req, res) => { + const { token } = req.params; + const { password, confirmation } = req.body; + if (!password || !confirmation) { + return res + .status(400) + .json({ message: 'Password and confirmation are required' }); + } + + try { + await authService.resetPassword({ token, password, confirmation }); + return res.json({ + message: 'Password reset successfully.', + }); + } catch (err) { + return res.status(err.status || 500).json({ + message: err.message, + errors: err.errors, + }); + } +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 00000000..96f200f2 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,88 @@ +'use strict'; + +import * as userService from '../services/user.service.js'; + +export const getProfile = async (req, res) => { + try { + const user = await userService.getProfile(req.user.id); + return res.json({ user }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const changeName = async (req, res) => { + const { name } = req.body; + + try { + const user = await userService.changeName(req.user.id, name); + + return res.json({ + message: 'Name updated successfully', + user: { id: user.id, name: user.name, email: user.email }, + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const changePassword = async (req, res) => { + const { oldPassword, newPassword, confirmation } = req.body; + + if (!oldPassword || !newPassword || !confirmation) { + return res.status(400).json({ + message: 'Old password, new password and confirmation are required', + }); + } + + try { + await userService.changePassword(req.user.id, { + oldPassword, + newPassword, + confirmation, + }); + + return res.json({ + message: 'Password changed successfully. Please log in again.', + }); + } catch (err) { + return res.status(err.status || 500).json({ + message: err.message, + errors: err.errors, + }); + } +}; + +export const changeEmail = async (req, res) => { + const { password, newEmail } = req.body; + + if (!password || !newEmail) { + return res + .status(400) + .json({ message: 'Password and new email are required' }); + } + + try { + await userService.changeEmail(req.user.id, { password, newEmail }); + return res.json({ + message: 'A confirmation link has been sent to your new email address.', + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; + +export const confirmEmailChange = async (req, res) => { + const { token } = req.params; + + try { + const user = await userService.confirmEmailChange(token); + + return res.json({ + message: 'Email changed successfully.', + user: { id: user.id, name: user.name, email: user.email }, + }); + } catch (err) { + return res.status(err.status || 500).json({ message: err.message }); + } +}; diff --git a/src/db.js b/src/db.js new file mode 100644 index 00000000..c34cd139 --- /dev/null +++ b/src/db.js @@ -0,0 +1,17 @@ +'use strict'; + +import { Sequelize } from 'sequelize'; + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + dialect: 'postgres', + logging: false, + }, +); + +export default sequelize; diff --git a/src/index.js b/src/index.js index ad9a93a7..eaf7811c 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,35 @@ 'use strict'; + +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import { initDb } from './models/index.js'; +import authRouter from './routes/auth.route.js'; +import userRouter from './routes/user.route.js'; + +const PORT = process.env.PORT || 3005; + +const app = express(); + +app.use( + cors({ + origin: process.env.CLIENT_URL || 'http://localhost:3000', + credentials: true, + }), +); +app.use(express.json()); +app.use(cookieParser(process.env.COOKIE_SECRET || 'cookie_secret_change_me')); +app.use('/auth', authRouter); +app.use('/user', userRouter); +app.use((req, res) => { + res.status(404).json({ message: 'Not found' }); +}); + +initDb() + .then(() => { + app.listen(PORT); + }) + .catch(() => { + process.exit(1); + }); diff --git a/src/middlewares/auth.middleware.js b/src/middlewares/auth.middleware.js new file mode 100644 index 00000000..994699b6 --- /dev/null +++ b/src/middlewares/auth.middleware.js @@ -0,0 +1,12 @@ +'use strict'; + +export const authMiddleware = (req, res, next) => { + const userId = req.signedCookies?.userId; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + req.user = { id: userId }; + next(); +}; diff --git a/src/middlewares/guest.middleware.js b/src/middlewares/guest.middleware.js new file mode 100644 index 00000000..2d1d1f1e --- /dev/null +++ b/src/middlewares/guest.middleware.js @@ -0,0 +1,9 @@ +'use strict'; + +export const guestMiddleware = (req, res, next) => { + if (req.signedCookies?.userId) { + return res.status(403).json({ message: 'You are already authenticated' }); + } + + next(); +}; diff --git a/src/models/User.model.js b/src/models/User.model.js new file mode 100644 index 00000000..ecab1344 --- /dev/null +++ b/src/models/User.model.js @@ -0,0 +1,51 @@ +'use strict'; + +import { DataTypes } from 'sequelize'; +import sequelize from '../db.js'; + +const User = sequelize.define('User', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + activationToken: { + type: DataTypes.STRING, + allowNull: true, + }, + passwordResetToken: { + type: DataTypes.STRING, + allowNull: true, + }, + passwordResetExpiry: { + type: DataTypes.DATE, + allowNull: true, + }, + pendingEmail: { + type: DataTypes.STRING, + allowNull: true, + }, + emailChangeToken: { + type: DataTypes.STRING, + allowNull: true, + }, +}); + +export default User; diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 00000000..19aa4a51 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,10 @@ +'use strict'; + +import sequelize from '../db.js'; +import User from './User.model.js'; + +export const initDb = async () => { + await sequelize.sync(); +}; + +export { User }; diff --git a/src/routes/auth.route.js b/src/routes/auth.route.js new file mode 100644 index 00000000..440c3dac --- /dev/null +++ b/src/routes/auth.route.js @@ -0,0 +1,17 @@ +'use strict'; + +import express from 'express'; +import * as authController from '../controllers/auth.controller.js'; +import { guestMiddleware } from '../middlewares/guest.middleware.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; + +const authRouter = express.Router(); + +authRouter.post('/register', guestMiddleware, authController.register); +authRouter.get('/activate/:token', guestMiddleware, authController.activate); +authRouter.post('/login', guestMiddleware, authController.login); +authRouter.post('/logout', authMiddleware, authController.logout); +authRouter.post('/reset-password', guestMiddleware, authController.requestPasswordReset); +authRouter.post('/reset-password/:token', guestMiddleware, authController.confirmPasswordReset); + +export default authRouter; diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 00000000..0d8d197a --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,17 @@ +'use strict'; + +import express from 'express'; +import * as userController from '../controllers/user.controller.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; + +const userRouter = express.Router(); + +userRouter.use(authMiddleware); + +userRouter.get('/profile', userController.getProfile); +userRouter.patch('/profile/name', userController.changeName); +userRouter.patch('/profile/password', userController.changePassword); +userRouter.patch('/profile/email', userController.changeEmail); +userRouter.get('/profile/confirm-email/:token', userController.confirmEmailChange); + +export default userRouter; diff --git a/src/services/auth.service.js b/src/services/auth.service.js new file mode 100644 index 00000000..f43d1e16 --- /dev/null +++ b/src/services/auth.service.js @@ -0,0 +1,131 @@ +'use strict'; + +import { v4 as uuidv4 } from 'uuid'; +import { User } from '../models/index.js'; +import * as passwordService from './password.service.js'; +import * as emailService from './email.service.js'; + +export const register = async ({ name, email, password }) => { + const existing = await User.findOne({ where: { email } }); + + if (existing) { + throw Object.assign(new Error('Email is already in use'), { status: 409 }); + } + + const passwordErrors = passwordService.validate(password); + + if (passwordErrors.length) { + throw Object.assign(new Error('Invalid password'), { + status: 400, + errors: passwordErrors, + }); + } + + const hashedPassword = await passwordService.hash(password); + const activationToken = uuidv4(); + + const user = await User.create({ + name, + email, + password: hashedPassword, + activationToken, + }); + + try { + await emailService.sendActivation(email, activationToken); + } catch (emailErr) { + throw new Error(emailErr.message); + } + + return user; +}; + +export const activate = async (token) => { + const user = await User.findOne({ where: { activationToken: token } }); + + if (!user) { + throw Object.assign(new Error('Invalid activation token'), { status: 400 }); + } + + user.isActive = true; + user.activationToken = null; + await user.save(); + + return user; +}; + +export const login = async ({ email, password }) => { + const user = await User.findOne({ where: { email } }); + + if (!user) { + throw Object.assign(new Error('Invalid credentials'), { status: 401 }); + } + + const isValid = await passwordService.compare(password, user.password); + + if (!isValid) { + throw Object.assign(new Error('Invalid credentials'), { status: 401 }); + } + + if (!user.isActive) { + throw Object.assign( + new Error('Please activate your account via the email we sent you'), + { status: 403 }, + ); + } + + return user; +}; + +export const requestPasswordReset = async (email) => { + const user = await User.findOne({ where: { email } }); + + if (!user) { + return; + } + + const resetToken = uuidv4(); + const expiry = new Date(Date.now() + 60 * 60 * 1000); + + user.passwordResetToken = resetToken; + user.passwordResetExpiry = expiry; + await user.save(); + + try { + await emailService.sendPasswordReset(email, resetToken); + } catch (emailErr) { + throw new Error(emailErr.message); + } +}; + +export const resetPassword = async ({ token, password, confirmation }) => { + if (password !== confirmation) { + throw Object.assign(new Error('Passwords do not match'), { status: 400 }); + } + + const passwordErrors = passwordService.validate(password); + + if (passwordErrors.length) { + throw Object.assign(new Error('Invalid password'), { + status: 400, + errors: passwordErrors, + }); + } + + const user = await User.findOne({ where: { passwordResetToken: token } }); + + if (!user) { + throw Object.assign(new Error('Invalid or expired reset token'), { + status: 400, + }); + } + + if (user.passwordResetExpiry < new Date()) { + throw Object.assign(new Error('Reset token has expired'), { status: 400 }); + } + + user.password = await passwordService.hash(password); + user.passwordResetToken = null; + user.passwordResetExpiry = null; + await user.save(); +}; diff --git a/src/services/email.service.js b/src/services/email.service.js new file mode 100644 index 00000000..4c350f3d --- /dev/null +++ b/src/services/email.service.js @@ -0,0 +1,59 @@ +'use strict'; + +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.ethereal.email', + port: Number(process.env.SMTP_PORT) || 587, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +const CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000'; +const FROM = process.env.EMAIL_FROM || '"Auth App" '; + +export const sendActivation = async (email, token) => { + const link = `${CLIENT_URL}/auth/activate/${token}`; + + await transporter.sendMail({ + from: FROM, + to: email, + subject: 'Activate your account', + html: ` +

Welcome!

+

Please activate your account by clicking the link below:

+ ${link} +

This link will expire in 24 hours.

+ `, + }); +}; + +export const sendPasswordReset = async (email, token) => { + const link = `${CLIENT_URL}/auth/reset-password/${token}`; + + await transporter.sendMail({ + from: FROM, + to: email, + subject: 'Reset your password', + html: ` +

Password Reset

+

You requested a password reset. Click the link below to set a new password:

+ ${link} +

This link will expire in 1 hour. If you did not request a password reset, you can ignore this email.

+ `, + }); +}; + +export const sendEmailChangeNotice = async (oldEmail) => { + await transporter.sendMail({ + from: FROM, + to: oldEmail, + subject: 'Your email address has been changed', + html: ` +

Email Change Notice

+

Your account email address has been changed. If you did not make this change, please contact support immediately.

+ `, + }); +}; diff --git a/src/services/password.service.js b/src/services/password.service.js new file mode 100644 index 00000000..88cb8d89 --- /dev/null +++ b/src/services/password.service.js @@ -0,0 +1,35 @@ +'use strict'; + +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 10; + +export const hash = (plain) => bcrypt.hash(plain, SALT_ROUNDS); + +export const compare = (plain, hashed) => bcrypt.compare(plain, hashed); + +export const validate = (password) => { + const errors = []; + + if (!password || password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/\d/.test(password)) { + errors.push('Password must contain at least one number'); + } + + if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) { + errors.push('Password must contain at least one special character'); + } + + return errors; +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 00000000..95b1169a --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,121 @@ +'use strict'; + +import { v4 as uuidv4 } from 'uuid'; +import { User } from '../models/index.js'; +import * as passwordService from './password.service.js'; +import * as emailService from './email.service.js'; + +export const getProfile = async (userId) => { + const user = await User.findByPk(userId, { + attributes: ['id', 'name', 'email'], + }); + + if (!user) { + throw Object.assign(new Error('User not found'), { status: 404 }); + } + + return user; +}; + +export const changeName = async (userId, name) => { + if (!name || !name.trim()) { + throw Object.assign(new Error('Name is required'), { status: 400 }); + } + + const user = await User.findByPk(userId); + + if (!user) { + throw Object.assign(new Error('User not found'), { status: 404 }); + } + + user.name = name.trim(); + await user.save(); + + return user; +}; + +export const changePassword = async (userId, { oldPassword, newPassword, confirmation }) => { + if (newPassword !== confirmation) { + throw Object.assign(new Error('Passwords do not match'), { status: 400 }); + } + + const passwordErrors = passwordService.validate(newPassword); + + if (passwordErrors.length) { + throw Object.assign(new Error('Invalid password'), { + status: 400, + errors: passwordErrors, + }); + } + + const user = await User.findByPk(userId); + + if (!user) { + throw Object.assign(new Error('User not found'), { status: 404 }); + } + + const isValid = await passwordService.compare(oldPassword, user.password); + + if (!isValid) { + throw Object.assign(new Error('Old password is incorrect'), { status: 400 }); + } + + user.password = await passwordService.hash(newPassword); + await user.save(); +}; + +export const changeEmail = async (userId, { password, newEmail }) => { + const user = await User.findByPk(userId); + + if (!user) { + throw Object.assign(new Error('User not found'), { status: 404 }); + } + + const isValid = await passwordService.compare(password, user.password); + + if (!isValid) { + throw Object.assign(new Error('Password is incorrect'), { status: 400 }); + } + + const existing = await User.findOne({ where: { email: newEmail } }); + + if (existing) { + throw Object.assign(new Error('Email is already in use'), { status: 409 }); + } + + const emailChangeToken = uuidv4(); + + user.pendingEmail = newEmail; + user.emailChangeToken = emailChangeToken; + await user.save(); + + try { + await emailService.sendActivation(newEmail, emailChangeToken); + await emailService.sendEmailChangeNotice(user.email); + } catch (emailErr) { + throw new Error(emailErr.message); + } +}; + +export const confirmEmailChange = async (token) => { + const user = await User.findOne({ where: { emailChangeToken: token } }); + + if (!user || !user.pendingEmail) { + throw Object.assign(new Error('Invalid email change token'), { status: 400 }); + } + + const oldEmail = user.email; + + user.email = user.pendingEmail; + user.pendingEmail = null; + user.emailChangeToken = null; + await user.save(); + + try { + await emailService.sendEmailChangeNotice(oldEmail); + } catch (emailErr) { + throw new Error(emailErr.message); + } + + return user; +};