Skip to content

Implement secure password reset functionality #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.

[<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)

Expand Down Expand Up @@ -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": "[email protected]", "password": "password"}'
```

### Request Password Reset
```bash
curl -X POST http://localhost:3000/forgot-password -H "Content-Type: application/json" -d '{"email": "[email protected]"}'
```

### 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"
Expand Down
113 changes: 109 additions & 4 deletions controllers/authController.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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: `
<p>You requested a password reset.</p>
<p>Please click the link below to reset your password:</p>
<a href="${resetUrl}">Reset Password</a>
<p>This link will expire in 1 hour.</p>
<p>If you did not request this, please ignore this email.</p>
`
});

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 };
8 changes: 8 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
84 changes: 80 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading