the37lab-authlib 0.1.1758263039__py3-none-any.whl → 0.1.1758264497__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.

Potentially problematic release.


This version of the37lab-authlib might be problematic. Click here for more details.

the37lab_authlib/auth.py CHANGED
@@ -1,1148 +1,1148 @@
1
- import inspect
2
- import inspect
3
- from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
4
- import jwt
5
- from datetime import datetime, timedelta
6
- from .db import Database
7
- from .models import User, Role, ApiToken
8
- from .exceptions import AuthError
9
- import uuid
10
- import requests
11
- import bcrypt
12
- import logging
13
- import os
14
- from functools import wraps
15
- from isodate import parse_duration
16
- import threading
17
- import time
18
- import msal
19
-
20
- logging.basicConfig(level=logging.DEBUG)
21
- logger = logging.getLogger(__name__)
22
-
23
- class AuthManager:
24
- 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=False):
25
- self.user_override = None
26
- self._user_cache = {}
27
- self._cache_ttl = cache_ttl or 10 # 10 seconds
28
- self._last_used_updates = {} # Track pending updates
29
- self._update_lock = threading.Lock()
30
- self._update_thread = None
31
- self._shutdown_event = threading.Event()
32
- # OAuth user creation policy (can be controlled by env)
33
- self.allow_oauth_auto_create = allow_oauth_auto_create
34
-
35
- if environment_prefix:
36
- prefix = environment_prefix.upper() + '_'
37
- db_dsn = os.getenv(f'{prefix}DATABASE_URL')
38
- jwt_secret = os.getenv(f'{prefix}JWT_SECRET')
39
- google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
40
- google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
41
- oauth_config = {}
42
- if google_client_id and google_client_secret:
43
- oauth_config['google'] = {
44
- 'client_id': google_client_id,
45
- 'client_secret': google_client_secret
46
- }
47
- # Allow control via prefixed env var (defaults to True)
48
- auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
49
- if auto_create_env is not None:
50
- self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
51
- api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
52
- if api_tokens_env:
53
- api_tokens = {}
54
- for entry in api_tokens_env.split(','):
55
- if ':' in entry:
56
- key, user = entry.split(':', 1)
57
- api_tokens[key.strip()] = user.strip()
58
- user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
59
- if user_override_env:
60
- self.user_override = user_override_env
61
- else:
62
- prefix = ''
63
-
64
- self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
65
- if self.user_override and (api_tokens or db_dsn):
66
- raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
67
- if api_tokens and db_dsn:
68
- raise ValueError('Cannot set both api_tokens and db_dsn')
69
- self.api_tokens = api_tokens or None
70
- self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
71
- self.jwt_secret = jwt_secret
72
- self.oauth_config = oauth_config or {}
73
- self.public_endpoints = {
74
- 'auth.login',
75
- 'auth.oauth_login',
76
- 'auth.oauth_callback',
77
- 'auth.refresh_token',
78
- 'auth.register',
79
- 'auth.get_roles'
80
- }
81
- self.bp = None
82
-
83
- if app:
84
- self.init_app(app)
85
-
86
- # Start the background update thread
87
- self._start_update_thread()
88
-
89
- def _extract_token_from_header(self):
90
- auth = request.authorization
91
- if not auth or not auth.token:
92
- raise AuthError('No authorization header or token', 401)
93
-
94
- if auth.type.lower() != 'bearer':
95
- raise AuthError('Invalid authorization scheme', 401)
96
-
97
- return auth.token
98
-
99
- def get_redirect_uri(self):
100
- redirect_uri = os.getenv('REDIRECT_URL') or url_for('auth.oauth_callback', _external=True).replace("http://", "https://")
101
- logger.info(f"REDIRECT URI..: {redirect_uri}")
102
- return redirect_uri
103
-
104
- def _validate_api_token(self, api_token):
105
- if self.api_tokens is not None:
106
- username = self.api_tokens.get(api_token)
107
- if not username:
108
- raise AuthError('Invalid API token')
109
- # Return a minimal user dict
110
- return {
111
- 'id': username,
112
- 'username': username,
113
- 'email': '',
114
- 'real_name': username,
115
- 'roles': []
116
- }
117
- try:
118
- parsed = ApiToken.parse_token(api_token)
119
-
120
- # Check cache first
121
- cache_key = f"api_token_{parsed['id']}"
122
- current_time = datetime.utcnow()
123
-
124
- if cache_key in self._user_cache:
125
- cached_data, cache_time = self._user_cache[cache_key]
126
- if (current_time - cache_time).total_seconds() < self._cache_ttl:
127
- logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
128
- return cached_data.copy() # Return a copy to avoid modifying cache
129
-
130
- # Cache miss or expired, fetch from database
131
- with self.db.get_cursor() as cur:
132
- # First get the API token record
133
- cur.execute("""
134
- SELECT t.*, u.*, r.name as role_name FROM api_tokens t
135
- JOIN users u ON t.user_id = u.id
136
- LEFT JOIN user_roles ur ON ur.user_id = u.id
137
- LEFT JOIN roles r ON ur.role_id = r.id
138
- WHERE t.id = %s
139
- """, (parsed['id'],))
140
- results = cur.fetchall()
141
- if not results:
142
- raise AuthError('Invalid API token')
143
-
144
- # Get the first row for token/user data (all rows will have same token/user data)
145
- result = results[0]
146
-
147
- # Verify the nonce
148
- if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
149
- raise AuthError('Invalid API token')
150
-
151
- # Check if token is expired
152
- if result['expires_at'] and result['expires_at'] < datetime.utcnow():
153
- raise AuthError('API token has expired')
154
-
155
- # Schedule last used timestamp update (asynchronous with 10s delay)
156
- self._schedule_last_used_update(parsed['id'])
157
-
158
- # Extract roles from results
159
- roles = [row['role_name'] for row in results if row['role_name'] is not None]
160
-
161
- # Construct user object
162
- user_data = {
163
- 'id': result['user_id'],
164
- 'username': result['username'],
165
- 'email': result['email'],
166
- 'real_name': result['real_name'],
167
- 'roles': roles
168
- }
169
-
170
- # Cache the result
171
- self._user_cache[cache_key] = (user_data.copy(), current_time)
172
-
173
- # Clean up expired cache entries
174
- self._cleanup_cache()
175
-
176
- return user_data
177
- except ValueError:
178
- raise AuthError('Invalid token format')
179
-
180
- def _authenticate_request(self):
181
- if self.user_override:
182
- return {
183
- 'id': self.user_override,
184
- 'username': self.user_override,
185
- 'email': '',
186
- 'real_name': self.user_override,
187
- 'roles': []
188
- }
189
- auth_header = request.headers.get('Authorization')
190
- api_token = request.headers.get('X-API-Token')
191
-
192
- if auth_header and auth_header.startswith('Bearer '):
193
- # JWT authentication
194
- token = self._extract_token_from_header()
195
- return self.validate_token(token)
196
- elif api_token:
197
- # API token authentication
198
- return self._validate_api_token(api_token)
199
- else:
200
- raise AuthError('No authentication provided', 401)
201
-
202
- def require_auth(self, f):
203
- @wraps(f)
204
- def decorated(*args, **kwargs):
205
- user = self._authenticate_request()
206
- sig = inspect.signature(f)
207
- if 'requesting_user' in sig.parameters:
208
- kwargs['requesting_user'] = user
209
-
210
- return f(*args, **kwargs)
211
- return decorated
212
-
213
- def add_public_endpoint(self, endpoint):
214
- """Mark an endpoint as public so it bypasses authentication."""
215
- self.public_endpoints.add(endpoint)
216
-
217
- def public_endpoint(self, f):
218
- """Decorator to mark a view function as public."""
219
- # Always register the bare function name so application level routes
220
- # are exempt from authentication checks.
221
- self.add_public_endpoint(f.__name__)
222
-
223
- # If a blueprint is active, also register the blueprint-prefixed name
224
- # used by Flask for endpoint identification.
225
- if self.bp:
226
- endpoint = f"{self.bp.name}.{f.__name__}"
227
- self.add_public_endpoint(endpoint)
228
- return f
229
-
230
- def init_app(self, app):
231
- app.auth_manager = self
232
- app.register_blueprint(self.create_blueprint())
233
- @app.errorhandler(AuthError)
234
- def handle_auth_error(e):
235
- response = jsonify(e.to_dict())
236
- response.status_code = e.status_code
237
- return response
238
-
239
- def create_blueprint(self):
240
- bp = Blueprint('auth', __name__, url_prefix='/api/v1/users')
241
- self.bp = bp
242
- bp.public_endpoint = self.public_endpoint
243
-
244
- @bp.errorhandler(AuthError)
245
- def handle_auth_error(err):
246
- response = jsonify(err.to_dict())
247
- response.status_code = err.status_code
248
- return response
249
-
250
- @bp.before_request
251
- def load_user():
252
- if request.method == 'OPTIONS':
253
- return # Skip authentication for OPTIONS
254
- if request.endpoint not in self.public_endpoints:
255
- g.requesting_user = self._authenticate_request()
256
-
257
- @bp.route('/login', methods=['POST'])
258
- def login():
259
- data = request.get_json()
260
- username = data.get('username')
261
- password = data.get('password')
262
-
263
- if not username or not password:
264
- raise AuthError('Username and password required', 400)
265
-
266
- with self.db.get_cursor() as cur:
267
- cur.execute("SELECT * FROM users WHERE username = %s", (username,))
268
- user = cur.fetchone()
269
-
270
- if not user or not self._verify_password(password, user['password_hash']):
271
- raise AuthError('Invalid username or password', 401)
272
-
273
- # Fetch roles
274
- cur.execute("""
275
- SELECT r.name FROM roles r
276
- JOIN user_roles ur ON ur.role_id = r.id
277
- WHERE ur.user_id = %s
278
- """, (user['id'],))
279
- roles = [row['name'] for row in cur.fetchall()]
280
- user['roles'] = roles
281
-
282
- token = self._create_token(user)
283
- refresh_token = self._create_refresh_token(user)
284
-
285
- return jsonify({
286
- 'token': token,
287
- 'refresh_token': refresh_token,
288
- 'user': user
289
- })
290
-
291
- @bp.route('/login/oauth', methods=['POST'])
292
- def oauth_login():
293
- provider = request.json.get('provider')
294
- if provider not in self.oauth_config:
295
- logger.error(f"Invalid OAuth provider: {provider}")
296
- logger.error(f"These are the known ones: {self.oauth_config.keys()}")
297
- raise AuthError('Invalid OAuth provider', 400)
298
-
299
- redirect_uri = self.get_redirect_uri()
300
- return jsonify({
301
- 'redirect_url': self._get_oauth_url(provider, redirect_uri)
302
- })
303
-
304
- @bp.route('/login/oauth2callback')
305
- def oauth_callback():
306
- code = request.args.get('code')
307
- provider = request.args.get('state')
308
-
309
- if not code or not provider:
310
- raise AuthError('Invalid OAuth callback', 400)
311
- from urllib.parse import urlencode, urlparse, urlunparse
312
- get_redirect_uri = self.get_redirect_uri()
313
- parsed_uri = urlparse(get_redirect_uri)
314
- frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
315
-
316
- #if provider == 'microsoft':
317
- # client = msal.ConfidentialClientApplication(
318
- # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
319
- # )
320
- # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
321
- # code = result['access_token']
322
-
323
- try:
324
- user_info = self._get_oauth_user_info(provider, code)
325
- token = self._create_token(user_info)
326
- refresh_token = self._create_refresh_token(user_info)
327
- # Redirect to frontend with tokens
328
- return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
329
- except AuthError as e:
330
- # Surface error to frontend for user-friendly messaging
331
- params = {
332
- 'error': str(e.message) if hasattr(e, 'message') else str(e),
333
- 'status': getattr(e, 'status_code', 500),
334
- 'provider': provider,
335
- }
336
- return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
337
-
338
- @bp.route('/login/profile')
339
- def profile():
340
- user = g.requesting_user
341
- return jsonify(user)
342
-
343
- @bp.route('/api-tokens', methods=['GET'])
344
- def get_tokens():
345
- tokens = self.get_user_api_tokens(g.requesting_user['id'])
346
- return jsonify(tokens)
347
-
348
- @bp.route('/api-tokens', methods=['POST'])
349
- def create_token():
350
- name = request.json.get('name')
351
- expires_in_days = request.json.get('expires_in_days')
352
- if not name:
353
- raise AuthError('Token name is required', 400)
354
- api_token = self.create_api_token(g.requesting_user['id'], name, expires_in_days)
355
- return jsonify({
356
- 'id': api_token.id,
357
- 'name': api_token.name,
358
- 'token': api_token.get_full_token(),
359
- 'created_at': api_token.created_at,
360
- 'expires_at': api_token.expires_at
361
- })
362
-
363
- @bp.route('/token-refresh', methods=['POST'])
364
- def refresh_token():
365
- refresh_token = request.json.get('refresh_token')
366
- if not refresh_token:
367
- raise AuthError('No refresh token provided', 400)
368
-
369
- try:
370
- payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
371
- user_id = payload['sub']
372
-
373
- with self.db.get_cursor() as cur:
374
- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
375
- user = cur.fetchone()
376
-
377
- if not user:
378
- raise AuthError('User not found', 404)
379
-
380
- return jsonify({
381
- 'token': self._create_token(user),
382
- 'refresh_token': self._create_refresh_token(user)
383
- })
384
- except jwt.InvalidTokenError:
385
- raise AuthError('Invalid refresh token', 401)
386
-
387
- @bp.route('/api-tokens', methods=['POST'])
388
- def create_api_token():
389
- name = request.json.get('name')
390
- if not name:
391
- raise AuthError('Token name required', 400)
392
-
393
- token = self.create_api_token(g.requesting_user['id'], name)
394
- return jsonify({'token': token.token})
395
-
396
- @bp.route('/api-tokens/validate', methods=['GET'])
397
- def validate_api_token():
398
- token = request.json.get('token')
399
- if not token:
400
- raise AuthError('No API token provided', 401)
401
- token = ApiToken.parse_token_id(token)
402
-
403
- with self.db.get_cursor() as cur:
404
- cur.execute("""
405
- SELECT * FROM api_tokens
406
- WHERE user_id = %s AND id = %s
407
- """, (g.requesting_user['id'], token))
408
- api_token = cur.fetchone()
409
-
410
- if not api_token:
411
- raise AuthError('Invalid API token', 401)
412
-
413
- # Check if token is expired
414
- if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
415
- raise AuthError('API token has expired', 401)
416
-
417
- # Update last used timestamp
418
- with self.db.get_cursor() as cur:
419
- cur.execute("""
420
- UPDATE api_tokens
421
- SET last_used_at = %s
422
- WHERE id = %s
423
- """, (datetime.utcnow(), api_token['id']))
424
-
425
- return jsonify({'valid': True})
426
-
427
- @bp.route('/api-tokens', methods=['DELETE'])
428
- def delete_api_token():
429
- token = request.json.get('token')
430
- if not token:
431
- raise AuthError('Token required', 400)
432
- token = ApiToken.parse_token_id(token)
433
-
434
- with self.db.get_cursor() as cur:
435
- cur.execute("""
436
- DELETE FROM api_tokens
437
- WHERE user_id = %s AND id = %s
438
- RETURNING id
439
- """, (g.requesting_user['id'], token))
440
- deleted_id = cur.fetchone()
441
- if not deleted_id:
442
- raise ValueError('Token not found or already deleted')
443
-
444
- return jsonify({'deleted': True})
445
-
446
- @bp.route('/register', methods=['POST'])
447
- def register():
448
- data = request.get_json()
449
-
450
- # Hash the password
451
- password = data.get('password')
452
- if not password:
453
- raise AuthError('Password is required', 400)
454
-
455
- salt = bcrypt.gensalt()
456
- password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
457
-
458
- user = User(
459
- username=data['username'],
460
- email=data['email'],
461
- real_name=data['real_name'],
462
- roles=data.get('roles', []),
463
- id_generator=self.db.get_id_generator()
464
- )
465
-
466
- with self.db.get_cursor() as cur:
467
- if user.id is None:
468
- cur.execute("""
469
- INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
470
- VALUES (%s, %s, %s, %s, %s, %s)
471
- RETURNING id
472
- """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
473
- user.created_at, user.updated_at))
474
- user.id = cur.fetchone()['id']
475
- else:
476
- cur.execute("""
477
- INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
478
- VALUES (%s, %s, %s, %s, %s, %s, %s)
479
- """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
480
- user.created_at, user.updated_at))
481
-
482
- return jsonify({'id': user.id}), 201
483
-
484
- @bp.route('/roles', methods=['GET'])
485
- def get_roles():
486
- with self.db.get_cursor() as cur:
487
- cur.execute("SELECT * FROM roles")
488
- roles = cur.fetchall()
489
- return jsonify(roles)
490
-
491
- # Admin endpoints - require administrator role
492
- @bp.route('/admin/users', methods=['GET'])
493
- def admin_get_users():
494
- self._require_admin_role()
495
- with self.db.get_cursor() as cur:
496
- cur.execute("""
497
- SELECT u.*,
498
- COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
499
- FROM users u
500
- LEFT JOIN user_roles ur ON ur.user_id = u.id
501
- LEFT JOIN roles r ON ur.role_id = r.id
502
- GROUP BY u.id, u.username, u.email, u.real_name, u.created_at, u.updated_at
503
- ORDER BY u.created_at DESC
504
- """)
505
- users = cur.fetchall()
506
- return jsonify(users)
507
-
508
- @bp.route('/admin/users', methods=['POST'])
509
- def admin_create_user():
510
- self._require_admin_role()
511
- data = request.get_json()
512
-
513
- # Validate required fields
514
- required_fields = ['username', 'email', 'real_name', 'password']
515
- for field in required_fields:
516
- if not data.get(field):
517
- raise AuthError(f'{field} is required', 400)
518
-
519
- # Hash the password
520
- salt = bcrypt.gensalt()
521
- password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
522
-
523
- with self.db.get_cursor() as cur:
524
- # Check if username or email already exists
525
- cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
526
- (data['username'], data['email']))
527
- if cur.fetchone():
528
- raise AuthError('Username or email already exists', 400)
529
-
530
- # Create user
531
- cur.execute("""
532
- INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
533
- VALUES (%s, %s, %s, %s, %s, %s)
534
- RETURNING id
535
- """, (data['username'], data['email'], data['real_name'],
536
- password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
537
- user_id = cur.fetchone()['id']
538
-
539
- # Assign roles if provided
540
- if data.get('roles'):
541
- for role_name in data['roles']:
542
- cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
543
- role = cur.fetchone()
544
- if role:
545
- cur.execute("""
546
- INSERT INTO user_roles (user_id, role_id)
547
- VALUES (%s, %s)
548
- ON CONFLICT (user_id, role_id) DO NOTHING
549
- """, (user_id, role['id']))
550
-
551
- return jsonify({'id': user_id}), 201
552
-
553
- @bp.route('/admin/users/<user_id>', methods=['PUT'])
554
- def admin_update_user(user_id):
555
- self._require_admin_role()
556
- data = request.get_json()
557
-
558
- with self.db.get_cursor() as cur:
559
- # Check if user exists
560
- cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
561
- if not cur.fetchone():
562
- raise AuthError('User not found', 404)
563
-
564
- # Update user fields
565
- update_fields = []
566
- update_values = []
567
-
568
- if 'username' in data:
569
- update_fields.append('username = %s')
570
- update_values.append(data['username'])
571
- if 'email' in data:
572
- update_fields.append('email = %s')
573
- update_values.append(data['email'])
574
- if 'real_name' in data:
575
- update_fields.append('real_name = %s')
576
- update_values.append(data['real_name'])
577
- if 'password' in data:
578
- salt = bcrypt.gensalt()
579
- password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
580
- update_fields.append('password_hash = %s')
581
- update_values.append(password_hash.decode('utf-8'))
582
-
583
- if update_fields:
584
- update_fields.append('updated_at = %s')
585
- update_values.append(datetime.utcnow())
586
- update_values.append(user_id)
587
-
588
- cur.execute(f"""
589
- UPDATE users
590
- SET {', '.join(update_fields)}
591
- WHERE id = %s
592
- """, update_values)
593
-
594
- # Update roles if provided
595
- if 'roles' in data:
596
- # Remove existing roles
597
- cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
598
-
599
- # Add new roles
600
- for role_name in data['roles']:
601
- cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
602
- role = cur.fetchone()
603
- if role:
604
- cur.execute("""
605
- INSERT INTO user_roles (user_id, role_id)
606
- VALUES (%s, %s)
607
- """, (user_id, role['id']))
608
-
609
- return jsonify({'success': True})
610
-
611
- @bp.route('/admin/users/<user_id>', methods=['DELETE'])
612
- def admin_delete_user(user_id):
613
- self._require_admin_role()
614
-
615
- with self.db.get_cursor() as cur:
616
- # Check if user exists
617
- cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
618
- if not cur.fetchone():
619
- raise AuthError('User not found', 404)
620
-
621
- # Delete user (cascade will handle related records)
622
- cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
623
-
624
- return jsonify({'success': True})
625
-
626
- @bp.route('/admin/roles', methods=['GET'])
627
- def admin_get_roles():
628
- self._require_admin_role()
629
- with self.db.get_cursor() as cur:
630
- cur.execute("SELECT * FROM roles ORDER BY name")
631
- roles = cur.fetchall()
632
- return jsonify(roles)
633
-
634
- @bp.route('/admin/roles', methods=['POST'])
635
- def admin_create_role():
636
- self._require_admin_role()
637
- data = request.get_json()
638
-
639
- if not data.get('name'):
640
- raise AuthError('Role name is required', 400)
641
-
642
- with self.db.get_cursor() as cur:
643
- # Check if role already exists
644
- cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
645
- if cur.fetchone():
646
- raise AuthError('Role already exists', 400)
647
-
648
- cur.execute("""
649
- INSERT INTO roles (name, description, created_at)
650
- VALUES (%s, %s, %s)
651
- RETURNING id
652
- """, (data['name'], data.get('description', ''), datetime.utcnow()))
653
- role_id = cur.fetchone()['id']
654
-
655
- return jsonify({'id': role_id}), 201
656
-
657
- @bp.route('/admin/roles/<role_id>', methods=['PUT'])
658
- def admin_update_role(role_id):
659
- self._require_admin_role()
660
- data = request.get_json()
661
-
662
- with self.db.get_cursor() as cur:
663
- # Check if role exists
664
- cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
665
- if not cur.fetchone():
666
- raise AuthError('Role not found', 404)
667
-
668
- update_fields = []
669
- update_values = []
670
-
671
- if 'name' in data:
672
- update_fields.append('name = %s')
673
- update_values.append(data['name'])
674
- if 'description' in data:
675
- update_fields.append('description = %s')
676
- update_values.append(data['description'])
677
-
678
- if update_fields:
679
- update_values.append(role_id)
680
- cur.execute(f"""
681
- UPDATE roles
682
- SET {', '.join(update_fields)}
683
- WHERE id = %s
684
- """, update_values)
685
-
686
- return jsonify({'success': True})
687
-
688
- @bp.route('/admin/roles/<role_id>', methods=['DELETE'])
689
- def admin_delete_role(role_id):
690
- self._require_admin_role()
691
-
692
- with self.db.get_cursor() as cur:
693
- # Check if role exists
694
- cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
695
- if not cur.fetchone():
696
- raise AuthError('Role not found', 404)
697
-
698
- # Check if role is assigned to any users
699
- cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
700
- count = cur.fetchone()['count']
701
- if count > 0:
702
- raise AuthError('Cannot delete role that is assigned to users', 400)
703
-
704
- cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
705
-
706
- return jsonify({'success': True})
707
-
708
- @bp.route('/admin/api-tokens', methods=['GET'])
709
- def admin_get_all_tokens():
710
- self._require_admin_role()
711
- with self.db.get_cursor() as cur:
712
- cur.execute("""
713
- SELECT t.*, u.username, u.email
714
- FROM api_tokens t
715
- JOIN users u ON t.user_id = u.id
716
- ORDER BY t.created_at DESC
717
- """)
718
- tokens = cur.fetchall()
719
- return jsonify(tokens)
720
-
721
- @bp.route('/admin/api-tokens', methods=['POST'])
722
- def admin_create_token():
723
- self._require_admin_role()
724
- data = request.get_json()
725
-
726
- if not data.get('user_id') or not data.get('name'):
727
- raise AuthError('user_id and name are required', 400)
728
-
729
- expires_in_days = data.get('expires_in_days')
730
- token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
731
-
732
- return jsonify({
733
- 'id': token.id,
734
- 'name': token.name,
735
- 'token': token.get_full_token(),
736
- 'created_at': token.created_at,
737
- 'expires_at': token.expires_at
738
- }), 201
739
-
740
- @bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
741
- def admin_delete_token(token_id):
742
- self._require_admin_role()
743
-
744
- with self.db.get_cursor() as cur:
745
- cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
746
- if cur.rowcount == 0:
747
- raise AuthError('Token not found', 404)
748
-
749
- return jsonify({'success': True})
750
-
751
- @bp.route('/admin/invite', methods=['POST'])
752
- def admin_send_invitation():
753
- self._require_admin_role()
754
- data = request.get_json()
755
-
756
- if not data.get('email'):
757
- raise AuthError('Email is required', 400)
758
-
759
- # Check if user already exists
760
- with self.db.get_cursor() as cur:
761
- cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
762
- if cur.fetchone():
763
- raise AuthError('User with this email already exists', 400)
764
-
765
- # Send invitation email (placeholder - implement actual email sending)
766
- invitation_token = str(uuid.uuid4())
767
-
768
- # Store invitation in database (you might want to create an invitations table)
769
- # For now, we'll just return success
770
- return jsonify({
771
- 'success': True,
772
- 'message': f'Invitation sent to {data["email"]}',
773
- 'invitation_token': invitation_token
774
- })
775
-
776
- return bp
777
-
778
- def validate_token(self, token):
779
- try:
780
- logger.debug(f"Validating token: {token}")
781
- payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
782
- logger.debug(f"Token payload: {payload}")
783
- user_id = int(payload['sub']) # Convert string ID back to integer
784
-
785
- # Check cache first
786
- cache_key = f"user_{user_id}"
787
- current_time = datetime.utcnow()
788
-
789
- if cache_key in self._user_cache:
790
- cached_data, cache_time = self._user_cache[cache_key]
791
- if (current_time - cache_time).total_seconds() < self._cache_ttl:
792
- logger.debug(f"Returning cached user data for ID: {user_id}")
793
- return cached_data.copy() # Return a copy to avoid modifying cache
794
-
795
- # Cache miss or expired, fetch from database
796
- with self.db.get_cursor() as cur:
797
- cur.execute("""
798
- SELECT u.*, r.name as role_name FROM users u
799
- LEFT JOIN user_roles ur ON ur.user_id = u.id
800
- LEFT JOIN roles r ON ur.role_id = r.id
801
- WHERE u.id = %s
802
- """, (user_id,))
803
- results = cur.fetchall()
804
- if not results:
805
- logger.error(f"User not found for ID: {user_id}")
806
- raise AuthError('User not found', 404)
807
-
808
- # Get the first row for user data (all rows will have same user data)
809
- user = results[0]
810
-
811
- # Extract roles from results
812
- roles = [row['role_name'] for row in results if row['role_name'] is not None]
813
- user['roles'] = roles
814
-
815
- # Cache the result
816
- self._user_cache[cache_key] = (user.copy(), current_time)
817
-
818
- # Clean up expired cache entries
819
- self._cleanup_cache()
820
-
821
- return user
822
- except jwt.InvalidTokenError as e:
823
- logger.error(f"Invalid token error: {str(e)}")
824
- raise AuthError('Invalid token', 401)
825
- except Exception as e:
826
- logger.error(f"Unexpected error during token validation: {str(e)}")
827
- raise AuthError(str(e), 500)
828
-
829
- def _cleanup_cache(self):
830
- """Remove expired cache entries."""
831
- current_time = datetime.utcnow()
832
- expired_keys = [
833
- key for key, (_, cache_time) in self._user_cache.items()
834
- if (current_time - cache_time).total_seconds() >= self._cache_ttl
835
- ]
836
- for key in expired_keys:
837
- del self._user_cache[key]
838
-
839
- def _start_update_thread(self):
840
- """Start the background thread for processing last_used_at updates."""
841
- if self._update_thread is None or not self._update_thread.is_alive():
842
- self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
843
- self._update_thread.start()
844
- logger.debug("Started background update thread")
845
-
846
- def _schedule_last_used_update(self, token_id):
847
- """Schedule a last_used_at update for an API token with 10s delay."""
848
- with self._update_lock:
849
- self._last_used_updates[token_id] = time.time()
850
- logger.debug(f"Scheduled last_used update for token {token_id}")
851
-
852
- def _update_worker(self):
853
- """Background worker that processes last_used_at updates."""
854
- while not self._shutdown_event.is_set():
855
- try:
856
- current_time = time.time()
857
- tokens_to_update = []
858
-
859
- # Collect tokens that need updating (older than 10 seconds)
860
- with self._update_lock:
861
- for token_id, schedule_time in list(self._last_used_updates.items()):
862
- if current_time - schedule_time >= 10: # 10 second delay
863
- tokens_to_update.append(token_id)
864
- del self._last_used_updates[token_id]
865
-
866
- # Perform batch update
867
- if tokens_to_update:
868
- self._perform_batch_update(tokens_to_update)
869
-
870
- # Sleep for a short interval
871
- time.sleep(10)
872
-
873
- except Exception as e:
874
- logger.error(f"Error in update worker: {e}")
875
- time.sleep(5) # Wait longer on error
876
-
877
- def _perform_batch_update(self, token_ids):
878
- """Perform batch update of last_used_at for multiple tokens."""
879
- try:
880
- with self.db.get_cursor() as cur:
881
- # Update all tokens in a single query
882
- placeholders = ','.join(['%s'] * len(token_ids))
883
- cur.execute(f"""
884
- UPDATE api_tokens
885
- SET last_used_at = %s
886
- WHERE id IN ({placeholders})
887
- """, [datetime.utcnow()] + token_ids)
888
-
889
- logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
890
-
891
- except Exception as e:
892
- logger.error(f"Error performing batch update: {e}")
893
-
894
- def shutdown(self):
895
- """Shutdown the background update thread."""
896
- self._shutdown_event.set()
897
- if self._update_thread and self._update_thread.is_alive():
898
- self._update_thread.join(timeout=5)
899
- logger.debug("Background update thread shutdown complete")
900
-
901
- def get_current_user(self):
902
- return self._authenticate_request()
903
-
904
- def _require_admin_role(self):
905
- """Require the current user to have administrator role."""
906
- user = g.requesting_user
907
- if not user or 'administrator' not in user.get('roles', []):
908
- raise AuthError('Administrator role required', 403)
909
-
910
- def get_user_api_tokens(self, user_id):
911
- """Get all API tokens for a user."""
912
- with self.db.get_cursor() as cur:
913
- cur.execute("""
914
- SELECT id, name, created_at, expires_at, last_used_at
915
- FROM api_tokens
916
- WHERE user_id = %s
917
- ORDER BY created_at DESC
918
- """, (user_id,))
919
- return cur.fetchall()
920
-
921
- def create_api_token(self, user_id, name, expires_in_days=None):
922
- """Create a new API token for a user."""
923
- token = ApiToken(user_id, name, expires_in_days)
924
-
925
- with self.db.get_cursor() as cur:
926
- cur.execute("""
927
- INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
928
- VALUES (%s, %s, %s, %s, %s, %s)
929
- """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
930
- return token
931
-
932
- def _create_token(self, user):
933
- payload = {
934
- 'sub': str(user['id']),
935
- 'exp': datetime.utcnow() + self.expiry_time,
936
- 'iat': datetime.utcnow()
937
- }
938
- logger.debug(f"Creating token with payload: {payload}")
939
- token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
940
- logger.info(f"Created token: {token}")
941
- return token
942
-
943
- def _create_refresh_token(self, user):
944
- payload = {
945
- 'sub': str(user['id']),
946
- 'exp': datetime.utcnow() + timedelta(days=30),
947
- 'iat': datetime.utcnow()
948
- }
949
- return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
950
-
951
- def _verify_password(self, password, password_hash):
952
- return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
953
-
954
- def _get_oauth_url(self, provider, redirect_uri):
955
- meta = self._get_provider_meta(provider)
956
- client_id = self.oauth_config[provider]['client_id']
957
- scope = self.oauth_config[provider].get('scope', meta['default_scope'])
958
- state = provider # Pass provider as state for callback
959
- # Some providers require additional params
960
- params = {
961
- 'client_id': client_id,
962
- 'redirect_uri': redirect_uri,
963
- 'response_type': 'code',
964
- 'scope': scope,
965
- 'state': state
966
- }
967
- # Facebook requires display; GitHub supports prompt
968
- if provider == 'facebook':
969
- params['display'] = 'page'
970
- # Build URL
971
- from urllib.parse import urlencode
972
- return f"{meta['auth_url']}?{urlencode(params)}"
973
-
974
- def _get_oauth_user_info(self, provider, code):
975
- meta = self._get_provider_meta(provider)
976
- client_id = self.oauth_config[provider]['client_id']
977
- client_secret = self.oauth_config[provider]['client_secret']
978
- redirect_uri = self.get_redirect_uri()
979
-
980
-
981
- if provider == 'microsoft':
982
- import msal
983
- client = msal.ConfidentialClientApplication(
984
- client_id,
985
- client_credential=client_secret,
986
- authority="https://login.microsoftonline.com/common"
987
- )
988
- tokens = client.acquire_token_by_authorization_code(
989
- code,
990
- scopes=["email"],
991
- redirect_uri=redirect_uri
992
- )
993
- else:
994
- # Standard OAuth flow for other providers
995
- token_data = {
996
- 'client_id': client_id,
997
- 'client_secret': client_secret,
998
- 'code': code,
999
- 'grant_type': 'authorization_code',
1000
- 'redirect_uri': redirect_uri,
1001
- 'scope': meta['default_scope']
1002
- }
1003
- token_headers = {}
1004
- if provider == 'github':
1005
- token_headers['Accept'] = 'application/json'
1006
- token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
1007
- logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
1008
- token_response.raise_for_status()
1009
- tokens = token_response.json()
1010
-
1011
-
1012
- access_token = tokens.get('access_token') or tokens.get('id_token')
1013
- if not access_token:
1014
- # Some providers return id_token separately but require access_token for userinfo
1015
- access_token = tokens.get('access_token')
1016
-
1017
- # Build userinfo request
1018
- userinfo_url = meta['userinfo_url']
1019
- userinfo_headers = {'Authorization': f"Bearer {access_token}"}
1020
- if provider == 'facebook':
1021
- # Ensure fields
1022
- from urllib.parse import urlencode
1023
- userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
1024
-
1025
- userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
1026
- userinfo_response.raise_for_status()
1027
- raw_userinfo = userinfo_response.json()
1028
-
1029
- # Special handling for GitHub missing email
1030
- if provider == 'github' and not raw_userinfo.get('email'):
1031
- emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
1032
- if emails_resp.ok:
1033
- emails = emails_resp.json()
1034
- primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
1035
- raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
1036
-
1037
-
1038
-
1039
-
1040
- # Normalize
1041
- norm = self._normalize_userinfo(provider, raw_userinfo)
1042
- if not norm.get('email'):
1043
- # Fallback pseudo-email if allowed
1044
- norm['email'] = f"{norm['sub']}@{provider}.local"
1045
-
1046
- # Create or update user
1047
- with self.db.get_cursor() as cur:
1048
- cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
1049
- user = cur.fetchone()
1050
-
1051
- if not user:
1052
- if not self.allow_oauth_auto_create:
1053
- raise AuthError('User not found and auto-create disabled', 403)
1054
- # Create new user (auto-create enabled)
1055
- user_obj = User(
1056
- username=norm['email'],
1057
- email=norm['email'],
1058
- real_name=norm.get('name', norm['email']),
1059
- id_generator=self.db.get_id_generator()
1060
- )
1061
- cur.execute("""
1062
- INSERT INTO users (username, email, real_name, created_at, updated_at)
1063
- VALUES (%s, %s, %s, %s, %s)
1064
- RETURNING id
1065
- """, (user_obj.username, user_obj.email, user_obj.real_name,
1066
- user_obj.created_at, user_obj.updated_at))
1067
- new_id = cur.fetchone()['id']
1068
- user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1069
- 'real_name': user_obj.real_name, 'roles': []}
1070
- else:
1071
- # Update existing user
1072
- cur.execute("""
1073
- UPDATE users
1074
- SET real_name = %s, updated_at = %s
1075
- WHERE email = %s
1076
- """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
1077
- user['real_name'] = norm.get('name', norm['email'])
1078
-
1079
- return user
1080
-
1081
- def _get_provider_meta(self, provider):
1082
- providers = {
1083
- 'google': {
1084
- 'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
1085
- 'token_url': 'https://oauth2.googleapis.com/token',
1086
- 'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
1087
- 'default_scope': 'openid email profile'
1088
- },
1089
- 'github': {
1090
- 'auth_url': 'https://github.com/login/oauth/authorize',
1091
- 'token_url': 'https://github.com/login/oauth/access_token',
1092
- 'userinfo_url': 'https://api.github.com/user',
1093
- 'default_scope': 'read:user user:email'
1094
- },
1095
- 'facebook': {
1096
- 'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
1097
- 'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
1098
- 'userinfo_url': 'https://graph.facebook.com/me',
1099
- 'default_scope': 'email public_profile'
1100
- },
1101
- 'microsoft': {
1102
- 'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
1103
- 'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
1104
- 'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
1105
- 'default_scope': 'openid email profile'
1106
- },
1107
- 'linkedin': {
1108
- 'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
1109
- 'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
1110
- 'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
1111
- 'default_scope': 'openid profile email'
1112
- },
1113
- 'slack': {
1114
- 'auth_url': 'https://slack.com/openid/connect/authorize',
1115
- 'token_url': 'https://slack.com/api/openid.connect.token',
1116
- 'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
1117
- 'default_scope': 'openid profile email'
1118
- },
1119
- 'apple': {
1120
- 'auth_url': 'https://appleid.apple.com/auth/authorize',
1121
- 'token_url': 'https://appleid.apple.com/auth/token',
1122
- 'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
1123
- 'default_scope': 'name email'
1124
- }
1125
- }
1126
- if provider not in providers:
1127
- raise AuthError('Invalid OAuth provider ' + provider)
1128
- return providers[provider]
1129
-
1130
- def _normalize_userinfo(self, provider, info):
1131
- # Map into a common structure: sub, email, name
1132
- if provider == 'google':
1133
- return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1134
- if provider == 'github':
1135
- return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
1136
- if provider == 'facebook':
1137
- return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1138
- if provider == 'microsoft':
1139
- # OIDC userinfo
1140
- return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
1141
- if provider == 'linkedin':
1142
- return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1143
- if provider == 'slack':
1144
- return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1145
- if provider == 'apple':
1146
- # Apple email may be private relay; name not always present
1147
- return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1148
- return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1
+ import inspect
2
+ import inspect
3
+ from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
4
+ import jwt
5
+ from datetime import datetime, timedelta
6
+ from .db import Database
7
+ from .models import User, Role, ApiToken
8
+ from .exceptions import AuthError
9
+ import uuid
10
+ import requests
11
+ import bcrypt
12
+ import logging
13
+ import os
14
+ from functools import wraps
15
+ from isodate import parse_duration
16
+ import threading
17
+ import time
18
+ import msal
19
+
20
+ logging.basicConfig(level=logging.DEBUG)
21
+ logger = logging.getLogger(__name__)
22
+
23
+ class AuthManager:
24
+ 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=False):
25
+ self.user_override = None
26
+ self._user_cache = {}
27
+ self._cache_ttl = cache_ttl or 10 # 10 seconds
28
+ self._last_used_updates = {} # Track pending updates
29
+ self._update_lock = threading.Lock()
30
+ self._update_thread = None
31
+ self._shutdown_event = threading.Event()
32
+ # OAuth user creation policy (can be controlled by env)
33
+ self.allow_oauth_auto_create = allow_oauth_auto_create
34
+
35
+ if environment_prefix:
36
+ prefix = environment_prefix.upper() + '_'
37
+ db_dsn = os.getenv(f'{prefix}DATABASE_URL')
38
+ jwt_secret = os.getenv(f'{prefix}JWT_SECRET')
39
+ google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
40
+ google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
41
+ oauth_config = {}
42
+ if google_client_id and google_client_secret:
43
+ oauth_config['google'] = {
44
+ 'client_id': google_client_id,
45
+ 'client_secret': google_client_secret
46
+ }
47
+ # Allow control via prefixed env var (defaults to True)
48
+ auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
49
+ if auto_create_env is not None:
50
+ self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
51
+ api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
52
+ if api_tokens_env:
53
+ api_tokens = {}
54
+ for entry in api_tokens_env.split(','):
55
+ if ':' in entry:
56
+ key, user = entry.split(':', 1)
57
+ api_tokens[key.strip()] = user.strip()
58
+ user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
59
+ if user_override_env:
60
+ self.user_override = user_override_env
61
+ else:
62
+ prefix = ''
63
+
64
+ self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
65
+ if self.user_override and (api_tokens or db_dsn):
66
+ raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
67
+ if api_tokens and db_dsn:
68
+ raise ValueError('Cannot set both api_tokens and db_dsn')
69
+ self.api_tokens = api_tokens or None
70
+ self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
71
+ self.jwt_secret = jwt_secret
72
+ self.oauth_config = oauth_config or {}
73
+ self.public_endpoints = {
74
+ 'auth.login',
75
+ 'auth.oauth_login',
76
+ 'auth.oauth_callback',
77
+ 'auth.refresh_token',
78
+ 'auth.register',
79
+ 'auth.get_roles'
80
+ }
81
+ self.bp = None
82
+
83
+ if app:
84
+ self.init_app(app)
85
+
86
+ # Start the background update thread
87
+ self._start_update_thread()
88
+
89
+ def _extract_token_from_header(self):
90
+ auth = request.authorization
91
+ if not auth or not auth.token:
92
+ raise AuthError('No authorization header or token', 401)
93
+
94
+ if auth.type.lower() != 'bearer':
95
+ raise AuthError('Invalid authorization scheme', 401)
96
+
97
+ return auth.token
98
+
99
+ def get_redirect_uri(self):
100
+ redirect_uri = os.getenv('REDIRECT_URL') or url_for('auth.oauth_callback', _external=True).replace("http://", "https://")
101
+ logger.info(f"REDIRECT URI..: {redirect_uri}")
102
+ return redirect_uri
103
+
104
+ def _validate_api_token(self, api_token):
105
+ if self.api_tokens is not None:
106
+ username = self.api_tokens.get(api_token)
107
+ if not username:
108
+ raise AuthError('Invalid API token')
109
+ # Return a minimal user dict
110
+ return {
111
+ 'id': username,
112
+ 'username': username,
113
+ 'email': '',
114
+ 'real_name': username,
115
+ 'roles': []
116
+ }
117
+ try:
118
+ parsed = ApiToken.parse_token(api_token)
119
+
120
+ # Check cache first
121
+ cache_key = f"api_token_{parsed['id']}"
122
+ current_time = datetime.utcnow()
123
+
124
+ if cache_key in self._user_cache:
125
+ cached_data, cache_time = self._user_cache[cache_key]
126
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
127
+ logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
128
+ return cached_data.copy() # Return a copy to avoid modifying cache
129
+
130
+ # Cache miss or expired, fetch from database
131
+ with self.db.get_cursor() as cur:
132
+ # First get the API token record
133
+ cur.execute("""
134
+ SELECT t.*, u.*, r.name as role_name FROM api_tokens t
135
+ JOIN users u ON t.user_id = u.id
136
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
137
+ LEFT JOIN roles r ON ur.role_id = r.id
138
+ WHERE t.id = %s
139
+ """, (parsed['id'],))
140
+ results = cur.fetchall()
141
+ if not results:
142
+ raise AuthError('Invalid API token')
143
+
144
+ # Get the first row for token/user data (all rows will have same token/user data)
145
+ result = results[0]
146
+
147
+ # Verify the nonce
148
+ if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
149
+ raise AuthError('Invalid API token')
150
+
151
+ # Check if token is expired
152
+ if result['expires_at'] and result['expires_at'] < datetime.utcnow():
153
+ raise AuthError('API token has expired')
154
+
155
+ # Schedule last used timestamp update (asynchronous with 10s delay)
156
+ self._schedule_last_used_update(parsed['id'])
157
+
158
+ # Extract roles from results
159
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
160
+
161
+ # Construct user object
162
+ user_data = {
163
+ 'id': result['user_id'],
164
+ 'username': result['username'],
165
+ 'email': result['email'],
166
+ 'real_name': result['real_name'],
167
+ 'roles': roles
168
+ }
169
+
170
+ # Cache the result
171
+ self._user_cache[cache_key] = (user_data.copy(), current_time)
172
+
173
+ # Clean up expired cache entries
174
+ self._cleanup_cache()
175
+
176
+ return user_data
177
+ except ValueError:
178
+ raise AuthError('Invalid token format')
179
+
180
+ def _authenticate_request(self):
181
+ if self.user_override:
182
+ return {
183
+ 'id': self.user_override,
184
+ 'username': self.user_override,
185
+ 'email': '',
186
+ 'real_name': self.user_override,
187
+ 'roles': []
188
+ }
189
+ auth_header = request.headers.get('Authorization')
190
+ api_token = request.headers.get('X-API-Token')
191
+
192
+ if auth_header and auth_header.startswith('Bearer '):
193
+ # JWT authentication
194
+ token = self._extract_token_from_header()
195
+ return self.validate_token(token)
196
+ elif api_token:
197
+ # API token authentication
198
+ return self._validate_api_token(api_token)
199
+ else:
200
+ raise AuthError('No authentication provided', 401)
201
+
202
+ def require_auth(self, f):
203
+ @wraps(f)
204
+ def decorated(*args, **kwargs):
205
+ user = self._authenticate_request()
206
+ sig = inspect.signature(f)
207
+ if 'requesting_user' in sig.parameters:
208
+ kwargs['requesting_user'] = user
209
+
210
+ return f(*args, **kwargs)
211
+ return decorated
212
+
213
+ def add_public_endpoint(self, endpoint):
214
+ """Mark an endpoint as public so it bypasses authentication."""
215
+ self.public_endpoints.add(endpoint)
216
+
217
+ def public_endpoint(self, f):
218
+ """Decorator to mark a view function as public."""
219
+ # Always register the bare function name so application level routes
220
+ # are exempt from authentication checks.
221
+ self.add_public_endpoint(f.__name__)
222
+
223
+ # If a blueprint is active, also register the blueprint-prefixed name
224
+ # used by Flask for endpoint identification.
225
+ if self.bp:
226
+ endpoint = f"{self.bp.name}.{f.__name__}"
227
+ self.add_public_endpoint(endpoint)
228
+ return f
229
+
230
+ def init_app(self, app):
231
+ app.auth_manager = self
232
+ app.register_blueprint(self.create_blueprint())
233
+ @app.errorhandler(AuthError)
234
+ def handle_auth_error(e):
235
+ response = jsonify(e.to_dict())
236
+ response.status_code = e.status_code
237
+ return response
238
+
239
+ def create_blueprint(self):
240
+ bp = Blueprint('auth', __name__, url_prefix='/api/v1/users')
241
+ self.bp = bp
242
+ bp.public_endpoint = self.public_endpoint
243
+
244
+ @bp.errorhandler(AuthError)
245
+ def handle_auth_error(err):
246
+ response = jsonify(err.to_dict())
247
+ response.status_code = err.status_code
248
+ return response
249
+
250
+ @bp.before_request
251
+ def load_user():
252
+ if request.method == 'OPTIONS':
253
+ return # Skip authentication for OPTIONS
254
+ if request.endpoint not in self.public_endpoints:
255
+ g.requesting_user = self._authenticate_request()
256
+
257
+ @bp.route('/login', methods=['POST'])
258
+ def login():
259
+ data = request.get_json()
260
+ username = data.get('username')
261
+ password = data.get('password')
262
+
263
+ if not username or not password:
264
+ raise AuthError('Username and password required', 400)
265
+
266
+ with self.db.get_cursor() as cur:
267
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
268
+ user = cur.fetchone()
269
+
270
+ if not user or not self._verify_password(password, user['password_hash']):
271
+ raise AuthError('Invalid username or password', 401)
272
+
273
+ # Fetch roles
274
+ cur.execute("""
275
+ SELECT r.name FROM roles r
276
+ JOIN user_roles ur ON ur.role_id = r.id
277
+ WHERE ur.user_id = %s
278
+ """, (user['id'],))
279
+ roles = [row['name'] for row in cur.fetchall()]
280
+ user['roles'] = roles
281
+
282
+ token = self._create_token(user)
283
+ refresh_token = self._create_refresh_token(user)
284
+
285
+ return jsonify({
286
+ 'token': token,
287
+ 'refresh_token': refresh_token,
288
+ 'user': user
289
+ })
290
+
291
+ @bp.route('/login/oauth', methods=['POST'])
292
+ def oauth_login():
293
+ provider = request.json.get('provider')
294
+ if provider not in self.oauth_config:
295
+ logger.error(f"Invalid OAuth provider: {provider}")
296
+ logger.error(f"These are the known ones: {self.oauth_config.keys()}")
297
+ raise AuthError('Invalid OAuth provider', 400)
298
+
299
+ redirect_uri = self.get_redirect_uri()
300
+ return jsonify({
301
+ 'redirect_url': self._get_oauth_url(provider, redirect_uri)
302
+ })
303
+
304
+ @bp.route('/login/oauth2callback')
305
+ def oauth_callback():
306
+ code = request.args.get('code')
307
+ provider = request.args.get('state')
308
+
309
+ if not code or not provider:
310
+ raise AuthError('Invalid OAuth callback', 400)
311
+ from urllib.parse import urlencode, urlparse, urlunparse
312
+ get_redirect_uri = self.get_redirect_uri()
313
+ parsed_uri = urlparse(get_redirect_uri)
314
+ frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
315
+
316
+ #if provider == 'microsoft':
317
+ # client = msal.ConfidentialClientApplication(
318
+ # self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
319
+ # )
320
+ # result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
321
+ # code = result['access_token']
322
+
323
+ try:
324
+ user_info = self._get_oauth_user_info(provider, code)
325
+ token = self._create_token(user_info)
326
+ refresh_token = self._create_refresh_token(user_info)
327
+ # Redirect to frontend with tokens
328
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
329
+ except AuthError as e:
330
+ # Surface error to frontend for user-friendly messaging
331
+ params = {
332
+ 'error': str(e.message) if hasattr(e, 'message') else str(e),
333
+ 'status': getattr(e, 'status_code', 500),
334
+ 'provider': provider,
335
+ }
336
+ return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
337
+
338
+ @bp.route('/login/profile')
339
+ def profile():
340
+ user = g.requesting_user
341
+ return jsonify(user)
342
+
343
+ @bp.route('/api-tokens', methods=['GET'])
344
+ def get_tokens():
345
+ tokens = self.get_user_api_tokens(g.requesting_user['id'])
346
+ return jsonify(tokens)
347
+
348
+ @bp.route('/api-tokens', methods=['POST'])
349
+ def create_token():
350
+ name = request.json.get('name')
351
+ expires_in_days = request.json.get('expires_in_days')
352
+ if not name:
353
+ raise AuthError('Token name is required', 400)
354
+ api_token = self.create_api_token(g.requesting_user['id'], name, expires_in_days)
355
+ return jsonify({
356
+ 'id': api_token.id,
357
+ 'name': api_token.name,
358
+ 'token': api_token.get_full_token(),
359
+ 'created_at': api_token.created_at,
360
+ 'expires_at': api_token.expires_at
361
+ })
362
+
363
+ @bp.route('/token-refresh', methods=['POST'])
364
+ def refresh_token():
365
+ refresh_token = request.json.get('refresh_token')
366
+ if not refresh_token:
367
+ raise AuthError('No refresh token provided', 400)
368
+
369
+ try:
370
+ payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
371
+ user_id = payload['sub']
372
+
373
+ with self.db.get_cursor() as cur:
374
+ cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
375
+ user = cur.fetchone()
376
+
377
+ if not user:
378
+ raise AuthError('User not found', 404)
379
+
380
+ return jsonify({
381
+ 'token': self._create_token(user),
382
+ 'refresh_token': self._create_refresh_token(user)
383
+ })
384
+ except jwt.InvalidTokenError:
385
+ raise AuthError('Invalid refresh token', 401)
386
+
387
+ @bp.route('/api-tokens', methods=['POST'])
388
+ def create_api_token():
389
+ name = request.json.get('name')
390
+ if not name:
391
+ raise AuthError('Token name required', 400)
392
+
393
+ token = self.create_api_token(g.requesting_user['id'], name)
394
+ return jsonify({'token': token.token})
395
+
396
+ @bp.route('/api-tokens/validate', methods=['GET'])
397
+ def validate_api_token():
398
+ token = request.json.get('token')
399
+ if not token:
400
+ raise AuthError('No API token provided', 401)
401
+ token = ApiToken.parse_token_id(token)
402
+
403
+ with self.db.get_cursor() as cur:
404
+ cur.execute("""
405
+ SELECT * FROM api_tokens
406
+ WHERE user_id = %s AND id = %s
407
+ """, (g.requesting_user['id'], token))
408
+ api_token = cur.fetchone()
409
+
410
+ if not api_token:
411
+ raise AuthError('Invalid API token', 401)
412
+
413
+ # Check if token is expired
414
+ if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
415
+ raise AuthError('API token has expired', 401)
416
+
417
+ # Update last used timestamp
418
+ with self.db.get_cursor() as cur:
419
+ cur.execute("""
420
+ UPDATE api_tokens
421
+ SET last_used_at = %s
422
+ WHERE id = %s
423
+ """, (datetime.utcnow(), api_token['id']))
424
+
425
+ return jsonify({'valid': True})
426
+
427
+ @bp.route('/api-tokens', methods=['DELETE'])
428
+ def delete_api_token():
429
+ token = request.json.get('token')
430
+ if not token:
431
+ raise AuthError('Token required', 400)
432
+ token = ApiToken.parse_token_id(token)
433
+
434
+ with self.db.get_cursor() as cur:
435
+ cur.execute("""
436
+ DELETE FROM api_tokens
437
+ WHERE user_id = %s AND id = %s
438
+ RETURNING id
439
+ """, (g.requesting_user['id'], token))
440
+ deleted_id = cur.fetchone()
441
+ if not deleted_id:
442
+ raise ValueError('Token not found or already deleted')
443
+
444
+ return jsonify({'deleted': True})
445
+
446
+ @bp.route('/register', methods=['POST'])
447
+ def register():
448
+ data = request.get_json()
449
+
450
+ # Hash the password
451
+ password = data.get('password')
452
+ if not password:
453
+ raise AuthError('Password is required', 400)
454
+
455
+ salt = bcrypt.gensalt()
456
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
457
+
458
+ user = User(
459
+ username=data['username'],
460
+ email=data['email'],
461
+ real_name=data['real_name'],
462
+ roles=data.get('roles', []),
463
+ id_generator=self.db.get_id_generator()
464
+ )
465
+
466
+ with self.db.get_cursor() as cur:
467
+ if user.id is None:
468
+ cur.execute("""
469
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
470
+ VALUES (%s, %s, %s, %s, %s, %s)
471
+ RETURNING id
472
+ """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
473
+ user.created_at, user.updated_at))
474
+ user.id = cur.fetchone()['id']
475
+ else:
476
+ cur.execute("""
477
+ INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
478
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
479
+ """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
480
+ user.created_at, user.updated_at))
481
+
482
+ return jsonify({'id': user.id}), 201
483
+
484
+ @bp.route('/roles', methods=['GET'])
485
+ def get_roles():
486
+ with self.db.get_cursor() as cur:
487
+ cur.execute("SELECT * FROM roles")
488
+ roles = cur.fetchall()
489
+ return jsonify(roles)
490
+
491
+ # Admin endpoints - require administrator role
492
+ @bp.route('/admin/users', methods=['GET'])
493
+ def admin_get_users():
494
+ self._require_admin_role()
495
+ with self.db.get_cursor() as cur:
496
+ cur.execute("""
497
+ SELECT u.*,
498
+ COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
499
+ FROM users u
500
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
501
+ LEFT JOIN roles r ON ur.role_id = r.id
502
+ GROUP BY u.id, u.username, u.email, u.real_name, u.created_at, u.updated_at
503
+ ORDER BY u.created_at DESC
504
+ """)
505
+ users = cur.fetchall()
506
+ return jsonify(users)
507
+
508
+ @bp.route('/admin/users', methods=['POST'])
509
+ def admin_create_user():
510
+ self._require_admin_role()
511
+ data = request.get_json()
512
+
513
+ # Validate required fields
514
+ required_fields = ['username', 'email', 'real_name', 'password']
515
+ for field in required_fields:
516
+ if not data.get(field):
517
+ raise AuthError(f'{field} is required', 400)
518
+
519
+ # Hash the password
520
+ salt = bcrypt.gensalt()
521
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
522
+
523
+ with self.db.get_cursor() as cur:
524
+ # Check if username or email already exists
525
+ cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
526
+ (data['username'], data['email']))
527
+ if cur.fetchone():
528
+ raise AuthError('Username or email already exists', 400)
529
+
530
+ # Create user
531
+ cur.execute("""
532
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
533
+ VALUES (%s, %s, %s, %s, %s, %s)
534
+ RETURNING id
535
+ """, (data['username'], data['email'], data['real_name'],
536
+ password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
537
+ user_id = cur.fetchone()['id']
538
+
539
+ # Assign roles if provided
540
+ if data.get('roles'):
541
+ for role_name in data['roles']:
542
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
543
+ role = cur.fetchone()
544
+ if role:
545
+ cur.execute("""
546
+ INSERT INTO user_roles (user_id, role_id)
547
+ VALUES (%s, %s)
548
+ ON CONFLICT (user_id, role_id) DO NOTHING
549
+ """, (user_id, role['id']))
550
+
551
+ return jsonify({'id': user_id}), 201
552
+
553
+ @bp.route('/admin/users/<user_id>', methods=['PUT'])
554
+ def admin_update_user(user_id):
555
+ self._require_admin_role()
556
+ data = request.get_json()
557
+
558
+ with self.db.get_cursor() as cur:
559
+ # Check if user exists
560
+ cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
561
+ if not cur.fetchone():
562
+ raise AuthError('User not found', 404)
563
+
564
+ # Update user fields
565
+ update_fields = []
566
+ update_values = []
567
+
568
+ if 'username' in data:
569
+ update_fields.append('username = %s')
570
+ update_values.append(data['username'])
571
+ if 'email' in data:
572
+ update_fields.append('email = %s')
573
+ update_values.append(data['email'])
574
+ if 'real_name' in data:
575
+ update_fields.append('real_name = %s')
576
+ update_values.append(data['real_name'])
577
+ if 'password' in data:
578
+ salt = bcrypt.gensalt()
579
+ password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
580
+ update_fields.append('password_hash = %s')
581
+ update_values.append(password_hash.decode('utf-8'))
582
+
583
+ if update_fields:
584
+ update_fields.append('updated_at = %s')
585
+ update_values.append(datetime.utcnow())
586
+ update_values.append(user_id)
587
+
588
+ cur.execute(f"""
589
+ UPDATE users
590
+ SET {', '.join(update_fields)}
591
+ WHERE id = %s
592
+ """, update_values)
593
+
594
+ # Update roles if provided
595
+ if 'roles' in data:
596
+ # Remove existing roles
597
+ cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
598
+
599
+ # Add new roles
600
+ for role_name in data['roles']:
601
+ cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
602
+ role = cur.fetchone()
603
+ if role:
604
+ cur.execute("""
605
+ INSERT INTO user_roles (user_id, role_id)
606
+ VALUES (%s, %s)
607
+ """, (user_id, role['id']))
608
+
609
+ return jsonify({'success': True})
610
+
611
+ @bp.route('/admin/users/<user_id>', methods=['DELETE'])
612
+ def admin_delete_user(user_id):
613
+ self._require_admin_role()
614
+
615
+ with self.db.get_cursor() as cur:
616
+ # Check if user exists
617
+ cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
618
+ if not cur.fetchone():
619
+ raise AuthError('User not found', 404)
620
+
621
+ # Delete user (cascade will handle related records)
622
+ cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
623
+
624
+ return jsonify({'success': True})
625
+
626
+ @bp.route('/admin/roles', methods=['GET'])
627
+ def admin_get_roles():
628
+ self._require_admin_role()
629
+ with self.db.get_cursor() as cur:
630
+ cur.execute("SELECT * FROM roles ORDER BY name")
631
+ roles = cur.fetchall()
632
+ return jsonify(roles)
633
+
634
+ @bp.route('/admin/roles', methods=['POST'])
635
+ def admin_create_role():
636
+ self._require_admin_role()
637
+ data = request.get_json()
638
+
639
+ if not data.get('name'):
640
+ raise AuthError('Role name is required', 400)
641
+
642
+ with self.db.get_cursor() as cur:
643
+ # Check if role already exists
644
+ cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
645
+ if cur.fetchone():
646
+ raise AuthError('Role already exists', 400)
647
+
648
+ cur.execute("""
649
+ INSERT INTO roles (name, description, created_at)
650
+ VALUES (%s, %s, %s)
651
+ RETURNING id
652
+ """, (data['name'], data.get('description', ''), datetime.utcnow()))
653
+ role_id = cur.fetchone()['id']
654
+
655
+ return jsonify({'id': role_id}), 201
656
+
657
+ @bp.route('/admin/roles/<role_id>', methods=['PUT'])
658
+ def admin_update_role(role_id):
659
+ self._require_admin_role()
660
+ data = request.get_json()
661
+
662
+ with self.db.get_cursor() as cur:
663
+ # Check if role exists
664
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
665
+ if not cur.fetchone():
666
+ raise AuthError('Role not found', 404)
667
+
668
+ update_fields = []
669
+ update_values = []
670
+
671
+ if 'name' in data:
672
+ update_fields.append('name = %s')
673
+ update_values.append(data['name'])
674
+ if 'description' in data:
675
+ update_fields.append('description = %s')
676
+ update_values.append(data['description'])
677
+
678
+ if update_fields:
679
+ update_values.append(role_id)
680
+ cur.execute(f"""
681
+ UPDATE roles
682
+ SET {', '.join(update_fields)}
683
+ WHERE id = %s
684
+ """, update_values)
685
+
686
+ return jsonify({'success': True})
687
+
688
+ @bp.route('/admin/roles/<role_id>', methods=['DELETE'])
689
+ def admin_delete_role(role_id):
690
+ self._require_admin_role()
691
+
692
+ with self.db.get_cursor() as cur:
693
+ # Check if role exists
694
+ cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
695
+ if not cur.fetchone():
696
+ raise AuthError('Role not found', 404)
697
+
698
+ # Check if role is assigned to any users
699
+ cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
700
+ count = cur.fetchone()['count']
701
+ if count > 0:
702
+ raise AuthError('Cannot delete role that is assigned to users', 400)
703
+
704
+ cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
705
+
706
+ return jsonify({'success': True})
707
+
708
+ @bp.route('/admin/api-tokens', methods=['GET'])
709
+ def admin_get_all_tokens():
710
+ self._require_admin_role()
711
+ with self.db.get_cursor() as cur:
712
+ cur.execute("""
713
+ SELECT t.*, u.username, u.email
714
+ FROM api_tokens t
715
+ JOIN users u ON t.user_id = u.id
716
+ ORDER BY t.created_at DESC
717
+ """)
718
+ tokens = cur.fetchall()
719
+ return jsonify(tokens)
720
+
721
+ @bp.route('/admin/api-tokens', methods=['POST'])
722
+ def admin_create_token():
723
+ self._require_admin_role()
724
+ data = request.get_json()
725
+
726
+ if not data.get('user_id') or not data.get('name'):
727
+ raise AuthError('user_id and name are required', 400)
728
+
729
+ expires_in_days = data.get('expires_in_days')
730
+ token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
731
+
732
+ return jsonify({
733
+ 'id': token.id,
734
+ 'name': token.name,
735
+ 'token': token.get_full_token(),
736
+ 'created_at': token.created_at,
737
+ 'expires_at': token.expires_at
738
+ }), 201
739
+
740
+ @bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
741
+ def admin_delete_token(token_id):
742
+ self._require_admin_role()
743
+
744
+ with self.db.get_cursor() as cur:
745
+ cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
746
+ if cur.rowcount == 0:
747
+ raise AuthError('Token not found', 404)
748
+
749
+ return jsonify({'success': True})
750
+
751
+ @bp.route('/admin/invite', methods=['POST'])
752
+ def admin_send_invitation():
753
+ self._require_admin_role()
754
+ data = request.get_json()
755
+
756
+ if not data.get('email'):
757
+ raise AuthError('Email is required', 400)
758
+
759
+ # Check if user already exists
760
+ with self.db.get_cursor() as cur:
761
+ cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
762
+ if cur.fetchone():
763
+ raise AuthError('User with this email already exists', 400)
764
+
765
+ # Send invitation email (placeholder - implement actual email sending)
766
+ invitation_token = str(uuid.uuid4())
767
+
768
+ # Store invitation in database (you might want to create an invitations table)
769
+ # For now, we'll just return success
770
+ return jsonify({
771
+ 'success': True,
772
+ 'message': f'Invitation sent to {data["email"]}',
773
+ 'invitation_token': invitation_token
774
+ })
775
+
776
+ return bp
777
+
778
+ def validate_token(self, token):
779
+ try:
780
+ logger.debug(f"Validating token: {token}")
781
+ payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
782
+ logger.debug(f"Token payload: {payload}")
783
+ user_id = int(payload['sub']) # Convert string ID back to integer
784
+
785
+ # Check cache first
786
+ cache_key = f"user_{user_id}"
787
+ current_time = datetime.utcnow()
788
+
789
+ if cache_key in self._user_cache:
790
+ cached_data, cache_time = self._user_cache[cache_key]
791
+ if (current_time - cache_time).total_seconds() < self._cache_ttl:
792
+ logger.debug(f"Returning cached user data for ID: {user_id}")
793
+ return cached_data.copy() # Return a copy to avoid modifying cache
794
+
795
+ # Cache miss or expired, fetch from database
796
+ with self.db.get_cursor() as cur:
797
+ cur.execute("""
798
+ SELECT u.*, r.name as role_name FROM users u
799
+ LEFT JOIN user_roles ur ON ur.user_id = u.id
800
+ LEFT JOIN roles r ON ur.role_id = r.id
801
+ WHERE u.id = %s
802
+ """, (user_id,))
803
+ results = cur.fetchall()
804
+ if not results:
805
+ logger.error(f"User not found for ID: {user_id}")
806
+ raise AuthError('User not found', 404)
807
+
808
+ # Get the first row for user data (all rows will have same user data)
809
+ user = results[0]
810
+
811
+ # Extract roles from results
812
+ roles = [row['role_name'] for row in results if row['role_name'] is not None]
813
+ user['roles'] = roles
814
+
815
+ # Cache the result
816
+ self._user_cache[cache_key] = (user.copy(), current_time)
817
+
818
+ # Clean up expired cache entries
819
+ self._cleanup_cache()
820
+
821
+ return user
822
+ except jwt.InvalidTokenError as e:
823
+ logger.error(f"Invalid token error: {str(e)}")
824
+ raise AuthError('Invalid token', 401)
825
+ except Exception as e:
826
+ logger.error(f"Unexpected error during token validation: {str(e)}")
827
+ raise AuthError(str(e), 500)
828
+
829
+ def _cleanup_cache(self):
830
+ """Remove expired cache entries."""
831
+ current_time = datetime.utcnow()
832
+ expired_keys = [
833
+ key for key, (_, cache_time) in self._user_cache.items()
834
+ if (current_time - cache_time).total_seconds() >= self._cache_ttl
835
+ ]
836
+ for key in expired_keys:
837
+ del self._user_cache[key]
838
+
839
+ def _start_update_thread(self):
840
+ """Start the background thread for processing last_used_at updates."""
841
+ if self._update_thread is None or not self._update_thread.is_alive():
842
+ self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
843
+ self._update_thread.start()
844
+ logger.debug("Started background update thread")
845
+
846
+ def _schedule_last_used_update(self, token_id):
847
+ """Schedule a last_used_at update for an API token with 10s delay."""
848
+ with self._update_lock:
849
+ self._last_used_updates[token_id] = time.time()
850
+ logger.debug(f"Scheduled last_used update for token {token_id}")
851
+
852
+ def _update_worker(self):
853
+ """Background worker that processes last_used_at updates."""
854
+ while not self._shutdown_event.is_set():
855
+ try:
856
+ current_time = time.time()
857
+ tokens_to_update = []
858
+
859
+ # Collect tokens that need updating (older than 10 seconds)
860
+ with self._update_lock:
861
+ for token_id, schedule_time in list(self._last_used_updates.items()):
862
+ if current_time - schedule_time >= 10: # 10 second delay
863
+ tokens_to_update.append(token_id)
864
+ del self._last_used_updates[token_id]
865
+
866
+ # Perform batch update
867
+ if tokens_to_update:
868
+ self._perform_batch_update(tokens_to_update)
869
+
870
+ # Sleep for a short interval
871
+ time.sleep(10)
872
+
873
+ except Exception as e:
874
+ logger.error(f"Error in update worker: {e}")
875
+ time.sleep(5) # Wait longer on error
876
+
877
+ def _perform_batch_update(self, token_ids):
878
+ """Perform batch update of last_used_at for multiple tokens."""
879
+ try:
880
+ with self.db.get_cursor() as cur:
881
+ # Update all tokens in a single query
882
+ placeholders = ','.join(['%s'] * len(token_ids))
883
+ cur.execute(f"""
884
+ UPDATE api_tokens
885
+ SET last_used_at = %s
886
+ WHERE id IN ({placeholders})
887
+ """, [datetime.utcnow()] + token_ids)
888
+
889
+ logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
890
+
891
+ except Exception as e:
892
+ logger.error(f"Error performing batch update: {e}")
893
+
894
+ def shutdown(self):
895
+ """Shutdown the background update thread."""
896
+ self._shutdown_event.set()
897
+ if self._update_thread and self._update_thread.is_alive():
898
+ self._update_thread.join(timeout=5)
899
+ logger.debug("Background update thread shutdown complete")
900
+
901
+ def get_current_user(self):
902
+ return self._authenticate_request()
903
+
904
+ def _require_admin_role(self):
905
+ """Require the current user to have administrator role."""
906
+ user = g.requesting_user
907
+ if not user or 'administrator' not in user.get('roles', []):
908
+ raise AuthError('Administrator role required', 403)
909
+
910
+ def get_user_api_tokens(self, user_id):
911
+ """Get all API tokens for a user."""
912
+ with self.db.get_cursor() as cur:
913
+ cur.execute("""
914
+ SELECT id, name, created_at, expires_at, last_used_at
915
+ FROM api_tokens
916
+ WHERE user_id = %s
917
+ ORDER BY created_at DESC
918
+ """, (user_id,))
919
+ return cur.fetchall()
920
+
921
+ def create_api_token(self, user_id, name, expires_in_days=None):
922
+ """Create a new API token for a user."""
923
+ token = ApiToken(user_id, name, expires_in_days)
924
+
925
+ with self.db.get_cursor() as cur:
926
+ cur.execute("""
927
+ INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
928
+ VALUES (%s, %s, %s, %s, %s, %s)
929
+ """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
930
+ return token
931
+
932
+ def _create_token(self, user):
933
+ payload = {
934
+ 'sub': str(user['id']),
935
+ 'exp': datetime.utcnow() + self.expiry_time,
936
+ 'iat': datetime.utcnow()
937
+ }
938
+ logger.debug(f"Creating token with payload: {payload}")
939
+ token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
940
+ logger.info(f"Created token: {token}")
941
+ return token
942
+
943
+ def _create_refresh_token(self, user):
944
+ payload = {
945
+ 'sub': str(user['id']),
946
+ 'exp': datetime.utcnow() + timedelta(days=30),
947
+ 'iat': datetime.utcnow()
948
+ }
949
+ return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
950
+
951
+ def _verify_password(self, password, password_hash):
952
+ return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
953
+
954
+ def _get_oauth_url(self, provider, redirect_uri):
955
+ meta = self._get_provider_meta(provider)
956
+ client_id = self.oauth_config[provider]['client_id']
957
+ scope = self.oauth_config[provider].get('scope', meta['default_scope'])
958
+ state = provider # Pass provider as state for callback
959
+ # Some providers require additional params
960
+ params = {
961
+ 'client_id': client_id,
962
+ 'redirect_uri': redirect_uri,
963
+ 'response_type': 'code',
964
+ 'scope': scope,
965
+ 'state': state
966
+ }
967
+ # Facebook requires display; GitHub supports prompt
968
+ if provider == 'facebook':
969
+ params['display'] = 'page'
970
+ # Build URL
971
+ from urllib.parse import urlencode
972
+ return f"{meta['auth_url']}?{urlencode(params)}"
973
+
974
+ def _get_oauth_user_info(self, provider, code):
975
+ meta = self._get_provider_meta(provider)
976
+ client_id = self.oauth_config[provider]['client_id']
977
+ client_secret = self.oauth_config[provider]['client_secret']
978
+ redirect_uri = self.get_redirect_uri()
979
+
980
+
981
+ if provider == 'microsoft':
982
+ import msal
983
+ client = msal.ConfidentialClientApplication(
984
+ client_id,
985
+ client_credential=client_secret,
986
+ authority="https://login.microsoftonline.com/common"
987
+ )
988
+ tokens = client.acquire_token_by_authorization_code(
989
+ code,
990
+ scopes=["email"],
991
+ redirect_uri=redirect_uri
992
+ )
993
+ else:
994
+ # Standard OAuth flow for other providers
995
+ token_data = {
996
+ 'client_id': client_id,
997
+ 'client_secret': client_secret,
998
+ 'code': code,
999
+ 'grant_type': 'authorization_code',
1000
+ 'redirect_uri': redirect_uri,
1001
+ 'scope': meta['default_scope']
1002
+ }
1003
+ token_headers = {}
1004
+ if provider == 'github':
1005
+ token_headers['Accept'] = 'application/json'
1006
+ token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
1007
+ logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
1008
+ token_response.raise_for_status()
1009
+ tokens = token_response.json()
1010
+
1011
+
1012
+ access_token = tokens.get('access_token') or tokens.get('id_token')
1013
+ if not access_token:
1014
+ # Some providers return id_token separately but require access_token for userinfo
1015
+ access_token = tokens.get('access_token')
1016
+
1017
+ # Build userinfo request
1018
+ userinfo_url = meta['userinfo_url']
1019
+ userinfo_headers = {'Authorization': f"Bearer {access_token}"}
1020
+ if provider == 'facebook':
1021
+ # Ensure fields
1022
+ from urllib.parse import urlencode
1023
+ userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
1024
+
1025
+ userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
1026
+ userinfo_response.raise_for_status()
1027
+ raw_userinfo = userinfo_response.json()
1028
+
1029
+ # Special handling for GitHub missing email
1030
+ if provider == 'github' and not raw_userinfo.get('email'):
1031
+ emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
1032
+ if emails_resp.ok:
1033
+ emails = emails_resp.json()
1034
+ primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
1035
+ raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
1036
+
1037
+
1038
+
1039
+
1040
+ # Normalize
1041
+ norm = self._normalize_userinfo(provider, raw_userinfo)
1042
+ if not norm.get('email'):
1043
+ # Fallback pseudo-email if allowed
1044
+ norm['email'] = f"{norm['sub']}@{provider}.local"
1045
+
1046
+ # Create or update user
1047
+ with self.db.get_cursor() as cur:
1048
+ cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
1049
+ user = cur.fetchone()
1050
+
1051
+ if not user:
1052
+ if not self.allow_oauth_auto_create:
1053
+ raise AuthError('User not found and auto-create disabled', 403)
1054
+ # Create new user (auto-create enabled)
1055
+ user_obj = User(
1056
+ username=norm['email'],
1057
+ email=norm['email'],
1058
+ real_name=norm.get('name', norm['email']),
1059
+ id_generator=self.db.get_id_generator()
1060
+ )
1061
+ cur.execute("""
1062
+ INSERT INTO users (username, email, real_name, created_at, updated_at)
1063
+ VALUES (%s, %s, %s, %s, %s)
1064
+ RETURNING id
1065
+ """, (user_obj.username, user_obj.email, user_obj.real_name,
1066
+ user_obj.created_at, user_obj.updated_at))
1067
+ new_id = cur.fetchone()['id']
1068
+ user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
1069
+ 'real_name': user_obj.real_name, 'roles': []}
1070
+ else:
1071
+ # Update existing user
1072
+ cur.execute("""
1073
+ UPDATE users
1074
+ SET real_name = %s, updated_at = %s
1075
+ WHERE email = %s
1076
+ """, (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
1077
+ user['real_name'] = norm.get('name', norm['email'])
1078
+
1079
+ return user
1080
+
1081
+ def _get_provider_meta(self, provider):
1082
+ providers = {
1083
+ 'google': {
1084
+ 'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
1085
+ 'token_url': 'https://oauth2.googleapis.com/token',
1086
+ 'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
1087
+ 'default_scope': 'openid email profile'
1088
+ },
1089
+ 'github': {
1090
+ 'auth_url': 'https://github.com/login/oauth/authorize',
1091
+ 'token_url': 'https://github.com/login/oauth/access_token',
1092
+ 'userinfo_url': 'https://api.github.com/user',
1093
+ 'default_scope': 'read:user user:email'
1094
+ },
1095
+ 'facebook': {
1096
+ 'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
1097
+ 'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
1098
+ 'userinfo_url': 'https://graph.facebook.com/me',
1099
+ 'default_scope': 'email public_profile'
1100
+ },
1101
+ 'microsoft': {
1102
+ 'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
1103
+ 'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
1104
+ 'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
1105
+ 'default_scope': 'openid email profile'
1106
+ },
1107
+ 'linkedin': {
1108
+ 'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
1109
+ 'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
1110
+ 'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
1111
+ 'default_scope': 'openid profile email'
1112
+ },
1113
+ 'slack': {
1114
+ 'auth_url': 'https://slack.com/openid/connect/authorize',
1115
+ 'token_url': 'https://slack.com/api/openid.connect.token',
1116
+ 'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
1117
+ 'default_scope': 'openid profile email'
1118
+ },
1119
+ 'apple': {
1120
+ 'auth_url': 'https://appleid.apple.com/auth/authorize',
1121
+ 'token_url': 'https://appleid.apple.com/auth/token',
1122
+ 'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
1123
+ 'default_scope': 'name email'
1124
+ }
1125
+ }
1126
+ if provider not in providers:
1127
+ raise AuthError('Invalid OAuth provider ' + provider)
1128
+ return providers[provider]
1129
+
1130
+ def _normalize_userinfo(self, provider, info):
1131
+ # Map into a common structure: sub, email, name
1132
+ if provider == 'google':
1133
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1134
+ if provider == 'github':
1135
+ return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
1136
+ if provider == 'facebook':
1137
+ return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1138
+ if provider == 'microsoft':
1139
+ # OIDC userinfo
1140
+ return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
1141
+ if provider == 'linkedin':
1142
+ return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
1143
+ if provider == 'slack':
1144
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1145
+ if provider == 'apple':
1146
+ # Apple email may be private relay; name not always present
1147
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
1148
+ return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}