MukeshKapoor25 commited on
Commit
96e0270
·
1 Parent(s): e4562cb

feat: Implement forgot password feature with email reset functionality

Browse files

- Added `FORGOT_PASSWORD_QUICKSTART.md` for quick reference on setup and usage.
- Created `IMPLEMENTATION_SUMMARY.md` detailing the implementation process and features.
- Introduced new API endpoints for password reset: `/auth/forgot-password`, `/auth/verify-reset-token`, and `/auth/reset-password`.
- Developed email service in `app/utils/email_service.py` for sending transactional emails.
- Updated configuration in `app/core/config.py` to include password reset settings.
- Enhanced user service in `app/system_users/services/service.py` to handle password reset token creation, verification, and email sending.
- Added schemas for password reset requests in `app/system_users/schemas/schema.py`.
- Implemented token storage and management in `app/system_users/models/model.py`.
- Created a test script `test_forgot_password.py` to validate the complete password reset flow.
- Included troubleshooting and security features in documentation.

.env.forgot-password.example ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # FORGOT PASSWORD FEATURE - SMTP CONFIGURATION
3
+ # ============================================
4
+ # Add these settings to your .env file to enable email functionality
5
+
6
+ # Password Reset Configuration
7
+ PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES=60
8
+ PASSWORD_RESET_BASE_URL=http://localhost:3000/reset-password
9
+
10
+ # ============================================
11
+ # SMTP Email Configuration (Choose one)
12
+ # ============================================
13
+
14
+ # Option 1: Gmail (Recommended for testing)
15
+ # -----------------------------------------
16
+ # 1. Enable 2-Factor Authentication on your Google account
17
+ # 2. Generate an App Password: https://myaccount.google.com/apppasswords
18
+ # 3. Use the generated password below (NOT your regular Gmail password)
19
+
20
+ SMTP_HOST=smtp.gmail.com
21
+ SMTP_PORT=587
22
+ SMTP_USERNAME=your-email@gmail.com
23
+ SMTP_PASSWORD=your-16-char-app-password
24
+ SMTP_FROM_EMAIL=noreply@cuatrolabs.com
25
+ SMTP_USE_TLS=true
26
+
27
+
28
+ # Option 2: SendGrid
29
+ # -----------------------------------------
30
+ # 1. Sign up at https://sendgrid.com/
31
+ # 2. Generate an API key
32
+ # 3. Use the API key as password
33
+
34
+ # SMTP_HOST=smtp.sendgrid.net
35
+ # SMTP_PORT=587
36
+ # SMTP_USERNAME=apikey
37
+ # SMTP_PASSWORD=your-sendgrid-api-key
38
+ # SMTP_FROM_EMAIL=noreply@cuatrolabs.com
39
+ # SMTP_USE_TLS=true
40
+
41
+
42
+ # Option 3: AWS SES
43
+ # -----------------------------------------
44
+ # 1. Set up AWS SES: https://aws.amazon.com/ses/
45
+ # 2. Verify your domain/email
46
+ # 3. Generate SMTP credentials
47
+
48
+ # SMTP_HOST=email-smtp.us-east-1.amazonaws.com
49
+ # SMTP_PORT=587
50
+ # SMTP_USERNAME=your-ses-smtp-username
51
+ # SMTP_PASSWORD=your-ses-smtp-password
52
+ # SMTP_FROM_EMAIL=noreply@cuatrolabs.com
53
+ # SMTP_USE_TLS=true
54
+
55
+
56
+ # Option 4: Mailgun
57
+ # -----------------------------------------
58
+ # 1. Sign up at https://www.mailgun.com/
59
+ # 2. Get your SMTP credentials
60
+
61
+ # SMTP_HOST=smtp.mailgun.org
62
+ # SMTP_PORT=587
63
+ # SMTP_USERNAME=postmaster@your-domain.mailgun.org
64
+ # SMTP_PASSWORD=your-mailgun-smtp-password
65
+ # SMTP_FROM_EMAIL=noreply@cuatrolabs.com
66
+ # SMTP_USE_TLS=true
67
+
68
+
69
+ # Option 5: Office 365 / Outlook
70
+ # -----------------------------------------
71
+ # SMTP_HOST=smtp.office365.com
72
+ # SMTP_PORT=587
73
+ # SMTP_USERNAME=your-email@outlook.com
74
+ # SMTP_PASSWORD=your-password
75
+ # SMTP_FROM_EMAIL=your-email@outlook.com
76
+ # SMTP_USE_TLS=true
77
+
78
+
79
+ # ============================================
80
+ # Testing Configuration
81
+ # ============================================
82
+
83
+ # For local testing, you can use a service like Mailtrap
84
+ # which captures emails without sending them
85
+ # Sign up at: https://mailtrap.io/
86
+
87
+ # SMTP_HOST=smtp.mailtrap.io
88
+ # SMTP_PORT=2525
89
+ # SMTP_USERNAME=your-mailtrap-username
90
+ # SMTP_PASSWORD=your-mailtrap-password
91
+ # SMTP_FROM_EMAIL=noreply@cuatrolabs.com
92
+ # SMTP_USE_TLS=true
93
+
94
+
95
+ # ============================================
96
+ # Additional Notes
97
+ # ============================================
98
+
99
+ # 1. Never commit actual credentials to version control
100
+ # 2. Use environment-specific .env files (.env.development, .env.production)
101
+ # 3. For production, use proper email service (SendGrid, AWS SES, etc.)
102
+ # 4. Test email configuration with: python test_forgot_password.py --check-config
103
+ # 5. Monitor email delivery and bounces in production
FORGOT_PASSWORD_FEATURE.md ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forgot Password Feature Documentation
2
+
3
+ ## Overview
4
+
5
+ The forgot password feature allows users to securely reset their password by receiving a time-limited reset link via email. This implementation follows security best practices to prevent account enumeration and token reuse.
6
+
7
+ ## Architecture
8
+
9
+ ### Components
10
+
11
+ 1. **Email Service** (`app/utils/email_service.py`)
12
+ - Handles sending transactional emails via SMTP
13
+ - Uses `aiosmtplib` for async email delivery
14
+ - Provides HTML and plain text email templates
15
+
16
+ 2. **Service Layer** (`app/system_users/services/service.py`)
17
+ - `create_password_reset_token()` - Generates secure JWT token
18
+ - `send_password_reset_email()` - Sends reset email to user
19
+ - `verify_password_reset_token()` - Validates reset token
20
+ - `reset_password_with_token()` - Updates password with valid token
21
+
22
+ 3. **API Endpoints** (`app/system_users/controllers/router.py`)
23
+ - `POST /auth/forgot-password` - Request password reset
24
+ - `POST /auth/verify-reset-token` - Verify token validity
25
+ - `POST /auth/reset-password` - Reset password with token
26
+
27
+ 4. **Data Models** (`app/system_users/models/model.py`)
28
+ - Added `password_reset_token` field to SecuritySettingsModel
29
+ - Added `password_reset_token_created_at` timestamp
30
+
31
+ ## API Endpoints
32
+
33
+ ### 1. Request Password Reset
34
+
35
+ **Endpoint:** `POST /auth/forgot-password`
36
+
37
+ **Request Body:**
38
+ ```json
39
+ {
40
+ "email": "user@example.com"
41
+ }
42
+ ```
43
+
44
+ **Response:**
45
+ ```json
46
+ {
47
+ "success": true,
48
+ "message": "If the email exists in our system, a password reset link has been sent"
49
+ }
50
+ ```
51
+
52
+ **Security Note:** This endpoint always returns success to prevent email enumeration attacks.
53
+
54
+ ---
55
+
56
+ ### 2. Verify Reset Token
57
+
58
+ **Endpoint:** `POST /auth/verify-reset-token`
59
+
60
+ **Request Body:**
61
+ ```json
62
+ {
63
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
64
+ }
65
+ ```
66
+
67
+ **Success Response (200):**
68
+ ```json
69
+ {
70
+ "success": true,
71
+ "message": "Reset token is valid",
72
+ "data": {
73
+ "email": "user@example.com"
74
+ }
75
+ }
76
+ ```
77
+
78
+ **Error Response (400):**
79
+ ```json
80
+ {
81
+ "detail": "Invalid or expired reset token"
82
+ }
83
+ ```
84
+
85
+ ---
86
+
87
+ ### 3. Reset Password
88
+
89
+ **Endpoint:** `POST /auth/reset-password`
90
+
91
+ **Request Body:**
92
+ ```json
93
+ {
94
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
95
+ "new_password": "NewSecurePass123"
96
+ }
97
+ ```
98
+
99
+ **Success Response (200):**
100
+ ```json
101
+ {
102
+ "success": true,
103
+ "message": "Password has been reset successfully. You can now login with your new password."
104
+ }
105
+ ```
106
+
107
+ **Error Response (400):**
108
+ ```json
109
+ {
110
+ "detail": "Invalid or expired reset token"
111
+ }
112
+ ```
113
+
114
+ ## Security Features
115
+
116
+ ### 1. Token Security
117
+ - **JWT-based tokens** with expiration (default: 60 minutes)
118
+ - **Secure random token** embedded in JWT for double verification
119
+ - **One-time use** - Token is cleared after successful reset
120
+ - **Server-side validation** - Token stored in database and verified on use
121
+
122
+ ### 2. Anti-Enumeration
123
+ - Forgot password endpoint always returns success
124
+ - No indication whether email exists in system
125
+ - Prevents attackers from discovering valid email addresses
126
+
127
+ ### 3. Token Expiration
128
+ - Configurable expiration time (default: 60 minutes)
129
+ - Expired tokens are automatically rejected
130
+ - Token creation timestamp stored in database
131
+
132
+ ### 4. Additional Protections
133
+ - Tokens cleared after successful password reset
134
+ - Failed login attempts reset after successful password reset
135
+ - Account unlocked automatically after password reset
136
+ - Password must meet complexity requirements
137
+
138
+ ## Configuration
139
+
140
+ ### Environment Variables
141
+
142
+ Add these to your `.env` file:
143
+
144
+ ```bash
145
+ # Password Reset Configuration
146
+ PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES=60
147
+ PASSWORD_RESET_BASE_URL=http://localhost:3000/reset-password
148
+
149
+ # SMTP Configuration (required for email)
150
+ SMTP_HOST=smtp.gmail.com
151
+ SMTP_PORT=587
152
+ SMTP_USERNAME=your-email@gmail.com
153
+ SMTP_PASSWORD=your-app-password
154
+ SMTP_FROM_EMAIL=noreply@cuatrolabs.com
155
+ SMTP_USE_TLS=true
156
+ ```
157
+
158
+ ### SMTP Configuration Examples
159
+
160
+ #### Gmail
161
+ ```bash
162
+ SMTP_HOST=smtp.gmail.com
163
+ SMTP_PORT=587
164
+ SMTP_USERNAME=your-email@gmail.com
165
+ SMTP_PASSWORD=your-app-password # Use App Password, not regular password
166
+ SMTP_USE_TLS=true
167
+ ```
168
+
169
+ #### SendGrid
170
+ ```bash
171
+ SMTP_HOST=smtp.sendgrid.net
172
+ SMTP_PORT=587
173
+ SMTP_USERNAME=apikey
174
+ SMTP_PASSWORD=your-sendgrid-api-key
175
+ SMTP_USE_TLS=true
176
+ ```
177
+
178
+ #### AWS SES
179
+ ```bash
180
+ SMTP_HOST=email-smtp.us-east-1.amazonaws.com
181
+ SMTP_PORT=587
182
+ SMTP_USERNAME=your-ses-smtp-username
183
+ SMTP_PASSWORD=your-ses-smtp-password
184
+ SMTP_USE_TLS=true
185
+ ```
186
+
187
+ ## Email Template
188
+
189
+ The password reset email includes:
190
+ - Professional HTML design with inline CSS
191
+ - Clear call-to-action button
192
+ - Security warnings and expiration notice
193
+ - Plain text fallback for email clients without HTML support
194
+ - Company branding (Cuatro Labs)
195
+
196
+ ### Email Preview
197
+
198
+ **Subject:** Password Reset Request - Cuatro Labs Auth
199
+
200
+ The email contains:
201
+ - Personalized greeting with user's first name
202
+ - "Reset My Password" button linking to reset page
203
+ - Security warnings about:
204
+ - 1-hour expiration
205
+ - One-time use only
206
+ - Ignore if not requested
207
+ - Fallback plain text link
208
+ - Professional footer
209
+
210
+ ## Frontend Integration
211
+
212
+ ### 1. Request Password Reset
213
+
214
+ ```javascript
215
+ async function requestPasswordReset(email) {
216
+ const response = await fetch('/auth/forgot-password', {
217
+ method: 'POST',
218
+ headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ email })
220
+ });
221
+
222
+ const data = await response.json();
223
+
224
+ if (data.success) {
225
+ // Show success message
226
+ alert('Check your email for reset instructions');
227
+ }
228
+ }
229
+ ```
230
+
231
+ ### 2. Verify Token on Page Load
232
+
233
+ ```javascript
234
+ async function verifyResetToken(token) {
235
+ try {
236
+ const response = await fetch('/auth/verify-reset-token', {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ token })
240
+ });
241
+
242
+ if (response.ok) {
243
+ const data = await response.json();
244
+ return { valid: true, email: data.data.email };
245
+ } else {
246
+ return { valid: false, error: 'Token expired or invalid' };
247
+ }
248
+ } catch (error) {
249
+ return { valid: false, error: 'Network error' };
250
+ }
251
+ }
252
+
253
+ // Usage in reset page
254
+ const urlParams = new URLSearchParams(window.location.search);
255
+ const token = urlParams.get('token');
256
+
257
+ if (token) {
258
+ const result = await verifyResetToken(token);
259
+ if (!result.valid) {
260
+ // Show error and redirect to forgot password page
261
+ alert(result.error);
262
+ window.location.href = '/forgot-password';
263
+ }
264
+ }
265
+ ```
266
+
267
+ ### 3. Reset Password
268
+
269
+ ```javascript
270
+ async function resetPassword(token, newPassword) {
271
+ const response = await fetch('/auth/reset-password', {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/json' },
274
+ body: JSON.stringify({
275
+ token,
276
+ new_password: newPassword
277
+ })
278
+ });
279
+
280
+ const data = await response.json();
281
+
282
+ if (response.ok && data.success) {
283
+ // Show success and redirect to login
284
+ alert('Password reset successful!');
285
+ window.location.href = '/login';
286
+ } else {
287
+ // Show error
288
+ alert(data.detail || 'Failed to reset password');
289
+ }
290
+ }
291
+ ```
292
+
293
+ ## Testing
294
+
295
+ ### Manual Testing Steps
296
+
297
+ 1. **Request Password Reset**
298
+ ```bash
299
+ curl -X POST http://localhost:9182/auth/forgot-password \
300
+ -H "Content-Type: application/json" \
301
+ -d '{"email": "test@example.com"}'
302
+ ```
303
+
304
+ 2. **Check Email**
305
+ - Check the email inbox for test@example.com
306
+ - Copy the reset token from the URL in the email
307
+
308
+ 3. **Verify Token**
309
+ ```bash
310
+ curl -X POST http://localhost:9182/auth/verify-reset-token \
311
+ -H "Content-Type: application/json" \
312
+ -d '{"token": "YOUR_TOKEN_HERE"}'
313
+ ```
314
+
315
+ 4. **Reset Password**
316
+ ```bash
317
+ curl -X POST http://localhost:9182/auth/reset-password \
318
+ -H "Content-Type: application/json" \
319
+ -d '{
320
+ "token": "YOUR_TOKEN_HERE",
321
+ "new_password": "NewPassword123"
322
+ }'
323
+ ```
324
+
325
+ 5. **Login with New Password**
326
+ ```bash
327
+ curl -X POST http://localhost:9182/auth/login \
328
+ -H "Content-Type: application/json" \
329
+ -d '{
330
+ "email_or_phone": "test@example.com",
331
+ "password": "NewPassword123"
332
+ }'
333
+ ```
334
+
335
+ ### Automated Tests
336
+
337
+ Create a test file `test_password_reset.py`:
338
+
339
+ ```python
340
+ import pytest
341
+ from httpx import AsyncClient
342
+ from app.main import app
343
+
344
+ @pytest.mark.asyncio
345
+ async def test_forgot_password():
346
+ async with AsyncClient(app=app, base_url="http://test") as client:
347
+ response = await client.post(
348
+ "/auth/forgot-password",
349
+ json={"email": "test@example.com"}
350
+ )
351
+ assert response.status_code == 200
352
+ assert response.json()["success"] is True
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_reset_password_invalid_token():
356
+ async with AsyncClient(app=app, base_url="http://test") as client:
357
+ response = await client.post(
358
+ "/auth/reset-password",
359
+ json={
360
+ "token": "invalid_token",
361
+ "new_password": "NewPassword123"
362
+ }
363
+ )
364
+ assert response.status_code == 400
365
+ ```
366
+
367
+ ## Troubleshooting
368
+
369
+ ### Email Not Sending
370
+
371
+ 1. **Check SMTP Configuration**
372
+ ```python
373
+ # In Python shell
374
+ from app.core.config import settings
375
+ print(f"SMTP Host: {settings.SMTP_HOST}")
376
+ print(f"SMTP Port: {settings.SMTP_PORT}")
377
+ print(f"SMTP Username: {settings.SMTP_USERNAME}")
378
+ ```
379
+
380
+ 2. **Test SMTP Connection**
381
+ ```python
382
+ import aiosmtplib
383
+ import asyncio
384
+
385
+ async def test_smtp():
386
+ try:
387
+ await aiosmtplib.send(
388
+ message="Test",
389
+ hostname="smtp.gmail.com",
390
+ port=587,
391
+ username="your-email@gmail.com",
392
+ password="your-app-password",
393
+ use_tls=True
394
+ )
395
+ print("SMTP connection successful!")
396
+ except Exception as e:
397
+ print(f"Error: {e}")
398
+
399
+ asyncio.run(test_smtp())
400
+ ```
401
+
402
+ 3. **Check Logs**
403
+ ```bash
404
+ tail -f logs/app.log | grep -i email
405
+ ```
406
+
407
+ ### Token Invalid or Expired
408
+
409
+ 1. **Check token expiration time in config**
410
+ 2. **Verify system clocks are synchronized**
411
+ 3. **Check if token was already used (one-time use)**
412
+ 4. **Verify JWT secret key matches**
413
+
414
+ ### Password Requirements Not Met
415
+
416
+ The new password must:
417
+ - Be at least 8 characters long
418
+ - Contain at least one uppercase letter
419
+ - Contain at least one lowercase letter
420
+ - Contain at least one digit
421
+
422
+ ## Database Schema
423
+
424
+ The password reset functionality adds these fields to the user document:
425
+
426
+ ```javascript
427
+ {
428
+ "security_settings": {
429
+ "password_reset_token": "string (nullable)",
430
+ "password_reset_token_created_at": "datetime (nullable)",
431
+ // ... other security settings
432
+ }
433
+ }
434
+ ```
435
+
436
+ ## Future Enhancements
437
+
438
+ 1. **Rate Limiting**
439
+ - Limit password reset requests per email per hour
440
+ - Prevent abuse and spam
441
+
442
+ 2. **Email Templates**
443
+ - Customizable email templates
444
+ - Multi-language support
445
+
446
+ 3. **SMS Reset Option**
447
+ - Alternative reset via SMS
448
+ - Two-factor authentication for reset
449
+
450
+ 4. **Admin Notifications**
451
+ - Alert admins of multiple failed reset attempts
452
+ - Security monitoring dashboard
453
+
454
+ 5. **Password History**
455
+ - Prevent reuse of recent passwords
456
+ - Store hashed password history
457
+
458
+ ## Support
459
+
460
+ For issues or questions:
461
+ - Check logs: `logs/app.log`
462
+ - Review configuration: `.env` file
463
+ - Contact: support@cuatrolabs.com
FORGOT_PASSWORD_QUICKSTART.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forgot Password - Quick Reference
2
+
3
+ ## 🚀 Quick Start
4
+
5
+ ### 1. Configure SMTP (Required)
6
+
7
+ Add to your `.env` file:
8
+
9
+ ```bash
10
+ # For Gmail
11
+ SMTP_HOST=smtp.gmail.com
12
+ SMTP_PORT=587
13
+ SMTP_USERNAME=your-email@gmail.com
14
+ SMTP_PASSWORD=your-app-password # Generate at https://myaccount.google.com/apppasswords
15
+ SMTP_FROM_EMAIL=noreply@cuatrolabs.com
16
+ SMTP_USE_TLS=true
17
+
18
+ # Password Reset Settings
19
+ PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES=60
20
+ PASSWORD_RESET_BASE_URL=http://localhost:3000/reset-password
21
+ ```
22
+
23
+ ### 2. Test Email Configuration
24
+
25
+ ```bash
26
+ python test_forgot_password.py --check-config
27
+ ```
28
+
29
+ ### 3. Test Complete Flow
30
+
31
+ ```bash
32
+ python test_forgot_password.py user@example.com
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 📡 API Endpoints
38
+
39
+ ### Request Password Reset
40
+ ```bash
41
+ curl -X POST http://localhost:9182/auth/forgot-password \
42
+ -H "Content-Type: application/json" \
43
+ -d '{"email": "user@example.com"}'
44
+ ```
45
+
46
+ ### Verify Reset Token
47
+ ```bash
48
+ curl -X POST http://localhost:9182/auth/verify-reset-token \
49
+ -H "Content-Type: application/json" \
50
+ -d '{"token": "YOUR_TOKEN"}'
51
+ ```
52
+
53
+ ### Reset Password
54
+ ```bash
55
+ curl -X POST http://localhost:9182/auth/reset-password \
56
+ -H "Content-Type: application/json" \
57
+ -d '{
58
+ "token": "YOUR_TOKEN",
59
+ "new_password": "NewPassword123"
60
+ }'
61
+ ```
62
+
63
+ ---
64
+
65
+ ## 🌐 Frontend Integration
66
+
67
+ ### Step 1: Forgot Password Page
68
+
69
+ ```javascript
70
+ // Request password reset
71
+ async function handleForgotPassword(email) {
72
+ const res = await fetch('/auth/forgot-password', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ email })
76
+ });
77
+
78
+ const data = await res.json();
79
+ alert('Check your email for reset instructions!');
80
+ }
81
+ ```
82
+
83
+ ### Step 2: Reset Password Page
84
+
85
+ ```javascript
86
+ // Get token from URL
87
+ const params = new URLSearchParams(window.location.search);
88
+ const token = params.get('token');
89
+
90
+ // Verify token on page load
91
+ async function verifyToken(token) {
92
+ const res = await fetch('/auth/verify-reset-token', {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ token })
96
+ });
97
+
98
+ if (!res.ok) {
99
+ alert('Invalid or expired link');
100
+ window.location.href = '/forgot-password';
101
+ }
102
+ }
103
+
104
+ // Reset password
105
+ async function handleResetPassword(token, newPassword) {
106
+ const res = await fetch('/auth/reset-password', {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({ token, new_password: newPassword })
110
+ });
111
+
112
+ if (res.ok) {
113
+ alert('Password reset successful!');
114
+ window.location.href = '/login';
115
+ }
116
+ }
117
+
118
+ // Initialize
119
+ verifyToken(token);
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 🔒 Security Features
125
+
126
+ ✅ **Secure Tokens** - JWT with 60-minute expiration
127
+ ✅ **One-Time Use** - Token invalidated after use
128
+ ✅ **Anti-Enumeration** - No user existence disclosure
129
+ ✅ **Password Requirements** - 8+ chars, uppercase, lowercase, digit
130
+ ✅ **Account Unlock** - Failed attempts reset on password change
131
+
132
+ ---
133
+
134
+ ## 📧 Email Template Features
135
+
136
+ - Professional HTML design
137
+ - Mobile responsive
138
+ - Clear call-to-action button
139
+ - Security warnings
140
+ - Plain text fallback
141
+ - Company branding
142
+
143
+ ---
144
+
145
+ ## 🐛 Troubleshooting
146
+
147
+ ### Email Not Sending?
148
+
149
+ 1. **Check SMTP config:**
150
+ ```bash
151
+ python test_forgot_password.py --check-config
152
+ ```
153
+
154
+ 2. **For Gmail:** Enable 2FA and create [App Password](https://myaccount.google.com/apppasswords)
155
+
156
+ 3. **Check logs:**
157
+ ```bash
158
+ tail -f logs/app.log | grep -i email
159
+ ```
160
+
161
+ ### Token Invalid?
162
+
163
+ - Tokens expire after 60 minutes
164
+ - Tokens can only be used once
165
+ - Check system time is synchronized
166
+
167
+ ---
168
+
169
+ ## 📚 Full Documentation
170
+
171
+ See [FORGOT_PASSWORD_FEATURE.md](FORGOT_PASSWORD_FEATURE.md) for complete documentation.
172
+
173
+ ---
174
+
175
+ ## ✅ Testing Checklist
176
+
177
+ - [ ] Configure SMTP in `.env`
178
+ - [ ] Run `python test_forgot_password.py --check-config`
179
+ - [ ] Test with real user: `python test_forgot_password.py user@email.com`
180
+ - [ ] Check email received
181
+ - [ ] Click reset link
182
+ - [ ] Set new password
183
+ - [ ] Login with new password
184
+
185
+ ---
186
+
187
+ ## 🎯 API Response Examples
188
+
189
+ ### Success Response
190
+ ```json
191
+ {
192
+ "success": true,
193
+ "message": "Password has been reset successfully"
194
+ }
195
+ ```
196
+
197
+ ### Error Response
198
+ ```json
199
+ {
200
+ "detail": "Invalid or expired reset token"
201
+ }
202
+ ```
IMPLEMENTATION_SUMMARY.md ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🔐 Forgot Password Feature - Implementation Summary
2
+
3
+ ## ✅ Implementation Complete
4
+
5
+ The forgot password feature with email reset link has been successfully implemented in your authentication microservice.
6
+
7
+ ---
8
+
9
+ ## 📦 What Was Added
10
+
11
+ ### 1. New Files Created
12
+
13
+ - **`app/utils/email_service.py`** - Email service for sending transactional emails
14
+ - **`test_forgot_password.py`** - Test script for the feature
15
+ - **`FORGOT_PASSWORD_FEATURE.md`** - Complete documentation
16
+ - **`FORGOT_PASSWORD_QUICKSTART.md`** - Quick reference guide
17
+ - **`.env.forgot-password.example`** - SMTP configuration examples
18
+
19
+ ### 2. Modified Files
20
+
21
+ - **`app/system_users/schemas/schema.py`**
22
+ - Added `ForgotPasswordRequest` schema
23
+ - Added `ResetPasswordRequest` schema
24
+ - Added `VerifyResetTokenRequest` schema
25
+
26
+ - **`app/system_users/services/service.py`**
27
+ - Added `create_password_reset_token()` method
28
+ - Added `send_password_reset_email()` method
29
+ - Added `verify_password_reset_token()` method
30
+ - Added `reset_password_with_token()` method
31
+
32
+ - **`app/system_users/controllers/router.py`**
33
+ - Added `POST /auth/forgot-password` endpoint
34
+ - Added `POST /auth/verify-reset-token` endpoint
35
+ - Added `POST /auth/reset-password` endpoint
36
+
37
+ - **`app/system_users/models/model.py`**
38
+ - Added `password_reset_token` field to `SecuritySettingsModel`
39
+ - Added `password_reset_token_created_at` field
40
+
41
+ - **`app/core/config.py`**
42
+ - Added `PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES` setting
43
+ - Added `PASSWORD_RESET_BASE_URL` setting
44
+
45
+ ---
46
+
47
+ ## 🚀 How to Use
48
+
49
+ ### Step 1: Configure SMTP
50
+
51
+ Copy settings from `.env.forgot-password.example` to your `.env` file:
52
+
53
+ ```bash
54
+ # For Gmail (easiest for testing)
55
+ SMTP_HOST=smtp.gmail.com
56
+ SMTP_PORT=587
57
+ SMTP_USERNAME=your-email@gmail.com
58
+ SMTP_PASSWORD=your-app-password # Get from https://myaccount.google.com/apppasswords
59
+ SMTP_FROM_EMAIL=noreply@cuatrolabs.com
60
+ SMTP_USE_TLS=true
61
+
62
+ # Password Reset Settings
63
+ PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES=60
64
+ PASSWORD_RESET_BASE_URL=http://localhost:3000/reset-password
65
+ ```
66
+
67
+ ### Step 2: Test Configuration
68
+
69
+ ```bash
70
+ python test_forgot_password.py --check-config
71
+ ```
72
+
73
+ ### Step 3: Test Full Flow
74
+
75
+ ```bash
76
+ python test_forgot_password.py user@example.com
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 🌐 API Endpoints
82
+
83
+ ### 1. Request Password Reset
84
+
85
+ **POST** `/auth/forgot-password`
86
+
87
+ ```bash
88
+ curl -X POST http://localhost:9182/auth/forgot-password \
89
+ -H "Content-Type: application/json" \
90
+ -d '{"email": "user@example.com"}'
91
+ ```
92
+
93
+ **Response:**
94
+ ```json
95
+ {
96
+ "success": true,
97
+ "message": "If the email exists in our system, a password reset link has been sent"
98
+ }
99
+ ```
100
+
101
+ ### 2. Verify Reset Token
102
+
103
+ **POST** `/auth/verify-reset-token`
104
+
105
+ ```bash
106
+ curl -X POST http://localhost:9182/auth/verify-reset-token \
107
+ -H "Content-Type: application/json" \
108
+ -d '{"token": "YOUR_TOKEN"}'
109
+ ```
110
+
111
+ **Response:**
112
+ ```json
113
+ {
114
+ "success": true,
115
+ "message": "Reset token is valid",
116
+ "data": {
117
+ "email": "user@example.com"
118
+ }
119
+ }
120
+ ```
121
+
122
+ ### 3. Reset Password
123
+
124
+ **POST** `/auth/reset-password`
125
+
126
+ ```bash
127
+ curl -X POST http://localhost:9182/auth/reset-password \
128
+ -H "Content-Type: application/json" \
129
+ -d '{
130
+ "token": "YOUR_TOKEN",
131
+ "new_password": "NewPassword123"
132
+ }'
133
+ ```
134
+
135
+ **Response:**
136
+ ```json
137
+ {
138
+ "success": true,
139
+ "message": "Password has been reset successfully. You can now login with your new password."
140
+ }
141
+ ```
142
+
143
+ ---
144
+
145
+ ## 🎨 Frontend Integration
146
+
147
+ ### Forgot Password Page
148
+
149
+ ```javascript
150
+ async function requestPasswordReset(email) {
151
+ const response = await fetch('/auth/forgot-password', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ email })
155
+ });
156
+
157
+ const data = await response.json();
158
+ alert('Check your email for reset instructions!');
159
+ }
160
+ ```
161
+
162
+ ### Reset Password Page
163
+
164
+ ```javascript
165
+ // Get token from URL query parameter
166
+ const urlParams = new URLSearchParams(window.location.search);
167
+ const token = urlParams.get('token');
168
+
169
+ // Verify token on page load
170
+ async function verifyToken(token) {
171
+ const response = await fetch('/auth/verify-reset-token', {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ token })
175
+ });
176
+
177
+ if (!response.ok) {
178
+ alert('Invalid or expired reset link');
179
+ window.location.href = '/forgot-password';
180
+ return;
181
+ }
182
+
183
+ const data = await response.json();
184
+ // Show reset form with email pre-filled
185
+ document.getElementById('email').value = data.data.email;
186
+ }
187
+
188
+ // Reset password
189
+ async function resetPassword(token, newPassword) {
190
+ const response = await fetch('/auth/reset-password', {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify({
194
+ token,
195
+ new_password: newPassword
196
+ })
197
+ });
198
+
199
+ if (response.ok) {
200
+ alert('Password reset successful!');
201
+ window.location.href = '/login';
202
+ } else {
203
+ const data = await response.json();
204
+ alert(data.detail || 'Failed to reset password');
205
+ }
206
+ }
207
+
208
+ // Initialize
209
+ verifyToken(token);
210
+ ```
211
+
212
+ ---
213
+
214
+ ## 🔒 Security Features
215
+
216
+ ✅ **JWT Tokens** - Secure, time-limited tokens (60 minutes)
217
+ ✅ **One-Time Use** - Tokens invalidated after successful reset
218
+ ✅ **Double Verification** - Token stored in both JWT and database
219
+ ✅ **Anti-Enumeration** - Doesn't reveal if email exists
220
+ ✅ **Password Requirements** - Enforced complexity rules
221
+ ✅ **Account Unlock** - Failed login attempts reset on password change
222
+ ✅ **Expiration** - Tokens automatically expire after 1 hour
223
+
224
+ ---
225
+
226
+ ## 📧 Email Template
227
+
228
+ Professional HTML email with:
229
+ - Clean, modern design
230
+ - Mobile responsive layout
231
+ - Clear "Reset Password" button
232
+ - Security warnings and instructions
233
+ - Plain text fallback
234
+ - Company branding
235
+
236
+ ---
237
+
238
+ ## 🧪 Testing
239
+
240
+ ### Manual Testing
241
+
242
+ 1. **Request reset:**
243
+ ```bash
244
+ curl -X POST http://localhost:9182/auth/forgot-password \
245
+ -H "Content-Type: application/json" \
246
+ -d '{"email": "test@example.com"}'
247
+ ```
248
+
249
+ 2. **Check email** for reset link
250
+
251
+ 3. **Copy token** from email URL
252
+
253
+ 4. **Verify token:**
254
+ ```bash
255
+ curl -X POST http://localhost:9182/auth/verify-reset-token \
256
+ -H "Content-Type: application/json" \
257
+ -d '{"token": "YOUR_TOKEN"}'
258
+ ```
259
+
260
+ 5. **Reset password:**
261
+ ```bash
262
+ curl -X POST http://localhost:9182/auth/reset-password \
263
+ -H "Content-Type: application/json" \
264
+ -d '{"token": "YOUR_TOKEN", "new_password": "NewPass123"}'
265
+ ```
266
+
267
+ 6. **Login** with new password
268
+
269
+ ### Automated Testing
270
+
271
+ ```bash
272
+ # Check SMTP configuration
273
+ python test_forgot_password.py --check-config
274
+
275
+ # Test full flow
276
+ python test_forgot_password.py user@example.com
277
+ ```
278
+
279
+ ---
280
+
281
+ ## 📝 Configuration Options
282
+
283
+ | Setting | Default | Description |
284
+ |---------|---------|-------------|
285
+ | `PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES` | 60 | Token validity period |
286
+ | `PASSWORD_RESET_BASE_URL` | `http://localhost:3000/reset-password` | Frontend reset page URL |
287
+ | `SMTP_HOST` | - | SMTP server hostname |
288
+ | `SMTP_PORT` | 587 | SMTP server port |
289
+ | `SMTP_USERNAME` | - | SMTP authentication username |
290
+ | `SMTP_PASSWORD` | - | SMTP authentication password |
291
+ | `SMTP_FROM_EMAIL` | - | Sender email address |
292
+ | `SMTP_USE_TLS` | true | Use TLS encryption |
293
+
294
+ ---
295
+
296
+ ## 🐛 Troubleshooting
297
+
298
+ ### Email Not Sending
299
+
300
+ 1. **Verify SMTP config:**
301
+ ```bash
302
+ python test_forgot_password.py --check-config
303
+ ```
304
+
305
+ 2. **For Gmail:**
306
+ - Enable 2-Factor Authentication
307
+ - Generate App Password at https://myaccount.google.com/apppasswords
308
+ - Use App Password, not regular password
309
+
310
+ 3. **Check logs:**
311
+ ```bash
312
+ tail -f logs/app.log | grep -i email
313
+ ```
314
+
315
+ ### Token Issues
316
+
317
+ - **Expired:** Tokens expire after 60 minutes
318
+ - **Already Used:** Tokens can only be used once
319
+ - **Invalid:** Check JWT secret key matches
320
+
321
+ ### Password Requirements
322
+
323
+ New password must:
324
+ - Be at least 8 characters long
325
+ - Contain at least one uppercase letter (A-Z)
326
+ - Contain at least one lowercase letter (a-z)
327
+ - Contain at least one digit (0-9)
328
+
329
+ ---
330
+
331
+ ## 📚 Documentation
332
+
333
+ - **[FORGOT_PASSWORD_FEATURE.md](FORGOT_PASSWORD_FEATURE.md)** - Complete documentation
334
+ - **[FORGOT_PASSWORD_QUICKSTART.md](FORGOT_PASSWORD_QUICKSTART.md)** - Quick reference
335
+ - **[.env.forgot-password.example](.env.forgot-password.example)** - SMTP configuration examples
336
+
337
+ ---
338
+
339
+ ## ✅ Testing Checklist
340
+
341
+ Before deploying to production:
342
+
343
+ - [ ] Configure SMTP in `.env` file
344
+ - [ ] Test SMTP configuration with `--check-config`
345
+ - [ ] Test forgot password request
346
+ - [ ] Verify email is received with correct formatting
347
+ - [ ] Test reset link works
348
+ - [ ] Test token expiration (wait 60+ minutes)
349
+ - [ ] Test token can't be reused
350
+ - [ ] Test invalid token handling
351
+ - [ ] Test password requirements validation
352
+ - [ ] Test successful password reset
353
+ - [ ] Test login with new password
354
+ - [ ] Test with non-existent email (should not reveal)
355
+ - [ ] Test with inactive account
356
+ - [ ] Check logs for any errors
357
+ - [ ] Monitor email delivery in production
358
+
359
+ ---
360
+
361
+ ## 🎯 Next Steps
362
+
363
+ 1. **Configure SMTP** in your `.env` file
364
+ 2. **Test locally** using the test script
365
+ 3. **Update frontend** to integrate the new endpoints
366
+ 4. **Test thoroughly** before deploying to production
367
+ 5. **Monitor** email delivery and errors
368
+
369
+ ---
370
+
371
+ ## 🔮 Future Enhancements
372
+
373
+ Potential improvements:
374
+ - Rate limiting on password reset requests
375
+ - SMS-based password reset option
376
+ - Multi-language email templates
377
+ - Password history to prevent reuse
378
+ - Admin notifications for suspicious activity
379
+ - Custom email templates per merchant type
380
+ - Two-factor authentication for reset
381
+
382
+ ---
383
+
384
+ ## 💡 Tips
385
+
386
+ 1. **Use App Passwords** for Gmail (not regular password)
387
+ 2. **Test with Mailtrap.io** to avoid sending real emails during development
388
+ 3. **Monitor email deliverability** in production
389
+ 4. **Set up proper SPF/DKIM** records for your domain
390
+ 5. **Use professional email service** (SendGrid, AWS SES) for production
391
+ 6. **Log all password reset attempts** for security monitoring
392
+
393
+ ---
394
+
395
+ ## 🤝 Support
396
+
397
+ For questions or issues:
398
+ - Review logs: `logs/app.log`
399
+ - Check configuration: `.env` file
400
+ - Review documentation: `FORGOT_PASSWORD_FEATURE.md`
401
+ - Run test script: `python test_forgot_password.py --check-config`
402
+
403
+ ---
404
+
405
+ **Implementation completed successfully! 🎉**
406
+
407
+ The forgot password feature is now fully integrated and ready to use.
app/core/config.py CHANGED
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
33
  MAX_FAILED_LOGIN_ATTEMPTS: int = int(os.getenv("MAX_FAILED_LOGIN_ATTEMPTS", "5"))
