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