the37lab-authlib 0.1.1762438606__py3-none-any.whl → 0.1.1768813136__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,5 +1,4 @@
1
1
  import inspect
2
- import inspect
3
2
  from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
4
3
  import jwt
5
4
  from datetime import datetime, timedelta
@@ -20,27 +19,36 @@ import msal
20
19
  import smtplib
21
20
  from email.mime.text import MIMEText
22
21
  from email.mime.multipart import MIMEMultipart
22
+ import secrets
23
+ import string
24
+ from cachetools import TTLCache
25
+ import json
26
+ from pathlib import Path
23
27
 
24
28
  logging.basicConfig(level=logging.DEBUG)
25
29
  logger = logging.getLogger(__name__)
26
30
 
27
31
  class AuthManager:
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):
32
+ 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, role_implications=None):
29
33
  self.user_override = None
30
- self._user_cache = {}
31
34
  self._cache_ttl = cache_ttl or 10 # 10 seconds
35
+ self._user_cache = TTLCache(maxsize=10000, ttl=self._cache_ttl)
36
+ self._fetch_locks = {} # Locks for preventing concurrent fetches
37
+ self._fetch_locks_lock = threading.Lock() # Lock for managing fetch_locks dict
32
38
  self._last_used_updates = {} # Track pending updates
33
39
  self._update_lock = threading.Lock()
34
40
  self._update_thread = None
35
41
  self._shutdown_event = threading.Event()
42
+ self._token_resolvers = {} # Registered functions for token resolution
43
+ self.role_implications = role_implications or {}
36
44
 
37
45
  # Determine prefix: empty if environment_prefix is None/empty, otherwise use it with '_' delimiter
38
46
  prefix = (environment_prefix.upper() + '_') if environment_prefix else ''
39
-
47
+
40
48
  # Arguments have priority over environment variables
41
49
  db_dsn = db_dsn or os.getenv(f'{prefix}DATABASE_URL')
42
50
  jwt_secret = jwt_secret or os.getenv(f'{prefix}JWT_SECRET')
43
-
51
+
44
52
  # OAuth config: use argument if provided, otherwise build from env vars
45
53
  if oauth_config is None:
46
54
  google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
@@ -51,7 +59,7 @@ class AuthManager:
51
59
  'client_id': google_client_id,
52
60
  'client_secret': google_client_secret
53
61
  }
54
-
62
+
55
63
  # OAuth auto-create: use argument if provided, otherwise check env var (defaults to False)
56
64
  if allow_oauth_auto_create is not None:
57
65
  self.allow_oauth_auto_create = allow_oauth_auto_create
@@ -61,7 +69,7 @@ class AuthManager:
61
69
  self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
62
70
  else:
63
71
  self.allow_oauth_auto_create = False
64
-
72
+
65
73
  # API tokens: use argument if provided, otherwise parse from env var
66
74
  if api_tokens is None:
67
75
  api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
@@ -71,12 +79,12 @@ class AuthManager:
71
79
  if ':' in entry:
72
80
  key, user = entry.split(':', 1)
73
81
  api_tokens[key.strip()] = user.strip()
74
-
82
+
75
83
  # User override: use argument if provided, otherwise check env var
76
84
  user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
77
85
  if user_override_env:
78
86
  self.user_override = user_override_env
79
-
87
+
80
88
  # Email configuration: arguments have priority
81
89
  email_username = email_username or os.getenv(f'{prefix}EMAIL_USERNAME')
82
90
  email_password = email_password or os.getenv(f'{prefix}EMAIL_PASSWORD')
@@ -84,7 +92,7 @@ class AuthManager:
84
92
  email_reply_to = email_reply_to or os.getenv(f'{prefix}EMAIL_REPLY_TO')
85
93
  email_server = email_server or os.getenv(f'{prefix}EMAIL_SERVER')
86
94
  email_port = email_port or os.getenv(f'{prefix}EMAIL_PORT')
87
-
95
+
88
96
  self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
89
97
  if self.user_override and (api_tokens or db_dsn):
90
98
  raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
@@ -94,7 +102,7 @@ class AuthManager:
94
102
  self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
95
103
  self.jwt_secret = jwt_secret
96
104
  self.oauth_config = oauth_config or {}
97
-
105
+
98
106
  # Email configuration
99
107
  self.email_username = email_username
100
108
  self.email_password = email_password
@@ -108,7 +116,7 @@ class AuthManager:
108
116
  self.email_reply_to = None
109
117
  self.email_server = email_server
110
118
  self.email_port = int(email_port) if email_port else 587
111
-
119
+
112
120
  self.public_endpoints = {
113
121
  'auth.login',
114
122
  'auth.oauth_login',
@@ -120,14 +128,67 @@ class AuthManager:
120
128
  'auth.resend_validation'
121
129
  }
122
130
  self.bp = None
123
-
131
+ if self.db:
132
+ self._ensure_admin_role()
133
+
124
134
  if app:
125
135
  self.init_app(app)
126
-
136
+
127
137
  # Start the background update thread
128
138
  self._start_update_thread()
129
139
 
140
+ def _ensure_admin_role(self):
141
+ try:
142
+ with self.db.get_cursor() as cur:
143
+ cur.execute("SELECT COUNT(*) AS role_count FROM roles")
144
+ result = cur.fetchone() or {}
145
+ if result.get('role_count', 0):
146
+ return
147
+ role = Role('administrator', 'Default administrator role', self.db.get_id_generator())
148
+ columns = ['name', 'description', 'created_at']
149
+ values = [role.name, role.description, role.created_at]
150
+ placeholders = ['%s', '%s', '%s']
151
+ if role.id is not None:
152
+ columns.insert(0, 'id')
153
+ values.insert(0, role.id)
154
+ placeholders.insert(0, '%s')
155
+ cur.execute(
156
+ f"INSERT INTO roles ({', '.join(columns)}) VALUES ({', '.join(placeholders)})",
157
+ values
158
+ )
159
+ logger.info('Default admin role created')
160
+ except Exception:
161
+ logger.exception('Ensure admin role failed')
162
+
163
+ def _ensure_admin_role(self):
164
+ try:
165
+ with self.db.get_cursor() as cur:
166
+ cur.execute("SELECT COUNT(*) AS user_count FROM users")
167
+ result = cur.fetchone() or {}
168
+ if result.get('user_count', 0):
169
+ return
170
+ # Generate a secure 12-character password
171
+ alphabet = string.ascii_letters + string.digits + string.punctuation
172
+ password = ''.join(secrets.choice(alphabet) for _ in range(12))
173
+ logger.info(f"There were no users in the database. A temporary user `admin` has been created with password: {password}")
174
+ role = Role('administrator', 'Default administrator role', self.db.get_id_generator())
175
+ columns = ['name', 'description', 'created_at']
176
+ values = [role.name, role.description, role.created_at]
177
+ placeholders = ['%s', '%s', '%s']
178
+ if role.id is not None:
179
+ columns.insert(0, 'id')
180
+ values.insert(0, role.id)
181
+ placeholders.insert(0, '%s')
182
+ cur.execute(
183
+ f"INSERT INTO roles ({', '.join(columns)}) VALUES ({', '.join(placeholders)})",
184
+ values
185
+ )
186
+ logger.info('Default admin role created')
187
+ except Exception:
188
+ logger.exception('Ensure admin role failed')
189
+
130
190
  def _extract_token_from_header(self):
191
+ #print('request.headers', request.headers, 'authorization', request.authorization, 'request', request)
131
192
  auth = request.authorization
132
193
  if not auth or not auth.token:
133
194
  raise AuthError('No authorization header or token', 401)
@@ -157,68 +218,83 @@ class AuthManager:
157
218
  }
158
219
  try:
159
220
  parsed = ApiToken.parse_token(api_token)
160
-
221
+
161
222
  # Check cache first
162
223
  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
