diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 00000000..7958f3ca --- /dev/null +++ b/src/controllers/authController.js @@ -0,0 +1,307 @@ +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { pool } from '../db.js'; +import jwt from 'jsonwebtoken'; +import { sendMail } from '../utils/mailService.js'; + +export const register = async (req, res) => { + const { name, email, password } = req.body; + + try { + const userCheck = await pool.query('SELECT * FROM users WHERE email = $1', [ + email, + ]); + + if (userCheck.rows.length > 0) { + return res + .status(400) + .json({ message: 'User with this email already exists.' }); + } + + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/; + + if (!passwordRegex.test(password)) { + return res.status(400).json({ + message: + 'Password must be at least 8 characters' + + ' long and contain at least one number.', + }); + } + + const passwordHash = await bcrypt.hash(password, 10); + + const activationToken = uuidv4(); + + const query = ` + INSERT INTO users (name, email, password_hash, is_activated, activation_token) + VALUES ($1, $2, $3, $4, $5) + RETURNING id; + `; + const values = [name, email, passwordHash, false, activationToken]; + + await pool.query(query, values); + + const activationLink = `http://localhost:3000/api/activate/${activationToken}`; + + await sendMail( + email, + 'Account activation', + `Please activate your account by clicking: ${activationLink}`, + ); + + res + .status(201) + .json({ message: 'Registration successful! Please check your email.' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const activate = async (req, res) => { + const { token } = req.params; + + try { + const user = await pool.query( + 'SELECT * FROM users WHERE activation_token = $1', + [token], + ); + + if (user.rows.length === 0) { + return res + .status(400) + .json({ message: 'Invalid or expired activation link.' }); + } + + await pool.query( + 'UPDATE users SET is_activated = true,' + + ' activation_token = null WHERE activation_token = $1', + [token], + ); + + res + .status(200) + .json({ message: 'Account activated successfully! You can now log in.' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const login = async (req, res) => { + const { email, password } = req.body; + + try { + const result = await pool.query('SELECT * FROM users WHERE email = $1', [ + email, + ]); + const user = result.rows[0]; + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + if (!user.is_activated) { + return res + .status(403) + .json({ message: 'Please activate your email before logging in.' }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password_hash); + + if (!isPasswordValid) { + return res.status(401).json({ message: 'Invalid email or password.' }); + } + + const accessToken = jwt.sign( + { id: user.id, email: user.email }, + process.env.JWT_ACCESS_SECRET, + { expiresIn: '30m' }, + ); + + res.status(200).json({ + message: 'Login successful!', + accessToken, + user: { + id: user.id, + name: user.name, + email: user.email, + }, + }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const forgotPassword = async (req, res) => { + const { email } = req.body; + + try { + const result = await pool.query('SELECT * FROM users WHERE email = $1', [ + email, + ]); + const user = result.rows[0]; + + if (!user) { + return res + .status(404) + .json({ message: 'User with this email not found.' }); + } + + const resetToken = uuidv4(); + + await pool.query( + 'UPDATE users SET activation_token = $1 WHERE email = $2', + [resetToken, email], + ); + + const resetLink = `http://localhost:5173/reset-password/${resetToken}`; + + await sendMail( + email, + 'Password Reset Request', + ` +
+

Password Reset

+

To reset your password, please click the link below:

+ ${resetLink} +

If you didn't request this, just ignore this email.

+
+ `, + ); + + res.json({ message: 'Password reset link sent to your email.' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const resetPassword = async (req, res) => { + const { token, newPassword, confirmation } = req.body; + + if (newPassword !== confirmation) { + return res.status(400).json({ message: 'Passwords do not match.' }); + } + + try { + const user = await pool.query( + 'SELECT * FROM users WHERE activation_token = $1', + [token], + ); + + if (user.rows.length === 0) { + return res + .status(400) + .json({ message: 'Invalid or expired reset token.' }); + } + + const hashedPath = await bcrypt.hash(newPassword, 10); + + await pool.query( + 'UPDATE users SET password_hash = $1, ' + + 'activation_token = null WHERE activation_token = $2', + [hashedPath, token], + ); + + res.json({ + message: 'Password has been reset successfully! You can now log in.', + }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const updateName = async (req, res) => { + const { name } = req.body; + const userId = req.user.id; + + try { + await pool.query('UPDATE users SET name = $1 WHERE id = $2', [ + name, + userId, + ]); + res.json({ message: 'Name updated successfully!' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const updatePassword = async (req, res) => { + const { oldPassword, newPassword, confirmation } = req.body; + const userId = req.user.id; + + if (newPassword !== confirmation) { + return res.status(400).json({ message: 'New passwords do not match.' }); + } + + try { + const result = await pool.query( + 'SELECT password_hash FROM users WHERE id = $1', + [userId], + ); + const user = result.rows[0]; + + const isMatch = await bcrypt.compare(oldPassword, user.password_hash); + + if (!isMatch) { + return res.status(401).json({ message: 'Old password is incorrect.' }); + } + + const hashedPath = await bcrypt.hash(newPassword, 10); + + await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [ + hashedPath, + userId, + ]); + + res.json({ message: 'Password updated successfully!' }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; + +export const updateEmail = async (req, res) => { + const { newEmail, password } = req.body; + const userId = req.user.id; + + try { + const result = await pool.query( + 'SELECT email, password_hash FROM users WHERE id = $1', + [userId], + ); + const user = result.rows[0]; + const oldEmail = user.email; + + const isMatch = await bcrypt.compare(password, user.password_hash); + + if (!isMatch) { + return res.status(401).json({ message: 'Invalid password.' }); + } + + const activationToken = uuidv4(); + + await pool.query( + 'UPDATE users SET email = $1, ' + + 'is_activated = false, activation_token = $2 WHERE id = $3', + [newEmail, activationToken, userId], + ); + + const link = `http://localhost:3000/api/activate/${activationToken}`; + + await sendMail( + newEmail, + 'Confirm your new email', + `Activate here: ${link}`, + ); + + await sendMail( + oldEmail, + 'Your email has been changed', + `The email address for your account was recently changed to ${newEmail}. If you didn't do this, please contact support.`, + ); + + res.json({ + message: 'Email updated! Please check your new email to activate it.', + }); + } catch (error) { + res.status(500).json({ message: 'Internal server error.' }); + } +}; diff --git a/src/db.js b/src/db.js new file mode 100644 index 00000000..70f4e624 --- /dev/null +++ b/src/db.js @@ -0,0 +1,8 @@ +import { Pool } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); diff --git a/src/index.js b/src/index.js index ad9a93a7..798b0954 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,21 @@ -'use strict'; +import express from 'express'; +import dotenv from 'dotenv'; +import authRoutes from './routes/authRoutes'; + +dotenv.config(); + +const app = express(); + +app.use(express.json()); + +app.use('/api', authRoutes); + +app.use((req, res) => { + res + .status(404) + .json({ message: 'Opps! This page was not found on the backend' }); +}); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => {}); diff --git a/src/middlewars/authMiddleware.js b/src/middlewars/authMiddleware.js new file mode 100644 index 00000000..b29ba2df --- /dev/null +++ b/src/middlewars/authMiddleware.js @@ -0,0 +1,20 @@ +import jwt from 'jsonwebtoken'; + +export const authMiddleware = (req, res, next) => { + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Unauthorized. Please log in.' }); + } + + try { + const userData = jwt.verify(token, process.env.JWT_ACCESS_SECRET); + + req.user = userData; + + next(); + } catch (error) { + return res.status(401).json({ message: 'Invalid or expired token.' }); + } +}; diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js new file mode 100644 index 00000000..49a0250d --- /dev/null +++ b/src/routes/authRoutes.js @@ -0,0 +1,49 @@ +import { Router } from 'express'; +import { + activate, + forgotPassword, + login, + register, + resetPassword, + updateEmail, + updateName, + updatePassword, +} from '../controllers/authController'; +import { authMiddleware } from '../middlewars/authMiddleware'; + +const router = Router(); + +router.post('/register', register); + +router.get('/activate/:token', activate); + +router.post('/login', login); + +router.post('/forgot-password', forgotPassword); + +router.post('/reset-password', resetPassword); + +router.post('/logout', authMiddleware, (req, res) => { + res.json({ message: 'Logout successful. Redirecting to login...' }); +}); + +router.patch('/update-name', authMiddleware, updateName); + +router.patch('/update-password', authMiddleware, updatePassword); + +router.patch('/update-email', authMiddleware, updateEmail); + +router.get('/test', (req, res) => { + res.json({ message: 'API is working!' }); +}); + +router.get('/profile', authMiddleware, (req, res) => { + const user = req.user; + + res.json({ + message: 'Welcome to your profile!', + user, + }); +}); + +export default router; diff --git a/src/utils/mailService.js b/src/utils/mailService.js new file mode 100644 index 00000000..0e5a00b6 --- /dev/null +++ b/src/utils/mailService.js @@ -0,0 +1,23 @@ +import nodemailer from 'nodemailer'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +export const sendMail = async (to, subject, html) => { + await transporter.sendMail({ + from: process.env.EMAIL_USER, + to, + subject, + html, + }); +};