patera-auth 0.1.0__tar.gz
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.
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "patera-auth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"bcrypt>=5.0.0",
|
|
7
|
+
"cryptography>=46.0.5",
|
|
8
|
+
"jwt>=1.4.0",
|
|
9
|
+
"patera",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.uv.sources]
|
|
13
|
+
patera = { workspace = true }
|
|
14
|
+
|
|
15
|
+
[tool.uv.build-backend]
|
|
16
|
+
module-name = "patera.auth"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .authentication import (
|
|
6
|
+
login_required,
|
|
7
|
+
role_required,
|
|
8
|
+
Authentication,
|
|
9
|
+
AuthUtils,
|
|
10
|
+
AuthConfig,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .exceptions import AuthenticationException, AuthorizationException
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"login_required",
|
|
17
|
+
"role_required",
|
|
18
|
+
"Authentication",
|
|
19
|
+
"AuthUtils",
|
|
20
|
+
"AuthConfig",
|
|
21
|
+
"AuthenticationException",
|
|
22
|
+
"AuthorizationException",
|
|
23
|
+
]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
authentication.py
|
|
3
|
+
Authentication module of Patera
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import (
|
|
8
|
+
Callable,
|
|
9
|
+
Optional,
|
|
10
|
+
Dict,
|
|
11
|
+
Any,
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
Type,
|
|
14
|
+
cast,
|
|
15
|
+
TypedDict,
|
|
16
|
+
NotRequired,
|
|
17
|
+
)
|
|
18
|
+
import base64
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
|
|
21
|
+
import bcrypt
|
|
22
|
+
import jwt
|
|
23
|
+
import binascii
|
|
24
|
+
from cryptography.hazmat.primitives.hmac import HMAC
|
|
25
|
+
from cryptography.hazmat.primitives import hashes
|
|
26
|
+
from cryptography.exceptions import InvalidSignature
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from patera import Patera, Response, Request
|
|
31
|
+
|
|
32
|
+
from patera.controller import Controller
|
|
33
|
+
from patera.utilities import run_sync_or_async
|
|
34
|
+
from patera.middleware import AppCallableType, MiddlewareBase
|
|
35
|
+
|
|
36
|
+
from .exceptions import AuthenticationException, AuthorizationException
|
|
37
|
+
|
|
38
|
+
REQUEST_ARGS_ERROR_MSG: str = (
|
|
39
|
+
"Injected argument 'req' of route handler is not an instance "
|
|
40
|
+
"of the Request class. If you used additional decorators "
|
|
41
|
+
"make sure the order of arguments was not changed. "
|
|
42
|
+
"The Request argument must always come first."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
USER_LOADER_ERROR_MSG: str = (
|
|
46
|
+
"Undefined user loader method. Please define a user loader "
|
|
47
|
+
"method with the @user_loader decorator before using "
|
|
48
|
+
"the login_required decorator"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _AuthenticationConfigs(BaseModel):
|
|
53
|
+
"""
|
|
54
|
+
Authentication configuration model
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
AUTHENTICATION_ERROR_MSG: Optional[str] = Field(
|
|
58
|
+
default="Login required", description="Default authentication error message"
|
|
59
|
+
)
|
|
60
|
+
AUTHORIZATION_ERROR_MSG: Optional[str] = Field(
|
|
61
|
+
default="Missing user role(s)",
|
|
62
|
+
description="Default authorization error message",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthConfig(TypedDict):
|
|
67
|
+
"""Authentication configurations"""
|
|
68
|
+
|
|
69
|
+
AUTHENTICATION_ERROR_MSG: NotRequired[str]
|
|
70
|
+
AUTHORIZATION_ERROR_MSG: NotRequired[str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AuthUtils:
|
|
74
|
+
"""
|
|
75
|
+
Utility class with useful static methods for authentication
|
|
76
|
+
1. create_signed_cookie_value
|
|
77
|
+
2. decode_signed_cookie
|
|
78
|
+
3. create_password_hash
|
|
79
|
+
4. check_password_hash
|
|
80
|
+
5. create_jwt_token
|
|
81
|
+
6. validate_jwt_token
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def create_signed_cookie_value(value: str | int, secret_key: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Creates a signed cookie value using HMAC and a secret key.
|
|
88
|
+
|
|
89
|
+
value: The string value to be signed
|
|
90
|
+
secret_key: The application's secret key for signing
|
|
91
|
+
|
|
92
|
+
Returns a base64-encoded signed value.
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(value, int):
|
|
95
|
+
value = f"{value}"
|
|
96
|
+
|
|
97
|
+
hmac_instance = HMAC(secret_key.encode("utf-8"), hashes.SHA256())
|
|
98
|
+
hmac_instance.update(value.encode("utf-8"))
|
|
99
|
+
signature = hmac_instance.finalize()
|
|
100
|
+
signed_value = f"{value}|{base64.urlsafe_b64encode(signature).decode('utf-8')}"
|
|
101
|
+
return signed_value
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def decode_signed_cookie(cookie_value: str, secret_key: str) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Decodes and verifies a signed cookie value.
|
|
107
|
+
|
|
108
|
+
cookie_value: The signed cookie value to be verified and decoded
|
|
109
|
+
secret_key: The application's secret key for verification
|
|
110
|
+
|
|
111
|
+
Returns the original string value if the signature is valid.
|
|
112
|
+
Raises a ValueError if the signature is invalid.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
value, signature = cookie_value.rsplit("|", 1)
|
|
116
|
+
signature_bytes = base64.urlsafe_b64decode(signature)
|
|
117
|
+
hmac_instance = HMAC(secret_key.encode("utf-8"), hashes.SHA256())
|
|
118
|
+
hmac_instance.update(value.encode("utf-8"))
|
|
119
|
+
hmac_instance.verify(signature_bytes) # Throws an exception if invalid
|
|
120
|
+
return value
|
|
121
|
+
except (ValueError, IndexError, binascii.Error, InvalidSignature):
|
|
122
|
+
# pylint: disable-next=W0707
|
|
123
|
+
raise ValueError("Invalid signed cookie format or signature.")
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def create_password_hash(password: str) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Creates a secure hash for a given password.
|
|
129
|
+
|
|
130
|
+
password: The plain text password to be hashed
|
|
131
|
+
Returns the hashed password as a string.
|
|
132
|
+
"""
|
|
133
|
+
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
|
134
|
+
return hashed.decode("utf-8")
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def check_password_hash(password: str, hashed_password: str) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Verifies a given password against a hashed password.
|
|
140
|
+
|
|
141
|
+
password: The plain text password provided by the user
|
|
142
|
+
hashed_password: The stored hashed password
|
|
143
|
+
Returns True if the password matches, False otherwise.
|
|
144
|
+
"""
|
|
145
|
+
return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def create_jwt_token(payload: Dict, secret_key: str, expires_in: int = 3600) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Creates a JWT token.
|
|
151
|
+
|
|
152
|
+
:param payload: A dictionary containing the payload data.
|
|
153
|
+
:param expires_in: Token expiry time in seconds (default: 3600 seconds = 1 hour).
|
|
154
|
+
:return: Encoded JWT token as a string.
|
|
155
|
+
"""
|
|
156
|
+
if not isinstance(payload, dict):
|
|
157
|
+
raise ValueError("Payload must be a dictionary.")
|
|
158
|
+
|
|
159
|
+
# Add expiry to the payload
|
|
160
|
+
payload = payload.copy()
|
|
161
|
+
payload["exp"] = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
162
|
+
|
|
163
|
+
# Create the token using the app's SECRET_KEY
|
|
164
|
+
token = jwt.encode(payload, secret_key, algorithm="HS256")
|
|
165
|
+
return token
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def validate_jwt_token(token: str, secret_key: str) -> Dict | None:
|
|
169
|
+
"""
|
|
170
|
+
Validates a JWT token.
|
|
171
|
+
|
|
172
|
+
:param token: The JWT token to validate.
|
|
173
|
+
:return: Decoded payload if the token is valid.
|
|
174
|
+
:raises: InvalidJWTError if the token is expired.
|
|
175
|
+
InvalidJWTError for other validation issues.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
# Decode the token using the app's SECRET_KEY
|
|
179
|
+
payload = jwt.decode(token, secret_key, algorithms=["HS256"])
|
|
180
|
+
return payload
|
|
181
|
+
except:
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Authentication(MiddlewareBase, ABC):
|
|
186
|
+
"""
|
|
187
|
+
Authentication middleware for Patera application
|
|
188
|
+
User must implement user_loader method and optionally role_check method
|
|
189
|
+
to define how users are loaded and how roles are checked.
|
|
190
|
+
1. user_loader: should return a user object (or None) loaded from the cookie/jwt/header token
|
|
191
|
+
2. role_check: should check if user has required role(s) and return a boolean
|
|
192
|
+
True -> user has role(s)
|
|
193
|
+
False -> user doesn't have role(s)
|
|
194
|
+
3. Decorators:
|
|
195
|
+
- login_required: to mark route handlers/controllers that require authentication
|
|
196
|
+
- role_required: to mark route handlers/controllers that require specific roles
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
configs_name: str = cast(str, None)
|
|
200
|
+
|
|
201
|
+
def __init__(self, app: "Patera", next_app: AppCallableType) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Initilizer for authentication module
|
|
204
|
+
"""
|
|
205
|
+
super().__init__(app, next_app) # type: ignore
|
|
206
|
+
self._configs: dict[str, Any] = {}
|
|
207
|
+
self.authentication_error: str
|
|
208
|
+
self.authorization_error: str
|
|
209
|
+
|
|
210
|
+
self._configs = app.get_conf(self.configs_name, {})
|
|
211
|
+
self._configs = self.validate_configs(self._configs, _AuthenticationConfigs)
|
|
212
|
+
|
|
213
|
+
self.authentication_error = self._configs["AUTHENTICATION_ERROR_MSG"]
|
|
214
|
+
self.authorization_error = self._configs["AUTHORIZATION_ERROR_MSG"]
|
|
215
|
+
# self._app.add_extension(self) #is this neccessary? - Probably not
|
|
216
|
+
|
|
217
|
+
async def middleware(self, req: "Request") -> "Response":
|
|
218
|
+
"""
|
|
219
|
+
Middleware for authentication
|
|
220
|
+
"""
|
|
221
|
+
handler_method: Callable = req.route_handler
|
|
222
|
+
handler_authentication_attributes: Optional[dict[str, Any]] = getattr(
|
|
223
|
+
handler_method, "_authentication", None
|
|
224
|
+
)
|
|
225
|
+
controller_authentication_attributes: Optional[dict[str, Any]] = getattr(
|
|
226
|
+
handler_method.__self__,
|
|
227
|
+
"_authentication",
|
|
228
|
+
None, # type: ignore
|
|
229
|
+
) # type: ignore
|
|
230
|
+
if (
|
|
231
|
+
handler_authentication_attributes is None
|
|
232
|
+
and controller_authentication_attributes is None
|
|
233
|
+
):
|
|
234
|
+
return await self.next(req)
|
|
235
|
+
if (
|
|
236
|
+
controller_authentication_attributes is not None
|
|
237
|
+
or handler_authentication_attributes is not None
|
|
238
|
+
): # type: ignore
|
|
239
|
+
user: Optional[Any] = await run_sync_or_async(self.user_loader, req)
|
|
240
|
+
if user is None:
|
|
241
|
+
# not Authenticated
|
|
242
|
+
raise AuthenticationException(self.authentication_error)
|
|
243
|
+
req.set_user(user)
|
|
244
|
+
controller_roles: list[Any] = (
|
|
245
|
+
controller_authentication_attributes.get("roles", [])
|
|
246
|
+
if controller_authentication_attributes
|
|
247
|
+
else []
|
|
248
|
+
)
|
|
249
|
+
handler_roles: list[Any] = (
|
|
250
|
+
handler_authentication_attributes.get("roles", [])
|
|
251
|
+
if handler_authentication_attributes
|
|
252
|
+
else []
|
|
253
|
+
)
|
|
254
|
+
roles: list[Any] = list(set(controller_roles + handler_roles))
|
|
255
|
+
if len(roles) == 0: # roles not are specified
|
|
256
|
+
# user is authenticated
|
|
257
|
+
return await self.next(req)
|
|
258
|
+
authorized: bool = await run_sync_or_async(
|
|
259
|
+
self.role_check, req.user, list(roles)
|
|
260
|
+
)
|
|
261
|
+
if not authorized:
|
|
262
|
+
# not authorized
|
|
263
|
+
raise AuthorizationException(self.authorization_error, list(roles))
|
|
264
|
+
# user is authenticated and authorized - calls next middleware in chain
|
|
265
|
+
return await self.next(req)
|
|
266
|
+
|
|
267
|
+
@abstractmethod
|
|
268
|
+
async def user_loader(self, req: "Request") -> Any:
|
|
269
|
+
"""
|
|
270
|
+
Should return a user object (or None) loaded from the cookie
|
|
271
|
+
or some other way provided by the request object
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
@abstractmethod
|
|
275
|
+
async def role_check(self, user: Any, roles: list[Any]) -> bool:
|
|
276
|
+
"""
|
|
277
|
+
Should check if user has required role(s) and return a boolean
|
|
278
|
+
True -> user has role(s)
|
|
279
|
+
False -> user doesn't have role(s)
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def login_required(handler: "Callable|Type[Controller]") -> "Callable|Type[Controller]":
|
|
284
|
+
"""
|
|
285
|
+
Decorator for login required
|
|
286
|
+
"""
|
|
287
|
+
setattr(handler, "_authentication", {"required": True})
|
|
288
|
+
return handler
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def role_required(
|
|
292
|
+
*roles,
|
|
293
|
+
) -> Callable[[Callable | Type[Controller]], Callable | Type[Controller]]:
|
|
294
|
+
"""
|
|
295
|
+
Decorator for role required
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def decorator(handler: "Callable|Type[Controller]") -> "Callable|Type[Controller]":
|
|
299
|
+
attributes: dict[str, Any] = getattr(handler, "_authentication", {})
|
|
300
|
+
attributes["roles"] = list(roles)
|
|
301
|
+
attributes["required"] = True
|
|
302
|
+
setattr(handler, "_authentication", attributes)
|
|
303
|
+
return handler
|
|
304
|
+
|
|
305
|
+
return decorator
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from patera import HttpStatus
|
|
3
|
+
from patera.exceptions import BaseHttpException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthenticationException(BaseHttpException):
|
|
7
|
+
"""
|
|
8
|
+
Authentication exception for endpoints which require authentication
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str):
|
|
12
|
+
super().__init__(message, HttpStatus.FORBIDDEN, "error", None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthorizationException(BaseHttpException):
|
|
16
|
+
"""
|
|
17
|
+
Authorization exception for endpoints which require specific roles
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, roles: list[Any]):
|
|
21
|
+
super().__init__(message, HttpStatus.UNAUTHORIZED, "error", roles)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidJWTError(BaseHttpException):
|
|
25
|
+
"""
|
|
26
|
+
Invalid or expired JWT token error
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str):
|
|
30
|
+
super().__init__(message, 401, "error", None)
|