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,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-auth
3
+ Version: 0.1.0
4
+ Requires-Dist: bcrypt>=5.0.0
5
+ Requires-Dist: cryptography>=46.0.5
6
+ Requires-Dist: jwt>=1.4.0
7
+ Requires-Dist: patera
8
+ Requires-Python: >=3.12
@@ -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)