flask-advanced-auth 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.
- flask_advanced_auth/TokenProxy.py +69 -0
- flask_advanced_auth/__init__.py +73 -0
- flask_advanced_auth/authentication_classes.py +168 -0
- flask_advanced_auth/blacklist_token.py +101 -0
- flask_advanced_auth/database.py +108 -0
- flask_advanced_auth/decorators.py +126 -0
- flask_advanced_auth/exceptions.py +67 -0
- flask_advanced_auth/factory.py +276 -0
- flask_advanced_auth/helpers.py +84 -0
- flask_advanced_auth/import_handler.py +69 -0
- flask_advanced_auth/initialize.py +60 -0
- flask_advanced_auth/jwt_rules.py +254 -0
- flask_advanced_auth/model_managers.py +85 -0
- flask_advanced_auth/models.py +43 -0
- flask_advanced_auth/py.typed +1 -0
- flask_advanced_auth/validator.py +125 -0
- flask_advanced_auth-0.1.0.dist-info/METADATA +263 -0
- flask_advanced_auth-0.1.0.dist-info/RECORD +20 -0
- flask_advanced_auth-0.1.0.dist-info/WHEEL +4 -0
- flask_advanced_auth-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Module contains proxy class for JWT token encoding and decoding.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
import jwt
|
|
9
|
+
from .jwt_rules import JWTAuthenticationRules
|
|
10
|
+
from datetime import datetime, timezone, timedelta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonWebToken(ABC):
|
|
14
|
+
"""Abstract base class for JWT encode/decode operations (Proxy pattern)."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def encode_token(self, payload: dict, secret_key: str, algorithm: str) -> str:
|
|
18
|
+
"""Encode a payload into a JWT token string."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def decode_token(self, token: str, secret_key: str, algorithm: str) -> Optional[dict]:
|
|
23
|
+
"""Decode a JWT token string into a payload dict."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PyJWT(JsonWebToken):
|
|
28
|
+
"""Concrete JWT proxy using the PyJWT library."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def rule_handler(self) -> JWTAuthenticationRules:
|
|
32
|
+
return JWTAuthenticationRules()
|
|
33
|
+
|
|
34
|
+
def required_payload(self, rule_name: str, token_type: str) -> dict[str, Any]:
|
|
35
|
+
"""Build the standard required payload fields (type + expiry)."""
|
|
36
|
+
token_lifetime: timedelta = self.rule_handler.get_token_lifetime(
|
|
37
|
+
rule_name, token_type
|
|
38
|
+
)
|
|
39
|
+
return {
|
|
40
|
+
"type": token_type,
|
|
41
|
+
"exp": datetime.now(timezone.utc) + token_lifetime,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def encode_token(
|
|
45
|
+
self,
|
|
46
|
+
payload: dict,
|
|
47
|
+
secret_key: str,
|
|
48
|
+
algorithm: str,
|
|
49
|
+
*args: Any,
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Encode a payload dict into a signed JWT string."""
|
|
53
|
+
return jwt.encode(payload, key=secret_key, algorithm=algorithm, *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
def decode_token(
|
|
56
|
+
self,
|
|
57
|
+
token: str,
|
|
58
|
+
secret_key: str,
|
|
59
|
+
algorithm: str,
|
|
60
|
+
*args: Any,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> Optional[dict]:
|
|
63
|
+
"""Decode and verify a JWT token."""
|
|
64
|
+
try:
|
|
65
|
+
return jwt.decode(
|
|
66
|
+
token, key=secret_key, algorithms=[algorithm], *args, **kwargs
|
|
67
|
+
)
|
|
68
|
+
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
69
|
+
return None
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
flask-advanced-auth
|
|
4
|
+
===================
|
|
5
|
+
Advanced JWT authentication for Flask with multi-rule support,
|
|
6
|
+
token tracking, and blacklisting.
|
|
7
|
+
|
|
8
|
+
MIT License
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .initialize import initialize_jwt_authentication
|
|
12
|
+
from .jwt_rules import JWTAuthenticationRules
|
|
13
|
+
from .factory import TokenFactory
|
|
14
|
+
from .decorators import authenticate
|
|
15
|
+
from .blacklist_token import BlackListed
|
|
16
|
+
from .authentication_classes import (
|
|
17
|
+
BaseAuthentication,
|
|
18
|
+
AuthenticateJWT,
|
|
19
|
+
HeaderAuthentication,
|
|
20
|
+
CookieAuthentication,
|
|
21
|
+
QueryParamAuthentication,
|
|
22
|
+
)
|
|
23
|
+
from .helpers import refresh_access_token, logout
|
|
24
|
+
from .models import AbstractTrackModelMixin, AbstractBlackListModelMixin
|
|
25
|
+
from .database import Database
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
InvalidDefaultKeyType,
|
|
28
|
+
JWTRulesEmpty,
|
|
29
|
+
DefaultRuleDoesNotExist,
|
|
30
|
+
InvalidJWTRule,
|
|
31
|
+
JWTFieldValueError,
|
|
32
|
+
TokenExpired,
|
|
33
|
+
TokenInvalid,
|
|
34
|
+
TokenBlacklisted,
|
|
35
|
+
TokenTypeMismatch,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
__author__ = "Waheed"
|
|
40
|
+
__license__ = "MIT"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Core bootstrap & rules
|
|
44
|
+
"initialize_jwt_authentication",
|
|
45
|
+
"JWTAuthenticationRules",
|
|
46
|
+
"Database",
|
|
47
|
+
# Factory & Decorator
|
|
48
|
+
"TokenFactory",
|
|
49
|
+
"authenticate",
|
|
50
|
+
"BlackListed",
|
|
51
|
+
# Authentication Strategies
|
|
52
|
+
"BaseAuthentication",
|
|
53
|
+
"AuthenticateJWT",
|
|
54
|
+
"HeaderAuthentication",
|
|
55
|
+
"CookieAuthentication",
|
|
56
|
+
"QueryParamAuthentication",
|
|
57
|
+
# Helpers
|
|
58
|
+
"refresh_access_token",
|
|
59
|
+
"logout",
|
|
60
|
+
# Model Mixins
|
|
61
|
+
"AbstractTrackModelMixin",
|
|
62
|
+
"AbstractBlackListModelMixin",
|
|
63
|
+
# Exceptions
|
|
64
|
+
"InvalidDefaultKeyType",
|
|
65
|
+
"JWTRulesEmpty",
|
|
66
|
+
"DefaultRuleDoesNotExist",
|
|
67
|
+
"InvalidJWTRule",
|
|
68
|
+
"JWTFieldValueError",
|
|
69
|
+
"TokenExpired",
|
|
70
|
+
"TokenInvalid",
|
|
71
|
+
"TokenBlacklisted",
|
|
72
|
+
"TokenTypeMismatch",
|
|
73
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Module contains base and concrete authentication classes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
from flask import Request
|
|
9
|
+
from .blacklist_token import BlackListed
|
|
10
|
+
from .factory import TokenFactory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseAuthentication(ABC):
|
|
14
|
+
"""Abstract base class for authentication strategies.
|
|
15
|
+
|
|
16
|
+
Subclass this to implement custom token extraction and validation logic.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
token_headers: The prefix expected in the Authorization header (e.g. 'Bearer ').
|
|
20
|
+
token_type: The expected token type field value (e.g. 'access', 'refresh').
|
|
21
|
+
rule_name: The JWT rule name to use for validation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
token_headers: str = "Bearer ",
|
|
27
|
+
token_type: str = "access",
|
|
28
|
+
rule_name: Optional[str] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.token_headers: str = token_headers
|
|
31
|
+
self.token_type: str = token_type
|
|
32
|
+
self.rule_name: Optional[str] = rule_name
|
|
33
|
+
|
|
34
|
+
def parse_token(self, request: Request) -> Optional[str]:
|
|
35
|
+
"""Extract the JWT string from the request Authorization header.
|
|
36
|
+
|
|
37
|
+
Expects the header format: ``Authorization: <prefix><token>``
|
|
38
|
+
where ``prefix`` is ``self.token_headers`` (e.g. ``'Bearer '``).
|
|
39
|
+
"""
|
|
40
|
+
auth_header: Optional[str] = request.headers.get("Authorization")
|
|
41
|
+
if not auth_header:
|
|
42
|
+
return None
|
|
43
|
+
if not auth_header.startswith(self.token_headers):
|
|
44
|
+
return None
|
|
45
|
+
token = auth_header[len(self.token_headers):]
|
|
46
|
+
if not token:
|
|
47
|
+
return None
|
|
48
|
+
return token
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def authenticate(self, token: str) -> Optional[dict[str, Any]]:
|
|
52
|
+
"""Validate a token and return its payload, or None if invalid."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def authenticate_request(self, request: Request) -> Optional[dict[str, Any]]:
|
|
56
|
+
"""Extract and authenticate the token from the request.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
request: The Flask request object.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The decoded token payload dict if valid, None otherwise.
|
|
63
|
+
"""
|
|
64
|
+
token = self.parse_token(request)
|
|
65
|
+
if token is None:
|
|
66
|
+
return None
|
|
67
|
+
return self.authenticate(token)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthenticateJWT(BaseAuthentication):
|
|
71
|
+
"""Standard JWT authentication — validates token extracted from Authorization header.
|
|
72
|
+
|
|
73
|
+
Checks:
|
|
74
|
+
1. Token is not blacklisted (if blacklisting is enabled for the rule).
|
|
75
|
+
2. Token signature and expiry are valid.
|
|
76
|
+
3. Token type matches the expected type.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def validate_token(self, token: str) -> Optional[dict[str, Any]]:
|
|
80
|
+
"""Decode and verify a token using the configured rule."""
|
|
81
|
+
try:
|
|
82
|
+
return TokenFactory().validate_token(token, self.rule_name)
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def validate_token_type(self, decoded_payload: dict[str, Any]) -> bool:
|
|
87
|
+
"""Check that the decoded payload's 'type' field matches self.token_type."""
|
|
88
|
+
return decoded_payload.get("type") == self.token_type
|
|
89
|
+
|
|
90
|
+
def authenticate(self, token: str) -> Optional[dict[str, Any]]:
|
|
91
|
+
"""Validate a JWT token string.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
token: The raw JWT string.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The decoded payload if all checks pass, None otherwise.
|
|
98
|
+
"""
|
|
99
|
+
if BlackListed().is_blacklisted(self.rule_name, token):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
payload: Optional[dict[str, Any]] = self.validate_token(token)
|
|
103
|
+
if payload is None:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if not self.validate_token_type(payload):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
return payload
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class HeaderAuthentication(AuthenticateJWT):
|
|
113
|
+
"""Explicit alias for Header-based JWT authentication."""
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class CookieAuthentication(AuthenticateJWT):
|
|
118
|
+
"""JWT authentication that extracts tokens from HTTP cookies.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
token_headers: Unused (kept for interface compatibility).
|
|
122
|
+
token_type: Expected token type ('access' or 'refresh').
|
|
123
|
+
rule_name: JWT rule name.
|
|
124
|
+
cookie_name: Name of the cookie containing the token (default: 'access_token').
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
cookie_name: str = "access_token"
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
token_headers: str = "Bearer ",
|
|
132
|
+
token_type: str = "access",
|
|
133
|
+
rule_name: Optional[str] = None,
|
|
134
|
+
cookie_name: str = "access_token",
|
|
135
|
+
) -> None:
|
|
136
|
+
super().__init__(token_headers=token_headers, token_type=token_type, rule_name=rule_name)
|
|
137
|
+
self.cookie_name = cookie_name
|
|
138
|
+
|
|
139
|
+
def parse_token(self, request: Request) -> Optional[str]:
|
|
140
|
+
"""Extract the token from request cookies."""
|
|
141
|
+
return request.cookies.get(self.cookie_name)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class QueryParamAuthentication(AuthenticateJWT):
|
|
145
|
+
"""JWT authentication that extracts tokens from URL query parameters.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
token_headers: Unused (kept for interface compatibility).
|
|
149
|
+
token_type: Expected token type ('access' or 'refresh').
|
|
150
|
+
rule_name: JWT rule name.
|
|
151
|
+
param_name: Name of the query parameter (default: 'token').
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
param_name: str = "token"
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
token_headers: str = "Bearer ",
|
|
159
|
+
token_type: str = "access",
|
|
160
|
+
rule_name: Optional[str] = None,
|
|
161
|
+
param_name: str = "token",
|
|
162
|
+
) -> None:
|
|
163
|
+
super().__init__(token_headers=token_headers, token_type=token_type, rule_name=rule_name)
|
|
164
|
+
self.param_name = param_name
|
|
165
|
+
|
|
166
|
+
def parse_token(self, request: Request) -> Optional[str]:
|
|
167
|
+
"""Extract the token from query arguments."""
|
|
168
|
+
return request.args.get(self.param_name)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Module contains BlackListed class for token revocation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from .jwt_rules import JWTAuthenticationRules
|
|
8
|
+
from .model_managers import ModelManager
|
|
9
|
+
from typing import Any, Union, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AbstractBlackList(ABC):
|
|
13
|
+
"""Abstract interface for token blacklisting."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def blacklist(self, rule_name: str, token: str) -> None:
|
|
17
|
+
"""Add a token to the blacklist."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def is_blacklisted(self, rule_name: str, token: str) -> bool:
|
|
22
|
+
"""Check whether a token is blacklisted."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BlackListed(AbstractBlackList):
|
|
27
|
+
"""Blacklist manager that persists revoked tokens in a database table."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
rule_handler: Optional[JWTAuthenticationRules] = None,
|
|
32
|
+
manager: Optional[ModelManager] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.__rule_handler = rule_handler
|
|
35
|
+
self.__manager = manager
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def _rule_handler(self) -> JWTAuthenticationRules:
|
|
39
|
+
if self.__rule_handler is not None:
|
|
40
|
+
return self.__rule_handler
|
|
41
|
+
return JWTAuthenticationRules()
|
|
42
|
+
|
|
43
|
+
@_rule_handler.setter
|
|
44
|
+
def _rule_handler(self, value: JWTAuthenticationRules) -> None:
|
|
45
|
+
self.__rule_handler = value
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def _manager(self) -> ModelManager:
|
|
49
|
+
if self.__manager is None:
|
|
50
|
+
self.__manager = ModelManager()
|
|
51
|
+
return self.__manager
|
|
52
|
+
|
|
53
|
+
@_manager.setter
|
|
54
|
+
def _manager(self, value: ModelManager) -> None:
|
|
55
|
+
self.__manager = value
|
|
56
|
+
|
|
57
|
+
def blacklist_token(
|
|
58
|
+
self,
|
|
59
|
+
rule_name: str,
|
|
60
|
+
token: str,
|
|
61
|
+
*args: Any,
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Persist a token to the rule's blacklist table."""
|
|
65
|
+
model_path: str = self._rule_handler.get_blacklisted_table_path(rule_name)
|
|
66
|
+
BlackListModel = self._manager.get_model(model_path=model_path)
|
|
67
|
+
model_instance = BlackListModel(token=token, **kwargs)
|
|
68
|
+
self._manager.register(model_instance)
|
|
69
|
+
|
|
70
|
+
def unregister_token_from_track_model(
|
|
71
|
+
self,
|
|
72
|
+
rule_name: str,
|
|
73
|
+
token: str,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Remove a token record from the rule's token-tracking table."""
|
|
76
|
+
model_path: str = self._rule_handler.get_track_created_table_path(rule_name)
|
|
77
|
+
model_instance = self._manager.get_instance(model_path=model_path, token=token)
|
|
78
|
+
if model_instance:
|
|
79
|
+
self._manager.unregister(model_instance)
|
|
80
|
+
|
|
81
|
+
def blacklist(
|
|
82
|
+
self,
|
|
83
|
+
rule_name: str,
|
|
84
|
+
token: str,
|
|
85
|
+
extra_blacklist_fields: dict = {},
|
|
86
|
+
remove_token_from_track_model: bool = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Blacklist a token and optionally remove it from the tracking table."""
|
|
89
|
+
self.blacklist_token(rule_name=rule_name, token=token, **extra_blacklist_fields)
|
|
90
|
+
if remove_token_from_track_model:
|
|
91
|
+
self.unregister_token_from_track_model(rule_name, token)
|
|
92
|
+
|
|
93
|
+
def is_blacklisted(self, rule_name: str, token: str) -> Union[Any, None]:
|
|
94
|
+
"""Check whether a token is in the blacklist."""
|
|
95
|
+
if not self._rule_handler.get_blacklisted(rule_name):
|
|
96
|
+
return False
|
|
97
|
+
model_path: str = self._rule_handler.get_blacklisted_table_path(rule_name)
|
|
98
|
+
blacklisted_token = self._manager.get_instance(
|
|
99
|
+
model_path=model_path, token=token
|
|
100
|
+
)
|
|
101
|
+
return blacklisted_token
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Module contains the Database singleton — wraps a Flask-SQLAlchemy db instance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Any, Type
|
|
7
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
8
|
+
from .import_handler import import_handler
|
|
9
|
+
|
|
10
|
+
_UNSET = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabasePathNotProvided(Exception):
|
|
14
|
+
"""Raised when a database path is not provided on first initialization."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Database:
|
|
19
|
+
"""Singleton that holds and provides access to the Flask-SQLAlchemy db instance.
|
|
20
|
+
|
|
21
|
+
The database instance is discovered via its dotted import path, e.g.
|
|
22
|
+
``"myapp.database.db"``.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> # First call — initialize with the path
|
|
26
|
+
>>> Database("myapp.database.db")
|
|
27
|
+
>>> # Subsequent calls — access the db
|
|
28
|
+
>>> db = Database().db
|
|
29
|
+
>>> db.session.query(MyModel).filter_by(id=1).first()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_instance: Type["Database"] = None
|
|
33
|
+
|
|
34
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
cls._instance = super(Database, cls).__new__(cls)
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
def __init__(self, path: Optional[str] = _UNSET) -> None:
|
|
40
|
+
if path is None:
|
|
41
|
+
raise DatabasePathNotProvided(
|
|
42
|
+
"Database path must be provided on first initialization."
|
|
43
|
+
)
|
|
44
|
+
if path is not _UNSET:
|
|
45
|
+
self.database_path = path
|
|
46
|
+
self._initialized = True
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def _reset(cls) -> None:
|
|
50
|
+
"""Reset singleton state. Intended for use in tests only."""
|
|
51
|
+
cls._instance = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def database_path(self) -> str:
|
|
55
|
+
"""The dotted import path to the SQLAlchemy db instance."""
|
|
56
|
+
if not hasattr(self, "_database_path"):
|
|
57
|
+
raise DatabasePathNotProvided(
|
|
58
|
+
"Database path has not been configured. Call initialize_jwt_authentication() first."
|
|
59
|
+
)
|
|
60
|
+
return self._database_path
|
|
61
|
+
|
|
62
|
+
@database_path.setter
|
|
63
|
+
def database_path(self, value: str) -> None:
|
|
64
|
+
if not isinstance(value, str):
|
|
65
|
+
raise DatabasePathNotProvided(
|
|
66
|
+
f"Database path must be a string, got {type(value)}"
|
|
67
|
+
)
|
|
68
|
+
self._database_path = value
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def db(self) -> SQLAlchemy:
|
|
72
|
+
"""The SQLAlchemy database instance."""
|
|
73
|
+
return self.grab_database(self.database_path)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def grab_database(path: str) -> SQLAlchemy:
|
|
77
|
+
"""Import and return the SQLAlchemy db from the given dotted path."""
|
|
78
|
+
return import_handler.grab(path=path)
|
|
79
|
+
|
|
80
|
+
def model_instance_exists(self, model_class: Any, **kwargs: Any) -> Any:
|
|
81
|
+
"""Query the database for a model instance matching the given filters.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
model_class: The SQLAlchemy model class.
|
|
85
|
+
**kwargs: Filter arguments passed to ``filter_by()``.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The first matching instance, or None.
|
|
89
|
+
"""
|
|
90
|
+
return self.db.session.query(model_class).filter_by(**kwargs).first()
|
|
91
|
+
|
|
92
|
+
def get_model_by_path(self, model_path: str) -> Type:
|
|
93
|
+
"""Return a model class from its dotted import path."""
|
|
94
|
+
return import_handler.grab(model_path)
|
|
95
|
+
|
|
96
|
+
def create_instance(self, instance: Any) -> None:
|
|
97
|
+
"""Add and commit a new model instance to the database."""
|
|
98
|
+
self.db.session.add(instance)
|
|
99
|
+
self.db.session.commit()
|
|
100
|
+
|
|
101
|
+
def delete(self, instance: Any) -> None:
|
|
102
|
+
"""Delete and commit removal of a model instance from the database."""
|
|
103
|
+
self.db.session.delete(instance)
|
|
104
|
+
self.db.session.commit()
|
|
105
|
+
|
|
106
|
+
def update(self, *args: Any, **kwargs: Any) -> None:
|
|
107
|
+
"""Commit any pending changes to the database session."""
|
|
108
|
+
self.db.session.commit()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Module contains the @authenticate decorator for protecting Flask routes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from flask import request, make_response, Response
|
|
8
|
+
from typing import Any, Callable, Tuple, Union, Type, Optional
|
|
9
|
+
from .authentication_classes import BaseAuthentication
|
|
10
|
+
from .jwt_rules import JWTAuthenticationRules
|
|
11
|
+
from .model_managers import ModelManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def authenticate(
|
|
15
|
+
auth_classes: list = None,
|
|
16
|
+
rule_name: Optional[str] = None,
|
|
17
|
+
token_type: str = "access",
|
|
18
|
+
assign_to_request: bool = False,
|
|
19
|
+
assignment_key: str = "instance",
|
|
20
|
+
model_id_key_name: str = "id",
|
|
21
|
+
return_func: Callable = make_response,
|
|
22
|
+
return_func_args: Tuple[Any, ...] = ({"error": "Unauthorized access"}, 401),
|
|
23
|
+
jwt_rules: JWTAuthenticationRules = None,
|
|
24
|
+
) -> Callable:
|
|
25
|
+
"""Decorator that protects a Flask route with JWT authentication.
|
|
26
|
+
|
|
27
|
+
At least one authentication class in ``auth_classes`` must successfully
|
|
28
|
+
authenticate the request for the view to be called.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
auth_classes: List of :class:`BaseAuthentication` subclass **classes**
|
|
32
|
+
(not instances) to try in order. If any succeeds, the route is called.
|
|
33
|
+
rule_name: JWT rule name (None -> default rule).
|
|
34
|
+
token_type: Expected token type ('access' or 'refresh').
|
|
35
|
+
assign_to_request: If True, attaches the authenticated model instance to
|
|
36
|
+
``request.<assignment_key>``.
|
|
37
|
+
assignment_key: The attribute name on the request object (default 'instance').
|
|
38
|
+
model_id_key_name: The primary key field name on the model (default 'id').
|
|
39
|
+
return_func: Callable invoked on auth failure (default: ``make_response``).
|
|
40
|
+
return_func_args: Arguments passed to ``return_func`` on failure.
|
|
41
|
+
jwt_rules: Override for the JWTAuthenticationRules singleton (for testing).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The decorated view function or a failure response.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
TypeError: If auth_classes, return_func, or return_func_args are invalid types.
|
|
48
|
+
"""
|
|
49
|
+
# Default to AuthenticateJWT if no classes provided
|
|
50
|
+
if auth_classes is None:
|
|
51
|
+
from .authentication_classes import AuthenticateJWT
|
|
52
|
+
auth_classes = [AuthenticateJWT]
|
|
53
|
+
|
|
54
|
+
if jwt_rules is None:
|
|
55
|
+
jwt_rules = JWTAuthenticationRules()
|
|
56
|
+
|
|
57
|
+
# Validate arguments
|
|
58
|
+
if not callable(return_func):
|
|
59
|
+
raise TypeError("return_func must be a callable.")
|
|
60
|
+
if not isinstance(return_func_args, tuple):
|
|
61
|
+
raise TypeError("return_func_args must be a tuple.")
|
|
62
|
+
if not isinstance(auth_classes, list) or not all(
|
|
63
|
+
(isinstance(auth, type) and issubclass(auth, BaseAuthentication))
|
|
64
|
+
for auth in auth_classes
|
|
65
|
+
):
|
|
66
|
+
raise TypeError(
|
|
67
|
+
"auth_classes must be a list of BaseAuthentication subclass classes (not instances)."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Grab token header prefix for this rule
|
|
71
|
+
token_header: str = jwt_rules.get_token_header(rule_name)
|
|
72
|
+
|
|
73
|
+
def decorator(view: Callable) -> Callable:
|
|
74
|
+
# FIX T-04: corrected indentation of return wrapper
|
|
75
|
+
@wraps(view)
|
|
76
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
77
|
+
if not auth_classes:
|
|
78
|
+
return return_func(*return_func_args)
|
|
79
|
+
|
|
80
|
+
for auth_class in auth_classes:
|
|
81
|
+
auth_instance: BaseAuthentication = auth_class(
|
|
82
|
+
token_header, token_type, rule_name
|
|
83
|
+
)
|
|
84
|
+
payload: Optional[dict[str, Any]] = auth_instance.authenticate_request(request)
|
|
85
|
+
if payload:
|
|
86
|
+
if assign_to_request:
|
|
87
|
+
instance = _get_instance(rule_name, payload, model_id_key_name, jwt_rules)
|
|
88
|
+
setattr(request, assignment_key, instance)
|
|
89
|
+
return view(*args, **kwargs)
|
|
90
|
+
|
|
91
|
+
# None of the auth_classes authenticated the request
|
|
92
|
+
return return_func(*return_func_args)
|
|
93
|
+
|
|
94
|
+
return wrapper
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_instance(
|
|
100
|
+
rule_name: Optional[str],
|
|
101
|
+
payload: dict[str, Any],
|
|
102
|
+
model_id_key_name: str = "id",
|
|
103
|
+
jwt_rules: Optional[JWTAuthenticationRules] = None,
|
|
104
|
+
model_manager: Optional[ModelManager] = None,
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Retrieve the model instance identified by the JWT payload.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
rule_name: The JWT rule name.
|
|
110
|
+
payload: The decoded JWT payload dict.
|
|
111
|
+
model_id_key_name: The primary key field name (default 'id').
|
|
112
|
+
jwt_rules: JWTAuthenticationRules instance.
|
|
113
|
+
model_manager: ModelManager instance.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The model instance, or None if not found.
|
|
117
|
+
"""
|
|
118
|
+
if jwt_rules is None:
|
|
119
|
+
jwt_rules = JWTAuthenticationRules()
|
|
120
|
+
if model_manager is None:
|
|
121
|
+
model_manager = ModelManager()
|
|
122
|
+
|
|
123
|
+
table_class = model_manager.get_model(jwt_rules.get_table_path(rule_name))
|
|
124
|
+
id_key_name: str = f"{table_class.__tablename__}_{model_id_key_name}"
|
|
125
|
+
instance_id = payload.get(id_key_name)
|
|
126
|
+
return model_manager.get_instance(table_class, **{model_id_key_name: instance_id})
|