172
- with self.db.get_cursor() as cur:
173
- # First get the API token record
174
- cur.execute("""
175
- SELECT t.*, u.*, r.name as role_name FROM api_tokens t
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
179
- WHERE t.id = %s
180
- """, (parsed['id'],))
181
- results = cur.fetchall()
182
- if not results:
183
- raise AuthError('Invalid API token')
184
-
185
- # Get the first row for token/user data (all rows will have same token/user data)
186
- result = results[0]
187
-
188
- # Verify the nonce
189
- if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
190
- raise AuthError('Invalid API token')
191
-
192
- # Check if token is expired
193
- if result['expires_at'] and result['expires_at'] < datetime.utcnow():
194
- raise AuthError('API token has expired')
195
-
196
- # Schedule last used timestamp update (asynchronous with 10s delay)
197
- self._schedule_last_used_update(parsed['id'])
198
-
199
- # Extract roles from results
200
- roles = [row['role_name'] for row in results if row['role_name'] is not None]
201
-
202
- # Construct user object
203
- user_data = {
204
- 'id': result['user_id'],
205
- 'username': result['username'],
206
- 'email': result['email'],
207
- 'real_name': result['real_name'],
208
- 'roles': roles
209
- }
210
224
 
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
225
+ cached_data = self._user_cache.get(cache_key)
226
+ if cached_data is not None:
227
+ logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
228
+ return cached_data.copy() # Return a copy to avoid modifying cache
229
+
230
+ # Cache miss - get or create lock for this key
231
+ with self._fetch_locks_lock:
232
+ if cache_key not in self._fetch_locks:
233
+ self._fetch_locks[cache_key] = threading.Lock()
234
+ fetch_lock = self._fetch_locks[cache_key]
235
+
236
+ # Acquire lock to prevent concurrent fetches
237
+ with fetch_lock:
238
+ # Double-check cache after acquiring lock
239
+ cached_data = self._user_cache.get(cache_key)
240
+ if cached_data is not None:
241
+ logger.debug(f"Returning cached API token data for ID: {parsed['id']} (after lock)")
242
+ return cached_data.copy()
243
+
244
+ # Fetch from database
245
+ with self.db.get_cursor() as cur:
246
+ # First get the API token record
247
+ cur.execute("""
248
+ SELECT t.*, u.*, r.name as role_name FROM api_tokens t
249
+ JOIN users u ON t.user_id = u.id
250
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
251
+ LEFT JOIN roles r ON ur.role_id = r.id
252
+ WHERE t.id = %s
253
+ """, (parsed['id'],))
254
+ results = cur.fetchall()
255
+ if not results:
256
+ raise AuthError('Invalid API token')
257
+
258
+ # Get the first row for token/user data (all rows will have same token/user data)
259
+ result = results[0]
260
+
261
+ # Verify the nonce
262
+ if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
263
+ raise AuthError('Invalid API token')
264
+
265
+ # Check if token is expired
266
+ if result['expires_at'] and result['expires_at'] < datetime.utcnow():
267
+ raise AuthError('API token has expired')
268
+
269
+ # Schedule last used timestamp update (asynchronous with 10s delay)
270
+ self._schedule_last_used_update(parsed['id'])
271
+
272
+ # Extract roles from results
273
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
274
+
275
+ # Construct user object
276
+ user_data = {
277
+ 'id': result['user_id'],
278
+ 'username': result['username'],
279
+ 'email': result['email'],
280
+ 'real_name': result['real_name'],
281
+ 'roles': roles
282
+ }
283
+
284
+ # Cache the result
285
+ self._user_cache[cache_key] = user_data.copy()
286
+
287
+ return user_data
218
288
  except ValueError:
219
289
  raise AuthError('Invalid token format')
220
290
 
221
291
  def _authenticate_request(self):
292
+ if hasattr(g, 'requesting_user'):
293
+ return g.requesting_user
294
+ g.requesting_user = self._authenticate_request_helper()
295
+ return g.requesting_user
296
+
297
+ def _authenticate_request_helper(self):
222
298
  if self.user_override:
