the37lab-authlib 0.1.1751371611__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
@@ -10,18 +10,47 @@ import requests
10
10
  import bcrypt
11
11
  import logging
12
12
  import os
13
+ import re
13
14
  from functools import wraps
15
+ from isodate import parse_duration
16
+ import threading
17
+ import time
18
+ import msal
19
+ import smtplib
20
+ from email.mime.text import MIMEText
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
14
27
 
15
28
  logging.basicConfig(level=logging.DEBUG)
16
29
  logger = logging.getLogger(__name__)
17
30
 
18
31
  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):
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):
20
33
  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')
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
38
+ self._last_used_updates = {} # Track pending updates
39
+ self._update_lock = threading.Lock()
40
+ self._update_thread = None
41
+ self._shutdown_event = threading.Event()
42
+ self._token_resolvers = {} # Registered functions for token resolution
43
+ self.role_implications = role_implications or {}
44
+
45
+ # Determine prefix: empty if environment_prefix is None/empty, otherwise use it with '_' delimiter
46
+ prefix = (environment_prefix.upper() + '_') if environment_prefix else ''
47
+
48
+ # Arguments have priority over environment variables
49
+ db_dsn = db_dsn or os.getenv(f'{prefix}DATABASE_URL')
50
+ jwt_secret = jwt_secret or os.getenv(f'{prefix}JWT_SECRET')
51
+
52
+ # OAuth config: use argument if provided, otherwise build from env vars
53
+ if oauth_config is None:
25
54
  google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
26
55
  google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
27
56
  oauth_config = {}
@@ -30,6 +59,19 @@ class AuthManager:
30
59
  'client_id': google_client_id,
31
60
  'client_secret': google_client_secret
32
61
  }
62
+
63
+ # OAuth auto-create: use argument if provided, otherwise check env var (defaults to False)
64
+ if allow_oauth_auto_create is not None:
65
+ self.allow_oauth_auto_create = allow_oauth_auto_create
66
+ else:
67
+ auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
68
+ if auto_create_env is not None:
69
+ self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
70
+ else:
71
+ self.allow_oauth_auto_create = False
72
+
73
+ # API tokens: use argument if provided, otherwise parse from env var
74
+ if api_tokens is None:
33
75
  api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
34
76
  if api_tokens_env:
35
77
  api_tokens = {}
@@ -37,9 +79,21 @@ class AuthManager:
37
79
  if ':' in entry:
38
80
  key, user = entry.split(':', 1)
39
81
  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
82
+
83
+ # User override: use argument if provided, otherwise check env var
84
+ user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
85
+ if user_override_env:
86
+ self.user_override = user_override_env
87
+
88
+ # Email configuration: arguments have priority
89
+ email_username = email_username or os.getenv(f'{prefix}EMAIL_USERNAME')
90
+ email_password = email_password or os.getenv(f'{prefix}EMAIL_PASSWORD')
91
+ email_address = email_address or os.getenv(f'{prefix}EMAIL_ADDRESS')
92
+ email_reply_to = email_reply_to or os.getenv(f'{prefix}EMAIL_REPLY_TO')
93
+ email_server = email_server or os.getenv(f'{prefix}EMAIL_SERVER')
94
+ email_port = email_port or os.getenv(f'{prefix}EMAIL_PORT')
95
+
96
+ self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
43
97
  if self.user_override and (api_tokens or db_dsn):
44
98
  raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
45
99
  if api_tokens and db_dsn:
@@ -48,20 +102,93 @@ class AuthManager:
48
102
  self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
49
103
  self.jwt_secret = jwt_secret
50
104
  self.oauth_config = oauth_config or {}
105
+
106
+ # Email configuration
107
+ self.email_username = email_username
108
+ self.email_password = email_password
109
+ self.email_address = email_address or email_username
110
+ if email_reply_to:
111
+ self.email_reply_to = email_reply_to
112
+ elif email_username:
113
+ domain = email_username.split('@')[1] if '@' in email_username else 'localhost'
114
+ self.email_reply_to = f'noreply@{domain}'
115
+ else:
116
+ self.email_reply_to = None
117
+ self.email_server = email_server
118
+ self.email_port = int(email_port) if email_port else 587
119
+
51
120
  self.public_endpoints = {
52
121
  'auth.login',
53
122
  'auth.oauth_login',
54
123
  'auth.oauth_callback',
55
124
  'auth.refresh_token',
56
125
  'auth.register',
57
- 'auth.get_roles'
126
+ 'auth.get_roles',
127
+ 'auth.validate_registration',
128
+ 'auth.resend_validation'
58
129
  }
59
130
  self.bp = None
60
-
131
+ if self.db:
132
+ self._ensure_admin_role()
133
+
61
134
  if app:
62
135
  self.init_app(app)
63
136
 
137
+ # Start the background update thread
138
+ self._start_update_thread()
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
+
64
190
  def _extract_token_from_header(self):
191
+ #print('request.headers', request.headers, 'authorization', request.authorization, 'request', request)
65
192
  auth = request.authorization
66
193
  if not auth or not auth.token:
67
194
  raise AuthError('No authorization header or token', 401)
@@ -91,52 +218,83 @@ class AuthManager:
91
218
  }
92
219
  try:
93
220
  parsed = ApiToken.parse_token(api_token)
94
- with self.db.get_cursor() as cur:
95
- # First get the API token record
96
- cur.execute("""
97
- SELECT t.*, u.* FROM api_tokens t
98
- JOIN users u ON t.user_id = u.id
99
- WHERE t.id = %s
100
- """, (parsed['id'],))
101
- result = cur.fetchone()
102
- if not result:
103
- raise AuthError('Invalid API token')
104
-
105
- # Verify the nonce
106
- if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
107
- raise AuthError('Invalid API token')
108
-
109
- # Check if token is expired
110
- if result['expires_at'] and result['expires_at'] < datetime.utcnow():
111
- raise AuthError('API token has expired')
112
221
 
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']))
119
-
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()]
127
-
128
- # Construct user object
129
- return {
130
- 'id': result['user_id'],
131
- 'username': result['username'],
132
- 'email': result['email'],
133
- 'real_name': result['real_name'],
134
- 'roles': roles
135
- }
222
+ # Check cache first
223
+ cache_key = f"api_token_{parsed['id']}"
224
+
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
136
288
  except ValueError:
137
289
  raise AuthError('Invalid token format')
138
290
 
139
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):
140
298
  if self.user_override:
