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,87 @@
|
|
|
1
|
+
"""The keys a third-party OIDC token is verified against, and a fixed source of them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol, cast, final, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from joserfc.errors import JoseError
|
|
9
|
+
from joserfc.jwk import KeySet
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
from xtr_security_core.exception import InvalidArgumentError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from joserfc._keys import KeySetSerialization
|
|
17
|
+
|
|
18
|
+
__all__ = ["OidcKeySetProviderInterface", "StaticOidcKeySetProvider"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class OidcKeySetProviderInterface(Protocol):
|
|
23
|
+
"""Supplies the public keys a third-party OIDC token is verified against.
|
|
24
|
+
|
|
25
|
+
A token names its key by ``kid``; the whole set is handed to the verifier,
|
|
26
|
+
which picks the one that matches. A remote provider caches the set, and
|
|
27
|
+
``force_refresh`` asks it to fetch again — the handler does so once when a
|
|
28
|
+
token names a ``kid`` the cached set does not hold, a key rotation the
|
|
29
|
+
provider had not yet seen.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def get_key_set(self, *, force_refresh: bool = False) -> KeySet:
|
|
33
|
+
"""Return the key set a token is verified against.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
force_refresh: Fetch a fresh set rather than answer from the cache,
|
|
37
|
+
subject to a provider's own cooldown.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
OidcKeySetError: When the key set cannot be fetched, discovered or
|
|
41
|
+
read.
|
|
42
|
+
"""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@final
|
|
47
|
+
class StaticOidcKeySetProvider(OidcKeySetProviderInterface):
|
|
48
|
+
"""Holds a fixed key set read from a JWKS document.
|
|
49
|
+
|
|
50
|
+
For an issuer whose keys are known ahead of time and pinned in the
|
|
51
|
+
configuration, rather than fetched. The document is read once, as the
|
|
52
|
+
provider is made, so a malformed one fails here rather than at the first
|
|
53
|
+
token; :meth:`get_key_set` always answers the same set, ``force_refresh``
|
|
54
|
+
or not, since there is nowhere to refresh from.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("_key_set",)
|
|
58
|
+
|
|
59
|
+
def __init__(self, jwks_json: str | Mapping[str, object]) -> None:
|
|
60
|
+
"""Build the key set from ``jwks_json``, a JWKS string or mapping.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
InvalidArgumentError: When the document is not a readable JWK set.
|
|
64
|
+
"""
|
|
65
|
+
self._key_set = _read_key_set(jwks_json)
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
async def get_key_set(self, *, force_refresh: bool = False) -> KeySet:
|
|
69
|
+
"""Return the fixed key set, whether or not a refresh is asked for."""
|
|
70
|
+
del force_refresh
|
|
71
|
+
return self._key_set
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_key_set(jwks_json: str | Mapping[str, object]) -> KeySet:
|
|
75
|
+
"""Turn a JWKS string or mapping into a joserfc key set.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
InvalidArgumentError: When the document is not a readable JWK set.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
document: object = json.loads(jwks_json) if isinstance(jwks_json, str) else dict(jwks_json)
|
|
82
|
+
if not isinstance(document, dict):
|
|
83
|
+
raise InvalidArgumentError("A JWK set document must be a JSON object.")
|
|
84
|
+
serialization = cast("KeySetSerialization", cast("object", document))
|
|
85
|
+
return KeySet.import_key_set(serialization)
|
|
86
|
+
except (JoseError, ValueError) as error:
|
|
87
|
+
raise InvalidArgumentError(f"The JWK set document could not be read: {error}") from error
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The badges a passport carries: the user, and notes about the authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .badge_interface import BadgeInterface
|
|
6
|
+
from .password_upgrade_badge import PasswordUpgradeBadge
|
|
7
|
+
from .pre_authenticated_user_badge import PreAuthenticatedUserBadge
|
|
8
|
+
from .user_badge import MAX_USERNAME_LENGTH, UserBadge
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MAX_USERNAME_LENGTH",
|
|
12
|
+
"BadgeInterface",
|
|
13
|
+
"PasswordUpgradeBadge",
|
|
14
|
+
"PreAuthenticatedUserBadge",
|
|
15
|
+
"UserBadge",
|
|
16
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""What a badge — a single fact a passport carries — answers to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
__all__ = ["BadgeInterface"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class BadgeInterface(Protocol):
|
|
12
|
+
"""One fact about an authentication, resolved before a token is made.
|
|
13
|
+
|
|
14
|
+
A passport is a bag of badges: the user's identifier, the password to
|
|
15
|
+
verify, a note that a password should be upgraded. Each badge starts
|
|
16
|
+
unresolved and is marked resolved by whoever handles it — a listener on the
|
|
17
|
+
passport-check event, or the badge itself when it needs nothing. Every
|
|
18
|
+
badge must report itself resolved by the time the check is done, or
|
|
19
|
+
authentication fails.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def is_resolved(self) -> bool:
|
|
23
|
+
"""Tell whether this badge has been handled and needs nothing more."""
|
|
24
|
+
...
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""A badge asking that a verified password be re-stored under a fresh hash."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from .badge_interface import BadgeInterface
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from xtr_security_core.user.password_upgrader_interface import PasswordUpgraderInterface
|
|
13
|
+
|
|
14
|
+
__all__ = ["PasswordUpgradeBadge"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
class PasswordUpgradeBadge(BadgeInterface):
|
|
19
|
+
"""Carries the plaintext and the place to write its upgraded hash to.
|
|
20
|
+
|
|
21
|
+
Added to a passport when a password verified against an outdated hash, so
|
|
22
|
+
the migrating listener can re-hash the plaintext with the current
|
|
23
|
+
algorithm and hand it to an upgrader to store. It resolves on its own — an
|
|
24
|
+
upgrade is opportunistic, never a condition of authentication.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
plaintext_password: The verified plaintext, to be re-hashed.
|
|
28
|
+
password_upgrader: Where the fresh hash is written, when one is known.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_password_upgrader", "_plaintext_password")
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
plaintext_password: str,
|
|
36
|
+
password_upgrader: PasswordUpgraderInterface | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Record the plaintext and, when known, where to store its new hash."""
|
|
39
|
+
self._plaintext_password = plaintext_password
|
|
40
|
+
self._password_upgrader = password_upgrader
|
|
41
|
+
|
|
42
|
+
def get_and_erase_plaintext_password(self) -> str:
|
|
43
|
+
"""Return the plaintext once, then drop it so it is not held longer."""
|
|
44
|
+
plaintext = self._plaintext_password
|
|
45
|
+
self._plaintext_password = ""
|
|
46
|
+
return plaintext
|
|
47
|
+
|
|
48
|
+
def get_password_upgrader(self) -> PasswordUpgraderInterface | None:
|
|
49
|
+
"""Return the upgrader the new hash is written to, if one was set."""
|
|
50
|
+
return self._password_upgrader
|
|
51
|
+
|
|
52
|
+
def set_password_upgrader(self, password_upgrader: PasswordUpgraderInterface) -> None:
|
|
53
|
+
"""Set the upgrader the migrating listener writes the new hash to."""
|
|
54
|
+
self._password_upgrader = password_upgrader
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def is_resolved(self) -> bool:
|
|
58
|
+
"""Report resolved always: an upgrade never gates authentication."""
|
|
59
|
+
return True
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A badge marking a user pre-authenticated, needing no credential check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from .badge_interface import BadgeInterface
|
|
10
|
+
|
|
11
|
+
__all__ = ["PreAuthenticatedUserBadge"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
class PreAuthenticatedUserBadge(BadgeInterface):
|
|
16
|
+
"""States that the user was already authenticated elsewhere.
|
|
17
|
+
|
|
18
|
+
A bearer token was verified by its handler before ever reaching a
|
|
19
|
+
passport; there are no credentials on the passport to check. This badge
|
|
20
|
+
stands for that fact — it is resolved from the moment it exists, so the
|
|
21
|
+
credentials listener knows there is nothing to verify.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@override
|
|
25
|
+
def is_resolved(self) -> bool:
|
|
26
|
+
"""Report resolved always: a pre-authenticated user needs no check."""
|
|
27
|
+
return True
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The badge naming the user an authentication is for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import TYPE_CHECKING, Final, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
from xtr_security_core.exception import AuthenticationServiceError
|
|
10
|
+
from xtr_security_core.user.user_interface import UserInterface
|
|
11
|
+
|
|
12
|
+
from .badge_interface import BadgeInterface
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
16
|
+
|
|
17
|
+
__all__ = ["MAX_USERNAME_LENGTH", "UserBadge"]
|
|
18
|
+
|
|
19
|
+
MAX_USERNAME_LENGTH: Final = 4096
|
|
20
|
+
"""The longest identifier a badge accepts, guarding against a runaway input."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class UserBadge(BadgeInterface):
|
|
25
|
+
"""Names the user an authentication is for, and loads them on demand.
|
|
26
|
+
|
|
27
|
+
The identifier is what authentication proved — a username, a token's
|
|
28
|
+
``sub``. A loader turns it into a user: passed in by the authenticator, or
|
|
29
|
+
set by a user-provider listener during the passport check. Attributes
|
|
30
|
+
carried alongside — a token's claims — are handed to the loader, so an
|
|
31
|
+
attributes-based provider can build the user from more than the identifier.
|
|
32
|
+
|
|
33
|
+
The badge resolves as soon as its loader is set; :meth:`get_user` runs the
|
|
34
|
+
loader once and caches the user. An identifier longer than
|
|
35
|
+
:data:`MAX_USERNAME_LENGTH` is refused at construction.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_attributes", "_identifier", "_user", "_user_loader")
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
identifier: str,
|
|
43
|
+
user_loader: Callable[..., UserInterface | Awaitable[UserInterface]] | None = None,
|
|
44
|
+
attributes: Mapping[str, object] | None = None,
|
|
45
|
+
identifier_normalizer: Callable[[str], str] | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Record the identifier, an optional loader and the attributes.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
identifier: What authentication proved the caller to be.
|
|
51
|
+
user_loader: Turns the identifier (and attributes) into a user;
|
|
52
|
+
a provider listener sets one during the check when omitted.
|
|
53
|
+
attributes: Extra data — a token's claims — handed to the loader.
|
|
54
|
+
identifier_normalizer: Applied to the identifier before it is
|
|
55
|
+
stored, for a provider that folds case or trims it.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
BadCredentialsError: When the identifier is empty.
|
|
59
|
+
InvalidArgumentError: When the identifier is longer than
|
|
60
|
+
:data:`MAX_USERNAME_LENGTH`.
|
|
61
|
+
"""
|
|
62
|
+
from xtr_security_core.exception import ( # noqa: PLC0415 -- local: only the constructor validates
|
|
63
|
+
BadCredentialsError,
|
|
64
|
+
InvalidArgumentError,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if identifier_normalizer is not None:
|
|
68
|
+
identifier = identifier_normalizer(identifier)
|
|
69
|
+
if not identifier:
|
|
70
|
+
raise BadCredentialsError("The user identifier must not be empty.")
|
|
71
|
+
if len(identifier) > MAX_USERNAME_LENGTH:
|
|
72
|
+
raise InvalidArgumentError(
|
|
73
|
+
f"The user identifier is too long, {MAX_USERNAME_LENGTH} characters at most.",
|
|
74
|
+
)
|
|
75
|
+
self._identifier = identifier
|
|
76
|
+
self._user_loader = user_loader
|
|
77
|
+
self._attributes: dict[str, object] = dict(attributes) if attributes is not None else {}
|
|
78
|
+
self._user: UserInterface | None = None
|
|
79
|
+
|
|
80
|
+
def get_user_identifier(self) -> str:
|
|
81
|
+
"""Return the identifier this badge names."""
|
|
82
|
+
return self._identifier
|
|
83
|
+
|
|
84
|
+
def get_loaded_user(self) -> UserInterface:
|
|
85
|
+
"""Return the user :meth:`get_user` already loaded, without doing I/O.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AuthenticationServiceError: When the user has not been loaded yet,
|
|
89
|
+
which a token being created before the passport check would be.
|
|
90
|
+
"""
|
|
91
|
+
if self._user is None:
|
|
92
|
+
raise AuthenticationServiceError(
|
|
93
|
+
"The user badge's user has not been loaded; the passport check must run first.",
|
|
94
|
+
)
|
|
95
|
+
return self._user
|
|
96
|
+
|
|
97
|
+
def get_attributes(self) -> Mapping[str, object]:
|
|
98
|
+
"""Return the attributes handed to the loader."""
|
|
99
|
+
return dict(self._attributes)
|
|
100
|
+
|
|
101
|
+
def get_user_loader(self) -> Callable[..., UserInterface | Awaitable[UserInterface]] | None:
|
|
102
|
+
"""Return the loader set for this badge, or ``None``."""
|
|
103
|
+
return self._user_loader
|
|
104
|
+
|
|
105
|
+
def set_user_loader(
|
|
106
|
+
self,
|
|
107
|
+
user_loader: Callable[..., UserInterface | Awaitable[UserInterface]],
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Set the loader a provider listener resolved for this badge."""
|
|
110
|
+
self._user_loader = user_loader
|
|
111
|
+
|
|
112
|
+
async def get_user(self) -> UserInterface:
|
|
113
|
+
"""Load the user this badge names, once, and return it.
|
|
114
|
+
|
|
115
|
+
The loader is called with the identifier, and with the attributes when
|
|
116
|
+
it accepts a second argument. Its result is awaited when it is an
|
|
117
|
+
awaitable.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
AuthenticationServiceError: When no loader was set, or the loader
|
|
121
|
+
returned something that is not a user.
|
|
122
|
+
UserNotFoundError: When the loader reports no such user.
|
|
123
|
+
"""
|
|
124
|
+
if self._user is not None:
|
|
125
|
+
return self._user
|
|
126
|
+
if self._user_loader is None:
|
|
127
|
+
raise AuthenticationServiceError(
|
|
128
|
+
"No user loader is set on the user badge; a user provider must set one.",
|
|
129
|
+
)
|
|
130
|
+
result = self._call_loader(self._user_loader)
|
|
131
|
+
loaded: object = await result if inspect.isawaitable(result) else result
|
|
132
|
+
if not isinstance(loaded, UserInterface):
|
|
133
|
+
raise AuthenticationServiceError("The user loader did not return a user.")
|
|
134
|
+
self._user = loaded
|
|
135
|
+
return loaded
|
|
136
|
+
|
|
137
|
+
def _call_loader(
|
|
138
|
+
self,
|
|
139
|
+
loader: Callable[..., UserInterface | Awaitable[UserInterface]],
|
|
140
|
+
) -> object:
|
|
141
|
+
"""Call ``loader`` with the identifier, and the attributes when it takes them."""
|
|
142
|
+
try:
|
|
143
|
+
signature = inspect.signature(loader)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return loader(self._identifier)
|
|
146
|
+
positional = [
|
|
147
|
+
parameter
|
|
148
|
+
for parameter in signature.parameters.values()
|
|
149
|
+
if parameter.kind
|
|
150
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
151
|
+
]
|
|
152
|
+
takes_varargs = any(
|
|
153
|
+
parameter.kind is inspect.Parameter.VAR_POSITIONAL
|
|
154
|
+
for parameter in signature.parameters.values()
|
|
155
|
+
)
|
|
156
|
+
if len(positional) >= 2 or takes_varargs: # noqa: PLR2004 -- identifier plus attributes
|
|
157
|
+
return loader(self._identifier, dict(self._attributes))
|
|
158
|
+
return loader(self._identifier)
|
|
159
|
+
|
|
160
|
+
@override
|
|
161
|
+
def is_resolved(self) -> bool:
|
|
162
|
+
"""Tell whether a loader is set, so the user can be loaded."""
|
|
163
|
+
return self._user_loader is not None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The credentials a passport carries for a listener to verify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .credentials_interface import CredentialsInterface
|
|
6
|
+
from .custom_credentials import CustomCredentials
|
|
7
|
+
from .password_credentials import PasswordCredentials
|
|
8
|
+
|
|
9
|
+
__all__ = ["CredentialsInterface", "CustomCredentials", "PasswordCredentials"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""What a set of credentials on a passport answers to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from xtr_security_http.authenticator.passport.badge.badge_interface import BadgeInterface
|
|
8
|
+
|
|
9
|
+
__all__ = ["CredentialsInterface"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class CredentialsInterface(BadgeInterface, Protocol):
|
|
14
|
+
"""A credential to verify, carried on a passport as a badge.
|
|
15
|
+
|
|
16
|
+
A password to check, a signature to validate. It is a
|
|
17
|
+
:class:`~xtr_security_http.authenticator.passport.badge.badge_interface.BadgeInterface`
|
|
18
|
+
like any other, unresolved until the listener that knows how verifies it —
|
|
19
|
+
at which point it reports resolved and drops whatever secret it held, so a
|
|
20
|
+
credential is consumed exactly once.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A credential verified by a caller-supplied check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import TYPE_CHECKING, final
|
|
7
|
+
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from .credentials_interface import CredentialsInterface
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
__all__ = ["CustomCredentials"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class CustomCredentials(CredentialsInterface):
|
|
20
|
+
"""A credential whose verification an authenticator supplies as a check.
|
|
21
|
+
|
|
22
|
+
The escape hatch for a scheme the built-in credentials do not cover: the
|
|
23
|
+
check is a callable given the credentials and the resolved user, returning
|
|
24
|
+
whether they are valid — awaited when it returns an awaitable. The
|
|
25
|
+
credentials listener runs it, and a falsy result fails authentication.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
checker: The callable that verifies ``credentials`` for a user.
|
|
29
|
+
credentials: The value handed to the checker.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("_checker", "_credentials", "_resolved")
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
checker: Callable[..., bool | Awaitable[bool]],
|
|
37
|
+
credentials: object,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Record the check and the credentials it verifies."""
|
|
40
|
+
self._checker = checker
|
|
41
|
+
self._credentials = credentials
|
|
42
|
+
self._resolved = False
|
|
43
|
+
|
|
44
|
+
async def verify(self, user: object) -> bool:
|
|
45
|
+
"""Run the check for ``user`` and mark the credentials resolved.
|
|
46
|
+
|
|
47
|
+
The checker is called with the credentials and the user; a result that
|
|
48
|
+
is an awaitable is awaited. Resolving happens whatever the outcome — a
|
|
49
|
+
credential is consumed by being checked, valid or not.
|
|
50
|
+
"""
|
|
51
|
+
result = self._checker(self._credentials, user)
|
|
52
|
+
if inspect.isawaitable(result):
|
|
53
|
+
result = await result
|
|
54
|
+
self._resolved = True
|
|
55
|
+
return bool(result)
|
|
56
|
+
|
|
57
|
+
def get_credentials(self) -> object:
|
|
58
|
+
"""Return the credentials the checker verifies."""
|
|
59
|
+
return self._credentials
|
|
60
|
+
|
|
61
|
+
@override
|
|
62
|
+
def is_resolved(self) -> bool:
|
|
63
|
+
"""Tell whether the check has run."""
|
|
64
|
+
return self._resolved
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""A plaintext password to verify, consumed once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_security_core.exception import InvalidArgumentError
|
|
9
|
+
|
|
10
|
+
from .credentials_interface import CredentialsInterface
|
|
11
|
+
|
|
12
|
+
__all__ = ["PasswordCredentials"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class PasswordCredentials(CredentialsInterface):
|
|
17
|
+
"""A plaintext password waiting to be verified.
|
|
18
|
+
|
|
19
|
+
Carried on a passport for the credentials listener to check against the
|
|
20
|
+
user's stored hash. Verifying it calls :meth:`mark_resolved`, which drops
|
|
21
|
+
the plaintext: it is held only until the one check that reads it, never
|
|
22
|
+
longer. Reading the plaintext after it is resolved is refused.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__slots__ = ("_password", "_resolved")
|
|
26
|
+
|
|
27
|
+
def __init__(self, password: str) -> None:
|
|
28
|
+
"""Record the plaintext password to verify."""
|
|
29
|
+
self._password = password
|
|
30
|
+
self._resolved = False
|
|
31
|
+
|
|
32
|
+
def get_password(self) -> str:
|
|
33
|
+
"""Return the plaintext to verify, before it is consumed.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
InvalidArgumentError: When the credentials are already resolved and
|
|
37
|
+
the plaintext has been dropped.
|
|
38
|
+
"""
|
|
39
|
+
if self._resolved:
|
|
40
|
+
raise InvalidArgumentError("The password credentials have already been resolved.")
|
|
41
|
+
return self._password
|
|
42
|
+
|
|
43
|
+
def mark_resolved(self) -> None:
|
|
44
|
+
"""Mark the credentials verified and drop the plaintext."""
|
|
45
|
+
self._resolved = True
|
|
46
|
+
self._password = ""
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
def is_resolved(self) -> bool:
|
|
50
|
+
"""Tell whether the credentials have been verified."""
|
|
51
|
+
return self._resolved
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The bag of badges an authenticator hands to the passport check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, TypeVar
|
|
6
|
+
|
|
7
|
+
from xtr_security_core.exception import BadCredentialsError
|
|
8
|
+
|
|
9
|
+
from xtr_security_http.authenticator.passport.badge.user_badge import UserBadge
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
|
|
14
|
+
from xtr_security_core.user.user_interface import UserInterface
|
|
15
|
+
|
|
16
|
+
from .badge.badge_interface import BadgeInterface
|
|
17
|
+
|
|
18
|
+
__all__ = ["Passport"]
|
|
19
|
+
|
|
20
|
+
_BadgeT = TypeVar("_BadgeT", bound="BadgeInterface")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Passport:
|
|
24
|
+
"""What an authenticator produced from a request, before a token exists.
|
|
25
|
+
|
|
26
|
+
A passport always carries a
|
|
27
|
+
:class:`~xtr_security_http.authenticator.passport.badge.user_badge.UserBadge`
|
|
28
|
+
naming the user, and any number of other badges — credentials to verify, a
|
|
29
|
+
note to upgrade a password. Listeners resolve the badges during the
|
|
30
|
+
passport check; :meth:`get_user` loads the user through the user badge.
|
|
31
|
+
Attributes are a scratch space listeners and the authenticator share while
|
|
32
|
+
building the token.
|
|
33
|
+
|
|
34
|
+
Badges are keyed by class: a passport carries at most one of each kind.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_attributes", "_badges")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
user_badge: UserBadge,
|
|
42
|
+
badges: Iterable[BadgeInterface] = (),
|
|
43
|
+
attributes: Mapping[str, object] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Record the user badge and any other badges, keyed by their class."""
|
|
46
|
+
self._badges: dict[type, BadgeInterface] = {}
|
|
47
|
+
_ = self.add_badge(user_badge)
|
|
48
|
+
for badge in badges:
|
|
49
|
+
_ = self.add_badge(badge)
|
|
50
|
+
self._attributes: dict[str, object] = dict(attributes) if attributes is not None else {}
|
|
51
|
+
|
|
52
|
+
def add_badge(self, badge: BadgeInterface) -> Passport:
|
|
53
|
+
"""Attach ``badge``, replacing any badge of its class, and return self."""
|
|
54
|
+
self._badges[type(badge)] = badge
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def has_badge(self, badge_class: type[BadgeInterface]) -> bool:
|
|
58
|
+
"""Tell whether a badge of ``badge_class`` is attached."""
|
|
59
|
+
return badge_class in self._badges
|
|
60
|
+
|
|
61
|
+
def get_badge(self, badge_class: type[_BadgeT]) -> _BadgeT | None:
|
|
62
|
+
"""Return the badge of ``badge_class``, or ``None`` when none is attached."""
|
|
63
|
+
badge = self._badges.get(badge_class)
|
|
64
|
+
return badge if isinstance(badge, badge_class) else None
|
|
65
|
+
|
|
66
|
+
def get_badges(self) -> Mapping[type, BadgeInterface]:
|
|
67
|
+
"""Return every badge attached, keyed by class."""
|
|
68
|
+
return dict(self._badges)
|
|
69
|
+
|
|
70
|
+
def get_user_badge(self) -> UserBadge:
|
|
71
|
+
"""Return the user badge every passport carries."""
|
|
72
|
+
badge = self._badges[UserBadge]
|
|
73
|
+
assert isinstance(badge, UserBadge) # noqa: S101 -- construction guarantees it
|
|
74
|
+
return badge
|
|
75
|
+
|
|
76
|
+
async def get_user(self) -> UserInterface:
|
|
77
|
+
"""Load and return the user through the user badge."""
|
|
78
|
+
return await self.get_user_badge().get_user()
|
|
79
|
+
|
|
80
|
+
def get_attribute(self, name: str, default: object = None) -> object:
|
|
81
|
+
"""Return the attribute ``name``, or ``default`` when it is not set."""
|
|
82
|
+
return self._attributes.get(name, default)
|
|
83
|
+
|
|
84
|
+
def set_attribute(self, name: str, value: object) -> None:
|
|
85
|
+
"""Attach ``value`` under ``name`` on the passport."""
|
|
86
|
+
self._attributes[name] = value
|
|
87
|
+
|
|
88
|
+
def get_attributes(self) -> Mapping[str, object]:
|
|
89
|
+
"""Return every attribute attached to the passport."""
|
|
90
|
+
return dict(self._attributes)
|
|
91
|
+
|
|
92
|
+
def check_if_completely_resolved(self) -> None:
|
|
93
|
+
"""Raise unless every badge reports itself resolved.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
BadCredentialsError: When any badge is still unresolved once the
|
|
97
|
+
passport check is done — a credential nobody verified, a user
|
|
98
|
+
badge no provider gave a loader.
|
|
99
|
+
"""
|
|
100
|
+
for badge in self._badges.values():
|
|
101
|
+
if not badge.is_resolved():
|
|
102
|
+
raise BadCredentialsError(
|
|
103
|
+
f"The badge {type(badge).__name__} was not resolved by the passport check.",
|
|
104
|
+
)
|