223
299
  return {
224
300
  'id': self.user_override,
@@ -267,7 +343,7 @@ class AuthManager:
267
343
  endpoint = f"{self.bp.name}.{f.__name__}"
268
344
  self.add_public_endpoint(endpoint)
269
345
  return f
270
-
346
+
271
347
  def init_app(self, app):
272
348
  app.auth_manager = self
273
349
  app.register_blueprint(self.create_blueprint())
@@ -300,17 +376,17 @@ class AuthManager:
300
376
  data = request.get_json()
301
377
  username = data.get('username')
302
378
  password = data.get('password')
303
-
379
+
304
380
  if not username or not password:
305
381
  raise AuthError('Username and password required', 400)
306
-
382
+
307
383
  with self.db.get_cursor() as cur:
308
384
  cur.execute("SELECT * FROM users WHERE username = %s", (username,))
309
385
  user = cur.fetchone()
310
-
386
+
311
387
  if not user or not self._verify_password(password, user['password_hash']):
312
388
  raise AuthError('Invalid username or password', 401)
313
-
389
+
314
390
  # Fetch roles
315
391
  cur.execute("""
316
392
  SELECT r.name FROM roles r
@@ -319,14 +395,14 @@ class AuthManager:
319
395
  """, (user['id'],))
320
396
  roles = [row['name'] for row in cur.fetchall()]
321
397
  user['roles'] = roles
322
-
398
+
323
399
  # Check if user is validated
324
400
  if 'validated' not in roles:
325
401
  raise AuthError('Account not yet validated. Please check your email for the validation link.', 403)
326
-
402
+
327
403
  token = self._create_token(user)
328
404
  refresh_token = self._create_refresh_token(user)
329
-
405
+
330
406
  return jsonify({
331
407
  'token': token,
332
408
  'refresh_token': refresh_token,
@@ -350,21 +426,21 @@ class AuthManager:
350
426
  def oauth_callback():
351
427
  code = request.args.get('code')
352
428
  provider = request.args.get('state')
353
-
429
+
354
430
  if not code or not provider:
355
431
  raise AuthError('Invalid OAuth callback', 400)
356
432
  from urllib.parse import urlencode, urlparse, urlunparse
357
433
  get_redirect_uri = self.get_redirect_uri()
358
434
  parsed_uri = urlparse(get_redirect_uri)
359
435
  frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
360
-
436
+
361
437
  #if provider == 'microsoft':
362
438
  # client = msal.ConfidentialClientApplication(
363
439
  # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
364
440
  # )
365
441
  # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
366
442
  # code = result['access_token']
367
-
443
+
368
444
  try:
369
445
  user_info = self._get_oauth_user_info(provider, code)
370
446
  token = self._create_token(user_info)
@@ -414,7 +490,7 @@ class AuthManager:
414
490
  try:
415
491
  payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
416
492
  user_id = payload['sub']
417
-
493
+
418
494
  with self.db.get_cursor() as cur:
419
495
  cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
420
496
  user = cur.fetchone()
@@ -447,7 +523,7 @@ class AuthManager:
447
523
 
448
524
  with self.db.get_cursor() as cur:
449
525
  cur.execute("""
450
- SELECT * FROM api_tokens
526
+ SELECT * FROM api_tokens
451
527
  WHERE user_id = %s AND id = %s
452
528
  """, (g.requesting_user['id'], token))
453
529
  api_token = cur.fetchone()
@@ -462,7 +538,7 @@ class AuthManager:
462
538
  # Update last used timestamp
463
539
  with self.db.get_cursor() as cur:
464
540
  cur.execute("""
465
- UPDATE api_tokens
541
+ UPDATE api_tokens
466
542
  SET last_used_at = %s
467
543
  WHERE id = %s
468
544
  """, (datetime.utcnow(), api_token['id']))
@@ -478,7 +554,7 @@ class AuthManager:
478
554
 
479
555
  with self.db.get_cursor() as cur:
480
556
  cur.execute("""
481
- DELETE FROM api_tokens
557
+ DELETE FROM api_tokens
482
558
  WHERE user_id = %s AND id = %s
483
559
  RETURNING id
484
560
  """, (g.requesting_user['id'], token))
@@ -491,26 +567,26 @@ class AuthManager:
491
567
  @bp.route('/register', methods=['POST'])
492
568
  def register():
493
569
  data = request.get_json()
494
-
570
+
495
571
  password = data.get('password')
496
572
  if not password:
497
573
  raise AuthError('Password is required', 400)
498
-
574
+
499
575
  username = data.get('username')
500
576
  email = data.get('email')
501
-
577
+
502
578
  if not username:
503
579
  raise AuthError('Username is required', 400)
504
580
  if not email:
505
581
  raise AuthError('Email is required', 400)
506
-
582
+
507
583
  # Validate password strength
508
584
  self._validate_password_strength(password, username=username, email=email)
509
-
585
+
510
586
  # Hash the password
511
587
  salt = bcrypt.gensalt()
512
588
  password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
513
-
589
+
514
590
  user = User(
515
591
  username=username,
516
592
  email=email,
@@ -523,10 +599,10 @@ class AuthManager:
523
599
  # Check if username or email already exists
524
600
  cur.execute("SELECT id FROM users WHERE username = %s OR email = %s", (username, email))
525
601
  existing_user = cur.fetchone()
526
-
602
+
527
603
  if existing_user:
528
604
  user_id = existing_user['id']
529
-
605
+
530
606
  # Check if user is validated
531
607
  cur.execute("""
532
608
  SELECT r.name FROM roles r
@@ -536,16 +612,16 @@ class AuthManager:
536
612
  if cur.fetchone():
537
613
  # User is validated, reject registration
538
614
  raise AuthError('Username or email already exists', 400)
539
-
615
+
540
616
  # User exists but not validated - allow re-registration
541
617
  # This works even if the previous registration hasn't expired yet
542
618
  # Update existing user with new registration data
543
619
  cur.execute("""
544
- UPDATE users
620
+ UPDATE users
545
621
  SET username = %s, email = %s, real_name = %s, password_hash = %s, updated_at = %s
546
622
  WHERE id = %s
547
623
  """, (username, email, user.real_name, password_hash.decode('utf-8'), datetime.utcnow(), user_id))
548
-
624
+
549
625
  # Remove all existing register-* roles (including non-expired ones)
550
626
  cur.execute("""
551
627
  DELETE FROM user_roles
@@ -554,7 +630,7 @@ class AuthManager:
554
630
  SELECT id FROM roles WHERE name LIKE 'register-%'
555
631
  )
556
632
  """, (user_id,))
557
-
633
+
558
634
  user.id = user_id
559
635
  else:
560
636
  # New user - create it
@@ -572,12 +648,12 @@ class AuthManager:
572
648
  VALUES (%s, %s, %s, %s, %s, %s, %s)
573
649
  """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
574
650
  user.created_at, user.updated_at))
575
-
651
+
576
652
  # Generate nonce and timestamp for validation
577
653
  nonce = str(uuid.uuid4())
578
654
  timestamp = int(time.time())
579
655
  role_name = f'register-{nonce}-{timestamp}'
580
-
656
+
581
657
  # Create temporary validation role
582
658
  cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
583
659
  role = cur.fetchone()
@@ -598,14 +674,14 @@ class AuthManager:
598
674
  role_id = role_obj.id
599
675
  else:
600
676
  role_id = role['id']
601
-
677
+
602
678
  # Associate role with user
603
679
  cur.execute("""
604
680
  INSERT INTO user_roles (user_id, role_id)
605
681
  VALUES (%s, %s)
606
682
  ON CONFLICT (user_id, role_id) DO NOTHING
607
683
  """, (user.id, role_id))
608
-
684
+
609
685
  # Send validation email
610
686
  frontend_url = self._get_frontend_url()
611
687
  validation_link = f"{frontend_url}/register/{nonce}"
@@ -637,15 +713,15 @@ If you did not register for this account, please ignore this email.
637
713
  WHERE r.name LIKE %s
638
714
  """, (f'register-{nonce}-%',))
639
715
  results = cur.fetchall()
640
-
716
+
641
717
  if not results:
642
718
  raise AuthError('Invalid or expired validation link', 400)
643
-
719
+
644
720
  # Check if expired (24 hours)
645
721
  current_time = int(time.time())
646
722
  user_id = None
647
723
  expired = True
648
-
724
+
649
725
  for row in results:
650
726
  role_name = row['role_name']
651
727
  if role_name.startswith(f'register-{nonce}-'):
@@ -657,10 +733,10 @@ If you did not register for this account, please ignore this email.
657
733
  break
658
734
  except (ValueError, IndexError):
659
735
  continue
660
-
736
+
661
737
  if expired or not user_id:
662
738
  raise AuthError('Validation link has expired. Please request a new validation email.', 400)
663
-
739
+
664
740
  # Remove all register-* roles from user
665
741
  cur.execute("""
666
742
  DELETE FROM user_roles
@@ -669,7 +745,7 @@ If you did not register for this account, please ignore this email.
669
745
  SELECT id FROM roles WHERE name LIKE 'register-%%'
670
746
  )
671
747
  """, (user_id,))
672
-
748
+
673
749
  # Ensure validated role exists
674
750
  cur.execute("SELECT id FROM roles WHERE name = 'validated'")
675
751
  validated_role = cur.fetchone()
@@ -690,14 +766,14 @@ If you did not register for this account, please ignore this email.
690
766
  validated_role_id = role_obj.id
691
767
  else:
692
768
  validated_role_id = validated_role['id']
693
-
769
+
694
770
  # Add validated role to user
695
771
  cur.execute("""
696
772
  INSERT INTO user_roles (user_id, role_id)
697
773
  VALUES (%s, %s)
698
774
  ON CONFLICT (user_id, role_id) DO NOTHING
699
775
  """, (user_id, validated_role_id))
700
-
776
+
701
777
  return jsonify({'message': 'Account validated successfully. You can now log in.'})
702
778
 
703
779
  @bp.route('/resend-validation', methods=['POST'])
@@ -706,10 +782,10 @@ If you did not register for this account, please ignore this email.
706
782
  data = request.get_json()
707
783
  email = data.get('email')
708
784
  username = data.get('username')
709
-
785
+
710
786
  if not email and not username:
711
787
  raise AuthError('Email or username is required', 400)
712
-
788
+
713
789
  with self.db.get_cursor() as cur:
714
790
  # Find user by email or username
715
791
  if email:
@@ -717,11 +793,11 @@ If you did not register for this account, please ignore this email.
717
793
  else:
718
794
  cur.execute("SELECT * FROM users WHERE username = %s", (username,))
719
795
  user = cur.fetchone()
720
-
796
+
721
797
  if not user:
722
798
  # Don't reveal if user exists
723
799
  return jsonify({'message': 'If an account exists, a validation email has been sent.'})
724
-
800
+
725
801
  # Check if user is already validated
726
802
  cur.execute("""
727
803
  SELECT r.name FROM roles r
@@ -731,7 +807,7 @@ If you did not register for this account, please ignore this email.
731
807
  if cur.fetchone():
732
808
  # User is already validated, don't reveal this
733
809
  return jsonify({'message': 'If an account exists, a validation email has been sent.'})
734
-
810
+
735
811
  # Remove existing register-* roles
736
812
  cur.execute("""
737
813
  DELETE FROM user_roles
@@ -740,12 +816,12 @@ If you did not register for this account, please ignore this email.
740
816
  SELECT id FROM roles WHERE name LIKE 'register-%%'
741
817
  )
742
818
  """, (user['id'],))
743
-
819
+
744
820
  # Generate new nonce and timestamp
745
821
  nonce = str(uuid.uuid4())
746
822
  timestamp = int(time.time())
747
823
  role_name = f'register-{nonce}-{timestamp}'
748
-
824
+
749
825
  # Create new validation role
750
826
  cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
751
827
  role = cur.fetchone()
@@ -766,14 +842,14 @@ If you did not register for this account, please ignore this email.
766
842
  role_id = role_obj.id
767
843
  else:
768
844
  role_id = role['id']
769
-
845
+
770
846
  # Associate role with user
771
847
  cur.execute("""
772
848
  INSERT INTO user_roles (user_id, role_id)
773
849
  VALUES (%s, %s)
774
850
  ON CONFLICT (user_id, role_id) DO NOTHING
775
851
  """, (user['id'], role_id))
776
-
852
+
777
853
  # Send validation email
778
854
  frontend_url = self._get_frontend_url()
779
855
  validation_link = f"{frontend_url}/register/{nonce}"
@@ -789,7 +865,7 @@ This link will expire in 24 hours.
789
865
  If you did not request this email, please ignore it.
790
866
  """
791
867
  self._send_email(user['email'], email_subject, email_body)
792
-
868
+
793
869
  return jsonify({'message': 'If an account exists, a validation email has been sent.'})
794
870
 
795
871
  @bp.route('/request-password-reset', methods=['POST'])
@@ -797,19 +873,19 @@ If you did not request this email, please ignore it.
797
873
  def request_password_reset():
798
874
  data = request.get_json()
799
875
  username = data.get('username')
800
-
876
+
801
877
  if not username:
802
878
  raise AuthError('Username is required', 400)
803
-
879
+
804
880
  with self.db.get_cursor() as cur:
805
881
  # Find user by username
806
882
  cur.execute("SELECT * FROM users WHERE username = %s", (username,))
807
883
  user = cur.fetchone()
808
-
884
+
809
885
  if not user:
810
886
  # Don't reveal if user exists
811
887
  return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
812
-
888
+
813
889
  # Remove existing password-reset-* roles
814
890
  cur.execute("""
815
891
  DELETE FROM user_roles
@@ -818,12 +894,12 @@ If you did not request this email, please ignore it.
818
894
  SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
819
895
  )
