Skip to content

Commit 43bbab3

Browse files
authored
Merge pull request #31 from JawherKl/feature/22-password-reset-with-sendgrid
Implement secure password reset functionality
2 parents 0f0c4f2 + 878ebd5 commit 43bbab3

File tree

8 files changed

+377
-34
lines changed

8 files changed

+377
-34
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This is a modern RESTful API built with **Node.js** and **Express**, designed to
2626
- **Authentication & Authorization**:
2727
- **User Authentication**: Secure API access using **JSON Web Tokens (JWT)**.
2828
- **Role-based Access Control (RBAC)**: Control access to resources based on user roles (e.g., admin, user).
29+
- **Password Reset**: Secure password reset functionality with time-limited tokens and email verification using **SendGrid**.
2930

3031
- **Swagger API Documentation**:
3132
- **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
129130
- **PUT /users/:id** - Update an existing user by ID (requires JSON body).
130131
- **DELETE /users/:id** - Delete a user by ID.
131132
- **POST /login** - Authenticate a user and return a JWT (requires JSON body with email and password).
133+
- **POST /forgot-password** - Request a password reset link (requires email in JSON body).
134+
- **POST /reset-password/:token** - Reset password using the token received via email.
132135

133136
[<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](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)
134137

@@ -159,6 +162,16 @@ curl -X DELETE http://localhost:3000/users/1
159162
curl -X POST http://localhost:3000/login -H "Content-Type: application/json" -d '{"email": "[email protected]", "password": "password"}'
160163
```
161164

165+
### Request Password Reset
166+
```bash
167+
curl -X POST http://localhost:3000/forgot-password -H "Content-Type: application/json" -d '{"email": "[email protected]"}'
168+
```
169+
170+
### Reset Password (using token from email)
171+
```bash
172+
curl -X POST http://localhost:3000/reset-password/your_reset_token -H "Content-Type: application/json" -d '{"password": "new_password"}'
173+
```
174+
162175
### Access Protected Route
163176
```bash
164177
curl -X GET http://localhost:3000/users -H "Authorization: Bearer your_jwt_token"

controllers/authController.js

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
require('express-async-errors');
22
const bcrypt = require('bcryptjs');
33
const jwt = require('jsonwebtoken');
4+
const crypto = require('crypto');
45
const pool = require('../config/db');
6+
const { sendEmail } = require('../utils/mailer');
57

68
const register = async (req, res, next) => {
79
const { name, email, password } = req.body;
@@ -36,11 +38,23 @@ const login = async (req, res) => {
3638
return res.status(401).json({ message: 'Invalid email or password' });
3739
}
3840

39-
// Generate a JWT token including user role
41+
// Generate a more secure JWT token
42+
const tokenPayload = {
43+
userId: user.id,
44+
email: user.email,
45+
role: user.role,
46+
name: user.name,
47+
iat: Math.floor(Date.now() / 1000),
48+
jti: require('crypto').randomBytes(16).toString('hex') // Add unique token ID
49+
};
50+
4051
const token = jwt.sign(
41-
{ userId: user.id, email: user.email, role: user.role }, // Include role in payload
52+
tokenPayload,
4253
process.env.JWT_SECRET,
43-
{ expiresIn: '1h' } // Token expiration
54+
{
55+
expiresIn: '24h', // Increased from 1h to 24h
56+
algorithm: 'HS512' // More secure algorithm (upgrade from default HS256)
57+
}
4458
);
4559

4660
// Response with token and user details
@@ -56,4 +70,95 @@ const login = async (req, res) => {
5670
});
5771
};
5872

