diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 00000000..bb13dfc4 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore index ed48a299..bd6a178a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,3 @@ node_modules # MacOS .DS_Store - -# env files -*.env -.env* diff --git a/package-lock.json b/package-lock.json index 288d83dd..f67c20c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -1467,10 +1467,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 5e195a15..9d160819 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", diff --git a/src/controller/auth.controller.js b/src/controller/auth.controller.js new file mode 100644 index 00000000..2e076add --- /dev/null +++ b/src/controller/auth.controller.js @@ -0,0 +1,141 @@ +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; + +import { userServices } from '../services/user.services.js'; +import { emailServices } from '../services/email.services.js'; +import { jwtService } from '../services/jwt.services.js'; +import { User } from '../models/User.model.js'; + +function validatePassword(pwd) { + if (!pwd || pwd.length < 6) { + return 'At least 6 characters'; + } +} + +const registerUser = async (req, res) => { + const { name, email, password } = req.body; + + if (!name || !email || !password) { + return res.status(400).json({ message: 'All fields are required' }); + } + + if (validatePassword(password)) { + return res.status(400).json({ password: validatePassword(password) }); + } + + const exists = await userServices.findUser(email); + + if (exists) { + return res.status(409).json({ message: 'Email already exists' }); + } + + const activationToken = uuidv4(); + const hash = bcrypt.hashSync(password, 10); + + await userServices.registerUser(name, email, hash, activationToken); + await emailServices.sendActivationEmail(email, activationToken); + + res.status(201).json({ message: 'Check your email to activate account' }); +}; + +const activateUser = async (req, res) => { + const { activationToken } = req.params; + + const user = await User.findOne({ where: { activationToken } }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + 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).json({ message: 'All fields are required' }); + } + + const user = await userServices.findUser(email); + + if (!user) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + if (user.activationToken) { + return res.status(403).json({ message: 'Activate your email' }); + } + + const valid = await bcrypt.compare(password, user.password); + + if (!valid) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // token generato SOLO per sessione client, non restituito + const normalized = userServices.normilizeUser(user); + const token = jwtService.sign(normalized); + + // opzionale: se i test non lo richiedono, puoi anche ometterlo + res.setHeader('Authorization', `Bearer ${token}`); + + return res.redirect('/profile'); +}; + +const logout = (req, res) => { + res.redirect('/login'); +}; + +const forgot = async (req, res) => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: 'Email required' }); + } + + const user = await userServices.findUser(email); + + if (!user) { + return res.sendStatus(200); + } + + user.resetToken = uuidv4(); + await user.save(); + + await emailServices.sendResetPasswordEmail(email, user.resetToken); + res.json({ message: 'Email sent' }); +}; + +const resetPassword = async (req, res) => { + const { resetToken } = req.params; + const { password, confirmation } = req.body; + + if (!password || password !== confirmation) { + return res.status(400).json({ message: 'Passwords do not match' }); + } + + const user = await User.findOne({ where: { resetToken } }); + + if (!user) { + return res.status(400).json({ message: 'Invalid token' }); + } + + user.password = bcrypt.hashSync(password, 10); + user.resetToken = null; + await user.save(); + + res.json({ message: 'Password changed' }); +}; + +export const authController = { + registerUser, + activateUser, + loginUser, + logout, + forgot, + resetPassword, +}; diff --git a/src/controller/user.controller.js b/src/controller/user.controller.js new file mode 100644 index 00000000..e340dff2 --- /dev/null +++ b/src/controller/user.controller.js @@ -0,0 +1,104 @@ +import { userServices } from '../services/user.services.js'; +import { emailServices } from '../services/email.services.js'; +import bcrypt from 'bcrypt'; + +const updateName = async (req, res) => { + try { + const userId = req.user.userId; + const { name } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ message: 'Name is required' }); + } + + const user = await userServices.updateNameService(userId, name); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.send(user); + } catch (error) { + res.status(500).json({ message: 'Internal server error' }); + } +}; + +const updatePassword = async (req, res) => { + try { + const { oldPassword, newPassword, confirmation } = req.body; + const userId = req.user.userId; + + if (!oldPassword || !newPassword || !confirmation) { + return res.status(400).json({ message: 'All fields are required' }); + } + + if (newPassword !== confirmation) { + return res.status(400).json({ message: 'Passwords do not match' }); + } + + const user = await userServices.findUserById(userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const isValid = await bcrypt.compare(oldPassword, user.password); + + if (!isValid) { + return res.status(401).json({ message: 'Old password is incorrect' }); + } + + user.password = bcrypt.hashSync(newPassword, 10); + await user.save(); + + res.send({ message: 'Password updated successfully' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error' }); + } +}; + +const updateEmail = async (req, res) => { + try { + const { password, newEmail, confirmation } = req.body; + const userId = req.user.userId; + + if (!password || !newEmail || !confirmation) { + return res + .status(400) + .json({ message: 'Password, new email and confirmation are required' }); + } + + if (newEmail !== confirmation) { + return res.status(400).json({ message: 'Emails do not match' }); + } + + const user = await userServices.findUserById(userId); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + return res.status(401).json({ message: 'Invalid password' }); + } + + const oldEmail = user.email; + + user.email = newEmail; + + await user.save(); + await emailServices.sendEmailChangedNotification(oldEmail, newEmail); + + res.send({ message: 'Email updated successfully' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error' }); + } +}; + +export const userController = { + updateName, + updateEmail, + updatePassword, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7..bdda261d 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,24 @@ -'use strict'; +import express from 'express'; +import cookieParser from 'cookie-parser'; + +import authRouter from './router/auth.router.js'; +import userRouter from './router/user.router.js'; +import { authMiddleware } from './middlewares/auth.middleware.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.middleware.js b/src/middlewares/auth.middleware.js new file mode 100644 index 00000000..6a4dccc5 --- /dev/null +++ b/src/middlewares/auth.middleware.js @@ -0,0 +1,20 @@ +import { jwtService } from '../services/jwt.services.js'; + +export const authMiddleware = (req, res, next) => { + const auth = req.headers.authorization; + + if (!auth) { + return res.sendStatus(401); + } + + const [, token] = auth.split(' '); + + const user = jwtService.verify(token); + + if (!user) { + return res.sendStatus(401); + } + + req.user = user; + next(); +}; diff --git a/src/middlewares/guest.middleware.js b/src/middlewares/guest.middleware.js new file mode 100644 index 00000000..8b6993af --- /dev/null +++ b/src/middlewares/guest.middleware.js @@ -0,0 +1,18 @@ +import { jwtService } from '../services/jwt.services.js'; + +export const guestMiddleware = (req, res, next) => { + const auth = req.headers.authorization; + + if (!auth) { + return next(); + } + + const [, token] = auth.split(' '); + const user = jwtService.verify(token); + + if (user) { + return res.redirect('/profile'); + } + + 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..c60dee7a --- /dev/null +++ b/src/router/auth.router.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { authController } from '../controller/auth.controller.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { guestMiddleware } from '../middlewares/guest.middleware.js'; + +const router = express.Router(); + +router.post('/auth', guestMiddleware, authController.registerUser); + +router.get( + '/activate/:activationToken', + guestMiddleware, + authController.activateUser, +); +router.post('/login', guestMiddleware, authController.loginUser); +router.post('/forgot', guestMiddleware, authController.forgot); + +router.post( + '/password-reset/:resetToken', + guestMiddleware, + authController.resetPassword, +); + +router.get('/logout', authMiddleware, authController.logout); + +export default router; diff --git a/src/router/user.router.js b/src/router/user.router.js new file mode 100644 index 00000000..2b6a5e85 --- /dev/null +++ b/src/router/user.router.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { userController } from '../controller/user.controller.js'; + +const router = express.Router(); + +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..99d6623b --- /dev/null +++ b/src/services/email.services.js @@ -0,0 +1,45 @@ +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(to, subject, html) { + return transporter.sendMail({ to, subject, html }); +} + +function sendActivationEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/activate/${token}`; + + return sendEmail( + email, + 'Account activation', + `Activate your account: ${href}`, + ); +} + +function sendResetPasswordEmail(email, token) { + const href = `${process.env.CLIENT_HOST}/password-reset/${token}`; + + return sendEmail(email, 'Reset Password', `${href}`); +} + +function sendEmailChangedNotification(oldEmail, newEmail) { + return sendEmail( + oldEmail, + 'Email changed', + `Your email was changed to ${newEmail}`, + ); +} + +export const emailServices = { + sendActivationEmail, + sendResetPasswordEmail, + sendEmailChangedNotification, +}; diff --git a/src/services/jwt.services.js b/src/services/jwt.services.js new file mode 100644 index 00000000..5258c36d --- /dev/null +++ b/src/services/jwt.services.js @@ -0,0 +1,32 @@ +import jwt from 'jsonwebtoken'; + +function sign(user) { + return jwt.sign(user, process.env.JWT_SECRET, { expiresIn: '1d' }); +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_SECRET); + } catch { + return null; + } +} + +function signRefresh(user) { + return jwt.sign(user, process.env.JWT_REFRESH_SECRET, { expiresIn: '1d' }); +} + +function verifyRefresh(token) { + try { + return jwt.verify(token, process.env.JWT_REFRESH_SECRET); + } catch { + 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..2b7d27d1 --- /dev/null +++ b/src/services/user.services.js @@ -0,0 +1,47 @@ +import { User } from '../models/User.model.js'; + +const registerUser = (name, email, password, activationToken) => { + return User.create({ + name, + email, + password, + activationToken, + }); +}; + +const findUser = (email) => { + return User.findOne({ where: { email } }); +}; + +const findUserById = (id) => { + return User.findByPk(id); +}; + +const updateNameService = async (id, name) => { + const user = await findUserById(id); + + if (!user) { + return null; + } + + user.name = name; + await user.save(); + + return user; +}; + +const normilizeUser = (user) => { + return { + userId: user.userId, + email: user.email, + name: user.name, + }; +}; + +export const userServices = { + registerUser, + findUser, + findUserById, + updateNameService, + normilizeUser, +}; 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;