34
  ACCOUNT_LOCK_DURATION_MINUTES: int = int(os.getenv("ACCOUNT_LOCK_DURATION_MINUTES", "15"))
35
  REMEMBER_ME_TOKEN_HOURS: int = int(os.getenv("REMEMBER_ME_TOKEN_HOURS", "24"))
 
 
 
 
36
 
37
  # API Configuration
38
  MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
 
33
  MAX_FAILED_LOGIN_ATTEMPTS: int = int(os.getenv("MAX_FAILED_LOGIN_ATTEMPTS", "5"))
34
  ACCOUNT_LOCK_DURATION_MINUTES: int = int(os.getenv("ACCOUNT_LOCK_DURATION_MINUTES", "15"))
35
  REMEMBER_ME_TOKEN_HOURS: int = int(os.getenv("REMEMBER_ME_TOKEN_HOURS", "24"))
36
+
37
+ # Password Reset Configuration
38
+ PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES: int = int(os.getenv("PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES", "60"))
39
+ PASSWORD_RESET_BASE_URL: str = os.getenv("PASSWORD_RESET_BASE_URL", "http://localhost:3000/reset-password")
40
 
41
  # API Configuration
42
  MAX_PAGE_SIZE: int = int(os.getenv("MAX_PAGE_SIZE", "100"))