820
896
  """, (user['id'],))
821
-
897
+
822
898
  # Generate new nonce and timestamp
823
899
  nonce = str(uuid.uuid4())
824
900
  timestamp = int(time.time())
825
901
  role_name = f'password-reset-{nonce}-{timestamp}'
826
-
902
+
827
903
  # Create new password reset role
828
904
  cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
829
905
  role = cur.fetchone()
@@ -844,14 +920,14 @@ If you did not request this email, please ignore it.
844
920
  role_id = role_obj.id
845
921
  else:
846
922
  role_id = role['id']
847
-
923
+
848
924
  # Associate role with user
849
925
  cur.execute("""
850
926
  INSERT INTO user_roles (user_id, role_id)
851
927
  VALUES (%s, %s)
852
928
  ON CONFLICT (user_id, role_id) DO NOTHING
853
929
  """, (user['id'], role_id))
854
-
930
+
855
931
  # Send password reset email
856
932
  frontend_url = self._get_frontend_url()
857
933
  reset_link = f"{frontend_url}/password-reset/{nonce}"
@@ -867,7 +943,7 @@ This link will expire in 24 hours.
867
943
  If you did not request a password reset, please ignore this email.
868
944
  """
869
945
  self._send_email(user['email'], email_subject, email_body)
870
-
946
+
871
947
  return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
872
948
 
873
949
  @bp.route('/password-reset/<nonce>', methods=['GET'])
@@ -883,15 +959,15 @@ If you did not request a password reset, please ignore this email.
883
959
  WHERE r.name LIKE %s
884
960
  """, (f'password-reset-{nonce}-%',))
885
961
  results = cur.fetchall()
886
-
962
+
887
963
  if not results:
888
964
  raise AuthError('Invalid or expired password reset link', 400)
889
-
965
+
890
966
  # Check if expired (24 hours)
891
967
  current_time = int(time.time())
892
968
  user_id = None
893
969
  expired = True
894
-
970
+
895
971
  for row in results:
896
972
  role_name = row['role_name']
897
973
  if role_name.startswith(f'password-reset-{nonce}-'):
@@ -903,14 +979,14 @@ If you did not request a password reset, please ignore this email.
903
979
  break
904
980
  except (ValueError, IndexError):
905
981
  continue
906
-
982
+
907
983
  if expired or not user_id:
908
984
  raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
909
-
985
+
910
986
  # Return user info (username only for security)
911
987
  cur.execute("SELECT username FROM users WHERE id = %s", (user_id,))
912
988
  user = cur.fetchone()
913
-
989
+
914
990
  return jsonify({'username': user['username'], 'message': 'Password reset link is valid.'})
915
991
 
916
992
  @bp.route('/password-reset/<nonce>', methods=['POST'])
@@ -919,12 +995,12 @@ If you did not request a password reset, please ignore this email.
919
995
  data = request.get_json()
920
996
  password = data.get('password')
921
997
  confirm_password = data.get('confirmPassword')
922
-
998
+
923
999
  if not password:
924
1000
  raise AuthError('Password is required', 400)
925
1001
  if password != confirm_password:
926
1002
  raise AuthError('Passwords do not match', 400)
927
-
1003
+
928
1004
  with self.db.get_cursor() as cur:
929
1005
  # Find user with password-reset-{nonce}-{timestamp} role
930
1006
  cur.execute("""
@@ -935,17 +1011,17 @@ If you did not request a password reset, please ignore this email.
935
1011
  WHERE r.name LIKE %s
936
1012
  """, (f'password-reset-{nonce}-%',))
937
1013
  results = cur.fetchall()
938
-
1014
+
939
1015
  if not results:
940
1016
  raise AuthError('Invalid or expired password reset link', 400)
941
-
1017
+
942
1018
  # Check if expired (24 hours)
943
1019
  current_time = int(time.time())
944
1020
  user_id = None
945
1021
  username = None
946
1022
  email = None
947
1023
  expired = True
948
-
1024
+
949
1025
  for row in results:
950
1026
  role_name = row['role_name']
951
1027
  if role_name.startswith(f'password-reset-{nonce}-'):
@@ -959,24 +1035,24 @@ If you did not request a password reset, please ignore this email.
959
1035
  break
960
1036
  except (ValueError, IndexError):
961
1037
  continue
962
-
1038
+
963
1039
  if expired or not user_id:
964
1040
  raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
965
-
1041
+
966
1042
  # Validate password strength
967
1043
  self._validate_password_strength(password, username=username, email=email)
968
-
1044
+
969
1045
  # Hash new password
970
1046
  salt = bcrypt.gensalt()
971
1047
  password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
972
-
1048
+
973
1049
  # Update user's password
974
1050
  cur.execute("""
975
- UPDATE users
1051
+ UPDATE users
976
1052
  SET password_hash = %s, updated_at = %s
977
1053
  WHERE id = %s
978
1054
  """, (password_hash.decode('utf-8'), datetime.utcnow(), user_id))
979
-
1055
+
980
1056
  # Remove all password-reset-* roles from user
981
1057
  cur.execute("""
982
1058
  DELETE FROM user_roles
@@ -985,9 +1061,58 @@ If you did not request a password reset, please ignore this email.
985
1061
  SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
986
1062
  )
987
1063
  """, (user_id,))
988
-
1064
+
989
1065
  return jsonify({'message': 'Password has been reset successfully. You can now log in with your new password.'})
990
1066
 
1067
+ @bp.route('/change-password', methods=['POST'])
1068
+ def change_password():
1069
+ user = g.requesting_user
1070
+ if not user:
1071
+ raise AuthError('Authentication required', 401)
1072
+
1073
+ data = request.get_json()
1074
+ current_password = data.get('currentPassword')
1075
+ password = data.get('password')
1076
+ confirm_password = data.get('confirmPassword')
1077
+
1078
+ if not current_password or not password or not confirm_password:
1079
+ raise AuthError('Current password, new password, and confirmation are required', 400)
1080
+
1081
+ if password != confirm_password:
1082
+ raise AuthError('New password and confirmation do not match', 400)
1083
+
1084
+ with self.db.get_cursor() as cur:
1085
+ # Get user with password hash
1086
+ cur.execute("SELECT * FROM users WHERE id = %s", (user['id'],))
1087
+ db_user = cur.fetchone()
1088
+
1089
+ if not db_user:
1090
+ raise AuthError('User not found', 404)
1091
+
1092
+ # Check if user has a password (OAuth-only users might not have one)
1093
+ if not db_user.get('password_hash'):
1094
+ raise AuthError('No password set for this account. Please use password reset instead.', 400)
1095
+
1096
+ # Verify current password
1097
+ if not self._verify_password(current_password, db_user['password_hash']):
1098
+ raise AuthError('Current password is incorrect', 401)
1099
+
1100
+ # Validate new password strength
1101
+ self._validate_password_strength(password, username=db_user['username'], email=db_user.get('email'))
1102
+
1103
+ # Hash new password
1104
+ salt = bcrypt.gensalt()
1105
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
1106
+
1107
+ # Update user's password
1108
+ cur.execute("""
1109
+ UPDATE users
1110
+ SET password_hash = %s, updated_at = %s
1111
+ WHERE id = %s
1112
+ """, (password_hash.decode('utf-8'), datetime.utcnow(), user['id']))
1113
+
1114
+ return jsonify({'message': 'Password has been changed successfully.'})
1115
+
991
1116
  @bp.route('/roles', methods=['GET'])
992
1117
  def get_roles():
993
1118
  with self.db.get_cursor() as cur:
@@ -1001,7 +1126,7 @@ If you did not request a password reset, please ignore this email.
1001
1126
  self._require_admin_role()
1002
1127
  with self.db.get_cursor() as cur:
1003
1128
  cur.execute("""
