the37lab-authlib 0.1.1751371611__py3-none-any.whl → 0.1.1762438606__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
the37lab_authlib/auth.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import inspect
2
3
  from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
3
4
  import jwt
4
5
  from datetime import datetime, timedelta
@@ -10,18 +11,38 @@ import requests
10
11
  import bcrypt
11
12
  import logging
12
13
  import os
14
+ import re
13
15
  from functools import wraps
16
+ from isodate import parse_duration
17
+ import threading
18
+ import time
19
+ import msal
20
+ import smtplib
21
+ from email.mime.text import MIMEText
22
+ from email.mime.multipart import MIMEMultipart
14
23
 
15
24
  logging.basicConfig(level=logging.DEBUG)
16
25
  logger = logging.getLogger(__name__)
17
26
 
18
27
  class AuthManager:
19
- def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None):
28
+ def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None, cache_ttl=10, allow_oauth_auto_create=None, email_username=None, email_password=None, email_address=None, email_reply_to=None, email_server=None, email_port=None):
20
29
  self.user_override = None
21
- if environment_prefix:
22
- prefix = environment_prefix.upper() + '_'
23
- db_dsn = os.getenv(f'{prefix}DATABASE_URL')
24
- jwt_secret = os.getenv(f'{prefix}JWT_SECRET')
30
+ self._user_cache = {}
31
+ self._cache_ttl = cache_ttl or 10 # 10 seconds
32
+ self._last_used_updates = {} # Track pending updates
33
+ self._update_lock = threading.Lock()
34
+ self._update_thread = None
35
+ self._shutdown_event = threading.Event()
36
+
37
+ # Determine prefix: empty if environment_prefix is None/empty, otherwise use it with '_' delimiter
38
+ prefix = (environment_prefix.upper() + '_') if environment_prefix else ''
39
+
40
+ # Arguments have priority over environment variables
41
+ db_dsn = db_dsn or os.getenv(f'{prefix}DATABASE_URL')
42
+ jwt_secret = jwt_secret or os.getenv(f'{prefix}JWT_SECRET')
43
+
44
+ # OAuth config: use argument if provided, otherwise build from env vars
45
+ if oauth_config is None:
25
46
  google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
26
47
  google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
27
48
  oauth_config = {}
@@ -30,6 +51,19 @@ class AuthManager:
30
51
  'client_id': google_client_id,
31
52
  'client_secret': google_client_secret
32
53
  }
54
+
55
+ # OAuth auto-create: use argument if provided, otherwise check env var (defaults to False)
56
+ if allow_oauth_auto_create is not None:
57
+ self.allow_oauth_auto_create = allow_oauth_auto_create
58
+ else:
59
+ auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
60
+ if auto_create_env is not None:
61
+ self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
62
+ else:
63
+ self.allow_oauth_auto_create = False
64
+
65
+ # API tokens: use argument if provided, otherwise parse from env var
66
+ if api_tokens is None:
33
67
  api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
34
68
  if api_tokens_env:
35
69
  api_tokens = {}
@@ -37,9 +71,21 @@ class AuthManager:
37
71
  if ':' in entry:
38
72
  key, user = entry.split(':', 1)
39
73
  api_tokens[key.strip()] = user.strip()
40
- user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
41
- if user_override_env:
42
- self.user_override = user_override_env
74
+
75
+ # User override: use argument if provided, otherwise check env var
76
+ user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
77
+ if user_override_env:
78
+ self.user_override = user_override_env
79
+
80
+ # Email configuration: arguments have priority
81
+ email_username = email_username or os.getenv(f'{prefix}EMAIL_USERNAME')
82
+ email_password = email_password or os.getenv(f'{prefix}EMAIL_PASSWORD')
83
+ email_address = email_address or os.getenv(f'{prefix}EMAIL_ADDRESS')
84
+ email_reply_to = email_reply_to or os.getenv(f'{prefix}EMAIL_REPLY_TO')
85
+ email_server = email_server or os.getenv(f'{prefix}EMAIL_SERVER')
86
+ email_port = email_port or os.getenv(f'{prefix}EMAIL_PORT')
87
+
88
+ self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
43
89
  if self.user_override and (api_tokens or db_dsn):
44
90
  raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
45
91
  if api_tokens and db_dsn:
@@ -48,18 +94,38 @@ class AuthManager:
48
94
  self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
49
95
  self.jwt_secret = jwt_secret
50
96
  self.oauth_config = oauth_config or {}
97
+
98
+ # Email configuration
99
+ self.email_username = email_username
100
+ self.email_password = email_password
101
+ self.email_address = email_address or email_username
102
+ if email_reply_to:
103
+ self.email_reply_to = email_reply_to
104
+ elif email_username:
105
+ domain = email_username.split('@')[1] if '@' in email_username else 'localhost'
106
+ self.email_reply_to = f'noreply@{domain}'
107
+ else:
108
+ self.email_reply_to = None
109
+ self.email_server = email_server
110
+ self.email_port = int(email_port) if email_port else 587
111
+
51
112
  self.public_endpoints = {
52
113
  'auth.login',
53
114
  'auth.oauth_login',
54
115
  'auth.oauth_callback',
55
116
  'auth.refresh_token',
56
117
  'auth.register',
57
- 'auth.get_roles'
118
+ 'auth.get_roles',
119
+ 'auth.validate_registration',
120
+ 'auth.resend_validation'
58
121
  }
59
122
  self.bp = None
60
123
 
61
124
  if app:
62
125
  self.init_app(app)
126
+
127
+ # Start the background update thread
128
+ self._start_update_thread()
63
129
 
64
130
  def _extract_token_from_header(self):
65
131
  auth = request.authorization
@@ -91,17 +157,34 @@ class AuthManager:
91
157
  }
92
158
  try:
93
159
  parsed = ApiToken.parse_token(api_token)
160
+
161
+ # Check cache first
162
+ cache_key = f"api_token_{parsed['id']}"
163
+ current_time = datetime.utcnow()
164
+
165
+ if cache_key in self._user_cache:
166
+ cached_data, cache_time = self._user_cache[cache_key]
167
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
168
+ logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
169
+ return cached_data.copy() # Return a copy to avoid modifying cache
170
+
171
+ # Cache miss or expired, fetch from database
94
172
  with self.db.get_cursor() as cur:
95
173
  # First get the API token record
96
174
  cur.execute("""
97
- SELECT t.*, u.* FROM api_tokens t
175
+ SELECT t.*, u.*, r.name as role_name FROM api_tokens t
98
176
  JOIN users u ON t.user_id = u.id
177
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
178
+ LEFT JOIN roles r ON ur.role_id = r.id
99
179
  WHERE t.id = %s
100
180
  """, (parsed['id'],))
101
- result = cur.fetchone()
102
- if not result:
181
+ results = cur.fetchall()
182
+ if not results:
103
183
  raise AuthError('Invalid API token')
104
184
 
185
+ # Get the first row for token/user data (all rows will have same token/user data)
186
+ result = results[0]
187
+
105
188
  # Verify the nonce
106
189
  if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
107
190
  raise AuthError('Invalid API token')
@@ -110,29 +193,28 @@ class AuthManager:
110
193
  if result['expires_at'] and result['expires_at'] < datetime.utcnow():
111
194
  raise AuthError('API token has expired')
112
195
 
113
- # Update last used timestamp
114
- cur.execute("""
115
- UPDATE api_tokens
116
- SET last_used_at = %s
117
- WHERE id = %s
118
- """, (datetime.utcnow(), parsed['id']))
196
+ # Schedule last used timestamp update (asynchronous with 10s delay)
197
+ self._schedule_last_used_update(parsed['id'])
119
198
 