59-
module.exports = { register, login };
73+
const forgotPassword = async (req, res) => {
74+
const { email } = req.body;
75+
76+
if (!email) {
77+
return res.status(400).json({ message: 'Email is required' });
78+
}
79+
80+
// Check if user exists
81+
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
82+
const user = result.rows[0];
83+
84+
if (!user) {
85+
return res.status(404).json({ message: 'User not found' });
86+
}
87+
88+
// Generate reset token
89+
const resetToken = crypto.randomBytes(32).toString('hex');
90+
const resetTokenExpires = new Date(Date.now() + 3600000); // 1 hour from now
91+
92+
// Save reset token and expiry to database
93+
await pool.query(
94+
'UPDATE users SET reset_token = $1, reset_token_expires = $2 WHERE id = $3',
95+
[resetToken, resetTokenExpires, user.id]
96+
);
97+
98+
// Create reset URL - using the base URL from where the request originated
99+
const resetUrl = `${req.protocol}://${req.get('host')}/reset-password/${resetToken}`;
100+
101+
// Send email
102+
try {
103+
await sendEmail({
104+
to: user.email,
105+
subject: 'Password Reset Request',
106+
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.`,
107+
html: `
108+
<p>You requested a password reset.</p>
109+
<p>Please click the link below to reset your password:</p>
110+
<a href="${resetUrl}">Reset Password</a>
111+
<p>This link will expire in 1 hour.</p>
112+
<p>If you did not request this, please ignore this email.</p>
113+
`
114+
});
115+
116+
res.status(200).json({ message: 'Password reset email sent' });
117+
} catch (error) {
118+
// If email fails, remove the reset token from database
119+
await pool.query(
120+
'UPDATE users SET reset_token = NULL, reset_token_expires = NULL WHERE id = $1',
121+
[user.id]
122+
);
123+
return res.status(500).json({ message: 'Error sending password reset email' });
124+
}
125+
};
126+
127+
const resetPassword = async (req, res) => {
128+
try {
129+
const { token } = req.params;
130+
const { password } = req.body;
131+
132+
if (!token || !password) {
133+
return res.status(400).json({ message: 'Token and new password are required' });
134+
}
135+
136+
// Find user with valid reset token
137+
const result = await pool.query(
138+
'SELECT * FROM users WHERE reset_token = $1 AND reset_token_expires > NOW()',
139+
[token]
140+
);
141+
142+
const user = result.rows[0];
143+
144+
if (!user) {
145+
return res.status(400).json({ message: 'Invalid or expired reset token' });
146+
}
147+
148+
// Hash new password and update user
149+
const hashedPassword = await bcrypt.hash(password, 10);
150+
151+
await pool.query(
152+
'UPDATE users SET password = $1, reset_token = NULL, reset_token_expires = NULL WHERE id = $2 RETURNING id',
153+
[hashedPassword, user.id]
154+
);
155+
156+
console.log(`Password reset successful for user ID: ${user.id}`);
157+
res.status(200).json({ message: 'Password has been reset successfully' });
158+
} catch (error) {
159+
console.error('Password reset error:', error);
160+
res.status(500).json({ message: 'Error resetting password. Please try again.' });
161+
}
162+
};
163+
164+
module.exports = { register, login, forgotPassword, resetPassword };

index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
4848

4949
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
5050

51+
// Serve static files from the public directory
52+
app.use(express.static(path.join(__dirname, 'public')));
53+
54+
// Password reset route
55+
app.get('/reset-password/:token', (req, res) => {
56+
res.sendFile(path.join(__dirname, 'public', 'reset-password.html'));
57+
});
58+
5159
// Routes
5260
app.use('/users', userRoutes);
5361

package-lock.json

Lines changed: 80 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"main": "index.js",
66
"license": "MIT",
77
"dependencies": {
8+
"@sendgrid/mail": "^8.1.5",
89
"bcryptjs": "^2.4.3",
910
"chai-http": "^5.1.1",
1011
"cors": "^2.8.5",
@@ -18,7 +19,7 @@
1819
"joi": "^17.13.3",
1920
"jsonwebtoken": "^9.0.2",
2021
"multer": "^1.4.5-lts.1",
21-
"nodemailer": "^6.9.16",
22+
"nodemailer": "^6.10.1",
2223
"pg": "^8.12.0",
2324
"prom-client": "^15.1.3",
2425
"redis": "^4.7.0",

0 commit comments

Comments
 (0)