141
299
  return {
142
300
  'id': self.user_override,
@@ -185,7 +343,7 @@ class AuthManager:
185
343
  endpoint = f"{self.bp.name}.{f.__name__}"
186
344
  self.add_public_endpoint(endpoint)
187
345
  return f
188
-
346
+
189
347
  def init_app(self, app):
190
348
  app.auth_manager = self
191
349
  app.register_blueprint(self.create_blueprint())
@@ -218,17 +376,17 @@ class AuthManager:
218
376
  data = request.get_json()
219
377
  username = data.get('username')
220
378
  password = data.get('password')
221
-
379
+
222
380
  if not username or not password:
223
381
  raise AuthError('Username and password required', 400)
224
-
382
+
225
383
  with self.db.get_cursor() as cur:
226
384
  cur.execute("SELECT * FROM users WHERE username = %s", (username,))
227
385
  user = cur.fetchone()
228
-
386
+
229
387
  if not user or not self._verify_password(password, user['password_hash']):
230
388
  raise AuthError('Invalid username or password', 401)
231
-
389
+
232
390
  # Fetch roles
233
391
  cur.execute("""
234
392
  SELECT r.name FROM roles r
@@ -237,10 +395,14 @@ class AuthManager:
237
395
  """, (user['id'],))
238
396
  roles = [row['name'] for row in cur.fetchall()]
239
397
  user['roles'] = roles
240
-
398
+
399
+ # Check if user is validated
400
+ if 'validated' not in roles:
401
+ raise AuthError('Account not yet validated. Please check your email for the validation link.', 403)
402
+
241
403
  token = self._create_token(user)
242
404
  refresh_token = self._create_refresh_token(user)
243
-
405
+
244
406
  return jsonify({
245
407
  'token': token,
246
408
  'refresh_token': refresh_token,
@@ -251,6 +413,8 @@ class AuthManager:
251
413
  def oauth_login():
252
414
  provider = request.json.get('provider')
253
415
  if provider not in self.oauth_config:
416
+ logger.error(f"Invalid OAuth provider: {provider}")
417
+ logger.error(f"These are the known ones: {self.oauth_config.keys()}")
254
418
  raise AuthError('Invalid OAuth provider', 400)
255
419
 
256
420
  redirect_uri = self.get_redirect_uri()
@@ -262,17 +426,35 @@ class AuthManager:
262
426
  def oauth_callback():
263
427
  code = request.args.get('code')
264
428
  provider = request.args.get('state')
265
-
429
+
266
430
  if not code or not provider:
267
431
  raise AuthError('Invalid OAuth callback', 400)
432
+ from urllib.parse import urlencode, urlparse, urlunparse
433
+ get_redirect_uri = self.get_redirect_uri()
434
+ parsed_uri = urlparse(get_redirect_uri)
435
+ frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
436
+
437
+ #if provider == 'microsoft':
438
+ # client = msal.ConfidentialClientApplication(
439
+ # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
440
+ # )
441
+ # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
442
+ # code = result['access_token']
268
443
 
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)
272
-
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}")
444
+ try:
445
+ user_info = self._get_oauth_user_info(provider, code)
446
+ token = self._create_token(user_info)
447
+ refresh_token = self._create_refresh_token(user_info)
448
+ # Redirect to frontend with tokens
449
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
450
+ except AuthError as e:
451
+ # Surface error to frontend for user-friendly messaging
452
+ params = {
453
+ 'error': str(e.message) if hasattr(e, 'message') else str(e),
454
+ 'status': getattr(e, 'status_code', 500),
455
+ 'provider': provider,
456
+ }
457
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
276
458
 
277
459
  @bp.route('/login/profile')
278
460
  def profile():
@@ -308,7 +490,7 @@ class AuthManager:
308
490
  try:
309
491
  payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
310
492
  user_id = payload['sub']
311
-
493
+
312
494
  with self.db.get_cursor() as cur:
313
495
  cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
314
496
  user = cur.fetchone()
@@ -341,7 +523,7 @@ class AuthManager:
341
523
 
342
524
  with self.db.get_cursor() as cur:
343
525
  cur.execute("""
344
- SELECT * FROM api_tokens
526
+ SELECT * FROM api_tokens
345
527
  WHERE user_id = %s AND id = %s
346
528
  """, (g.requesting_user['id'], token))
347
529
  api_token = cur.fetchone()
@@ -356,7 +538,7 @@ class AuthManager:
356
538
  # Update last used timestamp
357
539
  with self.db.get_cursor() as cur:
358
540
  cur.execute("""
359
- UPDATE api_tokens
541
+ UPDATE api_tokens
360
542
  SET last_used_at = %s
361
543
  WHERE id = %s
362
544
  """, (datetime.utcnow(), api_token['id']))
@@ -372,7 +554,7 @@ class AuthManager:
372
554
 
373
555
  with self.db.get_cursor() as cur:
374
556
  cur.execute("""
375
- DELETE FROM api_tokens
557
+ DELETE FROM api_tokens
376
558
  WHERE user_id = %s AND id = %s
377
559
  RETURNING id
378
560
  """, (g.requesting_user['id'], token))
@@ -385,40 +567,551 @@ class AuthManager:
385
567
  @bp.route('/register', methods=['POST'])
386
568
  def register():
387
569
  data = request.get_json()
388
-
389
- # Hash the password
570
+
390
571
  password = data.get('password')
391
572
  if not password:
392
573
  raise AuthError('Password is required', 400)
393
-
574
+
575
+ username = data.get('username')
576
+ email = data.get('email')
577
+
578
+ if not username:
579
+ raise AuthError('Username is required', 400)
580
+ if not email:
581
+ raise AuthError('Email is required', 400)
582
+
583
+ # Validate password strength
584
+ self._validate_password_strength(password, username=username, email=email)
585
+
586
+ # Hash the password
394
587
  salt = bcrypt.gensalt()
395
588
  password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
396
-
589
+
397
590
  user = User(
398
- username=data['username'],
399
- email=data['email'],
591
+ username=username,
592
+ email=email,
400
593
  real_name=data['real_name'],
401
594
  roles=data.get('roles', []),
402
595
  id_generator=self.db.get_id_generator()
403
596
  )
404
597
 
405
598
  with self.db.get_cursor() as cur:
406
- if user.id is None:
599
+ # Check if username or email already exists
600
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s", (username, email))
601
+ existing_user = cur.fetchone()
602
+
603
+ if existing_user:
604
+ user_id = existing_user['id']
605
+
606
+ # Check if user is validated
407
607
  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:
608
+ SELECT r.name FROM roles r
609
+ JOIN user_roles ur ON ur.role_id = r.id
610
+ WHERE ur.user_id = %s AND r.name = 'validated'
611
+ """, (user_id,))
612
+ if cur.fetchone():
613
+ # User is validated, reject registration
614
+ raise AuthError('Username or email already exists', 400)
615
+
616
+ # User exists but not validated - allow re-registration
617
+ # This works even if the previous registration hasn't expired yet
618
+ # Update existing user with new registration data
415
619
  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))
620
+ UPDATE users
621
+ SET username = %s, email = %s, real_name = %s, password_hash = %s, updated_at = %s
622
+ WHERE id = %s
623
+ """, (username, email, user.real_name, password_hash.decode('utf-8'), datetime.utcnow(), user_id))
624
+
625
+ # Remove all existing register-* roles (including non-expired ones)
626
+ cur.execute("""
627
+ DELETE FROM user_roles
628
+ WHERE user_id = %s
629
+ AND role_id IN (
630
+ SELECT id FROM roles WHERE name LIKE 'register-%'
631
+ )
632
+ """, (user_id,))
633
+
634
+ user.id = user_id
635
+ else:
636
+ # New user - create it
637
+ if user.id is None:
638
+ cur.execute("""
639
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
640
+ VALUES (%s, %s, %s, %s, %s, %s)
641
+ RETURNING id
642
+ """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
643
+ user.created_at, user.updated_at))
644
+ user.id = cur.fetchone()['id']
645
+ else:
646
+ cur.execute("""
647
+ INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
648
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
649
+ """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
650
+ user.created_at, user.updated_at))
651
+
652
+ # Generate nonce and timestamp for validation
653
+ nonce = str(uuid.uuid4())
654
+ timestamp = int(time.time())
655
+ role_name = f'register-{nonce}-{timestamp}'
656
+
657
+ # Create temporary validation role
658
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
659
+ role = cur.fetchone()
660
+ if not role:
661
+ role_obj = Role(role_name, description='Temporary registration validation role', id_generator=self.db.get_id_generator())
662
+ if role_obj.id is None:
663
+ cur.execute("""
664
+ INSERT INTO roles (name, description, created_at)
665
+ VALUES (%s, %s, %s)
666
+ RETURNING id
667
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
668
+ role_id = cur.fetchone()['id']
669
+ else:
670
+ cur.execute("""
671
+ INSERT INTO roles (id, name, description, created_at)
672
+ VALUES (%s, %s, %s, %s)
673
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
674
+ role_id = role_obj.id
675
+ else:
676
+ role_id = role['id']
677
+
678
+ # Associate role with user
679
+ cur.execute("""
680
+ INSERT INTO user_roles (user_id, role_id)
681
+ VALUES (%s, %s)
682
+ ON CONFLICT (user_id, role_id) DO NOTHING
683
+ """, (user.id, role_id))
684
+
685
+ # Send validation email
686
+ frontend_url = self._get_frontend_url()
687
+ validation_link = f"{frontend_url}/register/{nonce}"
688
+ email_subject = "Please validate your account"
689
+ email_body = f"""Hello {user.real_name},
690
+
691
+ Thank you for registering. Please click the link below to validate your account:
692
+
693
+ {validation_link}
694
+
695
+ This link will expire in 24 hours.
696
+
697
+ If you did not register for this account, please ignore this email.
698
+ """
699
+ self._send_email(user.email, email_subject, email_body)
700
+
701
+ return jsonify({'id': user.id, 'message': 'Registration successful. Please check your email for validation link.'}), 201
702
+
703
+ @bp.route('/register/<nonce>', methods=['GET'])
704
+ @bp.public_endpoint
705
+ def validate_registration(nonce):
706
+ with self.db.get_cursor() as cur:
707
+ # Find user with register-{nonce}-{timestamp} role
708
+ cur.execute("""
709
+ SELECT u.id, u.username, u.email, r.name as role_name
710
+ FROM users u
711
+ JOIN user_roles ur ON ur.user_id = u.id
712
+ JOIN roles r ON ur.role_id = r.id
713
+ WHERE r.name LIKE %s
714
+ """, (f'register-{nonce}-%',))
715
+ results = cur.fetchall()
716
+
717
+ if not results:
718
+ raise AuthError('Invalid or expired validation link', 400)
719
+
720
+ # Check if expired (24 hours)
721
+ current_time = int(time.time())
722
+ user_id = None
723
+ expired = True
724
+
725
+ for row in results:
726
+ role_name = row['role_name']
727
+ if role_name.startswith(f'register-{nonce}-'):
728
+ try:
729
+ timestamp = int(role_name.split('-')[-1])
730
+ if current_time - timestamp < 86400: # 24 hours
731
+ expired = False
732
+ user_id = row['id']
733
+ break
734
+ except (ValueError, IndexError):
735
+ continue
736
+
737
+ if expired or not user_id:
738
+ raise AuthError('Validation link has expired. Please request a new validation email.', 400)
739
+
740
+ # Remove all register-* roles from user
741
+ cur.execute("""
742
+ DELETE FROM user_roles
743
+ WHERE user_id = %s
744
+ AND role_id IN (
745
+ SELECT id FROM roles WHERE name LIKE 'register-%%'
746
+ )
747
+ """, (user_id,))
748
+
749
+ # Ensure validated role exists
750
+ cur.execute("SELECT id FROM roles WHERE name = 'validated'")
751
+ validated_role = cur.fetchone()
752
+ if not validated_role:
753
+ role_obj = Role('validated', description='User has validated their email', 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
+ validated_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
+ validated_role_id = role_obj.id
767
+ else:
768
+ validated_role_id = validated_role['id']
769
+
770
+ # Add validated role to 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, validated_role_id))
776
+
777
+ return jsonify({'message': 'Account validated successfully. You can now log in.'})
778
+
779
+ @bp.route('/resend-validation', methods=['POST'])
780
+ @bp.public_endpoint
781
+ def resend_validation():
782
+ data = request.get_json()
783
+ email = data.get('email')
784
+ username = data.get('username')
785
+
786
+ if not email and not username:
787
+ raise AuthError('Email or username is required', 400)
788
+
789
+ with self.db.get_cursor() as cur:
790
+ # Find user by email or username
791
+ if email:
792
+ cur.execute("SELECT * FROM users WHERE email = %s", (email,))
793
+ else:
794
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
795
+ user = cur.fetchone()
796
+
797
+ if not user:
798
+ # Don't reveal if user exists
799
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
800
+
801
+ # Check if user is already validated
802
+ cur.execute("""
803
+ SELECT r.name FROM roles r
804
+ JOIN user_roles ur ON ur.role_id = r.id
805
+ WHERE ur.user_id = %s AND r.name = 'validated'
806
+ """, (user['id'],))
807
+ if cur.fetchone():
808
+ # User is already validated, don't reveal this
809
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
810
+
811
+ # Remove existing register-* roles
812
+ cur.execute("""
813
+ DELETE FROM user_roles
814
+ WHERE user_id = %s
815
+ AND role_id IN (
816
+ SELECT id FROM roles WHERE name LIKE 'register-%%'
817
+ )
818
+ """, (user['id'],))
819
+
820
+ # Generate new nonce and timestamp
821
+ nonce = str(uuid.uuid4())
822
+ timestamp = int(time.time())
823
+ role_name = f'register-{nonce}-{timestamp}'
824
+
825
+ # Create new validation role
826
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
827
+ role = cur.fetchone()
828
+ if not role:
829
+ role_obj = Role(role_name, description='Temporary registration validation role', id_generator=self.db.get_id_generator())
830
+ if role_obj.id is None:
831
+ cur.execute("""
832
+ INSERT INTO roles (name, description, created_at)
833
+ VALUES (%s, %s, %s)
834
+ RETURNING id
835
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
836
+ role_id = cur.fetchone()['id']
837
+ else:
838
+ cur.execute("""
839
+ INSERT INTO roles (id, name, description, created_at)
840
+ VALUES (%s, %s, %s, %s)
841
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
842
+ role_id = role_obj.id
843
+ else:
844
+ role_id = role['id']
845
+
846
+ # Associate role with user
847
+ cur.execute("""
848
+ INSERT INTO user_roles (user_id, role_id)
849
+ VALUES (%s, %s)
850
+ ON CONFLICT (user_id, role_id) DO NOTHING
851
+ """, (user['id'], role_id))
852
+
853
+ # Send validation email
854
+ frontend_url = self._get_frontend_url()
855
+ validation_link = f"{frontend_url}/register/{nonce}"
856
+ email_subject = "Please validate your account"
857
+ email_body = f"""Hello {user['real_name']},
858
+
859
+ Please click the link below to validate your account:
860
+
861
+ {validation_link}
862
+
863
+ This link will expire in 24 hours.
864
+
865
+ If you did not request this email, please ignore it.
866
+ """
867
+ self._send_email(user['email'], email_subject, email_body)
868
+
869
+ return jsonify({'message': 'If an account exists, a validation email has been sent.'})
870
+
871
+ @bp.route('/request-password-reset', methods=['POST'])
872
+ @bp.public_endpoint
873
+ def request_password_reset():
874
+ data = request.get_json()
875
+ username = data.get('username')
876
+
877
+ if not username:
878
+ raise AuthError('Username is required', 400)
879
+
880
+ with self.db.get_cursor() as cur:
881
+ # Find user by username
882
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
883
+ user = cur.fetchone()
884
+
885
+ if not user:
886
+ # Don't reveal if user exists
887
+ return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
888
+
889
+ # Remove existing password-reset-* roles
890
+ cur.execute("""
891
+ DELETE FROM user_roles
892
+ WHERE user_id = %s
893
+ AND role_id IN (
894
+ SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
895
+ )
896
+ """, (user['id'],))
897
+
898
+ # Generate new nonce and timestamp
899
+ nonce = str(uuid.uuid4())
900
+ timestamp = int(time.time())
901
+ role_name = f'password-reset-{nonce}-{timestamp}'
902
+
903
+ # Create new password reset role
904
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
905
+ role = cur.fetchone()
906
+ if not role:
907
+ role_obj = Role(role_name, description='Temporary password reset role', id_generator=self.db.get_id_generator())
908
+ if role_obj.id is None:
909
+ cur.execute("""
910
+ INSERT INTO roles (name, description, created_at)
911
+ VALUES (%s, %s, %s)
912
+ RETURNING id
913
+ """, (role_obj.name, role_obj.description, role_obj.created_at))
914
+ role_id = cur.fetchone()['id']
915
+ else:
916
+ cur.execute("""
917
+ INSERT INTO roles (id, name, description, created_at)
918
+ VALUES (%s, %s, %s, %s)
919
+ """, (role_obj.id, role_obj.name, role_obj.description, role_obj.created_at))
920
+ role_id = role_obj.id
921
+ else:
922
+ role_id = role['id']
923
+
924
+ # Associate role with user
925
+ cur.execute("""
926
+ INSERT INTO user_roles (user_id, role_id)
927
+ VALUES (%s, %s)
928
+ ON CONFLICT (user_id, role_id) DO NOTHING
929
+ """, (user['id'], role_id))
930
+
931
+ # Send password reset email
932
+ frontend_url = self._get_frontend_url()
933
+ reset_link = f"{frontend_url}/password-reset/{nonce}"
934
+ email_subject = "Password Reset Request"
935
+ email_body = f"""Hello {user['real_name']},
936
+
937
+ You requested to reset your password. Please click the link below to reset your password:
938
+
939
+ {reset_link}
940
+
941
+ This link will expire in 24 hours.
942
+
943
+ If you did not request a password reset, please ignore this email.
944
+ """
945
+ self._send_email(user['email'], email_subject, email_body)
946
+
947
+ return jsonify({'message': 'If an account exists, a password reset email has been sent.'})
948
+
949
+ @bp.route('/password-reset/<nonce>', methods=['GET'])
950
+ @bp.public_endpoint
951
+ def validate_password_reset(nonce):
952
+ with self.db.get_cursor() as cur:
953
+ # Find user with password-reset-{nonce}-{timestamp} role
954
+ cur.execute("""
955
+ SELECT u.id, u.username, u.email, r.name as role_name
956
+ FROM users u
957
+ JOIN user_roles ur ON ur.user_id = u.id
958
+ JOIN roles r ON ur.role_id = r.id
959
+ WHERE r.name LIKE %s
960
+ """, (f'password-reset-{nonce}-%',))
961
+ results = cur.fetchall()
962
+
963
+ if not results:
964
+ raise AuthError('Invalid or expired password reset link', 400)
965
+
966
+ # Check if expired (24 hours)
967
+ current_time = int(time.time())
968
+ user_id = None
969
+ expired = True
970
+
971
+ for row in results:
972
+ role_name = row['role_name']
973
+ if role_name.startswith(f'password-reset-{nonce}-'):
974
+ try:
975
+ timestamp = int(role_name.split('-')[-1])
976
+ if current_time - timestamp < 86400: # 24 hours
977
+ expired = False
978
+ user_id = row['id']
979
+ break
980
+ except (ValueError, IndexError):
981
+ continue
982
+
983
+ if expired or not user_id:
984
+ raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
985
+
986
+ # Return user info (username only for security)
987
+ cur.execute("SELECT username FROM users WHERE id = %s", (user_id,))
988
+ user = cur.fetchone()
989
+
990
+ return jsonify({'username': user['username'], 'message': 'Password reset link is valid.'})
991
+
992
+ @bp.route('/password-reset/<nonce>', methods=['POST'])
993
+ @bp.public_endpoint
994
+ def reset_password(nonce):
995
+ data = request.get_json()
996
+ password = data.get('password')
997
+ confirm_password = data.get('confirmPassword')
998
+
999
+ if not password:
1000
+ raise AuthError('Password is required', 400)
1001
+ if password != confirm_password:
1002
+ raise AuthError('Passwords do not match', 400)
1003
+
1004
+ with self.db.get_cursor() as cur:
1005
+ # Find user with password-reset-{nonce}-{timestamp} role
1006
+ cur.execute("""
1007
+ SELECT u.id, u.username, u.email, r.name as role_name
1008
+ FROM users u
1009
+ JOIN user_roles ur ON ur.user_id = u.id
1010
+ JOIN roles r ON ur.role_id = r.id
1011
+ WHERE r.name LIKE %s
1012
+ """, (f'password-reset-{nonce}-%',))
1013
+ results = cur.fetchall()
1014
+
1015
+ if not results:
1016
+ raise AuthError('Invalid or expired password reset link', 400)
1017
+
1018
+ # Check if expired (24 hours)
1019
+ current_time = int(time.time())
1020
+ user_id = None
1021
+ username = None
1022
+ email = None
1023
+ expired = True
1024
+
1025
+ for row in results:
1026
+ role_name = row['role_name']
1027
+ if role_name.startswith(f'password-reset-{nonce}-'):
1028
+ try:
1029
+ timestamp = int(role_name.split('-')[-1])
1030
+ if current_time - timestamp < 86400: # 24 hours
1031
+ expired = False
1032
+ user_id = row['id']
1033
+ username = row['username']
1034
+ email = row['email']
1035
+ break
1036
+ except (ValueError, IndexError):
1037
+ continue
1038
+
1039
+ if expired or not user_id:
1040
+ raise AuthError('Password reset link has expired. Please request a new password reset email.', 400)
1041
+
1042
+ # Validate password strength
1043
+ self._validate_password_strength(password, username=username, email=email)
1044
+
1045
+ # Hash new password
1046
+ salt = bcrypt.gensalt()
1047
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
1048
+
1049
+ # Update user's password
1050
+ cur.execute("""
1051
+ UPDATE users
1052
+ SET password_hash = %s, updated_at = %s
1053
+ WHERE id = %s
1054
+ """, (password_hash.decode('utf-8'), datetime.utcnow(), user_id))
1055
+
1056
+ # Remove all password-reset-* roles from user
1057
+ cur.execute("""
1058
+ DELETE FROM user_roles
1059
+ WHERE user_id = %s
1060
+ AND role_id IN (
1061
+ SELECT id FROM roles WHERE name LIKE 'password-reset-%%'
1062
+ )
1063
+ """, (user_id,))
1064
+
1065
+ return jsonify({'message': 'Password has been reset successfully. You can now log in with your new password.'})
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']))
420
1113
 
421
- return jsonify({'id': user.id}), 201
1114
+ return jsonify({'message': 'Password has been changed successfully.'})
422
1115
 
423
1116
  @bp.route('/roles', methods=['GET'])
424
1117
  def get_roles():
@@ -427,6 +1120,303 @@ class AuthManager:
427
1120
  roles = cur.fetchall()
428
1121
  return jsonify(roles)
429
1122
 
1123
+ # Admin endpoints - require administrator role
1124
+ @bp.route('/admin/users', methods=['GET'])
1125
+ def admin_get_users():
1126
+ self._require_admin_role()
1127
+ with self.db.get_cursor() as cur:
1128
+ cur.execute("""
1129
+ SELECT u.*,
1130
+ COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
1131
+ FROM users u
1132
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
1133
+ LEFT JOIN roles r ON ur.role_id = r.id
1134
+ GROUP BY u.id, u.username, u.email, u.real_name, u.created_at, u.updated_at
1135
+ ORDER BY u.created_at DESC
1136
+ """)
1137
+ users = cur.fetchall()
1138
+ return jsonify(users)
1139
+
1140
+ @bp.route('/admin/users', methods=['POST'])
1141
+ def admin_create_user():
1142
+ self._require_admin_role()
1143
+ data = request.get_json()
1144
+
1145
+ # Validate required fields
1146
+ required_fields = ['username', 'email', 'real_name', 'password']
1147
+ for field in required_fields:
1148
+ if not data.get(field):
1149
+ raise AuthError(f'{field} is required', 400)
1150
+
1151
+ # Validate password strength
1152
+ self._validate_password_strength(data['password'], username=data['username'], email=data['email'])
1153
+
1154
+ # Hash the password
1155
+ salt = bcrypt.gensalt()
1156
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1157
+
1158
+ with self.db.get_cursor() as cur:
1159
+ # Check if username or email already exists
1160
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
1161
+ (data['username'], data['email']))
1162
+ if cur.fetchone():
1163
+ raise AuthError('Username or email already exists', 400)
1164
+
1165
+ # Create user
1166
+ cur.execute("""
1167
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
1168
+ VALUES (%s, %s, %s, %s, %s, %s)
1169
+ RETURNING id
1170
+ """, (data['username'], data['email'], data['real_name'],
1171
+ password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
1172
+ user_id = cur.fetchone()['id']
1173
+
1174
+ # Assign roles if provided
1175
+ if data.get('roles'):
1176
+ for role_name in data['roles']:
1177
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
1178
+ role = cur.fetchone()
1179
+ if role:
1180
+ cur.execute("""
1181
+ INSERT INTO user_roles (user_id, role_id)
1182
+ VALUES (%s, %s)
1183
+ ON CONFLICT (user_id, role_id) DO NOTHING
1184
+ """, (user_id, role['id']))
1185
+
1186
+ return jsonify({'id': user_id}), 201
1187
+
1188
+ @bp.route('/admin/users/<user_id>', methods=['PUT'])
1189
+ def admin_update_user(user_id):
1190
+ self._require_admin_role()
1191
+ data = request.get_json()
1192
+
1193
+ with self.db.get_cursor() as cur:
1194
+ # Check if user exists and get current username/email
1195
+ cur.execute("SELECT id, username, email FROM users WHERE id = %s", (user_id,))
1196
+ user = cur.fetchone()
1197
+ if not user:
1198
+ raise AuthError('User not found', 404)
1199
+
1200
+ # Get username and email for password validation (use updated values if provided)
1201
+ username = data.get('username', user['username'])
1202
+ email = data.get('email', user['email'])
1203
+
1204
+ # Validate password strength if password is being updated
1205
+ if 'password' in data:
1206
+ self._validate_password_strength(data['password'], username=username, email=email)
1207
+
1208
+ # Update user fields
1209
+ update_fields = []
1210
+ update_values = []
1211
+
1212
+ if 'username' in data:
1213
+ update_fields.append('username = %s')
1214
+ update_values.append(data['username'])
1215
+ if 'email' in data:
1216
+ update_fields.append('email = %s')
1217
+ update_values.append(data['email'])
1218
+ if 'real_name' in data:
1219
+ update_fields.append('real_name = %s')
1220
+ update_values.append(data['real_name'])
1221
+ if 'password' in data:
1222
+ salt = bcrypt.gensalt()
1223
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
1224
+ update_fields.append('password_hash = %s')
1225
+ update_values.append(password_hash.decode('utf-8'))
1226
+
1227
+ if update_fields:
1228
+ update_fields.append('updated_at = %s')
1229
+ update_values.append(datetime.utcnow())
1230
+ update_values.append(user_id)
1231
+
1232
+ cur.execute(f"""
1233
+ UPDATE users
1234
+ SET {', '.join(update_fields)}
1235
+ WHERE id = %s
1236
+ """, update_values)
1237
+
1238
+ # Update roles if provided
1239
+ if 'roles' in data:
1240
+ # Remove existing roles
1241
+ cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
1242
+
1243
+ # Add new roles
1244
+ for role_name in data['roles']:
1245
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
1246
+ role = cur.fetchone()
1247
+ if role:
1248
+ cur.execute("""
1249
+ INSERT INTO user_roles (user_id, role_id)
1250
+ VALUES (%s, %s)
1251
+ """, (user_id, role['id']))
1252
+
1253
+ return jsonify({'success': True})
1254
+
1255
+ @bp.route('/admin/users/<user_id>', methods=['DELETE'])
1256
+ def admin_delete_user(user_id):
1257
+ self._require_admin_role()
1258
+
1259
+ with self.db.get_cursor() as cur:
1260
+ # Check if user exists
1261
+ cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
1262
+ if not cur.fetchone():
1263
+ raise AuthError('User not found', 404)
1264
+
1265
+ # Delete user (cascade will handle related records)
1266
+ cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
1267
+
1268
+ return jsonify({'success': True})
1269
+
1270
+ @bp.route('/admin/roles', methods=['GET'])
1271
+ def admin_get_roles():
1272
+ self._require_admin_role()
1273
+ with self.db.get_cursor() as cur:
1274
+ cur.execute("SELECT * FROM roles ORDER BY name")
1275
+ roles = cur.fetchall()
1276
+ return jsonify(roles)
1277
+
1278
+ @bp.route('/admin/roles', methods=['POST'])
1279
+ def admin_create_role():
1280
+ self._require_admin_role()
1281
+ data = request.get_json()
1282
+
1283
+ if not data.get('name'):
1284
+ raise AuthError('Role name is required', 400)
1285
+
1286
+ with self.db.get_cursor() as cur:
1287
+ # Check if role already exists
1288
+ cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
1289
+ if cur.fetchone():
1290
+ raise AuthError('Role already exists', 400)
1291
+
1292
+ cur.execute("""
1293
+ INSERT INTO roles (name, description, created_at)
1294
+ VALUES (%s, %s, %s)
1295
+ RETURNING id
1296
+ """, (data['name'], data.get('description', ''), datetime.utcnow()))
1297
+ role_id = cur.fetchone()['id']
1298
+
1299
+ return jsonify({'id': role_id}), 201
1300
+
1301
+ @bp.route('/admin/roles/<role_id>', methods=['PUT'])
1302
+ def admin_update_role(role_id):
1303
+ self._require_admin_role()
1304
+ data = request.get_json()
1305
+
1306
+ with self.db.get_cursor() as cur:
1307
+ # Check if role exists
1308
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1309
+ if not cur.fetchone():
1310
+ raise AuthError('Role not found', 404)
1311
+
1312
+ update_fields = []
1313
+ update_values = []
1314
+
1315
+ if 'name' in data:
1316
+ update_fields.append('name = %s')
1317
+ update_values.append(data['name'])
1318
+ if 'description' in data:
1319
+ update_fields.append('description = %s')
1320
+ update_values.append(data['description'])
1321
+
1322
+ if update_fields:
1323
+ update_values.append(role_id)
1324
+ cur.execute(f"""
1325
+ UPDATE roles
1326
+ SET {', '.join(update_fields)}
1327
+ WHERE id = %s
1328
+ """, update_values)
1329
+
1330
+ return jsonify({'success': True})
1331
+
1332
+ @bp.route('/admin/roles/<role_id>', methods=['DELETE'])
1333
+ def admin_delete_role(role_id):
1334
+ self._require_admin_role()
1335
+
1336
+ with self.db.get_cursor() as cur:
1337
+ # Check if role exists
1338
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
1339
+ if not cur.fetchone():
1340
+ raise AuthError('Role not found', 404)
1341
+
1342
+ # Check if role is assigned to any users
1343
+ cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
1344
+ count = cur.fetchone()['count']
1345
+ if count > 0:
1346
+ raise AuthError('Cannot delete role that is assigned to users', 400)
1347
+
1348
+ cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
1349
+
1350
+ return jsonify({'success': True})
1351
+
1352
+ @bp.route('/admin/api-tokens', methods=['GET'])
1353
+ def admin_get_all_tokens():
1354
+ self._require_admin_role()
1355
+ with self.db.get_cursor() as cur:
1356
+ cur.execute("""
1357
+ SELECT t.*, u.username, u.email
1358
+ FROM api_tokens t
1359
+ JOIN users u ON t.user_id = u.id
1360
+ ORDER BY t.created_at DESC
1361
+ """)
1362
+ tokens = cur.fetchall()
1363
+ return jsonify(tokens)
1364
+
1365
+ @bp.route('/admin/api-tokens', methods=['POST'])
1366
+ def admin_create_token():
1367
+ self._require_admin_role()
1368
+ data = request.get_json()
1369
+
1370
+ if not data.get('user_id') or not data.get('name'):
1371
+ raise AuthError('user_id and name are required', 400)
1372
+
1373
+ expires_in_days = data.get('expires_in_days')
1374
+ token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
1375
+
1376
+ return jsonify({
1377
+ 'id': token.id,
1378
+ 'name': token.name,
1379
+ 'token': token.get_full_token(),
1380
+ 'created_at': token.created_at,
1381
+ 'expires_at': token.expires_at
1382
+ }), 201
1383
+
1384
+ @bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
1385
+ def admin_delete_token(token_id):
1386
+ self._require_admin_role()
1387
+
1388
+ with self.db.get_cursor() as cur:
1389
+ cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
1390
+ if cur.rowcount == 0:
1391
+ raise AuthError('Token not found', 404)
1392
+
1393
+ return jsonify({'success': True})
1394
+
1395
+ @bp.route('/admin/invite', methods=['POST'])
1396
+ def admin_send_invitation():
1397
+ self._require_admin_role()
1398
+ data = request.get_json()
1399
+
1400
+ if not data.get('email'):
1401
+ raise AuthError('Email is required', 400)
1402
+
1403
+ # Check if user already exists
1404
+ with self.db.get_cursor() as cur:
1405
+ cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
1406
+ if cur.fetchone():
1407
+ raise AuthError('User with this email already exists', 400)
1408
+
1409
+ # Send invitation email (placeholder - implement actual email sending)
1410
+ invitation_token = str(uuid.uuid4())
1411
+
1412
+ # Store invitation in database (you might want to create an invitations table)
1413
+ # For now, we'll just return success
1414
+ return jsonify({
1415
+ 'success': True,
1416
+ 'message': f'Invitation sent to {data["email"]}',
1417
+ 'invitation_token': invitation_token
1418
+ })
1419
+
430
1420
  return bp
431
1421
 
432
1422
  def validate_token(self, token):
@@ -434,40 +1424,222 @@ class AuthManager:
434
1424
  logger.debug(f"Validating token: {token}")
435
1425
  payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
436
1426
  logger.debug(f"Token payload: {payload}")
437
- user_id = int(payload['sub']) # Convert string ID back to integer
438
-
439
- 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
- 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
450
- """, (user_id,))
451
- roles = [row['name'] for row in cur.fetchall()]
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
+
452
1476
  user['roles'] = roles
1477
+ return user
453
1478
 
454
- return user
1479
+ # Fall back to existing format with 'sub' (database lookup)
1480
+ if 'sub' not in payload:
1481
+ raise AuthError('Invalid token format', 401)
1482
+
1483
+ user_id = int(payload['sub']) # Convert string ID back to integer
1484
+
1485
+ # Check cache first
1486
+ cache_key = f"user_{user_id}"
1487
+
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
455
1534
  except jwt.InvalidTokenError as e:
456
1535
  logger.error(f"Invalid token error: {str(e)}")
457
1536
  raise AuthError('Invalid token', 401)
1537
+ except AuthError:
1538
+ raise
458
1539
  except Exception as e:
459
1540
  logger.error(f"Unexpected error during token validation: {str(e)}")
460
1541
  raise AuthError(str(e), 500)
461
1542
 
1543
+
1544
+ def _start_update_thread(self):
1545
+ """Start the background thread for processing last_used_at updates."""
1546
+ if self._update_thread is None or not self._update_thread.is_alive():
1547
+ self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
1548
+ self._update_thread.start()
1549
+ logger.debug("Started background update thread")
1550
+
1551
+ def _schedule_last_used_update(self, token_id):
1552
+ """Schedule a last_used_at update for an API token with 10s delay."""
1553
+ with self._update_lock:
1554
+ self._last_used_updates[token_id] = time.time()
1555
+ logger.debug(f"Scheduled last_used update for token {token_id}")
1556
+
1557
+ def _update_worker(self):
1558
+ """Background worker that processes last_used_at updates."""
1559
+ while not self._shutdown_event.is_set():
1560
+ try:
1561
+ current_time = time.time()
1562
+ tokens_to_update = []
1563
+
1564
+ # Collect tokens that need updating (older than 10 seconds)
1565
+ with self._update_lock:
1566
+ for token_id, schedule_time in list(self._last_used_updates.items()):
1567
+ if current_time - schedule_time >= 10: # 10 second delay
1568
+ tokens_to_update.append(token_id)
1569
+ del self._last_used_updates[token_id]
1570
+
1571
+ # Perform batch update
1572
+ if tokens_to_update:
1573
+ self._perform_batch_update(tokens_to_update)
1574
+
1575
+ # Sleep for a short interval
1576
+ time.sleep(10)
1577
+
1578
+ except Exception as e:
1579
+ logger.error(f"Error in update worker: {e}")
1580
+ time.sleep(5) # Wait longer on error
1581
+
1582
+ def _perform_batch_update(self, token_ids):
1583
+ """Perform batch update of last_used_at for multiple tokens."""
1584
+ try:
1585
+ with self.db.get_cursor() as cur:
1586
+ # Update all tokens in a single query
1587
+ placeholders = ','.join(['%s'] * len(token_ids))
1588
+ cur.execute(f"""
1589
+ UPDATE api_tokens
1590
+ SET last_used_at = %s
1591
+ WHERE id IN ({placeholders})
1592
+ """, [datetime.utcnow()] + token_ids)
1593
+
1594
+ logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
1595
+
1596
+ except Exception as e:
1597
+ logger.error(f"Error performing batch update: {e}")
1598
+
1599
+ def shutdown(self):
1600
+ """Shutdown the background update thread."""
1601
+ self._shutdown_event.set()
1602
+ if self._update_thread and self._update_thread.is_alive():
1603
+ self._update_thread.join(timeout=5)
1604
+ logger.debug("Background update thread shutdown complete")
1605
+
462
1606
  def get_current_user(self):
463
1607
  return self._authenticate_request()
464
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
+
1631
+ def _require_admin_role(self):
1632
+ """Require the current user to have administrator role."""
1633
+ user = g.requesting_user
1634
+ if not user or 'administrator' not in user.get('roles', []):
1635
+ raise AuthError('Administrator role required', 403)
1636
+
465
1637
  def get_user_api_tokens(self, user_id):
466
1638
  """Get all API tokens for a user."""
467
1639
  with self.db.get_cursor() as cur:
468
1640
  cur.execute("""
469
1641
  SELECT id, name, created_at, expires_at, last_used_at
470
- FROM api_tokens
1642
+ FROM api_tokens
471
1643
  WHERE user_id = %s
472
1644
  ORDER BY created_at DESC
473
1645
  """, (user_id,))
@@ -476,7 +1648,7 @@ class AuthManager:
476
1648
  def create_api_token(self, user_id, name, expires_in_days=None):
477
1649
  """Create a new API token for a user."""
478
1650
  token = ApiToken(user_id, name, expires_in_days)
479
-
1651
+
480
1652
  with self.db.get_cursor() as cur:
481
1653
  cur.execute("""
482
1654
  INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
@@ -484,15 +1656,93 @@ class AuthManager:
484
1656
  """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
485
1657
  return token
486
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
+
487
1737
  def _create_token(self, user):
488
1738
  payload = {
489
1739
  'sub': str(user['id']),
490
- 'exp': datetime.utcnow() + timedelta(hours=1),
1740
+ 'exp': datetime.utcnow() + self.expiry_time,
491
1741
  'iat': datetime.utcnow()
492
1742
  }
493
1743
  logger.debug(f"Creating token with payload: {payload}")
494
1744
  token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
495
- logger.debug(f"Created token: {token}")
1745
+ logger.info(f"Created token: {token}")
496
1746
  return token
497
1747
 
498
1748
  def _create_refresh_token(self, user):
@@ -506,73 +1756,349 @@ class AuthManager:
506
1756
  def _verify_password(self, password, password_hash):
507
1757
  return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
508
1758
 
1759
+ def _validate_password_strength(self, password, username=None, email=None):
1760
+ """Validate password strength and return error message listing all rules and failures."""
1761
+ rules = []
1762
+ failures = []
1763
+
1764
+ # Rule 1: Minimum length
1765
+ min_length = 8
1766
+ rules.append(f"At least {min_length} characters long")
1767
+ if len(password) < min_length:
1768
+ failures.append(f"At least {min_length} characters long")
1769
+
1770
+ # Rule 2: Maximum length
1771
+ max_length = 128
1772
+ rules.append(f"No more than {max_length} characters long")
1773
+ if len(password) > max_length:
1774
+ failures.append(f"No more than {max_length} characters long")
1775
+
1776
+ # Rule 3: Uppercase letter
1777
+ rules.append("Contains at least one uppercase letter (A-Z)")
1778
+ if not re.search(r'[A-Z]', password):
1779
+ failures.append("Contains at least one uppercase letter (A-Z)")
1780
+
1781
+ # Rule 4: Lowercase letter
1782
+ rules.append("Contains at least one lowercase letter (a-z)")
1783
+ if not re.search(r'[a-z]', password):
1784
+ failures.append("Contains at least one lowercase letter (a-z)")
1785
+
1786
+ # Rule 5: Digit
1787
+ rules.append("Contains at least one number (0-9)")
1788
+ if not re.search(r'\d', password):
1789
+ failures.append("Contains at least one number (0-9)")
1790
+
1791
+ # Rule 6: Special character
1792
+ rules.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1793
+ if not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
1794
+ failures.append("Contains at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)")
1795
+
1796
+ # Rule 7: Not contain username
1797
+ if username:
1798
+ rules.append("Does not contain your username")
1799
+ if username.lower() in password.lower():
1800
+ failures.append("Does not contain your username")
1801
+
1802
+ # Rule 8: Not contain email username
1803
+ if email:
1804
+ email_username = email.split('@')[0].lower()
1805
+ rules.append("Does not contain your email username")
1806
+ if email_username and email_username in password.lower():
1807
+ failures.append("Does not contain your email username")
1808
+
1809
+ # Rule 9: Not a common password
1810
+ common_passwords = {'password', 'password123', '12345678', 'qwerty', 'abc123', 'letmein', 'welcome', 'monkey', '1234567890', 'password1'}
1811
+ rules.append("Is not a common password")
1812
+ if password.lower() in common_passwords:
1813
+ failures.append("Is not a common password")
1814
+
1815
+ if failures:
1816
+ all_rules_text = "\n".join([f" {'✗' if rule in failures else '✓'} {rule}" for rule in rules])
1817
+ error_msg = f"Password does not meet the following requirements:\n\n{all_rules_text}\n\nPlease fix the issues marked with ✗."
1818
+ raise AuthError(error_msg, 400)
1819
+
1820
+ return True
1821
+
509
1822
  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')
1823
+ meta = self._get_provider_meta(provider)
1824
+ client_id = self.oauth_config[provider]['client_id']
1825
+ scope = self.oauth_config[provider].get('scope', meta['default_scope'])
1826
+ state = provider # Pass provider as state for callback
1827
+ # Some providers require additional params
1828
+ params = {
1829
+ 'client_id': client_id,
1830
+ 'redirect_uri': redirect_uri,
1831
+ 'response_type': 'code',
1832
+ 'scope': scope,
1833
+ 'state': state
1834
+ }
1835
+ # Facebook requires display; GitHub supports prompt
1836
+ if provider == 'facebook':
1837
+ params['display'] = 'page'
1838
+ # Build URL
1839
+ from urllib.parse import urlencode
1840
+ return f"{meta['auth_url']}?{urlencode(params)}"
516
1841
 
517
1842
  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'
1843
+ meta = self._get_provider_meta(provider)
1844
+ client_id = self.oauth_config[provider]['client_id']
1845
+ client_secret = self.oauth_config[provider]['client_secret']
1846
+ redirect_uri = self.get_redirect_uri()
1847
+
1848
+
1849
+ if provider == 'microsoft':
1850
+ import msal
1851
+ client = msal.ConfidentialClientApplication(
1852
+ client_id,
1853
+ client_credential=client_secret,
1854
+ authority="https://login.microsoftonline.com/common"
1855
+ )
1856
+ tokens = client.acquire_token_by_authorization_code(
1857
+ code,
1858
+ scopes=["email"],
1859
+ redirect_uri=redirect_uri
1860
+ )
1861
+ else:
1862
+ # Standard OAuth flow for other providers
525
1863
  token_data = {
526
1864
  'client_id': client_id,
527
1865
  'client_secret': client_secret,
528
1866
  'code': code,
529
1867
  'grant_type': 'authorization_code',
530
- 'redirect_uri': redirect_uri
1868
+ 'redirect_uri': redirect_uri,
1869
+ 'scope': meta['default_scope']
531
1870
  }
532
- token_response = requests.post(token_url, data=token_data)
1871
+ token_headers = {}
1872
+ if provider == 'github':
1873
+ token_headers['Accept'] = 'application/json'
1874
+ token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
533
1875
  logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
534
1876
  token_response.raise_for_status()
535
1877
  tokens = token_response.json()
536
1878
 
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
1879
 
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()
1880
+ access_token = tokens.get('access_token') or tokens.get('id_token')
1881
+ if not access_token:
1882
+ # Some providers return id_token separately but require access_token for userinfo
1883
+ access_token = tokens.get('access_token')
550
1884
 
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': []}
1885
+ # Build userinfo request
1886
+ userinfo_url = meta['userinfo_url']
1887
+ userinfo_headers = {'Authorization': f"Bearer {access_token}"}
1888
+ if provider == 'facebook':
1889
+ # Ensure fields
1890
+ from urllib.parse import urlencode
1891
+ userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
1892
+
1893
+ userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
1894
+ userinfo_response.raise_for_status()
1895
+ raw_userinfo = userinfo_response.json()
1896
+
1897
+ # Special handling for GitHub missing email
1898
+ if provider == 'github' and not raw_userinfo.get('email'):
1899
+ emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
1900
+ if emails_resp.ok:
1901
+ emails = emails_resp.json()
1902
+ primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
1903
+ raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
1904
+
1905
+
1906
+
1907
+
1908
+ # Normalize
1909
+ norm = self._normalize_userinfo(provider, raw_userinfo)
1910
+ if not norm.get('email'):
1911
+ # Fallback pseudo-email if allowed
1912
+ norm['email'] = f"{norm['sub']}@{provider}.local"
1913
+
1914
+ # Create or update user
1915
+ with self.db.get_cursor() as cur:
1916
+ cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
1917
+ user = cur.fetchone()
1918
+
1919
+ if not user:
1920
+ if not self.allow_oauth_auto_create:
1921
+ raise AuthError('User not found and auto-create disabled', 403)
1922
+ # Create new user (auto-create enabled)
1923
+ user_obj = User(
1924
+ username=norm['email'],
1925
+ email=norm['email'],
1926
+ real_name=norm.get('name', norm['email']),
1927
+ id_generator=self.db.get_id_generator()
1928
+ )
1929
+ cur.execute("""
1930
+ INSERT INTO users (username, email, real_name, created_at, updated_at)
1931
+ VALUES (%s, %s, %s, %s, %s)
1932
+ RETURNING id
1933
+ """, (user_obj.username, user_obj.email, user_obj.real_name,
1934
+ user_obj.created_at, user_obj.updated_at))
1935
+ new_id = cur.fetchone()['id']
1936
+ user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1937
+ 'real_name': user_obj.real_name, 'roles': []}
1938
+ else:
1939
+ # Update existing user
1940
+ cur.execute("""
1941
+ UPDATE users
1942
+ SET real_name = %s, updated_at = %s
1943
+ WHERE email = %s
1944
+ """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
1945
+ user['real_name'] = norm.get('name', norm['email'])
1946
+
1947
+ return user
1948
+
1949
+ def _get_provider_meta(self, provider):
1950
+ providers = {
1951
+ 'google': {
1952
+ 'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
1953
+ 'token_url': 'https://oauth2.googleapis.com/token',
1954
+ 'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
1955
+ 'default_scope': 'openid email profile'
1956
+ },
1957
+ 'github': {
1958
+ 'auth_url': 'https://github.com/login/oauth/authorize',
1959
+ 'token_url': 'https://github.com/login/oauth/access_token',
1960
+ 'userinfo_url': 'https://api.github.com/user',
1961
+ 'default_scope': 'read:user user:email'
1962
+ },
1963
+ 'facebook': {
1964
+ 'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
1965
+ 'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
1966
+ 'userinfo_url': 'https://graph.facebook.com/me',
1967
+ 'default_scope': 'email public_profile'
1968
+ },
1969
+ 'microsoft': {
1970
+ 'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
1971
+ 'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
1972
+ 'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
1973
+ 'default_scope': 'openid email profile'
1974
+ },
1975
+ 'linkedin': {
1976
+ 'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
1977
+ 'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
1978
+ 'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
1979
+ 'default_scope': 'openid profile email'
1980
+ },
1981
+ 'slack': {
1982
+ 'auth_url': 'https://slack.com/openid/connect/authorize',
1983
+ 'token_url': 'https://slack.com/api/openid.connect.token',
1984
+ 'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
1985
+ 'default_scope': 'openid profile email'
1986
+ },
1987
+ 'apple': {
1988
+ 'auth_url': 'https://appleid.apple.com/auth/authorize',
1989
+ 'token_url': 'https://appleid.apple.com/auth/token',
1990
+ 'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
1991
+ 'default_scope': 'name email'
1992
+ }
1993
+ }
1994
+ if provider not in providers:
1995
+ raise AuthError('Invalid OAuth provider ' + provider)
1996
+ return providers[provider]
1997
+
1998
+ def _normalize_userinfo(self, provider, info):
1999
+ # Map into a common structure: sub, email, name
2000
+ if provider == 'google':
2001
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
2002
+ if provider == 'github':
2003
+ return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
2004
+ if provider == 'facebook':
2005
+ return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
2006
+ if provider == 'microsoft':
2007
+ # OIDC userinfo
2008
+ return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
2009
+ if provider == 'linkedin':
2010
+ return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
2011
+ if provider == 'slack':
2012
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
2013
+ if provider == 'apple':
2014
+ # Apple email may be private relay; name not always present
2015
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
2016
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
2017
+
2018
+ def _send_email(self, to_email, subject, body):
2019
+ if not self.email_server or not self.email_username or not self.email_password:
2020
+ logger.error('Email configuration not set, cannot send email')
2021
+ raise AuthError('Email configuration not set. Cannot send validation email.', 500)
2022
+
2023
+ try:
2024
+ msg = MIMEMultipart()
2025
+ msg['From'] = self.email_address
2026
+ msg['To'] = to_email
2027
+ msg['Reply-To'] = self.email_reply_to
2028
+ msg['Subject'] = subject
2029
+ msg.attach(MIMEText(body, 'plain'))
2030
+
2031
+ server = smtplib.SMTP(self.email_server, self.email_port)
2032
+ server.starttls()
2033
+ server.login(self.email_username, self.email_password)
2034
+ server.send_message(msg)
2035
+ server.quit()
2036
+ logger.info(f'Validation email sent to {to_email}')
2037
+ except AuthError:
2038
+ raise
2039
+ except Exception as e:
2040
+ logger.error(f'Failed to send email to {to_email}: {e}')
2041
+ raise AuthError(f'Failed to send validation email: {str(e)}', 500)
2042
+
2043
+ def _get_frontend_url(self):
2044
+ frontend_url = os.getenv('FRONTEND_URL')
2045
+ if not frontend_url:
2046
+ from urllib.parse import urlparse, urlunparse
2047
+ redirect_uri = self.get_redirect_uri()
2048
+ parsed_uri = urlparse(redirect_uri)
2049
+ frontend_url = urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', ''))
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]
568
2088
  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'])
576
-
577
- return user
578
- raise AuthError('Invalid OAuth provider')
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
+ }