1004
- SELECT u.*,
1129
+ SELECT u.*,
1005
1130
  COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
1006
1131
  FROM users u
1007
1132
  LEFT JOIN user_roles ur ON ur.user_id = u.id
@@ -1016,36 +1141,36 @@ If you did not request a password reset, please ignore this email.
1016
1141
  def admin_create_user():
1017
1142
  self._require_admin_role()
1018
1143
  data = request.get_json()
1019
-
1144
+
1020
1145
  # Validate required fields
1021
1146
  required_fields = ['username', 'email', 'real_name', 'password']
1022
1147
  for field in required_fields:
1023
1148
  if not data.get(field):
1024
1149
  raise AuthError(f'{field} is required', 400)
1025
-
1150
+
1026
1151
  # Validate password strength
1027
1152
  self._validate_password_strength(data['password'], username=data['username'], email=data['email'])
1028
-
1153
+
1029
1154
  # Hash the password
1030
1155
  salt = bcrypt.gensalt()
1031
1156
  password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1032
-
1157
+
1033
1158
  with self.db.get_cursor() as cur:
1034
1159
  # Check if username or email already exists
1035
- cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
1160
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
1036
1161
  (data['username'], data['email']))
1037
1162
  if cur.fetchone():
1038
1163
  raise AuthError('Username or email already exists', 400)
1039
-
1164
+
1040
1165
  # Create user
