DarthBihan commited on
Commit
1043988
·
verified ·
1 Parent(s): b870a26

Update routes/auth.py

Browse files
Files changed (1) hide show
  1. routes/auth.py +517 -15
routes/auth.py CHANGED
@@ -3,43 +3,545 @@ from flask_jwt_extended import create_access_token
3
  import bcrypt
4
  from pymongo import MongoClient
5
  import os
 
 
6
  from dotenv import load_dotenv
 
 
7
 
8
  load_dotenv()
9
- MONGO_URI = os.getenv("MONGO_URI")
10
 
 
 
 
 
 
11
  auth_bp = Blueprint('auth', __name__)
 
12
  client = MongoClient(MONGO_URI)
13
  db = client["codewhisperer"]
14
  users = db["users"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  @auth_bp.route("/register", methods=["POST"])
17
  def register():
18
  data = request.json
19
- username = data["username"]
20
- email = data["email"]
21
- password = data["password"]
22
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  if users.find_one({"username": username}):
24
- return jsonify({"error": "User already exists"}), 400
25
-
 
 
 
 
 
 
 
 
 
 
26
  hashed_pw = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
27
- users.insert_one({"username": username, "email": email, "password": hashed_pw})
 
 
 
 
 
 
28
  token = create_access_token(identity=username)
29
- return jsonify({"token": token}), 201
 
 
 
30
 
31
  @auth_bp.route("/login", methods=["POST"])
32
  def login():
33
  data = request.json
34
- username = data["username"]
35
- password = data["password"]
36
-
 
 
 
37
  user = users.find_one({"username": username})
 
38
  if not user:
39
  return jsonify({"error": "Invalid credentials"}), 401
40
-
41
  if not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
42
  return jsonify({"error": "Invalid credentials"}), 401
43
-
44
  token = create_access_token(identity=username)
45
- return jsonify({"token": token})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import bcrypt
4
  from pymongo import MongoClient
5
  import os
6
+ import re
7
+ import requests
8
  from dotenv import load_dotenv
9
+ import secrets
10
+ from datetime import datetime, timedelta
11
 
12
  load_dotenv()
 
13
 
14
+ MONGO_URI = os.getenv("MONGO_URI")
15
+ ABSTRACT_API_KEY = os.getenv("ABSTRACT_API_KEY", "")
16
+ RESEND_API_KEY = os.getenv("RESEND_API_KEY", "")
17
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
18
+ BREVO_API_KEY = os.getenv("BREVO_API_KEY", "")
19
  auth_bp = Blueprint('auth', __name__)
20
+
21
  client = MongoClient(MONGO_URI)
22
  db = client["codewhisperer"]
23
  users = db["users"]
24
+ reset_tokens = db["reset_tokens"]
25
+
26
+ # Common disposable email domains to block
27
+ DISPOSABLE_DOMAINS = {
28
+ '10minutemail.com', 'tempmail.com', 'guerrillamail.com', 'mailinator.com',
29
+ 'throwaway.email', 'temp-mail.org', 'getnada.com', 'maildrop.cc',
30
+ 'trashmail.com', 'yopmail.com', 'fakeinbox.com', 'sharklasers.com',
31
+ 'guerrillamailblock.com', 'pokemail.net', 'spam4.me', 'tempr.email',
32
+ 'throwawaymail.com', 'wegwerfemail.de', 'mintemail.com', 'mytrashmail.com'
33
+ }
34
+
35
+ def validate_email_format(email):
36
+ """Basic email format validation"""
37
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
38
+ return re.match(pattern, email) is not None
39
+
40
+ def is_disposable_email(email):
41
+ """Check if email is from a disposable domain"""
42
+ domain = email.split('@')[1].lower() if '@' in email else ''
43
+ return domain in DISPOSABLE_DOMAINS
44
+
45
+ def verify_email_with_api(email):
46
+ """
47
+ Verify email using AbstractAPI (free tier: 100 requests/month)
48
+ Returns: (is_valid, error_message)
49
+ """
50
+ if not ABSTRACT_API_KEY:
51
+ # Fallback to basic validation if no API key
52
+ return validate_email_format(email), None
53
+
54
+ try:
55
+ url = f"https://emailvalidation.abstractapi.com/v1/"
56
+ params = {
57
+ 'api_key': ABSTRACT_API_KEY,
58
+ 'email': email
59
+ }
60
+
61
+ response = requests.get(url, params=params, timeout=3)
62
+
63
+ if response.status_code == 200:
64
+ data = response.json()
65
+
66
+ # Check various quality indicators
67
+ is_valid_format = data.get('is_valid_format', {}).get('value', False)
68
+ is_mx_found = data.get('is_mx_found', {}).get('value', False)
69
+ is_smtp_valid = data.get('is_smtp_valid', {}).get('value', False)
70
+ is_free_email = data.get('is_free_email', {}).get('value', False)
71
+ is_disposable = data.get('is_disposable_email', {}).get('value', False)
72
+ is_role_email = data.get('is_role_email', {}).get('value', False) # e.g., admin@, info@
73
+
74
+ # Strict validation
75
+ if not is_valid_format:
76
+ return False, "Invalid email format"
77
+
78
+ if is_disposable:
79
+ return False, "Disposable email addresses are not allowed"
80
+
81
+ if not is_mx_found:
82
+ return False, "Email domain does not exist"
83
+
84
+ if is_smtp_valid is False: # Explicitly False, not None
85
+ return False, "Email address does not exist"
86
+
87
+ # Optional: Block role emails (like admin@, support@)
88
+ if is_role_email:
89
+ return False, "Role-based email addresses are not allowed. Please use a personal email."
90
+
91
+ return True, None
92
+ else:
93
+ # API failed, fallback to basic validation
94
+ return validate_email_format(email), None
95
+
96
+ except requests.exceptions.Timeout:
97
+ # API timeout, fallback to basic validation
98
+ return validate_email_format(email), None
99
+ except Exception as e:
100
+ print(f"Email validation API error: {str(e)}")
101
+ # Fallback to basic validation
102
+ return validate_email_format(email), None
103
 
104
  @auth_bp.route("/register", methods=["POST"])
105
  def register():
106
  data = request.json
107
+
108
+ # Validate input
109
+ username = data.get("username", "").strip()
110
+ email = data.get("email", "").strip().lower()
111
+ password = data.get("password", "")
112
+
113
+ if not username or not email or not password:
114
+ return jsonify({"error": "All fields are required"}), 400
115
+
116
+ # Username validation
117
+ if len(username) < 3:
118
+ return jsonify({"error": "Username must be at least 3 characters"}), 400
119
+
120
+ if len(username) > 30:
121
+ return jsonify({"error": "Username must be less than 30 characters"}), 400
122
+
123
+ # Password strength validation
124
+ if len(password) < 8:
125
+ return jsonify({"error": "Password must be at least 8 characters"}), 400
126
+
127
+ # Basic email format check
128
+ if not validate_email_format(email):
129
+ return jsonify({"error": "Invalid email format"}), 400
130
+
131
+ # Check disposable email (fast, local check)
132
+ if is_disposable_email(email):
133
+ return jsonify({"error": "Disposable email addresses are not allowed"}), 400
134
+
135
+ # Check if user already exists
136
  if users.find_one({"username": username}):
137
+ return jsonify({"error": "Username already taken"}), 400
138
+
139
+ if users.find_one({"email": email}):
140
+ return jsonify({"error": "Email already registered"}), 400
141
+
142
+ # Verify email with API (with fallback)
143
+ is_valid, error_message = verify_email_with_api(email)
144
+
145
+ if not is_valid:
146
+ return jsonify({"error": error_message or "Invalid email address"}), 400
147
+
148
+ # Hash password and create user
149
  hashed_pw = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
150
+ users.insert_one({
151
+ "username": username,
152
+ "email": email,
153
+ "password": hashed_pw,
154
+ "created_at": __import__('datetime').datetime.utcnow()
155
+ })
156
+
157
  token = create_access_token(identity=username)
158
+ return jsonify({
159
+ "token": token,
160
+ "message": "Registration successful!"
161
+ }), 201
162
 
163
  @auth_bp.route("/login", methods=["POST"])
164
  def login():
165
  data = request.json
166
+ username = data.get("username", "").strip()
167
+ password = data.get("password", "")
168
+
169
+ if not username or not password:
170
+ return jsonify({"error": "Username and password are required"}), 400
171
+
172
  user = users.find_one({"username": username})
173
+
174
  if not user:
175
  return jsonify({"error": "Invalid credentials"}), 401
176
+
177
  if not bcrypt.checkpw(password.encode("utf-8"), user["password"]):
178
  return jsonify({"error": "Invalid credentials"}), 401
179
+
180
  token = create_access_token(identity=username)
181
+ return jsonify({"token": token, "message": "Login successful"})
182
+
183
+ @auth_bp.route("/validate-email", methods=["POST"])
184
+ def validate_email_endpoint():
185
+ """
186
+ Separate endpoint for real-time email validation (optional)
187
+ """
188
+ data = request.json
189
+ email = data.get("email", "").strip().lower()
190
+
191
+ if not email:
192
+ return jsonify({"valid": False, "error": "Email is required"}), 400
193
+
194
+ # Basic format check
195
+ if not validate_email_format(email):
196
+ return jsonify({"valid": False, "error": "Invalid email format"}), 200
197
+
198
+ # Disposable check
199
+ if is_disposable_email(email):
200
+ return jsonify({"valid": False, "error": "Disposable emails not allowed"}), 200
201
+
202
+ # Check if already registered
203
+ if users.find_one({"email": email}):
204
+ return jsonify({"valid": False, "error": "Email already registered"}), 200
205
+
206
+ # API validation
207
+ is_valid, error_message = verify_email_with_api(email)
208
+
209
+ if not is_valid:
210
+ return jsonify({"valid": False, "error": error_message}), 200
211
+
212
+ return jsonify({"valid": True, "message": "Email is valid"}), 200
213
+
214
+ def send_reset_email(email, reset_token, username):
215
+ """
216
+ Send password reset email using Brevo (Sendinblue) API
217
+ FREE: 300 emails/day forever!
218
+ """
219
+ if not BREVO_API_KEY:
220
+ print("⚠️ Warning: BREVO_API_KEY not set, email not sent")
221
+ return False
222
+
223
+ try:
224
+ reset_link = f"{FRONTEND_URL}/reset-password?token={reset_token}"
225
+
226
+ # HTML email template
227
+ html_content = f"""
228
+ <!DOCTYPE html>
229
+ <html>
230
+ <head>
231
+ <meta charset="UTF-8">
232
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
233
+ <style>
234
+ body {{
235
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
236
+ line-height: 1.6;
237
+ color: #333;
238
+ margin: 0;
239
+ padding: 0;
240
+ background-color: #f5f5f5;
241
+ }}
242
+ .container {{
243
+ max-width: 600px;
244
+ margin: 20px auto;
245
+ background: white;
246
+ border-radius: 12px;
247
+ overflow: hidden;
248
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
249
+ }}
250
+ .header {{
251
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
252
+ color: white;
253
+ padding: 40px 30px;
254
+ text-align: center;
255
+ }}
256
+ .header h1 {{
257
+ margin: 0;
258
+ font-size: 28px;
259
+ font-weight: 700;
260
+ }}
261
+ .content {{
262
+ padding: 40px 30px;
263
+ background: white;
264
+ }}
265
+ .content p {{
266
+ margin: 0 0 16px 0;
267
+ font-size: 16px;
268
+ color: #4b5563;
269
+ }}
270
+ .button-container {{
271
+ text-align: center;
272
+ margin: 30px 0;
273
+ }}
274
+ .button {{
275
+ display: inline-block;
276
+ background: #2563eb;
277
+ color: white !important;
278
+ padding: 16px 32px;
279
+ text-decoration: none;
280
+ border-radius: 8px;
281
+ font-weight: 600;
282
+ font-size: 16px;
283
+ box-shadow: 0 4px 6px rgba(37, 99, 235, 0.2);
284
+ }}
285
+ .button:hover {{
286
+ background: #1d4ed8;
287
+ }}
288
+ .link-box {{
289
+ background: #f3f4f6;
290
+ padding: 16px;
291
+ border-radius: 8px;
292
+ word-break: break-all;
293
+ font-size: 14px;
294
+ color: #6b7280;
295
+ margin: 20px 0;
296
+ }}
297
+ .warning {{
298
+ background: #fef3c7;
299
+ border-left: 4px solid #f59e0b;
300
+ padding: 16px;
301
+ margin: 24px 0;
302
+ border-radius: 4px;
303
+ }}
304
+ .warning strong {{
305
+ color: #92400e;
306
+ display: block;
307
+ margin-bottom: 8px;
308
+ }}
309
+ .warning ul {{
310
+ margin: 8px 0 0 0;
311
+ padding-left: 20px;
312
+ color: #78350f;
313
+ }}
314
+ .warning li {{
315
+ margin: 4px 0;
316
+ }}
317
+ .footer {{
318
+ text-align: center;
319
+ padding: 24px 30px;
320
+ background: #f9fafb;
321
+ color: #6b7280;
322
+ font-size: 13px;
323
+ border-top: 1px solid #e5e7eb;
324
+ }}
325
+ .footer p {{
326
+ margin: 4px 0;
327
+ }}
328
+ </style>
329
+ </head>
330
+ <body>
331
+ <div class="container">
332
+ <div class="header">
333
+ <h1>🔐 Password Reset Request</h1>
334
+ </div>
335
+ <div class="content">
336
+ <p>Hi <strong>{username}</strong>,</p>
337
+
338
+ <p>We received a request to reset your password for your <strong>AI Code Security</strong> account.</p>
339
+
340
+ <p>Click the button below to create a new password:</p>
341
+
342
+ <div class="button-container">
343
+ <a href="{reset_link}" class="button">Reset My Password</a>
344
+ </div>
345
+
346
+ <p style="font-size: 14px; color: #6b7280;">Or copy and paste this link into your browser:</p>
347
+ <div class="link-box">{reset_link}</div>
348
+
349
+ <div class="warning">
350
+ <strong>⚠️ Security Notice:</strong>
351
+ <ul>
352
+ <li>This link will expire in <strong>1 hour</strong></li>
353
+ <li>If you didn't request this, please ignore this email</li>
354
+ <li>Never share this link with anyone</li>
355
+ <li>Your account is safe until you click the link</li>
356
+ </ul>
357
+ </div>
358
+
359
+ <p style="margin-top: 24px;">If you didn't request a password reset, your account is still secure and you can safely ignore this email.</p>
360
+
361
+ <p style="margin-top: 24px;">
362
+ Best regards,<br>
363
+ <strong>AI Code Security Team</strong>
364
+ </p>
365
+ </div>
366
+ <div class="footer">
367
+ <p>This is an automated message, please do not reply to this email.</p>
368
+ <p>&copy; 2025 AI Code Security. All rights reserved.</p>
369
+ </div>
370
+ </div>
371
+ </body>
372
+ </html>
373
+ """
374
+
375
+ # Plain text version (fallback)
376
+ text_content = f"""
377
+ Password Reset Request
378
+
379
+ Hi {username},
380
+
381
+ We received a request to reset your password for your AI Code Security account.
382
+
383
+ Click this link to reset your password:
384
+ {reset_link}
385
+
386
+ This link will expire in 1 hour.
387
+
388
+ If you didn't request this, please ignore this email. Your account is safe.
389
+
390
+ Best regards,
391
+ AI Code Security Team
392
+ """
393
+
394
+ # Send via Brevo API
395
+ url = "https://api.brevo.com/v3/smtp/email"
396
+ headers = {
397
+ "accept": "application/json",
398
+ "api-key": BREVO_API_KEY,
399
+ "content-type": "application/json"
400
+ }
401
+
402
+ payload = {
403
+ "sender": {
404
+ "name": "AI Code Security",
405
+ "email": "bihanbanerjee26@gmail.com" # Can be any email!
406
+ },
407
+ "to": [
408
+ {
409
+ "email": email,
410
+ "name": username
411
+ }
412
+ ],
413
+ "subject": "Reset Your Password - AI Code Security",
414
+ "htmlContent": html_content,
415
+ "textContent": text_content
416
+ }
417
+
418
+ response = requests.post(url, json=payload, headers=headers, timeout=10)
419
+
420
+ if response.status_code in [200, 201]:
421
+ print(f"✅ Reset email sent successfully to {email}")
422
+ print(f"📧 Message ID: {response.json().get('messageId', 'N/A')}")
423
+ return True
424
+ else:
425
+ print(f"❌ Failed to send email: {response.status_code}")
426
+ print(f"❌ Response: {response.text}")
427
+ return False
428
+
429
+ except requests.exceptions.Timeout:
430
+ print("❌ Email sending timeout")
431
+ return False
432
+ except Exception as e:
433
+ print(f"❌ Email sending error: {str(e)}")
434
+ return False
435
+
436
+
437
+ @auth_bp.route("/forgot-password", methods=["POST"])
438
+ def forgot_password():
439
+ """
440
+ Request password reset - generates token and sends email
441
+ """
442
+ data = request.json
443
+ email = data.get("email", "").strip().lower()
444
+
445
+ if not email:
446
+ return jsonify({"error": "Email is required"}), 400
447
+
448
+ if not validate_email_format(email):
449
+ return jsonify({"error": "Invalid email format"}), 400
450
+
451
+ # Find user by email
452
+ user = users.find_one({"email": email})
453
+
454
+ # Always return success to prevent email enumeration
455
+ # But only send email if user exists
456
+ if user:
457
+ # Generate secure random token
458
+ reset_token = secrets.token_urlsafe(32)
459
+
460
+ # Store token with expiration (1 hour)
461
+ reset_tokens.insert_one({
462
+ "email": email,
463
+ "token": reset_token,
464
+ "created_at": datetime.utcnow(),
465
+ "expires_at": datetime.utcnow() + timedelta(hours=1),
466
+ "used": False
467
+ })
468
+
469
+ # Send email
470
+ send_reset_email(email, reset_token, user["username"])
471
+
472
+ # Always return success message (security best practice)
473
+ return jsonify({
474
+ "message": "If an account exists with that email, a password reset link has been sent."
475
+ }), 200
476
+
477
+ @auth_bp.route("/verify-reset-token", methods=["POST"])
478
+ def verify_reset_token():
479
+ """
480
+ Verify if reset token is valid
481
+ """
482
+ data = request.json
483
+ token = data.get("token", "")
484
+
485
+ if not token:
486
+ return jsonify({"valid": False, "error": "Token is required"}), 400
487
+
488
+ # Find token in database
489
+ token_doc = reset_tokens.find_one({
490
+ "token": token,
491
+ "used": False
492
+ })
493
+
494
+ if not token_doc:
495
+ return jsonify({"valid": False, "error": "Invalid or expired token"}), 400
496
+
497
+ # Check if token is expired
498
+ if datetime.utcnow() > token_doc["expires_at"]:
499
+ return jsonify({"valid": False, "error": "Token has expired"}), 400
500
+
501
+ return jsonify({"valid": True, "email": token_doc["email"]}), 200
502
+
503
+ @auth_bp.route("/reset-password", methods=["POST"])
504
+ def reset_password():
505
+ """
506
+ Reset password using valid token
507
+ """
508
+ data = request.json
509
+ token = data.get("token", "")
510
+ new_password = data.get("password", "")
511
+
512
+ if not token or not new_password:
513
+ return jsonify({"error": "Token and new password are required"}), 400
514
+
515
+ # Validate password strength
516
+ if len(new_password) < 8:
517
+ return jsonify({"error": "Password must be at least 8 characters"}), 400
518
+
519
+ # Find valid token
520
+ token_doc = reset_tokens.find_one({
521
+ "token": token,
522
+ "used": False
523
+ })
524
+
525
+ if not token_doc:
526
+ return jsonify({"error": "Invalid or expired reset token"}), 400
527
+
528
+ # Check expiration
529
+ if datetime.utcnow() > token_doc["expires_at"]:
530
+ return jsonify({"error": "Reset token has expired"}), 400
531
+
532
+ # Hash new password
533
+ hashed_pw = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
534
+
535
+ # Update user password
536
+ users.update_one(
537
+ {"email": token_doc["email"]},
538
+ {"$set": {"password": hashed_pw, "updated_at": datetime.utcnow()}}
539
+ )
540
+
541
+ # Mark token as used
542
+ reset_tokens.update_one(
543
+ {"_id": token_doc["_id"]},
544
+ {"$set": {"used": True, "used_at": datetime.utcnow()}}
545
+ )
546
+
547
+ return jsonify({"message": "Password reset successfully!"}), 200