fastapi-rtk 0.2.27__py3-none-any.whl → 1.0.13__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.
- fastapi_rtk/__init__.py +39 -35
- fastapi_rtk/_version.py +1 -0
- fastapi_rtk/api/model_rest_api.py +476 -221
- fastapi_rtk/auth/auth.py +0 -9
- fastapi_rtk/backends/generic/__init__.py +6 -0
- fastapi_rtk/backends/generic/column.py +21 -12
- fastapi_rtk/backends/generic/db.py +42 -7
- fastapi_rtk/backends/generic/filters.py +21 -16
- fastapi_rtk/backends/generic/interface.py +14 -8
- fastapi_rtk/backends/generic/model.py +19 -11
- fastapi_rtk/backends/sqla/__init__.py +1 -0
- fastapi_rtk/backends/sqla/db.py +77 -17
- fastapi_rtk/backends/sqla/extensions/audit/audit.py +401 -189
- fastapi_rtk/backends/sqla/extensions/geoalchemy2/filters.py +15 -12
- fastapi_rtk/backends/sqla/filters.py +50 -21
- fastapi_rtk/backends/sqla/interface.py +96 -34
- fastapi_rtk/backends/sqla/model.py +56 -39
- fastapi_rtk/bases/__init__.py +20 -0
- fastapi_rtk/bases/db.py +94 -7
- fastapi_rtk/bases/file_manager.py +47 -3
- fastapi_rtk/bases/filter.py +22 -0
- fastapi_rtk/bases/interface.py +49 -5
- fastapi_rtk/bases/model.py +3 -0
- fastapi_rtk/bases/session.py +2 -0
- fastapi_rtk/cli/cli.py +62 -9
- fastapi_rtk/cli/commands/__init__.py +23 -0
- fastapi_rtk/cli/{db.py → commands/db/__init__.py} +107 -50
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/env.py +2 -3
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/env.py +10 -9
- fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/script.py.mako +3 -1
- fastapi_rtk/cli/{export.py → commands/export.py} +12 -10
- fastapi_rtk/cli/{security.py → commands/security.py} +73 -7
- fastapi_rtk/cli/commands/translate.py +299 -0
- fastapi_rtk/cli/decorators.py +9 -4
- fastapi_rtk/cli/utils.py +46 -0
- fastapi_rtk/config.py +41 -1
- fastapi_rtk/const.py +29 -1
- fastapi_rtk/db.py +76 -40
- fastapi_rtk/decorators.py +1 -1
- fastapi_rtk/dependencies.py +134 -62
- fastapi_rtk/exceptions.py +51 -1
- fastapi_rtk/fastapi_react_toolkit.py +186 -171
- fastapi_rtk/file_managers/file_manager.py +8 -6
- fastapi_rtk/file_managers/s3_file_manager.py +69 -33
- fastapi_rtk/globals.py +22 -12
- fastapi_rtk/lang/__init__.py +3 -0
- fastapi_rtk/lang/babel/__init__.py +4 -0
- fastapi_rtk/lang/babel/cli.py +40 -0
- fastapi_rtk/lang/babel/config.py +17 -0
- fastapi_rtk/lang/babel.cfg +1 -0
- fastapi_rtk/lang/lazy_text.py +120 -0
- fastapi_rtk/lang/messages.pot +238 -0
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/de/LC_MESSAGES/messages.po +248 -0
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.mo +0 -0
- fastapi_rtk/lang/translations/en/LC_MESSAGES/messages.po +244 -0
- fastapi_rtk/manager.py +355 -37
- fastapi_rtk/mixins.py +12 -0
- fastapi_rtk/routers.py +208 -72
- fastapi_rtk/schemas.py +142 -39
- fastapi_rtk/security/sqla/apis.py +39 -13
- fastapi_rtk/security/sqla/models.py +8 -23
- fastapi_rtk/security/sqla/security_manager.py +369 -11
- fastapi_rtk/setting.py +446 -88
- fastapi_rtk/types.py +94 -27
- fastapi_rtk/utils/__init__.py +8 -0
- fastapi_rtk/utils/async_task_runner.py +286 -61
- fastapi_rtk/utils/csv_json_converter.py +243 -40
- fastapi_rtk/utils/hooks.py +34 -0
- fastapi_rtk/utils/merge_schema.py +3 -3
- fastapi_rtk/utils/multiple_async_contexts.py +21 -0
- fastapi_rtk/utils/pydantic.py +46 -1
- fastapi_rtk/utils/run_utils.py +31 -1
- fastapi_rtk/utils/self_dependencies.py +1 -1
- fastapi_rtk/utils/use_default_when_none.py +1 -1
- fastapi_rtk/version.py +6 -1
- fastapi_rtk-1.0.13.dist-info/METADATA +28 -0
- fastapi_rtk-1.0.13.dist-info/RECORD +133 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/WHEEL +1 -2
- fastapi_rtk/backends/gremlinpython/__init__.py +0 -108
- fastapi_rtk/backends/gremlinpython/column.py +0 -208
- fastapi_rtk/backends/gremlinpython/db.py +0 -228
- fastapi_rtk/backends/gremlinpython/exceptions.py +0 -34
- fastapi_rtk/backends/gremlinpython/filters.py +0 -461
- fastapi_rtk/backends/gremlinpython/interface.py +0 -734
- fastapi_rtk/backends/gremlinpython/model.py +0 -364
- fastapi_rtk/backends/gremlinpython/session.py +0 -23
- fastapi_rtk/cli/commands.py +0 -295
- fastapi_rtk-0.2.27.dist-info/METADATA +0 -23
- fastapi_rtk-0.2.27.dist-info/RECORD +0 -126
- fastapi_rtk-0.2.27.dist-info/top_level.txt +0 -1
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/README +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/alembic.ini.mako +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi/script.py.mako +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/README +0 -0
- /fastapi_rtk/cli/{templates → commands/db/templates}/fastapi-multidb/alembic.ini.mako +0 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/entry_points.txt +0 -0
- {fastapi_rtk-0.2.27.dist-info → fastapi_rtk-1.0.13.dist-info}/licenses/LICENSE +0 -0
fastapi_rtk/manager.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import ssl
|
|
1
2
|
import typing
|
|
2
3
|
from datetime import UTC, datetime
|
|
3
4
|
from typing import Any, Callable, Coroutine, Dict, Optional, overload
|
|
4
5
|
|
|
6
|
+
import fastapi
|
|
5
7
|
from fastapi import HTTPException, Request, Response
|
|
6
8
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
7
9
|
from fastapi_users import BaseUserManager, exceptions
|
|
@@ -11,6 +13,7 @@ from fastapi_users.password import PasswordHelperProtocol
|
|
|
11
13
|
from pydantic import BaseModel
|
|
12
14
|
from sqlalchemy import select
|
|
13
15
|
|
|
16
|
+
from .const import ErrorCode, logger
|
|
14
17
|
from .db import UserDatabase
|
|
15
18
|
from .exceptions import RolesMismatchException
|
|
16
19
|
from .schemas import (
|
|
@@ -111,17 +114,205 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
111
114
|
credentials.password, user.hashed_password
|
|
112
115
|
)
|
|
113
116
|
if not verified:
|
|
114
|
-
await self.
|
|
115
|
-
user, {"fail_login_count": (user.fail_login_count or 0) + 1}
|
|
116
|
-
)
|
|
117
|
+
await self.on_after_authenticate(user, False)
|
|
117
118
|
return None
|
|
118
119
|
# Update password hash to a more robust one if needed
|
|
119
120
|
if updated_password_hash is not None:
|
|
120
121
|
await self.user_db.update(user, {"hashed_password": updated_password_hash})
|
|
121
122
|
|
|
122
|
-
await self.
|
|
123
|
+
await self.on_after_authenticate(user)
|
|
123
124
|
return user
|
|
124
125
|
|
|
126
|
+
async def authenticate_ldap(self, credentials: OAuth2PasswordRequestForm):
|
|
127
|
+
"""
|
|
128
|
+
Authenticate a user using LDAP.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
credentials (OAuth2PasswordRequestForm): The credentials to authenticate the user.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
User | None: The user if the credentials are valid, None otherwise.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
user = await self.get_by_username(credentials.username)
|
|
138
|
+
except exceptions.UserNotExists:
|
|
139
|
+
# Run the hasher to mitigate timing attack
|
|
140
|
+
# Inspired from Django: https://code.djangoproject.com/ticket/20760
|
|
141
|
+
self.password_helper.hash(credentials.password)
|
|
142
|
+
user = None
|
|
143
|
+
|
|
144
|
+
# If no username is provided, go away
|
|
145
|
+
if not credentials.username:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
# If user is not active, go away
|
|
149
|
+
if user and not user.is_active:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# If user is not registered, and not self-registration, go away
|
|
153
|
+
if not user and not Setting.AUTH_USER_REGISTRATION:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Tried to login using ldap with username %s, but self-registration is disabled.",
|
|
156
|
+
credentials.username,
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
# Ensure ldap3 is installed
|
|
161
|
+
try:
|
|
162
|
+
import ldap3
|
|
163
|
+
import ldap3.core.exceptions
|
|
164
|
+
except ImportError:
|
|
165
|
+
logger.error(
|
|
166
|
+
"You tried to use LDAP authentication, but 'ldap3' is not installed."
|
|
167
|
+
)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# --- 1. Configure TLS ---
|
|
171
|
+
# ldap3 centralizes TLS settings into a Tls object.
|
|
172
|
+
assert Setting.AUTH_LDAP_SERVER
|
|
173
|
+
tls_config = None
|
|
174
|
+
use_ssl = Setting.AUTH_LDAP_USE_TLS or "ldaps://" in Setting.AUTH_LDAP_SERVER
|
|
175
|
+
if use_ssl:
|
|
176
|
+
validate_cert = ssl.CERT_REQUIRED
|
|
177
|
+
if Setting.AUTH_LDAP_ALLOW_SELF_SIGNED:
|
|
178
|
+
validate_cert = ssl.CERT_OPTIONAL
|
|
179
|
+
elif not Setting.AUTH_LDAP_TLS_DEMAND:
|
|
180
|
+
# Default behavior is to require certs if TLS is on
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
tls_config = ldap3.Tls(
|
|
184
|
+
local_private_key_file=Setting.AUTH_LDAP_TLS_KEYFILE,
|
|
185
|
+
local_certificate_file=Setting.AUTH_LDAP_TLS_CERTFILE,
|
|
186
|
+
validate=validate_cert,
|
|
187
|
+
ca_certs_path=Setting.AUTH_LDAP_TLS_CACERTDIR,
|
|
188
|
+
ca_certs_file=Setting.AUTH_LDAP_TLS_CACERTFILE,
|
|
189
|
+
version=ssl.PROTOCOL_TLS,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# --- 2. Define the Server ---
|
|
193
|
+
server = ldap3.Server(
|
|
194
|
+
Setting.AUTH_LDAP_SERVER,
|
|
195
|
+
use_ssl=use_ssl,
|
|
196
|
+
tls=tls_config,
|
|
197
|
+
get_info=None, # No need for get_info for authentication
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
user_dn = None
|
|
202
|
+
user_attributes = {}
|
|
203
|
+
|
|
204
|
+
# --- Flow 1 (Indirect Search Bind) ---
|
|
205
|
+
if Setting.AUTH_LDAP_BIND_USER:
|
|
206
|
+
# Bind with service account, search, then rebind with user credentials.
|
|
207
|
+
# The 'with' statement handles connection and unbinding.
|
|
208
|
+
with ldap3.Connection(
|
|
209
|
+
server,
|
|
210
|
+
Setting.AUTH_LDAP_BIND_USER,
|
|
211
|
+
Setting.AUTH_LDAP_BIND_PASSWORD,
|
|
212
|
+
auto_bind=True,
|
|
213
|
+
auto_referrals=False, # Equivalent to OPT_REFERRALS = 0
|
|
214
|
+
) as conn:
|
|
215
|
+
assert Setting.AUTH_LDAP_SEARCH
|
|
216
|
+
|
|
217
|
+
# Search for the user
|
|
218
|
+
user_dn, user_attributes = self._ldap3_search(
|
|
219
|
+
conn, credentials.username
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not user_dn:
|
|
223
|
+
logger.info(
|
|
224
|
+
"No LDAP object found for user '%s'.", credentials.username
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Validate user credentials by attempting a rebind
|
|
229
|
+
if not conn.rebind(user_dn, credentials.password):
|
|
230
|
+
logger.info("Login Failed for user: %s", credentials.username)
|
|
231
|
+
if user:
|
|
232
|
+
await self.on_after_authenticate(user, False)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# --- Flow 2 (Direct Search Bind) ---
|
|
236
|
+
else:
|
|
237
|
+
# Bind directly with the end-user's credentials.
|
|
238
|
+
bind_username = credentials.username
|
|
239
|
+
if Setting.AUTH_LDAP_APPEND_DOMAIN:
|
|
240
|
+
bind_username = f"{bind_username}@{Setting.AUTH_LDAP_APPEND_DOMAIN}"
|
|
241
|
+
if Setting.AUTH_LDAP_USERNAME_FORMAT:
|
|
242
|
+
bind_username = (
|
|
243
|
+
Setting.AUTH_LDAP_USERNAME_FORMAT % credentials.username
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
with ldap3.Connection(
|
|
247
|
+
server,
|
|
248
|
+
bind_username,
|
|
249
|
+
credentials.password,
|
|
250
|
+
auto_bind=True,
|
|
251
|
+
auto_referrals=False,
|
|
252
|
+
) as conn:
|
|
253
|
+
# If we are here, the bind was successful.
|
|
254
|
+
|
|
255
|
+
if Setting.AUTH_LDAP_SEARCH:
|
|
256
|
+
# Search for user attributes
|
|
257
|
+
user_dn, user_attributes = self._ldap3_search(
|
|
258
|
+
conn, credentials.username
|
|
259
|
+
)
|
|
260
|
+
if not user_dn:
|
|
261
|
+
logger.info(
|
|
262
|
+
"No LDAP object found for: %s", credentials.username
|
|
263
|
+
)
|
|
264
|
+
# Bind succeeded but search failed. Decide if this is an error.
|
|
265
|
+
# For now, we exit as the original code did.
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
# --- 3. Post-Authentication Processing ---
|
|
269
|
+
# (This logic remains largely the same)
|
|
270
|
+
if user and user_attributes and Setting.AUTH_ROLES_SYNC_AT_LOGIN:
|
|
271
|
+
user = await self._ldap3_calculate_user(user, user_attributes)
|
|
272
|
+
logger.debug(
|
|
273
|
+
"Calculated new roles for user='%s' as: %s", user_dn, user.roles
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if not user and user_attributes and Setting.AUTH_USER_REGISTRATION:
|
|
277
|
+
user = await self.create(
|
|
278
|
+
{
|
|
279
|
+
"username": credentials.username,
|
|
280
|
+
"password": self.password_helper.generate(),
|
|
281
|
+
"first_name": (
|
|
282
|
+
user_attributes.get(Setting.AUTH_LDAP_FIRSTNAME_FIELD)
|
|
283
|
+
or [""]
|
|
284
|
+
)[0],
|
|
285
|
+
"last_name": (
|
|
286
|
+
user_attributes.get(Setting.AUTH_LDAP_LASTNAME_FIELD)
|
|
287
|
+
or [""]
|
|
288
|
+
)[0],
|
|
289
|
+
"email": (
|
|
290
|
+
user_attributes.get(
|
|
291
|
+
Setting.AUTH_LDAP_EMAIL_FIELD,
|
|
292
|
+
)
|
|
293
|
+
or [f"{credentials.username}@email.notfound"]
|
|
294
|
+
)[0],
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
user = await self._ldap3_calculate_user(user, user_attributes)
|
|
298
|
+
|
|
299
|
+
if user:
|
|
300
|
+
await self.on_after_authenticate(user)
|
|
301
|
+
return user
|
|
302
|
+
else:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
except ldap3.core.exceptions.LDAPBindError:
|
|
306
|
+
# More specific error for failed logins
|
|
307
|
+
logger.info("Login Failed for user: %s", credentials.username)
|
|
308
|
+
if user:
|
|
309
|
+
await self.on_after_authenticate(user, False)
|
|
310
|
+
return None
|
|
311
|
+
except ldap3.core.exceptions.LDAPException as e:
|
|
312
|
+
# Catch all other ldap3 specific errors
|
|
313
|
+
logger.error("LDAP Error %s", e)
|
|
314
|
+
return None
|
|
315
|
+
|
|
125
316
|
async def create(
|
|
126
317
|
self,
|
|
127
318
|
user_create: BaseModel | dict[str, Any],
|
|
@@ -309,6 +500,7 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
309
500
|
# Associate account
|
|
310
501
|
user = await self.get_by_email(account_email)
|
|
311
502
|
if not associate_by_email:
|
|
503
|
+
await self.on_after_authenticate(user, False)
|
|
312
504
|
raise exceptions.UserAlreadyExists()
|
|
313
505
|
user = await self.user_db.add_oauth_account(user, oauth_account_dict)
|
|
314
506
|
except exceptions.UserNotExists:
|
|
@@ -366,6 +558,7 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
366
558
|
user = await self._handle_role_keys(
|
|
367
559
|
user, oauth_callback_params["user_dict"].pop("role_keys", None)
|
|
368
560
|
)
|
|
561
|
+
await self.on_after_authenticate(user)
|
|
369
562
|
return user
|
|
370
563
|
|
|
371
564
|
@overload
|
|
@@ -386,6 +579,7 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
386
579
|
:param request: Optional FastAPI request that
|
|
387
580
|
triggered the operation, defaults to None.
|
|
388
581
|
:raises UserInactive: The user is inactive.
|
|
582
|
+
:return: None if in a request context, otherwise returns the reset token.
|
|
389
583
|
"""
|
|
390
584
|
if not user.is_active:
|
|
391
585
|
raise exceptions.UserInactive()
|
|
@@ -405,30 +599,6 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
405
599
|
if not request:
|
|
406
600
|
return token
|
|
407
601
|
|
|
408
|
-
async def on_after_login(
|
|
409
|
-
self,
|
|
410
|
-
user: User,
|
|
411
|
-
request: Request | None = None,
|
|
412
|
-
response: Response | None = None,
|
|
413
|
-
) -> None:
|
|
414
|
-
"""
|
|
415
|
-
Perform logic after user login.
|
|
416
|
-
|
|
417
|
-
Please call await super().on_after_login(user, request, response) to keep the default behavior.
|
|
418
|
-
|
|
419
|
-
*You should overload this method to add your own logic.*
|
|
420
|
-
|
|
421
|
-
:param user: The user that is logging in
|
|
422
|
-
:param request: Optional FastAPI request
|
|
423
|
-
:param response: Optional response built by the transport.
|
|
424
|
-
Defaults to None
|
|
425
|
-
"""
|
|
426
|
-
update_fields = {
|
|
427
|
-
"last_login": datetime.now(UTC).replace(tzinfo=None),
|
|
428
|
-
"login_count": (user.login_count or 0) + 1,
|
|
429
|
-
}
|
|
430
|
-
await self.user_db.update(user, update_fields)
|
|
431
|
-
|
|
432
602
|
async def on_after_logout(
|
|
433
603
|
self,
|
|
434
604
|
user: User,
|
|
@@ -436,16 +606,14 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
436
606
|
response: Response | None = None,
|
|
437
607
|
) -> None:
|
|
438
608
|
"""
|
|
439
|
-
Custom `on_after_logout` method to handle user logout.
|
|
440
|
-
|
|
441
609
|
Perform logic after user logout.
|
|
442
610
|
|
|
443
611
|
*You should overload this method to add your own logic.*
|
|
444
612
|
|
|
445
|
-
:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
613
|
+
Args:
|
|
614
|
+
user (User): The user that is logging out.
|
|
615
|
+
request (Request | None, optional): Optional FastAPI request.
|
|
616
|
+
response (Response | None, optional): Optional response built by the transport.
|
|
449
617
|
"""
|
|
450
618
|
return
|
|
451
619
|
|
|
@@ -453,19 +621,48 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
453
621
|
self, user: User, token: str, request: Request | None = None
|
|
454
622
|
) -> None:
|
|
455
623
|
if request:
|
|
456
|
-
raise HTTPException(
|
|
624
|
+
raise HTTPException(
|
|
625
|
+
fastapi.status.HTTP_501_NOT_IMPLEMENTED,
|
|
626
|
+
ErrorCode.FEATURE_NOT_IMPLEMENTED,
|
|
627
|
+
)
|
|
457
628
|
|
|
458
629
|
async def on_after_reset_password(
|
|
459
630
|
self, user: User, request: Request | None = None
|
|
460
631
|
) -> None:
|
|
461
632
|
if request:
|
|
462
|
-
raise HTTPException(
|
|
633
|
+
raise HTTPException(
|
|
634
|
+
fastapi.status.HTTP_501_NOT_IMPLEMENTED,
|
|
635
|
+
ErrorCode.FEATURE_NOT_IMPLEMENTED,
|
|
636
|
+
)
|
|
463
637
|
|
|
464
638
|
async def on_after_request_verify(
|
|
465
639
|
self, user: User, token: str, request: Request | None = None
|
|
466
640
|
) -> None:
|
|
467
641
|
if request:
|
|
468
|
-
raise HTTPException(
|
|
642
|
+
raise HTTPException(
|
|
643
|
+
fastapi.status.HTTP_501_NOT_IMPLEMENTED,
|
|
644
|
+
ErrorCode.FEATURE_NOT_IMPLEMENTED,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
async def on_after_authenticate(self, user: User, success=True):
|
|
648
|
+
"""
|
|
649
|
+
Perform logic after authentication attempt.
|
|
650
|
+
|
|
651
|
+
Please call `await super().on_after_authenticate(user, success)` to keep the default behavior.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
user (User): The user that attempted authentication.
|
|
655
|
+
success (bool, optional): Whether the authentication was successful. Defaults to True.
|
|
656
|
+
"""
|
|
657
|
+
update_fields = {}
|
|
658
|
+
if success:
|
|
659
|
+
update_fields["fail_login_count"] = 0
|
|
660
|
+
update_fields["last_login"] = datetime.now(UTC).replace(tzinfo=None)
|
|
661
|
+
update_fields["login_count"] = (user.login_count or 0) + 1
|
|
662
|
+
else:
|
|
663
|
+
update_fields["fail_login_count"] = (user.fail_login_count or 0) + 1
|
|
664
|
+
if update_fields:
|
|
665
|
+
await self.user_db.update(user, update_fields)
|
|
469
666
|
|
|
470
667
|
async def _update(self, user: User, update_dict: Dict[str, Any]) -> User:
|
|
471
668
|
"""
|
|
@@ -533,3 +730,124 @@ class UserManager(IDParser, BaseUserManager[User, int]):
|
|
|
533
730
|
else:
|
|
534
731
|
user_or_user_dict["roles"] = await self.get_roles_by_names(role_names)
|
|
535
732
|
return user_or_user_dict
|
|
733
|
+
|
|
734
|
+
"""
|
|
735
|
+
--------------------------------------------------------------------------------------------------------
|
|
736
|
+
LDAP3 HELPER METHODS
|
|
737
|
+
--------------------------------------------------------------------------------------------------------
|
|
738
|
+
"""
|
|
739
|
+
|
|
740
|
+
def _ldap3_search(self, conn, username: str):
|
|
741
|
+
"""
|
|
742
|
+
Helper function to perform an LDAP search using ldap3.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
conn (ldap3.Connection): The LDAP connection object.
|
|
746
|
+
username (str): The username to search for.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
Tuple[str | None, dict | None]: A tuple containing the user's DN and attributes if found, otherwise (None, None).
|
|
750
|
+
"""
|
|
751
|
+
import ldap3.utils.conv
|
|
752
|
+
|
|
753
|
+
# Configuration check remains good practice
|
|
754
|
+
assert Setting.AUTH_LDAP_SEARCH
|
|
755
|
+
|
|
756
|
+
# 1. Filter Construction (with security improvement)
|
|
757
|
+
# ldap3 provides a utility to escape special characters to prevent LDAP injection
|
|
758
|
+
safe_username = ldap3.utils.conv.escape_filter_chars(username)
|
|
759
|
+
|
|
760
|
+
base_filter = f"({Setting.AUTH_LDAP_UID_FIELD}={safe_username})"
|
|
761
|
+
if Setting.AUTH_LDAP_SEARCH_FILTER:
|
|
762
|
+
# Combine the custom filter with the UID search
|
|
763
|
+
filter_str = f"(&{Setting.AUTH_LDAP_SEARCH_FILTER}{base_filter})"
|
|
764
|
+
else:
|
|
765
|
+
filter_str = base_filter
|
|
766
|
+
|
|
767
|
+
# 2. Attribute List Construction
|
|
768
|
+
# Using a set to prevent duplicates if config fields overlap
|
|
769
|
+
attributes_to_fetch = {
|
|
770
|
+
Setting.AUTH_LDAP_FIRSTNAME_FIELD,
|
|
771
|
+
Setting.AUTH_LDAP_LASTNAME_FIELD,
|
|
772
|
+
Setting.AUTH_LDAP_EMAIL_FIELD,
|
|
773
|
+
}
|
|
774
|
+
if Setting.AUTH_ROLES_MAPPING:
|
|
775
|
+
attributes_to_fetch.add(Setting.AUTH_LDAP_GROUP_FIELD)
|
|
776
|
+
|
|
777
|
+
logger.debug(
|
|
778
|
+
"LDAP search for '%s' with filter '%s' in base '%s'",
|
|
779
|
+
username,
|
|
780
|
+
filter_str,
|
|
781
|
+
Setting.AUTH_LDAP_SEARCH,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
# 3. The Search Method
|
|
785
|
+
# The search operation is a method of the connection object
|
|
786
|
+
conn.search(
|
|
787
|
+
Setting.AUTH_LDAP_SEARCH,
|
|
788
|
+
filter_str,
|
|
789
|
+
search_scope=ldap3.SUBTREE,
|
|
790
|
+
attributes=list(attributes_to_fetch),
|
|
791
|
+
)
|
|
792
|
+
logger.debug("LDAP search returned: %s", conn.entries)
|
|
793
|
+
|
|
794
|
+
# 4. Result Processing
|
|
795
|
+
# Results are stored in the conn.entries property
|
|
796
|
+
if len(conn.entries) > 1:
|
|
797
|
+
logger.error("LDAP search for '%s' returned multiple results.", username)
|
|
798
|
+
return None, None
|
|
799
|
+
|
|
800
|
+
if conn.entries:
|
|
801
|
+
# Get the first (and only) entry
|
|
802
|
+
user_entry = conn.entries[0]
|
|
803
|
+
|
|
804
|
+
# Extract DN and attributes
|
|
805
|
+
user_dn = user_entry.entry_dn
|
|
806
|
+
# .entry_attributes_as_dict provides a simple dict format
|
|
807
|
+
user_attributes = user_entry.entry_attributes_as_dict
|
|
808
|
+
|
|
809
|
+
return user_dn, user_attributes
|
|
810
|
+
else:
|
|
811
|
+
# No results found
|
|
812
|
+
return None, None
|
|
813
|
+
|
|
814
|
+
async def _ldap3_calculate_user(
|
|
815
|
+
self, user: User, user_attributes: dict[str, typing.Any]
|
|
816
|
+
):
|
|
817
|
+
"""
|
|
818
|
+
Calculate the roles for the user.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
user (User): User object to update.
|
|
822
|
+
user_attributes (dict[str, typing.Any]): User attributes from LDAP.
|
|
823
|
+
|
|
824
|
+
Returns:
|
|
825
|
+
User: Updated user object.
|
|
826
|
+
"""
|
|
827
|
+
# Apply AUTH_ROLES_MAPPING
|
|
828
|
+
user_role_keys = user_attributes.get(Setting.AUTH_LDAP_GROUP_FIELD, [])
|
|
829
|
+
user = await self._handle_role_keys(user, user_role_keys)
|
|
830
|
+
|
|
831
|
+
# Apply AUTH_USER_REGISTRATION
|
|
832
|
+
if Setting.AUTH_USER_REGISTRATION:
|
|
833
|
+
if not Setting.AUTH_USER_REGISTRATION_ROLE:
|
|
834
|
+
logger.warning(
|
|
835
|
+
"AUTH_USER_REGISTRATION is set but AUTH_USER_REGISTRATION_ROLE is not set"
|
|
836
|
+
)
|
|
837
|
+
return user
|
|
838
|
+
|
|
839
|
+
# lookup registration role in flask db
|
|
840
|
+
db_roles = await self.get_roles_by_names(
|
|
841
|
+
Setting.AUTH_USER_REGISTRATION_ROLE
|
|
842
|
+
)
|
|
843
|
+
if not db_roles:
|
|
844
|
+
logger.warning(
|
|
845
|
+
"Can't find AUTH_USER_REGISTRATION_ROLE in database: %s",
|
|
846
|
+
Setting.AUTH_USER_REGISTRATION_ROLE,
|
|
847
|
+
)
|
|
848
|
+
return user
|
|
849
|
+
await self.update(
|
|
850
|
+
{}, user, list(set(r.name for r in db_roles + user.roles))
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
return user
|
fastapi_rtk/mixins.py
ADDED