xtr-security-http 3.0.0__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.
Files changed (93) hide show
  1. xtr_security_http/__init__.py +143 -0
  2. xtr_security_http/_runner.py +134 -0
  3. xtr_security_http/_state.py +57 -0
  4. xtr_security_http/access_map.py +52 -0
  5. xtr_security_http/access_map_interface.py +24 -0
  6. xtr_security_http/access_token/__init__.py +19 -0
  7. xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
  8. xtr_security_http/access_token/access_token_handler_interface.py +34 -0
  9. xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
  10. xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
  11. xtr_security_http/access_token/header_access_token_extractor.py +80 -0
  12. xtr_security_http/access_token/oidc/__init__.py +41 -0
  13. xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
  14. xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
  15. xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
  16. xtr_security_http/access_token/query_access_token_extractor.py +51 -0
  17. xtr_security_http/authentication/__init__.py +24 -0
  18. xtr_security_http/authentication/_sensitive.py +60 -0
  19. xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
  20. xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
  21. xtr_security_http/authentication/authenticator_manager.py +232 -0
  22. xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
  23. xtr_security_http/authentication/expose_security_level.py +27 -0
  24. xtr_security_http/authenticator/__init__.py +9 -0
  25. xtr_security_http/authenticator/abstract_authenticator.py +66 -0
  26. xtr_security_http/authenticator/access_token_authenticator.py +199 -0
  27. xtr_security_http/authenticator/authenticator_interface.py +68 -0
  28. xtr_security_http/authenticator/oidc/__init__.py +7 -0
  29. xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
  30. xtr_security_http/authenticator/passport/__init__.py +8 -0
  31. xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
  32. xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
  33. xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
  34. xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
  35. xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
  36. xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
  37. xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
  38. xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
  39. xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
  40. xtr_security_http/authenticator/passport/passport.py +104 -0
  41. xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
  42. xtr_security_http/authenticator/token/__init__.py +7 -0
  43. xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
  44. xtr_security_http/authorization/__init__.py +15 -0
  45. xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
  46. xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
  47. xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
  48. xtr_security_http/decorator/__init__.py +16 -0
  49. xtr_security_http/decorator/current_user.py +83 -0
  50. xtr_security_http/decorator/is_granted.py +199 -0
  51. xtr_security_http/decorator/is_granted_context.py +14 -0
  52. xtr_security_http/entry_point/__init__.py +7 -0
  53. xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
  54. xtr_security_http/event/__init__.py +20 -0
  55. xtr_security_http/event/authentication_token_created_event.py +48 -0
  56. xtr_security_http/event/check_passport_event.py +48 -0
  57. xtr_security_http/event/login_failure_event.py +79 -0
  58. xtr_security_http/event/login_success_event.py +79 -0
  59. xtr_security_http/event_listener/__init__.py +21 -0
  60. xtr_security_http/event_listener/check_credentials_listener.py +162 -0
  61. xtr_security_http/event_listener/password_migrating_listener.py +79 -0
  62. xtr_security_http/event_listener/user_checker_listener.py +60 -0
  63. xtr_security_http/event_listener/user_provider_listener.py +53 -0
  64. xtr_security_http/exception/__init__.py +18 -0
  65. xtr_security_http/exception/firewall_not_booted_error.py +18 -0
  66. xtr_security_http/exception/invalid_access_token_error.py +20 -0
  67. xtr_security_http/exception/unknown_firewall_error.py +34 -0
  68. xtr_security_http/firewall/__init__.py +15 -0
  69. xtr_security_http/firewall/access_listener.py +66 -0
  70. xtr_security_http/firewall/exception_listener.py +151 -0
  71. xtr_security_http/firewall/firewall.py +142 -0
  72. xtr_security_http/firewall_context_interface.py +64 -0
  73. xtr_security_http/firewall_map.py +93 -0
  74. xtr_security_http/firewall_map_interface.py +48 -0
  75. xtr_security_http/firewall_scheme.py +114 -0
  76. xtr_security_http/firewall_scheme_registry.py +87 -0
  77. xtr_security_http/oidc/__init__.py +7 -0
  78. xtr_security_http/oidc/oidc_discovery.py +238 -0
  79. xtr_security_http/py.typed +0 -0
  80. xtr_security_http/request_matcher/__init__.py +28 -0
  81. xtr_security_http/request_matcher/_pattern.py +27 -0
  82. xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
  83. xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
  84. xtr_security_http/request_matcher/host_request_matcher.py +43 -0
  85. xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
  86. xtr_security_http/request_matcher/method_request_matcher.py +32 -0
  87. xtr_security_http/request_matcher/path_request_matcher.py +41 -0
  88. xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
  89. xtr_security_http/security_events.py +41 -0
  90. xtr_security_http-3.0.0.dist-info/METADATA +427 -0
  91. xtr_security_http-3.0.0.dist-info/RECORD +93 -0
  92. xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
  93. xtr_security_http-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,33 @@
