SessionArmor 0.1.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.
- session_armor/__init__.py +29 -0
- session_armor/audit.py +163 -0
- session_armor/gate.py +213 -0
- session_armor/py.typed +0 -0
- session_armor/session.py +263 -0
- session_armor/utils.py +93 -0
- sessionarmor-0.1.0.dist-info/METADATA +174 -0
- sessionarmor-0.1.0.dist-info/RECORD +10 -0
- sessionarmor-0.1.0.dist-info/WHEEL +4 -0
- sessionarmor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionArmor — defense-grade Django middleware.
|
|
3
|
+
|
|
4
|
+
Session security, audit logging, and configurable request gates with hooks.
|
|
5
|
+
|
|
6
|
+
Three middleware classes, one base class:
|
|
7
|
+
|
|
8
|
+
SessionSecurityMiddleware
|
|
9
|
+
Fingerprint binding, absolute/idle timeouts, claim drift detection.
|
|
10
|
+
|
|
11
|
+
AuditMiddleware
|
|
12
|
+
Privacy-preserving audit trail for every authenticated request.
|
|
13
|
+
|
|
14
|
+
GateMiddleware
|
|
15
|
+
Base class for building cacheable, hookable conditional gates
|
|
16
|
+
(compliance, onboarding, feature flags, maintenance, etc.).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .audit import AuditMiddleware
|
|
20
|
+
from .gate import GateMiddleware
|
|
21
|
+
from .session import SessionSecurityMiddleware
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
'GateMiddleware',
|
|
25
|
+
'SessionSecurityMiddleware',
|
|
26
|
+
'AuditMiddleware',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = '0.1.0'
|
session_armor/audit.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AuditMiddleware — security audit trail for Django requests.
|
|
3
|
+
|
|
4
|
+
Logs every authenticated request with security-relevant context for
|
|
5
|
+
forensic analysis, anomaly detection, and compliance reporting.
|
|
6
|
+
|
|
7
|
+
Every log entry captures **WHO** did **WHAT** from **WHERE**, enabling:
|
|
8
|
+
- Post-incident forensics
|
|
9
|
+
- Real-time anomaly detection (via log aggregation / SIEM)
|
|
10
|
+
- Compliance reporting (NIST 800-53 AU-3, AU-12; SOC 2)
|
|
11
|
+
|
|
12
|
+
Never logs request bodies, tokens, or credentials.
|
|
13
|
+
|
|
14
|
+
Hooks (override in subclass)::
|
|
15
|
+
|
|
16
|
+
get_user_identity(request) → dict
|
|
17
|
+
get_extra_fields(request, response) → dict
|
|
18
|
+
before_log(request, response, entry) → dict | None
|
|
19
|
+
get_log_level(status_code) → int
|
|
20
|
+
format_entry(entry) → str
|
|
21
|
+
|
|
22
|
+
Usage::
|
|
23
|
+
|
|
24
|
+
# Generic
|
|
25
|
+
MIDDLEWARE = [..., 'session_armor.audit.AuditMiddleware', ...]
|
|
26
|
+
|
|
27
|
+
# Customized (e.g. Auth0-aware)
|
|
28
|
+
class MyAudit(AuditMiddleware):
|
|
29
|
+
def get_user_identity(self, request):
|
|
30
|
+
return {
|
|
31
|
+
'uid': request.user.sub,
|
|
32
|
+
'tenant': request.user.organization_uuid,
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
|
|
40
|
+
from .utils import get_client_ip, hash_value, is_authenticated, sanitize_for_log
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger('security.audit')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuditMiddleware:
|
|
46
|
+
"""
|
|
47
|
+
Audit logging for authenticated requests.
|
|
48
|
+
|
|
49
|
+
Captures:
|
|
50
|
+
Request ID, user identity, method, path, status, hashed IP,
|
|
51
|
+
truncated user-agent, and request duration.
|
|
52
|
+
|
|
53
|
+
Does NOT capture:
|
|
54
|
+
Request/response bodies, tokens, passwords, query parameters.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# ── Configuration ────────────────────────────────────────────────────
|
|
58
|
+
logger_name: str = 'security.audit'
|
|
59
|
+
exempt_paths: tuple[str, ...] = ()
|
|
60
|
+
|
|
61
|
+
# ── Django middleware protocol ────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def __init__(self, get_response):
|
|
64
|
+
self.get_response = get_response
|
|
65
|
+
self._logger = logging.getLogger(self.logger_name)
|
|
66
|
+
|
|
67
|
+
def __call__(self, request):
|
|
68
|
+
if self._is_exempt(request):
|
|
69
|
+
return self.get_response(request)
|
|
70
|
+
|
|
71
|
+
# Attach request ID for correlation
|
|
72
|
+
request_id = str(uuid.uuid4())[:8]
|
|
73
|
+
request.META['X_REQUEST_ID'] = request_id
|
|
74
|
+
start = time.time()
|
|
75
|
+
|
|
76
|
+
response = self.get_response(request)
|
|
77
|
+
|
|
78
|
+
duration_ms = round((time.time() - start) * 1000)
|
|
79
|
+
|
|
80
|
+
# Only audit authenticated requests
|
|
81
|
+
if is_authenticated(request):
|
|
82
|
+
self._emit(request, response, request_id, duration_ms)
|
|
83
|
+
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
# ── HOOKS (override in subclass) ─────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def get_user_identity(self, request) -> dict:
|
|
89
|
+
"""
|
|
90
|
+
Extract user identity fields for the audit log entry.
|
|
91
|
+
|
|
92
|
+
Override to match your auth system's user model.
|
|
93
|
+
Default returns ``{'uid': user.pk}``.
|
|
94
|
+
"""
|
|
95
|
+
user = request.user
|
|
96
|
+
return {
|
|
97
|
+
'uid': getattr(user, 'sub', None) or str(getattr(user, 'pk', '')),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def get_extra_fields(self, request, response) -> dict:
|
|
101
|
+
"""Return additional fields to include in the audit entry."""
|
|
102
|
+
return {}
|
|
103
|
+
|
|
104
|
+
def before_log(self, request, response, entry: dict) -> dict | None:
|
|
105
|
+
"""
|
|
106
|
+
Called before the entry is logged.
|
|
107
|
+
|
|
108
|
+
Return the (optionally modified) entry, or ``None`` to suppress
|
|
109
|
+
logging for this request.
|
|
110
|
+
"""
|
|
111
|
+
return entry
|
|
112
|
+
|
|
113
|
+
def get_log_level(self, status_code: int) -> int:
|
|
114
|
+
"""Return the ``logging`` level for a given response status code."""
|
|
115
|
+
if status_code >= 500:
|
|
116
|
+
return logging.ERROR
|
|
117
|
+
if status_code in (401, 403):
|
|
118
|
+
return logging.WARNING
|
|
119
|
+
if status_code >= 400:
|
|
120
|
+
return logging.INFO
|
|
121
|
+
return logging.DEBUG
|
|
122
|
+
|
|
123
|
+
def format_entry(self, entry: dict) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Format the audit entry for the log message.
|
|
126
|
+
|
|
127
|
+
Override to produce structured JSON, CEF, or other formats.
|
|
128
|
+
"""
|
|
129
|
+
return f"AUDIT {entry}"
|
|
130
|
+
|
|
131
|
+
# ── INTERNALS ────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _emit(self, request, response, request_id: str, duration_ms: int):
|
|
134
|
+
ip = get_client_ip(request)
|
|
135
|
+
|
|
136
|
+
entry = {
|
|
137
|
+
'rid': request_id,
|
|
138
|
+
**self.get_user_identity(request),
|
|
139
|
+
'method': request.method,
|
|
140
|
+
'path': sanitize_for_log(request.path, max_length=500),
|
|
141
|
+
'status': response.status_code,
|
|
142
|
+
'ip_hash': hash_value(ip, length=16),
|
|
143
|
+
'ua': sanitize_for_log(
|
|
144
|
+
request.META.get('HTTP_USER_AGENT', ''), max_length=80
|
|
145
|
+
),
|
|
146
|
+
'ms': duration_ms,
|
|
147
|
+
**self.get_extra_fields(request, response),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
entry = self.before_log(request, response, entry)
|
|
151
|
+
if entry is None:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
level = self.get_log_level(response.status_code)
|
|
155
|
+
message = self.format_entry(entry)
|
|
156
|
+
|
|
157
|
+
if response.status_code in (401, 403):
|
|
158
|
+
self._logger.log(level, message.replace('AUDIT', 'AUDIT_DENIED', 1))
|
|
159
|
+
else:
|
|
160
|
+
self._logger.log(level, message)
|
|
161
|
+
|
|
162
|
+
def _is_exempt(self, request) -> bool:
|
|
163
|
+
return any(request.path.startswith(p) for p in self.exempt_paths)
|
session_armor/gate.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GateMiddleware — configurable conditional gate for Django middleware stacks.
|
|
3
|
+
|
|
4
|
+
A gate checks a condition on each request (with session caching) and either
|
|
5
|
+
passes the request through or rejects it. Subclass this to build:
|
|
6
|
+
|
|
7
|
+
- Compliance gates (legal document acceptance)
|
|
8
|
+
- Onboarding gates (step completion)
|
|
9
|
+
- Feature gates (entitlement / feature-flag checks)
|
|
10
|
+
- Maintenance gates (scheduled downtime)
|
|
11
|
+
- Any "must satisfy X before proceeding" pattern
|
|
12
|
+
|
|
13
|
+
Follows the before/after hooks pattern for maximum customizability::
|
|
14
|
+
|
|
15
|
+
class ComplianceGate(GateMiddleware):
|
|
16
|
+
gate_id = 'compliance'
|
|
17
|
+
cache_ttl_setting = 'COMPLIANCE_CACHE_TTL'
|
|
18
|
+
default_cache_ttl = 3600
|
|
19
|
+
|
|
20
|
+
def check(self, request) -> bool:
|
|
21
|
+
return user_accepted_terms(request)
|
|
22
|
+
|
|
23
|
+
def on_reject(self, request):
|
|
24
|
+
return redirect('/accept-terms/')
|
|
25
|
+
|
|
26
|
+
Hooks execute in this order::
|
|
27
|
+
|
|
28
|
+
before_check → check → after_check → on_pass / on_reject
|
|
29
|
+
↓ (exception)
|
|
30
|
+
on_error
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from django.conf import settings
|
|
37
|
+
|
|
38
|
+
from .utils import is_authenticated
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger('session_armor.gate')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GateMiddleware:
|
|
44
|
+
"""
|
|
45
|
+
Base class for conditional gate middleware with session caching and hooks.
|
|
46
|
+
|
|
47
|
+
Configuration (set as class attributes on your subclass):
|
|
48
|
+
|
|
49
|
+
gate_id Unique ID used for session-key namespacing.
|
|
50
|
+
cache_ttl_setting Name of a Django setting that holds the TTL override.
|
|
51
|
+
default_cache_ttl Fallback TTL in seconds (default 300).
|
|
52
|
+
exempt_paths Tuple of path prefixes that bypass this gate.
|
|
53
|
+
requires_auth Only check authenticated requests (default True).
|
|
54
|
+
fail_open Pass through on errors (default False).
|
|
55
|
+
|
|
56
|
+
Hooks (override any combination in your subclass):
|
|
57
|
+
|
|
58
|
+
before_check(request) → bool | None
|
|
59
|
+
check(request) → bool **required**
|
|
60
|
+
after_check(request, passed: bool)
|
|
61
|
+
on_reject(request) → HttpResponse
|
|
62
|
+
on_pass(request)
|
|
63
|
+
on_error(request, error) → bool
|
|
64
|
+
get_rejection_context(request) → dict
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# ── Configuration (override in subclass) ─────────────────────────────
|
|
68
|
+
gate_id: str = ''
|
|
69
|
+
cache_ttl_setting: str = ''
|
|
70
|
+
default_cache_ttl: int = 300
|
|
71
|
+
exempt_paths: tuple[str, ...] = ()
|
|
72
|
+
requires_auth: bool = True
|
|
73
|
+
fail_open: bool = False
|
|
74
|
+
|
|
75
|
+
# ── Django middleware protocol ────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
def __init__(self, get_response):
|
|
78
|
+
self.get_response = get_response
|
|
79
|
+
if not self.gate_id:
|
|
80
|
+
self.gate_id = self.__class__.__name__.lower()
|
|
81
|
+
|
|
82
|
+
def __call__(self, request):
|
|
83
|
+
# Exempt paths bypass the gate entirely
|
|
84
|
+
if self._is_exempt(request):
|
|
85
|
+
return self.get_response(request)
|
|
86
|
+
|
|
87
|
+
# Auth guard
|
|
88
|
+
if self.requires_auth and not is_authenticated(request):
|
|
89
|
+
return self.get_response(request)
|
|
90
|
+
|
|
91
|
+
# ── before_check hook ──
|
|
92
|
+
should_check = self.before_check(request)
|
|
93
|
+
if should_check is False:
|
|
94
|
+
return self.get_response(request)
|
|
95
|
+
|
|
96
|
+
now = time.time()
|
|
97
|
+
prefix = self._cache_prefix
|
|
98
|
+
ttl = self._get_cache_ttl()
|
|
99
|
+
|
|
100
|
+
# ── Session-cache fast path ──
|
|
101
|
+
checked_at = request.session.get(f'{prefix}_at', 0)
|
|
102
|
+
if (now - checked_at) < ttl and f'{prefix}_result' in request.session:
|
|
103
|
+
passed = request.session[f'{prefix}_result']
|
|
104
|
+
if not passed:
|
|
105
|
+
return self.on_reject(request)
|
|
106
|
+
self.on_pass(request)
|
|
107
|
+
return self.get_response(request)
|
|
108
|
+
|
|
109
|
+
# ── Run the check ──
|
|
110
|
+
try:
|
|
111
|
+
passed = self.check(request)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
passed = self.on_error(request, exc)
|
|
114
|
+
|
|
115
|
+
# ── after_check hook ──
|
|
116
|
+
self.after_check(request, passed)
|
|
117
|
+
|
|
118
|
+
# ── Cache the result ──
|
|
119
|
+
request.session[f'{prefix}_at'] = now
|
|
120
|
+
request.session[f'{prefix}_result'] = passed
|
|
121
|
+
|
|
122
|
+
if not passed:
|
|
123
|
+
ctx = self.get_rejection_context(request)
|
|
124
|
+
if ctx:
|
|
125
|
+
request.session[f'{prefix}_context'] = ctx
|
|
126
|
+
return self.on_reject(request)
|
|
127
|
+
|
|
128
|
+
self.on_pass(request)
|
|
129
|
+
return self.get_response(request)
|
|
130
|
+
|
|
131
|
+
# ── HOOKS (override in subclass) ─────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def before_check(self, request) -> bool | None:
|
|
134
|
+
"""Called before ``check()``. Return ``False`` to skip the gate."""
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
def check(self, request) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
The main condition.
|
|
140
|
+
|
|
141
|
+
Return ``True`` to let the request through, ``False`` to reject it.
|
|
142
|
+
**Must** be overridden in every subclass.
|
|
143
|
+
"""
|
|
144
|
+
raise NotImplementedError(
|
|
145
|
+
f"{self.__class__.__name__} must implement check()"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def after_check(self, request, passed: bool) -> None:
|
|
149
|
+
"""Called after ``check()`` with the result. Logging, metrics, side effects."""
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def on_reject(self, request):
|
|
153
|
+
"""Called when the gate rejects. Return an ``HttpResponse``."""
|
|
154
|
+
from django.http import HttpResponseForbidden
|
|
155
|
+
return HttpResponseForbidden("Access denied")
|
|
156
|
+
|
|
157
|
+
def on_pass(self, request) -> None:
|
|
158
|
+
"""Called when the gate passes. Side effects only (no return value)."""
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def on_error(self, request, error: Exception) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Called when ``check()`` raises an exception.
|
|
164
|
+
|
|
165
|
+
Return ``True`` to pass through (fail-open), ``False`` to reject.
|
|
166
|
+
"""
|
|
167
|
+
logger.error(
|
|
168
|
+
"%s check error: %s", self.__class__.__name__, error,
|
|
169
|
+
exc_info=True,
|
|
170
|
+
)
|
|
171
|
+
return self.fail_open
|
|
172
|
+
|
|
173
|
+
def get_rejection_context(self, request) -> dict:
|
|
174
|
+
"""
|
|
175
|
+
Return extra data to store in session when the gate rejects.
|
|
176
|
+
|
|
177
|
+
Accessible later via ``cls.get_context(request)`` from views.
|
|
178
|
+
"""
|
|
179
|
+
return {}
|
|
180
|
+
|
|
181
|
+
# ── CLASS UTILITIES (call from views) ────────────────────────────────
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def invalidate(cls, request):
|
|
185
|
+
"""
|
|
186
|
+
Clear cached gate state, forcing a re-check on the next request.
|
|
187
|
+
|
|
188
|
+
Call this from views when the underlying condition changes
|
|
189
|
+
(e.g. user just accepted terms, completed an onboarding step).
|
|
190
|
+
"""
|
|
191
|
+
prefix = f'_gate_{cls.gate_id or cls.__name__.lower()}'
|
|
192
|
+
for suffix in ('_at', '_result', '_context'):
|
|
193
|
+
request.session.pop(f'{prefix}{suffix}', None)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def get_context(cls, request) -> dict:
|
|
197
|
+
"""Read the cached rejection context from the session."""
|
|
198
|
+
prefix = f'_gate_{cls.gate_id or cls.__name__.lower()}'
|
|
199
|
+
return request.session.get(f'{prefix}_context', {})
|
|
200
|
+
|
|
201
|
+
# ── INTERNALS ────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def _cache_prefix(self) -> str:
|
|
205
|
+
return f'_gate_{self.gate_id}'
|
|
206
|
+
|
|
207
|
+
def _get_cache_ttl(self) -> int:
|
|
208
|
+
if self.cache_ttl_setting:
|
|
209
|
+
return getattr(settings, self.cache_ttl_setting, self.default_cache_ttl)
|
|
210
|
+
return self.default_cache_ttl
|
|
211
|
+
|
|
212
|
+
def _is_exempt(self, request) -> bool:
|
|
213
|
+
return any(request.path.startswith(p) for p in self.exempt_paths)
|
session_armor/py.typed
ADDED
|
File without changes
|
session_armor/session.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionSecurityMiddleware — defense-grade session protection.
|
|
3
|
+
|
|
4
|
+
Enforces:
|
|
5
|
+
- Client fingerprint binding (IP + User-Agent hash)
|
|
6
|
+
- Absolute session timeout (force re-auth after N hours)
|
|
7
|
+
- Idle session timeout (force re-auth after inactivity)
|
|
8
|
+
- JWT claim drift detection (critical claims must not change mid-session)
|
|
9
|
+
|
|
10
|
+
All behavior is customizable via hooks so different auth systems
|
|
11
|
+
(Auth0, WorkOS, Django allauth, etc.) can plug in seamlessly.
|
|
12
|
+
|
|
13
|
+
References:
|
|
14
|
+
NIST SP 800-53 AC-12 (Session Termination)
|
|
15
|
+
NIST SP 800-53 SC-23 (Session Authenticity)
|
|
16
|
+
OWASP Session Management Cheat Sheet
|
|
17
|
+
|
|
18
|
+
Hooks (override in subclass)::
|
|
19
|
+
|
|
20
|
+
get_fingerprint_parts(request) → list[str]
|
|
21
|
+
get_critical_claims(user) → list[str]
|
|
22
|
+
get_login_url(request) → str
|
|
23
|
+
on_violation(request, reason) → HttpResponse | None
|
|
24
|
+
on_timeout(request, kind) → None
|
|
25
|
+
on_session_init(request) → None
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
# Generic (works with any Django auth)
|
|
30
|
+
MIDDLEWARE = [
|
|
31
|
+
...
|
|
32
|
+
'session_armor.session.SessionSecurityMiddleware',
|
|
33
|
+
...
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Customized
|
|
37
|
+
class MySessionSecurity(SessionSecurityMiddleware):
|
|
38
|
+
def get_critical_claims(self, user):
|
|
39
|
+
return [user.sub, user.tenant_id]
|
|
40
|
+
|
|
41
|
+
def get_login_url(self, request):
|
|
42
|
+
return reverse('my_login')
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import hashlib
|
|
46
|
+
import logging
|
|
47
|
+
import time
|
|
48
|
+
|
|
49
|
+
from django.conf import settings
|
|
50
|
+
from django.shortcuts import redirect
|
|
51
|
+
|
|
52
|
+
from .utils import get_client_ip, is_authenticated
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger('session_armor.session')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SessionSecurityMiddleware:
|
|
58
|
+
"""
|
|
59
|
+
Session binding, timeouts, and anomaly detection.
|
|
60
|
+
|
|
61
|
+
Must run **after** your authentication middleware (needs ``request.user``).
|
|
62
|
+
|
|
63
|
+
Settings (read from Django settings with secure defaults):
|
|
64
|
+
|
|
65
|
+
SESSION_ABSOLUTE_TIMEOUT int — seconds (default 28800 / 8 h)
|
|
66
|
+
SESSION_IDLE_TIMEOUT int — seconds (default 1800 / 30 min)
|
|
67
|
+
SESSION_BIND_IP bool (default True)
|
|
68
|
+
SESSION_BIND_USER_AGENT bool (default True)
|
|
69
|
+
SESSION_DETECT_CLAIM_DRIFT bool (default True)
|
|
70
|
+
|
|
71
|
+
Session keys (all prefixed ``_ss_``):
|
|
72
|
+
|
|
73
|
+
_ss_fingerprint Client fingerprint hash
|
|
74
|
+
_ss_auth_time Absolute session start (epoch)
|
|
75
|
+
_ss_last_active Last activity timestamp (epoch)
|
|
76
|
+
_ss_claims_hash Hash of critical JWT claims
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# ── Configuration ────────────────────────────────────────────────────
|
|
80
|
+
exempt_paths: tuple[str, ...] = ()
|
|
81
|
+
|
|
82
|
+
# ── Django middleware protocol ────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def __init__(self, get_response):
|
|
85
|
+
self.get_response = get_response
|
|
86
|
+
|
|
87
|
+
def __call__(self, request):
|
|
88
|
+
if self._is_exempt(request):
|
|
89
|
+
return self.get_response(request)
|
|
90
|
+
|
|
91
|
+
if not is_authenticated(request):
|
|
92
|
+
return self.get_response(request)
|
|
93
|
+
|
|
94
|
+
now = time.time()
|
|
95
|
+
user = request.user
|
|
96
|
+
|
|
97
|
+
# ── Binding check ──
|
|
98
|
+
violation = self._check_binding(request)
|
|
99
|
+
if violation:
|
|
100
|
+
logger.warning(
|
|
101
|
+
"Session binding violation (%s) for user %s, path=%s",
|
|
102
|
+
violation, getattr(user, 'sub', '?'), request.path,
|
|
103
|
+
)
|
|
104
|
+
return self._terminate(request, reason=violation)
|
|
105
|
+
|
|
106
|
+
# ── Absolute timeout ──
|
|
107
|
+
auth_time = request.session.get('_ss_auth_time')
|
|
108
|
+
timeout = getattr(settings, 'SESSION_ABSOLUTE_TIMEOUT', 28800)
|
|
109
|
+
if auth_time and (now - auth_time) > timeout:
|
|
110
|
+
logger.info("Absolute timeout for user %s", getattr(user, 'sub', '?'))
|
|
111
|
+
self.on_timeout(request, 'absolute')
|
|
112
|
+
return self._terminate(request, reason='absolute_timeout')
|
|
113
|
+
|
|
114
|
+
# ── Idle timeout ──
|
|
115
|
+
last_active = request.session.get('_ss_last_active')
|
|
116
|
+
idle_timeout = getattr(settings, 'SESSION_IDLE_TIMEOUT', 1800)
|
|
117
|
+
if last_active and (now - last_active) > idle_timeout:
|
|
118
|
+
logger.info("Idle timeout for user %s", getattr(user, 'sub', '?'))
|
|
119
|
+
self.on_timeout(request, 'idle')
|
|
120
|
+
return self._terminate(request, reason='idle_timeout')
|
|
121
|
+
|
|
122
|
+
# ── Claim drift ──
|
|
123
|
+
if getattr(settings, 'SESSION_DETECT_CLAIM_DRIFT', True):
|
|
124
|
+
drift = self._check_claim_drift(request)
|
|
125
|
+
if drift:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"JWT claim drift for user %s: %s",
|
|
128
|
+
getattr(user, 'sub', '?'), drift,
|
|
129
|
+
)
|
|
130
|
+
return self._terminate(request, reason='claim_drift')
|
|
131
|
+
|
|
132
|
+
# ── Initialize on first authenticated request ──
|
|
133
|
+
if '_ss_auth_time' not in request.session:
|
|
134
|
+
self._init_session(request, now)
|
|
135
|
+
self.on_session_init(request)
|
|
136
|
+
|
|
137
|
+
# ── Update last-active ──
|
|
138
|
+
request.session['_ss_last_active'] = now
|
|
139
|
+
|
|
140
|
+
return self.get_response(request)
|
|
141
|
+
|
|
142
|
+
# ── HOOKS (override in subclass) ─────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def get_fingerprint_parts(self, request) -> list[str]:
|
|
145
|
+
"""
|
|
146
|
+
Return the parts that make up the client fingerprint.
|
|
147
|
+
|
|
148
|
+
Override to add custom binding factors (client certs, device ID, etc.).
|
|
149
|
+
Each part is joined with ``|`` and SHA-256 hashed.
|
|
150
|
+
"""
|
|
151
|
+
parts = []
|
|
152
|
+
if getattr(settings, 'SESSION_BIND_IP', True):
|
|
153
|
+
parts.append(get_client_ip(request))
|
|
154
|
+
if getattr(settings, 'SESSION_BIND_USER_AGENT', True):
|
|
155
|
+
parts.append(request.META.get('HTTP_USER_AGENT', ''))
|
|
156
|
+
return parts
|
|
157
|
+
|
|
158
|
+
def get_critical_claims(self, user) -> list[str]:
|
|
159
|
+
"""
|
|
160
|
+
Return claim values that must not change mid-session.
|
|
161
|
+
|
|
162
|
+
Override to match your auth system's user model. The values are
|
|
163
|
+
hashed together; if the hash changes, the session is terminated.
|
|
164
|
+
|
|
165
|
+
Default extracts ``sub`` and ``email`` (works for most OIDC setups).
|
|
166
|
+
"""
|
|
167
|
+
return [
|
|
168
|
+
getattr(user, 'sub', '') or '',
|
|
169
|
+
getattr(user, 'email', '') or '',
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def get_login_url(self, request) -> str:
|
|
173
|
+
"""
|
|
174
|
+
Return the URL to redirect to when a session is terminated.
|
|
175
|
+
|
|
176
|
+
Override to implement platform-specific or tenant-specific routing.
|
|
177
|
+
Default uses Django's ``LOGIN_URL`` setting.
|
|
178
|
+
"""
|
|
179
|
+
return getattr(settings, 'LOGIN_URL', '/')
|
|
180
|
+
|
|
181
|
+
def on_violation(self, request, reason: str):
|
|
182
|
+
"""
|
|
183
|
+
Called on security violation, before session flush.
|
|
184
|
+
|
|
185
|
+
Return an ``HttpResponse`` to override the default redirect.
|
|
186
|
+
Return ``None`` to use the default behavior (flush → login redirect).
|
|
187
|
+
"""
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def on_timeout(self, request, kind: str) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Called on absolute or idle timeout, before session flush.
|
|
193
|
+
|
|
194
|
+
``kind`` is ``'absolute'`` or ``'idle'``.
|
|
195
|
+
Use for logging, metrics, or pre-flush cleanup.
|
|
196
|
+
"""
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
def on_session_init(self, request) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Called when session security metadata is initialized for a new session.
|
|
202
|
+
|
|
203
|
+
Use to set additional session values at auth time.
|
|
204
|
+
"""
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# ── INTERNALS ────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def _check_binding(self, request) -> str | None:
|
|
210
|
+
stored = request.session.get('_ss_fingerprint')
|
|
211
|
+
if not stored:
|
|
212
|
+
return None # First request — will be initialized
|
|
213
|
+
|
|
214
|
+
parts = self.get_fingerprint_parts(request)
|
|
215
|
+
current = hashlib.sha256('|'.join(parts).encode('utf-8')).hexdigest()
|
|
216
|
+
if current != stored:
|
|
217
|
+
return 'fingerprint_mismatch'
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _check_claim_drift(self, request) -> str | None:
|
|
221
|
+
stored = request.session.get('_ss_claims_hash')
|
|
222
|
+
if not stored:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
current = self._hash_claims(request.user)
|
|
226
|
+
if current != stored:
|
|
227
|
+
return 'critical_claims_changed'
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _hash_claims(self, user) -> str:
|
|
231
|
+
parts = self.get_critical_claims(user)
|
|
232
|
+
raw = '|'.join(str(p) for p in parts)
|
|
233
|
+
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
|
234
|
+
|
|
235
|
+
def _init_session(self, request, now: float):
|
|
236
|
+
# Rotate session ID to prevent fixation attacks (OWASP A7)
|
|
237
|
+
request.session.cycle_key()
|
|
238
|
+
|
|
239
|
+
parts = self.get_fingerprint_parts(request)
|
|
240
|
+
request.session['_ss_fingerprint'] = hashlib.sha256(
|
|
241
|
+
'|'.join(parts).encode('utf-8')
|
|
242
|
+
).hexdigest()
|
|
243
|
+
request.session['_ss_auth_time'] = now
|
|
244
|
+
request.session['_ss_last_active'] = now
|
|
245
|
+
request.session['_ss_claims_hash'] = self._hash_claims(request.user)
|
|
246
|
+
logger.debug(
|
|
247
|
+
"Session security initialized for user %s",
|
|
248
|
+
getattr(request.user, 'sub', '?'),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _terminate(self, request, reason: str):
|
|
252
|
+
# Get login URL BEFORE flushing (user info may be needed)
|
|
253
|
+
custom = self.on_violation(request, reason)
|
|
254
|
+
login_url = self.get_login_url(request)
|
|
255
|
+
|
|
256
|
+
request.session.flush()
|
|
257
|
+
|
|
258
|
+
if custom is not None:
|
|
259
|
+
return custom
|
|
260
|
+
return redirect(login_url)
|
|
261
|
+
|
|
262
|
+
def _is_exempt(self, request) -> bool:
|
|
263
|
+
return any(request.path.startswith(p) for p in self.exempt_paths)
|
session_armor/utils.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for SessionArmor middleware.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
|
|
10
|
+
# Characters that can corrupt structured log parsing (newlines, ANSI escapes,
|
|
11
|
+
# null bytes, control characters).
|
|
12
|
+
_LOG_UNSAFE_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x1b\[\]\r\n]')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hash_value(value: str, length: int = 0) -> str:
|
|
16
|
+
"""SHA-256 hash of a string. Optionally truncate to ``length`` chars."""
|
|
17
|
+
h = hashlib.sha256(value.encode('utf-8')).hexdigest()
|
|
18
|
+
return h[:length] if length else h
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_client_ip(request) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Extract the originating client IP with proxy-depth awareness.
|
|
24
|
+
|
|
25
|
+
When ``SESSION_ARMOR_TRUSTED_PROXY_DEPTH`` is set in Django settings,
|
|
26
|
+
only that many rightmost ``X-Forwarded-For`` entries are trusted.
|
|
27
|
+
The client IP is the entry just before the trusted proxies.
|
|
28
|
+
|
|
29
|
+
If the header has fewer entries than the proxy depth (i.e. the request
|
|
30
|
+
didn't traverse enough proxies), we fall back to ``REMOTE_ADDR`` to
|
|
31
|
+
avoid trusting a spoofed header.
|
|
32
|
+
|
|
33
|
+
Examples with ``SESSION_ARMOR_TRUSTED_PROXY_DEPTH = 1``::
|
|
34
|
+
|
|
35
|
+
X-Forwarded-For: spoofed, real, proxy1
|
|
36
|
+
→ returns "real"
|
|
37
|
+
|
|
38
|
+
X-Forwarded-For: real, proxy1
|
|
39
|
+
→ returns "real"
|
|
40
|
+
|
|
41
|
+
X-Forwarded-For: spoofed_only
|
|
42
|
+
→ returns REMOTE_ADDR (not enough hops)
|
|
43
|
+
"""
|
|
44
|
+
depth = getattr(settings, 'SESSION_ARMOR_TRUSTED_PROXY_DEPTH', 0)
|
|
45
|
+
forwarded = request.META.get('HTTP_X_FORWARDED_FOR', '')
|
|
46
|
+
|
|
47
|
+
if not forwarded:
|
|
48
|
+
return request.META.get('REMOTE_ADDR', '')
|
|
49
|
+
|
|
50
|
+
parts = [p.strip() for p in forwarded.split(',')]
|
|
51
|
+
|
|
52
|
+
if depth and depth > 0:
|
|
53
|
+
# The client is at index -(depth + 1) from the right.
|
|
54
|
+
# If the header is shorter than depth + 1, it's spoofed — use REMOTE_ADDR.
|
|
55
|
+
client_index = len(parts) - depth - 1
|
|
56
|
+
if client_index < 0:
|
|
57
|
+
return request.META.get('REMOTE_ADDR', '')
|
|
58
|
+
return parts[client_index]
|
|
59
|
+
|
|
60
|
+
# No proxy depth configured — legacy behavior (leftmost, less safe).
|
|
61
|
+
return parts[0]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_client_fingerprint(request, parts: list[str] | None = None) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Build a privacy-preserving fingerprint from client connection properties.
|
|
67
|
+
|
|
68
|
+
By default hashes IP + User-Agent. Pass *parts* to override.
|
|
69
|
+
"""
|
|
70
|
+
if parts is None:
|
|
71
|
+
parts = [
|
|
72
|
+
get_client_ip(request),
|
|
73
|
+
request.META.get('HTTP_USER_AGENT', ''),
|
|
74
|
+
]
|
|
75
|
+
raw = '|'.join(parts)
|
|
76
|
+
return hash_value(raw)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_authenticated(request) -> bool:
|
|
80
|
+
"""Check whether the request has an authenticated user."""
|
|
81
|
+
user = getattr(request, 'user', None)
|
|
82
|
+
return bool(user and getattr(user, 'is_authenticated', False))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sanitize_for_log(value: str, max_length: int = 200) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Sanitize a user-controlled string for safe inclusion in log entries.
|
|
88
|
+
|
|
89
|
+
Strips control characters, ANSI escape sequences, null bytes, and
|
|
90
|
+
truncates to ``max_length`` to prevent log injection / log flooding.
|
|
91
|
+
"""
|
|
92
|
+
clean = _LOG_UNSAFE_RE.sub('', value)
|
|
93
|
+
return clean[:max_length]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: SessionArmor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Defense-grade Django middleware: session security, audit logging, and configurable request gates with hooks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Tunet-xyz/session_armor
|
|
6
|
+
Project-URL: Documentation, https://github.com/Tunet-xyz/session_armor#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/Tunet-xyz/session_armor
|
|
8
|
+
Project-URL: Issues, https://github.com/Tunet-xyz/session_armor/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Tunet-xyz/session_armor/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Alex C <alex@coded.uk>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: audit,django,gates,middleware,nist,owasp,security,session
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: django>=4.2
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: django-stubs>=4.2.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-django>=4.5; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# SessionArmor
|
|
38
|
+
|
|
39
|
+
[](https://github.com/Tunet-xyz/session_armor/actions/workflows/tests.yaml)
|
|
40
|
+
[](https://pypi.org/project/SessionArmor/)
|
|
41
|
+
[](https://www.python.org/downloads/)
|
|
42
|
+
[](https://www.djangoproject.com/)
|
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
|
44
|
+
|
|
45
|
+
**Defense-grade Django middleware: session security, audit logging, and configurable request gates with hooks.**
|
|
46
|
+
|
|
47
|
+
Part of the [Tunet](https://github.com/Tunet-xyz) ecosystem — alongside [SwapLayer](https://github.com/Tunet-xyz/swap_layer) and [InfraGlyph](https://github.com/Tunet-xyz/infra_glyph).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## What It Does
|
|
52
|
+
|
|
53
|
+
SessionArmor provides three drop-in Django middleware classes that harden your application's session layer:
|
|
54
|
+
|
|
55
|
+
| Middleware | Purpose |
|
|
56
|
+
|-----------|---------|
|
|
57
|
+
| `SessionSecurityMiddleware` | Fingerprint binding, absolute/idle timeouts, claim drift detection |
|
|
58
|
+
| `AuditMiddleware` | Privacy-preserving security audit trail for every authenticated request |
|
|
59
|
+
| `GateMiddleware` | Base class for building cacheable conditional gates (compliance, onboarding, feature flags) |
|
|
60
|
+
|
|
61
|
+
All three are **hookable** — override methods to customize behavior without touching internals.
|
|
62
|
+
|
|
63
|
+
## References
|
|
64
|
+
|
|
65
|
+
- [NIST SP 800-53 AC-12](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Session Termination)
|
|
66
|
+
- [NIST SP 800-53 SC-23](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Session Authenticity)
|
|
67
|
+
- [NIST SP 800-53 AU-3/AU-12](https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final) (Audit Content/Generation)
|
|
68
|
+
- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install SessionArmor
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quick Start
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# settings.py
|
|
82
|
+
MIDDLEWARE = [
|
|
83
|
+
'django.middleware.security.SecurityMiddleware',
|
|
84
|
+
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
85
|
+
# ... your auth middleware ...
|
|
86
|
+
'session_armor.session.SessionSecurityMiddleware',
|
|
87
|
+
'session_armor.audit.AuditMiddleware',
|
|
88
|
+
# ... rest of your middleware ...
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
# Optional settings (shown with defaults)
|
|
92
|
+
SESSION_ABSOLUTE_TIMEOUT = 28800 # 8 hours
|
|
93
|
+
SESSION_IDLE_TIMEOUT = 1800 # 30 minutes
|
|
94
|
+
SESSION_BIND_IP = True
|
|
95
|
+
SESSION_BIND_USER_AGENT = True
|
|
96
|
+
SESSION_DETECT_CLAIM_DRIFT = True
|
|
97
|
+
SESSION_ARMOR_TRUSTED_PROXY_DEPTH = 1 # Number of trusted reverse proxies
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Building a Gate
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from session_armor import GateMiddleware
|
|
104
|
+
from django.shortcuts import redirect
|
|
105
|
+
|
|
106
|
+
class ComplianceGate(GateMiddleware):
|
|
107
|
+
gate_id = 'compliance'
|
|
108
|
+
cache_ttl_setting = 'COMPLIANCE_CACHE_TTL'
|
|
109
|
+
default_cache_ttl = 3600
|
|
110
|
+
fail_open = False # Block access if check fails
|
|
111
|
+
|
|
112
|
+
def check(self, request) -> bool:
|
|
113
|
+
return user_accepted_current_terms(request)
|
|
114
|
+
|
|
115
|
+
def on_reject(self, request):
|
|
116
|
+
return redirect('/accept-terms/')
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Customizing Session Security
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from session_armor import SessionSecurityMiddleware
|
|
123
|
+
|
|
124
|
+
class MySessionSecurity(SessionSecurityMiddleware):
|
|
125
|
+
exempt_paths = ('/health/', '/static/')
|
|
126
|
+
|
|
127
|
+
def get_critical_claims(self, user):
|
|
128
|
+
# Auth0 / OIDC claims that must not change mid-session
|
|
129
|
+
return [user.sub, user.organization_uuid, user.platform]
|
|
130
|
+
|
|
131
|
+
def get_login_url(self, request):
|
|
132
|
+
platform = getattr(request.user, 'platform', '')
|
|
133
|
+
return f'/{platform}/login/' if platform else '/login/'
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Customizing Audit Logging
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from session_armor import AuditMiddleware
|
|
140
|
+
|
|
141
|
+
class MyAudit(AuditMiddleware):
|
|
142
|
+
exempt_paths = ('/health/', '/static/', '/favicon.ico')
|
|
143
|
+
|
|
144
|
+
def get_user_identity(self, request):
|
|
145
|
+
return {
|
|
146
|
+
'uid': request.user.sub,
|
|
147
|
+
'platform': request.user.platform,
|
|
148
|
+
'org': request.user.organization_uuid,
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Clone and install
|
|
158
|
+
git clone https://github.com/Tunet-xyz/session_armor.git
|
|
159
|
+
cd session_armor
|
|
160
|
+
pip install -e ".[dev]"
|
|
161
|
+
|
|
162
|
+
# Run tests
|
|
163
|
+
pytest
|
|
164
|
+
|
|
165
|
+
# Lint
|
|
166
|
+
ruff check src/session_armor tests
|
|
167
|
+
|
|
168
|
+
# Type check
|
|
169
|
+
mypy src/session_armor
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
session_armor/__init__.py,sha256=7TpN2B6RmBAtkDFKFQs3q9AMq0tiywVYzBwf2mcus1w,768
|
|
2
|
+
session_armor/audit.py,sha256=ZFG0O5Vlkzy7j4bP95qU9dmuLo7j-1HN5Fw1enZ9OgM,5594
|
|
3
|
+
session_armor/gate.py,sha256=vWFj9pyQcdBNCnrFtgayL2R5_OBHzsHRVe8rz4yIH7Q,7677
|
|
4
|
+
session_armor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
session_armor/session.py,sha256=n0kXie7RTiXhRstR9ncUo_j5BJ3xnEzIsFf4TbYHN2k,9590
|
|
6
|
+
session_armor/utils.py,sha256=7tRlsX-5M7bzj-7QqF6NbNvx4DHzUL2ShgqNeV82akA,3050
|
|
7
|
+
sessionarmor-0.1.0.dist-info/METADATA,sha256=BIMpHqRuNyHY3Aum2uam-CEIh7rlaB8UlTX0Xz9Z8GQ,6024
|
|
8
|
+
sessionarmor-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
sessionarmor-0.1.0.dist-info/licenses/LICENSE,sha256=Y0CO8lj8gytK0m9xyNBIteUiW0n9A0k5tBaVXaB2beE,1068
|
|
10
|
+
sessionarmor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Alex C
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|