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.
- xtr_security_http/__init__.py +143 -0
- xtr_security_http/_runner.py +134 -0
- xtr_security_http/_state.py +57 -0
- xtr_security_http/access_map.py +52 -0
- xtr_security_http/access_map_interface.py +24 -0
- xtr_security_http/access_token/__init__.py +19 -0
- xtr_security_http/access_token/access_token_extractor_interface.py +31 -0
- xtr_security_http/access_token/access_token_handler_interface.py +34 -0
- xtr_security_http/access_token/chain_access_token_extractor.py +58 -0
- xtr_security_http/access_token/form_encoded_body_extractor.py +57 -0
- xtr_security_http/access_token/header_access_token_extractor.py +80 -0
- xtr_security_http/access_token/oidc/__init__.py +41 -0
- xtr_security_http/access_token/oidc/exception/__init__.py +7 -0
- xtr_security_http/access_token/oidc/exception/oidc_key_set_error.py +18 -0
- xtr_security_http/access_token/oidc/oidc_token_handler.py +246 -0
- xtr_security_http/access_token/query_access_token_extractor.py +51 -0
- xtr_security_http/authentication/__init__.py +24 -0
- xtr_security_http/authentication/_sensitive.py +60 -0
- xtr_security_http/authentication/authentication_failure_handler_interface.py +31 -0
- xtr_security_http/authentication/authentication_success_handler_interface.py +31 -0
- xtr_security_http/authentication/authenticator_manager.py +232 -0
- xtr_security_http/authentication/authenticator_manager_interface.py +40 -0
- xtr_security_http/authentication/expose_security_level.py +27 -0
- xtr_security_http/authenticator/__init__.py +9 -0
- xtr_security_http/authenticator/abstract_authenticator.py +66 -0
- xtr_security_http/authenticator/access_token_authenticator.py +199 -0
- xtr_security_http/authenticator/authenticator_interface.py +68 -0
- xtr_security_http/authenticator/oidc/__init__.py +7 -0
- xtr_security_http/authenticator/oidc/oidc_jwks.py +87 -0
- xtr_security_http/authenticator/passport/__init__.py +8 -0
- xtr_security_http/authenticator/passport/badge/__init__.py +16 -0
- xtr_security_http/authenticator/passport/badge/badge_interface.py +24 -0
- xtr_security_http/authenticator/passport/badge/password_upgrade_badge.py +59 -0
- xtr_security_http/authenticator/passport/badge/pre_authenticated_user_badge.py +27 -0
- xtr_security_http/authenticator/passport/badge/user_badge.py +163 -0
- xtr_security_http/authenticator/passport/credentials/__init__.py +9 -0
- xtr_security_http/authenticator/passport/credentials/credentials_interface.py +21 -0
- xtr_security_http/authenticator/passport/credentials/custom_credentials.py +64 -0
- xtr_security_http/authenticator/passport/credentials/password_credentials.py +51 -0
- xtr_security_http/authenticator/passport/passport.py +104 -0
- xtr_security_http/authenticator/passport/self_validating_passport.py +42 -0
- xtr_security_http/authenticator/token/__init__.py +7 -0
- xtr_security_http/authenticator/token/post_authentication_token.py +43 -0
- xtr_security_http/authorization/__init__.py +15 -0
- xtr_security_http/authorization/access_denied_handler_interface.py +27 -0
- xtr_security_http/authorization/insufficient_scope_access_denied_handler.py +66 -0
- xtr_security_http/authorization/oauth2_scope_voter.py +122 -0
- xtr_security_http/decorator/__init__.py +16 -0
- xtr_security_http/decorator/current_user.py +83 -0
- xtr_security_http/decorator/is_granted.py +199 -0
- xtr_security_http/decorator/is_granted_context.py +14 -0
- xtr_security_http/entry_point/__init__.py +7 -0
- xtr_security_http/entry_point/authentication_entry_point_interface.py +33 -0
- xtr_security_http/event/__init__.py +20 -0
- xtr_security_http/event/authentication_token_created_event.py +48 -0
- xtr_security_http/event/check_passport_event.py +48 -0
- xtr_security_http/event/login_failure_event.py +79 -0
- xtr_security_http/event/login_success_event.py +79 -0
- xtr_security_http/event_listener/__init__.py +21 -0
- xtr_security_http/event_listener/check_credentials_listener.py +162 -0
- xtr_security_http/event_listener/password_migrating_listener.py +79 -0
- xtr_security_http/event_listener/user_checker_listener.py +60 -0
- xtr_security_http/event_listener/user_provider_listener.py +53 -0
- xtr_security_http/exception/__init__.py +18 -0
- xtr_security_http/exception/firewall_not_booted_error.py +18 -0
- xtr_security_http/exception/invalid_access_token_error.py +20 -0
- xtr_security_http/exception/unknown_firewall_error.py +34 -0
- xtr_security_http/firewall/__init__.py +15 -0
- xtr_security_http/firewall/access_listener.py +66 -0
- xtr_security_http/firewall/exception_listener.py +151 -0
- xtr_security_http/firewall/firewall.py +142 -0
- xtr_security_http/firewall_context_interface.py +64 -0
- xtr_security_http/firewall_map.py +93 -0
- xtr_security_http/firewall_map_interface.py +48 -0
- xtr_security_http/firewall_scheme.py +114 -0
- xtr_security_http/firewall_scheme_registry.py +87 -0
- xtr_security_http/oidc/__init__.py +7 -0
- xtr_security_http/oidc/oidc_discovery.py +238 -0
- xtr_security_http/py.typed +0 -0
- xtr_security_http/request_matcher/__init__.py +28 -0
- xtr_security_http/request_matcher/_pattern.py +27 -0
- xtr_security_http/request_matcher/callable_request_matcher.py +37 -0
- xtr_security_http/request_matcher/chain_request_matcher.py +37 -0
- xtr_security_http/request_matcher/host_request_matcher.py +43 -0
- xtr_security_http/request_matcher/ip_request_matcher.py +37 -0
- xtr_security_http/request_matcher/method_request_matcher.py +32 -0
- xtr_security_http/request_matcher/path_request_matcher.py +41 -0
- xtr_security_http/request_matcher/request_matcher_interface.py +25 -0
- xtr_security_http/security_events.py +41 -0
- xtr_security_http-3.0.0.dist-info/METADATA +427 -0
- xtr_security_http-3.0.0.dist-info/RECORD +93 -0
- xtr_security_http-3.0.0.dist-info/WHEEL +4 -0
- 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)
|