the37lab-authlib 0.1.1750952155__py3-none-any.whl → 0.1.1751357568__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,528 +1,542 @@
1
- import inspect
2
- from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
3
- import jwt
4
- from datetime import datetime, timedelta
5
- from .db import Database
6
- from .models import User, Role, ApiToken
7
- from .exceptions import AuthError
8
- import uuid
9
- import requests
10
- import bcrypt
11
- import logging
12
- import os
13
- from functools import wraps
14
-
15
- logging.basicConfig(level=logging.DEBUG)
16
- logger = logging.getLogger(__name__)
17
-
18
- class AuthManager:
19
- def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer'):
20
- self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
21
- self.jwt_secret = jwt_secret
22
- self.oauth_config = oauth_config or {}
23
- self.public_endpoints = {
24
- 'auth.login',
25
- 'auth.oauth_login',
26
- 'auth.oauth_callback',
27
- 'auth.refresh_token',
28
- 'auth.register',
29
- 'auth.get_roles'
30
- }
31
- self.bp = None
32
-
33
- if app:
34
- self.init_app(app)
35
-
36
- def _extract_token_from_header(self):
37
- auth = request.authorization
38
- if not auth or not auth.token:
39
- raise AuthError('No authorization header or token', 401)
40
-
41
- if auth.type.lower() != 'bearer':
42
- raise AuthError('Invalid authorization scheme', 401)
43
-
44
- return auth.token
45
-
46
- def get_redirect_uri(self):
47
- redirect_uri = os.getenv('REDIRECT_URL') or url_for('auth.oauth_callback', _external=True).replace("http://", "https://")
48
- logger.info(f"REDIRECT URI..: {redirect_uri}")
49
- return redirect_uri
50
-
51
- def _validate_api_token(self, api_token):
52
- try:
53
- parsed = ApiToken.parse_token(api_token)
54
- with self.db.get_cursor() as cur:
55
- # First get the API token record
56
- cur.execute("""
57
- SELECT t.*, u.* FROM api_tokens t
58
- JOIN users u ON t.user_id = u.id
59
- WHERE t.id = %s
60
- """, (parsed['id'],))
61
- result = cur.fetchone()
62
- if not result:
63
- raise AuthError('Invalid API token')
64
-
65
- # Verify the nonce
66
- if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
67
- raise AuthError('Invalid API token')
68
-
69
- # Check if token is expired
70
- if result['expires_at'] and result['expires_at'] < datetime.utcnow():
71
- raise AuthError('API token has expired')
72
-
73
- # Update last used timestamp
74
- cur.execute("""
75
- UPDATE api_tokens
76
- SET last_used_at = %s
77
- WHERE id = %s
78
- """, (datetime.utcnow(), parsed['id']))
79
-
80
- # Fetch roles
81
- cur.execute("""
82
- SELECT r.name FROM roles r
83
- JOIN user_roles ur ON ur.role_id = r.id
84
- WHERE ur.user_id = %s
85
- """, (result['user_id'],))
86
- roles = [row['name'] for row in cur.fetchall()]
87
-
88
- # Construct user object
89
- return {
90
- 'id': result['user_id'],
91
- 'username': result['username'],
92
- 'email': result['email'],
93
- 'real_name': result['real_name'],
94
- 'roles': roles
95
- }
96
- except ValueError:
97
- raise AuthError('Invalid token format')
98
-
99
- def _authenticate_request(self):
100
- auth_header = request.headers.get('Authorization')
101
- api_token = request.headers.get('X-API-Token')
102
-
103
- if auth_header and auth_header.startswith('Bearer '):
104
- # JWT authentication
105
- token = self._extract_token_from_header()
106
- return self.validate_token(token)
107
- elif api_token:
108
- # API token authentication
109
- return self._validate_api_token(api_token)
110
- else:
111
- raise AuthError('No authentication provided', 401)
112
-
113
- def require_auth(self, f):
114
- @wraps(f)
115
- def decorated(*args, **kwargs):
116
- user = self._authenticate_request()
117
- sig = inspect.signature(f)
118
- if 'requesting_user' in sig.parameters:
119
- kwargs['requesting_user'] = user
120
-
121
- return f(*args, **kwargs)
122
- return decorated
123
-
124
- def add_public_endpoint(self, endpoint):
125
- """Mark an endpoint as public so it bypasses authentication."""
126
- self.public_endpoints.add(endpoint)
127
-
128
- def public_endpoint(self, f):
129
- """Decorator to mark a view function as public."""
130
- # Always register the bare function name so application level routes
131
- # are exempt from authentication checks.
132
- self.add_public_endpoint(f.__name__)
133
-
134
- # If a blueprint is active, also register the blueprint-prefixed name
135
- # used by Flask for endpoint identification.
136
- if self.bp:
137
- endpoint = f"{self.bp.name}.{f.__name__}"
138
- self.add_public_endpoint(endpoint)
139
- return f
140
-
141
- def init_app(self, app):
142
- app.auth_manager = self
143
- app.register_blueprint(self.create_blueprint())
144
- @app.errorhandler(AuthError)
145
- def handle_auth_error(e):
146
- response = jsonify(e.to_dict())
147
- response.status_code = e.status_code
148
- return response
149
-
150
- def create_blueprint(self):
151
- bp = Blueprint('auth', __name__, url_prefix='/api/v1/users')
152
- self.bp = bp
153
- bp.public_endpoint = self.public_endpoint
154
-
155
- @bp.errorhandler(AuthError)
156
- def handle_auth_error(err):
157
- response = jsonify(err.to_dict())
158
- response.status_code = err.status_code
159
- return response
160
-
161
- @bp.before_request
162
- def load_user():
163
- if request.endpoint not in self.public_endpoints:
164
- g.requesting_user = self._authenticate_request()
165
-
166
- @bp.route('/login', methods=['POST'])
167
- def login():
168
- data = request.get_json()
169
- username = data.get('username')
170
- password = data.get('password')
171
-
172
- if not username or not password:
173
- raise AuthError('Username and password required', 400)
174
-
175
- with self.db.get_cursor() as cur:
176
- cur.execute("SELECT * FROM users WHERE username = %s", (username,))
177
- user = cur.fetchone()
178
-
179
- if not user or not self._verify_password(password, user['password_hash']):
180
- raise AuthError('Invalid username or password', 401)
181
-
182
- # Fetch roles
183
- cur.execute("""
184
- SELECT r.name FROM roles r
185
- JOIN user_roles ur ON ur.role_id = r.id
186
- WHERE ur.user_id = %s
187
- """, (user['id'],))
188
- roles = [row['name'] for row in cur.fetchall()]
189
- user['roles'] = roles
190
-
191
- token = self._create_token(user)
192
- refresh_token = self._create_refresh_token(user)
193
-
194
- return jsonify({
195
- 'token': token,
196
- 'refresh_token': refresh_token,
197
- 'user': user
198
- })
199
-
200
- @bp.route('/login/oauth', methods=['POST'])
201
- def oauth_login():
202
- provider = request.json.get('provider')
203
- if provider not in self.oauth_config:
204
- raise AuthError('Invalid OAuth provider', 400)
205
-
206
- redirect_uri = self.get_redirect_uri()
207
- return jsonify({
208
- 'redirect_url': self._get_oauth_url(provider, redirect_uri)
209
- })
210
-
211
- @bp.route('/login/oauth2callback')
212
- def oauth_callback():
213
- code = request.args.get('code')
214
- provider = request.args.get('state')
215
-
216
- if not code or not provider:
217
- raise AuthError('Invalid OAuth callback', 400)
218
-
219
- user_info = self._get_oauth_user_info(provider, code)
220
- token = self._create_token(user_info)
221
- refresh_token = self._create_refresh_token(user_info)
222
-
223
- # Redirect to frontend with tokens
224
- frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
225
- return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
226
-
227
- @bp.route('/login/profile')
228
- def profile():
229
- user = g.requesting_user
230
- return jsonify(user)
231
-
232
- @bp.route('/api-tokens', methods=['GET'])
233
- def get_tokens():
234
- tokens = self.get_user_api_tokens(g.requesting_user['id'])
235
- return jsonify(tokens)
236
-
237
- @bp.route('/api-tokens', methods=['POST'])
238
- def create_token():
239
- name = request.json.get('name')
240
- expires_in_days = request.json.get('expires_in_days')
241
- if not name:
242
- raise AuthError('Token name is required', 400)
243
- api_token = self.create_api_token(g.requesting_user['id'], name, expires_in_days)
244
- return jsonify({
245
- 'id': api_token.id,
246
- 'name': api_token.name,
247
- 'token': api_token.get_full_token(),
248
- 'created_at': api_token.created_at,
249
- 'expires_at': api_token.expires_at
250
- })
251
-
252
- @bp.route('/token-refresh', methods=['POST'])
253
- def refresh_token():
254
- refresh_token = request.json.get('refresh_token')
255
- if not refresh_token:
256
- raise AuthError('No refresh token provided', 400)
257
-
258
- try:
259
- payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
260
- user_id = payload['sub']
261
-
262
- with self.db.get_cursor() as cur:
263
- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
264
- user = cur.fetchone()
265
-
266
- if not user:
267
- raise AuthError('User not found', 404)
268
-
269
- return jsonify({
270
- 'token': self._create_token(user),
271
- 'refresh_token': self._create_refresh_token(user)
272
- })
273
- except jwt.InvalidTokenError:
274
- raise AuthError('Invalid refresh token', 401)
275
-
276
- @bp.route('/api-tokens', methods=['POST'])
277
- def create_api_token():
278
- name = request.json.get('name')
279
- if not name:
280
- raise AuthError('Token name required', 400)
281
-
282
- token = self.create_api_token(g.requesting_user['id'], name)
283
- return jsonify({'token': token.token})
284
-
285
- @bp.route('/api-tokens/validate', methods=['GET'])
286
- def validate_api_token():
287
- token = request.json.get('token')
288
- if not token:
289
- raise AuthError('No API token provided', 401)
290
- token = ApiToken.parse_token_id(token)
291
-
292
- with self.db.get_cursor() as cur:
293
- cur.execute("""
294
- SELECT * FROM api_tokens
295
- WHERE user_id = %s AND id = %s
296
- """, (g.requesting_user['id'], token))
297
- api_token = cur.fetchone()
298
-
299
- if not api_token:
300
- raise AuthError('Invalid API token', 401)
301
-
302
- # Check if token is expired
303
- if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
304
- raise AuthError('API token has expired', 401)
305
-
306
- # Update last used timestamp
307
- with self.db.get_cursor() as cur:
308
- cur.execute("""
309
- UPDATE api_tokens
310
- SET last_used_at = %s
311
- WHERE id = %s
312
- """, (datetime.utcnow(), api_token['id']))
313
-
314
- return jsonify({'valid': True})
315
-
316
- @bp.route('/api-tokens', methods=['DELETE'])
317
- def delete_api_token():
318
- token = request.json.get('token')
319
- if not token:
320
- raise AuthError('Token required', 400)
321
- token = ApiToken.parse_token_id(token)
322
-
323
- with self.db.get_cursor() as cur:
324
- cur.execute("""
325
- DELETE FROM api_tokens
326
- WHERE user_id = %s AND id = %s
327
- RETURNING id
328
- """, (g.requesting_user['id'], token))
329
- deleted_id = cur.fetchone()
330
- if not deleted_id:
331
- raise ValueError('Token not found or already deleted')
332
-
333
- return jsonify({'deleted': True})
334
-
335
- @bp.route('/register', methods=['POST'])
336
- def register():
337
- data = request.get_json()
338
-
339
- # Hash the password
340
- password = data.get('password')
341
- if not password:
342
- raise AuthError('Password is required', 400)
343
-
344
- salt = bcrypt.gensalt()
345
- password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
346
-
347
- user = User(
348
- username=data['username'],
349
- email=data['email'],
350
- real_name=data['real_name'],
351
- roles=data.get('roles', []),
352
- id_generator=self.db.get_id_generator()
353
- )
354
-
355
- with self.db.get_cursor() as cur:
356
- if user.id is None:
357
- cur.execute("""
358
- INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
359
- VALUES (%s, %s, %s, %s, %s, %s)
360
- RETURNING id
361
- """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
362
- user.created_at, user.updated_at))
363
- user.id = cur.fetchone()['id']
364
- else:
365
- cur.execute("""
366
- INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
367
- VALUES (%s, %s, %s, %s, %s, %s, %s)
368
- """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
369
- user.created_at, user.updated_at))
370
-
371
- return jsonify({'id': user.id}), 201
372
-
373
- @bp.route('/roles', methods=['GET'])
374
- def get_roles():
375
- with self.db.get_cursor() as cur:
376
- cur.execute("SELECT * FROM roles")
377
- roles = cur.fetchall()
378
- return jsonify(roles)
379
-
380
- return bp
381
-
382
- def validate_token(self, token):
383
- try:
384
- logger.debug(f"Validating token: {token}")
385
- payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
386
- logger.debug(f"Token payload: {payload}")
387
- user_id = int(payload['sub']) # Convert string ID back to integer
388
-
389
- with self.db.get_cursor() as cur:
390
- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
391
- user = cur.fetchone()
392
- if not user:
393
- logger.error(f"User not found for ID: {user_id}")
394
- raise AuthError('User not found', 404)
395
- # Fetch roles
396
- cur.execute("""
397
- SELECT r.name FROM roles r
398
- JOIN user_roles ur ON ur.role_id = r.id
399
- WHERE ur.user_id = %s
400
- """, (user_id,))
401
- roles = [row['name'] for row in cur.fetchall()]
402
- user['roles'] = roles
403
-
404
- return user
405
- except jwt.InvalidTokenError as e:
406
- logger.error(f"Invalid token error: {str(e)}")
407
- raise AuthError('Invalid token', 401)
408
- except Exception as e:
409
- logger.error(f"Unexpected error during token validation: {str(e)}")
410
- raise AuthError(str(e), 500)
411
-
412
- def get_current_user(self):
413
- return self._authenticate_request()
414
-
415
- def get_user_api_tokens(self, user_id):
416
- """Get all API tokens for a user."""
417
- with self.db.get_cursor() as cur:
418
- cur.execute("""
419
- SELECT id, name, created_at, expires_at, last_used_at
420
- FROM api_tokens
421
- WHERE user_id = %s
422
- ORDER BY created_at DESC
423
- """, (user_id,))
424
- return cur.fetchall()
425
-
426
- def create_api_token(self, user_id, name, expires_in_days=None):
427
- """Create a new API token for a user."""
428
- token = ApiToken(user_id, name, expires_in_days)
429
-
430
- with self.db.get_cursor() as cur:
431
- cur.execute("""
432
- INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
433
- VALUES (%s, %s, %s, %s, %s, %s)
434
- """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
435
- return token
436
-
437
- def _create_token(self, user):
438
- payload = {
439
- 'sub': str(user['id']),
440
- 'exp': datetime.utcnow() + timedelta(hours=1),
441
- 'iat': datetime.utcnow()
442
- }
443
- logger.debug(f"Creating token with payload: {payload}")
444
- token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
445
- logger.debug(f"Created token: {token}")
446
- return token
447
-
448
- def _create_refresh_token(self, user):
449
- payload = {
450
- 'sub': str(user['id']),
451
- 'exp': datetime.utcnow() + timedelta(days=30),
452
- 'iat': datetime.utcnow()
453
- }
454
- return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
455
-
456
- def _verify_password(self, password, password_hash):
457
- return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
458
-
459
- def _get_oauth_url(self, provider, redirect_uri):
460
- if provider == 'google':
461
- client_id = self.oauth_config['google']['client_id']
462
- scope = 'openid email profile'
463
- state = provider # Pass provider as state for callback
464
- 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}'
465
- raise AuthError('Invalid OAuth provider')
466
-
467
- def _get_oauth_user_info(self, provider, code):
468
- if provider == 'google':
469
- client_id = self.oauth_config['google']['client_id']
470
- client_secret = self.oauth_config['google']['client_secret']
471
- redirect_uri = self.get_redirect_uri()
472
-
473
- # Exchange code for tokens
474
- token_url = 'https://oauth2.googleapis.com/token'
475
- token_data = {
476
- 'client_id': client_id,
477
- 'client_secret': client_secret,
478
- 'code': code,
479
- 'grant_type': 'authorization_code',
480
- 'redirect_uri': redirect_uri
481
- }
482
- token_response = requests.post(token_url, data=token_data)
483
- logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
484
- token_response.raise_for_status()
485
- tokens = token_response.json()
486
-
487
- # Get user info
488
- userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
489
- userinfo_response = requests.get(
490
- userinfo_url,
491
- headers={'Authorization': f"Bearer {tokens['access_token']}"}
492
- )
493
- userinfo_response.raise_for_status()
494
- userinfo = userinfo_response.json()
495
-
496
- # Create or update user
497
- with self.db.get_cursor() as cur:
498
- cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
499
- user = cur.fetchone()
500
-
501
- if not user:
502
- # Create new user
503
- user = User(
504
- username=userinfo['email'],
505
- email=userinfo['email'],
506
- real_name=userinfo.get('name', userinfo['email']),
507
- id_generator=self.db.get_id_generator()
508
- )
509
- cur.execute("""
510
- INSERT INTO users (username, email, real_name, created_at, updated_at)
511
- VALUES (%s, %s, %s, %s, %s)
512
- RETURNING id
513
- """, (user.username, user.email, user.real_name,
514
- user.created_at, user.updated_at))
515
- user.id = cur.fetchone()['id']
516
- user = {'id': user.id, 'username': user.username, 'email': user.email,
517
- 'real_name': user.real_name, 'roles': []}
518
- else:
519
- # Update existing user
520
- cur.execute("""
521
- UPDATE users
522
- SET real_name = %s, updated_at = %s
523
- WHERE email = %s
524
- """, (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
525
- user['real_name'] = userinfo.get('name', userinfo['email'])
526
-
527
- return user
1
+ import inspect
2
+ from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
3
+ import jwt
4
+ from datetime import datetime, timedelta
5
+ from .db import Database
6
+ from .models import User, Role, ApiToken
7
+ from .exceptions import AuthError
8
+ import uuid
9
+ import requests
10
+ import bcrypt
11
+ import logging
12
+ import os
13
+ from functools import wraps
14
+
15
+ logging.basicConfig(level=logging.DEBUG)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class AuthManager:
19
+ def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None):
20
+ if environment_prefix:
21
+ prefix = environment_prefix.upper() + '_'
22
+ db_dsn = os.getenv(f'{prefix}DATABASE_URL')
23
+ jwt_secret = os.getenv(f'{prefix}JWT_SECRET')
24
+ google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
25
+ google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
26
+ oauth_config = {}
27
+ if google_client_id and google_client_secret:
28
+ oauth_config['google'] = {
29
+ 'client_id': google_client_id,
30
+ 'client_secret': google_client_secret
31
+ }
32
+ self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
33
+ self.jwt_secret = jwt_secret
34
+ self.oauth_config = oauth_config or {}
35
+ self.public_endpoints = {
36
+ 'auth.login',
37
+ 'auth.oauth_login',
38
+ 'auth.oauth_callback',
39
+ 'auth.refresh_token',
40
+ 'auth.register',
41
+ 'auth.get_roles'
42
+ }
43
+ self.bp = None
44
+
45
+ if app:
46
+ self.init_app(app)
47
+
48
+ def _extract_token_from_header(self):
49
+ auth = request.authorization
50
+ if not auth or not auth.token:
51
+ raise AuthError('No authorization header or token', 401)
52
+
53
+ if auth.type.lower() != 'bearer':
54
+ raise AuthError('Invalid authorization scheme', 401)
55
+
56
+ return auth.token
57
+
58
+ def get_redirect_uri(self):
59
+ redirect_uri = os.getenv('REDIRECT_URL') or url_for('auth.oauth_callback', _external=True).replace("http://", "https://")
60
+ logger.info(f"REDIRECT URI..: {redirect_uri}")
61
+ return redirect_uri
62
+
63
+ def _validate_api_token(self, api_token):
64
+ try:
65
+ parsed = ApiToken.parse_token(api_token)
66
+ with self.db.get_cursor() as cur:
67
+ # First get the API token record
68
+ cur.execute("""
69
+ SELECT t.*, u.* FROM api_tokens t
70
+ JOIN users u ON t.user_id = u.id
71
+ WHERE t.id = %s
72
+ """, (parsed['id'],))
73
+ result = cur.fetchone()
74
+ if not result:
75
+ raise AuthError('Invalid API token')
76
+
77
+ # Verify the nonce
78
+ if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
79
+ raise AuthError('Invalid API token')
80
+
81
+ # Check if token is expired
82
+ if result['expires_at'] and result['expires_at'] < datetime.utcnow():
83
+ raise AuthError('API token has expired')
84
+
85
+ # Update last used timestamp
86
+ cur.execute("""
87
+ UPDATE api_tokens
88
+ SET last_used_at = %s
89
+ WHERE id = %s
90
+ """, (datetime.utcnow(), parsed['id']))
91
+
92
+ # Fetch roles
93
+ cur.execute("""
94
+ SELECT r.name FROM roles r
95
+ JOIN user_roles ur ON ur.role_id = r.id
96
+ WHERE ur.user_id = %s
97
+ """, (result['user_id'],))
98
+ roles = [row['name'] for row in cur.fetchall()]
99
+
100
+ # Construct user object
101
+ return {
102
+ 'id': result['user_id'],
103
+ 'username': result['username'],
104
+ 'email': result['email'],
105
+ 'real_name': result['real_name'],
106
+ 'roles': roles
107
+ }
108
+ except ValueError:
109
+ raise AuthError('Invalid token format')
110
+
111
+ def _authenticate_request(self):
112
+ auth_header = request.headers.get('Authorization')
113
+ api_token = request.headers.get('X-API-Token')
114
+
115
+ if auth_header and auth_header.startswith('Bearer '):
116
+ # JWT authentication
117
+ token = self._extract_token_from_header()
118
+ return self.validate_token(token)
119
+ elif api_token:
120
+ # API token authentication
121
+ return self._validate_api_token(api_token)
122
+ else:
123
+ raise AuthError('No authentication provided', 401)
124
+
125
+ def require_auth(self, f):
126
+ @wraps(f)
127
+ def decorated(*args, **kwargs):
128
+ user = self._authenticate_request()
129
+ sig = inspect.signature(f)
130
+ if 'requesting_user' in sig.parameters:
131
+ kwargs['requesting_user'] = user
132
+
133
+ return f(*args, **kwargs)
134
+ return decorated
135
+
136
+ def add_public_endpoint(self, endpoint):
137
+ """Mark an endpoint as public so it bypasses authentication."""
138
+ self.public_endpoints.add(endpoint)
139
+
140
+ def public_endpoint(self, f):
141
+ """Decorator to mark a view function as public."""
142
+ # Always register the bare function name so application level routes
143
+ # are exempt from authentication checks.
144
+ self.add_public_endpoint(f.__name__)
145
+
146
+ # If a blueprint is active, also register the blueprint-prefixed name
147
+ # used by Flask for endpoint identification.
148
+ if self.bp:
149
+ endpoint = f"{self.bp.name}.{f.__name__}"
150
+ self.add_public_endpoint(endpoint)
151
+ return f
152
+
153
+ def init_app(self, app):
154
+ app.auth_manager = self
155
+ app.register_blueprint(self.create_blueprint())
156
+ @app.errorhandler(AuthError)
157
+ def handle_auth_error(e):
158
+ response = jsonify(e.to_dict())
159
+ response.status_code = e.status_code
160
+ return response
161
+
162
+ def create_blueprint(self):
163
+ bp = Blueprint('auth', __name__, url_prefix='/api/v1/users')
164
+ self.bp = bp
165
+ bp.public_endpoint = self.public_endpoint
166
+
167
+ @bp.errorhandler(AuthError)
168
+ def handle_auth_error(err):
169
+ response = jsonify(err.to_dict())
170
+ response.status_code = err.status_code
171
+ return response
172
+
173
+ @bp.before_request
174
+ def load_user():
175
+ if request.method == 'OPTIONS':
176
+ return # Skip authentication for OPTIONS
177
+ if request.endpoint not in self.public_endpoints:
178
+ g.requesting_user = self._authenticate_request()
179
+
180
+ @bp.route('/login', methods=['POST'])
181
+ def login():
182
+ data = request.get_json()
183
+ username = data.get('username')
184
+ password = data.get('password')
185
+
186
+ if not username or not password:
187
+ raise AuthError('Username and password required', 400)
188
+
189
+ with self.db.get_cursor() as cur:
190
+ cur.execute("SELECT * FROM users WHERE username = %s", (username,))
191
+ user = cur.fetchone()
192
+
193
+ if not user or not self._verify_password(password, user['password_hash']):
194
+ raise AuthError('Invalid username or password', 401)
195
+
196
+ # Fetch roles
197
+ cur.execute("""
198
+ SELECT r.name FROM roles r
199
+ JOIN user_roles ur ON ur.role_id = r.id
200
+ WHERE ur.user_id = %s
201
+ """, (user['id'],))
202
+ roles = [row['name'] for row in cur.fetchall()]
203
+ user['roles'] = roles
204
+
205
+ token = self._create_token(user)
206
+ refresh_token = self._create_refresh_token(user)
207
+
208
+ return jsonify({
209
+ 'token': token,
210
+ 'refresh_token': refresh_token,
211
+ 'user': user
212
+ })
213
+
214
+ @bp.route('/login/oauth', methods=['POST'])
215
+ def oauth_login():
216
+ provider = request.json.get('provider')
217
+ if provider not in self.oauth_config:
218
+ raise AuthError('Invalid OAuth provider', 400)
219
+
220
+ redirect_uri = self.get_redirect_uri()
221
+ return jsonify({
222
+ 'redirect_url': self._get_oauth_url(provider, redirect_uri)
223
+ })
224
+
225
+ @bp.route('/login/oauth2callback')
226
+ def oauth_callback():
227
+ code = request.args.get('code')
228
+ provider = request.args.get('state')
229
+
230
+ if not code or not provider:
231
+ raise AuthError('Invalid OAuth callback', 400)
232
+
233
+ user_info = self._get_oauth_user_info(provider, code)
234
+ token = self._create_token(user_info)
235
+ refresh_token = self._create_refresh_token(user_info)
236
+
237
+ # Redirect to frontend with tokens
238
+ frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
239
+ return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
240
+
241
+ @bp.route('/login/profile')
242
+ def profile():
243
+ user = g.requesting_user
244
+ return jsonify(user)
245
+
246
+ @bp.route('/api-tokens', methods=['GET'])
247
+ def get_tokens():
248
+ tokens = self.get_user_api_tokens(g.requesting_user['id'])
249
+ return jsonify(tokens)
250
+
251
+ @bp.route('/api-tokens', methods=['POST'])
252
+ def create_token():
253
+ name = request.json.get('name')
254
+ expires_in_days = request.json.get('expires_in_days')
255
+ if not name:
256
+ raise AuthError('Token name is required', 400)
257
+ api_token = self.create_api_token(g.requesting_user['id'], name, expires_in_days)
258
+ return jsonify({
259
+ 'id': api_token.id,
260
+ 'name': api_token.name,
261
+ 'token': api_token.get_full_token(),
262
+ 'created_at': api_token.created_at,
263
+ 'expires_at': api_token.expires_at
264
+ })
265
+
266
+ @bp.route('/token-refresh', methods=['POST'])
267
+ def refresh_token():
268
+ refresh_token = request.json.get('refresh_token')
269
+ if not refresh_token:
270
+ raise AuthError('No refresh token provided', 400)
271
+
272
+ try:
273
+ payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
274
+ user_id = payload['sub']
275
+
276
+ with self.db.get_cursor() as cur:
277
+ cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
278
+ user = cur.fetchone()
279
+
280
+ if not user:
281
+ raise AuthError('User not found', 404)
282
+
283
+ return jsonify({
284
+ 'token': self._create_token(user),
285
+ 'refresh_token': self._create_refresh_token(user)
286
+ })
287
+ except jwt.InvalidTokenError:
288
+ raise AuthError('Invalid refresh token', 401)
289
+
290
+ @bp.route('/api-tokens', methods=['POST'])
291
+ def create_api_token():
292
+ name = request.json.get('name')
293
+ if not name:
294
+ raise AuthError('Token name required', 400)
295
+
296
+ token = self.create_api_token(g.requesting_user['id'], name)
297
+ return jsonify({'token': token.token})
298
+
299
+ @bp.route('/api-tokens/validate', methods=['GET'])
300
+ def validate_api_token():
301
+ token = request.json.get('token')
302
+ if not token:
303
+ raise AuthError('No API token provided', 401)
304
+ token = ApiToken.parse_token_id(token)
305
+
306
+ with self.db.get_cursor() as cur:
307
+ cur.execute("""
308
+ SELECT * FROM api_tokens
309
+ WHERE user_id = %s AND id = %s
310
+ """, (g.requesting_user['id'], token))
311
+ api_token = cur.fetchone()
312
+
313
+ if not api_token:
314
+ raise AuthError('Invalid API token', 401)
315
+
316
+ # Check if token is expired
317
+ if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
318
+ raise AuthError('API token has expired', 401)
319
+
320
+ # Update last used timestamp
321
+ with self.db.get_cursor() as cur:
322
+ cur.execute("""
323
+ UPDATE api_tokens
324
+ SET last_used_at = %s
325
+ WHERE id = %s
326
+ """, (datetime.utcnow(), api_token['id']))
327
+
328
+ return jsonify({'valid': True})
329
+
330
+ @bp.route('/api-tokens', methods=['DELETE'])
331
+ def delete_api_token():
332
+ token = request.json.get('token')
333
+ if not token:
334
+ raise AuthError('Token required', 400)
335
+ token = ApiToken.parse_token_id(token)
336
+
337
+ with self.db.get_cursor() as cur:
338
+ cur.execute("""
339
+ DELETE FROM api_tokens
340
+ WHERE user_id = %s AND id = %s
341
+ RETURNING id
342
+ """, (g.requesting_user['id'], token))
343
+ deleted_id = cur.fetchone()
344
+ if not deleted_id:
345
+ raise ValueError('Token not found or already deleted')
346
+
347
+ return jsonify({'deleted': True})
348
+
349
+ @bp.route('/register', methods=['POST'])
350
+ def register():
351
+ data = request.get_json()
352
+
353
+ # Hash the password
354
+ password = data.get('password')
355
+ if not password:
356
+ raise AuthError('Password is required', 400)
357
+
358
+ salt = bcrypt.gensalt()
359
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
360
+
361
+ user = User(
362
+ username=data['username'],
363
+ email=data['email'],
364
+ real_name=data['real_name'],
365
+ roles=data.get('roles', []),
366
+ id_generator=self.db.get_id_generator()
367
+ )
368
+
369
+ with self.db.get_cursor() as cur:
370
+ if user.id is None:
371
+ cur.execute("""
372
+ INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
373
+ VALUES (%s, %s, %s, %s, %s, %s)
374
+ RETURNING id
375
+ """, (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
376
+ user.created_at, user.updated_at))
377
+ user.id = cur.fetchone()['id']
378
+ else:
379
+ cur.execute("""
380
+ INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
381
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
382
+ """, (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
383
+ user.created_at, user.updated_at))
384
+
385
+ return jsonify({'id': user.id}), 201
386
+
387
+ @bp.route('/roles', methods=['GET'])
388
+ def get_roles():
389
+ with self.db.get_cursor() as cur:
390
+ cur.execute("SELECT * FROM roles")
391
+ roles = cur.fetchall()
392
+ return jsonify(roles)
393
+
394
+ return bp
395
+
396
+ def validate_token(self, token):
397
+ try:
398
+ logger.debug(f"Validating token: {token}")
399
+ payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
400
+ logger.debug(f"Token payload: {payload}")
401
+ user_id = int(payload['sub']) # Convert string ID back to integer
402
+
403
+ with self.db.get_cursor() as cur:
404
+ cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
405
+ user = cur.fetchone()
406
+ if not user:
407
+ logger.error(f"User not found for ID: {user_id}")
408
+ raise AuthError('User not found', 404)
409
+ # Fetch roles
410
+ cur.execute("""
411
+ SELECT r.name FROM roles r
412
+ JOIN user_roles ur ON ur.role_id = r.id
413
+ WHERE ur.user_id = %s
414
+ """, (user_id,))
415
+ roles = [row['name'] for row in cur.fetchall()]
416
+ user['roles'] = roles
417
+
418
+ return user
419
+ except jwt.InvalidTokenError as e:
420
+ logger.error(f"Invalid token error: {str(e)}")
421
+ raise AuthError('Invalid token', 401)
422
+ except Exception as e:
423
+ logger.error(f"Unexpected error during token validation: {str(e)}")
424
+ raise AuthError(str(e), 500)
425
+
426
+ def get_current_user(self):
427
+ return self._authenticate_request()
428
+
429
+ def get_user_api_tokens(self, user_id):
430
+ """Get all API tokens for a user."""
431
+ with self.db.get_cursor() as cur:
432
+ cur.execute("""
433
+ SELECT id, name, created_at, expires_at, last_used_at
434
+ FROM api_tokens
435
+ WHERE user_id = %s
436
+ ORDER BY created_at DESC
437
+ """, (user_id,))
438
+ return cur.fetchall()
439
+
440
+ def create_api_token(self, user_id, name, expires_in_days=None):
441
+ """Create a new API token for a user."""
442
+ token = ApiToken(user_id, name, expires_in_days)
443
+
444
+ with self.db.get_cursor() as cur:
445
+ cur.execute("""
446
+ INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
447
+ VALUES (%s, %s, %s, %s, %s, %s)
448
+ """, (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
449
+ return token
450
+
451
+ def _create_token(self, user):
452
+ payload = {
453
+ 'sub': str(user['id']),
454
+ 'exp': datetime.utcnow() + timedelta(hours=1),
455
+ 'iat': datetime.utcnow()
456
+ }
457
+ logger.debug(f"Creating token with payload: {payload}")
458
+ token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
459
+ logger.debug(f"Created token: {token}")
460
+ return token
461
+
462
+ def _create_refresh_token(self, user):
463
+ payload = {
464
+ 'sub': str(user['id']),
465
+ 'exp': datetime.utcnow() + timedelta(days=30),
466
+ 'iat': datetime.utcnow()
467
+ }
468
+ return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
469
+
470
+ def _verify_password(self, password, password_hash):
471
+ return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
472
+
473
+ def _get_oauth_url(self, provider, redirect_uri):
474
+ if provider == 'google':
475
+ client_id = self.oauth_config['google']['client_id']
476
+ scope = 'openid email profile'
477
+ state = provider # Pass provider as state for callback
478
+ 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}'
479
+ raise AuthError('Invalid OAuth provider')
480
+
481
+ def _get_oauth_user_info(self, provider, code):
482
+ if provider == 'google':
483
+ client_id = self.oauth_config['google']['client_id']
484
+ client_secret = self.oauth_config['google']['client_secret']
485
+ redirect_uri = self.get_redirect_uri()
486
+
487
+ # Exchange code for tokens
488
+ token_url = 'https://oauth2.googleapis.com/token'
489
+ token_data = {
490
+ 'client_id': client_id,
491
+ 'client_secret': client_secret,
492
+ 'code': code,
493
+ 'grant_type': 'authorization_code',
494
+ 'redirect_uri': redirect_uri
495
+ }
496
+ token_response = requests.post(token_url, data=token_data)
497
+ logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
498
+ token_response.raise_for_status()
499
+ tokens = token_response.json()
500
+
501
+ # Get user info
502
+ userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
503
+ userinfo_response = requests.get(
504
+ userinfo_url,
505
+ headers={'Authorization': f"Bearer {tokens['access_token']}"}
506
+ )
507
+ userinfo_response.raise_for_status()
508
+ userinfo = userinfo_response.json()
509
+
510
+ # Create or update user
511
+ with self.db.get_cursor() as cur:
512
+ cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
513
+ user = cur.fetchone()
514
+
515
+ if not user:
516
+ # Create new user
517
+ user = User(
518
+ username=userinfo['email'],
519
+ email=userinfo['email'],
520
+ real_name=userinfo.get('name', userinfo['email']),
521
+ id_generator=self.db.get_id_generator()
522
+ )
523
+ cur.execute("""
524
+ INSERT INTO users (username, email, real_name, created_at, updated_at)
525
+ VALUES (%s, %s, %s, %s, %s)
526
+ RETURNING id
527
+ """, (user.username, user.email, user.real_name,
528
+ user.created_at, user.updated_at))
529
+ user.id = cur.fetchone()['id']
530
+ user = {'id': user.id, 'username': user.username, 'email': user.email,
531
+ 'real_name': user.real_name, 'roles': []}
532
+ else:
533
+ # Update existing user
534
+ cur.execute("""
535
+ UPDATE users
536
+ SET real_name = %s, updated_at = %s
537
+ WHERE email = %s
538
+ """, (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
539
+ user['real_name'] = userinfo.get('name', userinfo['email'])
540
+
541
+ return user
528
542
  raise AuthError('Invalid OAuth provider')