1041
1166
  cur.execute("""
1042
1167
  INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
1043
1168
  VALUES (%s, %s, %s, %s, %s, %s)
1044
1169
  RETURNING id
1045
- """, (data['username'], data['email'], data['real_name'],
1170
+ """, (data['username'], data['email'], data['real_name'],
1046
1171
  password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
1047
1172
  user_id = cur.fetchone()['id']
1048
-
1173
+
1049
1174
  # Assign roles if provided
1050
1175
  if data.get('roles'):
1051
1176
  for role_name in data['roles']:
@@ -1057,33 +1182,33 @@ If you did not request a password reset, please ignore this email.
1057
1182
  VALUES (%s, %s)
1058
1183
  ON CONFLICT (user_id, role_id) DO NOTHING
1059
1184
  """, (user_id, role['id']))
1060
-
1185
+
1061
1186
  return jsonify({'id': user_id}), 201
1062
1187
 
1063
1188
  @bp.route('/admin/users/<user_id>', methods=['PUT'])
1064
1189
  def admin_update_user(user_id):
1065
1190
  self._require_admin_role()
1066
1191
  data = request.get_json()
1067
-
1192
+
1068
1193
  with self.db.get_cursor() as cur:
1069
1194
  # Check if user exists and get current username/email
1070
1195
  cur.execute("SELECT id, username, email FROM users WHERE id = %s", (user_id,))
1071
1196
  user = cur.fetchone()
1072
1197
  if not user:
1073
1198
  raise AuthError('User not found', 404)
1074
-
1199
+
1075
1200
  # Get username and email for password validation (use updated values if provided)
1076
1201
  username = data.get('username', user['username'])
1077
1202
  email = data.get('email', user['email'])
1078
-
1203
+
1079
1204
  # Validate password strength if password is being updated
1080
1205
  if 'password' in data:
1081
1206
  self._validate_password_strength(data['password'], username=username, email=email)
1082
-
1207
+
1083
1208
  # Update user fields
1084
1209
  update_fields = []
1085
1210
  update_values = []
1086
-
1211
+
1087
1212
  if 'username' in data:
1088
1213
  update_fields.append('username = %s')
1089
1214
  update_values.append(data['username'])
@@ -1098,23 +1223,23 @@ If you did not request a password reset, please ignore this email.
1098
1223
  password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1099
1224
  update_fields.append('password_hash = %s')
1100
1225
  update_values.append(password_hash.decode('utf-8'))
1101
-
1226
+
1102
1227
  if update_fields:
1103
1228
  update_fields.append('updated_at = %s')
1104
1229
  update_values.append(datetime.utcnow())
1105
1230
  update_values.append(user_id)
1106
-
1231
+
1107
1232
  cur.execute(f"""
1108
- UPDATE users
1233
+ UPDATE users
1109
1234
  SET {', '.join(update_fields)}
1110
1235
  WHERE id = %s
1111
1236
  """, update_values)
1112
-
1237
+
1113
1238
  # Update roles if provided
1114
1239
  if 'roles' in data:
1115
1240
  # Remove existing roles
1116
1241
  cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
1117
-
1242
+
1118
1243
  # Add new roles
1119
1244
  for role_name in data['roles']:
1120
1245
  cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
@@ -1124,22 +1249,22 @@ If you did not request a password reset, please ignore this email.
1124
1249
  INSERT INTO user_roles (user_id, role_id)
1125
1250
  VALUES (%s, %s)
1126
1251
  """, (user_id, role['id']))
1127
-
1252
+
1128
1253
  return jsonify({'success': True})
1129
1254
 
1130
1255
  @bp.route('/admin/users/<user_id>', methods=['DELETE'])
1131
1256
  def admin_delete_user(user_id):
1132
1257
  self._require_admin_role()
1133
-
1258
+
1134
1259
  with self.db.get_cursor() as cur:
1135
1260
  # Check if user exists
1136
1261
  cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
1137
1262
  if not cur.fetchone():
1138
1263
  raise AuthError('User not found', 404)
1139
-
1264
+
1140
1265
  # Delete user (cascade will handle related records)
1141
1266
  cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
1142
-
1267
+
1143
1268
  return jsonify({'success': True})
1144
1269
 
1145
1270
  @bp.route('/admin/roles', methods=['GET'])
@@ -1154,74 +1279,74 @@ If you did not request a password reset, please ignore this email.
1154
1279
  def admin_create_role():
1155
1280
  self._require_admin_role()
1156
1281
  data = request.get_json()
1157
-
1282
+
1158
1283
  if not data.get('name'):
1159
1284
  raise AuthError('Role name is required', 400)
1160
-
1285
+
1161
1286
  with self.db.get_cursor() as cur:
1162
1287
  # Check if role already exists
1163
1288
  cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
1164
1289
  if cur.fetchone():
1165
1290
  raise AuthError('Role already exists', 400)
1166
-
1291
+
1167
1292
  cur.execute("""
1168
1293
  INSERT INTO roles (name, description, created_at)
1169
1294
  VALUES (%s, %s, %s)
1170
1295
  RETURNING id
1171
1296
  """, (data['name'], data.get('description', ''), datetime.utcnow()))
1172
1297
  role_id = cur.fetchone()['id']
1173
-
1298
+
1174
1299
  return jsonify({'id': role_id}), 201
1175
1300
 
1176
1301
  @bp.route('/admin/roles/<role_id>', methods=['PUT'])
1177
1302
  def admin_update_role(role_id):
1178
1303
  self._require_admin_role()
1179
1304
  data = request.get_json()
1180
-
1305
+
1181
1306
  with self.db.get_cursor() as cur:
1182
1307
  # Check if role exists
1183
1308
  cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1184
1309
  if not cur.fetchone():
1185
1310
  raise AuthError('Role not found', 404)
1186
-
1311
+
1187
1312
  update_fields = []
1188
1313
  update_values = []
1189
-
1314
+
1190
1315
  if 'name' in data:
1191
1316
  update_fields.append('name = %s')
1192
1317
  update_values.append(data['name'])
1193
1318
  if 'description' in data:
1194
1319
  update_fields.append('description = %s')
1195
1320
  update_values.append(data['description'])
1196
-
1321
+
1197
1322
  if update_fields:
1198
1323
  update_values.append(role_id)
1199
1324
  cur.execute(f"""
1200
- UPDATE roles
1325
+ UPDATE roles
1201
1326
  SET {', '.join(update_fields)}
1202
1327
  WHERE id = %s
1203
1328
  """, update_values)
1204
-
1329
+
1205
1330
  return jsonify({'success': True})
1206
1331
 
1207
1332
  @bp.route('/admin/roles/<role_id>', methods=['DELETE'])
1208
1333
  def admin_delete_role(role_id):
1209
1334
  self._require_admin_role()
1210
-
1335
+
1211
1336
  with self.db.get_cursor() as cur:
1212
1337
  # Check if role exists
1213
1338
  cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1214
1339
  if not cur.fetchone():
1215
1340
  raise AuthError('Role not found', 404)
1216
-
1341
+
1217
1342
  # Check if role is assigned to any users
1218
1343
  cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
1219
1344
  count = cur.fetchone()['count']
1220
1345
  if count > 0:
1221
1346
  raise AuthError('Cannot delete role that is assigned to users', 400)
1222
-
1347
+
1223
1348
  cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
1224
-
1349
+
1225
1350
  return jsonify({'success': True})
1226
1351
 
1227
1352
  @bp.route('/admin/api-tokens', methods=['GET'])
@@ -1241,13 +1366,13 @@ If you did not request a password reset, please ignore this email.
1241
1366
  def admin_create_token():
1242
1367
  self._require_admin_role()
1243
1368
  data = request.get_json()
1244
-
1369
+
1245
1370
  if not data.get('user_id') or not data.get('name'):
1246
1371
  raise AuthError('user_id and name are required', 400)
1247
-
1372
+
1248
1373
  expires_in_days = data.get('expires_in_days')
1249
1374
  token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
1250
-
1375
+
1251
1376
  return jsonify({
1252
1377
  'id': token.id,
1253
1378
  'name': token.name,
@@ -1259,31 +1384,31 @@ If you did not request a password reset, please ignore this email.
1259
1384
  @bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
1260
1385
  def admin_delete_token(token_id):
1261
1386
  self._require_admin_role()
1262
-
1387
+
1263
1388
  with self.db.get_cursor() as cur:
1264
1389
  cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
1265
1390
  if cur.rowcount == 0:
1266
1391
  raise AuthError('Token not found', 404)
1267
-
1392
+
1268
1393
  return jsonify({'success': True})
1269
1394
 
1270
1395
  @bp.route('/admin/invite', methods=['POST'])
1271
1396
  def admin_send_invitation():
1272
1397
  self._require_admin_role()
1273
1398
  data = request.get_json()
1274
-
1399
+
1275
1400
  if not data.get('email'):
1276
1401
  raise AuthError('Email is required', 400)
1277
-
1402
+
1278
1403
  # Check if user already exists
1279
1404
  with self.db.get_cursor() as cur:
1280
1405
  cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
1281
1406
  if cur.fetchone():
1282
1407
  raise AuthError('User with this email already exists', 400)
1283
-
1408
+
1284
1409
  # Send invitation email (placeholder - implement actual email sending)
1285
1410
  invitation_token = str(uuid.uuid4())
1286
-
1411
+
1287
1412
  # Store invitation in database (you might want to create an invitations table)
1288
1413
  # For now, we'll just return success
1289
1414
  return jsonify({
@@ -1299,61 +1424,122 @@ If you did not request a password reset, please ignore this email.
1299
1424
  logger.debug(f"Validating token: {token}")
1300
1425
  payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
1301
1426
  logger.debug(f"Token payload: {payload}")
1427
+
1428
+ # Check if token has function-based resolution
1429
+ if 'f' in payload:
1430
+ func_name = payload['f']
1431
+ data = payload['data']
1432
+
1433
+ # Look up function from registered token resolvers
1434
+ if func_name not in self._token_resolvers:
1435
+ raise AuthError(f'Function "{func_name}" not found. Register it using register_token_resolver().', 401)
1436
+
1437
+ func = self._token_resolvers[func_name]
1438
+ if not callable(func):
1439
+ raise AuthError(f'"{func_name}" is not callable', 401)
1440
+
1441
+ # Call function with data
1442
+ try:
1443
+ result = func(data)
1444
+ except AuthError:
1445
+ raise
1446
+ except Exception as e:
1447
+ logger.error(f"Error calling function {func_name}: {str(e)}")
1448
+ raise AuthError(f'Error resolving user data: {str(e)}', 500)
1449
+
1450
+ # Validate function return format
1451
+ if not isinstance(result, dict):
1452
+ raise AuthError('Function must return a dict', 500)
1453
+ if 'user' not in result:
1454
+ raise AuthError('Function must return dict with "user" key', 500)
1455
+ if 'roles' not in result:
1456
+ raise AuthError('Function must return dict with "roles" key', 500)
1457
+
1458
+ # Ensure roles is a list of strings
1459
+ roles = result['roles']
1460
+ if not isinstance(roles, list):
1461
+ raise AuthError('roles must be a list', 500)
1462
+
1463
+ user = result['user'].copy()
1464
+ user['roles'] = roles
1465
+ return user
1466
+
1467
+ # Check if token has user/roles directly (new format without function)
1468
+ if 'user' in payload and 'roles' in payload:
1469
+ user = payload['user'].copy()
1470
+ roles = payload['roles']
1471
+
1472
+ # Normalize roles: if dicts, extract 'name' field
1473
+ if isinstance(roles, list) and len(roles) > 0 and isinstance(roles[0], dict):
1474
+ roles = [role['name'] for role in roles if isinstance(role, dict) and 'name' in role]
1475
+
1476
+ user['roles'] = roles
1477
+ return user
1478
+
1479
+ # Fall back to existing format with 'sub' (database lookup)
1480
+ if 'sub' not in payload:
1481
+ raise AuthError('Invalid token format', 401)
1482
+
1302
1483
  user_id = int(payload['sub']) # Convert string ID back to integer
1303
-
1484
+
1304
1485
  # Check cache first
1305
1486
  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
1315
- with self.db.get_cursor() as cur:
1316
- cur.execute("""
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
1321
- """, (user_id,))
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]
1332
- user['roles'] = roles
1333
1487
 
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
-
1340
- return user
1488
+ cached_data = self._user_cache.get(cache_key)
1489
+ if cached_data is not None:
1490
+ logger.debug(f"Returning cached user data for ID: {user_id}")
1491
+ return cached_data.copy() # Return a copy to avoid modifying cache
1492
+
1493
+ # Cache miss - get or create lock for this key
1494
+ with self._fetch_locks_lock:
1495
+ if cache_key not in self._fetch_locks:
1496
+ self._fetch_locks[cache_key] = threading.Lock()
1497
+ fetch_lock = self._fetch_locks[cache_key]
1498
+
1499
+ # Acquire lock to prevent concurrent fetches
1500
+ with fetch_lock:
1501
+ # Double-check cache after acquiring lock
1502
+ cached_data = self._user_cache.get(cache_key)
1503
+ if cached_data is not None:
1504
+ logger.debug(f"Returning cached user data for ID: {user_id} (after lock)")
1505
+ return cached_data.copy()
1506
+
1507
+ # Fetch from database
1508
+ if not self.db:
1509
+ raise AuthError('Database not configured for token validation', 500)
1510
+
1511
+ with self.db.get_cursor() as cur:
1512
+ cur.execute("""
1513
+ SELECT u.*, r.name as role_name FROM users u
1514
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
1515
+ LEFT JOIN roles r ON ur.role_id = r.id
1516
+ WHERE u.id = %s
1517
+ """, (user_id,))
1518
+ results = cur.fetchall()
1519
+ if not results:
1520
+ logger.error(f"User not found for ID: {user_id}")
1521
+ raise AuthError('User not found', 404)
1522
+
1523
+ # Get the first row for user data (all rows will have same user data)
1524
+ user = results[0]
1525
+
1526
+ # Extract roles from results
1527
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
1528
+ user['roles'] = roles
1529
+
1530
+ # Cache the result
1531
+ self._user_cache[cache_key] = user.copy()
1532
+
1533
+ return user
1341
1534
  except jwt.InvalidTokenError as e:
1342
1535
  logger.error(f"Invalid token error: {str(e)}")
1343
1536
  raise AuthError('Invalid token', 401)
1537
+ except AuthError:
1538
+ raise
1344
1539
  except Exception as e:
1345
1540
  logger.error(f"Unexpected error during token validation: {str(e)}")
1346
1541
  raise AuthError(str(e), 500)
1347
1542
 
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
1543
 
1358
1544
  def _start_update_thread(self):
1359
1545
  """Start the background thread for processing last_used_at updates."""
@@ -1374,21 +1560,21 @@ If you did not request a password reset, please ignore this email.
1374
1560
  try:
1375
1561
  current_time = time.time()
1376
1562
  tokens_to_update = []
1377
-
1563
+
1378
1564
  # Collect tokens that need updating (older than 10 seconds)
1379
1565
  with self._update_lock:
1380
1566
  for token_id, schedule_time in list(self._last_used_updates.items()):
1381
1567
  if current_time - schedule_time >= 10: # 10 second delay
1382
1568
  tokens_to_update.append(token_id)
1383
1569
  del self._last_used_updates[token_id]
1384
-
1570
+
1385
1571
  # Perform batch update
1386
1572
  if tokens_to_update:
1387
1573
  self._perform_batch_update(tokens_to_update)
1388
-
1574
+
1389
1575
  # Sleep for a short interval
1390
1576
  time.sleep(10)
1391
-
1577
+
1392
1578
  except Exception as e:
1393
1579
  logger.error(f"Error in update worker: {e}")
1394
1580
  time.sleep(5) # Wait longer on error
@@ -1400,13 +1586,13 @@ If you did not request a password reset, please ignore this email.
1400
1586
  # Update all tokens in a single query
1401
1587
  placeholders = ','.join(['%s'] * len(token_ids))
1402
1588
  cur.execute(f"""
1403
- UPDATE api_tokens
1589
+ UPDATE api_tokens
1404
1590
  SET last_used_at = %s
1405
1591
  WHERE id IN ({placeholders})
1406
1592
  """, [datetime.utcnow()] + token_ids)
1407
-
1593
+
1408
1594
  logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
1409
-
1595
+
1410
1596
  except Exception as e:
1411
1597
  logger.error(f"Error performing batch update: {e}")
1412
1598
 
@@ -1420,6 +1606,28 @@ If you did not request a password reset, please ignore this email.
1420
1606
  def get_current_user(self):
1421
1607
  return self._authenticate_request()
1422
1608
 
1609
+ def _expand_roles(self, roles):
1610
+ """Expand roles list to include all implied roles.
1611
+
1612
+ Args:
1613
+ roles: List of role names
1614
+
1615
+ Returns:
1616
+ Set of role names including all implied roles
1617
+ """
1618
+ expanded = set(roles)
1619
+ to_process = list(roles)
1620
+
1621
+ while to_process:
1622
+ role = to_process.pop()
1623
+ if role in self.role_implications:
1624
+ for implied_role in self.role_implications[role]:
1625
+ if implied_role not in expanded:
1626
+ expanded.add(implied_role)
1627
+ to_process.append(implied_role)
1628
+
1629
+ return expanded
1630
+
1423
1631
  def _require_admin_role(self):
1424
1632
  """Require the current user to have administrator role."""
1425
1633
  user = g.requesting_user
@@ -1431,7 +1639,7 @@ If you did not request a password reset, please ignore this email.
1431
1639
  with self.db.get_cursor() as cur:
1432
1640
  cur.execute("""
1433
1641
  SELECT id, name, created_at, expires_at, last_used_at
1434
- FROM api_tokens
1642
+ FROM api_tokens
1435
1643
  WHERE user_id = %s
1436
1644
  ORDER BY created_at DESC
1437
1645
  """, (user_id,))
@@ -1440,7 +1648,7 @@ If you did not request a password reset, please ignore this email.
1440
1648
  def create_api_token(self, user_id, name, expires_in_days=None):
1441
1649
  """Create a new API token for a user."""
1442
1650
  token = ApiToken(user_id, name, expires_in_days)
1443
-
1651
+
1444
1652
  with self.db.get_cursor() as cur:
1445
1653
  cur.execute("""
1446
1654
  INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
@@ -1448,6 +1656,84 @@ If you did not request a password reset, please ignore this email.
1448
1656
  """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
1449
1657
  return token
1450
1658
 
1659
+ def register_token_resolver(self, name: str, func):
1660
+ """Register a function to be used for token resolution.
1661
+
1662
+ Args:
1663
+ name: Function name (string identifier used in create_jwt_token)
1664
+ func: Callable function that takes a dict and returns dict with 'user' and 'roles'
1665
+
1666
+ Raises:
1667
+ ValueError: If func is not callable
1668
+ """
1669
+ if not callable(func):
1670
+ raise ValueError(f'Function must be callable')
1671
+ self._token_resolvers[name] = func
1672
+ logger.debug(f"Registered token resolver: {name}")
1673
+
1674
+ def create_jwt_token(self, user_input: dict, f: str = None) -> str:
1675
+ """Create a JWT token from user input dict.
1676
+
1677
+ Args:
1678
+ user_input: Dict containing user and roles data
1679
+ f: Optional function name to call during token validation
1680
+
1681
+ Returns:
1682
+ JWT token string
1683
+
1684
+ Raises:
1685
+ AuthError: If validation fails or required keys are missing
1686
+ """
1687
+ if f is None:
1688
+ # Validate user_input structure
1689
+ if 'user' not in user_input:
1690
+ raise AuthError('user_input must contain "user" key', 400)
1691
+ if 'roles' not in user_input:
1692
+ raise AuthError('user_input must contain "roles" key', 400)
1693
+
1694
+ user = user_input['user']
1695
+ roles = user_input['roles']
1696
+
1697
+ # Validate user dict
1698
+ if not isinstance(user, dict):
1699
+ raise AuthError('user must be a dict', 400)
1700
+ if 'id' not in user:
1701
+ raise AuthError('user must contain "id" key', 400)
1702
+ if 'username' not in user:
1703
+ raise AuthError('user must contain "username" key', 400)
1704
+
1705
+ # Validate roles list
1706
+ if not isinstance(roles, list):
1707
+ raise AuthError('roles must be a list', 400)
1708
+ for role in roles:
1709
+ if not isinstance(role, dict):
1710
+ raise AuthError('each role must be a dict', 400)
1711
+ if 'id' not in role:
1712
+ raise AuthError('each role must contain "id" key', 400)
1713
+ if 'name' not in role:
1714
+ raise AuthError('each role must contain "name" key', 400)
1715
+
1716
+ # Create JWT payload with user and roles
1717
+ payload = {
1718
+ 'user': user,
1719
+ 'roles': roles,
1720
+ 'exp': datetime.utcnow() + self.expiry_time,
1721
+ 'iat': datetime.utcnow()
1722
+ }
1723
+ else:
1724
+ # Store function name and user_input in payload
1725
+ payload = {
1726
+ 'f': f,
1727
+ 'data': user_input,
1728
+ 'exp': datetime.utcnow() + self.expiry_time,
1729
+ 'iat': datetime.utcnow()
1730
+ }
1731
+
1732
+ logger.debug(f"Creating JWT token with payload: {payload}")
1733
+ token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
1734
+ logger.info(f"Created JWT token")
1735
+ return token
1736
+
1451
1737
  def _create_token(self, user):
1452
1738
  payload = {
1453
1739
  'sub': str(user['id']),
@@ -1474,63 +1760,63 @@ If you did not request a password reset, please ignore this email.
1474
1760
  """Validate password strength and return error message listing all rules and failures."""
1475
1761
  rules = []
1476
1762
  failures = []
1477
-
1763
+
1478
1764
  # Rule 1: Minimum length
1479
1765
  min_length = 8
1480
1766
  rules.append(f"At least {min_length} characters long")
1481
1767
  if len(password) < min_length:
1482
1768
  failures.append(f"At least {min_length} characters long")
1483
-
1769
+
1484
1770
  # Rule 2: Maximum length
1485
1771
  max_length = 128
1486
1772
  rules.append(f"No more than {max_length} characters long")
1487
1773
  if len(password) > max_length:
1488
1774
  failures.append(f"No more than {max_length} characters long")
1489
-
1775
+
1490
1776
  # Rule 3: Uppercase letter
1491
1777
  rules.append("Contains at least one uppercase letter (A-Z)")
1492
1778
  if not re.search(r'[A-Z]', password):
1493
1779
  failures.append("Contains at least one uppercase letter (A-Z)")
1494
-
1780
+
1495
1781
  # Rule 4: Lowercase letter
1496
1782
  rules.append("Contains at least one lowercase letter (a-z)")
1497
1783
  if not re.search(r'[a-z]', password):
1498
1784
  failures.append("Contains at least one lowercase letter (a-z)")
1499
-
1785
+
1500
1786
  # Rule 5: Digit
1501
1787
  rules.append("Contains at least one number (0-9)")
1502
1788
  if not re.search(r'\d', password):
1503
1789
  failures.append("Contains at least one number (0-9)")
1504
-
1790
+
1505
1791
  # Rule 6: Special character
1506
1792
  rules.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1507
1793
  if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
1508
1794
  failures.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1509
-
1795
+
1510
1796
  # Rule 7: Not contain username
1511
1797
  if username:
1512
1798
  rules.append("Does not contain your username")
1513
1799
  if username.lower() in password.lower():
1514
1800
  failures.append("Does not contain your username")
1515
-
1801
+
1516
1802
  # Rule 8: Not contain email username
1517
1803
  if email:
1518
1804
  email_username = email.split('@')[0].lower()
1519
1805
  rules.append("Does not contain your email username")
1520
1806
  if email_username and email_username in password.lower():
1521
1807
  failures.append("Does not contain your email username")
1522
-
1808
+
1523
1809
  # Rule 9: Not a common password
1524
1810
  common_passwords = {'password', 'password123', '12345678', 'qwerty', 'abc123', 'letmein', 'welcome', 'monkey', '1234567890', 'password1'}
1525
1811
  rules.append("Is not a common password")
1526
1812
  if password.lower() in common_passwords:
1527
1813
  failures.append("Is not a common password")
1528
-
1814
+
1529
1815
  if failures:
1530
1816
  all_rules_text = "\n".join([f" {'✗' if rule in failures else '✓'} {rule}" for rule in rules])
1531
1817
  error_msg = f"Password does not meet the following requirements:\n\n{all_rules_text}\n\nPlease fix the issues marked with ✗."
1532
1818
  raise AuthError(error_msg, 400)
1533
-
1819
+
1534
1820
  return True
1535
1821
 
1536
1822
  def _get_oauth_url(self, provider, redirect_uri):
@@ -1563,13 +1849,13 @@ If you did not request a password reset, please ignore this email.
1563
1849
  if provider == 'microsoft':
1564
1850
  import msal
1565
1851
  client = msal.ConfidentialClientApplication(
1566
- client_id,
1567
- client_credential=client_secret,
1852
+ client_id,
1853
+ client_credential=client_secret,
1568
1854
  authority="https://login.microsoftonline.com/common"
1569
1855
  )
1570
1856
  tokens = client.acquire_token_by_authorization_code(
1571
- code,
1572
- scopes=["email"],
1857
+ code,
1858
+ scopes=["email"],
1573
1859
  redirect_uri=redirect_uri
1574
1860
  )
1575
1861
  else:
@@ -1644,15 +1930,15 @@ If you did not request a password reset, please ignore this email.
1644
1930
  INSERT INTO users (username, email, real_name, created_at, updated_at)
1645
1931
  VALUES (%s, %s, %s, %s, %s)
1646
1932
  RETURNING id
1647
- """, (user_obj.username, user_obj.email, user_obj.real_name,
1933
+ """, (user_obj.username, user_obj.email, user_obj.real_name,
1648
1934
  user_obj.created_at, user_obj.updated_at))
1649
1935
  new_id = cur.fetchone()['id']
1650
- user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1936
+ user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1651
1937
  'real_name': user_obj.real_name, 'roles': []}
1652
1938
  else:
1653
1939
  # Update existing user
1654
1940
  cur.execute("""
1655
- UPDATE users
1941
+ UPDATE users
1656
1942
  SET real_name = %s, updated_at = %s
1657
1943
  WHERE email = %s
1658
1944
  """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
@@ -1728,12 +2014,12 @@ If you did not request a password reset, please ignore this email.
1728
2014
  # Apple email may be private relay; name not always present
1729
2015
  return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1730
2016
  return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1731
-
2017
+
1732
2018
  def _send_email(self, to_email, subject, body):
1733
2019
  if not self.email_server or not self.email_username or not self.email_password:
1734
2020
  logger.error('Email configuration not set, cannot send email')
1735
2021
  raise AuthError('Email configuration not set. Cannot send validation email.', 500)
1736
-
2022
+
1737
2023
  try:
1738
2024
  msg = MIMEMultipart()
1739
2025
  msg['From'] = self.email_address
@@ -1741,7 +2027,7 @@ If you did not request a password reset, please ignore this email.
1741
2027
  msg['Reply-To'] = self.email_reply_to
1742
2028
  msg['Subject'] = subject
1743
2029
  msg.attach(MIMEText(body, 'plain'))
1744
-
2030
+
1745
2031
  server = smtplib.SMTP(self.email_server, self.email_port)
1746
2032
  server.starttls()
1747
2033
  server.login(self.email_username, self.email_password)
@@ -1753,7 +2039,7 @@ If you did not request a password reset, please ignore this email.
1753
2039
  except Exception as e:
1754
2040
  logger.error(f'Failed to send email to {to_email}: {e}')
1755
2041
  raise AuthError(f'Failed to send validation email: {str(e)}', 500)
1756
-
2042
+
1757
2043
  def _get_frontend_url(self):
1758
2044
  frontend_url = os.getenv('FRONTEND_URL')
1759
2045
  if not frontend_url:
@@ -1762,3 +2048,57 @@ If you did not request a password reset, please ignore this email.
1762
2048
  parsed_uri = urlparse(redirect_uri)
1763
2049
  frontend_url = urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', ''))
1764
2050
  return frontend_url
2051
+
2052
+ def get_version(self):
2053
+ """Get the package version and git version information.
2054
+
2055
+ Returns:
2056
+ dict: Dictionary with 'package_version', 'git_commit', 'git_branch', 'git_dirty',
2057
+ 'version_tags', 'latest_version_tag', and 'database_name' keys.
2058
+ Git-related keys may be None if the version file is not found.
2059
+ """
2060
+ package_version = None
2061
+ try:
2062
+ import importlib.metadata
2063
+ package_version = importlib.metadata.version('the37lab_authlib')
2064
+ except Exception:
2065
+ try:
2066
+ p = Path(__file__).parent.parent.parent / 'pyproject.toml'
2067
+ if p.exists():
2068
+ for line in open(p, 'r', encoding='utf-8'):
2069
+ if line.strip().startswith('version = '):
2070
+ package_version = line.split('"')[1] if '"' in line else None
2071
+ break
2072
+ except Exception:
2073
+ pass
2074
+
2075
+ git_data = {}
2076
+ try:
2077
+ with open(Path(__file__).parent / '_git_version.txt', 'r', encoding='utf-8') as f:
2078
+ git_data = json.loads(f.read().strip())
2079
+ except Exception:
2080
+ pass
2081
+
2082
+ database_name = None
2083
+ if self.db and self.db.dsn:
2084
+ try:
2085
+ dsn = self.db.dsn
2086
+ if '://' in dsn:
2087
+ database_name = dsn.split('/')[-1].split('?')[0]
2088
+ else:
2089
+ for part in dsn.split():
2090
+ if part.startswith('dbname='):
2091
+ database_name = part.split('=')[1]
2092
+ break
2093
+ except Exception:
2094
+ pass
2095
+
2096
+ return {
2097
+ 'package_version': package_version,
2098
+ 'git_commit': git_data.get('commit'),
2099
+ 'git_branch': git_data.get('branch'),
2100
+ 'git_dirty': git_data.get('dirty'),
2101
+ 'version_tags': git_data.get('version_tags', []),
2102
+ 'latest_version_tag': git_data.get('latest_version_tag'),
2103
+ 'database_name': database_name
2104
+ }