app/system_users/controllers/router.py CHANGED
@@ -14,6 +14,9 @@ from app.system_users.schemas.schema import (
14
  CreateUserRequest,
15
  UpdateUserRequest,
16
  ChangePasswordRequest,
 
 
 
17
  UserInfoResponse,
18
  UserListResponse,
19
  UserListRequest,
@@ -560,6 +563,162 @@ async def change_password(
560
  )
561
 
562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  @router.delete("/users/{user_id}", response_model=StandardResponse)
564
  async def deactivate_user(
565
  user_id: str,
 
14
  CreateUserRequest,
15
  UpdateUserRequest,
16
  ChangePasswordRequest,
17
+ ForgotPasswordRequest,
18
+ ResetPasswordRequest,
19
+ VerifyResetTokenRequest,
20
  UserInfoResponse,
21
  UserListResponse,
22
  UserListRequest,
 
563
  )
564
 
565
 
566
+ @router.post("/forgot-password", response_model=StandardResponse)
567
+ async def forgot_password(
568
+ request_data: ForgotPasswordRequest,
569
+ user_service: SystemUserService = Depends(get_system_user_service)
570
+ ):
571
+ """
572
+ Request password reset link. Sends an email with reset link to the user.
573
+
574
+ This endpoint always returns success to prevent email enumeration attacks.
575
+
576
+ Raises:
577
+ HTTPException: 400 - Invalid email format
578
+ HTTPException: 500 - Server error
579
+ """
580
+ try:
581
+ # Validate email
582
+ if not request_data.email or not request_data.email.strip():
583
+ raise HTTPException(
584
+ status_code=status.HTTP_400_BAD_REQUEST,
585
+ detail="Email is required"
586
+ )
587
+
588
+ # Send password reset email
589
+ # Note: We always return success to prevent email enumeration
590
+ await user_service.send_password_reset_email(request_data.email)
591
+
592
+ logger.info(f"Password reset requested for email: {request_data.email}")
593
+
594
+ return StandardResponse(
595
+ success=True,
596
+ message="If the email exists in our system, a password reset link has been sent"
597
+ )
598
+
599
+ except HTTPException:
600
+ raise
601
+ except Exception as e:
602
+ logger.error(f"Unexpected error in forgot password: {str(e)}", exc_info=True)
603
+ raise HTTPException(
604
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
605
+ detail="Failed to process password reset request"
606
+ )
607
+
608
+
609
+ @router.post("/verify-reset-token", response_model=StandardResponse)
610
+ async def verify_reset_token(
611
+ request_data: VerifyResetTokenRequest,
612
+ user_service: SystemUserService = Depends(get_system_user_service)
613
+ ):
614
+ """
615
+ Verify if a password reset token is valid.
616
+
617
+ Use this endpoint to check if a token is valid before showing the reset password form.
618
+
619
+ Raises:
620
+ HTTPException: 400 - Invalid or expired token
621
+ HTTPException: 500 - Server error
622
+ """
623
+ try:
624
+ # Validate token
625
+ if not request_data.token or not request_data.token.strip():
626
+ raise HTTPException(
627
+ status_code=status.HTTP_400_BAD_REQUEST,
628
+ detail="Reset token is required"
629
+ )
630
+
631
+ # Verify token
632
+ token_data = await user_service.verify_password_reset_token(request_data.token)
633
+
634
+ if not token_data:
635
+ logger.warning("Invalid or expired reset token verification attempt")
636
+ raise HTTPException(
637
+ status_code=status.HTTP_400_BAD_REQUEST,
638
+ detail="Invalid or expired reset token"
639
+ )
640
+
641
+ return StandardResponse(
642
+ success=True,
643
+ message="Reset token is valid",
644
+ data={"email": token_data.get("email")}
645
+ )
646
+
647
+ except HTTPException:
648
+ raise
649
+ except Exception as e:
650
+ logger.error(f"Unexpected error verifying reset token: {str(e)}", exc_info=True)
651
+ raise HTTPException(
652
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
653
+ detail="Failed to verify reset token"
654
+ )
655
+
656
+
657
+ @router.post("/reset-password", response_model=StandardResponse)
658
+ async def reset_password(
659
+ request_data: ResetPasswordRequest,
660
+ user_service: SystemUserService = Depends(get_system_user_service)
661
+ ):
662
+ """
663
+ Reset password using a valid reset token.
664
+
665
+ The token is validated and can only be used once. After successful reset,
666
+ the user can login with their new password.
667
+
668
+ Raises:
669
+ HTTPException: 400 - Invalid token or password requirements not met
670
+ HTTPException: 500 - Server error
671
+ """
672
+ try:
673
+ # Validate inputs
674
+ if not request_data.token or not request_data.token.strip():
675
+ raise HTTPException(
676
+ status_code=status.HTTP_400_BAD_REQUEST,
677
+ detail="Reset token is required"
678
+ )
679
+
680
+ if not request_data.new_password or not request_data.new_password.strip():
681
+ raise HTTPException(
682
+ status_code=status.HTTP_400_BAD_REQUEST,
683
+ detail="New password is required"
684
+ )
685
+
686
+ if len(request_data.new_password) < 8:
687
+ raise HTTPException(
688
+ status_code=status.HTTP_400_BAD_REQUEST,
689
+ detail="Password must be at least 8 characters long"
690
+ )
691
+
692
+ # Reset password
693
+ success, message = await user_service.reset_password_with_token(
694
+ token=request_data.token,
695
+ new_password=request_data.new_password
696
+ )
697
+
698
+ if not success:
699
+ logger.warning(f"Password reset failed: {message}")
700
+ raise HTTPException(
701
+ status_code=status.HTTP_400_BAD_REQUEST,
702
+ detail=message
703
+ )
704
+
705
+ logger.info("Password reset completed successfully")
706
+
707
+ return StandardResponse(
708
+ success=True,
709
+ message="Password has been reset successfully. You can now login with your new password."
710
+ )
711
+
712
+ except HTTPException:
713
+ raise
714
+ except Exception as e:
715
+ logger.error(f"Unexpected error resetting password: {str(e)}", exc_info=True)
716
+ raise HTTPException(
717
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
718
+ detail="Failed to reset password"
719
+ )
720
+
721
+
722
  @router.delete("/users/{user_id}", response_model=StandardResponse)
723
  async def deactivate_user(
724
  user_id: str,
app/system_users/models/model.py CHANGED
@@ -44,6 +44,8 @@ class SecuritySettingsModel(BaseModel):
44
  account_locked_until: Optional[datetime] = Field(None, description="Account lock expiry time")
45
  last_password_change: Optional[datetime] = Field(None, description="Last password change timestamp")
46
  login_attempts: List[LoginAttemptModel] = Field(default_factory=list, description="Recent login attempts (last 10)")
 
 
47
 
48
 
49
  class SystemUserModel(BaseModel):
 
44
  account_locked_until: Optional[datetime] = Field(None, description="Account lock expiry time")
45
  last_password_change: Optional[datetime] = Field(None, description="Last password change timestamp")
46
  login_attempts: List[LoginAttemptModel] = Field(default_factory=list, description="Recent login attempts (last 10)")
47
+ password_reset_token: Optional[str] = Field(None, description="Password reset token (stored securely)")
48
+ password_reset_token_created_at: Optional[datetime] = Field(None, description="When reset token was created")
49
 
50
 
51
  class SystemUserModel(BaseModel):
app/system_users/schemas/schema.py CHANGED
@@ -101,9 +101,32 @@ class ChangePasswordRequest(BaseModel):
101
  return v
102
 
103
 
 
 
 
 
 
104
  class ResetPasswordRequest(BaseModel):
105
- """Reset password request schema."""
106
- email: EmailStr = Field(..., description="Email address")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
 
109
  class UserListResponse(BaseModel):
 
101
  return v
102
 
103
 
104
+ class ForgotPasswordRequest(BaseModel):
105
+ """Forgot password request schema."""
106
+ email: EmailStr = Field(..., description="Email address to receive reset link")
107
+
108
+
109
  class ResetPasswordRequest(BaseModel):
110
+ """Reset password with token request schema."""
111
+ token: str = Field(..., description="Password reset token", min_length=20)
112
+ new_password: str = Field(..., description="New password", min_length=8, max_length=100)
113
+
114
+ @validator('new_password')
115
+ def validate_new_password(cls, v):
116
+ if len(v) < 8:
117
+ raise ValueError('Password must be at least 8 characters long')
118
+ if not any(c.isupper() for c in v):
119
+ raise ValueError('Password must contain at least one uppercase letter')
120
+ if not any(c.islower() for c in v):
121
+ raise ValueError('Password must contain at least one lowercase letter')
122
+ if not any(c.isdigit() for c in v):
123
+ raise ValueError('Password must contain at least one digit')
124
+ return v
125
+
126
+
127
+ class VerifyResetTokenRequest(BaseModel):
128
+ """Verify reset token request schema."""
129
+ token: str = Field(..., description="Password reset token to verify")
130
 
131
 
132
  class UserListResponse(BaseModel):
app/system_users/services/service.py CHANGED
@@ -26,6 +26,7 @@ from app.system_users.schemas.schema import (
26
  from app.constants.collections import AUTH_SYSTEM_USERS_COLLECTION, AUTH_ACCESS_ROLES_COLLECTION, SCM_ACCESS_ROLES_COLLECTION
27
  from app.core.config import settings
28
  from app.core.logging import get_logger
 
29
 
30
  logger = get_logger(__name__)
31
 
@@ -502,6 +503,215 @@ class SystemUserService:
502
  logger.error(f"Error deactivating user {user_id}: {e}")
503
  return False
504
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  def convert_to_user_info_response(self, user: SystemUserModel) -> UserInfoResponse:
506
  """Convert SystemUserModel to UserInfoResponse."""
507
  return UserInfoResponse(
 
26
  from app.constants.collections import AUTH_SYSTEM_USERS_COLLECTION, AUTH_ACCESS_ROLES_COLLECTION, SCM_ACCESS_ROLES_COLLECTION
27
  from app.core.config import settings
28
  from app.core.logging import get_logger
29
+ from app.utils.email_service import email_service
30
 
31
  logger = get_logger(__name__)
32
 
 
503
  logger.error(f"Error deactivating user {user_id}: {e}")
504
  return False
505
 
506
+ async def create_password_reset_token(self, email: str) -> Optional[str]:
507
+ """
508
+ Create a password reset token for a user.
509
+
510
+ Args:
511
+ email: User's email address
512
+
513
+ Returns:
514
+ Reset token string or None if user not found
515
+ """
516
+ try:
517
+ # Get user by email
518
+ user = await self.get_user_by_email(email)
519
+ if not user:
520
+ logger.warning(f"Password reset requested for non-existent email: {email}")
521
+ # Don't reveal that user doesn't exist for security
522
+ return None
523
+
524
+ # Check if account is active
525
+ if user.status not in [UserStatus.ACTIVE, UserStatus.PENDING_ACTIVATION]:
526
+ logger.warning(f"Password reset requested for {user.status.value} account: {email}")
527
+ return None
528
+
529
+ # Generate secure token
530
+ reset_token = secrets.token_urlsafe(32)
531
+
532
+ # Create JWT token with expiration
533
+ token_data = {
534
+ "sub": user.user_id,
535
+ "email": user.email,
536
+ "type": "password_reset",
537
+ "token": reset_token,
538
+ "exp": datetime.utcnow() + timedelta(minutes=settings.PASSWORD_RESET_TOKEN_EXPIRATION_MINUTES)
539
+ }
540
+
541
+ jwt_token = jwt.encode(token_data, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
542
+
543
+ # Store reset token info in database
544
+ await self.collection.update_one(
545
+ {"user_id": user.user_id},
546
+ {"$set": {
547
+ "security_settings.password_reset_token": reset_token,
548
+ "security_settings.password_reset_token_created_at": datetime.utcnow(),
549
+ "updated_at": datetime.utcnow()
550
+ }}
551
+ )
552
+
553
+ logger.info(f"Password reset token created for user: {user.user_id}")
554
+ return jwt_token
555
+
556
+ except Exception as e:
557
+ logger.error(f"Error creating password reset token: {e}")
558
+ return None
559
+
560
+ async def send_password_reset_email(self, email: str) -> bool:
561
+ """
562
+ Generate password reset token and send reset email.
563
+
564
+ Args:
565
+ email: User's email address
566
+
567
+ Returns:
568
+ True if email was sent successfully, False otherwise
569
+ """
570
+ try:
571
+ # Get user
572
+ user = await self.get_user_by_email(email)
573
+ if not user:
574
+ # Don't reveal that user doesn't exist
575
+ logger.warning(f"Password reset email requested for non-existent email: {email}")
576
+ return True # Return true to not leak information
577
+
578
+ # Create reset token
579
+ reset_token = await self.create_password_reset_token(email)
580
+ if not reset_token:
581
+ logger.error(f"Failed to create reset token for: {email}")
582
+ return False
583
+
584
+ # Build reset link
585
+ reset_link = f"{settings.PASSWORD_RESET_BASE_URL}?token={reset_token}"
586
+
587
+ # Send email
588
+ email_sent = await email_service.send_password_reset_email(
589
+ to_email=user.email,
590
+ reset_link=reset_link,
591
+ user_name=user.first_name
592
+ )
593
+
594
+ if email_sent:
595
+ logger.info(f"Password reset email sent to: {email}")
596
+ else:
597
+ logger.error(f"Failed to send password reset email to: {email}")
598
+
599
+ return email_sent
600
+
601
+ except Exception as e:
602
+ logger.error(f"Error sending password reset email: {e}")
603
+ return False
604
+
605
+ async def verify_password_reset_token(self, token: str) -> Optional[Dict[str, Any]]:
606
+ """
607
+ Verify password reset token and return user info.
608
+
609
+ Args:
610
+ token: JWT reset token
611
+
612
+ Returns:
613
+ Dict with user_id and email if valid, None otherwise
614
+ """
615
+ try:
616
+ # Decode JWT token
617
+ payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
618
+
619
+ # Check token type
620
+ if payload.get("type") != "password_reset":
621
+ logger.warning("Invalid token type for password reset")
622
+ return None
623
+
624
+ user_id = payload.get("sub")
625
+ email = payload.get("email")
626
+ reset_token = payload.get("token")
627
+
628
+ if not all([user_id, email, reset_token]):
629
+ logger.warning("Missing required fields in reset token")
630
+ return None
631
+
632
+ # Get user from database
633
+ user = await self.get_user_by_id(user_id)
634
+ if not user:
635
+ logger.warning(f"User not found for reset token: {user_id}")
636
+ return None
637
+
638
+ # Verify email matches
639
+ if user.email != email:
640
+ logger.warning(f"Email mismatch in reset token for user: {user_id}")
641
+ return None
642
+
643
+ # Verify token matches stored token
644
+ stored_token = user.security_settings.password_reset_token
645
+ if not stored_token or stored_token != reset_token:
646
+ logger.warning(f"Reset token mismatch for user: {user_id}")
647
+ return None
648
+
649
+ # Check if token was used (token should be cleared after use)
650
+ if not user.security_settings.password_reset_token:
651
+ logger.warning(f"Reset token already used for user: {user_id}")
652
+ return None
653
+
654
+ return {
655
+ "user_id": user_id,
656
+ "email": email,
657
+ "token": reset_token
658
+ }
659
+
660
+ except JWTError as e:
661
+ logger.warning(f"Invalid or expired reset token: {e}")
662
+ return None
663
+ except Exception as e:
664
+ logger.error(f"Error verifying reset token: {e}")
665
+ return None
666
+
667
+ async def reset_password_with_token(self, token: str, new_password: str) -> Tuple[bool, str]:
668
+ """
669
+ Reset user password using reset token.
670
+
671
+ Args:
672
+ token: JWT reset token
673
+ new_password: New password to set
674
+
675
+ Returns:
676
+ Tuple of (success: bool, message: str)
677
+ """
678
+ try:
679
+ # Verify token
680
+ token_data = await self.verify_password_reset_token(token)
681
+ if not token_data:
682
+ return False, "Invalid or expired reset token"
683
+
684
+ user_id = token_data["user_id"]
685
+
686
+ # Hash new password
687
+ new_password_hash = self.get_password_hash(new_password)
688
+
689
+ # Update password and clear reset token
690
+ result = await self.collection.update_one(
691
+ {"user_id": user_id},
692
+ {"$set": {
693
+ "password_hash": new_password_hash,
694
+ "security_settings.password_reset_token": None,
695
+ "security_settings.password_reset_token_created_at": None,
696
+ "security_settings.last_password_change": datetime.utcnow(),
697
+ "security_settings.require_password_change": False,
698
+ "security_settings.failed_login_attempts": 0,
699
+ "security_settings.account_locked_until": None,
700
+ "updated_at": datetime.utcnow()
701
+ }}
702
+ )
703
+
704
+ if result.modified_count > 0:
705
+ logger.info(f"Password reset successfully for user: {user_id}")
706
+ return True, "Password reset successfully"
707
+ else:
708
+ logger.error(f"Failed to update password for user: {user_id}")
709
+ return False, "Failed to reset password"
710
+
711
+ except Exception as e:
712
+ logger.error(f"Error resetting password: {e}")
713
+ return False, "An error occurred while resetting password"
714
+
715
  def convert_to_user_info_response(self, user: SystemUserModel) -> UserInfoResponse:
716
  """Convert SystemUserModel to UserInfoResponse."""
717
  return UserInfoResponse(
app/utils/email_service.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email service for sending transactional emails.
3
+ Uses aiosmtplib for async email sending.
4
+ """
5
+ import aiosmtplib
6
+ from email.mime.text import MIMEText
7
+ from email.mime.multipart import MIMEMultipart
8
+ from typing import Optional, List
9
+ from app.core.config import settings
10
+ from app.core.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class EmailService:
16
+ """Service for sending emails via SMTP."""
17
+
18
+ def __init__(self):
19
+ self.smtp_host = settings.SMTP_HOST
20
+ self.smtp_port = settings.SMTP_PORT
21
+ self.smtp_username = settings.SMTP_USERNAME
22
+ self.smtp_password = settings.SMTP_PASSWORD
23
+ self.from_email = settings.SMTP_FROM_EMAIL or settings.SMTP_USERNAME
24
+ self.use_tls = settings.SMTP_USE_TLS
25
+
26
+ async def send_email(
27
+ self,
28
+ to_email: str,
29
+ subject: str,
30
+ html_content: str,
31
+ plain_content: Optional[str] = None
32
+ ) -> bool:
33
+ """
34
+ Send an email using SMTP.
35
+
36
+ Args:
37
+ to_email: Recipient email address
38
+ subject: Email subject
39
+ html_content: HTML email content
40
+ plain_content: Plain text fallback content
41
+
42
+ Returns:
43
+ True if email was sent successfully, False otherwise
44
+ """
45
+ try:
46
+ # Validate SMTP configuration
47
+ if not all([self.smtp_host, self.smtp_port, self.from_email]):
48
+ logger.error("SMTP configuration is incomplete")
49
+ return False
50
+
51
+ # Create message
52
+ message = MIMEMultipart("alternative")
53
+ message["From"] = self.from_email
54
+ message["To"] = to_email
55
+ message["Subject"] = subject
56
+
57
+ # Add plain text part if provided
58
+ if plain_content:
59
+ part1 = MIMEText(plain_content, "plain")
60
+ message.attach(part1)
61
+
62
+ # Add HTML part
63
+ part2 = MIMEText(html_content, "html")
64
+ message.attach(part2)
65
+
66
+ # Send email
67
+ await aiosmtplib.send(
68
+ message,
69
+ hostname=self.smtp_host,
70
+ port=self.smtp_port,
71
+ username=self.smtp_username,
72
+ password=self.smtp_password,
73
+ use_tls=self.use_tls,
74
+ )
75
+
76
+ logger.info(f"Email sent successfully to {to_email}")
77
+ return True
78
+
79
+ except Exception as e:
80
+ logger.error(f"Failed to send email to {to_email}: {e}", exc_info=True)
81
+ return False
82
+
83
+ async def send_password_reset_email(
84
+ self,
85
+ to_email: str,
86
+ reset_link: str,
87
+ user_name: Optional[str] = None
88
+ ) -> bool:
89
+ """
90
+ Send password reset email with reset link.
91
+
92
+ Args:
93
+ to_email: Recipient email address
94
+ reset_link: Password reset link
95
+ user_name: User's first name (optional)
96
+
97
+ Returns:
98
+ True if email was sent successfully, False otherwise
99
+ """
100
+ name = user_name or "User"
101
+
102
+ subject = "Password Reset Request - Cuatro Labs Auth"
103
+
104
+ # HTML content
105
+ html_content = f"""
106
+ <!DOCTYPE html>
107
+ <html>
108
+ <head>
109
+ <meta charset="UTF-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
111
+ <style>
112
+ body {{
113
+ font-family: Arial, sans-serif;
114
+ line-height: 1.6;
115
+ color: #333333;
116
+ max-width: 600px;
117
+ margin: 0 auto;
118
+ padding: 20px;
119
+ }}
120
+ .container {{
121
+ background-color: #f9f9f9;
122
+ border-radius: 10px;
123
+ padding: 30px;
124
+ border: 1px solid #e0e0e0;
125
+ }}
126
+ .header {{
127
+ text-align: center;
128
+ margin-bottom: 30px;
129
+ }}
130
+ .header h1 {{
131
+ color: #2c3e50;
132
+ margin: 0;
133
+ font-size: 24px;
134
+ }}
135
+ .content {{
136
+ background-color: white;
137
+ padding: 25px;
138
+ border-radius: 8px;
139
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
140
+ }}
141
+ .button {{
142
+ display: inline-block;
143
+ padding: 12px 30px;
144
+ background-color: #3498db;
145
+ color: white !important;
146
+ text-decoration: none;
147
+ border-radius: 5px;
148
+ margin: 20px 0;
149
+ font-weight: bold;
150
+ }}
151
+ .button:hover {{
152
+ background-color: #2980b9;
153
+ }}
154
+ .footer {{
155
+ margin-top: 30px;
156
+ text-align: center;
157
+ font-size: 12px;
158
+ color: #777777;
159
+ }}
160
+ .warning {{
161
+ background-color: #fff3cd;
162
+ border-left: 4px solid #ffc107;
163
+ padding: 12px;
164
+ margin: 20px 0;
165
+ border-radius: 4px;
166
+ }}
167
+ .link-text {{
168
+ word-break: break-all;
169
+ color: #3498db;
170
+ font-size: 12px;
171
+ margin-top: 15px;
172
+ }}
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div class="container">
177
+ <div class="header">
178
+ <h1>🔐 Password Reset Request</h1>
179
+ </div>
180
+
181
+ <div class="content">
182
+ <p>Hello <strong>{name}</strong>,</p>
183
+
184
+ <p>We received a request to reset the password for your account. If you made this request, click the button below to reset your password:</p>
185
+
186
+ <div style="text-align: center;">
187
+ <a href="{reset_link}" class="button">Reset My Password</a>
188
+ </div>
189
+
190
+ <div class="warning">
191
+ <strong>⚠️ Important:</strong>
192
+ <ul style="margin: 10px 0;">
193
+ <li>This link will expire in <strong>1 hour</strong></li>
194
+ <li>This link can only be used once</li>
195
+ <li>If you didn't request this, please ignore this email</li>
196
+ </ul>
197
+ </div>
198
+
199
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
200
+ <p class="link-text">{reset_link}</p>
201
+
202
+ <p style="margin-top: 25px;">If you didn't request a password reset, please ignore this email or contact support if you have concerns about your account security.</p>
203
+ </div>
204
+
205
+ <div class="footer">
206
+ <p>This is an automated message from Cuatro Labs Authentication Service.</p>
207
+ <p>Please do not reply to this email.</p>
208
+ </div>
209
+ </div>
210
+ </body>
211
+ </html>
212
+ """
213
+
214
+ # Plain text fallback
215
+ plain_content = f"""
216
+ Password Reset Request
217
+
218
+ Hello {name},
219
+
220
+ We received a request to reset the password for your account.
221
+
222
+ To reset your password, please click the following link:
223
+ {reset_link}
224
+
225
+ IMPORTANT:
226
+ - This link will expire in 1 hour
227
+ - This link can only be used once
228
+ - If you didn't request this, please ignore this email
229
+
230
+ If you didn't request a password reset, please ignore this email or contact support if you have concerns.
231
+
232
+ ---
233
+ Cuatro Labs Authentication Service
234
+ """
235
+
236
+ return await self.send_email(
237
+ to_email=to_email,
238
+ subject=subject,
239
+ html_content=html_content,
240
+ plain_content=plain_content
241
+ )
242
+
243
+
244
+ # Global email service instance
245
+ email_service = EmailService()
test_forgot_password.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test script for forgot password functionality.
4
+ """
5
+ import asyncio
6
+ import sys
7
+ from motor.motor_asyncio import AsyncIOMotorClient
8
+ from app.core.config import settings
9
+ from app.system_users.services.service import SystemUserService
10
+ from app.system_users.schemas.schema import ForgotPasswordRequest, ResetPasswordRequest
11
+
12
+ async def test_forgot_password_flow():
13
+ """Test the complete forgot password flow."""
14
+ print("=" * 60)
15
+ print("Testing Forgot Password Feature")
16
+ print("=" * 60)
17
+
18
+ # Get email from command line or use default
19
+ email = sys.argv[1] if len(sys.argv) > 1 else "test@example.com"
20
+
21
+ try:
22
+ # Connect to database
23
+ print(f"\n1. Connecting to database...")
24
+ client = AsyncIOMotorClient(settings.MONGODB_URI)
25
+ db = client[settings.MONGODB_DB_NAME]
26
+ service = SystemUserService(db)
27
+
28
+ # Check if user exists
29
+ print(f"\n2. Checking if user exists: {email}")
30
+ user = await service.get_user_by_email(email)
31
+
32
+ if not user:
33
+ print(f"❌ User with email {email} not found!")
34
+ print(f" Please create a user first or provide a valid email address.")
35
+ return
36
+
37
+ print(f"✅ User found: {user.username} ({user.first_name} {user.last_name})")
38
+ print(f" User ID: {user.user_id}")
39
+ print(f" Status: {user.status.value}")
40
+
41
+ # Test password reset token creation
42
+ print(f"\n3. Creating password reset token...")
43
+ token = await service.create_password_reset_token(email)
44
+
45
+ if not token:
46
+ print("❌ Failed to create password reset token!")
47
+ return
48
+
49
+ print(f"✅ Password reset token created successfully")
50
+ print(f" Token (first 50 chars): {token[:50]}...")
51
+
52
+ # Verify the token
53
+ print(f"\n4. Verifying password reset token...")
54
+ token_data = await service.verify_password_reset_token(token)
55
+
56
+ if not token_data:
57
+ print("❌ Token verification failed!")
58
+ return
59
+
60
+ print(f"✅ Token verified successfully")
61
+ print(f" User ID: {token_data['user_id']}")
62
+ print(f" Email: {token_data['email']}")
63
+
64
+ # Test sending email (if SMTP is configured)
65
+ print(f"\n5. Testing email sending...")
66
+
67
+ if not all([settings.SMTP_HOST, settings.SMTP_USERNAME, settings.SMTP_PASSWORD]):
68
+ print("⚠️ SMTP not configured. Skipping email test.")
69
+ print(" To enable email, configure these environment variables:")
70
+ print(" - SMTP_HOST")
71
+ print(" - SMTP_PORT")
72
+ print(" - SMTP_USERNAME")
73
+ print(" - SMTP_PASSWORD")
74
+ print(" - SMTP_FROM_EMAIL")
75
+ else:
76
+ print(f" SMTP Host: {settings.SMTP_HOST}")
77
+ print(f" SMTP Port: {settings.SMTP_PORT}")
78
+ print(f" From Email: {settings.SMTP_FROM_EMAIL}")
79
+ print(f" Attempting to send email...")
80
+
81
+ email_sent = await service.send_password_reset_email(email)
82
+
83
+ if email_sent:
84
+ print(f"✅ Password reset email sent successfully!")
85
+ print(f" Check inbox for: {email}")
86
+ else:
87
+ print(f"❌ Failed to send password reset email")
88
+ print(f" Check logs for details")
89
+
90
+ # Generate reset link
91
+ print(f"\n6. Generated reset link:")
92
+ reset_link = f"{settings.PASSWORD_RESET_BASE_URL}?token={token}"
93
+ print(f" {reset_link}")
94
+
95
+ # Summary
96
+ print("\n" + "=" * 60)
97
+ print("Test Summary")
98
+ print("=" * 60)
99
+ print("✅ All core functionality tests passed!")
100
+ print("\nNext steps:")
101
+ print("1. Copy the reset link above")
102
+ print("2. Open it in your browser")
103
+ print("3. Enter a new password")
104
+ print("4. Test login with the new password")
105
+
106
+ print("\nOr test password reset via API:")
107
+ print(f"""
108
+ curl -X POST http://localhost:9182/auth/reset-password \\
109
+ -H "Content-Type: application/json" \\
110
+ -d '{{
111
+ "token": "{token[:50]}...",
112
+ "new_password": "NewTestPassword123"
113
+ }}'
114
+ """)
115
+
116
+ except Exception as e:
117
+ print(f"\n❌ Error: {e}")
118
+ import traceback
119
+ traceback.print_exc()
120
+ finally:
121
+ client.close()
122
+
123
+
124
+ async def test_email_configuration():
125
+ """Test email configuration."""
126
+ print("=" * 60)
127
+ print("Email Configuration Test")
128
+ print("=" * 60)
129
+
130
+ print(f"\nSMTP Settings:")
131
+ print(f" Host: {settings.SMTP_HOST or '❌ Not configured'}")
132
+ print(f" Port: {settings.SMTP_PORT}")
133
+ print(f" Username: {settings.SMTP_USERNAME or '❌ Not configured'}")
134
+ print(f" Password: {'***' if settings.SMTP_PASSWORD else '❌ Not configured'}")
135
+ print(f" From Email: {settings.SMTP_FROM_EMAIL or settings.SMTP_USERNAME or '❌ Not configured'}")
136
+ print(f" Use TLS: {settings.SMTP_USE_TLS}")
137
+
138
+ if not all([settings.SMTP_HOST, settings.SMTP_USERNAME, settings.SMTP_PASSWORD]):
139
+ print("\n⚠️ SMTP is not fully configured!")
140
+ print("Please set these environment variables in your .env file:")
141
+ print("""
142
+ SMTP_HOST=smtp.gmail.com
143
+ SMTP_PORT=587
144
+ SMTP_USERNAME=your-email@gmail.com
145
+ SMTP_PASSWORD=your-app-password
146
+ SMTP_FROM_EMAIL=noreply@cuatrolabs.com
147
+ SMTP_USE_TLS=true
148
+ """)
149
+ else:
150
+ print("\n✅ SMTP configuration looks good!")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ print("\nForgot Password Feature Test\n")
155
+
156
+ if len(sys.argv) > 1 and sys.argv[1] == "--check-config":
157
+ asyncio.run(test_email_configuration())
158
+ else:
159
+ asyncio.run(test_forgot_password_flow())