Skip to content

Commit dcf1eb0

Browse files
committed
Session Documentation: Added documentation for session and refactored some session class names
1 parent 183aac7 commit dcf1eb0

File tree

21 files changed

+396
-119
lines changed

21 files changed

+396
-119
lines changed

docs/security/sessions.md

Lines changed: 264 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,264 @@
1-
# Coming Soon
1+
# **Session**
2+
A web session acts as a transient interaction between a user and a web application, usually initiated upon visiting a website.
3+
It enables the storage of user-specific data across multiple requests, facilitating personalized experiences, authentication,
4+
and state management within the application.
5+
6+
In Ellar, session management is overseen by the `SessionMiddleware`, which delegates the serialization and
7+
deserialization of sessions to any registered `SessionStrategy` service within the Dependency Injection (DI) container.
8+
Sessions can be accessed and modified using the `request.session` dictionary interface.
9+
10+
## **Accessing Session**
11+
Session objects are accessible via `request.session` or by injecting `Session` into route parameters.
12+
13+
For example:
14+
=== "Request Object"
15+
```python
16+
from ellar.common import Controller, ControllerBase, get
17+
18+
@Controller
19+
class SampleController(ControllerBase):
20+
@get()
21+
def index(self):
22+
session = self.context.switch_to_http_connection().get_request().session
23+
assert isinstance(session, dict)
24+
return {'index': 'okay'}
25+
```
26+
27+
=== "Route Parameter Injection"
28+
```python
29+
from ellar.common import Controller, ControllerBase, get, Inject
30+
31+
@Controller
32+
class SampleController(ControllerBase):
33+
@get()
34+
def index(self, session: Inject[str, Inject.Key('Session')]):
35+
assert isinstance(session, dict)
36+
return {'index': 'okay'}
37+
```
38+
39+
## **SessionClientStrategy**
40+
The `SessionClientStrategy` serves as the default implementation for `SessionStrategy`,
41+
leveraging the `itsdangerous` package for hashing session data. It serializes session data and saves it on the client side.
42+
However, large session data may cause issues for some requests.
43+
44+
To utilize `SessionClientStrategy`, ensure the `itsdangerous` package is installed:
45+
46+
```shell
47+
pip install itsdangerous
48+
```
49+
50+
Activate `SessionClientStrategy` by registering it with the DI container, as demonstrated below:
51+
52+
```python
53+
from ellar.common import IHostContext, JSONResponse, Module, Response, exception_handler
54+
from ellar.core import ModuleBase
55+
from ellar.samples.modules import HomeModule
56+
57+
from ellar.auth.session.strategy import SessionClientStrategy
58+
from ellar.auth.session import SessionStrategy
59+
from ellar.di import ProviderConfig
60+
61+
from .car.module import CarModule
62+
63+
64+
@Module(
65+
modules=[HomeModule, CarModule],
66+
providers=[ProviderConfig(SessionStrategy, use_class=SessionClientStrategy)]
67+
)
68+
class ApplicationModule(ModuleBase):
69+
@exception_handler(404)
70+
def exception_404_handler(cls, context: IHostContext, exc: Exception) -> Response:
71+
return JSONResponse({"detail": "Resource not found."}, status_code=404)
72+
```
73+
By registering `ISessionStrategy` as `SessionClientStrategy` with `ProviderConfig(ISessionStrategy, use_class=SessionClientStrategy)`, `SessionMiddleware` utilizes `SessionClientStrategy` for session management.
74+
75+
## **SessionStrategy Configuration Options**
76+
- **SESSION_COOKIE_NAME**: Defaults to "session".
77+
- **SESSION_COOKIE_PATH**: Sets the path for the session cookie. If not set, the cookie is valid for all of `APPLICATION_ROOT` or '/' if not specified.
78+
- **SESSION_COOKIE_SECURE**: Controls whether the cookie is set with the secure flag, which restricts it to HTTPS requests. Default: `False`.
79+
- **SESSION_COOKIE_MAX_AGE**: Sets the session expiry time in seconds, defaulting to 2 weeks. If set to `None`, the cookie lasts until the browser session ends.
80+
- **SESSION_COOKIE_SAME_SITE**: Specifies the SameSite flag to prevent sending the session cookie along with cross-site requests. Defaults to 'lax'.
81+
- **SESSION_COOKIE_HTTPONLY**: Indicates whether the HttpOnly flag should be set, restricting cookie access to HTTP requests only. Defaults to `False`.
82+
- **SESSION_COOKIE_DOMAIN**: Specifies the domain of the cookie, facilitating sharing between subdomains or cross-domains. The browser defaults the domain to the same host that set the cookie, excluding subdomain references.
83+
84+
## **Custom SessionStrategy**
85+
86+
In this section, we'll walk through creating another session strategy that saves session data
87+
to a relational database using the `EllarSQL` package.
88+
89+
To begin, you'll need to install the `EllarSQL` package:
90+
91+
```shell
92+
pip install ellar-sql
93+
```
94+
95+
Next, create a Python file named `session.py` in your project's root directory and paste the following code:
96+
97+
```python title="project_name/session.py"
98+
import pickle
99+
import secrets
100+
import typing as t
101+
from ellar_sql import model, first_or_none
102+
from datetime import datetime, timedelta
103+
104+
from ellar.auth.session import SessionStrategy, SessionCookieObject, SessionCookieOption
105+
from ellar.threading import run_as_async
106+
107+
from itsdangerous import want_bytes
108+
109+
110+
class SessionTable(model.Model):
111+
id = model.Column(model.Integer, primary_key=True)
112+
session_id = model.Column(model.String(255), unique=True)
113+
data = model.Column(model.LargeBinary)
114+
expiry = model.Column(model.DateTime)
115+
116+
def __init__(self, session_id, data, expiry):
117+
super().__init__()
118+
119+
self.session_id = session_id
120+
self.data = data
121+
self.expiry = expiry
122+
123+
def __repr__(self):
124+
return "<Session data %s>" % self.data
125+
126+
127+
class EllarSQLSessionStrategy(SessionStrategy):
128+
"""Uses the EllarSQL for a session backend."""
129+
130+
serializer = pickle
131+
132+
def __init__(
133+
self,
134+
key_prefix: str,
135+
name: str = "ellar-sql"
136+
):
137+
self.key_prefix = key_prefix
138+
self._session_option = SessionCookieOption(NAME=name)
139+
140+
@property
141+
def session_cookie_options(self) -> SessionCookieOption:
142+
return self._session_option
143+
144+
def serialize_session(
145+
self,
146+
session: t.Union[str, SessionCookieObject],
147+
) -> str:
148+
return self._save_session_data(session)
149+
150+
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
151+
return self._fetch_record(session_data)
152+
153+
async def _try_coroutine(self, func: t.Optional[t.Coroutine]) -> None:
154+
if isinstance(func, t.Coroutine):
155+
await func
156+
157+
@run_as_async
158+
async def _fetch_record(self, key: str) -> SessionCookieObject:
159+
"""Get the saved session (record) from the database"""
160+
key = key or secrets.token_urlsafe(5)
161+
store_id = self.key_prefix + key
162+
record: t.Optional[SessionTable] = await first_or_none(
163+
model.select(SessionTable).filter_by(session_id=store_id))
164+
165+
# If the expiration time is less than or equal to the current time (expired), delete the document
166+
if record is not None:
167+
expiration_datetime = record.expiry
168+
if expiration_datetime is None or expiration_datetime <= datetime.utcnow():
169+
session = SessionTable.get_db_session()
170+
await self._try_coroutine(session.delete(record))
171+
await self._try_coroutine(session.commit())
172+
173+
record = None
174+
175+
# If the saved session still exists after checking for expiration, load the session data from the document
176+
if record:
177+
try:
178+
session_data = self.serializer.loads(want_bytes(record.data))
179+
return SessionCookieObject(session_data, sid=key)
180+
except pickle.UnpicklingError:
181+
return SessionCookieObject(sid=key)
182+
183+
return SessionCookieObject(sid=key)
184+
185+
@run_as_async
186+
async def _save_session_data(self, session: t.Union[str, SessionCookieObject], ) -> str:
187+
"""Generate a prefixed session id"""
188+
prefixed_session_id = self.key_prefix + session.sid
189+
190+
# If the session is empty, do not save it to the database or set a cookie
191+
if not session:
192+
# If the session was deleted (empty and modified), delete the saved session from the database and tell the client to delete the cookie
193+
if session.modified:
194+
record = await first_or_none(model.select(SessionTable).filter_by(session_id=prefixed_session_id))
195+
session = SessionTable.get_db_session()
196+
197+
await self._try_coroutine(session.delete(record))
198+
await self._try_coroutine(session.commit())
199+
200+
return self.get_cookie_header_value(session, delete=True)
201+
202+
# Serialize session data
203+
serialized_session_data = self.serializer.dumps(dict(session))
204+
205+
# Get the new expiration time for the session
206+
expiration_datetime = datetime.utcnow() + timedelta(days=14)
207+
208+
# Update existing or create new session in the database
209+
record = await first_or_none(model.select(SessionTable).filter_by(session_id=prefixed_session_id))
210+
db_session = SessionTable.get_db_session()
211+
212+
if record:
213+
record.data = serialized_session_data
214+
record.expiry = expiration_datetime
215+
else:
216+
db_session = SessionTable.get_db_session()
217+
record = SessionTable(
218+
session_id=prefixed_session_id,
219+
data=serialized_session_data,
220+
expiry=expiration_datetime,
221+
)
222+
db_session.add(record)
223+
await self._try_coroutine(db_session.commit())
224+
225+
return self.get_cookie_header_value(session.sid)
226+
```
227+
228+
In the code above, session data is serialized to bytes using the Python `pickle` package, and other processes are standard SQLAlchemy actions.
229+
230+
Next, register the `EllarSQLSessionStrategy` as the `SessionStrategy`:
231+
232+
```python
233+
from ellar.common import IHostContext, JSONResponse, Module, Response, exception_handler, IApplicationStartup
234+
from ellar.core import ModuleBase
235+
from ellar.samples.modules import HomeModule
236+
from ellar.auth.session import SessionStrategy
237+
from ellar.di import ProviderConfig
238+
from ellar_sql import EllarSQLService
239+
240+
from .car.module import CarModule
241+
from .session import EllarSQLSessionStrategy
242+
243+
244+
@Module(
245+
modules=[HomeModule, CarModule],
246+
providers=[ProviderConfig(SessionStrategy, use_class=EllarSQLSessionStrategy)]
247+
)
248+
class ApplicationModule(ModuleBase, IApplicationStartup):
249+
async def on_startup(self, app: "App") -> None:
250+
ellar_sql_service = app.injector.get(EllarSQLService)
251+
ellar_sql_service.create_all()
252+
253+
@exception_handler(404)
254+
def exception_404_handler(cls, context: IHostContext, exc: Exception) -> Response:
255+
return JSONResponse({"detail": "Resource not found."}, status_code=404)
256+
```
257+
258+
In the above code, the `on_startup` method from `IApplicationStartup` ensures that the `SessionTable` is created.
259+
260+
Once this setup is complete, restart the local server to observe session table data in your relational database.
261+
262+
## **Disable Session**
263+
To disable sessions in your Ellar application, set `SESSION_DISABLED=True` in your application configuration.
264+
This configuration change will effectively turn off session functionality throughout your Ellar application.