120
- # Fetch roles
121
- cur.execute("""
122
- SELECT r.name FROM roles r
123
- JOIN user_roles ur ON ur.role_id = r.id
124
- WHERE ur.user_id = %s
125
- """, (result['user_id'],))
126
- roles = [row['name'] for row in cur.fetchall()]
199
+ # Extract roles from results
200
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
127
201
 
128
202
  # Construct user object
129
- return {
203
+ user_data = {
130
204
  'id': result['user_id'],
131
205
  'username': result['username'],
132
206
  'email': result['email'],
133
207
  'real_name': result['real_name'],
134
208
  'roles': roles
135
209
  }
210
+
211
+ # Cache the result
212
+ self._user_cache[cache_key] = (user_data.copy(), current_time)
213
+
214
+ # Clean up expired cache entries
215
+ self._cleanup_cache()
216
+
217
+ return user_data
136
218
  except ValueError:
137
219
  raise AuthError('Invalid token format')
138
220
 
@@ -238,6 +320,10 @@ class AuthManager:
238
320
  roles = [row['name'] for row in cur.fetchall()]
239
321
  user['roles'] = roles
240
322
 
323
+ # Check if user is validated
324
+ if 'validated' not in roles:
325
+ raise AuthError('Account not yet validated. Please check your email for the validation link.', 403)
326
+
241
327
  token = self._create_token(user)
242
328
  refresh_token = self._create_refresh_token(user)
243
329
 
@@ -251,6 +337,8 @@ class AuthManager:
251
337
  def oauth_login():
252
338
  provider = request.json.get('provider')
253
339
  if provider not in self.oauth_config:
340
+ logger.error(f"Invalid OAuth provider: {provider}")
341
+ logger.error(f"These are the known ones: {self.oauth_config.keys()}")
254
342
  raise AuthError('Invalid OAuth provider', 400)
255
343
 
256
344
  redirect_uri = self.get_redirect_uri()
@@ -265,14 +353,32 @@ class AuthManager:
265
353
 
266
354
  if not code or not provider:
267
355
  raise AuthError('Invalid OAuth callback', 400)
268
-
269
- user_info = self._get_oauth_user_info(provider, code)
270
- token = self._create_token(user_info)
271
- refresh_token = self._create_refresh_token(user_info)
356
+ from urllib.parse import urlencode, urlparse, urlunparse
357
+ get_redirect_uri = self.get_redirect_uri()
358
+ parsed_uri = urlparse(get_redirect_uri)
359
+ frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
272
360
 
273
- # Redirect to frontend with tokens
274
- frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
275
- return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
361
+ #if provider == 'microsoft':
362
+ # client = msal.ConfidentialClientApplication(
363
+ # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
364
+ # )
365
+ # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
366
+ # code = result['access_token']
367
+
368
+ try:
369
+ user_info = self._get_oauth_user_info(provider, code)
370
+ token = self._create_token(user_info)
371
+ refresh_token = self._create_refresh_token(user_info)
372
+ # Redirect to frontend with tokens
373
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
374
+ except AuthError as e:
375
+ # Surface error to frontend for user-friendly messaging
376
+ params = {
377
+ 'error': str(e.message) if hasattr(e, 'message') else str(e),
378
+ 'status': getattr(e, 'status_code', 500),
379
+ 'provider': provider,
380
+ }
381
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
276
382
 
277
383
  @bp.route('/login/profile')
278
384
  def profile():
@@ -386,39 +492,501 @@ class AuthManager:
386
492
  def register():
387
493
  data = request.get_json()
388
494
 
389
- # Hash the password
390
495
  password = data.get('password')
391
496
  if not password:
392
497
  raise AuthError('Password is required', 400)
393
498
 
499
+ username = data.get('username')
500
+ email = data.get('email')
501
+
502
+ if not username:
503
+ raise AuthError('Username is required', 400)
504
+ if not email:
505
+ raise AuthError('Email is required', 400)
506
+
507
+ # Validate password strength
508
+ self._validate_password_strength(password, username=username, email=email)
509
+
510
+ # Hash the password
394
511
  salt = bcrypt.gensalt()
395
512
  password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
396
513
 
397
514
  user = User(
398
- username=data['username'],
399
- email=data['email'],
515
+ username=username,
516
+ email=email,
400
517
  real_name=data['real_name'],
401
518
  roles=data.get('roles', []),
402
519
  id_generator=self.db.get_id_generator()
403
520
  )
404
521
 
405
522
  with self.db.get_cursor() as cur:
406
- if user.id is None:
523
+ # Check if username or email already exists
524
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s", (username, email))
525
+ existing_user = cur.fetchone()
526
+
527
+ if existing_user:
528
+ user_id = existing_user['id']
529
+
530
+ # Check if user is validated
407
531
  cur.execute("""
408
- INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
409
- VALUES (%s, %s, %s, %s, %s, %s)
410
- RETURNING id
411
- """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
412
- user.created_at, user.updated_at))
413
- user.id = cur.fetchone()['id']
414
- else:
532
+ SELECT r.name FROM roles r
533
+ JOIN user_roles ur ON ur.role_id = r.id
534
+ WHERE ur.user_id = %s AND r.name = 'validated'
535
+ """, (user_id,))
536
+ if cur.fetchone():
537
+ # User is validated, reject registration
538
+ raise AuthError('Username or email already exists', 400)
539
+
540
+ # User exists but not validated - allow re-registration
541
+ # This works even if the previous registration hasn't expired yet
542
+ # Update existing user with new registration data
543
+ cur.execute("""
544
+ UPDATE users
545
+ SET username = %s, email = %s, real_name = %s, password_hash = %s, updated_at = %s
546
+ WHERE id = %s
547
+ """, (username, email, user.real_name, password_hash.decode('utf-8'), datetime.utcnow(), user_id))
548
+
549
+ # Remove all existing register-* roles (including non-expired ones)
415
550
  cur.execute("""
