tortoise-auth 0.2.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.
- tortoise_auth/__init__.py +65 -0
- tortoise_auth/config.py +101 -0
- tortoise_auth/events.py +69 -0
- tortoise_auth/exceptions.py +73 -0
- tortoise_auth/hashers/__init__.py +61 -0
- tortoise_auth/hashers/argon2.py +20 -0
- tortoise_auth/hashers/bcrypt.py +11 -0
- tortoise_auth/hashers/pbkdf2.py +61 -0
- tortoise_auth/integrations/__init__.py +1 -0
- tortoise_auth/integrations/starlette.py +142 -0
- tortoise_auth/models/__init__.py +10 -0
- tortoise_auth/models/api_keys.py +0 -0
- tortoise_auth/models/base.py +85 -0
- tortoise_auth/models/jwt_blacklist.py +37 -0
- tortoise_auth/models/rate_limit.py +0 -0
- tortoise_auth/models/totp.py +0 -0
- tortoise_auth/py.typed +0 -0
- tortoise_auth/rate_limit/__init__.py +0 -0
- tortoise_auth/rate_limit/database.py +0 -0
- tortoise_auth/rate_limit/memory.py +0 -0
- tortoise_auth/services/__init__.py +5 -0
- tortoise_auth/services/api_keys.py +0 -0
- tortoise_auth/services/auth.py +151 -0
- tortoise_auth/services/password.py +0 -0
- tortoise_auth/services/registration.py +0 -0
- tortoise_auth/services/totp.py +0 -0
- tortoise_auth/signing.py +97 -0
- tortoise_auth/tokens/__init__.py +49 -0
- tortoise_auth/tokens/api_keys.py +0 -0
- tortoise_auth/tokens/jwt.py +211 -0
- tortoise_auth/utils.py +35 -0
- tortoise_auth/validators/__init__.py +54 -0
- tortoise_auth/validators/common.py +30 -0
- tortoise_auth/validators/common_passwords.txt +19640 -0
- tortoise_auth/validators/length.py +22 -0
- tortoise_auth/validators/numeric.py +16 -0
- tortoise_auth/validators/similarity.py +45 -0
- tortoise_auth-0.2.0.dist-info/METADATA +30 -0
- tortoise_auth-0.2.0.dist-info/RECORD +41 -0
- tortoise_auth-0.2.0.dist-info/WHEEL +4 -0
- tortoise_auth-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Async authentication and user management for Tortoise ORM."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
|
+
|
|
5
|
+
from tortoise_auth.config import AuthConfig, configure, get_config
|
|
6
|
+
from tortoise_auth.events import emit, emitter, on
|
|
7
|
+
from tortoise_auth.exceptions import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadSignatureError,
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
EventError,
|
|
12
|
+
InvalidHashError,
|
|
13
|
+
InvalidPasswordError,
|
|
14
|
+
SignatureExpiredError,
|
|
15
|
+
SigningError,
|
|
16
|
+
TokenError,
|
|
17
|
+
TokenExpiredError,
|
|
18
|
+
TokenInvalidError,
|
|
19
|
+
TokenRevokedError,
|
|
20
|
+
TortoiseAuthError,
|
|
21
|
+
)
|
|
22
|
+
from tortoise_auth.models import (
|
|
23
|
+
AbstractUser,
|
|
24
|
+
BlacklistedToken,
|
|
25
|
+
OutstandingToken,
|
|
26
|
+
)
|
|
27
|
+
from tortoise_auth.services import AuthService
|
|
28
|
+
from tortoise_auth.signing import Signer, TimestampSigner, make_token, verify_token
|
|
29
|
+
from tortoise_auth.tokens import AuthResult, TokenBackend, TokenPair, TokenPayload
|
|
30
|
+
from tortoise_auth.tokens.jwt import JWTBackend
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AbstractUser",
|
|
34
|
+
"AuthConfig",
|
|
35
|
+
"AuthResult",
|
|
36
|
+
"AuthService",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"BadSignatureError",
|
|
39
|
+
"BlacklistedToken",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"EventError",
|
|
42
|
+
"InvalidHashError",
|
|
43
|
+
"InvalidPasswordError",
|
|
44
|
+
"JWTBackend",
|
|
45
|
+
"OutstandingToken",
|
|
46
|
+
"SignatureExpiredError",
|
|
47
|
+
"Signer",
|
|
48
|
+
"SigningError",
|
|
49
|
+
"TimestampSigner",
|
|
50
|
+
"TokenBackend",
|
|
51
|
+
"TokenError",
|
|
52
|
+
"TokenExpiredError",
|
|
53
|
+
"TokenInvalidError",
|
|
54
|
+
"TokenPair",
|
|
55
|
+
"TokenPayload",
|
|
56
|
+
"TokenRevokedError",
|
|
57
|
+
"TortoiseAuthError",
|
|
58
|
+
"configure",
|
|
59
|
+
"emit",
|
|
60
|
+
"emitter",
|
|
61
|
+
"get_config",
|
|
62
|
+
"make_token",
|
|
63
|
+
"on",
|
|
64
|
+
"verify_token",
|
|
65
|
+
]
|
tortoise_auth/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Global configuration for tortoise-auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from tortoise_auth.exceptions import ConfigurationError
|
|
9
|
+
from tortoise_auth.hashers import default_password_hash
|
|
10
|
+
from tortoise_auth.validators.common import CommonPasswordValidator
|
|
11
|
+
from tortoise_auth.validators.length import MinimumLengthValidator
|
|
12
|
+
from tortoise_auth.validators.numeric import NumericPasswordValidator
|
|
13
|
+
from tortoise_auth.validators.similarity import UserAttributeSimilarityValidator
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pwdlib import PasswordHash
|
|
17
|
+
|
|
18
|
+
from tortoise_auth.validators import PasswordValidator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_validators() -> list[PasswordValidator]:
|
|
22
|
+
return [
|
|
23
|
+
MinimumLengthValidator(),
|
|
24
|
+
CommonPasswordValidator(),
|
|
25
|
+
NumericPasswordValidator(),
|
|
26
|
+
UserAttributeSimilarityValidator(),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AuthConfig:
|
|
32
|
+
"""Configuration for tortoise-auth."""
|
|
33
|
+
|
|
34
|
+
user_model: str = ""
|
|
35
|
+
|
|
36
|
+
# Hasher parameters
|
|
37
|
+
argon2_time_cost: int = 3
|
|
38
|
+
argon2_memory_cost: int = 65536
|
|
39
|
+
argon2_parallelism: int = 4
|
|
40
|
+
bcrypt_rounds: int = 12
|
|
41
|
+
pbkdf2_iterations: int = 600_000
|
|
42
|
+
|
|
43
|
+
# Validators
|
|
44
|
+
password_validators: list[PasswordValidator] = field(default_factory=_default_validators)
|
|
45
|
+
|
|
46
|
+
# Token settings
|
|
47
|
+
access_token_lifetime: int = 900 # 15 minutes
|
|
48
|
+
refresh_token_lifetime: int = 604_800 # 7 days
|
|
49
|
+
|
|
50
|
+
# Password limits
|
|
51
|
+
max_password_length: int = 4096
|
|
52
|
+
|
|
53
|
+
# Signing (HMAC)
|
|
54
|
+
signing_secret: str = ""
|
|
55
|
+
signing_token_lifetime: int = 86_400 # 24 hours
|
|
56
|
+
|
|
57
|
+
# JWT settings
|
|
58
|
+
jwt_secret: str = ""
|
|
59
|
+
jwt_algorithm: str = "HS256"
|
|
60
|
+
jwt_issuer: str = ""
|
|
61
|
+
jwt_audience: str = ""
|
|
62
|
+
jwt_blacklist_enabled: bool = False
|
|
63
|
+
|
|
64
|
+
def validate(self) -> None:
|
|
65
|
+
"""Validate config. Raises ConfigurationError."""
|
|
66
|
+
if self.access_token_lifetime <= 0:
|
|
67
|
+
raise ConfigurationError("access_token_lifetime must be positive")
|
|
68
|
+
if self.refresh_token_lifetime <= 0:
|
|
69
|
+
raise ConfigurationError("refresh_token_lifetime must be positive")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def effective_signing_secret(self) -> str:
|
|
73
|
+
"""Return signing_secret."""
|
|
74
|
+
return self.signing_secret
|
|
75
|
+
|
|
76
|
+
def get_password_hash(self) -> PasswordHash:
|
|
77
|
+
"""Build a PasswordHash instance from current config."""
|
|
78
|
+
return default_password_hash(
|
|
79
|
+
argon2_time_cost=self.argon2_time_cost,
|
|
80
|
+
argon2_memory_cost=self.argon2_memory_cost,
|
|
81
|
+
argon2_parallelism=self.argon2_parallelism,
|
|
82
|
+
bcrypt_rounds=self.bcrypt_rounds,
|
|
83
|
+
pbkdf2_iterations=self.pbkdf2_iterations,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_config: AuthConfig | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def configure(config: AuthConfig) -> None:
|
|
91
|
+
"""Set the global auth configuration."""
|
|
92
|
+
global _config
|
|
93
|
+
_config = config
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_config() -> AuthConfig:
|
|
97
|
+
"""Get the global auth configuration, creating a default if needed."""
|
|
98
|
+
global _config
|
|
99
|
+
if _config is None:
|
|
100
|
+
_config = AuthConfig()
|
|
101
|
+
return _config
|
tortoise_auth/events.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Async event emitter for tortoise-auth lifecycle hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tortoise_auth.exceptions import EventError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
Handler = Callable[..., Coroutine[Any, Any, Any]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EventEmitter:
|
|
18
|
+
"""Simple async event emitter with sequential handler execution."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, propagate_errors: bool = False) -> None:
|
|
21
|
+
self.propagate_errors = propagate_errors
|
|
22
|
+
self._listeners: dict[str, list[Handler]] = defaultdict(list)
|
|
23
|
+
|
|
24
|
+
def on(self, event_name: str) -> Callable[[Handler], Handler]:
|
|
25
|
+
"""Decorator to register a handler for an event."""
|
|
26
|
+
|
|
27
|
+
def decorator(handler: Handler) -> Handler:
|
|
28
|
+
self.add_listener(event_name, handler)
|
|
29
|
+
return handler
|
|
30
|
+
|
|
31
|
+
return decorator
|
|
32
|
+
|
|
33
|
+
def add_listener(self, event_name: str, handler: Handler) -> None:
|
|
34
|
+
"""Register a handler for an event."""
|
|
35
|
+
self._listeners[event_name].append(handler)
|
|
36
|
+
|
|
37
|
+
def remove_listener(self, event_name: str, handler: Handler) -> None:
|
|
38
|
+
"""Remove a handler for an event."""
|
|
39
|
+
self._listeners[event_name].remove(handler)
|
|
40
|
+
|
|
41
|
+
async def emit(self, event_name: str, *args: Any, **kwargs: Any) -> None:
|
|
42
|
+
"""Emit an event, calling all registered handlers sequentially."""
|
|
43
|
+
for handler in self._listeners[event_name][:]:
|
|
44
|
+
try:
|
|
45
|
+
await handler(*args, **kwargs)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
error = EventError(event_name, handler.__name__, exc)
|
|
48
|
+
if self.propagate_errors:
|
|
49
|
+
raise error from exc
|
|
50
|
+
logger.error(str(error))
|
|
51
|
+
|
|
52
|
+
def listeners(self, event_name: str) -> list[Handler]:
|
|
53
|
+
"""Return a copy of the listener list for an event."""
|
|
54
|
+
return list(self._listeners[event_name])
|
|
55
|
+
|
|
56
|
+
def clear(self, event_name: str | None = None) -> None:
|
|
57
|
+
"""Clear listeners. If event_name is None, clear all."""
|
|
58
|
+
if event_name is None:
|
|
59
|
+
self._listeners.clear()
|
|
60
|
+
else:
|
|
61
|
+
self._listeners.pop(event_name, None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Module-level emitter instance and convenience aliases
|
|
65
|
+
emitter = EventEmitter()
|
|
66
|
+
on = emitter.on
|
|
67
|
+
emit = emitter.emit
|
|
68
|
+
add_listener = emitter.add_listener
|
|
69
|
+
remove_listener = emitter.remove_listener
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Exception classes for tortoise-auth."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TortoiseAuthError(Exception):
|
|
5
|
+
"""Base exception for all tortoise-auth errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthenticationError(TortoiseAuthError):
|
|
9
|
+
"""Raised when authentication fails."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidPasswordError(TortoiseAuthError):
|
|
13
|
+
"""Raised when password validation fails.
|
|
14
|
+
|
|
15
|
+
Collects all validation errors before raising.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, errors: list[str]) -> None:
|
|
19
|
+
self.errors = errors
|
|
20
|
+
super().__init__("; ".join(errors))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidHashError(TortoiseAuthError):
|
|
24
|
+
"""Raised when a password hash is not recognized by any hasher."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, hash: str) -> None:
|
|
27
|
+
self.hash = hash
|
|
28
|
+
super().__init__(f"Unknown password hashing algorithm for hash: {hash!r}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConfigurationError(TortoiseAuthError):
|
|
32
|
+
"""Raised when configuration is invalid."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventError(TortoiseAuthError):
|
|
36
|
+
"""Raised when an event handler fails."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, event_name: str, handler_name: str, original: Exception) -> None:
|
|
39
|
+
self.event_name = event_name
|
|
40
|
+
self.handler_name = handler_name
|
|
41
|
+
self.original = original
|
|
42
|
+
super().__init__(
|
|
43
|
+
f"Handler {handler_name!r} for event {event_name!r} "
|
|
44
|
+
f"raised {type(original).__name__}: {original}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TokenError(TortoiseAuthError):
|
|
49
|
+
"""Raised when a token operation fails."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenExpiredError(TokenError):
|
|
53
|
+
"""Raised when a token has expired."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TokenInvalidError(TokenError):
|
|
57
|
+
"""Raised when a token is structurally invalid or cannot be decoded."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TokenRevokedError(TokenError):
|
|
61
|
+
"""Raised when a revoked token is presented."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SigningError(TortoiseAuthError):
|
|
65
|
+
"""Raised when token signing or verification fails."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SignatureExpiredError(SigningError):
|
|
69
|
+
"""Raised when a signed token has expired."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BadSignatureError(SigningError):
|
|
73
|
+
"""Raised when a signed token has an invalid signature."""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Password hashing utilities for tortoise-auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pwdlib import PasswordHash
|
|
6
|
+
from pwdlib.hashers import HasherProtocol
|
|
7
|
+
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
8
|
+
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
9
|
+
|
|
10
|
+
from tortoise_auth.hashers.argon2 import default_hasher as _argon2_default
|
|
11
|
+
from tortoise_auth.hashers.bcrypt import default_hasher as _bcrypt_default
|
|
12
|
+
from tortoise_auth.hashers.pbkdf2 import PBKDF2Hasher
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_password_hash(
|
|
16
|
+
*,
|
|
17
|
+
argon2_time_cost: int = 3,
|
|
18
|
+
argon2_memory_cost: int = 65536,
|
|
19
|
+
argon2_parallelism: int = 4,
|
|
20
|
+
bcrypt_rounds: int = 12,
|
|
21
|
+
pbkdf2_iterations: int = 600_000,
|
|
22
|
+
) -> PasswordHash:
|
|
23
|
+
"""Create a PasswordHash with all supported hashers.
|
|
24
|
+
|
|
25
|
+
Argon2 is the primary hasher; Bcrypt and PBKDF2 are kept for migration.
|
|
26
|
+
"""
|
|
27
|
+
return PasswordHash([
|
|
28
|
+
_argon2_default(
|
|
29
|
+
time_cost=argon2_time_cost,
|
|
30
|
+
memory_cost=argon2_memory_cost,
|
|
31
|
+
parallelism=argon2_parallelism,
|
|
32
|
+
),
|
|
33
|
+
_bcrypt_default(rounds=bcrypt_rounds),
|
|
34
|
+
PBKDF2Hasher(iterations=pbkdf2_iterations),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_password(password: str) -> str:
|
|
39
|
+
"""Hash a password using the primary hasher (Argon2)."""
|
|
40
|
+
return default_password_hash().hash(password)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_password(password: str, hashed: str) -> tuple[bool, str | None]:
|
|
44
|
+
"""Verify a password and return (valid, updated_hash).
|
|
45
|
+
|
|
46
|
+
If the hash was made with a non-primary hasher or outdated params,
|
|
47
|
+
``updated_hash`` contains the re-hashed value for transparent migration.
|
|
48
|
+
"""
|
|
49
|
+
return default_password_hash().verify_and_update(password, hashed)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Argon2Hasher",
|
|
54
|
+
"BcryptHasher",
|
|
55
|
+
"HasherProtocol",
|
|
56
|
+
"PBKDF2Hasher",
|
|
57
|
+
"PasswordHash",
|
|
58
|
+
"check_password",
|
|
59
|
+
"default_password_hash",
|
|
60
|
+
"make_password",
|
|
61
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Argon2 password hasher (wraps pwdlib)."""
|
|
2
|
+
|
|
3
|
+
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_hasher(
|
|
7
|
+
*,
|
|
8
|
+
time_cost: int = 3,
|
|
9
|
+
memory_cost: int = 65536,
|
|
10
|
+
parallelism: int = 4,
|
|
11
|
+
) -> Argon2Hasher:
|
|
12
|
+
"""Create an Argon2Hasher with OWASP-recommended defaults."""
|
|
13
|
+
return Argon2Hasher(
|
|
14
|
+
time_cost=time_cost,
|
|
15
|
+
memory_cost=memory_cost,
|
|
16
|
+
parallelism=parallelism,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["Argon2Hasher", "default_hasher"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Bcrypt password hasher (wraps pwdlib)."""
|
|
2
|
+
|
|
3
|
+
from pwdlib.hashers.bcrypt import BcryptHasher
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def default_hasher(*, rounds: int = 12) -> BcryptHasher:
|
|
7
|
+
"""Create a BcryptHasher with OWASP-recommended defaults."""
|
|
8
|
+
return BcryptHasher(rounds=rounds)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["BcryptHasher", "default_hasher"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""PBKDF2-SHA256 password hasher (Django-compatible format)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
_DEFAULT_ITERATIONS = 600_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PBKDF2Hasher:
|
|
14
|
+
"""PBKDF2-SHA256 hasher implementing pwdlib's HasherProtocol.
|
|
15
|
+
|
|
16
|
+
Hash format: ``pbkdf2_sha256$iterations$salt_b64$hash_b64``
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, iterations: int = _DEFAULT_ITERATIONS) -> None:
|
|
20
|
+
self.iterations = iterations
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def identify(cls, hash: str | bytes) -> bool:
|
|
24
|
+
if isinstance(hash, bytes):
|
|
25
|
+
hash = hash.decode("utf-8")
|
|
26
|
+
return hash.startswith("pbkdf2_sha256$")
|
|
27
|
+
|
|
28
|
+
def hash(self, password: str | bytes, *, salt: bytes | None = None) -> str:
|
|
29
|
+
if isinstance(password, str):
|
|
30
|
+
password = password.encode("utf-8")
|
|
31
|
+
if salt is None:
|
|
32
|
+
salt = os.urandom(16)
|
|
33
|
+
dk = hashlib.pbkdf2_hmac("sha256", password, salt, self.iterations)
|
|
34
|
+
salt_b64 = base64.b64encode(salt).decode("ascii")
|
|
35
|
+
hash_b64 = base64.b64encode(dk).decode("ascii")
|
|
36
|
+
return f"pbkdf2_sha256${self.iterations}${salt_b64}${hash_b64}"
|
|
37
|
+
|
|
38
|
+
def verify(self, password: str | bytes, hash: str | bytes) -> bool:
|
|
39
|
+
if isinstance(password, str):
|
|
40
|
+
password = password.encode("utf-8")
|
|
41
|
+
if isinstance(hash, bytes):
|
|
42
|
+
hash = hash.decode("utf-8")
|
|
43
|
+
parts = hash.split("$")
|
|
44
|
+
if len(parts) != 4 or parts[0] != "pbkdf2_sha256":
|
|
45
|
+
return False
|
|
46
|
+
iterations = int(parts[1])
|
|
47
|
+
salt = base64.b64decode(parts[2])
|
|
48
|
+
stored_hash = base64.b64decode(parts[3])
|
|
49
|
+
dk = hashlib.pbkdf2_hmac("sha256", password, salt, iterations)
|
|
50
|
+
return hmac.compare_digest(dk, stored_hash)
|
|
51
|
+
|
|
52
|
+
def check_needs_rehash(self, hash: str | bytes) -> bool:
|
|
53
|
+
if isinstance(hash, bytes):
|
|
54
|
+
hash = hash.decode("utf-8")
|
|
55
|
+
parts = hash.split("$")
|
|
56
|
+
if len(parts) != 4:
|
|
57
|
+
return True
|
|
58
|
+
return int(parts[1]) != self.iterations
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["PBKDF2Hasher"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework integrations for tortoise-auth."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Starlette integration for tortoise-auth.
|
|
2
|
+
|
|
3
|
+
Provides ``AuthenticationMiddleware``-compatible backend and route helpers
|
|
4
|
+
so that ``request.user`` is automatically populated from a Bearer token.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from starlette.applications import Starlette
|
|
9
|
+
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
10
|
+
from tortoise_auth.integrations.starlette import TokenAuthBackend
|
|
11
|
+
|
|
12
|
+
app = Starlette(...)
|
|
13
|
+
app.add_middleware(AuthenticationMiddleware, backend=TokenAuthBackend())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import functools
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from starlette.authentication import AuthCredentials, AuthenticationBackend
|
|
23
|
+
from starlette.requests import HTTPConnection, Request
|
|
24
|
+
from starlette.responses import JSONResponse, RedirectResponse, Response
|
|
25
|
+
|
|
26
|
+
from tortoise_auth.exceptions import AuthenticationError, TokenError
|
|
27
|
+
from tortoise_auth.services import AuthService
|
|
28
|
+
|
|
29
|
+
__all__ = ["AnonymousUser", "TokenAuthBackend", "login_required", "require_auth"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnonymousUser:
|
|
33
|
+
"""Unauthenticated user placeholder.
|
|
34
|
+
|
|
35
|
+
Compatible with Starlette's ``BaseUser`` protocol and
|
|
36
|
+
``AbstractUser``'s boolean properties.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_authenticated(self) -> bool:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_anonymous(self) -> bool:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def display_name(self) -> str:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenAuthBackend(AuthenticationBackend):
|
|
53
|
+
"""Bearer-token authentication backend for Starlette's ``AuthenticationMiddleware``."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
auth_service: AuthService | None = None,
|
|
58
|
+
*,
|
|
59
|
+
scopes: tuple[str, ...] = ("authenticated",),
|
|
60
|
+
) -> None:
|
|
61
|
+
self._auth_service = auth_service
|
|
62
|
+
self._scopes = scopes
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def auth_service(self) -> AuthService:
|
|
66
|
+
if self._auth_service is None:
|
|
67
|
+
self._auth_service = AuthService()
|
|
68
|
+
return self._auth_service
|
|
69
|
+
|
|
70
|
+
async def authenticate(
|
|
71
|
+
self, conn: HTTPConnection
|
|
72
|
+
) -> tuple[AuthCredentials, Any]:
|
|
73
|
+
anonymous = (AuthCredentials([]), AnonymousUser())
|
|
74
|
+
|
|
75
|
+
authorization = conn.headers.get("Authorization")
|
|
76
|
+
if not authorization:
|
|
77
|
+
return anonymous
|
|
78
|
+
|
|
79
|
+
parts = authorization.split(" ", 1)
|
|
80
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
81
|
+
return anonymous
|
|
82
|
+
|
|
83
|
+
token = parts[1]
|
|
84
|
+
try:
|
|
85
|
+
user = await self.auth_service.authenticate(token)
|
|
86
|
+
except (TokenError, AuthenticationError):
|
|
87
|
+
return anonymous
|
|
88
|
+
|
|
89
|
+
return AuthCredentials(list(self._scopes)), user
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_auth(request: Request) -> Any:
|
|
93
|
+
"""Extract the authenticated user from a request or raise ``AuthenticationError``.
|
|
94
|
+
|
|
95
|
+
This is a synchronous helper intended for use inside route handlers::
|
|
96
|
+
|
|
97
|
+
async def my_route(request: Request) -> Response:
|
|
98
|
+
user = require_auth(request)
|
|
99
|
+
return JSONResponse({"email": user.email})
|
|
100
|
+
"""
|
|
101
|
+
if not request.user.is_authenticated:
|
|
102
|
+
raise AuthenticationError("Authentication required")
|
|
103
|
+
return request.user
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def login_required(
|
|
107
|
+
fn: Callable[..., Any] | None = None,
|
|
108
|
+
*,
|
|
109
|
+
status_code: int = 401,
|
|
110
|
+
redirect_url: str | None = None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""Decorator that rejects unauthenticated requests.
|
|
113
|
+
|
|
114
|
+
Supports usage with and without parentheses::
|
|
115
|
+
|
|
116
|
+
@login_required
|
|
117
|
+
async def view(request): ...
|
|
118
|
+
|
|
119
|
+
@login_required(status_code=403)
|
|
120
|
+
async def admin_view(request): ...
|
|
121
|
+
|
|
122
|
+
@login_required(redirect_url="/login")
|
|
123
|
+
async def html_view(request): ...
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
127
|
+
@functools.wraps(func)
|
|
128
|
+
async def wrapper(request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
129
|
+
if not request.user.is_authenticated:
|
|
130
|
+
if redirect_url is not None:
|
|
131
|
+
return RedirectResponse(url=redirect_url, status_code=302)
|
|
132
|
+
return JSONResponse(
|
|
133
|
+
{"detail": "Authentication required"},
|
|
134
|
+
status_code=status_code,
|
|
135
|
+
)
|
|
136
|
+
return await func(request, *args, **kwargs) # type: ignore[no-any-return]
|
|
137
|
+
|
|
138
|
+
return wrapper
|
|
139
|
+
|
|
140
|
+
if fn is not None:
|
|
141
|
+
return decorator(fn)
|
|
142
|
+
return decorator
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""User and token models for tortoise-auth."""
|
|
2
|
+
|
|
3
|
+
from tortoise_auth.models.base import AbstractUser
|
|
4
|
+
from tortoise_auth.models.jwt_blacklist import BlacklistedToken, OutstandingToken
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AbstractUser",
|
|
8
|
+
"BlacklistedToken",
|
|
9
|
+
"OutstandingToken",
|
|
10
|
+
]
|
|
File without changes
|