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;