ellar/app/services.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing as t
22

33
from ellar.auth import AppIdentitySchemes
4-
from ellar.auth.session import ISessionStrategy, SessionServiceNullStrategy
4+
from ellar.auth.session import SessionServiceNullStrategy, SessionStrategy
55
from ellar.common import (
66
IExceptionMiddlewareService,
77
IExecutionContextFactory,
@@ -61,5 +61,5 @@ def register_core_services(self) -> None:
6161
self.injector.container.register_singleton(IGuardsConsumer, GuardConsumer)
6262
self.injector.container.register_singleton(IIdentitySchemes, AppIdentitySchemes)
6363
self.injector.container.register_singleton(
64-
ISessionStrategy, SessionServiceNullStrategy
64+
SessionStrategy, SessionServiceNullStrategy
6565
)

ellar/auth/middleware/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ellar.auth.session import ISessionStrategy, SessionServiceNullStrategy
1+
from ellar.auth.session import SessionServiceNullStrategy, SessionStrategy
22
from ellar.core.conf import Config
33
from starlette.datastructures import MutableHeaders
44
from starlette.requests import HTTPConnection
@@ -7,7 +7,7 @@
77

88
class SessionMiddleware:
99
def __init__(
10-
self, app: ASGIApp, session_strategy: ISessionStrategy, config: Config
10+
self, app: ASGIApp, session_strategy: SessionStrategy, config: Config
1111
) -> None:
1212
config.setdefault("SESSION_DISABLED", False)
1313
self.app = app

ellar/auth/session/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import typing as t
22

3+
from .base import SessionStrategy
34
from .cookie_dict import SessionCookieObject
4-
from .interface import ISessionStrategy
55
from .options import SessionCookieOption
66

77
__all__ = [
88
"SessionCookieObject",
99
"SessionCookieOption",
10-
"ISessionStrategy",
10+
"SessionStrategy",
1111
"SessionServiceNullStrategy",
1212
]
1313

1414

15-
class SessionServiceNullStrategy(ISessionStrategy):
15+
class SessionServiceNullStrategy(SessionStrategy):
1616
"""
1717
A Null implementation ISessionService. This is used as a placeholder for ISSessionService when there is no
1818
ISSessionService implementation registered.

ellar/auth/session/base.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import typing as t
2+
from abc import ABC, abstractmethod
3+
4+
from .cookie_dict import SessionCookieObject
5+
from .options import SessionCookieOption
6+
7+
8+
class SessionStrategy(ABC):
9+
@property
10+
@abstractmethod
11+
def session_cookie_options(self) -> SessionCookieOption:
12+
"""
13+
:return: SessionCookieOption
14+
"""
15+
16+
@abstractmethod
17+
def serialize_session(
18+
self,
19+
session: t.Union[str, SessionCookieObject],
20+
) -> str:
21+
"""
22+
:param session: Collection ExtraEndpointArg
23+
:return: string
24+
"""
25+
26+
@abstractmethod
27+
def deserialize_session(self, session_data: t.Optional[str]) -> SessionCookieObject:
28+
"""
29+
:param session_data:
30+
:return: SessionCookieObject
31+
"""
32+
33+
def get_cookie_header_value(self, data: t.Any, delete: bool = False) -> str:
34+
security_flags = "httponly; samesite=" + self.session_cookie_options.SAME_SITE
35+
if self.session_cookie_options.SECURE:
36+
security_flags += "; secure"
37+
38+
if not delete:
39+
max_age = (
40+
f"Max-Age={self.session_cookie_options.MAX_AGE}; "
41+
if self.session_cookie_options.MAX_AGE
42+
else ""
43+
)
44+
else:
45+
max_age = "Max-Age=0; "
46+
47+
header_value = "{session_cookie}={data}; path={path}; {max_age}{security_flags}".format( # E501
48+
session_cookie=self.session_cookie_options.NAME,
49+
data=data,
50+
path=self.session_cookie_options.PATH,
51+
max_age=max_age,
52+
security_flags=security_flags,
53+
)
54+
return header_value

0 commit comments

Comments
 (0)