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