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