416
- INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
417
- VALUES (%s, %s, %s, %s, %s, %s, %s)
418
- """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
419
- user.created_at, user.updated_at))
551
+ DELETE FROM user_roles
552
+ WHERE user_id = %s
553
+ AND role_id IN (
554
+ SELECT id FROM roles WHERE name LIKE 'register-%'
555
+ )
556
+ """, (user_id,))
557
+
558
+ user.id = user_id
559
+ else:
560
+ # New user - create it
561
+ if user.id is None:
562
+ cur.execute("""
563
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
564
+ VALUES (%s, %s, %s, %s, %s, %s)
565
+ RETURNING id
566
+ """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
567
+ user.created_at, user.updated_at))
568
+ user.id = cur.fetchone()['id']
569
+ else:
570
+ cur.execute("""
571
+ INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
572
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
573
+ """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
574
+ user.created_at, user.updated_at))
575
+
576
+ # Generate nonce and timestamp for validation
577
+ nonce = str(uuid.uuid4())
578
+ timestamp = int(time.time())
579
+ role_name = f'register-{nonce}-{timestamp}'
580
+
581
+ # Create temporary validation role
582
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
583
+ role = cur.fetchone()
584
+ if not role:
585
+ role_obj = Role(role_name, description='Temporary registration validation role', id_generator=self.db.get_id_generator())
586
+ if role_obj.id is None:
587
+ cur.execute("""
588
+ INSERT INTO roles (name, description, created_at)
589
+ VALUES (%s, %s, %s)
590
+ RETURNING id
591
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
592
+ role_id = cur.fetchone()['id']
593
+ else:
594
+ cur.execute("""
595
+ INSERT INTO roles (id, name, description, created_at)
596
+ VALUES (%s, %s, %s, %s)
597
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
598
+ role_id = role_obj.id
599
+ else:
600
+ role_id = role['id']
601
+
602
+ # Associate role with user
603
+ cur.execute("""
604
+ INSERT INTO user_roles (user_id, role_id)
605
+ VALUES (%s, %s)
606
+ ON CONFLICT (user_id, role_id) DO NOTHING
607
+ """, (user.id, role_id))
608
+
609
+ # Send validation email
610
+ frontend_url = self._get_frontend_url()
611
+ validation_link = f"{frontend_url}/register/{nonce}"
612
+ email_subject = "Please validate your account"
613
+ email_body = f"""Hello {user.real_name},
420
614
 
421
- return jsonify({'id': user.id}), 201
615
+ Thank you for registering. Please click the link below to validate your account:
616
+
617
+ {validation_link}
618
+
619
+ This link will expire in 24 hours.
620
+
621
+ If you did not register for this account, please ignore this email.
622
+ """
623
+ self._send_email(user.email, email_subject, email_body)
624
+
625
+ return jsonify({'id': user.id, 'message': 'Registration successful. Please check your email for validation link.'}), 201
626
+
627
+ @bp.route('/register/<nonce>', methods=['GET'])
628
+ @bp.public_endpoint
629
+ def validate_registration(nonce):
630
+ with self.db.get_cursor() as cur:
631
+ # Find user with register-{nonce}-{timestamp} role
632
+ cur.execute("""
633
+ SELECT u.id, u.username, u.email, r.name as role_name
634
+ FROM users u
635
+ JOIN user_roles ur ON ur.user_id = u.id
636
+ JOIN roles r ON ur.role_id = r.id
637
+ WHERE r.name LIKE %s
638
+ """, (f'register-{nonce}-%',))
639
+ results = cur.fetchall()
640
+
641
+ if not results:
642
+ raise AuthError('Invalid or expired validation link', 400)
643
+
644
+ # Check if expired (24 hours)
645
+ current_time = int(time.time())
646
+ user_id = None
647
+ expired = True
648
+
649
+ for row in results:
650
+ role_name = row['role_name']
651
+ if role_name.startswith(f'register-{nonce}-'):
652
+ try:
653
+ timestamp = int(role_name.split('-')[-1])
654
+ if current_time - timestamp < 86400: # 24 hours
655
+ expired = False
656
+ user_id = row['id']
657
+ break
658
+ except (ValueError, IndexError):
659
+ continue
660
+
661
+ if expired or not user_id:
662
+ raise AuthError('Validation link has expired. Please request a new validation email.', 400)
663
+
664
+ # Remove all register-* roles from user
665
+ cur.execute("""
666
+ DELETE FROM user_roles
667
+ WHERE user_id = %s
668
+ AND role_id IN (
669
+ SELECT id FROM roles WHERE name LIKE 'register-%%'
670
+ )
671
+ """, (user_id,))
672
+
673
+ # Ensure validated role exists
674
+ cur.execute("SELECT id FROM roles WHERE name = 'validated'")
675
+ validated_role = cur.fetchone()
676
+ if not validated_role:
677
+ role_obj = Role('validated', description='User has validated their email', id_generator=self.db.get_id_generator())
678
+ if role_obj.id is None:
679
+ cur.execute("""
680
+ INSERT INTO roles (name, description, created_at)
681
+ VALUES (%s, %s, %s)
682
+ RETURNING id
683
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
684
+ validated_role_id = cur.fetchone()['id']
685
+ else:
686
+ cur.execute("""
687
+ INSERT INTO roles (id, name, description, created_at)
688
+ VALUES (%s, %s, %s, %s)
689
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
690
+ validated_role_id = role_obj.id
691
+ else:
692
+ validated_role_id = validated_role['id']
693
+
694
+ # Add validated role to user
695
+ cur.execute("""
696
+ INSERT INTO user_roles (user_id, role_id)
697
+ VALUES (%s, %s)
698
+ ON CONFLICT (user_id, role_id) DO NOTHING
699
+ """, (user_id, validated_role_id))
700
+
701
+ return jsonify({'message': 'Account validated successfully. You can now log in.'})
702
+
703
+ @bp.route('/resend-validation', methods=['POST'])
704
+ @bp.public_endpoint
705
+ def resend_validation():
706
+ data = request.get_json()
707
+ email = data.get('email')
708
+ username = data.get('username')
709
+
710
+ if not email and not username:
711
+ raise AuthError('Email or username is required', 400)
712
+
713
+ with self.db.get_cursor() as cur:
714
+ # Find user by email or username
715
+ if email:
716
+ cur.execute("SELECT * FROM users WHERE email = %s", (email,))
717
+ else:
718
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
719
+ user = cur.fetchone()
720
+
721
+ if not user:
722
+ # Don't reveal if user exists
723
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
724
+
725
+ # Check if user is already validated
726
+ cur.execute("""
727
+ SELECT r.name FROM roles r
728
+ JOIN user_roles ur ON ur.role_id = r.id
729
+ WHERE ur.user_id = %s AND r.name = 'validated'
730
+ """, (user['id'],))
731
+ if cur.fetchone():
732
+ # User is already validated, don't reveal this
733
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
734
+
735
+ # Remove existing register-* roles
736
+ cur.execute("""
737
+ DELETE FROM user_roles
738
+ WHERE user_id = %s
739
+ AND role_id IN (
740
+ SELECT id FROM roles WHERE name LIKE 'register-%%'
741
+ )
742
+ """, (user['id'],))
743
+
744
+ # Generate new nonce and timestamp
745
+ nonce = str(uuid.uuid4())
746
+ timestamp = int(time.time())
747
+ role_name = f'register-{nonce}-{timestamp}'
748
+
749
+ # Create new validation role
750
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
751
+ role = cur.fetchone()
752
+ if not role:
753
+ role_obj = Role(role_name, description='Temporary registration validation role', id_generator=self.db.get_id_generator())
754
+ if role_obj.id is None:
755
+ cur.execute("""
756
+ INSERT INTO roles (name, description, created_at)
757
+ VALUES (%s, %s, %s)
758
+ RETURNING id
759
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
760
+ role_id = cur.fetchone()['id']
761
+ else:
762
+ cur.execute("""
763
+ INSERT INTO roles (id, name, description, created_at)
764
+ VALUES (%s, %s, %s, %s)
765
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
766
+ role_id = role_obj.id
767
+ else:
768
+ role_id = role['id']
769
+
770
+ # Associate role with user
771
+ cur.execute("""
772
+ INSERT INTO user_roles (user_id, role_id)
773
+ VALUES (%s, %s)
774
+ ON CONFLICT (user_id, role_id) DO NOTHING
775
+ """, (user['id'], role_id))
776
+
777
+ # Send validation email
778
+ frontend_url = self._get_frontend_url()
779
+ validation_link = f"{frontend_url}/register/{nonce}"
780
+ email_subject = "Please validate your account"
781
+ email_body = f"""Hello {user['real_name']},
782
+
783
+ Please click the link below to validate your account:
784
+
785
+ {validation_link}
786
+
787
+ This link will expire in 24 hours.
788
+
789
+ If you did not request this email, please ignore it.
790
+ """
791
+ self._send_email(user['email'], email_subject, email_body)
792
+
793
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
794
+
795
+ @bp.route('/request-password-reset', methods=['POST'])
796
+ @bp.public_endpoint
797
+ def request_password_reset():
798
+ data = request.get_json()
799
+ username = data.get('username')
800
+
801
+ if not username:
802
+ raise AuthError('Username is required', 400)
803
+
804
+ with self.db.get_cursor() as cur:
805
+ # Find user by username
806
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
807
+ user = cur.fetchone()
808
+
809
+ if not user:
810
+ # Don't reveal if user exists
811
+ return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
812
+
813
+ # Remove existing password-reset-* roles
814
+ cur.execute("""
815
+ DELETE FROM user_roles
816
+ WHERE user_id = %s
817
+ AND role_id IN (
818
+ SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
819
+ )
820
+ """, (user['id'],))
821
+
822
+ # Generate new nonce and timestamp
823
+ nonce = str(uuid.uuid4())
824
+ timestamp = int(time.time())
825
+ role_name = f'password-reset-{nonce}-{timestamp}'
826
+
827
+ # Create new password reset role
828
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
829
+ role = cur.fetchone()
830
+ if not role:
831
+ role_obj = Role(role_name, description='Temporary password reset role', id_generator=self.db.get_id_generator())
832
+ if role_obj.id is None:
833
+ cur.execute("""
834
+ INSERT INTO roles (name, description, created_at)
835
+ VALUES (%s, %s, %s)
836
+ RETURNING id
837
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
838
+ role_id = cur.fetchone()['id']
839
+ else:
840
+ cur.execute("""
841
+ INSERT INTO roles (id, name, description, created_at)
842
+ VALUES (%s, %s, %s, %s)
843
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
844
+ role_id = role_obj.id
845
+ else:
846
+ role_id = role['id']
847
+
848
+ # Associate role with user
849
+ cur.execute("""
850
+ INSERT INTO user_roles (user_id, role_id)
851
+ VALUES (%s, %s)
852
+ ON CONFLICT (user_id, role_id) DO NOTHING
853
+ """, (user['id'], role_id))
854
+
855
+ # Send password reset email
856
+ frontend_url = self._get_frontend_url()
857
+ reset_link = f"{frontend_url}/password-reset/{nonce}"
858
+ email_subject = "Password Reset Request"
859
+ email_body = f"""Hello {user['real_name']},
860
+
861
+ You requested to reset your password. Please click the link below to reset your password:
862
+
863
+ {reset_link}
864
+
865
+ This link will expire in 24 hours.
866
+
867
+ If you did not request a password reset, please ignore this email.
868
+ """
869
+ self._send_email(user['email'], email_subject, email_body)
870
+
871
+ return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
872
+
873
+ @bp.route('/password-reset/<nonce>', methods=['GET'])
874
+ @bp.public_endpoint
875
+ def validate_password_reset(nonce):
876
+ with self.db.get_cursor() as cur:
877
+ # Find user with password-reset-{nonce}-{timestamp} role
878
+ cur.execute("""
879
+ SELECT u.id, u.username, u.email, r.name as role_name
880
+ FROM users u
881
+ JOIN user_roles ur ON ur.user_id = u.id
882
+ JOIN roles r ON ur.role_id = r.id
883
+ WHERE r.name LIKE %s
884
+ """, (f'password-reset-{nonce}-%',))
885
+ results = cur.fetchall()
886
+
887
+ if not results:
888
+ raise AuthError('Invalid or expired password reset link', 400)
889
+
890
+ # Check if expired (24 hours)
891
+ current_time = int(time.time())
892
+ user_id = None
893
+ expired = True
894
+
895
+ for row in results:
896
+ role_name = row['role_name']
897
+ if role_name.startswith(f'password-reset-{nonce}-'):
898
+ try:
899
+ timestamp = int(role_name.split('-')[-1])
900
+ if current_time - timestamp < 86400: # 24 hours
901
+ expired = False
902
+ user_id = row['id']
903
+ break
904
+ except (ValueError, IndexError):
905
+ continue
906
+
907
+ if expired or not user_id:
908
+ raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
909
+
910
+ # Return user info (username only for security)
911
+ cur.execute("SELECT username FROM users WHERE id = %s", (user_id,))
912
+ user = cur.fetchone()
913
+
914
+ return jsonify({'username': user['username'], 'message': 'Password reset link is valid.'})
915
+
916
+ @bp.route('/password-reset/<nonce>', methods=['POST'])
917
+ @bp.public_endpoint
918
+ def reset_password(nonce):
919
+ data = request.get_json()
920
+ password = data.get('password')
921
+ confirm_password = data.get('confirmPassword')
922
+
923
+ if not password:
924
+ raise AuthError('Password is required', 400)
925
+ if password != confirm_password:
926
+ raise AuthError('Passwords do not match', 400)
927
+
928
+ with self.db.get_cursor() as cur:
929
+ # Find user with password-reset-{nonce}-{timestamp} role
930
+ cur.execute("""
931
+ SELECT u.id, u.username, u.email, r.name as role_name
932
+ FROM users u
933
+ JOIN user_roles ur ON ur.user_id = u.id
934
+ JOIN roles r ON ur.role_id = r.id
935
+ WHERE r.name LIKE %s
936
+ """, (f'password-reset-{nonce}-%',))
937
+ results = cur.fetchall()
938
+
939
+ if not results:
940
+ raise AuthError('Invalid or expired password reset link', 400)
941
+
942
+ # Check if expired (24 hours)
943
+ current_time = int(time.time())
944
+ user_id = None
945
+ username = None
946
+ email = None
947
+ expired = True
948
+
949
+ for row in results:
950
+ role_name = row['role_name']
951
+ if role_name.startswith(f'password-reset-{nonce}-'):
952
+ try:
953
+ timestamp = int(role_name.split('-')[-1])
954
+ if current_time - timestamp < 86400: # 24 hours
955
+ expired = False
956
+ user_id = row['id']
957
+ username = row['username']
958
+ email = row['email']
959
+ break
960
+ except (ValueError, IndexError):
961
+ continue
962
+
963
+ if expired or not user_id:
964
+ raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
965
+
966
+ # Validate password strength
967
+ self._validate_password_strength(password, username=username, email=email)
968
+
969
+ # Hash new password
970
+ salt = bcrypt.gensalt()
971
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
972
+
973
+ # Update user's password
974
+ cur.execute("""
975
+ UPDATE users
976
+ SET password_hash = %s, updated_at = %s
977
+ WHERE id = %s
978
+ """, (password_hash.decode('utf-8'), datetime.utcnow(), user_id))
979
+
980
+ # Remove all password-reset-* roles from user
981
+ cur.execute("""
982
+ DELETE FROM user_roles
983
+ WHERE user_id = %s
984
+ AND role_id IN (
985
+ SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
986
+ )
987
+ """, (user_id,))
988
+
989
+ return jsonify({'message': 'Password has been reset successfully. You can now log in with your new password.'})
422
990
 
423
991
  @bp.route('/roles', methods=['GET'])
424
992
  def get_roles():
@@ -427,6 +995,303 @@ class AuthManager:
427
995
  roles = cur.fetchall()
428
996
  return jsonify(roles)
429
997
 
998
+ # Admin endpoints - require administrator role
999
+ @bp.route('/admin/users', methods=['GET'])
1000
+ def admin_get_users():
1001
+ self._require_admin_role()
1002
+ with self.db.get_cursor() as cur:
1003
+ cur.execute("""
1004
+ SELECT u.*,
1005
+ COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
1006
+ FROM users u
1007
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
1008
+ LEFT JOIN roles r ON ur.role_id = r.id
1009
+ GROUP BY u.id, u.username, u.email, u.real_name, u.created_at, u.updated_at
1010
+ ORDER BY u.created_at DESC
1011
+ """)
1012
+ users = cur.fetchall()
1013
+ return jsonify(users)
1014
+
1015
+ @bp.route('/admin/users', methods=['POST'])
1016
+ def admin_create_user():
1017
+ self._require_admin_role()
1018
+ data = request.get_json()
1019
+
1020
+ # Validate required fields
1021
+ required_fields = ['username', 'email', 'real_name', 'password']
1022
+ for field in required_fields:
1023
+ if not data.get(field):
1024
+ raise AuthError(f'{field} is required', 400)
1025
+
1026
+ # Validate password strength
1027
+ self._validate_password_strength(data['password'], username=data['username'], email=data['email'])
1028
+
1029
+ # Hash the password
1030
+ salt = bcrypt.gensalt()
1031
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1032
+
1033
+ with self.db.get_cursor() as cur:
1034
+ # Check if username or email already exists
1035
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
1036
+ (data['username'], data['email']))
1037
+ if cur.fetchone():
1038
+ raise AuthError('Username or email already exists', 400)
1039
+
1040
+ # Create user
1041
+ cur.execute("""
1042
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
1043
+ VALUES (%s, %s, %s, %s, %s, %s)
1044
+ RETURNING id
1045
+ """, (data['username'], data['email'], data['real_name'],
1046
+ password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
1047
+ user_id = cur.fetchone()['id']
1048
+
1049
+ # Assign roles if provided
1050
+ if data.get('roles'):
1051
+ for role_name in data['roles']:
1052
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
1053
+ role = cur.fetchone()
1054
+ if role:
1055
+ cur.execute("""
1056
+ INSERT INTO user_roles (user_id, role_id)
1057
+ VALUES (%s, %s)
1058
+ ON CONFLICT (user_id, role_id) DO NOTHING
1059
+ """, (user_id, role['id']))
1060
+
1061
+ return jsonify({'id': user_id}), 201
1062
+
1063
+ @bp.route('/admin/users/<user_id>', methods=['PUT'])
1064
+ def admin_update_user(user_id):
1065
+ self._require_admin_role()
1066
+ data = request.get_json()
1067
+
1068
+ with self.db.get_cursor() as cur:
1069
+ # Check if user exists and get current username/email
1070
+ cur.execute("SELECT id, username, email FROM users WHERE id = %s", (user_id,))
1071
+ user = cur.fetchone()
1072
+ if not user:
1073
+ raise AuthError('User not found', 404)
1074
+
1075
+ # Get username and email for password validation (use updated values if provided)
1076
+ username = data.get('username', user['username'])
1077
+ email = data.get('email', user['email'])
1078
+
1079
+ # Validate password strength if password is being updated
1080
+ if 'password' in data:
1081
+ self._validate_password_strength(data['password'], username=username, email=email)
1082
+
1083
+ # Update user fields
1084
+ update_fields = []
1085
+ update_values = []
1086
+
1087
+ if 'username' in data:
1088
+ update_fields.append('username = %s')
1089
+ update_values.append(data['username'])
1090
+ if 'email' in data:
1091
+ update_fields.append('email = %s')
1092
+ update_values.append(data['email'])
1093
+ if 'real_name' in data:
1094
+ update_fields.append('real_name = %s')
1095
+ update_values.append(data['real_name'])
1096
+ if 'password' in data:
1097
+ salt = bcrypt.gensalt()
1098
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1099
+ update_fields.append('password_hash = %s')
1100
+ update_values.append(password_hash.decode('utf-8'))
1101
+
1102
+ if update_fields:
1103
+ update_fields.append('updated_at = %s')
1104
+ update_values.append(datetime.utcnow())
1105
+ update_values.append(user_id)
1106
+
1107
+ cur.execute(f"""
1108
+ UPDATE users
1109
+ SET {', '.join(update_fields)}
1110
+ WHERE id = %s
1111
+ """, update_values)
1112
+
1113
+ # Update roles if provided
1114
+ if 'roles' in data:
1115
+ # Remove existing roles
1116
+ cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
1117
+
1118
+ # Add new roles
1119
+ for role_name in data['roles']:
1120
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
1121
+ role = cur.fetchone()
1122
+ if role:
1123
+ cur.execute("""
1124
+ INSERT INTO user_roles (user_id, role_id)
1125
+ VALUES (%s, %s)
1126
+ """, (user_id, role['id']))
1127
+
1128
+ return jsonify({'success': True})
1129
+
1130
+ @bp.route('/admin/users/<user_id>', methods=['DELETE'])
1131
+ def admin_delete_user(user_id):
1132
+ self._require_admin_role()
1133
+
1134
+ with self.db.get_cursor() as cur:
1135
+ # Check if user exists
1136
+ cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
1137
+ if not cur.fetchone():
1138
+ raise AuthError('User not found', 404)
1139
+
1140
+ # Delete user (cascade will handle related records)
1141
+ cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
1142
+
1143
+ return jsonify({'success': True})
1144
+
1145
+ @bp.route('/admin/roles', methods=['GET'])
1146
+ def admin_get_roles():
1147
+ self._require_admin_role()
1148
+ with self.db.get_cursor() as cur:
1149
+ cur.execute("SELECT * FROM roles ORDER BY name")
1150
+ roles = cur.fetchall()
1151
+ return jsonify(roles)
1152
+
1153
+ @bp.route('/admin/roles', methods=['POST'])
1154
+ def admin_create_role():
1155
+ self._require_admin_role()
1156
+ data = request.get_json()
1157
+
1158
+ if not data.get('name'):
1159
+ raise AuthError('Role name is required', 400)
1160
+
1161
+ with self.db.get_cursor() as cur:
1162
+ # Check if role already exists
1163
+ cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
1164
+ if cur.fetchone():
1165
+ raise AuthError('Role already exists', 400)
1166
+
1167
+ cur.execute("""
1168
+ INSERT INTO roles (name, description, created_at)
1169
+ VALUES (%s, %s, %s)
1170
+ RETURNING id
1171
+ """, (data['name'], data.get('description', ''), datetime.utcnow()))
1172
+ role_id = cur.fetchone()['id']
1173
+
1174
+ return jsonify({'id': role_id}), 201
1175
+
1176
+ @bp.route('/admin/roles/<role_id>', methods=['PUT'])
1177
+ def admin_update_role(role_id):
1178
+ self._require_admin_role()
1179
+ data = request.get_json()
1180
+
1181
+ with self.db.get_cursor() as cur:
1182
+ # Check if role exists
1183
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1184
+ if not cur.fetchone():
1185
+ raise AuthError('Role not found', 404)
1186
+
1187
+ update_fields = []
1188
+ update_values = []
1189
+
1190
+ if 'name' in data:
1191
+ update_fields.append('name = %s')
1192
+ update_values.append(data['name'])
1193
+ if 'description' in data:
1194
+ update_fields.append('description = %s')
1195
+ update_values.append(data['description'])
1196
+
1197
+ if update_fields:
1198
+ update_values.append(role_id)
1199
+ cur.execute(f"""
1200
+ UPDATE roles
1201
+ SET {', '.join(update_fields)}
1202
+ WHERE id = %s
1203
+ """, update_values)
1204
+
1205
+ return jsonify({'success': True})
1206
+
1207
+ @bp.route('/admin/roles/<role_id>', methods=['DELETE'])
1208
+ def admin_delete_role(role_id):
1209
+ self._require_admin_role()
1210
+
1211
+ with self.db.get_cursor() as cur:
1212
+ # Check if role exists
1213
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1214
+ if not cur.fetchone():
1215
+ raise AuthError('Role not found', 404)
1216
+
1217
+ # Check if role is assigned to any users
1218
+ cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
1219
+ count = cur.fetchone()['count']
1220
+ if count > 0:
1221
+ raise AuthError('Cannot delete role that is assigned to users', 400)
1222
+
1223
+ cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
1224
+
1225
+ return jsonify({'success': True})
1226
+
1227
+ @bp.route('/admin/api-tokens', methods=['GET'])
1228
+ def admin_get_all_tokens():
1229
+ self._require_admin_role()
1230
+ with self.db.get_cursor() as cur:
1231
+ cur.execute("""
1232
+ SELECT t.*, u.username, u.email
1233
+ FROM api_tokens t
1234
+ JOIN users u ON t.user_id = u.id
1235
+ ORDER BY t.created_at DESC
1236
+ """)
1237
+ tokens = cur.fetchall()
1238
+ return jsonify(tokens)
1239
+
1240
+ @bp.route('/admin/api-tokens', methods=['POST'])
1241
+ def admin_create_token():
1242
+ self._require_admin_role()
1243
+ data = request.get_json()
1244
+
1245
+ if not data.get('user_id') or not data.get('name'):
1246
+ raise AuthError('user_id and name are required', 400)
1247
+
1248
+ expires_in_days = data.get('expires_in_days')
1249
+ token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
1250
+
1251
+ return jsonify({
1252
+ 'id': token.id,
1253
+ 'name': token.name,
1254
+ 'token': token.get_full_token(),
1255
+ 'created_at': token.created_at,
1256
+ 'expires_at': token.expires_at
1257
+ }), 201
1258
+
1259
+ @bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
1260
+ def admin_delete_token(token_id):
1261
+ self._require_admin_role()
1262
+
1263
+ with self.db.get_cursor() as cur:
1264
+ cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
1265
+ if cur.rowcount == 0:
1266
+ raise AuthError('Token not found', 404)
1267
+
1268
+ return jsonify({'success': True})
1269
+
1270
+ @bp.route('/admin/invite', methods=['POST'])
1271
+ def admin_send_invitation():
1272
+ self._require_admin_role()
1273
+ data = request.get_json()
1274
+
1275
+ if not data.get('email'):
1276
+ raise AuthError('Email is required', 400)
1277
+
1278
+ # Check if user already exists
1279
+ with self.db.get_cursor() as cur:
1280
+ cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
1281
+ if cur.fetchone():
1282
+ raise AuthError('User with this email already exists', 400)
1283
+
1284
+ # Send invitation email (placeholder - implement actual email sending)
1285
+ invitation_token = str(uuid.uuid4())
1286
+
1287
+ # Store invitation in database (you might want to create an invitations table)
1288
+ # For now, we'll just return success
1289
+ return jsonify({
1290
+ 'success': True,
1291
+ 'message': f'Invitation sent to {data["email"]}',
1292
+ 'invitation_token': invitation_token
1293
+ })
1294
+
430
1295
  return bp
431
1296
 
432
1297
  def validate_token(self, token):
@@ -436,21 +1301,42 @@ class AuthManager:
436
1301
  logger.debug(f"Token payload: {payload}")
437
1302
  user_id = int(payload['sub']) # Convert string ID back to integer
438
1303
 
1304
+ # Check cache first
1305
+ cache_key = f"user_{user_id}"
1306
+ current_time = datetime.utcnow()
1307
+
1308
+ if cache_key in self._user_cache:
1309
+ cached_data, cache_time = self._user_cache[cache_key]
1310
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
1311
+ logger.debug(f"Returning cached user data for ID: {user_id}")
1312
+ return cached_data.copy() # Return a copy to avoid modifying cache
1313
+
1314
+ # Cache miss or expired, fetch from database
439
1315
  with self.db.get_cursor() as cur:
440
- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
441
- user = cur.fetchone()
442
- if not user:
443
- logger.error(f"User not found for ID: {user_id}")
444
- raise AuthError('User not found', 404)
445
- # Fetch roles
446
1316
  cur.execute("""
447
- SELECT r.name FROM roles r
448
- JOIN user_roles ur ON ur.role_id = r.id
449
- WHERE ur.user_id = %s
1317
+ SELECT u.*, r.name as role_name FROM users u
1318
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
1319
+ LEFT JOIN roles r ON ur.role_id = r.id
1320
+ WHERE u.id = %s
450
1321
  """, (user_id,))
451
- roles = [row['name'] for row in cur.fetchall()]
1322
+ results = cur.fetchall()
1323
+ if not results:
1324
+ logger.error(f"User not found for ID: {user_id}")
1325
+ raise AuthError('User not found', 404)
1326
+
1327
+ # Get the first row for user data (all rows will have same user data)
1328
+ user = results[0]
1329
+
1330
+ # Extract roles from results
1331
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
452
1332
  user['roles'] = roles
453
1333
 
1334
+ # Cache the result
1335
+ self._user_cache[cache_key] = (user.copy(), current_time)
1336
+
1337
+ # Clean up expired cache entries
1338
+ self._cleanup_cache()
1339
+
454
1340
  return user
455
1341
  except jwt.InvalidTokenError as e:
456
1342
  logger.error(f"Invalid token error: {str(e)}")
@@ -459,9 +1345,87 @@ class AuthManager:
459
1345
  logger.error(f"Unexpected error during token validation: {str(e)}")
460
1346
  raise AuthError(str(e), 500)
461
1347
 
1348
+ def _cleanup_cache(self):
1349
+ """Remove expired cache entries."""
1350
+ current_time = datetime.utcnow()
1351
+ expired_keys = [
1352
+ key for key, (_, cache_time) in self._user_cache.items()
1353
+ if (current_time - cache_time).total_seconds() >= self._cache_ttl
1354
+ ]
1355
+ for key in expired_keys:
1356
+ del self._user_cache[key]
1357
+
1358
+ def _start_update_thread(self):
1359
+ """Start the background thread for processing last_used_at updates."""
1360
+ if self._update_thread is None or not self._update_thread.is_alive():
1361
+ self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
1362
+ self._update_thread.start()
1363
+ logger.debug("Started background update thread")
1364
+
1365
+ def _schedule_last_used_update(self, token_id):
1366
+ """Schedule a last_used_at update for an API token with 10s delay."""
1367
+ with self._update_lock:
1368
+ self._last_used_updates[token_id] = time.time()
1369
+ logger.debug(f"Scheduled last_used update for token {token_id}")
1370
+
1371
+ def _update_worker(self):
1372
+ """Background worker that processes last_used_at updates."""
1373
+ while not self._shutdown_event.is_set():
1374
+ try:
1375
+ current_time = time.time()
1376
+ tokens_to_update = []
1377
+
1378
+ # Collect tokens that need updating (older than 10 seconds)
1379
+ with self._update_lock:
1380
+ for token_id, schedule_time in list(self._last_used_updates.items()):
1381
+ if current_time - schedule_time >= 10: # 10 second delay
1382
+ tokens_to_update.append(token_id)
1383
+ del self._last_used_updates[token_id]
1384
+
1385
+ # Perform batch update
1386
+ if tokens_to_update:
1387
+ self._perform_batch_update(tokens_to_update)
1388
+
1389
+ # Sleep for a short interval
1390
+ time.sleep(10)
1391
+
1392
+ except Exception as e:
1393
+ logger.error(f"Error in update worker: {e}")
1394
+ time.sleep(5) # Wait longer on error
1395
+
1396
+ def _perform_batch_update(self, token_ids):
1397
+ """Perform batch update of last_used_at for multiple tokens."""
1398
+ try:
1399
+ with self.db.get_cursor() as cur:
1400
+ # Update all tokens in a single query
1401
+ placeholders = ','.join(['%s'] * len(token_ids))
1402
+ cur.execute(f"""
1403
+ UPDATE api_tokens
1404
+ SET last_used_at = %s
1405
+ WHERE id IN ({placeholders})
1406
+ """, [datetime.utcnow()] + token_ids)
1407
+
1408
+ logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
1409
+
1410
+ except Exception as e:
1411
+ logger.error(f"Error performing batch update: {e}")
1412
+
1413
+ def shutdown(self):
1414
+ """Shutdown the background update thread."""
1415
+ self._shutdown_event.set()
1416
+ if self._update_thread and self._update_thread.is_alive():
1417
+ self._update_thread.join(timeout=5)
1418
+ logger.debug("Background update thread shutdown complete")
1419
+
462
1420
  def get_current_user(self):
463
1421
  return self._authenticate_request()
464
1422
 
1423
+ def _require_admin_role(self):
1424
+ """Require the current user to have administrator role."""
1425
+ user = g.requesting_user
1426
+ if not user or 'administrator' not in user.get('roles', []):
1427
+ raise AuthError('Administrator role required', 403)
1428
+
465
1429
  def get_user_api_tokens(self, user_id):
466
1430
  """Get all API tokens for a user."""
467
1431
  with self.db.get_cursor() as cur:
@@ -487,12 +1451,12 @@ class AuthManager:
487
1451
  def _create_token(self, user):
488
1452
  payload = {
489
1453
  'sub': str(user['id']),
490
- 'exp': datetime.utcnow() + timedelta(hours=1),
1454
+ 'exp': datetime.utcnow() + self.expiry_time,
491
1455
  'iat': datetime.utcnow()
492
1456
  }
493
1457
  logger.debug(f"Creating token with payload: {payload}")
494
1458
  token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
495
- logger.debug(f"Created token: {token}")
1459
+ logger.info(f"Created token: {token}")
496
1460
  return token
497
1461
 
498
1462
  def _create_refresh_token(self, user):
@@ -506,73 +1470,295 @@ class AuthManager:
506
1470
  def _verify_password(self, password, password_hash):
507
1471
  return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
508
1472
 
1473
+ def _validate_password_strength(self, password, username=None, email=None):
1474
+ """Validate password strength and return error message listing all rules and failures."""
1475
+ rules = []
1476
+ failures = []
1477
+
1478
+ # Rule 1: Minimum length
1479
+ min_length = 8
1480
+ rules.append(f"At least {min_length} characters long")
1481
+ if len(password) < min_length:
1482
+ failures.append(f"At least {min_length} characters long")
1483
+
1484
+ # Rule 2: Maximum length
1485
+ max_length = 128
1486
+ rules.append(f"No more than {max_length} characters long")
1487
+ if len(password) > max_length:
1488
+ failures.append(f"No more than {max_length} characters long")
1489
+
1490
+ # Rule 3: Uppercase letter
1491
+ rules.append("Contains at least one uppercase letter (A-Z)")
1492
+ if not re.search(r'[A-Z]', password):
1493
+ failures.append("Contains at least one uppercase letter (A-Z)")
1494
+
1495
+ # Rule 4: Lowercase letter
1496
+ rules.append("Contains at least one lowercase letter (a-z)")
1497
+ if not re.search(r'[a-z]', password):
1498
+ failures.append("Contains at least one lowercase letter (a-z)")
1499
+
1500
+ # Rule 5: Digit
1501
+ rules.append("Contains at least one number (0-9)")
1502
+ if not re.search(r'\d', password):
1503
+ failures.append("Contains at least one number (0-9)")
1504
+
1505
+ # Rule 6: Special character
1506
+ rules.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1507
+ if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
1508
+ failures.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1509
+
1510
+ # Rule 7: Not contain username
1511
+ if username:
1512
+ rules.append("Does not contain your username")
1513
+ if username.lower() in password.lower():
1514
+ failures.append("Does not contain your username")
1515
+
1516
+ # Rule 8: Not contain email username
1517
+ if email:
1518
+ email_username = email.split('@')[0].lower()
1519
+ rules.append("Does not contain your email username")
1520
+ if email_username and email_username in password.lower():
1521
+ failures.append("Does not contain your email username")
1522
+
1523
+ # Rule 9: Not a common password
1524
+ common_passwords = {'password', 'password123', '12345678', 'qwerty', 'abc123', 'letmein', 'welcome', 'monkey', '1234567890', 'password1'}
1525
+ rules.append("Is not a common password")
1526
+ if password.lower() in common_passwords:
1527
+ failures.append("Is not a common password")
1528
+
1529
+ if failures:
1530
+ all_rules_text = "\n".join([f" {'✗' if rule in failures else '✓'} {rule}" for rule in rules])
1531
+ error_msg = f"Password does not meet the following requirements:\n\n{all_rules_text}\n\nPlease fix the issues marked with ✗."
1532
+ raise AuthError(error_msg, 400)
1533
+
1534
+ return True
1535
+
509
1536
  def _get_oauth_url(self, provider, redirect_uri):
510
- if provider == 'google':
511
- client_id = self.oauth_config['google']['client_id']
512
- scope = 'openid email profile'
513
- state = provider # Pass provider as state for callback
514
- return f'https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope={scope}&state={state}'
515
- raise AuthError('Invalid OAuth provider')
1537
+ meta = self._get_provider_meta(provider)
1538
+ client_id = self.oauth_config[provider]['client_id']
1539
+ scope = self.oauth_config[provider].get('scope', meta['default_scope'])
1540
+ state = provider # Pass provider as state for callback
1541
+ # Some providers require additional params
1542
+ params = {
1543
+ 'client_id': client_id,
1544
+ 'redirect_uri': redirect_uri,
1545
+ 'response_type': 'code',
1546
+ 'scope': scope,
1547
+ 'state': state
1548
+ }
1549
+ # Facebook requires display; GitHub supports prompt
1550
+ if provider == 'facebook':
1551
+ params['display'] = 'page'
1552
+ # Build URL
1553
+ from urllib.parse import urlencode
1554
+ return f"{meta['auth_url']}?{urlencode(params)}"
516
1555
 
517
1556
  def _get_oauth_user_info(self, provider, code):
518
- if provider == 'google':
519
- client_id = self.oauth_config['google']['client_id']
520
- client_secret = self.oauth_config['google']['client_secret']
521
- redirect_uri = self.get_redirect_uri()
522
-
523
- # Exchange code for tokens
524
- token_url = 'https://oauth2.googleapis.com/token'
1557
+ meta = self._get_provider_meta(provider)
1558
+ client_id = self.oauth_config[provider]['client_id']
1559
+ client_secret = self.oauth_config[provider]['client_secret']
1560
+ redirect_uri = self.get_redirect_uri()
1561
+
1562
+
1563
+ if provider == 'microsoft':
1564
+ import msal
1565
+ client = msal.ConfidentialClientApplication(
1566
+ client_id,
1567
+ client_credential=client_secret,
1568
+ authority="https://login.microsoftonline.com/common"
1569
+ )
1570
+ tokens = client.acquire_token_by_authorization_code(
1571
+ code,
1572
+ scopes=["email"],
1573
+ redirect_uri=redirect_uri
1574
+ )
1575
+ else:
1576
+ # Standard OAuth flow for other providers
525
1577
  token_data = {
526
1578
  'client_id': client_id,
527
1579
  'client_secret': client_secret,
528
1580
  'code': code,
529
1581
  'grant_type': 'authorization_code',
530
- 'redirect_uri': redirect_uri
1582
+ 'redirect_uri': redirect_uri,
1583
+ 'scope': meta['default_scope']
531
1584
  }
532
- token_response = requests.post(token_url, data=token_data)
1585
+ token_headers = {}
1586
+ if provider == 'github':
1587
+ token_headers['Accept'] = 'application/json'
1588
+ token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
533
1589
  logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
534
1590
  token_response.raise_for_status()
535
1591
  tokens = token_response.json()
536
1592
 
537
- # Get user info
538
- userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
539
- userinfo_response = requests.get(
540
- userinfo_url,
541
- headers={'Authorization': f"Bearer {tokens['access_token']}"}
542
- )
543
- userinfo_response.raise_for_status()
544
- userinfo = userinfo_response.json()
545
1593
 
546
- # Create or update user
547
- with self.db.get_cursor() as cur:
548
- cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
549
- user = cur.fetchone()
1594
+ access_token = tokens.get('access_token') or tokens.get('id_token')
1595
+ if not access_token:
1596
+ # Some providers return id_token separately but require access_token for userinfo
1597
+ access_token = tokens.get('access_token')
550
1598
 
551
- if not user:
552
- # Create new user
553
- user = User(
554
- username=userinfo['email'],
555
- email=userinfo['email'],
556
- real_name=userinfo.get('name', userinfo['email']),
557
- id_generator=self.db.get_id_generator()
558
- )
559
- cur.execute("""
560
- INSERT INTO users (username, email, real_name, created_at, updated_at)
561
- VALUES (%s, %s, %s, %s, %s)
562
- RETURNING id
563
- """, (user.username, user.email, user.real_name,
564
- user.created_at, user.updated_at))
565
- user.id = cur.fetchone()['id']
566
- user = {'id': user.id, 'username': user.username, 'email': user.email,
567
- 'real_name': user.real_name, 'roles': []}
568
- else:
569
- # Update existing user
570
- cur.execute("""
571
- UPDATE users
572
- SET real_name = %s, updated_at = %s
573
- WHERE email = %s
574
- """, (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
575
- user['real_name'] = userinfo.get('name', userinfo['email'])
1599
+ # Build userinfo request
1600
+ userinfo_url = meta['userinfo_url']
1601
+ userinfo_headers = {'Authorization': f"Bearer {access_token}"}
1602
+ if provider == 'facebook':
1603
+ # Ensure fields
1604
+ from urllib.parse import urlencode
1605
+ userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
576
1606
 
577
- return user
578
- raise AuthError('Invalid OAuth provider')
1607
+ userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
1608
+ userinfo_response.raise_for_status()
1609
+ raw_userinfo = userinfo_response.json()
1610
+
1611
+ # Special handling for GitHub missing email
1612
+ if provider == 'github' and not raw_userinfo.get('email'):
1613
+ emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
1614
+ if emails_resp.ok:
1615
+ emails = emails_resp.json()
1616
+ primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
1617
+ raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
1618
+
1619
+
1620
+
1621
+
1622
+ # Normalize
1623
+ norm = self._normalize_userinfo(provider, raw_userinfo)
1624
+ if not norm.get('email'):
1625
+ # Fallback pseudo-email if allowed
1626
+ norm['email'] = f"{norm['sub']}@{provider}.local"
1627
+
1628
+ # Create or update user
1629
+ with self.db.get_cursor() as cur:
1630
+ cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
1631
+ user = cur.fetchone()
1632
+
1633
+ if not user:
1634
+ if not self.allow_oauth_auto_create:
1635
+ raise AuthError('User not found and auto-create disabled', 403)
1636
+ # Create new user (auto-create enabled)
1637
+ user_obj = User(
1638
+ username=norm['email'],
1639
+ email=norm['email'],
1640
+ real_name=norm.get('name', norm['email']),
1641
+ id_generator=self.db.get_id_generator()
1642
+ )
1643
+ cur.execute("""
1644
+ INSERT INTO users (username, email, real_name, created_at, updated_at)
1645
+ VALUES (%s, %s, %s, %s, %s)
1646
+ RETURNING id
1647
+ """, (user_obj.username, user_obj.email, user_obj.real_name,
1648
+ user_obj.created_at, user_obj.updated_at))
1649
+ new_id = cur.fetchone()['id']
1650
+ user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1651
+ 'real_name': user_obj.real_name, 'roles': []}
1652
+ else:
1653
+ # Update existing user
1654
+ cur.execute("""
1655
+ UPDATE users
1656
+ SET real_name = %s, updated_at = %s
1657
+ WHERE email = %s
1658
+ """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
1659
+ user['real_name'] = norm.get('name', norm['email'])
1660
+
1661
+ return user
1662
+
1663
+ def _get_provider_meta(self, provider):
1664
+ providers = {
1665
+ 'google': {
1666
+ 'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
1667
+ 'token_url': 'https://oauth2.googleapis.com/token',
1668
+ 'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
1669
+ 'default_scope': 'openid email profile'
1670
+ },
1671
+ 'github': {
1672
+ 'auth_url': 'https://github.com/login/oauth/authorize',
1673
+ 'token_url': 'https://github.com/login/oauth/access_token',
1674
+ 'userinfo_url': 'https://api.github.com/user',
1675
+ 'default_scope': 'read:user user:email'
1676
+ },
1677
+ 'facebook': {
1678
+ 'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
1679
+ 'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
1680
+ 'userinfo_url': 'https://graph.facebook.com/me',
1681
+ 'default_scope': 'email public_profile'
1682
+ },
1683
+ 'microsoft': {
1684
+ 'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
1685
+ 'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
1686
+ 'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
1687
+ 'default_scope': 'openid email profile'
1688
+ },
1689
+ 'linkedin': {
1690
+ 'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
1691
+ 'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
1692
+ 'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
1693
+ 'default_scope': 'openid profile email'
1694
+ },
1695
+ 'slack': {
1696
+ 'auth_url': 'https://slack.com/openid/connect/authorize',
1697
+ 'token_url': 'https://slack.com/api/openid.connect.token',
1698
+ 'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
1699
+ 'default_scope': 'openid profile email'
1700
+ },
1701
+ 'apple': {
1702
+ 'auth_url': 'https://appleid.apple.com/auth/authorize',
1703
+ 'token_url': 'https://appleid.apple.com/auth/token',
1704
+ 'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
1705
+ 'default_scope': 'name email'
1706
+ }
1707
+ }
1708
+ if provider not in providers:
1709
+ raise AuthError('Invalid OAuth provider ' + provider)
1710
+ return providers[provider]
1711
+
1712
+ def _normalize_userinfo(self, provider, info):
1713
+ # Map into a common structure: sub, email, name
1714
+ if provider == 'google':
1715
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1716
+ if provider == 'github':
1717
+ return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
1718
+ if provider == 'facebook':
1719
+ return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1720
+ if provider == 'microsoft':
1721
+ # OIDC userinfo
1722
+ return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
1723
+ if provider == 'linkedin':
1724
+ return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1725
+ if provider == 'slack':
1726
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1727
+ if provider == 'apple':
1728
+ # Apple email may be private relay; name not always present
1729
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1730
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1731
+
1732
+ def _send_email(self, to_email, subject, body):
1733
+ if not self.email_server or not self.email_username or not self.email_password:
1734
+ logger.error('Email configuration not set, cannot send email')
1735
+ raise AuthError('Email configuration not set. Cannot send validation email.', 500)
1736
+
1737
+ try:
1738
+ msg = MIMEMultipart()
1739
+ msg['From'] = self.email_address
1740
+ msg['To'] = to_email
1741
+ msg['Reply-To'] = self.email_reply_to
1742
+ msg['Subject'] = subject
1743
+ msg.attach(MIMEText(body, 'plain'))
1744
+
1745
+ server = smtplib.SMTP(self.email_server, self.email_port)
1746
+ server.starttls()
1747
+ server.login(self.email_username, self.email_password)
1748
+ server.send_message(msg)
1749
+ server.quit()
1750
+ logger.info(f'Validation email sent to {to_email}')
1751
+ except AuthError:
1752
+ raise
1753
+ except Exception as e:
1754
+ logger.error(f'Failed to send email to {to_email}: {e}')
1755
+ raise AuthError(f'Failed to send validation email: {str(e)}', 500)
1756
+
1757
+ def _get_frontend_url(self):
1758
+ frontend_url = os.getenv('FRONTEND_URL')
1759
+ if not frontend_url:
1760
+ from urllib.parse import urlparse, urlunparse
1761
+ redirect_uri = self.get_redirect_uri()
1762
+ parsed_uri = urlparse(redirect_uri)
1763
+ frontend_url = urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', ''))
1764
+ return frontend_url