Skip to content

Commit ca5b2c1

Browse files
Improve built-in sessions. Fix #542 (#587)
1 parent 37dc9a6 commit ca5b2c1

File tree

10 files changed

+431
-160
lines changed

10 files changed

+431
-160
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [2.3.3] - 2025-06-??
8+
## [2.4.0] - 2025-06-??
99

10+
- **SOME BREAKING CHANGES**. Modify the built-in sessions to support any kind
11+
of storage for the session information. It still defaults to storing sessions
12+
in cookies, but allows defining a custom `SessionStore` to store information
13+
where desired.
1014
- Remove `charset-normalizer` dependency. This library was used only when a
1115
`UnicodeDecodeError` exception occurred when parsing the body of a web
1216
request. This can happen in two circumstances: when the client sends a

blacksheep/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
__author__ = "Roberto Prevato <[email protected]>"
7-
__version__ = "2.3.3"
7+
__version__ = "2.4.0"
88

99
from .contents import Content as Content
1010
from .contents import FormContent as FormContent

blacksheep/server/application.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
)
7171
from blacksheep.server.websocket import WebSocket, format_reason
7272
from blacksheep.sessions import SessionMiddleware, SessionSerializer
73+
from blacksheep.sessions.abc import SessionStore
7374
from blacksheep.settings.di import di_settings
7475
from blacksheep.utils import join_fragments
7576
from blacksheep.utils.meta import get_parent_file, import_child_modules
@@ -310,20 +311,49 @@ async def handle_child_app_stop(_):
310311

311312
def use_sessions(
312313
self,
313-
secret_key: str,
314+
store: Union[str, SessionStore],
314315
*,
315316
session_cookie: str = "session",
316317
serializer: Optional[SessionSerializer] = None,
317318
signer: Optional[Serializer] = None,
318319
session_max_age: Optional[int] = None,
319320
) -> None:
320-
self._session_middleware = SessionMiddleware(
321-
secret_key=secret_key,
322-
session_cookie=session_cookie,
323-
serializer=serializer,
324-
signer=signer,
325-
session_max_age=session_max_age,
326-
)
321+
"""
322+
Configures session support for the application.
323+
324+
This method enables session management by adding a SessionMiddleware to the
325+
application.
326+
It can be used with either a secret key (to use cookie-based sessions) or a
327+
custom SessionStore.
328+
329+
Args:
330+
store (Union[str, SessionStore]): A secret key for cookie-based sessions,
331+
or an instance of SessionStore for custom session storage.
332+
session_cookie (str, optional): Name of the session cookie. Defaults to
333+
session".
334+
serializer (SessionSerializer, optional): Serializer for session data.
335+
signer (Serializer, optional): Serializer used for signing session data.
336+
session_max_age (int, optional): Maximum age of the session in seconds.
337+
338+
Usage:
339+
app.use_sessions("my-secret-key")
340+
# or
341+
app.use_sessions(MyCustomSessionStore())
342+
"""
343+
if isinstance(store, str):
344+
from blacksheep.sessions.cookies import CookieSessionStore
345+
346+
self._session_middleware = SessionMiddleware(
347+
CookieSessionStore(
348+
store,
349+
session_cookie=session_cookie,
350+
serializer=serializer,
351+
signer=signer,
352+
session_max_age=session_max_age,
353+
)
354+
)
355+
elif isinstance(store, SessionStore):
356+
self._session_middleware = SessionMiddleware(store)
327357

