fastlifeweb 0.20.1__py3-none-any.whl → 0.22.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.
- CHANGELOG.md +18 -1
- fastlife/__init__.py +45 -13
- fastlife/adapters/__init__.py +1 -1
- fastlife/adapters/fastapi/__init__.py +9 -0
- fastlife/adapters/fastapi/form.py +26 -0
- fastlife/{request → adapters/fastapi}/form_data.py +1 -1
- fastlife/{request → adapters/fastapi}/localizer.py +4 -2
- fastlife/adapters/fastapi/request.py +33 -0
- fastlife/{routing → adapters/fastapi/routing}/route.py +3 -3
- fastlife/{routing → adapters/fastapi/routing}/router.py +1 -1
- fastlife/adapters/itsdangerous/__init__.py +3 -0
- fastlife/adapters/itsdangerous/session.py +50 -0
- fastlife/adapters/jinjax/jinjax_ext/inspectable_component.py +7 -7
- fastlife/adapters/jinjax/jinjax_ext/jinjax_doc.py +1 -1
- fastlife/adapters/jinjax/renderer.py +9 -57
- fastlife/adapters/jinjax/widget_factory/bool_builder.py +2 -2
- fastlife/adapters/jinjax/widget_factory/emailstr_builder.py +5 -4
- fastlife/adapters/jinjax/widget_factory/enum_builder.py +2 -2
- fastlife/adapters/jinjax/widget_factory/factory.py +32 -23
- fastlife/adapters/jinjax/widget_factory/literal_builder.py +7 -6
- fastlife/adapters/jinjax/widget_factory/model_builder.py +3 -3
- fastlife/adapters/jinjax/widget_factory/secretstr_builder.py +2 -2
- fastlife/adapters/jinjax/widget_factory/sequence_builder.py +3 -3
- fastlife/adapters/jinjax/widget_factory/set_builder.py +2 -2
- fastlife/adapters/jinjax/widget_factory/simpletype_builder.py +7 -8
- fastlife/adapters/jinjax/widget_factory/union_builder.py +3 -3
- fastlife/adapters/jinjax/widgets/base.py +36 -36
- fastlife/adapters/jinjax/widgets/boolean.py +13 -34
- fastlife/adapters/jinjax/widgets/checklist.py +36 -42
- fastlife/adapters/jinjax/widgets/dropdown.py +32 -38
- fastlife/adapters/jinjax/widgets/hidden.py +7 -15
- fastlife/adapters/jinjax/widgets/model.py +36 -43
- fastlife/adapters/jinjax/widgets/sequence.py +63 -42
- fastlife/adapters/jinjax/widgets/text.py +39 -78
- fastlife/adapters/jinjax/widgets/union.py +51 -58
- fastlife/components/CsrfToken.jinja +1 -1
- fastlife/components/Form.jinja +1 -1
- fastlife/components/pydantic_form/FatalError.jinja +8 -0
- fastlife/components/pydantic_form/Widget.jinja +4 -3
- fastlife/config/__init__.py +3 -6
- fastlife/config/configurator.py +80 -32
- fastlife/config/exceptions.py +0 -2
- fastlife/config/resources.py +1 -2
- fastlife/config/views.py +2 -4
- fastlife/domain/__init__.py +1 -0
- fastlife/domain/model/__init__.py +1 -0
- fastlife/domain/model/asgi.py +3 -0
- fastlife/domain/model/csrf.py +19 -0
- fastlife/{request → domain/model}/form.py +13 -22
- fastlife/{request → domain/model}/request.py +26 -30
- fastlife/domain/model/security_policy.py +105 -0
- fastlife/{templates/inline.py → domain/model/template.py} +8 -0
- fastlife/domain/model/types.py +17 -0
- fastlife/middlewares/base.py +1 -1
- fastlife/middlewares/reverse_proxy/x_forwarded.py +1 -2
- fastlife/middlewares/session/__init__.py +2 -2
- fastlife/middlewares/session/middleware.py +4 -3
- fastlife/middlewares/session/serializer.py +0 -44
- fastlife/{services/policy.py → service/check_permission.py} +1 -1
- fastlife/{security → service}/csrf.py +5 -15
- fastlife/{services → service}/locale_negociator.py +5 -8
- fastlife/{config → service}/registry.py +13 -7
- fastlife/service/security_policy.py +100 -0
- fastlife/{services → service}/templates.py +10 -48
- fastlife/{services → service}/translations.py +15 -0
- fastlife/{config/settings.py → settings.py} +6 -12
- fastlife/shared_utils/infer.py +24 -1
- fastlife/{templates/constants.py → template_globals.py} +2 -2
- fastlife/testing/testclient.py +2 -2
- fastlife/views/__init__.py +1 -0
- fastlife/views/pydantic_form.py +6 -0
- {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/METADATA +1 -1
- {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/RECORD +79 -80
- tailwind.config.js +1 -1
- fastlife/components/pydantic_form/Boolean.jinja +0 -13
- fastlife/components/pydantic_form/Checklist.jinja +0 -21
- fastlife/components/pydantic_form/Dropdown.jinja +0 -18
- fastlife/components/pydantic_form/Hidden.jinja +0 -3
- fastlife/components/pydantic_form/Model.jinja +0 -30
- fastlife/components/pydantic_form/Sequence.jinja +0 -47
- fastlife/components/pydantic_form/Text.jinja +0 -11
- fastlife/components/pydantic_form/Textarea.jinja +0 -38
- fastlife/components/pydantic_form/Union.jinja +0 -34
- fastlife/request/__init__.py +0 -5
- fastlife/security/__init__.py +0 -1
- fastlife/security/policy.py +0 -188
- fastlife/templates/__init__.py +0 -12
- fastlife/templates/binding.py +0 -52
- /fastlife/{routing → adapters/fastapi/routing}/__init__.py +0 -0
- /fastlife/{services → service}/__init__.py +0 -0
- {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/WHEEL +0 -0
- {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/entry_points.txt +0 -0
- {fastlifeweb-0.20.1.dist-info → fastlifeweb-0.22.0.dist-info}/licenses/LICENSE +0 -0
fastlife/config/exceptions.py
CHANGED
@@ -11,7 +11,6 @@ from .configurator import VENUSIAN_CATEGORY, Configurator
|
|
11
11
|
def exception_handler(
|
12
12
|
exception: type[Exception],
|
13
13
|
*,
|
14
|
-
template: str | None = None,
|
15
14
|
status_code: int | None = None,
|
16
15
|
) -> Callable[..., Any]:
|
17
16
|
"""
|
@@ -34,7 +33,6 @@ def exception_handler(
|
|
34
33
|
config.add_exception_handler(
|
35
34
|
exception,
|
36
35
|
wrapped,
|
37
|
-
template=template,
|
38
36
|
**({} if status_code is None else {"status_code": status_code}),
|
39
37
|
)
|
40
38
|
|
fastlife/config/resources.py
CHANGED
@@ -170,8 +170,7 @@ def resource_view(
|
|
170
170
|
`collection_head`, `collection_options`, `get`, `post`, `put`, `patch`, `delete`,
|
171
171
|
`head` or `options`.
|
172
172
|
|
173
|
-
:param permission: a permission to validate by the
|
174
|
-
:attr:`fastlife.config.settings.Settings.check_permission` function.
|
173
|
+
:param permission: a permission to validate by the security policy.
|
175
174
|
:param status_code: returned status_code
|
176
175
|
:param summary: OpenAPI summary for the route.
|
177
176
|
:param description:OpenAPI description for the route.
|
fastlife/config/views.py
CHANGED
@@ -31,7 +31,6 @@ def view_config(
|
|
31
31
|
path: str,
|
32
32
|
*,
|
33
33
|
permission: str | None = None,
|
34
|
-
template: str | None = None,
|
35
34
|
status_code: int | None = None,
|
36
35
|
methods: list[str] | None = None,
|
37
36
|
) -> Callable[..., Any]:
|
@@ -46,9 +45,9 @@ def view_config(
|
|
46
45
|
:param path: path of the route, use `{curly_brace}` to inject FastAPI Path
|
47
46
|
parameters.
|
48
47
|
:param template: the template rendered by the
|
49
|
-
{class}`fastlife.
|
48
|
+
{class}`fastlife.service.templates.AbstractTemplateRenderer`.
|
50
49
|
:param permission: a permission to validate by the
|
51
|
-
{class}`Security Policy <fastlife.
|
50
|
+
{class}`Security Policy <fastlife.service.security_policy.AbstractSecurityPolicy>`.
|
52
51
|
:param status_code: customize response status code.
|
53
52
|
:param methods: restrict route to a list of http methods.
|
54
53
|
|
@@ -72,7 +71,6 @@ def view_config(
|
|
72
71
|
permission=permission,
|
73
72
|
status_code=status_code,
|
74
73
|
methods=methods,
|
75
|
-
template=template,
|
76
74
|
)
|
77
75
|
|
78
76
|
venusian.attach(wrapped, callback, category=VENUSIAN_CATEGORY) # type: ignore
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Framework core domain."""
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Core Domain Classes."""
|
@@ -0,0 +1,19 @@
|
|
1
|
+
"""Models relative to the security."""
|
2
|
+
|
3
|
+
import secrets
|
4
|
+
|
5
|
+
from pydantic import BaseModel
|
6
|
+
|
7
|
+
|
8
|
+
def create_csrf_token() -> str:
|
9
|
+
"""A helper that create a csrf token."""
|
10
|
+
return secrets.token_urlsafe(5)
|
11
|
+
|
12
|
+
|
13
|
+
class CSRFToken(BaseModel):
|
14
|
+
"""Represent the CSRF Token"""
|
15
|
+
|
16
|
+
name: str
|
17
|
+
"""Name of the token while serialized."""
|
18
|
+
value: str
|
19
|
+
"""Value that must match between parts, cookie and posted form."""
|
@@ -1,13 +1,10 @@
|
|
1
1
|
"""HTTP Form serialization."""
|
2
2
|
|
3
|
-
from collections.abc import
|
3
|
+
from collections.abc import Mapping
|
4
4
|
from typing import Any, Generic, TypeVar, get_origin
|
5
5
|
|
6
|
-
from fastapi import Depends
|
7
6
|
from pydantic import BaseModel, ValidationError
|
8
7
|
|
9
|
-
from fastlife import Registry
|
10
|
-
from fastlife.request.form_data import MappingFormData
|
11
8
|
from fastlife.shared_utils.infer import is_union
|
12
9
|
|
13
10
|
T = TypeVar("T", bound=BaseModel)
|
@@ -17,14 +14,16 @@ T = TypeVar("T", bound=BaseModel)
|
|
17
14
|
class FormModel(Generic[T]):
|
18
15
|
prefix: str
|
19
16
|
model: T
|
20
|
-
|
17
|
+
fatal_error: str
|
18
|
+
errors: dict[str, str]
|
21
19
|
is_valid: bool
|
22
20
|
|
23
21
|
def __init__(
|
24
|
-
self, prefix: str, model: T, errors:
|
22
|
+
self, prefix: str, model: T, errors: dict[str, Any], is_valid: bool = False
|
25
23
|
) -> None:
|
26
24
|
self.prefix = prefix
|
27
25
|
self.model = model
|
26
|
+
self.fatal_error = ""
|
28
27
|
self.errors = errors
|
29
28
|
self.is_valid = is_valid
|
30
29
|
|
@@ -32,6 +31,14 @@ class FormModel(Generic[T]):
|
|
32
31
|
def default(cls, prefix: str, pydantic_type: type[T]) -> "FormModel[T]":
|
33
32
|
return cls(prefix, pydantic_type.model_construct(), {})
|
34
33
|
|
34
|
+
def set_fatal_error(self, value: str) -> None:
|
35
|
+
self.fatal_error = value
|
36
|
+
self.is_valid = False
|
37
|
+
|
38
|
+
def add_error(self, field: str, value: str) -> None:
|
39
|
+
self.errors[f"{self.prefix}.{field}"] = value
|
40
|
+
self.is_valid = False
|
41
|
+
|
35
42
|
def edit(self, pydantic_type: T) -> None:
|
36
43
|
"""
|
37
44
|
Load the form with the given model and consider it as valid for the user.
|
@@ -86,19 +93,3 @@ class FormModel(Generic[T]):
|
|
86
93
|
errors[loc] = error["msg"]
|
87
94
|
model = pydantic_type.model_construct(**data.get(prefix, {}))
|
88
95
|
return cls(prefix, model, errors)
|
89
|
-
|
90
|
-
|
91
|
-
def form_model(
|
92
|
-
cls: type[T], name: str | None = None
|
93
|
-
) -> Callable[[Mapping[str, Any]], FormModel[T]]:
|
94
|
-
"""
|
95
|
-
Build a model, a class of type T based on Pydandic Base Model from a form payload.
|
96
|
-
"""
|
97
|
-
|
98
|
-
def to_model(data: MappingFormData, registry: Registry) -> FormModel[T]:
|
99
|
-
prefix = name or registry.settings.form_data_model_prefix
|
100
|
-
if not data:
|
101
|
-
return FormModel[T].default(prefix, cls)
|
102
|
-
return FormModel[T].from_payload(prefix, cls, data)
|
103
|
-
|
104
|
-
return Depends(to_model)
|
@@ -1,20 +1,20 @@
|
|
1
1
|
"""HTTP Request representation in a python object."""
|
2
2
|
|
3
|
-
from typing import TYPE_CHECKING,
|
3
|
+
from typing import TYPE_CHECKING, Any, Generic
|
4
4
|
|
5
|
-
from
|
6
|
-
from fastapi.params import Depends
|
5
|
+
from starlette.requests import Request as BaseRequest
|
7
6
|
|
8
|
-
from fastlife.
|
7
|
+
from fastlife.domain.model.csrf import CSRFToken, create_csrf_token
|
8
|
+
from fastlife.service.registry import TRegistry
|
9
9
|
|
10
10
|
if TYPE_CHECKING:
|
11
|
-
from fastlife.
|
11
|
+
from fastlife.service.security_policy import ( # coverage: ignore
|
12
12
|
AbstractSecurityPolicy,
|
13
13
|
HasPermission,
|
14
14
|
)
|
15
15
|
|
16
16
|
|
17
|
-
class GenericRequest(
|
17
|
+
class GenericRequest(BaseRequest, Generic[TRegistry]):
|
18
18
|
"""HTTP Request representation."""
|
19
19
|
|
20
20
|
registry: TRegistry
|
@@ -25,11 +25,29 @@ class GenericRequest(FastAPIRequest, Generic[TRegistry]):
|
|
25
25
|
security_policy: "AbstractSecurityPolicy[Any, TRegistry] | None"
|
26
26
|
"""Request locale used for the i18n of the response."""
|
27
27
|
|
28
|
-
|
28
|
+
renderer_globals: dict[str, Any]
|
29
|
+
|
30
|
+
def __init__(self, registry: TRegistry, request: BaseRequest) -> None:
|
29
31
|
super().__init__(request.scope, request.receive)
|
30
32
|
self.registry = registry
|
31
33
|
self.locale_name = registry.locale_negociator(self)
|
32
34
|
self.security_policy = None # build it from the ? registry
|
35
|
+
self.renderer_globals = {}
|
36
|
+
self._csrf_token: CSRFToken | None = None
|
37
|
+
|
38
|
+
@property
|
39
|
+
def csrf_token(self) -> CSRFToken:
|
40
|
+
if self._csrf_token is None:
|
41
|
+
name = self.registry.settings.csrf_token_name
|
42
|
+
value = self.cookies.get(name) or create_csrf_token()
|
43
|
+
self._csrf_token = CSRFToken(name=name, value=value)
|
44
|
+
return self._csrf_token
|
45
|
+
|
46
|
+
def add_renderer_globals(self, **kwargs: Any) -> None:
|
47
|
+
"""
|
48
|
+
Add global variables to the template renderer context for the current request.
|
49
|
+
"""
|
50
|
+
self.renderer_globals.update(kwargs)
|
33
51
|
|
34
52
|
async def has_permission(
|
35
53
|
self, permission: str
|
@@ -45,29 +63,7 @@ class GenericRequest(FastAPIRequest, Generic[TRegistry]):
|
|
45
63
|
if self.security_policy is None:
|
46
64
|
raise RuntimeError(
|
47
65
|
f"Request {self.url.path} require a security policy, "
|
48
|
-
"explicit fastlife.
|
66
|
+
"explicit fastlife.service.security_policy.InsecurePolicy is required."
|
49
67
|
)
|
50
68
|
|
51
69
|
return await self.security_policy.has_permission(permission)
|
52
|
-
|
53
|
-
|
54
|
-
def get_request(request: FastAPIRequest) -> GenericRequest[Any]:
|
55
|
-
return request # type: ignore
|
56
|
-
|
57
|
-
|
58
|
-
Request = Annotated[GenericRequest[DefaultRegistry], Depends(get_request)]
|
59
|
-
"""A request that is associated to the default registry."""
|
60
|
-
# FastAPI handle its Request objects using a lenient_issubclass,
|
61
|
-
# basically a issubclass(Request), doe to the Generic[T], it does not work.
|
62
|
-
|
63
|
-
|
64
|
-
AnyRequest = Annotated[GenericRequest[Any], Depends(get_request)]
|
65
|
-
"""A request version that is associated to the any registry."""
|
66
|
-
|
67
|
-
|
68
|
-
def get_registry(request: Request) -> DefaultRegistry:
|
69
|
-
return request.registry
|
70
|
-
|
71
|
-
|
72
|
-
Registry = Annotated[DefaultRegistry, Depends(get_registry)]
|
73
|
-
"""FastAPI dependency to access to the registry."""
|
@@ -0,0 +1,105 @@
|
|
1
|
+
"""Security policy."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from collections.abc import Callable, Coroutine
|
5
|
+
from typing import Any, Literal, TypeVar
|
6
|
+
|
7
|
+
from fastapi import HTTPException
|
8
|
+
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
9
|
+
|
10
|
+
CheckPermissionHook = Callable[..., Coroutine[Any, Any, None]] | Callable[..., None]
|
11
|
+
CheckPermission = Callable[[str], CheckPermissionHook]
|
12
|
+
|
13
|
+
TUser = TypeVar("TUser")
|
14
|
+
|
15
|
+
log = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class Unauthorized(HTTPException):
|
19
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
status_code: int = HTTP_401_UNAUTHORIZED,
|
24
|
+
detail: str = "Unauthorized",
|
25
|
+
headers: dict[str, str] | None = None,
|
26
|
+
) -> None:
|
27
|
+
super().__init__(status_code, detail, headers)
|
28
|
+
|
29
|
+
|
30
|
+
class Forbidden(HTTPException):
|
31
|
+
"""An exception raised to stop a request exectution and return an HTTP Error."""
|
32
|
+
|
33
|
+
def __init__(
|
34
|
+
self,
|
35
|
+
status_code: int = HTTP_403_FORBIDDEN,
|
36
|
+
detail: str = "Forbidden",
|
37
|
+
headers: dict[str, str] | None = None,
|
38
|
+
) -> None:
|
39
|
+
super().__init__(status_code, detail, headers)
|
40
|
+
|
41
|
+
|
42
|
+
class BoolMeta(type):
|
43
|
+
def __bool__(cls) -> bool:
|
44
|
+
return cls.kind == "allowed" # type: ignore
|
45
|
+
|
46
|
+
def __repr__(cls) -> str:
|
47
|
+
return cls.reason # type: ignore
|
48
|
+
|
49
|
+
|
50
|
+
class HasPermission(int, metaclass=BoolMeta):
|
51
|
+
"""
|
52
|
+
A type used to know if a permission is allowed or not.
|
53
|
+
|
54
|
+
It behave has a boolean, but 3 possibilities exists defind has 3 sub-types
|
55
|
+
{class}`Allowed` {class}`Unauthenticated` or {class}`Denied`.
|
56
|
+
|
57
|
+
In many cases Unauthenticated call may redirect to a login page,
|
58
|
+
where authenticated user are not redirected. they have an error message,
|
59
|
+
or the frontend may use the information to adapt its interface.
|
60
|
+
"""
|
61
|
+
|
62
|
+
kind: Literal["allowed", "unauthenticated", "denied"]
|
63
|
+
"""
|
64
|
+
Identified basic information of the response.
|
65
|
+
It distinguished unauthenticated and denied to eventually raised 401 over 403 error.
|
66
|
+
"""
|
67
|
+
reason: str
|
68
|
+
"""A human explanation of the response."""
|
69
|
+
|
70
|
+
def __new__(cls, reason: str) -> "HasPermission":
|
71
|
+
instance = super().__new__(cls)
|
72
|
+
instance.reason = reason
|
73
|
+
return instance
|
74
|
+
|
75
|
+
def __repr__(self) -> str:
|
76
|
+
return self.reason
|
77
|
+
|
78
|
+
def __bool__(self) -> bool:
|
79
|
+
return self.kind == "allowed"
|
80
|
+
|
81
|
+
|
82
|
+
class Allowed(HasPermission):
|
83
|
+
"""Represent a permission check result that is allowed."""
|
84
|
+
|
85
|
+
kind = "allowed"
|
86
|
+
reason = "Allowed"
|
87
|
+
|
88
|
+
|
89
|
+
class Unauthenticated(HasPermission):
|
90
|
+
"""
|
91
|
+
Represent a permission check result that is not allowed due to
|
92
|
+
missing authentication mechanism.
|
93
|
+
"""
|
94
|
+
|
95
|
+
kind = "unauthenticated"
|
96
|
+
reason = "Authentication required"
|
97
|
+
|
98
|
+
|
99
|
+
class Denied(HasPermission):
|
100
|
+
"""
|
101
|
+
Represent a permission check result that is not allowed due to lack of permission.
|
102
|
+
"""
|
103
|
+
|
104
|
+
kind = "denied"
|
105
|
+
reason = "Access denied to this resource"
|
@@ -20,3 +20,11 @@ class InlineTemplate(BaseModel):
|
|
20
20
|
|
21
21
|
template: ClassVar[str]
|
22
22
|
"""The template string to render."""
|
23
|
+
renderer: ClassVar[str]
|
24
|
+
"""Template render engine to use."""
|
25
|
+
|
26
|
+
|
27
|
+
class JinjaXTemplate(InlineTemplate):
|
28
|
+
"""Template that render JinjaX"""
|
29
|
+
|
30
|
+
renderer = ".jinja"
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""Types that are serialized over HTTP and forms."""
|
2
|
+
|
3
|
+
from decimal import Decimal
|
4
|
+
from typing import Any
|
5
|
+
from uuid import UUID
|
6
|
+
|
7
|
+
from pydantic.networks import EmailStr
|
8
|
+
|
9
|
+
Builtins = str | int | str | float | Decimal | UUID | EmailStr
|
10
|
+
"""Builtins types."""
|
11
|
+
|
12
|
+
|
13
|
+
AnyLiteral = Any
|
14
|
+
"""
|
15
|
+
Something like Literal[...] or Literal[...] which does not exists
|
16
|
+
or Idon't know where it is hidden.
|
17
|
+
"""
|
fastlife/middlewares/base.py
CHANGED
@@ -1,8 +1,7 @@
|
|
1
1
|
import logging
|
2
2
|
from collections.abc import Sequence
|
3
3
|
|
4
|
-
from
|
5
|
-
|
4
|
+
from fastlife.domain.model.asgi import ASGIApp, Receive, Scope, Send
|
6
5
|
from fastlife.middlewares.base import AbstractMiddleware
|
7
6
|
|
8
7
|
log = logging.getLogger(__name__)
|
@@ -15,9 +15,9 @@ from fastlife import Configurator, configure
|
|
15
15
|
from fastlife.shared_utils.resolver import resolve
|
16
16
|
|
17
17
|
from .middleware import SessionMiddleware
|
18
|
-
from .serializer import AbsractSessionSerializer
|
18
|
+
from .serializer import AbsractSessionSerializer
|
19
19
|
|
20
|
-
__all__ = ["
|
20
|
+
__all__ = ["AbsractSessionSerializer", "SessionMiddleware"]
|
21
21
|
|
22
22
|
|
23
23
|
@configure
|
@@ -5,11 +5,11 @@ from typing import Literal
|
|
5
5
|
|
6
6
|
from starlette.datastructures import MutableHeaders
|
7
7
|
from starlette.requests import HTTPConnection
|
8
|
-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
9
8
|
|
9
|
+
from fastlife.domain.model.asgi import ASGIApp, Message, Receive, Scope, Send
|
10
10
|
from fastlife.middlewares.base import AbstractMiddleware
|
11
11
|
|
12
|
-
from .serializer import AbsractSessionSerializer
|
12
|
+
from .serializer import AbsractSessionSerializer
|
13
13
|
|
14
14
|
|
15
15
|
class SessionMiddleware(AbstractMiddleware):
|
@@ -17,6 +17,7 @@ class SessionMiddleware(AbstractMiddleware):
|
|
17
17
|
|
18
18
|
def __init__(
|
19
19
|
self,
|
20
|
+
*,
|
20
21
|
app: ASGIApp,
|
21
22
|
cookie_name: str,
|
22
23
|
secret_key: str,
|
@@ -25,7 +26,7 @@ class SessionMiddleware(AbstractMiddleware):
|
|
25
26
|
cookie_same_site: Literal["lax", "strict", "none"] = "lax",
|
26
27
|
cookie_secure: bool = False,
|
27
28
|
cookie_domain: str = "",
|
28
|
-
serializer: type[AbsractSessionSerializer]
|
29
|
+
serializer: type[AbsractSessionSerializer],
|
29
30
|
) -> None:
|
30
31
|
self.app = app
|
31
32
|
self.max_age = int(duration.total_seconds())
|
@@ -1,13 +1,9 @@
|
|
1
1
|
"""Serialize session."""
|
2
2
|
|
3
3
|
import abc
|
4
|
-
import json
|
5
|
-
from base64 import b64decode, b64encode
|
6
4
|
from collections.abc import Mapping
|
7
5
|
from typing import Any
|
8
6
|
|
9
|
-
import itsdangerous
|
10
|
-
|
11
7
|
|
12
8
|
class AbsractSessionSerializer(abc.ABC):
|
13
9
|
"""Session serializer base class"""
|
@@ -24,43 +20,3 @@ class AbsractSessionSerializer(abc.ABC):
|
|
24
20
|
def deserialize(self, data: bytes) -> tuple[Mapping[str, Any], bool]:
|
25
21
|
"""Derialize the session raw bytes content and return it as a mapping."""
|
26
22
|
...
|
27
|
-
|
28
|
-
|
29
|
-
class SignedSessionSerializer(AbsractSessionSerializer):
|
30
|
-
"""
|
31
|
-
The default fastlife session serializer.
|
32
|
-
|
33
|
-
It's based on the itsdangerous package to sign the session with a secret key.
|
34
|
-
|
35
|
-
:param secret_key: a secret used to sign the session payload.
|
36
|
-
|
37
|
-
:param max_age: session lifetime in seconds.
|
38
|
-
"""
|
39
|
-
|
40
|
-
def __init__(self, secret_key: str, max_age: int) -> None:
|
41
|
-
self.signer = itsdangerous.TimestampSigner(secret_key)
|
42
|
-
self.max_age = max_age
|
43
|
-
|
44
|
-
def serialize(self, data: Mapping[str, Any]) -> bytes:
|
45
|
-
"""Serialize and sign the session."""
|
46
|
-
dump = json.dumps(data).encode("utf-8")
|
47
|
-
encoded = b64encode(dump)
|
48
|
-
signed = self.signer.sign(encoded)
|
49
|
-
return signed
|
50
|
-
|
51
|
-
def deserialize(self, data: bytes) -> tuple[Mapping[str, Any], bool]:
|
52
|
-
"""Deserialize the session.
|
53
|
-
|
54
|
-
If the signature is incorect, the session restart from the begining.
|
55
|
-
No exception raised.
|
56
|
-
"""
|
57
|
-
try:
|
58
|
-
data = self.signer.unsign(data, max_age=self.max_age)
|
59
|
-
# We can't deserialize something wrong since the serialize
|
60
|
-
# is signing the content.
|
61
|
-
# If the signature key is compromise and we have invalid payload,
|
62
|
-
# raising exceptions here is fine, it's dangerous afterall.
|
63
|
-
session = json.loads(b64decode(data))
|
64
|
-
except itsdangerous.BadSignature:
|
65
|
-
return {}, True
|
66
|
-
return session, False
|
@@ -26,7 +26,7 @@ def check_permission(permission_name: str) -> CheckPermissionHook:
|
|
26
26
|
if request.security_policy is None:
|
27
27
|
raise RuntimeError(
|
28
28
|
f"Request {request.url.path} require a security policy, "
|
29
|
-
"explicit fastlife.
|
29
|
+
"explicit fastlife.service.security_policy.InsecurePolicy is required"
|
30
30
|
)
|
31
31
|
allowed = await request.security_policy.has_permission(permission_name)
|
32
32
|
match allowed.kind:
|
@@ -6,7 +6,7 @@ Fast life did not reinvent the wheel on CSRF Protection.
|
|
6
6
|
It use the good old method. A CSRF token is saved in a cookie.
|
7
7
|
Forms post the CSRF token, and the token in the cookies and the form must match
|
8
8
|
to process the request, otherwise an exception
|
9
|
-
{class}`fastlife.
|
9
|
+
{class}`fastlife.service.csrf.CSRFAttack` is raised.
|
10
10
|
|
11
11
|
The cookie named is configurabllefia the settings
|
12
12
|
:attr:`fastlife.config.settings.Settings.csrf_token_name`
|
@@ -18,11 +18,10 @@ no way to prevent to set the cookie in the request.
|
|
18
18
|
|
19
19
|
"""
|
20
20
|
|
21
|
-
import secrets
|
22
21
|
from collections.abc import Callable, Coroutine
|
23
22
|
from typing import Any
|
24
23
|
|
25
|
-
from fastlife.request import Request
|
24
|
+
from fastlife.adapters.fastapi.request import Request
|
26
25
|
|
27
26
|
|
28
27
|
class CSRFAttack(Exception):
|
@@ -31,11 +30,6 @@ class CSRFAttack(Exception):
|
|
31
30
|
"""
|
32
31
|
|
33
32
|
|
34
|
-
def create_csrf_token() -> str:
|
35
|
-
"""A helper that create a csrf token."""
|
36
|
-
return secrets.token_urlsafe(5)
|
37
|
-
|
38
|
-
|
39
33
|
def check_csrf() -> Callable[[Request], Coroutine[Any, Any, bool]]:
|
40
34
|
"""
|
41
35
|
A global application dependency, that is always active.
|
@@ -56,15 +50,11 @@ def check_csrf() -> Callable[[Request], Coroutine[Any, Any, bool]]:
|
|
56
50
|
!= "application/x-www-form-urlencoded"
|
57
51
|
):
|
58
52
|
return True
|
59
|
-
csrf_token_name = request.registry.settings.csrf_token_name
|
60
|
-
|
61
|
-
cookie = request.cookies.get(csrf_token_name)
|
62
|
-
if not cookie:
|
63
|
-
raise CSRFAttack("CSRF token did not match")
|
64
53
|
|
54
|
+
token = request.csrf_token
|
65
55
|
form_data = await request.form()
|
66
|
-
value = form_data.get(
|
67
|
-
if value !=
|
56
|
+
value = form_data.get(token.name)
|
57
|
+
if value != token.value:
|
68
58
|
raise CSRFAttack("CSRF token did not match")
|
69
59
|
|
70
60
|
return True
|
@@ -1,20 +1,17 @@
|
|
1
1
|
"""Find the localization gor the given request."""
|
2
2
|
|
3
3
|
from collections.abc import Callable
|
4
|
-
from typing import
|
4
|
+
from typing import Any
|
5
5
|
|
6
|
-
from fastlife.
|
6
|
+
from fastlife.settings import Settings
|
7
7
|
|
8
8
|
LocaleName = str
|
9
9
|
"""The LocaleName is a locale such as en, fr that will be consume for translations."""
|
10
10
|
|
11
|
-
|
12
|
-
from fastlife.request.request import GenericRequest # coverage: ignore
|
11
|
+
from fastlife.adapters.fastapi.request import GenericRequest # coverage: ignore
|
13
12
|
|
14
|
-
|
15
|
-
|
16
|
-
else:
|
17
|
-
LocaleNegociator = Any
|
13
|
+
LocaleNegociator = Callable[[GenericRequest[Any]], LocaleName] # coverage: ignore
|
14
|
+
"""Interface to implement to negociate a locale""" # coverage: ignore
|
18
15
|
|
19
16
|
|
20
17
|
def default_negociator(settings: Settings) -> LocaleNegociator:
|
@@ -1,15 +1,14 @@
|
|
1
1
|
from collections.abc import Mapping
|
2
2
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
3
3
|
|
4
|
-
from fastlife.services.locale_negociator import LocaleNegociator, default_negociator
|
5
|
-
from fastlife.services.translations import LocalizerFactory
|
6
|
-
|
7
4
|
if TYPE_CHECKING:
|
8
|
-
from fastlife.
|
5
|
+
from fastlife.service.locale_negociator import LocaleNegociator # coverage: ignore
|
6
|
+
from fastlife.service.templates import ( # coverage: ignore
|
9
7
|
AbstractTemplateRendererFactory, # coverage: ignore
|
10
8
|
) # coverage: ignore
|
9
|
+
from fastlife.service.translations import LocalizerFactory # coverage: ignore
|
11
10
|
|
12
|
-
from .settings import Settings
|
11
|
+
from fastlife.settings import Settings
|
13
12
|
|
14
13
|
TSettings = TypeVar("TSettings", bound=Settings, covariant=True)
|
15
14
|
"""
|
@@ -24,11 +23,18 @@ class GenericRegistry(Generic[TSettings]):
|
|
24
23
|
"""
|
25
24
|
|
26
25
|
settings: Settings
|
26
|
+
"""Application settings."""
|
27
27
|
renderers: Mapping[str, "AbstractTemplateRendererFactory"]
|
28
|
-
|
29
|
-
|
28
|
+
"""Registered template engine."""
|
29
|
+
locale_negociator: "LocaleNegociator"
|
30
|
+
"""Used to fine the best language for the response."""
|
31
|
+
localizer: "LocalizerFactory"
|
32
|
+
"""Used to localized message."""
|
30
33
|
|
31
34
|
def __init__(self, settings: Settings) -> None:
|
35
|
+
from fastlife.service.locale_negociator import default_negociator
|
36
|
+
from fastlife.service.translations import LocalizerFactory
|
37
|
+
|
32
38
|
self.settings = settings
|
33
39
|
self.locale_negociator = default_negociator(self.settings)
|
34
40
|
self.renderers = {}
|