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.
[](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 @@ + + + +