1
+ """What starts authentication when a request reaches a protected resource unauthenticated."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from starlette.requests import Request
9
+ from starlette.responses import Response
10
+ from xtr_security_core.exception import AuthenticationError
11
+
12
+ __all__ = ["AuthenticationEntryPointInterface"]
13
+
14
+
15
+ @runtime_checkable
16
+ class AuthenticationEntryPointInterface(Protocol):
17
+ """Answers an unauthenticated request with what it takes to authenticate.
18
+
19
+ When a request reaches a protected resource without proving who is calling,
20
+ the firewall's entry point turns that into a response: a ``401`` carrying
21
+ the challenge a bearer scheme expects, a redirect to a login form. The
22
+ error that led here, when there is one, shapes the challenge.
23
+ """
24
+
25
+ async def start(self, request: Request, error: AuthenticationError | None = None) -> Response:
26
+ """Return the response that asks ``request`` to authenticate.
27
+
28
+ Args:
29
+ request: The request that reached a protected resource.
30
+ error: The authentication error that led here, when one did, so the
31
+ challenge can name why — a bad token, insufficient scope.
32
+ """
33
+ ...
@@ -0,0 +1,20 @@
1
+ """The events raised at the HTTP edge, around a request's authentication.
2
+
3
+ The passport check, the token created, and the login having succeeded or
4
+ failed. Each derives the event contract's :class:`~xtr_event_dispatcher_contracts.Event`;
5
+ :mod:`xtr_security_http.security_events` names them for a subscriber.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .authentication_token_created_event import AuthenticationTokenCreatedEvent
11
+ from .check_passport_event import CheckPassportEvent
12
+ from .login_failure_event import LoginFailureEvent
13
+ from .login_success_event import LoginSuccessEvent
14
+
15
+ __all__ = [
16
+ "AuthenticationTokenCreatedEvent",
17
+ "CheckPassportEvent",
18
+ "LoginFailureEvent",
19
+ "LoginSuccessEvent",
20
+ ]
@@ -0,0 +1,48 @@
1
+ """The event that lets a listener replace the freshly created token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_event_dispatcher_contracts import Event
8
+
9
+ if TYPE_CHECKING:
10
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
11
+
12
+ from xtr_security_http.authenticator.passport.passport import Passport
13
+
14
+ __all__ = ["AuthenticationTokenCreatedEvent"]
15
+
16
+
17
+ @final
18
+ class AuthenticationTokenCreatedEvent(Event):
19
+ """Announces that a token was created for a passport, before it is put to use.
20
+
21
+ Dispatched right after an authenticator turned a resolved passport into a
22
+ token. A listener may read the passport's badges and attributes and
23
+ :meth:`set_authenticated_token` a token of its own — a richer one carrying
24
+ extra claims, say — which then becomes the token authentication settles on.
25
+
26
+ Not a frozen dataclass: replacing the token is the whole point, so the
27
+ token it carries is mutable state.
28
+
29
+ Attributes:
30
+ passport: The passport the token was created from.
31
+ """
32
+
33
+ def __init__(self, token: TokenInterface, passport: Passport) -> None:
34
+ """Record the created token and the passport it was made from."""
35
+ self._token = token
36
+ self._passport = passport
37
+
38
+ def get_authenticated_token(self) -> TokenInterface:
39
+ """Return the token as it currently stands, replacements included."""
40
+ return self._token
41
+
42
+ def set_authenticated_token(self, token: TokenInterface) -> None:
43
+ """Replace the token authentication will settle on with ``token``."""
44
+ self._token = token
45
+
46
+ def get_passport(self) -> Passport:
47
+ """Return the passport the token was created from."""
48
+ return self._passport
@@ -0,0 +1,48 @@
1
+ """The event that lets listeners resolve and check a passport's badges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from xtr_event_dispatcher_contracts import Event
9
+
10
+ if TYPE_CHECKING:
11
+ from xtr_security_http.authenticator.authenticator_interface import AuthenticatorInterface
12
+ from xtr_security_http.authenticator.passport.passport import Passport
13
+
14
+ __all__ = ["CheckPassportEvent"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CheckPassportEvent(Event):
19
+ """Announces that a passport is ready for its badges to be resolved and checked.
20
+
21
+ Dispatched once an authenticator has produced a passport, before a token is
22
+ made from it. Listeners resolve the badges the authenticator left for them
23
+ — a user provider sets a
24
+ :class:`~xtr_security_http.authenticator.passport.badge.user_badge.UserBadge`'s
25
+ loader, a credentials listener verifies a password — each marking the badge
26
+ it handled resolved. Every badge must be resolved by the time the listeners
27
+ are done, or authentication fails.
28
+
29
+ The event is mutable in spirit: a listener reaches into the passport it
30
+ carries and changes the badges' state. The dataclass itself is frozen — the
31
+ authenticator and passport it names do not change — but stopping it skips
32
+ the listeners that have not run, exactly as any event.
33
+
34
+ Attributes:
35
+ authenticator: The authenticator that produced the passport.
36
+ passport: The passport whose badges are to be resolved and checked.
37
+ """
38
+
39
+ authenticator: AuthenticatorInterface
40
+ passport: Passport
41
+
42
+ def get_authenticator(self) -> AuthenticatorInterface:
43
+ """Return the authenticator that produced the passport."""
44
+ return self.authenticator
45
+
46
+ def get_passport(self) -> Passport:
47
+ """Return the passport whose badges are to be resolved and checked."""
48
+ return self.passport
@@ -0,0 +1,79 @@
1
+ """The event announcing a failed authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_event_dispatcher_contracts import Event
8
+
9
+ if TYPE_CHECKING:
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+ from xtr_security_core.exception import AuthenticationError
13
+
14
+ from xtr_security_http.authenticator.authenticator_interface import AuthenticatorInterface
15
+ from xtr_security_http.authenticator.passport.passport import Passport
16
+
17
+ __all__ = ["LoginFailureEvent"]
18
+
19
+
20
+ @final
21
+ class LoginFailureEvent(Event):
22
+ """Announces that authentication failed, and lets a listener answer it.
23
+
24
+ Dispatched when an authenticator raised — after the error was masked, if
25
+ the firewall hides sensitive failures — before the authenticator's failure
26
+ handler runs. A listener reads the error and the passport that got as far
27
+ as it did, and may :meth:`set_response` to answer the failure itself,
28
+ short-circuiting the failure handler.
29
+
30
+ Not a frozen dataclass: setting the response mutates it.
31
+
32
+ Attributes:
33
+ firewall_name: The firewall the authentication ran under.
34
+ """
35
+
36
+ def __init__( # noqa: PLR0913, PLR0917 -- an event carrying the failure's full context
37
+ self,
38
+ exception: AuthenticationError,
39
+ authenticator: AuthenticatorInterface,
40
+ request: Request,
41
+ passport: Passport | None,
42
+ firewall_name: str,
43
+ response: Response | None = None,
44
+ ) -> None:
45
+ """Record the error, authenticator, request, passport, firewall and response."""
46
+ self._exception = exception
47
+ self._authenticator = authenticator
48
+ self._request = request
49
+ self._passport = passport
50
+ self._firewall_name = firewall_name
51
+ self._response = response
52
+
53
+ def get_exception(self) -> AuthenticationError:
54
+ """Return the error authentication failed with."""
55
+ return self._exception
56
+
57
+ def get_authenticator(self) -> AuthenticatorInterface:
58
+ """Return the authenticator that failed to authenticate the request."""
59
+ return self._authenticator
60
+
61
+ def get_request(self) -> Request:
62
+ """Return the request that failed to authenticate."""
63
+ return self._request
64
+
65
+ def get_passport(self) -> Passport | None:
66
+ """Return the passport, if one was produced before the failure."""
67
+ return self._passport
68
+
69
+ def get_firewall_name(self) -> str:
70
+ """Return the firewall the authentication ran under."""
71
+ return self._firewall_name
72
+
73
+ def get_response(self) -> Response | None:
74
+ """Return the response a listener set to answer the failure, if any."""
75
+ return self._response
76
+
77
+ def set_response(self, response: Response | None) -> None:
78
+ """Answer the failure with ``response`` instead of the failure handler."""
79
+ self._response = response
@@ -0,0 +1,79 @@
1
+ """The event announcing a completed, stored authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from xtr_event_dispatcher_contracts import Event
8
+
9
+ if TYPE_CHECKING:
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+ from xtr_security_core.authentication.token.token_interface import TokenInterface
13
+
14
+ from xtr_security_http.authenticator.authenticator_interface import AuthenticatorInterface
15
+ from xtr_security_http.authenticator.passport.passport import Passport
16
+
17
+ __all__ = ["LoginSuccessEvent"]
18
+
19
+
20
+ @final
21
+ class LoginSuccessEvent(Event):
22
+ """Announces that authentication succeeded, was stored, and produced a response.
23
+
24
+ Dispatched after the token has been set on the storage and the
25
+ authenticator's success handler has run. A listener reads the passport —
26
+ to migrate an outdated password hash, to write an audit line — and may
27
+ replace the response the success handler produced with one of its own.
28
+
29
+ Not a frozen dataclass: a listener replacing the response mutates it.
30
+
31
+ Attributes:
32
+ authenticator: The authenticator that authenticated the request.
33
+ firewall_name: The firewall the authentication ran under.
34
+ """
35
+
36
+ def __init__( # noqa: PLR0913, PLR0917 -- an event carrying the login's full context
37
+ self,
38
+ authenticator: AuthenticatorInterface,
39
+ passport: Passport,
40
+ token: TokenInterface,
41
+ request: Request,
42
+ response: Response | None,
43
+ firewall_name: str,
44
+ ) -> None:
45
+ """Record the authenticator, passport, token, request, response and firewall."""
46
+ self._authenticator = authenticator
47
+ self._passport = passport
48
+ self._token = token
49
+ self._request = request
50
+ self._response = response
51
+ self._firewall_name = firewall_name
52
+
53
+ def get_authenticator(self) -> AuthenticatorInterface:
54
+ """Return the authenticator that authenticated the request."""
55
+ return self._authenticator
56
+
57
+ def get_passport(self) -> Passport:
58
+ """Return the passport authentication settled on."""
59
+ return self._passport
60
+
61
+ def get_authenticated_token(self) -> TokenInterface:
62
+ """Return the token authentication settled on."""
63
+ return self._token
64
+
65
+ def get_request(self) -> Request:
66
+ """Return the request that authenticated."""
67
+ return self._request
68
+
69
+ def get_response(self) -> Response | None:
70
+ """Return the response the success handler produced, if any."""
71
+ return self._response
72
+
73
+ def set_response(self, response: Response | None) -> None:
74
+ """Replace the response the login produced with ``response``."""
75
+ self._response = response
76
+
77
+ def get_firewall_name(self) -> str:
78
+ """Return the firewall the authentication ran under."""
79
+ return self._firewall_name
@@ -0,0 +1,21 @@
1
+ """The listeners that resolve a passport, check an account, and migrate a hash.
2
+
3
+ Each is a plain :class:`~xtr_event_dispatcher.EventSubscriberInterface`: the
4
+ bundle registers one with ``dispatcher.add_subscriber(listener)`` on a
5
+ firewall's own dispatcher, and its declared methods run at the priorities the
6
+ authentication sequence needs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .check_credentials_listener import CheckCredentialsListener
12
+ from .password_migrating_listener import PasswordMigratingListener
13
+ from .user_checker_listener import UserCheckerListener
14
+ from .user_provider_listener import UserProviderListener
15
+
16
+ __all__ = [
17
+ "CheckCredentialsListener",
18
+ "PasswordMigratingListener",
19
+ "UserCheckerListener",
20
+ "UserProviderListener",
21
+ ]
@@ -0,0 +1,162 @@
1
+ """The listener that verifies a passport's credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ import anyio.to_thread
8
+ from typing_extensions import override
9
+ from xtr_event_dispatcher import EventSubscriberInterface
10
+ from xtr_password_hasher import PasswordAuthenticatedUserInterface
11
+ from xtr_security_core.exception import BadCredentialsError, UserNotFoundError
12
+
13
+ from xtr_security_http.authenticator.passport.badge.password_upgrade_badge import (
14
+ PasswordUpgradeBadge,
15
+ )
16
+ from xtr_security_http.authenticator.passport.credentials.custom_credentials import (
17
+ CustomCredentials,
18
+ )
19
+ from xtr_security_http.authenticator.passport.credentials.password_credentials import (
20
+ PasswordCredentials,
21
+ )
22
+ from xtr_security_http.event.check_passport_event import CheckPassportEvent
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+ from xtr_event_dispatcher import SubscribedEvents
28
+ from xtr_password_hasher import PasswordHasherInterface, UserPasswordHasherInterface
29
+
30
+ __all__ = ["CheckCredentialsListener"]
31
+
32
+ #: The priority the credentials check runs at — after the account pre-check.
33
+ PRIORITY = 128
34
+
35
+ #: The priority the unknown-user resolve runs at — before the account pre-check.
36
+ #: Above :data:`~xtr_security_http.event_listener.user_checker_listener.PRE_PRIORITY`
37
+ #: (256) and below
38
+ #: :data:`~xtr_security_http.event_listener.user_provider_listener.PRIORITY` (2048),
39
+ #: so the badge already has its loader and the dummy is burned here, where the user
40
+ #: is first resolved, rather than letting a ``UserNotFoundError`` escape the account
41
+ #: pre-check uncharged.
42
+ RESOLVE_PRIORITY = 512
43
+
44
+ #: The throwaway plaintext hashed once, so a burn is a constant-time verify.
45
+ _DUMMY_PLAINTEXT = "dummy-password-for-timing"
46
+
47
+ #: One dummy hash per hasher instance, computed eagerly and kept, so neither the
48
+ #: first unknown-user call nor a per-request listener pays the hash cost twice.
49
+ _DUMMY_HASHES: dict[int, str] = {}
50
+
51
+
52
+ def _dummy_hash_for(hasher: PasswordHasherInterface) -> str:
53
+ """Return the dummy hash for ``hasher``, computing and caching it once."""
54
+ key = id(hasher)
55
+ cached = _DUMMY_HASHES.get(key)
56
+ if cached is None:
57
+ cached = hasher.hash(_DUMMY_PLAINTEXT)
58
+ _DUMMY_HASHES[key] = cached
59
+ return cached
60
+
61
+
62
+ @final
63
+ class CheckCredentialsListener(EventSubscriberInterface):
64
+ """Verifies the credentials a passport carries, on the passport check.
65
+
66
+ A ``PasswordCredentials`` is verified against the user's stored hash through
67
+ the user password hasher, run in a worker thread so the argon2 cost never
68
+ blocks the event loop; a match that used an outdated hash adds a
69
+ ``PasswordUpgradeBadge`` for the migrating listener. When the user is
70
+ unknown, a dummy hash is still verified — the timing of a bad username and a
71
+ bad password stays the same, so neither reveals which was wrong.
72
+
73
+ A ``CustomCredentials`` is verified by running its own check against the
74
+ resolved user.
75
+ """
76
+
77
+ __slots__ = ("_dummy_hash", "_dummy_hasher", "_hasher")
78
+
79
+ def __init__(
80
+ self,
81
+ hasher: UserPasswordHasherInterface,
82
+ dummy_hasher: PasswordHasherInterface | None = None,
83
+ ) -> None:
84
+ """Record the user password hasher and, for the timing guard, a dummy hasher."""
85
+ self._hasher = hasher
86
+ self._dummy_hasher = dummy_hasher
87
+ self._dummy_hash = _dummy_hash_for(dummy_hasher) if dummy_hasher is not None else None
88
+
89
+ @classmethod
90
+ @override
91
+ def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
92
+ """Burn for an unknown user before the pre-check, verify after it."""
93
+ return {
94
+ CheckPassportEvent: [
95
+ ("resolve_user", RESOLVE_PRIORITY),
96
+ ("check_passport", PRIORITY),
97
+ ],
98
+ }
99
+
100
+ async def resolve_user(self, event: CheckPassportEvent) -> None:
101
+ """Resolve the user a password passport names, burning a dummy on no user.
102
+
103
+ Runs before the account pre-check, which loads the user too and would
104
+ otherwise let a ``UserNotFoundError`` escape before the credentials
105
+ check could charge the dummy. Only a passport carrying an unresolved
106
+ password is touched; a known user is loaded (and cached) with no burn,
107
+ leaving the pre-check and the verify to run as before.
108
+
109
+ Raises:
110
+ BadCredentialsError: When no user answers the identifier, after a
111
+ dummy has been burned so the timing matches a wrong password.
112
+ """
113
+ passport = event.get_passport()
114
+ password_credentials = passport.get_badge(PasswordCredentials)
115
+ if password_credentials is None or password_credentials.is_resolved():
116
+ return
117
+ try:
118
+ _ = await passport.get_user()
119
+ except UserNotFoundError as error:
120
+ await self._burn_dummy(password_credentials.get_password())
121
+ raise BadCredentialsError("The presented credentials are invalid.") from error
122
+
123
+ async def check_passport(self, event: CheckPassportEvent) -> None:
124
+ """Verify the password and any custom credentials the passport carries."""
125
+ passport = event.get_passport()
126
+ password_credentials = passport.get_badge(PasswordCredentials)
127
+ if password_credentials is not None and not password_credentials.is_resolved():
128
+ await self._check_password(event, password_credentials)
129
+ custom_credentials = passport.get_badge(CustomCredentials)
130
+ if custom_credentials is not None and not custom_credentials.is_resolved():
131
+ user = await passport.get_user()
132
+ if not await custom_credentials.verify(user):
133
+ raise BadCredentialsError("The presented credentials are invalid.")
134
+
135
+ async def _check_password(
136
+ self,
137
+ event: CheckPassportEvent,
138
+ credentials: PasswordCredentials,
139
+ ) -> None:
140
+ """Verify the plaintext against the user's hash, burning a dummy on no user."""
141
+ passport = event.get_passport()
142
+ plaintext = credentials.get_password()
143
+ try:
144
+ user = await passport.get_user()
145
+ except UserNotFoundError as error:
146
+ await self._burn_dummy(plaintext)
147
+ raise BadCredentialsError("The presented credentials are invalid.") from error
148
+ if not isinstance(user, PasswordAuthenticatedUserInterface):
149
+ await self._burn_dummy(plaintext)
150
+ raise BadCredentialsError("The user carries no password to verify against.")
151
+ valid = await anyio.to_thread.run_sync(self._hasher.is_password_valid, user, plaintext)
152
+ if not valid:
153
+ raise BadCredentialsError("The presented credentials are invalid.")
154
+ credentials.mark_resolved()
155
+ if await anyio.to_thread.run_sync(self._hasher.needs_rehash, user):
156
+ _ = passport.add_badge(PasswordUpgradeBadge(plaintext))
157
+
158
+ async def _burn_dummy(self, plaintext: str) -> None:
159
+ """Verify a throwaway hash, so an unknown user costs the same as a wrong password."""
160
+ if self._dummy_hasher is None or self._dummy_hash is None:
161
+ return
162
+ _ = await anyio.to_thread.run_sync(self._dummy_hasher.verify, self._dummy_hash, plaintext)
@@ -0,0 +1,79 @@
1
+ """The listener that re-stores a verified password under the current hash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ import anyio.to_thread
8
+ from typing_extensions import override
9
+ from xtr_event_dispatcher import EventSubscriberInterface
10
+ from xtr_password_hasher import PasswordAuthenticatedUserInterface
11
+
12
+ from xtr_security_http.authenticator.passport.badge.password_upgrade_badge import (
13
+ PasswordUpgradeBadge,
14
+ )
15
+ from xtr_security_http.event.login_success_event import LoginSuccessEvent
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Mapping
19
+
20
+ from xtr_event_dispatcher import SubscribedEvents
21
+ from xtr_logging_contracts import LoggerInterface
22
+ from xtr_password_hasher import UserPasswordHasherInterface
23
+ from xtr_security_core.user.password_upgrader_interface import PasswordUpgraderInterface
24
+
25
+ __all__ = ["PasswordMigratingListener"]
26
+
27
+
28
+ @final
29
+ class PasswordMigratingListener(EventSubscriberInterface):
30
+ """Rehashes and re-stores a password whose old hash the login flagged as outdated.
31
+
32
+ A successful login that verified against an outdated hash left a
33
+ :class:`~xtr_security_http.authenticator.passport.badge.password_upgrade_badge.PasswordUpgradeBadge`
34
+ on the passport. This listener rehashes the plaintext with the current
35
+ algorithm in a worker thread and hands the new hash to an upgrader to
36
+ store. The upgrade is best-effort — it runs after the caller is already
37
+ authenticated — so a failure is swallowed, and the hash is upgraded on the
38
+ next login instead.
39
+ """
40
+
41
+ __slots__ = ("_hasher", "_logger", "_password_upgrader")
42
+
43
+ def __init__(
44
+ self,
45
+ hasher: UserPasswordHasherInterface,
46
+ password_upgrader: PasswordUpgraderInterface | None = None,
47
+ logger: LoggerInterface | None = None,
48
+ ) -> None:
49
+ """Record the hasher, the upgrader used when a badge carries none, and a logger."""
50
+ self._hasher = hasher
51
+ self._password_upgrader = password_upgrader
52
+ self._logger = logger
53
+
54
+ @classmethod
55
+ @override
56
+ def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
57
+ """Listen to a successful, stored login."""
58
+ return {LoginSuccessEvent: "on_login_success"}
59
+
60
+ async def on_login_success(self, event: LoginSuccessEvent) -> None:
61
+ """Rehash the flagged password and store it through an upgrader."""
62
+ badge = event.get_passport().get_badge(PasswordUpgradeBadge)
63
+ if badge is None:
64
+ return
65
+ plaintext = badge.get_and_erase_plaintext_password()
66
+ if not plaintext:
67
+ return
68
+ upgrader = badge.get_password_upgrader() or self._password_upgrader
69
+ if upgrader is None:
70
+ return
71
+ user = event.get_authenticated_token().get_user()
72
+ if not isinstance(user, PasswordAuthenticatedUserInterface):
73
+ return
74
+ try:
75
+ new_hash = await anyio.to_thread.run_sync(self._hasher.hash_password, user, plaintext)
76
+ await upgrader.upgrade_password(user, new_hash)
77
+ except Exception: # noqa: BLE001 -- an opportunistic upgrade must never fail the login
78
+ if self._logger is not None:
79
+ self._logger.warning("Could not upgrade the password hash after a login.", {})
@@ -0,0 +1,60 @@
1
+ """The listener that runs the account checks around authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, final
6
+
7
+ from typing_extensions import override
8
+ from xtr_event_dispatcher import EventSubscriberInterface
9
+ from xtr_security_core.event.authentication_success_event import AuthenticationSuccessEvent
10
+
11
+ from xtr_security_http.event.check_passport_event import CheckPassportEvent
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Mapping
15
+
16
+ from xtr_event_dispatcher import SubscribedEvents
17
+ from xtr_security_core.user.user_checker_interface import UserCheckerInterface
18
+
19
+ __all__ = ["UserCheckerListener"]
20
+
21
+ #: The priority the pre-authentication check runs at on the passport check.
22
+ PRE_PRIORITY = 256
23
+
24
+
25
+ @final
26
+ class UserCheckerListener(EventSubscriberInterface):
27
+ """Gates a user's own state, before credentials are checked and again after.
28
+
29
+ Before the credentials check, on the passport check, it loads the user and
30
+ runs the pre-authentication check — the account must not be disabled or
31
+ locked to go on. After a token is settled, on the success event, it runs
32
+ the post-authentication check against the user the token carries.
33
+ """
34
+
35
+ __slots__ = ("_user_checker",)
36
+
37
+ def __init__(self, user_checker: UserCheckerInterface) -> None:
38
+ """Record the checker the account state is gated by."""
39
+ self._user_checker = user_checker
40
+
41
+ @classmethod
42
+ @override
43
+ def get_subscribed_events(cls) -> Mapping[str | type, SubscribedEvents]:
44
+ """Check pre-auth on the passport check, post-auth on the success event."""
45
+ return {
46
+ CheckPassportEvent: ("pre_check_credentials", PRE_PRIORITY),
47
+ AuthenticationSuccessEvent: "post_check_credentials",
48
+ }
49
+
50
+ async def pre_check_credentials(self, event: CheckPassportEvent) -> None:
51
+ """Load the user and run the pre-authentication account check."""
52
+ user = await event.get_passport().get_user()
53
+ await self._user_checker.check_pre_auth(user)
54
+
55
+ async def post_check_credentials(self, event: AuthenticationSuccessEvent) -> None:
56
+ """Run the post-authentication account check against the token's user."""
57
+ token = event.get_authentication_token()
58
+ user = token.get_user()
59
+ if user is not None:
60
+ await self._user_checker.check_post_auth(user, token)