328358
def use_cors(
329359
self,

blacksheep/sessions/__init__.py

Lines changed: 22 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,160 +1,40 @@
1-
import base64
2-
import logging
3-
from abc import ABC, abstractmethod
4-
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional
1+
from typing import Awaitable, Callable
52

6-
from itsdangerous import Serializer, URLSafeTimedSerializer # noqa
7-
from itsdangerous.exc import BadSignature, SignatureExpired
8-
9-
from blacksheep.cookies import Cookie
103
from blacksheep.messages import Request, Response
11-
from blacksheep.settings.json import json_settings
12-
from blacksheep.utils import ensure_str
13-
14-
15-
def get_logger():
16-
logger = logging.getLogger("blacksheep.sessions")
17-
logger.setLevel(logging.INFO)
18-
return logger
19-
20-
21-
class Session:
22-
def __init__(self, values: Optional[Mapping[str, Any]] = None) -> None:
23-
if values is None:
24-
values = {}
25-
self._modified = False
26-
self._values = dict(values)
27-
28-
@property
29-
def modified(self) -> bool:
30-
return self._modified
31-
32-
def get(self, name: str, default: Any = None) -> Any:
33-
return self._values.get(name, default)
34-
35-
def set(self, name: str, value: Any) -> None:
36-
self._modified = True
37-
self._values[name] = value
38-
39-
def update(self, values: Mapping[str, Any]) -> None:
40-
self._modified = True
41-
self._values.update(values)
42-
43-
def __getitem__(self, name: str) -> Any:
44-
return self._values[name]
45-
46-
def __setitem__(self, name: str, value: Any) -> None:
47-
self._modified = True
48-
self._values[name] = value
49-
50-
def __delitem__(self, name: str) -> None:
51-
self._modified = True
52-
del self._values[name]
4+
from blacksheep.sessions.abc import Session, SessionSerializer, SessionStore
535

54-
def __contains__(self, name: str) -> bool:
55-
return name in self._values
56-
57-
def __len__(self) -> int:
58-
return len(self._values)
59-
60-
def __eq__(self, o: object) -> bool:
61-
if self is o:
62-
return True
63-
if isinstance(o, Session):
64-
return self._values == o._values
65-
return self._values == o
66-
67-
def clear(self) -> None:
68-
self._modified = True
69-
self._values.clear()
70-
71-
def to_dict(self) -> Dict[str, Any]:
72-
return self._values.copy()
73-
74-
75-
class SessionSerializer(ABC):
76-
@abstractmethod
77-
def read(self, value: str) -> Session:
78-
"""Creates an instance of Session from a string representation."""
79-
80-
@abstractmethod
81-
def write(self, session: Session) -> str:
82-
"""Creates the string representation of a session."""
83-
84-
85-
class JSONSerializer(SessionSerializer):
86-
def read(self, value: str) -> Session:
87-
return Session(json_settings.loads(value))
88-
89-
def write(self, session: Session) -> str:
90-
return json_settings.dumps(session.to_dict())
6+
__all__ = [
7+
"Session",
8+
"SessionMiddleware",
9+
"SessionStore",
10+
"SessionSerializer",
11+
]
9112

9213

9314
class SessionMiddleware:
94-
def __init__(
95-
self,
96-
secret_key: str,
97-
*,
98-
session_cookie: str = "session",
99-
serializer: Optional[SessionSerializer] = None,
100-
signer: Optional[Serializer] = None,
101-
session_max_age: Optional[int] = None,
102-
) -> None:
103-
self._signer = signer or URLSafeTimedSerializer(secret_key)
104-
self._serializer = serializer or JSONSerializer()
105-
self._session_cookie = session_cookie
106-
self._logger = get_logger()
107-
if session_max_age is not None and session_max_age < 1:
108-
raise ValueError("session_max_age must be a positive number greater than 0")
109-
self.session_max_age = session_max_age
15+
"""
16+
Middleware for managing user sessions in a BlackSheep application.
11017
111-
def try_read_session(self, raw_value: str) -> Session:
112-
try:
113-
if self.session_max_age:
114-
assert isinstance(self._signer, URLSafeTimedSerializer), (
115-
"To use a session_max_age, the configured signer must be of "
116-
+ " TimestampSigner type"
117-
)
118-
unsigned_value = self._signer.loads(
119-
raw_value, max_age=self.session_max_age
120-
)
121-
else:
122-
unsigned_value = self._signer.loads(raw_value)
123-
except SignatureExpired:
124-
self._logger.info("The session signature has expired.")
125-
return Session()
126-
except BadSignature:
127-
# the client might be sending forged tokens
128-
self._logger.info("The session signature verification failed.")
129-
return Session()
18+
This middleware loads the session from the provided session store at the beginning
19+
of the request, attaches it to the request object, and saves the session back to
20+
the store if it was modified during request processing.
13021
131-
# in this case, we don't try because if the signature verification worked,
132-
# we expect the value to be valid - if reading fails here it's a bug in
133-
# in the serializer class
134-
return self._serializer.read(base64.b64decode(unsigned_value).decode("utf8"))
22+
Args:
23+
store (SessionStore): The session store used to load and save session data.
13524
136-
def write_session(self, session: Session) -> str:
137-
payload = base64.b64encode(
138-
self._serializer.write(session).encode("utf8")
139-
).decode()
140-
return ensure_str(self._signer.dumps(payload)) # type: ignore
25+
Usage:
26+
Add this middleware to your application to enable session support.
27+
"""
14128

142-
def prepare_cookie(self, value: str) -> Cookie:
143-
return Cookie(self._session_cookie, value, path="/", http_only=True)
29+
def __init__(self, store: SessionStore) -> None:
30+
self._store = store
14431

14532
async def __call__(
14633
self, request: Request, handler: Callable[[Request], Awaitable[Response]]
14734
) -> Response:
148-
session: Optional[Session] = None
149-
current_session_value = request.cookies.get(self._session_cookie, None)
150-
if current_session_value:
151-
session = self.try_read_session(current_session_value)
152-
else:
153-
session = Session()
35+
session = await self._store.load(request)
15436
request.session = session
155-
15637
response = await handler(request)
157-
15838
if session.modified:
159-
response.set_cookie(self.prepare_cookie(self.write_session(session)))
39+
await self._store.save(request, response, session)
16040
return response

blacksheep/sessions/abc.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any, Dict, Mapping, Optional
3+
4+
from blacksheep.messages import Request, Response
5+
6+
7+
class Session:
8+
"""
9+
Represents a session for storing and managing user-specific data across requests.
10+
11+
The Session class provides a dictionary-like interface for storing key-value pairs
12+
associated with a user's session. It tracks modifications to its contents and
13+
supports standard dictionary operations such as getting, setting, deleting, and
14+
updating items. The session data can be converted to a dictionary using `to_dict()`.
15+
16+
Attributes:
17+
modified (bool): Indicates whether the session data has been modified.
18+
19+
Methods:
20+
get(name, default=None): Retrieves a value by key, returning default if not
21+
found.
22+
set(name, value): Sets a value for a given key and marks the session as
23+
modified.
24+
update(values): Updates the session with multiple key-value pairs.
25+
clear(): Removes all items from the session and marks it as modified.
26+
to_dict(): Returns a shallow copy of the session data as a dictionary.
27+
"""
28+
29+
def __init__(self, values: Optional[Mapping[str, Any]] = None) -> None:
30+
if values is None:
31+
values = {}
32+
self._modified = False
33+
self._values = dict(values)
34+
35+
@property
36+
def modified(self) -> bool:
37+
return self._modified
38+
39+
def get(self, name: str, default: Any = None) -> Any:
40+
return self._values.get(name, default)
41+
42+
def set(self, name: str, value: Any) -> None:
43+
self._modified = True
44+
self._values[name] = value
45+
46+
def update(self, values: Mapping[str, Any]) -> None:
47+
self._modified = True
48+
self._values.update(values)
49+
50+
def __getitem__(self, name: str) -> Any:
51+
return self._values[name]
52+
53+
def __setitem__(self, name: str, value: Any) -> None:
54+
self._modified = True
55+
self._values[name] = value
56+
57+
def __delitem__(self, name: str) -> None:
58+
self._modified = True
59+
del self._values[name]
60+
61+
def __contains__(self, name: str) -> bool:
62+
return name in self._values
63+
64+
def __len__(self) -> int:
65+
return len(self._values)
66+
67+
def __eq__(self, o: object) -> bool:
68+
if self is o:
69+
return True
70+
if isinstance(o, Session):
71+
return self._values == o._values
72+
return self._values == o
73+
74+
def clear(self) -> None:
75+
self._modified = True
76+
self._values.clear()
77+
78+
def to_dict(self) -> Dict[str, Any]:
79+
return self._values.copy()
80+
81+
82+
class SessionSerializer(ABC):
83+
"""
84+
Abstract base class for session serialization and deserialization.
85+
86+
Implementations of this class provide methods to convert Session objects
87+
to and from their string representations, enabling storage and retrieval
88+
of session data in various formats (e.g., JSON, base64, etc.).
89+
"""
90+
91+
@abstractmethod
92+
def read(self, value: str) -> Session:
93+
"""Creates an instance of Session from a string representation."""
94+
95+
@abstractmethod
96+
def write(self, session: Session) -> str:
97+
"""Creates the string representation of a session."""
98+
99+
100+
class SessionStore(ABC):
101+
"""
102+
Abstract base class for session storage backends.
103+
104+
Implementations of this class define how sessions are loaded from and saved to
105+
a storage medium (such as cookies, databases, or distributed caches) during the
106+
request-response cycle.
107+
"""
108+
109+
@abstractmethod
110+
async def load(self, request: Request) -> Session:
111+
"""Load the session for the given request."""
112+
113+
@abstractmethod
114+
async def save(
115+
self, request: Request, response: Response, session: Session
116+
) -> None:
117+
"""Save the session related to the given request-response cycle."""

0 commit comments

Comments
 (0)