fastapi-cachex 0.2.1__tar.gz → 0.2.2__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.
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/PKG-INFO +13 -2
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/README.md +12 -1
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/__init__.py +13 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/memory.py +6 -5
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/redis.py +7 -4
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/cache.py +21 -3
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/routes.py +3 -2
- fastapi_cachex-0.2.2/fastapi_cachex/session/__init__.py +21 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/config.py +69 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/dependencies.py +65 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/exceptions.py +25 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/manager.py +333 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/middleware.py +127 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/models.py +166 -0
- fastapi_cachex-0.2.2/fastapi_cachex/session/security.py +97 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/types.py +9 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/pyproject.toml +1 -1
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/base.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/backends/memcached.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.2.1 → fastapi_cachex-0.2.2}/fastapi_cachex/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
6
|
Author: Allen
|
|
@@ -47,10 +47,11 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
|
|
48
48
|
[English](README.md) | [繁體中文](docs/README.zh-TW.md)
|
|
49
49
|
|
|
50
|
-
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support.
|
|
50
|
+
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
|
|
51
51
|
|
|
52
52
|
## Features
|
|
53
53
|
|
|
54
|
+
### HTTP Caching
|
|
54
55
|
- Support for HTTP caching headers
|
|
55
56
|
- `Cache-Control`
|
|
56
57
|
- `ETag`
|
|
@@ -62,6 +63,15 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
62
63
|
- Complete Cache-Control directive implementation
|
|
63
64
|
- Easy-to-use `@cache` decorator
|
|
64
65
|
|
|
66
|
+
### Session Management (Optional Extension)
|
|
67
|
+
- Secure session management with HMAC-SHA256 token signing
|
|
68
|
+
- IP address and User-Agent binding (optional security features)
|
|
69
|
+
- Header and bearer token support (API-first architecture)
|
|
70
|
+
- Automatic session renewal (sliding expiration)
|
|
71
|
+
- Flash messages for cross-request communication
|
|
72
|
+
- Multiple backend support (Redis, Memcached, In-Memory)
|
|
73
|
+
- Complete session lifecycle management (create, validate, refresh, invalidate)
|
|
74
|
+
|
|
65
75
|
### Cache-Control Directives
|
|
66
76
|
|
|
67
77
|
| Directive | Supported | Description |
|
|
@@ -236,6 +246,7 @@ async def expensive_operation():
|
|
|
236
246
|
- [Cache Flow Explanation](docs/CACHE_FLOW.md)
|
|
237
247
|
- [Development Guide](docs/DEVELOPMENT.md)
|
|
238
248
|
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
249
|
+
- [Session Management Guide](docs/SESSION.md) - Complete guide for session features
|
|
239
250
|
|
|
240
251
|
## License
|
|
241
252
|
|
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
[English](README.md) | [繁體中文](docs/README.zh-TW.md)
|
|
16
16
|
|
|
17
|
-
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support.
|
|
17
|
+
A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
20
|
|
|
21
|
+
### HTTP Caching
|
|
21
22
|
- Support for HTTP caching headers
|
|
22
23
|
- `Cache-Control`
|
|
23
24
|
- `ETag`
|
|
@@ -29,6 +30,15 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
29
30
|
- Complete Cache-Control directive implementation
|
|
30
31
|
- Easy-to-use `@cache` decorator
|
|
31
32
|
|
|
33
|
+
### Session Management (Optional Extension)
|
|
34
|
+
- Secure session management with HMAC-SHA256 token signing
|
|
35
|
+
- IP address and User-Agent binding (optional security features)
|
|
36
|
+
- Header and bearer token support (API-first architecture)
|
|
37
|
+
- Automatic session renewal (sliding expiration)
|
|
38
|
+
- Flash messages for cross-request communication
|
|
39
|
+
- Multiple backend support (Redis, Memcached, In-Memory)
|
|
40
|
+
- Complete session lifecycle management (create, validate, refresh, invalidate)
|
|
41
|
+
|
|
32
42
|
### Cache-Control Directives
|
|
33
43
|
|
|
34
44
|
| Directive | Supported | Description |
|
|
@@ -203,6 +213,7 @@ async def expensive_operation():
|
|
|
203
213
|
- [Cache Flow Explanation](docs/CACHE_FLOW.md)
|
|
204
214
|
- [Development Guide](docs/DEVELOPMENT.md)
|
|
205
215
|
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
216
|
+
- [Session Management Guide](docs/SESSION.md) - Complete guide for session features
|
|
206
217
|
|
|
207
218
|
## License
|
|
208
219
|
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
2
|
|
|
3
3
|
from .cache import cache as cache
|
|
4
|
+
from .cache import default_key_builder as default_key_builder
|
|
4
5
|
from .dependencies import CacheBackend as CacheBackend
|
|
5
6
|
from .dependencies import get_cache_backend as get_cache_backend
|
|
6
7
|
from .proxy import BackendProxy as BackendProxy
|
|
7
8
|
from .routes import add_routes as add_routes
|
|
9
|
+
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
10
|
+
|
|
11
|
+
# Session management (optional feature)
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BackendProxy",
|
|
14
|
+
"CacheBackend",
|
|
15
|
+
"CacheKeyBuilder",
|
|
16
|
+
"add_routes",
|
|
17
|
+
"cache",
|
|
18
|
+
"default_key_builder",
|
|
19
|
+
"get_cache_backend",
|
|
20
|
+
]
|
|
@@ -5,12 +5,13 @@ import contextlib
|
|
|
5
5
|
import fnmatch
|
|
6
6
|
import time
|
|
7
7
|
|
|
8
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
8
9
|
from fastapi_cachex.types import CacheItem
|
|
9
10
|
from fastapi_cachex.types import ETagContent
|
|
10
11
|
|
|
11
12
|
from .base import BaseCacheBackend
|
|
12
13
|
|
|
13
|
-
# Cache keys are formatted as: method
|
|
14
|
+
# Cache keys are formatted as: method|||host|||path|||query_params
|
|
14
15
|
# Minimum parts required to extract path component
|
|
15
16
|
_MIN_KEY_PARTS = 3
|
|
16
17
|
# Maximum parts to split (method, host, path, query_params)
|
|
@@ -115,8 +116,8 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
115
116
|
async with self.lock:
|
|
116
117
|
keys_to_delete = []
|
|
117
118
|
for key in self.cache:
|
|
118
|
-
# Keys are formatted as: method
|
|
119
|
-
parts = key.split(
|
|
119
|
+
# Keys are formatted as: method|||host|||path|||query_params
|
|
120
|
+
parts = key.split(CACHE_KEY_SEPARATOR, _MAX_KEY_PARTS)
|
|
120
121
|
if len(parts) >= _MIN_KEY_PARTS:
|
|
121
122
|
cache_path = parts[2]
|
|
122
123
|
has_params = len(parts) > _MIN_KEY_PARTS
|
|
@@ -145,8 +146,8 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
145
146
|
async with self.lock:
|
|
146
147
|
keys_to_delete = []
|
|
147
148
|
for key in self.cache:
|
|
148
|
-
# Extract path component (method
|
|
149
|
-
parts = key.split(
|
|
149
|
+
# Extract path component (method|||host|||path|||query_params)
|
|
150
|
+
parts = key.split(CACHE_KEY_SEPARATOR, _MAX_KEY_PARTS)
|
|
150
151
|
if len(parts) >= _MIN_KEY_PARTS:
|
|
151
152
|
cache_path = parts[2]
|
|
152
153
|
if fnmatch.fnmatch(cache_path, pattern):
|
|
@@ -6,6 +6,7 @@ from typing import Literal
|
|
|
6
6
|
|
|
7
7
|
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
8
8
|
from fastapi_cachex.exceptions import CacheXError
|
|
9
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
9
10
|
from fastapi_cachex.types import ETagContent
|
|
10
11
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
@@ -175,11 +176,13 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
175
176
|
"""
|
|
176
177
|
# Pattern includes the HTTP method, host, and path components
|
|
177
178
|
if include_params:
|
|
178
|
-
# Clear all variations:
|
|
179
|
-
pattern =
|
|
179
|
+
# Clear all variations: *|||path|||*
|
|
180
|
+
pattern = (
|
|
181
|
+
f"{self.key_prefix}*{CACHE_KEY_SEPARATOR}{path}{CACHE_KEY_SEPARATOR}*"
|
|
182
|
+
)
|
|
180
183
|
else:
|
|
181
|
-
# Clear only exact path (no query params):
|
|
182
|
-
pattern = f"{self.key_prefix}
|
|
184
|
+
# Clear only exact path (no query params): *|||path
|
|
185
|
+
pattern = f"{self.key_prefix}*{CACHE_KEY_SEPARATOR}{path}"
|
|
183
186
|
|
|
184
187
|
cursor = 0
|
|
185
188
|
batch_size = 100
|
|
@@ -25,6 +25,8 @@ from fastapi_cachex.exceptions import BackendNotFoundError
|
|
|
25
25
|
from fastapi_cachex.exceptions import CacheXError
|
|
26
26
|
from fastapi_cachex.exceptions import RequestNotFoundError
|
|
27
27
|
from fastapi_cachex.proxy import BackendProxy
|
|
28
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
29
|
+
from fastapi_cachex.types import CacheKeyBuilder
|
|
28
30
|
from fastapi_cachex.types import ETagContent
|
|
29
31
|
|
|
30
32
|
if TYPE_CHECKING:
|
|
@@ -36,6 +38,20 @@ SyncCallable = Callable[..., T]
|
|
|
36
38
|
AnyCallable = Union[AsyncCallable[T], SyncCallable[T]] # noqa: UP007
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
def default_key_builder(request: Request) -> str:
|
|
42
|
+
"""Default cache key builder function.
|
|
43
|
+
|
|
44
|
+
Generates cache key in format: method|||host|||path|||query_params
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
request: The FastAPI Request object
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Generated cache key string
|
|
51
|
+
"""
|
|
52
|
+
return f"{request.method}{CACHE_KEY_SEPARATOR}{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}{request.url.path}{CACHE_KEY_SEPARATOR}{request.query_params}"
|
|
53
|
+
|
|
54
|
+
|
|
39
55
|
class CacheControl:
|
|
40
56
|
"""Manages Cache-Control header directives."""
|
|
41
57
|
|
|
@@ -103,6 +119,7 @@ def cache( # noqa: C901
|
|
|
103
119
|
private: bool = False,
|
|
104
120
|
immutable: bool = False,
|
|
105
121
|
must_revalidate: bool = False,
|
|
122
|
+
cache_key_builder: CacheKeyBuilder | None = None,
|
|
106
123
|
) -> Callable[[AnyCallable[Response]], AsyncCallable[Response]]:
|
|
107
124
|
"""Cache decorator for FastAPI route handlers.
|
|
108
125
|
|
|
@@ -116,6 +133,7 @@ def cache( # noqa: C901
|
|
|
116
133
|
private: Whether responses are for single user only
|
|
117
134
|
immutable: Whether cached responses never change
|
|
118
135
|
must_revalidate: Whether to force revalidation when stale
|
|
136
|
+
cache_key_builder: Custom function to build cache keys. If None, uses default_cache_key_builder
|
|
119
137
|
|
|
120
138
|
Returns:
|
|
121
139
|
Decorator function that wraps route handlers with caching logic
|
|
@@ -207,9 +225,9 @@ def cache( # noqa: C901
|
|
|
207
225
|
if req.method != "GET":
|
|
208
226
|
return await get_response(func, req, *args, **kwargs)
|
|
209
227
|
|
|
210
|
-
# Generate cache key
|
|
211
|
-
|
|
212
|
-
cache_key =
|
|
228
|
+
# Generate cache key using custom builder or default
|
|
229
|
+
key_builder = cache_key_builder or default_key_builder
|
|
230
|
+
cache_key = key_builder(req)
|
|
213
231
|
client_etag = req.headers.get("if-none-match")
|
|
214
232
|
cache_control = await get_cache_control(CacheControl())
|
|
215
233
|
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
from fastapi_cachex.backends import BaseCacheBackend
|
|
8
8
|
from fastapi_cachex.exceptions import BackendNotFoundError
|
|
9
9
|
from fastapi_cachex.proxy import BackendProxy
|
|
10
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
10
11
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
12
13
|
from fastapi import FastAPI
|
|
@@ -93,12 +94,12 @@ def _parse_cache_key(cache_key: str) -> tuple[str, str, str, str]:
|
|
|
93
94
|
"""Parse cache key into components.
|
|
94
95
|
|
|
95
96
|
Args:
|
|
96
|
-
cache_key: Cache key in format method
|
|
97
|
+
cache_key: Cache key in format method|||host|||path|||query_params
|
|
97
98
|
|
|
98
99
|
Returns:
|
|
99
100
|
Tuple of (method, host, path, query_params)
|
|
100
101
|
"""
|
|
101
|
-
key_parts = cache_key.split(
|
|
102
|
+
key_parts = cache_key.split(CACHE_KEY_SEPARATOR, CACHE_KEY_MAX_PARTS)
|
|
102
103
|
if len(key_parts) >= CACHE_KEY_MIN_PARTS:
|
|
103
104
|
method, host, path = key_parts[0], key_parts[1], key_parts[2]
|
|
104
105
|
query_params = key_parts[3] if len(key_parts) > CACHE_KEY_MIN_PARTS else ""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Session management extension for FastAPI-CacheX."""
|
|
2
|
+
|
|
3
|
+
from fastapi_cachex.session.config import SessionConfig
|
|
4
|
+
from fastapi_cachex.session.dependencies import get_optional_session
|
|
5
|
+
from fastapi_cachex.session.dependencies import get_session
|
|
6
|
+
from fastapi_cachex.session.dependencies import require_session
|
|
7
|
+
from fastapi_cachex.session.manager import SessionManager
|
|
8
|
+
from fastapi_cachex.session.middleware import SessionMiddleware
|
|
9
|
+
from fastapi_cachex.session.models import Session
|
|
10
|
+
from fastapi_cachex.session.models import SessionUser
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Session",
|
|
14
|
+
"SessionConfig",
|
|
15
|
+
"SessionManager",
|
|
16
|
+
"SessionMiddleware",
|
|
17
|
+
"SessionUser",
|
|
18
|
+
"get_optional_session",
|
|
19
|
+
"get_session",
|
|
20
|
+
"require_session",
|
|
21
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Session configuration settings."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionConfig(BaseModel):
|
|
10
|
+
"""Session configuration settings."""
|
|
11
|
+
|
|
12
|
+
# Session lifetime
|
|
13
|
+
session_ttl: int = Field(
|
|
14
|
+
default=3600,
|
|
15
|
+
description="Session time-to-live in seconds (default: 1 hour)",
|
|
16
|
+
)
|
|
17
|
+
absolute_timeout: int | None = Field(
|
|
18
|
+
default=None,
|
|
19
|
+
description="Absolute session timeout in seconds (None = no absolute timeout)",
|
|
20
|
+
)
|
|
21
|
+
sliding_expiration: bool = Field(
|
|
22
|
+
default=True,
|
|
23
|
+
description="Whether to refresh session expiry on each access",
|
|
24
|
+
)
|
|
25
|
+
sliding_threshold: float = Field(
|
|
26
|
+
default=0.5,
|
|
27
|
+
ge=0.0,
|
|
28
|
+
le=1.0,
|
|
29
|
+
description="Fraction of TTL that must pass before sliding refresh (0.5 = refresh after half TTL)",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Token settings
|
|
33
|
+
header_name: str = Field(
|
|
34
|
+
default="X-Session-Token",
|
|
35
|
+
description="Custom header name for session token",
|
|
36
|
+
)
|
|
37
|
+
use_bearer_token: bool = Field(
|
|
38
|
+
default=True,
|
|
39
|
+
description="Whether to accept Authorization Bearer tokens",
|
|
40
|
+
)
|
|
41
|
+
token_source_priority: list[Literal["header", "bearer"]] = Field(
|
|
42
|
+
default=["header", "bearer"],
|
|
43
|
+
description="Priority order for token sources",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Security settings
|
|
47
|
+
secret_key: str = Field(
|
|
48
|
+
...,
|
|
49
|
+
min_length=32,
|
|
50
|
+
description="Secret key for signing session tokens (min 32 characters)",
|
|
51
|
+
)
|
|
52
|
+
ip_binding: bool = Field(
|
|
53
|
+
default=False,
|
|
54
|
+
description="Whether to bind session to client IP address",
|
|
55
|
+
)
|
|
56
|
+
user_agent_binding: bool = Field(
|
|
57
|
+
default=False,
|
|
58
|
+
description="Whether to bind session to User-Agent",
|
|
59
|
+
)
|
|
60
|
+
regenerate_on_login: bool = Field(
|
|
61
|
+
default=True,
|
|
62
|
+
description="Whether to regenerate session ID on login",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Backend settings
|
|
66
|
+
backend_key_prefix: str = Field(
|
|
67
|
+
default="session:",
|
|
68
|
+
description="Prefix for session keys in backend storage",
|
|
69
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""FastAPI dependency injection utilities for session management."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi import status
|
|
9
|
+
|
|
10
|
+
from fastapi_cachex.session.models import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_optional_session(request: Request) -> Session | None:
|
|
14
|
+
"""Get session from request state (optional).
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
request: FastAPI request object
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Session object or None if not authenticated
|
|
21
|
+
"""
|
|
22
|
+
return getattr(request.state, "session", None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_session(request: Request) -> Session:
|
|
26
|
+
"""Get session from request state (required).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
request: FastAPI request object
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Session object
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
HTTPException: 401 if session not found
|
|
36
|
+
"""
|
|
37
|
+
session: Session | None = getattr(request.state, "session", None)
|
|
38
|
+
if session is None:
|
|
39
|
+
raise HTTPException(
|
|
40
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
41
|
+
detail="Authentication required",
|
|
42
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
43
|
+
)
|
|
44
|
+
return session
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_session(request: Request) -> Session:
|
|
48
|
+
"""Require authenticated session (alias for get_session).
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
request: FastAPI request object
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Session object
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
HTTPException: 401 if session not found
|
|
58
|
+
"""
|
|
59
|
+
return get_session(request)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Type annotations for dependency injection
|
|
63
|
+
OptionalSession = Annotated[Session | None, Depends(get_optional_session)]
|
|
64
|
+
RequiredSession = Annotated[Session, Depends(get_session)]
|
|
65
|
+
SessionDep = Annotated[Session, Depends(require_session)]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Session-related exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SessionError(Exception):
|
|
5
|
+
"""Base exception for session errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionNotFoundError(SessionError):
|
|
9
|
+
"""Raised when a session is not found."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionExpiredError(SessionError):
|
|
13
|
+
"""Raised when a session has expired."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SessionInvalidError(SessionError):
|
|
17
|
+
"""Raised when a session is invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionSecurityError(SessionError):
|
|
21
|
+
"""Raised when a session fails security checks."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionTokenError(SessionError):
|
|
25
|
+
"""Raised when there's an issue with session token."""
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Session manager for CRUD operations."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from datetime import timezone
|
|
6
|
+
|
|
7
|
+
from fastapi_cachex.backends.base import BaseCacheBackend
|
|
8
|
+
from fastapi_cachex.session.config import SessionConfig
|
|
9
|
+
from fastapi_cachex.session.exceptions import SessionExpiredError
|
|
10
|
+
from fastapi_cachex.session.exceptions import SessionInvalidError
|
|
11
|
+
from fastapi_cachex.session.exceptions import SessionNotFoundError
|
|
12
|
+
from fastapi_cachex.session.exceptions import SessionSecurityError
|
|
13
|
+
from fastapi_cachex.session.exceptions import SessionTokenError
|
|
14
|
+
from fastapi_cachex.session.models import Session
|
|
15
|
+
from fastapi_cachex.session.models import SessionStatus
|
|
16
|
+
from fastapi_cachex.session.models import SessionToken
|
|
17
|
+
from fastapi_cachex.session.models import SessionUser
|
|
18
|
+
from fastapi_cachex.session.security import SecurityManager
|
|
19
|
+
from fastapi_cachex.types import ETagContent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionManager:
|
|
23
|
+
"""Manages session lifecycle and storage."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, backend: BaseCacheBackend, config: SessionConfig) -> None:
|
|
26
|
+
"""Initialize session manager.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
backend: Cache backend for session storage
|
|
30
|
+
config: Session configuration
|
|
31
|
+
"""
|
|
32
|
+
self.backend = backend
|
|
33
|
+
self.config = config
|
|
34
|
+
self.security = SecurityManager(config.secret_key)
|
|
35
|
+
|
|
36
|
+
def _get_backend_key(self, session_id: str) -> str:
|
|
37
|
+
"""Get backend storage key for a session.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
session_id: The session ID
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Backend storage key
|
|
44
|
+
"""
|
|
45
|
+
return f"{self.config.backend_key_prefix}{session_id}"
|
|
46
|
+
|
|
47
|
+
async def create_session(
|
|
48
|
+
self,
|
|
49
|
+
user: SessionUser | None = None,
|
|
50
|
+
ip_address: str | None = None,
|
|
51
|
+
user_agent: str | None = None,
|
|
52
|
+
**extra_data: dict[str, object],
|
|
53
|
+
) -> tuple[Session, str]:
|
|
54
|
+
"""Create a new session.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
user: Optional user data
|
|
58
|
+
ip_address: Client IP address (if IP binding enabled)
|
|
59
|
+
user_agent: Client User-Agent (if UA binding enabled)
|
|
60
|
+
**extra_data: Additional session data
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Tuple of (Session, token_string)
|
|
64
|
+
"""
|
|
65
|
+
# Create session
|
|
66
|
+
session = Session(
|
|
67
|
+
user=user,
|
|
68
|
+
data=extra_data,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Set expiry
|
|
72
|
+
if self.config.session_ttl:
|
|
73
|
+
session.expires_at = datetime.now(timezone.utc) + timedelta(
|
|
74
|
+
seconds=self.config.session_ttl,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Bind IP and User-Agent if configured
|
|
78
|
+
if self.config.ip_binding and ip_address:
|
|
79
|
+
session.ip_address = ip_address
|
|
80
|
+
if self.config.user_agent_binding and user_agent:
|
|
81
|
+
session.user_agent = user_agent
|
|
82
|
+
|
|
83
|
+
# Store in backend
|
|
84
|
+
await self._save_session(session)
|
|
85
|
+
|
|
86
|
+
# Generate signed token
|
|
87
|
+
token = self._create_token(session.session_id)
|
|
88
|
+
|
|
89
|
+
return session, token.to_string()
|
|
90
|
+
|
|
91
|
+
async def get_session(
|
|
92
|
+
self,
|
|
93
|
+
token_string: str,
|
|
94
|
+
ip_address: str | None = None,
|
|
95
|
+
user_agent: str | None = None,
|
|
96
|
+
) -> Session:
|
|
97
|
+
"""Retrieve and validate a session.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
token_string: Session token string
|
|
101
|
+
ip_address: Current request IP address
|
|
102
|
+
user_agent: Current request User-Agent
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Session object
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
SessionTokenError: If token is invalid
|
|
109
|
+
SessionNotFoundError: If session not found
|
|
110
|
+
SessionExpiredError: If session has expired
|
|
111
|
+
SessionInvalidError: If session is not active
|
|
112
|
+
SessionSecurityError: If security checks fail
|
|
113
|
+
"""
|
|
114
|
+
# Parse and verify token
|
|
115
|
+
try:
|
|
116
|
+
token = SessionToken.from_string(token_string)
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
raise SessionTokenError(str(e)) from e
|
|
119
|
+
|
|
120
|
+
if not self.security.verify_signature(token.session_id, token.signature):
|
|
121
|
+
msg = "Invalid session signature"
|
|
122
|
+
raise SessionSecurityError(msg)
|
|
123
|
+
|
|
124
|
+
# Load session from backend
|
|
125
|
+
session = await self._load_session(token.session_id)
|
|
126
|
+
if not session:
|
|
127
|
+
msg = f"Session {token.session_id} not found"
|
|
128
|
+
raise SessionNotFoundError(msg)
|
|
129
|
+
|
|
130
|
+
# Validate session
|
|
131
|
+
if session.status != SessionStatus.ACTIVE:
|
|
132
|
+
msg = f"Session is {session.status}"
|
|
133
|
+
raise SessionInvalidError(msg)
|
|
134
|
+
|
|
135
|
+
if session.is_expired():
|
|
136
|
+
session.status = SessionStatus.EXPIRED
|
|
137
|
+
await self._save_session(session)
|
|
138
|
+
msg = "Session has expired"
|
|
139
|
+
raise SessionExpiredError(msg)
|
|
140
|
+
|
|
141
|
+
# Security checks
|
|
142
|
+
if self.config.ip_binding and not self.security.check_ip_match(
|
|
143
|
+
session,
|
|
144
|
+
ip_address,
|
|
145
|
+
):
|
|
146
|
+
msg = "IP address mismatch"
|
|
147
|
+
raise SessionSecurityError(msg)
|
|
148
|
+
|
|
149
|
+
if self.config.user_agent_binding and not self.security.check_user_agent_match(
|
|
150
|
+
session,
|
|
151
|
+
user_agent,
|
|
152
|
+
):
|
|
153
|
+
msg = "User-Agent mismatch"
|
|
154
|
+
raise SessionSecurityError(msg)
|
|
155
|
+
|
|
156
|
+
# Update last accessed and handle sliding expiration
|
|
157
|
+
session.update_last_accessed()
|
|
158
|
+
|
|
159
|
+
if self.config.sliding_expiration and session.expires_at:
|
|
160
|
+
time_remaining = (
|
|
161
|
+
session.expires_at - datetime.now(timezone.utc)
|
|
162
|
+
).total_seconds()
|
|
163
|
+
threshold = self.config.session_ttl * self.config.sliding_threshold
|
|
164
|
+
|
|
165
|
+
if time_remaining < threshold:
|
|
166
|
+
session.renew(self.config.session_ttl)
|
|
167
|
+
|
|
168
|
+
await self._save_session(session)
|
|
169
|
+
|
|
170
|
+
return session
|
|
171
|
+
|
|
172
|
+
async def update_session(self, session: Session) -> None:
|
|
173
|
+
"""Update an existing session.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
session: Session to update
|
|
177
|
+
"""
|
|
178
|
+
session.update_last_accessed()
|
|
179
|
+
await self._save_session(session)
|
|
180
|
+
|
|
181
|
+
async def delete_session(self, session_id: str) -> None:
|
|
182
|
+
"""Delete a session.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
session_id: Session ID to delete
|
|
186
|
+
"""
|
|
187
|
+
key = self._get_backend_key(session_id)
|
|
188
|
+
await self.backend.delete(key)
|
|
189
|
+
|
|
190
|
+
async def invalidate_session(self, session: Session) -> None:
|
|
191
|
+
"""Invalidate a session.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
session: Session to invalidate
|
|
195
|
+
"""
|
|
196
|
+
session.invalidate()
|
|
197
|
+
await self._save_session(session)
|
|
198
|
+
|
|
199
|
+
async def regenerate_session_id(
|
|
200
|
+
self,
|
|
201
|
+
session: Session,
|
|
202
|
+
) -> tuple[Session, str]:
|
|
203
|
+
"""Regenerate session ID (after login for security).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
session: Session to regenerate
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple of (updated session, new token string)
|
|
210
|
+
"""
|
|
211
|
+
# Delete old session
|
|
212
|
+
await self.delete_session(session.session_id)
|
|
213
|
+
|
|
214
|
+
# Generate new ID
|
|
215
|
+
session.regenerate_id()
|
|
216
|
+
|
|
217
|
+
# Save with new ID
|
|
218
|
+
await self._save_session(session)
|
|
219
|
+
|
|
220
|
+
# Create new token
|
|
221
|
+
token = self._create_token(session.session_id)
|
|
222
|
+
|
|
223
|
+
return session, token.to_string()
|
|
224
|
+
|
|
225
|
+
async def delete_user_sessions(self, user_id: str) -> int:
|
|
226
|
+
"""Delete all sessions for a user.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
user_id: User ID
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Number of sessions deleted
|
|
233
|
+
"""
|
|
234
|
+
# This requires scanning all session keys
|
|
235
|
+
count = 0
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
all_keys = await self.backend.get_all_keys()
|
|
239
|
+
for key in all_keys:
|
|
240
|
+
if key.startswith(self.config.backend_key_prefix):
|
|
241
|
+
session = await self._load_session_by_key(key)
|
|
242
|
+
if session and session.user and session.user.user_id == user_id:
|
|
243
|
+
await self.backend.delete(key)
|
|
244
|
+
count += 1
|
|
245
|
+
except NotImplementedError:
|
|
246
|
+
# Backend doesn't support get_all_keys, can't delete by user
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
return count
|
|
250
|
+
|
|
251
|
+
async def clear_expired_sessions(self) -> int:
|
|
252
|
+
"""Clear all expired sessions.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Number of sessions cleared
|
|
256
|
+
"""
|
|
257
|
+
count = 0
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
all_keys = await self.backend.get_all_keys()
|
|
261
|
+
for key in all_keys:
|
|
262
|
+
if key.startswith(self.config.backend_key_prefix):
|
|
263
|
+
session = await self._load_session_by_key(key)
|
|
264
|
+
if session and session.is_expired():
|
|
265
|
+
await self.backend.delete(key)
|
|
266
|
+
count += 1
|
|
267
|
+
except NotImplementedError:
|
|
268
|
+
# Backend doesn't support get_all_keys
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
return count
|
|
272
|
+
|
|
273
|
+
def _create_token(self, session_id: str) -> SessionToken:
|
|
274
|
+
"""Create a signed session token.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
session_id: Session ID to sign
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
SessionToken object
|
|
281
|
+
"""
|
|
282
|
+
signature = self.security.sign_session_id(session_id)
|
|
283
|
+
return SessionToken(session_id=session_id, signature=signature)
|
|
284
|
+
|
|
285
|
+
async def _save_session(self, session: Session) -> None:
|
|
286
|
+
"""Save session to backend.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
session: Session to save
|
|
290
|
+
"""
|
|
291
|
+
key = self._get_backend_key(session.session_id)
|
|
292
|
+
value = session.model_dump_json().encode("utf-8")
|
|
293
|
+
|
|
294
|
+
# Calculate TTL
|
|
295
|
+
ttl = None
|
|
296
|
+
if session.expires_at:
|
|
297
|
+
ttl = int((session.expires_at - datetime.now(timezone.utc)).total_seconds())
|
|
298
|
+
ttl = max(ttl, 1) # Ensure at least 1 second
|
|
299
|
+
|
|
300
|
+
# Store as bytes in cache backend (wrapped in ETagContent for compatibility)
|
|
301
|
+
etag = self.security.hash_data(value.decode("utf-8"))
|
|
302
|
+
await self.backend.set(key, ETagContent(etag=etag, content=value), ttl=ttl)
|
|
303
|
+
|
|
304
|
+
async def _load_session(self, session_id: str) -> Session | None:
|
|
305
|
+
"""Load session from backend.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
session_id: Session ID to load
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Session object or None if not found
|
|
312
|
+
"""
|
|
313
|
+
key = self._get_backend_key(session_id)
|
|
314
|
+
return await self._load_session_by_key(key)
|
|
315
|
+
|
|
316
|
+
async def _load_session_by_key(self, key: str) -> Session | None:
|
|
317
|
+
"""Load session from backend by key.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
key: Backend key
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Session object or None if not found
|
|
324
|
+
"""
|
|
325
|
+
cached = await self.backend.get(key)
|
|
326
|
+
if not cached:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
return Session.model_validate_json(cached.content)
|
|
331
|
+
except (ValueError, TypeError):
|
|
332
|
+
# Invalid session data
|
|
333
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Session middleware for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
from fastapi import Response
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.middleware.base import RequestResponseEndpoint
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from fastapi_cachex.session.config import SessionConfig
|
|
12
|
+
from fastapi_cachex.session.exceptions import SessionError
|
|
13
|
+
from fastapi_cachex.session.manager import SessionManager
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from fastapi_cachex.session.models import Session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Middleware to handle session loading and cookie management."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
app: ASGIApp,
|
|
25
|
+
session_manager: SessionManager,
|
|
26
|
+
config: SessionConfig,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize session middleware.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
app: ASGI application
|
|
32
|
+
session_manager: Session manager instance
|
|
33
|
+
config: Session configuration
|
|
34
|
+
"""
|
|
35
|
+
super().__init__(app)
|
|
36
|
+
self.session_manager = session_manager
|
|
37
|
+
self.config = config
|
|
38
|
+
|
|
39
|
+
async def dispatch(
|
|
40
|
+
self,
|
|
41
|
+
request: Request,
|
|
42
|
+
call_next: RequestResponseEndpoint,
|
|
43
|
+
) -> Response:
|
|
44
|
+
"""Process request and handle session.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
request: Incoming request
|
|
48
|
+
call_next: Next handler in chain
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Response
|
|
52
|
+
"""
|
|
53
|
+
# Extract session token from request
|
|
54
|
+
token = self._extract_token(request)
|
|
55
|
+
|
|
56
|
+
# Try to load session
|
|
57
|
+
session: Session | None = None
|
|
58
|
+
if token:
|
|
59
|
+
try:
|
|
60
|
+
ip_address = self._get_client_ip(request)
|
|
61
|
+
user_agent = request.headers.get("user-agent")
|
|
62
|
+
session = await self.session_manager.get_session(
|
|
63
|
+
token,
|
|
64
|
+
ip_address=ip_address,
|
|
65
|
+
user_agent=user_agent,
|
|
66
|
+
)
|
|
67
|
+
except SessionError:
|
|
68
|
+
# Session invalid/expired, continue without session
|
|
69
|
+
session = None
|
|
70
|
+
|
|
71
|
+
# Store session in request state
|
|
72
|
+
request.state.session = session
|
|
73
|
+
|
|
74
|
+
# Process request
|
|
75
|
+
response: Response = await call_next(request)
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
def _extract_token(self, request: Request) -> str | None:
|
|
80
|
+
"""Extract session token from request.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
request: Incoming request
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Session token or None
|
|
87
|
+
"""
|
|
88
|
+
for source in self.config.token_source_priority:
|
|
89
|
+
if source == "header":
|
|
90
|
+
token = request.headers.get(self.config.header_name)
|
|
91
|
+
if token:
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
elif source == "bearer":
|
|
95
|
+
if self.config.use_bearer_token:
|
|
96
|
+
auth_header = request.headers.get("authorization")
|
|
97
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
98
|
+
bearer_prefix_len = 7
|
|
99
|
+
return auth_header[bearer_prefix_len:]
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _get_client_ip(self, request: Request) -> str | None:
|
|
104
|
+
"""Get client IP address from request.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
request: Incoming request
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Client IP address or None
|
|
111
|
+
"""
|
|
112
|
+
# Check X-Forwarded-For header (for proxied requests)
|
|
113
|
+
forwarded_for = request.headers.get("x-forwarded-for")
|
|
114
|
+
if forwarded_for:
|
|
115
|
+
# Get first IP from comma-separated list
|
|
116
|
+
return forwarded_for.split(",")[0].strip()
|
|
117
|
+
|
|
118
|
+
# Check X-Real-IP header
|
|
119
|
+
real_ip = request.headers.get("x-real-ip")
|
|
120
|
+
if real_ip:
|
|
121
|
+
return real_ip
|
|
122
|
+
|
|
123
|
+
# Fallback to direct client IP
|
|
124
|
+
if request.client:
|
|
125
|
+
return request.client.host
|
|
126
|
+
|
|
127
|
+
return None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Session data models and user structures."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from datetime import timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
# Token format constant
|
|
14
|
+
TOKEN_PARTS_COUNT = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionStatus(str, Enum):
|
|
18
|
+
"""Session status enumeration."""
|
|
19
|
+
|
|
20
|
+
ACTIVE = "active"
|
|
21
|
+
EXPIRED = "expired"
|
|
22
|
+
INVALIDATED = "invalidated"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionUser(BaseModel):
|
|
26
|
+
"""Base session user model.
|
|
27
|
+
|
|
28
|
+
This can be extended by application-specific user models.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
user_id: str
|
|
32
|
+
username: str | None = None
|
|
33
|
+
email: str | None = None
|
|
34
|
+
roles: list[str] = Field(default_factory=list)
|
|
35
|
+
permissions: list[str] = Field(default_factory=list)
|
|
36
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
model_config = {"extra": "allow"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Session(BaseModel):
|
|
42
|
+
"""Core session model containing all session data."""
|
|
43
|
+
|
|
44
|
+
session_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
45
|
+
user: SessionUser | None = None
|
|
46
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
47
|
+
last_accessed: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
48
|
+
expires_at: datetime | None = None
|
|
49
|
+
status: SessionStatus = SessionStatus.ACTIVE
|
|
50
|
+
ip_address: str | None = None
|
|
51
|
+
user_agent: str | None = None
|
|
52
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
53
|
+
flash_messages: list[dict[str, Any]] = Field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
model_config = {"use_enum_values": True}
|
|
56
|
+
|
|
57
|
+
def is_valid(self) -> bool:
|
|
58
|
+
"""Check if session is valid (active and not expired)."""
|
|
59
|
+
if self.status != SessionStatus.ACTIVE:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
return not (self.expires_at and datetime.now(timezone.utc) > self.expires_at)
|
|
63
|
+
|
|
64
|
+
def is_expired(self) -> bool:
|
|
65
|
+
"""Check if session has expired."""
|
|
66
|
+
if self.expires_at is None:
|
|
67
|
+
return False
|
|
68
|
+
return datetime.now(timezone.utc) > self.expires_at
|
|
69
|
+
|
|
70
|
+
def update_last_accessed(self) -> None:
|
|
71
|
+
"""Update the last accessed timestamp."""
|
|
72
|
+
self.last_accessed = datetime.now(timezone.utc)
|
|
73
|
+
|
|
74
|
+
def renew(self, ttl: int) -> None:
|
|
75
|
+
"""Renew session expiry time.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
ttl: Time-to-live in seconds
|
|
79
|
+
"""
|
|
80
|
+
self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
|
81
|
+
self.update_last_accessed()
|
|
82
|
+
|
|
83
|
+
def invalidate(self) -> None:
|
|
84
|
+
"""Mark session as invalidated."""
|
|
85
|
+
self.status = SessionStatus.INVALIDATED
|
|
86
|
+
|
|
87
|
+
def regenerate_id(self) -> str:
|
|
88
|
+
"""Regenerate session ID (for security after login).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The new session ID
|
|
92
|
+
"""
|
|
93
|
+
self.session_id = str(uuid4())
|
|
94
|
+
return self.session_id
|
|
95
|
+
|
|
96
|
+
def add_flash_message(self, message: str, category: str = "info") -> None:
|
|
97
|
+
"""Add a flash message.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
message: The message content
|
|
101
|
+
category: Message category (info, success, warning, error)
|
|
102
|
+
"""
|
|
103
|
+
self.flash_messages.append(
|
|
104
|
+
{
|
|
105
|
+
"message": message,
|
|
106
|
+
"category": category,
|
|
107
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get_flash_messages(self, clear: bool = True) -> list[dict[str, Any]]:
|
|
112
|
+
"""Get and optionally clear flash messages.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
clear: Whether to clear messages after retrieving
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of flash messages
|
|
119
|
+
"""
|
|
120
|
+
messages = self.flash_messages.copy()
|
|
121
|
+
if clear:
|
|
122
|
+
self.flash_messages.clear()
|
|
123
|
+
return messages
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SessionToken(BaseModel):
|
|
127
|
+
"""Session token containing signed data."""
|
|
128
|
+
|
|
129
|
+
session_id: str
|
|
130
|
+
signature: str
|
|
131
|
+
issued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
132
|
+
|
|
133
|
+
def to_string(self) -> str:
|
|
134
|
+
"""Convert token to string format.
|
|
135
|
+
|
|
136
|
+
Format: {session_id}.{signature}.{timestamp}
|
|
137
|
+
"""
|
|
138
|
+
timestamp = int(self.issued_at.timestamp())
|
|
139
|
+
return f"{self.session_id}.{self.signature}.{timestamp}"
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_string(cls, token_str: str) -> "SessionToken":
|
|
143
|
+
"""Parse token from string format.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
token_str: Token string in format {session_id}.{signature}.{timestamp}
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
SessionToken instance
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If token format is invalid
|
|
153
|
+
"""
|
|
154
|
+
parts = token_str.split(".")
|
|
155
|
+
if len(parts) != TOKEN_PARTS_COUNT:
|
|
156
|
+
msg = "Invalid token format"
|
|
157
|
+
raise ValueError(msg)
|
|
158
|
+
|
|
159
|
+
session_id, signature, timestamp = parts
|
|
160
|
+
try:
|
|
161
|
+
issued_at = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
|
|
162
|
+
except (ValueError, OSError) as e:
|
|
163
|
+
msg = f"Invalid timestamp in token: {e}"
|
|
164
|
+
raise ValueError(msg) from e
|
|
165
|
+
|
|
166
|
+
return cls(session_id=session_id, signature=signature, issued_at=issued_at)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Security utilities for session management."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
|
|
6
|
+
from fastapi_cachex.session.models import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SecurityManager:
|
|
10
|
+
"""Handles session security operations."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, secret_key: str) -> None:
|
|
13
|
+
"""Initialize security manager.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
secret_key: Secret key for signing tokens
|
|
17
|
+
"""
|
|
18
|
+
if len(secret_key) < 32: # noqa: PLR2004
|
|
19
|
+
msg = "Secret key must be at least 32 characters"
|
|
20
|
+
raise ValueError(msg)
|
|
21
|
+
self.secret_key = secret_key.encode("utf-8")
|
|
22
|
+
|
|
23
|
+
def sign_session_id(self, session_id: str) -> str:
|
|
24
|
+
"""Sign a session ID using HMAC-SHA256.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
session_id: The session ID to sign
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The signature as a hex string
|
|
31
|
+
"""
|
|
32
|
+
return hmac.new(
|
|
33
|
+
self.secret_key,
|
|
34
|
+
session_id.encode("utf-8"),
|
|
35
|
+
hashlib.sha256,
|
|
36
|
+
).hexdigest()
|
|
37
|
+
|
|
38
|
+
def verify_signature(self, session_id: str, signature: str) -> bool:
|
|
39
|
+
"""Verify a session signature.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
session_id: The session ID
|
|
43
|
+
signature: The signature to verify
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if signature is valid, False otherwise
|
|
47
|
+
"""
|
|
48
|
+
expected_signature = self.sign_session_id(session_id)
|
|
49
|
+
# Use constant-time comparison to prevent timing attacks
|
|
50
|
+
return hmac.compare_digest(expected_signature, signature)
|
|
51
|
+
|
|
52
|
+
def check_ip_match(self, session: Session, current_ip: str | None) -> bool:
|
|
53
|
+
"""Check if session IP matches current request IP.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
session: The session to check
|
|
57
|
+
current_ip: Current request IP address
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if IPs match or session has no IP binding
|
|
61
|
+
"""
|
|
62
|
+
if session.ip_address is None:
|
|
63
|
+
return True
|
|
64
|
+
if current_ip is None:
|
|
65
|
+
return False
|
|
66
|
+
return session.ip_address == current_ip
|
|
67
|
+
|
|
68
|
+
def check_user_agent_match(
|
|
69
|
+
self,
|
|
70
|
+
session: Session,
|
|
71
|
+
current_user_agent: str | None,
|
|
72
|
+
) -> bool:
|
|
73
|
+
"""Check if session User-Agent matches current request.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
session: The session to check
|
|
77
|
+
current_user_agent: Current request User-Agent
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if User-Agents match or session has no UA binding
|
|
81
|
+
"""
|
|
82
|
+
if session.user_agent is None:
|
|
83
|
+
return True
|
|
84
|
+
if current_user_agent is None:
|
|
85
|
+
return False
|
|
86
|
+
return session.user_agent == current_user_agent
|
|
87
|
+
|
|
88
|
+
def hash_data(self, data: str) -> str:
|
|
89
|
+
"""Hash data using SHA-256.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
data: Data to hash
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Hex digest of the hash
|
|
96
|
+
"""
|
|
97
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
"""Type definitions and type aliases for FastAPI-CacheX."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
|
|
9
|
+
# Cache key separator - using ||| to avoid conflicts with port numbers in host (e.g., 127.0.0.1:8000)
|
|
10
|
+
CACHE_KEY_SEPARATOR = "|||"
|
|
11
|
+
|
|
12
|
+
# Type for custom cache key builder function
|
|
13
|
+
CacheKeyBuilder = Callable[[Request], str]
|
|
14
|
+
|
|
6
15
|
|
|
7
16
|
@dataclass
|
|
8
17
|
class ETagContent:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|