reauth 0.1.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.
- reauth/__init__.py +3 -0
- reauth/amr.py +96 -0
- reauth/authentication_session.py +217 -0
- reauth/crypto.py +112 -0
- reauth/exceptions.py +9 -0
- reauth/factors/__init__.py +3 -0
- reauth/factors/base.py +44 -0
- reauth/factors/email_otp.py +198 -0
- reauth/factors/hotp.py +164 -0
- reauth/factors/totp.py +174 -0
- reauth/py.typed +0 -0
- reauth/timestamp.py +8 -0
- reauth-0.1.0.dist-info/METADATA +118 -0
- reauth-0.1.0.dist-info/RECORD +16 -0
- reauth-0.1.0.dist-info/WHEEL +4 -0
- reauth-0.1.0.dist-info/licenses/LICENSE +22 -0
reauth/__init__.py
ADDED
reauth/amr.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Authentication Method Reference Values (amr) per RFC 8176.
|
|
2
|
+
|
|
3
|
+
This module defines the standard Authentication Method Reference values
|
|
4
|
+
as specified in RFC 8176 for use in JWT amr claims. These values represent
|
|
5
|
+
the authentication methods used in an authentication event.
|
|
6
|
+
|
|
7
|
+
See: https://datatracker.ietf.org/doc/html/rfc8176
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationMethodReference(StrEnum):
|
|
14
|
+
"""Standard Authentication Method Reference values (amr) per RFC 8176.
|
|
15
|
+
|
|
16
|
+
These values are used in the 'amr' claim of JSON Web Tokens (JWT) to
|
|
17
|
+
identify the authentication methods used in an authentication event.
|
|
18
|
+
|
|
19
|
+
Each value represents a family of closely related authentication methods.
|
|
20
|
+
The registry is maintained by IANA and is extensible.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Biometric methods
|
|
24
|
+
FACE = "face"
|
|
25
|
+
"""Facial recognition biometric authentication."""
|
|
26
|
+
|
|
27
|
+
FPT = "fpt"
|
|
28
|
+
"""Fingerprint biometric authentication."""
|
|
29
|
+
|
|
30
|
+
IRIS = "iris"
|
|
31
|
+
"""Iris scan biometric authentication."""
|
|
32
|
+
|
|
33
|
+
RETINA = "retina"
|
|
34
|
+
"""Retina scan biometric authentication."""
|
|
35
|
+
|
|
36
|
+
VBM = "vbm"
|
|
37
|
+
"""Voice biometric authentication."""
|
|
38
|
+
|
|
39
|
+
# Knowledge-based methods
|
|
40
|
+
KBA = "kba"
|
|
41
|
+
"""Knowledge-based authentication (e.g., security questions)."""
|
|
42
|
+
|
|
43
|
+
PIN = "pin"
|
|
44
|
+
"""Personal Identification Number or pattern authentication."""
|
|
45
|
+
|
|
46
|
+
PWD = "pwd"
|
|
47
|
+
"""Password-based authentication."""
|
|
48
|
+
|
|
49
|
+
# Possession-based methods
|
|
50
|
+
HWK = "hwk"
|
|
51
|
+
"""Proof-of-Possession of a hardware-secured key."""
|
|
52
|
+
|
|
53
|
+
OTP = "otp"
|
|
54
|
+
"""One-time password (HOTP/TOTP)."""
|
|
55
|
+
|
|
56
|
+
SC = "sc"
|
|
57
|
+
"""Smart card authentication."""
|
|
58
|
+
|
|
59
|
+
SWK = "swk"
|
|
60
|
+
"""Proof-of-Possession of a software-secured key."""
|
|
61
|
+
|
|
62
|
+
# Out-of-band methods
|
|
63
|
+
SMS = "sms"
|
|
64
|
+
"""SMS text message confirmation."""
|
|
65
|
+
|
|
66
|
+
TEL = "tel"
|
|
67
|
+
"""Telephone call confirmation."""
|
|
68
|
+
|
|
69
|
+
EMAIL = "email"
|
|
70
|
+
"""Email-based OTP or magic link authentication.
|
|
71
|
+
|
|
72
|
+
This is a custom extension to RFC 8176, not part of the standard.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Multi-factor and risk-based methods
|
|
76
|
+
MFA = "mfa"
|
|
77
|
+
"""Multiple-factor authentication."""
|
|
78
|
+
|
|
79
|
+
MCA = "mca"
|
|
80
|
+
"""Multiple-channel authentication."""
|
|
81
|
+
|
|
82
|
+
RBA = "rba"
|
|
83
|
+
"""Risk-based authentication."""
|
|
84
|
+
|
|
85
|
+
# Special purpose
|
|
86
|
+
GEO = "geo"
|
|
87
|
+
"""Geolocation-based authentication."""
|
|
88
|
+
|
|
89
|
+
USER = "user"
|
|
90
|
+
"""User presence test."""
|
|
91
|
+
|
|
92
|
+
WIA = "wia"
|
|
93
|
+
"""Windows Integrated Authentication."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["AuthenticationMethodReference"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import datetime
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from .amr import AuthenticationMethodReference
|
|
7
|
+
from .crypto import TokenHash, generate_token_hash_pair, get_token_hash
|
|
8
|
+
from .exceptions import ReauthException
|
|
9
|
+
from .factors import FactorBase
|
|
10
|
+
from .timestamp import get_current_timestamp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass
|
|
14
|
+
class AuthenticationSession:
|
|
15
|
+
id: typing.Any | None
|
|
16
|
+
token_hash: TokenHash
|
|
17
|
+
expires_at: int
|
|
18
|
+
identity_id: typing.Any | None
|
|
19
|
+
amr: list[AuthenticationMethodReference] = dataclasses.field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def is_expired(self) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if the session has expired.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if the session has expired, False otherwise.
|
|
27
|
+
"""
|
|
28
|
+
return get_current_timestamp() >= self.expires_at
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationSessionException(ReauthException):
|
|
32
|
+
"""Base exception for authentication session errors."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidSessionTokenException(AuthenticationSessionException):
|
|
38
|
+
"""Raised when a token is invalid or does not correspond to any session."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExpiredSessionException(AuthenticationSessionException):
|
|
44
|
+
"""Raised when a session has expired."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnavailableFactorException(AuthenticationSessionException):
|
|
50
|
+
"""Raised when trying to advance a session with a factor that is not available for the session."""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthenticationSessionService(abc.ABC):
|
|
56
|
+
"""
|
|
57
|
+
Abstract base class for managing authentication sessions.
|
|
58
|
+
|
|
59
|
+
An authentication session represents a pre-auth state that can be used to
|
|
60
|
+
authenticate a user, allowing to manage multi-factor authentication flows.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
hash_secret: str,
|
|
67
|
+
factors: set[FactorBase],
|
|
68
|
+
token_prefix: str = "ls_",
|
|
69
|
+
lifetime: datetime.timedelta = datetime.timedelta(minutes=15),
|
|
70
|
+
) -> None:
|
|
71
|
+
self.hash_secret = hash_secret
|
|
72
|
+
self.factors = factors
|
|
73
|
+
self.token_prefix = token_prefix
|
|
74
|
+
self.lifetime = lifetime
|
|
75
|
+
|
|
76
|
+
async def create(self) -> tuple[str, AuthenticationSession]:
|
|
77
|
+
"""
|
|
78
|
+
Create a fresh authentication session.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
A tuple of (token, AuthenticationSession instance).
|
|
82
|
+
"""
|
|
83
|
+
token, token_hash = generate_token_hash_pair(
|
|
84
|
+
secret=self.hash_secret, prefix=self.token_prefix
|
|
85
|
+
)
|
|
86
|
+
authentication_session = AuthenticationSession(
|
|
87
|
+
id=None,
|
|
88
|
+
token_hash=token_hash,
|
|
89
|
+
expires_at=get_current_timestamp() + int(self.lifetime.total_seconds()),
|
|
90
|
+
identity_id=None,
|
|
91
|
+
)
|
|
92
|
+
authentication_session.id = await self.insert(authentication_session)
|
|
93
|
+
return token, authentication_session
|
|
94
|
+
|
|
95
|
+
async def get_by_token(self, token: str) -> AuthenticationSession:
|
|
96
|
+
"""
|
|
97
|
+
Validate a token and return the corresponding authentication session.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
token: The token to validate.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The corresponding AuthenticationSession instance.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
InvalidTokenException: If the token is invalid or does not correspond to any session.
|
|
107
|
+
ExpiredSessionException: If the session corresponding to the token has expired.
|
|
108
|
+
"""
|
|
109
|
+
token_hash = get_token_hash(token, secret=self.hash_secret)
|
|
110
|
+
authentication_session = await self.get_by_token_hash(token_hash)
|
|
111
|
+
if authentication_session is None:
|
|
112
|
+
raise InvalidSessionTokenException()
|
|
113
|
+
if authentication_session.is_expired():
|
|
114
|
+
raise ExpiredSessionException()
|
|
115
|
+
return authentication_session
|
|
116
|
+
|
|
117
|
+
async def get_available_factors(
|
|
118
|
+
self, authentication_session: AuthenticationSession
|
|
119
|
+
) -> set[FactorBase]:
|
|
120
|
+
"""
|
|
121
|
+
Get the set of available factors for a given authentication session.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
authentication_session: The AuthenticationSession instance to get available factors for.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A set of FactorBase instances representing the available factors for the session.
|
|
128
|
+
"""
|
|
129
|
+
available: set[FactorBase] = set()
|
|
130
|
+
for factor in self.factors:
|
|
131
|
+
# Already used?
|
|
132
|
+
if factor.AMR in authentication_session.amr:
|
|
133
|
+
continue
|
|
134
|
+
# Meets min_prior_factors?
|
|
135
|
+
if len(authentication_session.amr) < factor.min_prior_factors:
|
|
136
|
+
continue
|
|
137
|
+
# Enrolled for the identity in the session (if any)?
|
|
138
|
+
if authentication_session.identity_id is not None:
|
|
139
|
+
factor_enrollment = await factor.get_enrollment(
|
|
140
|
+
authentication_session.identity_id
|
|
141
|
+
)
|
|
142
|
+
if factor_enrollment is None:
|
|
143
|
+
continue
|
|
144
|
+
available.add(factor)
|
|
145
|
+
return available
|
|
146
|
+
|
|
147
|
+
async def advance(
|
|
148
|
+
self,
|
|
149
|
+
authentication_session: AuthenticationSession,
|
|
150
|
+
identity_id: typing.Any,
|
|
151
|
+
factor: FactorBase,
|
|
152
|
+
) -> AuthenticationSession:
|
|
153
|
+
"""
|
|
154
|
+
Advance an authentication session by marking a factor as completed.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
authentication_session: The AuthenticationSession instance to advance.
|
|
158
|
+
identity_id: The ID of the identity that has completed the factor.
|
|
159
|
+
factor: The FactorBase instance representing the factor that has been completed.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The updated AuthenticationSession instance.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
UnavailableFactorException: If the factor is not available for the session.
|
|
166
|
+
"""
|
|
167
|
+
available_factors = await self.get_available_factors(authentication_session)
|
|
168
|
+
if factor not in available_factors:
|
|
169
|
+
raise UnavailableFactorException()
|
|
170
|
+
|
|
171
|
+
authentication_session.identity_id = identity_id
|
|
172
|
+
authentication_session.amr.append(factor.AMR)
|
|
173
|
+
await self.update(authentication_session)
|
|
174
|
+
|
|
175
|
+
return authentication_session
|
|
176
|
+
|
|
177
|
+
@abc.abstractmethod
|
|
178
|
+
async def insert(self, authentication_session: AuthenticationSession) -> typing.Any:
|
|
179
|
+
"""
|
|
180
|
+
Insert an authentication session into a persistent store.
|
|
181
|
+
|
|
182
|
+
Implementers should implement this method.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
authentication_session: The AuthenticationSession instance to insert.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The ID of the inserted authentication session.
|
|
189
|
+
"""
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
@abc.abstractmethod
|
|
193
|
+
async def get_by_token_hash(self, token_hash: str) -> AuthenticationSession | None:
|
|
194
|
+
"""
|
|
195
|
+
Retrieve an authentication session by its token hash from the persistent store.
|
|
196
|
+
|
|
197
|
+
Implementers should implement this method.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
token_hash: The hash of the token to look up.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The corresponding AuthenticationSession instance, or None if not found.
|
|
204
|
+
"""
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
@abc.abstractmethod
|
|
208
|
+
async def update(self, authentication_session: AuthenticationSession) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Update an authentication session in the persistent store.
|
|
211
|
+
|
|
212
|
+
Implementers should implement this method.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
authentication_session: The AuthenticationSession instance to update.
|
|
216
|
+
"""
|
|
217
|
+
...
|
reauth/crypto.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""A set of cryptographic utilities."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import secrets
|
|
6
|
+
import string
|
|
7
|
+
import typing
|
|
8
|
+
import zlib
|
|
9
|
+
|
|
10
|
+
TokenHash = typing.NewType("TokenHash", str)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _crc32_to_base62(number: int) -> str:
|
|
14
|
+
characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
15
|
+
base = len(characters)
|
|
16
|
+
encoded = ""
|
|
17
|
+
while number:
|
|
18
|
+
number, remainder = divmod(number, base)
|
|
19
|
+
encoded = characters[remainder] + encoded
|
|
20
|
+
return encoded.zfill(6) # Ensure the checksum is 6 characters long
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_token_hash(token: str, *, secret: str) -> TokenHash:
|
|
24
|
+
"""Compute HMAC-SHA256 hash of a token.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
token: The token value to hash.
|
|
28
|
+
secret: The secret key used for HMAC computation.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Hexadecimal string of the HMAC-SHA256 hash.
|
|
32
|
+
"""
|
|
33
|
+
hash = hmac.new(secret.encode("ascii"), token.encode("ascii"), hashlib.sha256)
|
|
34
|
+
return TokenHash(hash.hexdigest())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_token(*, prefix: str = "") -> str:
|
|
38
|
+
"""Generate a high-entropy random token with embedded checksum.
|
|
39
|
+
|
|
40
|
+
The token consists of a random string of 37 alphanumeric characters
|
|
41
|
+
followed by a 6-character base62-encoded CRC32 checksum.
|
|
42
|
+
|
|
43
|
+
The CRC32 checksum provides fast pre-validation to filter out malformed
|
|
44
|
+
or corrupted tokens before more expensive HMAC verification or database
|
|
45
|
+
lookups. It is NOT a cryptographic security mechanism.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
prefix: Optional prefix prepended to the generated token.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A token string with format: {prefix}{37_random_chars}{6_checksum_chars}.
|
|
52
|
+
"""
|
|
53
|
+
# Generate a high entropy random token
|
|
54
|
+
token = "".join(
|
|
55
|
+
secrets.choice(string.ascii_letters + string.digits) for _ in range(37)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Calculate a 32-bit CRC checksum
|
|
59
|
+
checksum = zlib.crc32(token.encode("utf-8")) & 0xFFFFFFFF
|
|
60
|
+
checksum_base62 = _crc32_to_base62(checksum)
|
|
61
|
+
|
|
62
|
+
# Concatenate the prefix, token, and checksum
|
|
63
|
+
return f"{prefix}{token}{checksum_base62}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_token_hash_pair(*, secret: str, prefix: str = "") -> tuple[str, TokenHash]:
|
|
67
|
+
"""Generate a token and its HMAC-SHA256 hash pair.
|
|
68
|
+
|
|
69
|
+
Generates a high-entropy token with embedded checksum and computes its
|
|
70
|
+
HMAC-SHA256 hash using the provided secret. This pattern allows storing
|
|
71
|
+
only the hash in the database while the raw token is provided to the user.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
secret: The secret key used for HMAC computation.
|
|
75
|
+
prefix: Optional prefix prepended to the generated token.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A tuple of (token, hash) where token is the raw value and hash is
|
|
79
|
+
its HMAC-SHA256 hexadecimal digest.
|
|
80
|
+
"""
|
|
81
|
+
token = generate_token(prefix=prefix)
|
|
82
|
+
return token, get_token_hash(token, secret=secret)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_code_hash_pair(*, secret: str, length: int = 6) -> tuple[str, TokenHash]:
|
|
86
|
+
"""Generate a 6-alphanumeric code and its HMAC-SHA256 hash pair.
|
|
87
|
+
|
|
88
|
+
This is useful for generating OTP codes where the raw code is sent to the
|
|
89
|
+
user and only the hash is stored for verification.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
secret: The secret key used for HMAC computation.
|
|
93
|
+
length: The length of the numeric code to generate (default is 6).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A tuple of (code, hash) where code is the raw code and hash is
|
|
97
|
+
its HMAC-SHA256 hexadecimal digest.
|
|
98
|
+
"""
|
|
99
|
+
code = "".join(
|
|
100
|
+
secrets.choice(string.ascii_uppercase + string.digits) for _ in range(length)
|
|
101
|
+
)
|
|
102
|
+
code_hash = get_token_hash(code, secret=secret)
|
|
103
|
+
return code, code_hash
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TokenHash",
|
|
108
|
+
"get_token_hash",
|
|
109
|
+
"generate_code_hash_pair",
|
|
110
|
+
"generate_token",
|
|
111
|
+
"generate_token_hash_pair",
|
|
112
|
+
]
|
reauth/exceptions.py
ADDED
reauth/factors/base.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from ..amr import AuthenticationMethodReference
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FactorEnrollment(typing.Protocol):
|
|
8
|
+
"""
|
|
9
|
+
Protocol for factor enrollment objects,
|
|
10
|
+
which represent the state of an enrolled factor for a given identity.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
id: typing.Any | None
|
|
14
|
+
identity_id: typing.Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FactorBase(abc.ABC):
|
|
18
|
+
"""Base abstract class for all factor services."""
|
|
19
|
+
|
|
20
|
+
AMR: typing.ClassVar[AuthenticationMethodReference]
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, min_prior_factors: int = 0) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize the factor base.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
min_prior_factors: Minimum number of prior factors that must complete
|
|
28
|
+
before this factor can be used. Defaults to 0 (can be first factor).
|
|
29
|
+
"""
|
|
30
|
+
self.min_prior_factors = min_prior_factors
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
async def get_enrollment(self, identity_id: typing.Any) -> FactorEnrollment | None:
|
|
34
|
+
"""
|
|
35
|
+
Get the enrollment information for a given identity.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
identity_id: The ID of the identity to get the factor configuration for.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The enrollment information for the factor,
|
|
42
|
+
or None if the factor is not enrolled for the identity.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import datetime
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from reauth.exceptions import ReauthException
|
|
7
|
+
|
|
8
|
+
from ..amr import AuthenticationMethodReference
|
|
9
|
+
from ..crypto import generate_code_hash_pair, get_token_hash
|
|
10
|
+
from ..timestamp import get_current_timestamp
|
|
11
|
+
from .base import FactorBase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass
|
|
15
|
+
class EmailOTPEnrollment:
|
|
16
|
+
id: typing.Any | None
|
|
17
|
+
identity_id: typing.Any
|
|
18
|
+
email: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass
|
|
22
|
+
class EmailOTP:
|
|
23
|
+
id: typing.Any | None
|
|
24
|
+
identity_id: typing.Any
|
|
25
|
+
code_hash: str
|
|
26
|
+
expires_at: int
|
|
27
|
+
authentication_session_id: typing.Any
|
|
28
|
+
|
|
29
|
+
def is_expired(self) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Check if the OTP has expired.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
True if the OTP has expired, False otherwise.
|
|
35
|
+
"""
|
|
36
|
+
return get_current_timestamp() >= self.expires_at
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EmailOTPException(ReauthException):
|
|
40
|
+
"""Base exception for email OTP errors."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidOTPException(EmailOTPException):
|
|
46
|
+
"""Raised when an OTP code is invalid."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExpiredOTPException(EmailOTPException):
|
|
52
|
+
"""Raised when an OTP code has expired."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EmailOTPFactor(FactorBase, abc.ABC):
|
|
58
|
+
AMR: typing.ClassVar[AuthenticationMethodReference] = (
|
|
59
|
+
AuthenticationMethodReference.EMAIL
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
hash_secret: str,
|
|
66
|
+
code_length: int = 6,
|
|
67
|
+
lifetime: datetime.timedelta = datetime.timedelta(minutes=10),
|
|
68
|
+
min_prior_factors: int = 0,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__(min_prior_factors=min_prior_factors)
|
|
71
|
+
self.hash_secret = hash_secret
|
|
72
|
+
self.code_length = code_length
|
|
73
|
+
self.lifetime = lifetime
|
|
74
|
+
|
|
75
|
+
async def create(
|
|
76
|
+
self, identity_id: typing.Any, authentication_session_id: typing.Any
|
|
77
|
+
) -> tuple[str, EmailOTP]:
|
|
78
|
+
"""
|
|
79
|
+
Create a new OTP for the given identity.
|
|
80
|
+
|
|
81
|
+
If an existing OTP for the same authentication session exists,
|
|
82
|
+
it's deleted and replaced with the new one.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
identity_id: The ID of the identity to create the OTP for.
|
|
86
|
+
authentication_session_id: The ID of the authentication session this OTP is associated with.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A tuple of (OTP code, EmailOTP instance).
|
|
90
|
+
"""
|
|
91
|
+
await self.delete_by_authentication_session_id(authentication_session_id)
|
|
92
|
+
|
|
93
|
+
code, code_hash = generate_code_hash_pair(
|
|
94
|
+
secret=self.hash_secret, length=self.code_length
|
|
95
|
+
)
|
|
96
|
+
email_otp = EmailOTP(
|
|
97
|
+
id=None,
|
|
98
|
+
code_hash=code_hash,
|
|
99
|
+
expires_at=get_current_timestamp() + int(self.lifetime.total_seconds()),
|
|
100
|
+
identity_id=identity_id,
|
|
101
|
+
authentication_session_id=authentication_session_id,
|
|
102
|
+
)
|
|
103
|
+
email_otp.id = await self.insert(email_otp)
|
|
104
|
+
|
|
105
|
+
return code, email_otp
|
|
106
|
+
|
|
107
|
+
async def consume(self, code: str, authentication_session_id: object) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Consume an OTP code, deleting it from the persistent store if valid.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
code: The OTP code to consume.
|
|
113
|
+
authentication_session_id: The ID of the authentication session this OTP is associated with.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
InvalidOTPException: If the code is invalid or does not correspond to any OTP.
|
|
117
|
+
ExpiredOTPException: If the OTP has expired.
|
|
118
|
+
"""
|
|
119
|
+
code_hash = get_token_hash(code, secret=self.hash_secret)
|
|
120
|
+
email_otp = await self.get_by_code_hash_and_authentication_session_id(
|
|
121
|
+
code_hash, authentication_session_id
|
|
122
|
+
)
|
|
123
|
+
if email_otp is None:
|
|
124
|
+
raise InvalidOTPException()
|
|
125
|
+
if email_otp.is_expired():
|
|
126
|
+
raise ExpiredOTPException()
|
|
127
|
+
await self.delete(email_otp)
|
|
128
|
+
|
|
129
|
+
@abc.abstractmethod
|
|
130
|
+
async def insert(self, email_otp: EmailOTP) -> typing.Any:
|
|
131
|
+
"""
|
|
132
|
+
Insert an EmailOTP instance into a persistent store.
|
|
133
|
+
|
|
134
|
+
Implementers should implement this method.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
email_otp: The EmailOTP instance to insert.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The ID of the inserted EmailOTP.
|
|
141
|
+
"""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
@abc.abstractmethod
|
|
145
|
+
async def get_by_code_hash_and_authentication_session_id(
|
|
146
|
+
self, code_hash: str, authentication_session_id: typing.Any
|
|
147
|
+
) -> EmailOTP | None:
|
|
148
|
+
"""
|
|
149
|
+
Retrieve an EmailOTP instance by its code hash from the persistent store.
|
|
150
|
+
|
|
151
|
+
Implementers should implement this method.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
code_hash: The hash of the OTP code to retrieve.
|
|
155
|
+
authentication_session_id: The ID of the authentication session this OTP is associated with.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The corresponding EmailOTP instance, or None if not found.
|
|
159
|
+
"""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
@abc.abstractmethod
|
|
163
|
+
async def update(self, email_otp: EmailOTP) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Update an EmailOTP instance in the persistent store.
|
|
166
|
+
|
|
167
|
+
Implementers should implement this method.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
email_otp: The EmailOTP instance to update.
|
|
171
|
+
"""
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
@abc.abstractmethod
|
|
175
|
+
async def delete(self, email_otp: EmailOTP) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Delete an EmailOTP instance from the persistent store.
|
|
178
|
+
|
|
179
|
+
Implementers should implement this method.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
email_otp: The EmailOTP instance to delete.
|
|
183
|
+
"""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
@abc.abstractmethod
|
|
187
|
+
async def delete_by_authentication_session_id(
|
|
188
|
+
self, authentication_session_id: typing.Any
|
|
189
|
+
) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Delete all EmailOTP instances associated with a given authentication session ID.
|
|
192
|
+
|
|
193
|
+
Implementers should implement this method.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
authentication_session_id: The ID of the authentication session to delete OTPs for.
|
|
197
|
+
"""
|
|
198
|
+
...
|
reauth/factors/hotp.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import base64
|
|
3
|
+
import dataclasses
|
|
4
|
+
import functools
|
|
5
|
+
import secrets
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives.hashes import SHA1
|
|
9
|
+
from cryptography.hazmat.primitives.twofactor import InvalidToken
|
|
10
|
+
from cryptography.hazmat.primitives.twofactor.hotp import HOTP as CryptoHOTP
|
|
11
|
+
|
|
12
|
+
from reauth.exceptions import ReauthException
|
|
13
|
+
|
|
14
|
+
from ..amr import AuthenticationMethodReference
|
|
15
|
+
from .base import FactorBase
|
|
16
|
+
|
|
17
|
+
type HOTPAlgorithm = typing.Literal["sha1"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_algorithm(algorithm: HOTPAlgorithm) -> SHA1:
|
|
21
|
+
match algorithm:
|
|
22
|
+
case "sha1":
|
|
23
|
+
return SHA1()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass
|
|
27
|
+
class HOTPEnrollment:
|
|
28
|
+
id: typing.Any | None
|
|
29
|
+
identity_id: typing.Any
|
|
30
|
+
secret: str
|
|
31
|
+
algorithm: HOTPAlgorithm
|
|
32
|
+
code_length: int
|
|
33
|
+
counter: int
|
|
34
|
+
|
|
35
|
+
@functools.cached_property
|
|
36
|
+
def _impl(self) -> CryptoHOTP:
|
|
37
|
+
return CryptoHOTP(
|
|
38
|
+
key=base64.b32decode(self.secret.encode("ascii")),
|
|
39
|
+
length=self.code_length,
|
|
40
|
+
algorithm=_get_algorithm(self.algorithm),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def get_provisioning_uri(
|
|
44
|
+
self, account_name: str, issuer_name: str | None = None
|
|
45
|
+
) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Get the provisioning URI for this HOTP factor.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
account_name: The name of the account (e.g., email or username).
|
|
51
|
+
issuer_name: The name of the issuer (e.g., your service name).
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The provisioning URI that can be used to generate a QR code for enrollment in an authenticator app.
|
|
55
|
+
"""
|
|
56
|
+
return self._impl.get_provisioning_uri(account_name, self.counter, issuer_name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HOTPException(ReauthException):
|
|
60
|
+
"""Base exception for HOTP errors."""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvalidHOTPCodeException(HOTPException):
|
|
66
|
+
"""Raised when an HOTP code is invalid."""
|
|
67
|
+
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HOTPFactor(FactorBase, abc.ABC):
|
|
72
|
+
AMR: typing.ClassVar[AuthenticationMethodReference] = (
|
|
73
|
+
AuthenticationMethodReference.OTP
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
code_length: int = 6,
|
|
80
|
+
algorithm: HOTPAlgorithm = "sha1",
|
|
81
|
+
look_ahead: int = 5,
|
|
82
|
+
min_prior_factors: int = 1,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__(min_prior_factors=min_prior_factors)
|
|
85
|
+
self.code_length = code_length
|
|
86
|
+
self.algorithm: HOTPAlgorithm = algorithm
|
|
87
|
+
self.look_ahead = look_ahead
|
|
88
|
+
|
|
89
|
+
async def enroll(self, identity_id: typing.Any) -> HOTPEnrollment:
|
|
90
|
+
"""
|
|
91
|
+
Enroll a new HOTP factor for a given identity.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
identity_id: The ID of the identity to enroll the factor for.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The enrolled HOTP factor.
|
|
98
|
+
"""
|
|
99
|
+
secret = secrets.token_bytes(20) # 160-bit secret key
|
|
100
|
+
hotp = HOTPEnrollment(
|
|
101
|
+
id=None,
|
|
102
|
+
secret=base64.b32encode(secret).decode("ascii"),
|
|
103
|
+
algorithm=self.algorithm,
|
|
104
|
+
code_length=self.code_length,
|
|
105
|
+
counter=0,
|
|
106
|
+
identity_id=identity_id,
|
|
107
|
+
)
|
|
108
|
+
hotp.id = await self.insert(hotp)
|
|
109
|
+
return hotp
|
|
110
|
+
|
|
111
|
+
async def verify(self, hotp: HOTPEnrollment, code: str) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Verify a provided OTP code against the expected value for the given HOTP factor.
|
|
114
|
+
|
|
115
|
+
On success, the counter is incremented and the HOTP factor is updated in the persistent store.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
hotp: The HOTP factor to verify against.
|
|
119
|
+
code: The OTP code provided by the user.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InvalidHOTPCodeException: If the provided code is invalid.
|
|
123
|
+
"""
|
|
124
|
+
encoded_code = code.encode("ascii")
|
|
125
|
+
counter = hotp.counter
|
|
126
|
+
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
hotp._impl.verify(encoded_code, counter)
|
|
130
|
+
break
|
|
131
|
+
except InvalidToken as e:
|
|
132
|
+
if counter - hotp.counter >= self.look_ahead:
|
|
133
|
+
raise InvalidHOTPCodeException() from e
|
|
134
|
+
counter += 1
|
|
135
|
+
|
|
136
|
+
hotp.counter = counter + 1
|
|
137
|
+
await self.update(hotp)
|
|
138
|
+
|
|
139
|
+
@abc.abstractmethod
|
|
140
|
+
async def insert(self, hotp: HOTPEnrollment) -> typing.Any:
|
|
141
|
+
"""
|
|
142
|
+
Insert an HOTP factor into a persistent store.
|
|
143
|
+
|
|
144
|
+
Implementers should implement this method.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
hotp: The HOTP factor to insert.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The ID of the inserted HOTP factor.
|
|
151
|
+
"""
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
@abc.abstractmethod
|
|
155
|
+
async def update(self, hotp: HOTPEnrollment) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Update an HOTP factor in the persistent store.
|
|
158
|
+
|
|
159
|
+
Implementers should implement this method.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
hotp: The HOTP factor to update.
|
|
163
|
+
"""
|
|
164
|
+
...
|
reauth/factors/totp.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import base64
|
|
3
|
+
import dataclasses
|
|
4
|
+
import functools
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from cryptography.hazmat.primitives.hashes import SHA1
|
|
10
|
+
from cryptography.hazmat.primitives.twofactor import InvalidToken
|
|
11
|
+
from cryptography.hazmat.primitives.twofactor.totp import TOTP as CryptoTOTP
|
|
12
|
+
|
|
13
|
+
from reauth.exceptions import ReauthException
|
|
14
|
+
|
|
15
|
+
from ..amr import AuthenticationMethodReference
|
|
16
|
+
from .base import FactorBase
|
|
17
|
+
|
|
18
|
+
type TOTPAlgorithm = typing.Literal["sha1", "sha256", "sha512"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_algorithm(algorithm: TOTPAlgorithm) -> typing.Any:
|
|
22
|
+
match algorithm:
|
|
23
|
+
case "sha1":
|
|
24
|
+
return SHA1()
|
|
25
|
+
case "sha256":
|
|
26
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
27
|
+
|
|
28
|
+
return SHA256()
|
|
29
|
+
case "sha512":
|
|
30
|
+
from cryptography.hazmat.primitives.hashes import SHA512
|
|
31
|
+
|
|
32
|
+
return SHA512()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass
|
|
36
|
+
class TOTPEnrollment:
|
|
37
|
+
id: typing.Any | None
|
|
38
|
+
identity_id: typing.Any
|
|
39
|
+
secret: str
|
|
40
|
+
algorithm: TOTPAlgorithm
|
|
41
|
+
code_length: int
|
|
42
|
+
time_step: int
|
|
43
|
+
last_verified_time_step: int | None = None
|
|
44
|
+
|
|
45
|
+
@functools.cached_property
|
|
46
|
+
def _impl(self) -> CryptoTOTP:
|
|
47
|
+
return CryptoTOTP(
|
|
48
|
+
key=base64.b32decode(self.secret.encode("ascii")),
|
|
49
|
+
length=self.code_length,
|
|
50
|
+
algorithm=_get_algorithm(self.algorithm),
|
|
51
|
+
time_step=self.time_step,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def get_provisioning_uri(
|
|
55
|
+
self, account_name: str, issuer_name: str | None = None
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Get the provisioning URI for this TOTP factor.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
account_name: The name of the account (e.g., email or username).
|
|
62
|
+
issuer_name: The name of the issuer (e.g., your service name).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The provisioning URI that can be used to generate a QR code for enrollment in an authenticator app.
|
|
66
|
+
"""
|
|
67
|
+
return self._impl.get_provisioning_uri(account_name, issuer_name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TOTPException(ReauthException):
|
|
71
|
+
"""Base exception for TOTP errors."""
|
|
72
|
+
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InvalidTOTPCodeException(TOTPException):
|
|
77
|
+
"""Raised when a TOTP code is invalid."""
|
|
78
|
+
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TOTPFactor(FactorBase, abc.ABC):
|
|
83
|
+
AMR: typing.ClassVar[AuthenticationMethodReference] = (
|
|
84
|
+
AuthenticationMethodReference.OTP
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
code_length: int = 6,
|
|
91
|
+
algorithm: TOTPAlgorithm = "sha256",
|
|
92
|
+
time_step: int = 30,
|
|
93
|
+
drift_tolerance: int = 1,
|
|
94
|
+
min_prior_factors: int = 1,
|
|
95
|
+
) -> None:
|
|
96
|
+
super().__init__(min_prior_factors=min_prior_factors)
|
|
97
|
+
self.code_length = code_length
|
|
98
|
+
self.algorithm: TOTPAlgorithm = algorithm
|
|
99
|
+
self.time_step = time_step
|
|
100
|
+
self.drift_tolerance = drift_tolerance
|
|
101
|
+
|
|
102
|
+
async def enroll(self, identity_id: typing.Any) -> TOTPEnrollment:
|
|
103
|
+
"""
|
|
104
|
+
Enroll a new TOTP factor for a given identity.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
identity_id: The ID of the identity to enroll the factor for.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The enrolled TOTP factor.
|
|
111
|
+
"""
|
|
112
|
+
secret = secrets.token_bytes(20) # 160-bit secret key
|
|
113
|
+
totp = TOTPEnrollment(
|
|
114
|
+
id=None,
|
|
115
|
+
secret=base64.b32encode(secret).decode("ascii"),
|
|
116
|
+
algorithm=self.algorithm,
|
|
117
|
+
code_length=self.code_length,
|
|
118
|
+
time_step=self.time_step,
|
|
119
|
+
identity_id=identity_id,
|
|
120
|
+
last_verified_time_step=None,
|
|
121
|
+
)
|
|
122
|
+
totp.id = await self.insert(totp)
|
|
123
|
+
return totp
|
|
124
|
+
|
|
125
|
+
async def verify(self, totp: TOTPEnrollment, code: str) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Verify a provided OTP code against the expected value for the given TOTP factor.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
totp: The TOTP factor to verify against.
|
|
131
|
+
code: The OTP code provided by the user.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
InvalidTOTPCodeException: If the provided code is invalid or has already been used.
|
|
135
|
+
"""
|
|
136
|
+
encoded_code = code.encode("ascii")
|
|
137
|
+
current_time = int(time.time())
|
|
138
|
+
drift = -self.drift_tolerance
|
|
139
|
+
|
|
140
|
+
while True:
|
|
141
|
+
try:
|
|
142
|
+
# Calculate the actual Unix time for this drift step
|
|
143
|
+
check_time = current_time + drift * totp.time_step
|
|
144
|
+
# Calculate the time step for this check
|
|
145
|
+
check_time_step = check_time // totp.time_step
|
|
146
|
+
|
|
147
|
+
# Replay protection: reject if this time step has already been verified
|
|
148
|
+
if (
|
|
149
|
+
totp.last_verified_time_step is not None
|
|
150
|
+
and check_time_step <= totp.last_verified_time_step
|
|
151
|
+
):
|
|
152
|
+
drift += 1
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
totp._impl.verify(encoded_code, check_time)
|
|
156
|
+
except InvalidToken as e:
|
|
157
|
+
if drift > self.drift_tolerance:
|
|
158
|
+
raise InvalidTOTPCodeException() from e
|
|
159
|
+
drift += 1
|
|
160
|
+
else:
|
|
161
|
+
# Update last verified time step and persist
|
|
162
|
+
totp.last_verified_time_step = check_time_step
|
|
163
|
+
await self.update(totp)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
@abc.abstractmethod
|
|
167
|
+
async def insert(self, totp: TOTPEnrollment) -> typing.Any:
|
|
168
|
+
"""Insert a TOTP factor into a persistent store."""
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@abc.abstractmethod
|
|
172
|
+
async def update(self, totp: TOTPEnrollment) -> None:
|
|
173
|
+
"""Update a TOTP factor in the persistent store."""
|
|
174
|
+
...
|
reauth/py.typed
ADDED
|
File without changes
|
reauth/timestamp.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reauth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The authentication toolkit for Python
|
|
5
|
+
Project-URL: Documentation, https://frankie567.github.io/reauth/
|
|
6
|
+
Project-URL: Source, https://github.com/frankie567/reauth
|
|
7
|
+
Author-email: reauth <hello@fvoron.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Requires-Dist: anyio
|
|
13
|
+
Requires-Dist: cryptography<49.0.0,>=48.0.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Reauth
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<em>The authentication toolkit for Python</em>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
[](https://github.com/frankie567/reauth/actions)
|
|
23
|
+
[](https://codecov.io/gh/frankie567/reauth)
|
|
24
|
+
[](https://badge.fury.io/py/reauth)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
**Documentation**: <a href="https://frankie567.github.io/reauth/" target="_blank">https://frankie567.github.io/reauth/</a>
|
|
29
|
+
|
|
30
|
+
**Source Code**: <a href="https://github.com/frankie567/reauth" target="_blank">https://github.com/frankie567/reauth</a>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
> [!WARNING]
|
|
35
|
+
> This is an early-stage project with many moving parts and mostly missing documentation. The API is not yet stable and breaking changes are expected.
|
|
36
|
+
|
|
37
|
+
## Roadmap
|
|
38
|
+
|
|
39
|
+
Our vision is to build a comprehensive, flexible authentication toolkit for Python that handles everything from low-level factor primitives to high-level OIDC server capabilities.
|
|
40
|
+
|
|
41
|
+
### Short-term: Core Foundation
|
|
42
|
+
|
|
43
|
+
- [ ] Factor primitives — building blocks for authentication factors
|
|
44
|
+
- [x] Email OTP
|
|
45
|
+
- [x] HOTP
|
|
46
|
+
- [x] TOTP
|
|
47
|
+
- [ ] Passwords
|
|
48
|
+
- [ ] Security keys
|
|
49
|
+
- [ ] Passkeys
|
|
50
|
+
- [ ] Social Login
|
|
51
|
+
- [x] MFA authentication management — multi-factor authentication workflows
|
|
52
|
+
|
|
53
|
+
### Mid-term: Integration Layer
|
|
54
|
+
|
|
55
|
+
- [ ] Sessions management — robust session handling
|
|
56
|
+
- [ ] ORM and web frameworks wrappers — seamless integration with popular frameworks
|
|
57
|
+
|
|
58
|
+
### Long-term: Full Platform
|
|
59
|
+
|
|
60
|
+
- [ ] OIDC server — complete OpenID Connect provider implementation
|
|
61
|
+
- [ ] Team management — multi-user and organizational features
|
|
62
|
+
- [ ] And more — expanding the ecosystem
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
### Setup environment
|
|
67
|
+
|
|
68
|
+
We use [uv](https://docs.astral.sh/uv/) to manage the development environment and production build, and [just](https://github.com/casey/just) to manage command shortcuts. Ensure they are installed on your system.
|
|
69
|
+
|
|
70
|
+
### Run unit tests
|
|
71
|
+
|
|
72
|
+
You can run all the tests with:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
just test
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Format the code
|
|
79
|
+
|
|
80
|
+
Execute the following command to apply linting and check typing:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
just lint
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Publish a new version
|
|
87
|
+
|
|
88
|
+
You can bump the version, create a commit and associated tag with one command:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
just version patch
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
just version minor
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
just version major
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Your default Git text editor will open so you can add information about the release.
|
|
103
|
+
|
|
104
|
+
When you push the tag on GitHub, the workflow will automatically publish it on PyPi and a GitHub release will be created as draft.
|
|
105
|
+
|
|
106
|
+
## Serve the documentation
|
|
107
|
+
|
|
108
|
+
You can serve the Mkdocs documentation with:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
just docs-serve
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
It'll automatically watch for changes in your code.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
This project is licensed under the terms of the MIT license.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
reauth/__init__.py,sha256=hX2ohrI4CjrfoZm7GU4qQq3M8FMAsxcLUlKSxNqXa-U,67
|
|
2
|
+
reauth/amr.py,sha256=gffUu4AqBeQkO7YUZjAfqVsoVKAuANSCkxwUq7QNOtY,2421
|
|
3
|
+
reauth/authentication_session.py,sha256=-2hwF8-SyKIGoT4jOJf7e2wKJE3JyVMbdc5uVDBTvLg,7169
|
|
4
|
+
reauth/crypto.py,sha256=STsQ-PaiIcLqwft-yhpYvW3YoSCwkctr1F7DOGQrE_I,3635
|
|
5
|
+
reauth/exceptions.py,sha256=dhbRca8XkfcXspVf47mFrKGIknhiesbt1o5viL1Hr7A,229
|
|
6
|
+
reauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
reauth/timestamp.py,sha256=WgSMBAH9KA7KhysU7HkQgDG7b2SKCV7bKPHFDfuggdY,156
|
|
8
|
+
reauth/factors/__init__.py,sha256=2SsYgK0xqpfVCddW8owL7Aio3QtynugCn7MTkOpum0k,55
|
|
9
|
+
reauth/factors/base.py,sha256=1WmwNgOAnSF2up8VrdLMZsTfVLm9_0qQ624rl2YKVnk,1255
|
|
10
|
+
reauth/factors/email_otp.py,sha256=1Tt0Ra-cS506-oWYQAzGQ3zvZdYGA5Qi_rel8hIRAVM,5768
|
|
11
|
+
reauth/factors/hotp.py,sha256=-uWDfGUGutCfFUYRAhjaa1wtqb-k9qmmk33vJOJIbS0,4603
|
|
12
|
+
reauth/factors/totp.py,sha256=fuf7_t6RVveEblx5eZ9h0Mhf3ZjiGrujKWjxD2gHfEc,5415
|
|
13
|
+
reauth-0.1.0.dist-info/METADATA,sha256=Q5zJKNec99VNiR9Ofay9yxRf5kJyu8g2jkpTQLoGGmw,3346
|
|
14
|
+
reauth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
reauth-0.1.0.dist-info/licenses/LICENSE,sha256=nJKVyHWG779aFOFjAyk1dXYrhEdpZ9nMybIA6JsPJ7k,1074
|
|
16
|
+
reauth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, François Voron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|