diff --git a/README.md b/README.md index de3d7ec..0e9fc0b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This is a modern RESTful API built with **Node.js** and **Express**, designed to - **Authentication & Authorization**: - **User Authentication**: Secure API access using **JSON Web Tokens (JWT)**. - **Role-based Access Control (RBAC)**: Control access to resources based on user roles (e.g., admin, user). + - **Password Reset**: Secure password reset functionality with time-limited tokens and email verification using **SendGrid**. - **Swagger API Documentation**: - **Swagger** integrated for real-time API documentation and testing directly in the browser. Access the documentation at: [http://localhost:3000/api-docs](http://localhost:3000/api-docs). @@ -129,6 +130,8 @@ Once the server is running, you can access the auto-generated API documentation - **PUT /users/:id** - Update an existing user by ID (requires JSON body). - **DELETE /users/:id** - Delete a user by ID. - **POST /login** - Authenticate a user and return a JWT (requires JSON body with email and password). +- **POST /forgot-password** - Request a password reset link (requires email in JSON body). +- **POST /reset-password/:token** - Reset password using the token received via email. [Run In Postman](https://app.getpostman.com/run-collection/31522917-54350f46-dd5e-4a62-9dc2-4346a7879692?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D31522917-54350f46-dd5e-4a62-9dc2-4346a7879692%26entityType%3Dcollection%26workspaceId%3D212c8589-8dd4-4f19-9a53-e77403c6c7d9) @@ -159,6 +162,16 @@ curl -X DELETE http://localhost:3000/users/1 curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email": "john@example.com", "password": "password"}' ``` +### Request Password Reset +```bash +curl -X POST http://localhost:3000/forgot-password -H "Content-Type: application/json" -d '{"email": "john@example.com"}' +``` + +### Reset Password (using token from email) +```bash +curl -X POST http://localhost:3000/reset-password/your_reset_token -H "Content-Type: application/json" -d '{"password": "new_password"}' +``` + ### Access Protected Route ```bash curl -X GET http://localhost:3000/users -H "Authorization: Bearer your_jwt_token" diff --git a/controllers/authController.js b/controllers/authController.js index 43a7330..61f0b92 100644 --- a/controllers/authController.js +++ b/controllers/authController.js @@ -1,7 +1,9 @@ require('express-async-errors'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const pool = require('../config/db'); +const { sendEmail } = require('../utils/mailer'); const register = async (req, res, next) => { const { name, email, password } = req.body; @@ -36,11 +38,23 @@ const login = async (req, res) => { return res.status(401).json({ message: 'Invalid email or password' }); } - // Generate a JWT token including user role + // Generate a more secure JWT token + const tokenPayload = { + userId: user.id, + email: user.email, + role: user.role, + name: user.name, + iat: Math.floor(Date.now() / 1000), + jti: require('crypto').randomBytes(16).toString('hex') // Add unique token ID + }; + const token = jwt.sign( - { userId: user.id, email: user.email, role: user.role }, // Include role in payload + tokenPayload, process.env.JWT_SECRET, - { expiresIn: '1h' } // Token expiration + { + expiresIn: '24h', // Increased from 1h to 24h + algorithm: 'HS512' // More secure algorithm (upgrade from default HS256) + } ); // Response with token and user details @@ -56,4 +70,95 @@ const login = async (req, res) => { }); }; -module.exports = { register, login }; +const forgotPassword = async (req, res) => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: 'Email is required' }); + } + + // Check if user exists + 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' }); + } + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString('hex'); + const resetTokenExpires = new Date(Date.now() + 3600000); // 1 hour from now + + // Save reset token and expiry to database + await pool.query( + 'UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3', + [resetToken, resetTokenExpires, user.id] + ); + + // Create reset URL - using the base URL from where the request originated + const resetUrl = `${req.protocol}://${req.get('host')}/reset-password/${resetToken}`; + + // Send email + try { + await sendEmail({ + to: user.email, + subject: 'Password Reset Request', + text: `You requested a password reset. Please go to this link to reset your password: ${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this email.`, + html: ` +

You requested a password reset.

+

Please click the link below to reset your password:

+ Reset Password +

This link will expire in 1 hour.

+

If you did not request this, please ignore this email.

+ ` + }); + + res.status(200).json({ message: 'Password reset email sent' }); + } catch (error) { + // If email fails, remove the reset token from database + await pool.query( + 'UPDATE users SET reset_token = NULL, reset_token_expires = NULL WHERE id = $1', + [user.id] + ); + return res.status(500).json({ message: 'Error sending password reset email' }); + } +}; + +const resetPassword = async (req, res) => { + try { + const { token } = req.params; + const { password } = req.body; + + if (!token || !password) { + return res.status(400).json({ message: 'Token and new password are required' }); + } + + // Find user with valid reset token + const result = await pool.query( + 'SELECT * FROM users WHERE reset_token = $1 AND reset_token_expires > NOW()', + [token] + ); + + const user = result.rows[0]; + + if (!user) { + return res.status(400).json({ message: 'Invalid or expired reset token' }); + } + + // Hash new password and update user + const hashedPassword = await bcrypt.hash(password, 10); + + await pool.query( + 'UPDATE users SET password = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2 RETURNING id', + [hashedPassword, user.id] + ); + + console.log(`Password reset successful for user ID: ${user.id}`); + res.status(200).json({ message: 'Password has been reset successfully' }); + } catch (error) { + console.error('Password reset error:', error); + res.status(500).json({ message: 'Error resetting password. Please try again.' }); + } +}; + +module.exports = { register, login, forgotPassword, resetPassword }; diff --git a/index.js b/index.js index 47a8e2d..0b29209 100644 --- a/index.js +++ b/index.js @@ -48,6 +48,14 @@ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +// Serve static files from the public directory +app.use(express.static(path.join(__dirname, 'public'))); + +// Password reset route +app.get('/reset-password/:token', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'reset-password.html')); +}); + // Routes app.use('/users', userRoutes); diff --git a/package-lock.json b/package-lock.json index ec4f8dc..733c6be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@sendgrid/mail": "^8.1.5", "bcryptjs": "^2.4.3", "chai-http": "^5.1.1", "cors": "^2.8.5", @@ -22,7 +23,7 @@ "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.16", + "nodemailer": "^6.10.1", "pg": "^8.12.0", "prom-client": "^15.1.3", "redis": "^4.7.0", @@ -2332,6 +2333,44 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@sendgrid/client": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz", + "integrity": "sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==", + "license": "MIT", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.8.2" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.5.tgz", + "integrity": "sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==", + "license": "MIT", + "dependencies": { + "@sendgrid/client": "^8.1.5", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -2660,6 +2699,17 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4138,6 +4188,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -6280,9 +6350,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.9.16", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", - "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -7033,6 +7103,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", diff --git a/package.json b/package.json index 158f6a7..5fc4737 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "license": "MIT", "dependencies": { + "@sendgrid/mail": "^8.1.5", "bcryptjs": "^2.4.3", "chai-http": "^5.1.1", "cors": "^2.8.5", @@ -18,7 +19,7 @@ "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", - "nodemailer": "^6.9.16", + "nodemailer": "^6.10.1", "pg": "^8.12.0", "prom-client": "^15.1.3", "redis": "^4.7.0", diff --git a/public/reset-password.html b/public/reset-password.html new file mode 100644 index 0000000..004e395 --- /dev/null +++ b/public/reset-password.html @@ -0,0 +1,127 @@ + + + + Reset Password + + + +

Reset Your Password

+
+
+ + +
+
+ + +
+ +
Processing...
+
+

+

+ + + + \ No newline at end of file diff --git a/routes/loginRoutes.js b/routes/loginRoutes.js index 625636e..bc6c656 100644 --- a/routes/loginRoutes.js +++ b/routes/loginRoutes.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const authController = require('../controllers/authController'); +const { login, register, forgotPassword, resetPassword } = require('../controllers/authController'); /** * @swagger @@ -23,18 +23,8 @@ const authController = require('../controllers/authController'); * responses: * 200: * description: Login successful - * content: - * application/json: - * schema: - * type: object - * properties: - * token: - * type: string - * description: JWT token to be used for authentication - * 401: - * description: Invalid credentials */ -router.post('/login', authController.login); +router.post('/login', login); /** * @swagger @@ -78,6 +68,22 @@ router.post('/login', authController.login); * 500: * description: Server error */ -router.post('/register', authController.register); +router.post('/register', register); + +/** + * @swagger + * /forgot-password: + * post: + * summary: Request password reset + */ +router.post('/forgot-password', forgotPassword); + +/** + * @swagger + * /reset-password/{token}: + * post: + * summary: Reset password with token + */ +router.post('/reset-password/:token', resetPassword); module.exports = router; diff --git a/utils/mailer.js b/utils/mailer.js index 963c0c9..c370485 100644 --- a/utils/mailer.js +++ b/utils/mailer.js @@ -1,17 +1,24 @@ -const nodemailer = require('nodemailer'); +const sgMail = require('@sendgrid/mail'); -const transporter = nodemailer.createTransport({ - service: 'Gmail', - auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }, -}); +// Initialize SendGrid with your API key +sgMail.setApiKey(process.env.SENDGRID_API_KEY); -const sendWelcomeEmail = async (to, name) => { - await transporter.sendMail({ - from: process.env.EMAIL_USER, +const sendEmail = async ({ to, subject, text, html }) => { + const msg = { to, - subject: 'Welcome!', - text: `Hello ${name}, welcome to our service!`, - }); + from: process.env.SENDGRID_FROM_EMAIL, // verified sender email in SendGrid + subject, + text, + html: html || text // If no HTML is provided, use the text content + }; + + try { + await sgMail.send(msg); + return { success: true }; + } catch (error) { + console.error('Email sending failed:', error); + throw error; + } }; -module.exports = { sendWelcomeEmail }; \ No newline at end of file +module.exports = { sendEmail }; \ No newline at end of file