cherrypy-foundation 1.0.0a15__py3-none-any.whl → 1.0.0a17__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.
- cherrypy_foundation/components/Datatable.js +2 -2
- cherrypy_foundation/components/__init__.py +2 -2
- cherrypy_foundation/components/tests/test_static.py +1 -1
- cherrypy_foundation/error_page.py +2 -2
- cherrypy_foundation/flash.py +2 -2
- cherrypy_foundation/form.py +2 -2
- cherrypy_foundation/logging.py +2 -2
- cherrypy_foundation/passwd.py +2 -2
- cherrypy_foundation/plugins/db.py +1 -1
- cherrypy_foundation/plugins/ldap.py +3 -1
- cherrypy_foundation/plugins/restapi.py +1 -1
- cherrypy_foundation/plugins/scheduler.py +1 -1
- cherrypy_foundation/plugins/smtp.py +8 -2
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_ldap.py +1 -1
- cherrypy_foundation/plugins/tests/test_scheduler.py +1 -1
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +1 -1
- cherrypy_foundation/plugins/tests/test_smtp.py +31 -1
- cherrypy_foundation/sessions.py +82 -0
- cherrypy_foundation/tests/__init__.py +1 -1
- cherrypy_foundation/tests/templates/test_form.html +6 -1
- cherrypy_foundation/tests/templates/test_url.html +1 -0
- cherrypy_foundation/tests/test_error_page.py +1 -1
- cherrypy_foundation/tests/test_flash.py +3 -3
- cherrypy_foundation/tests/test_form.py +36 -4
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +2 -2
- cherrypy_foundation/tests/test_url.py +2 -2
- cherrypy_foundation/tools/auth.py +31 -27
- cherrypy_foundation/tools/auth_mfa.py +87 -83
- cherrypy_foundation/tools/i18n.py +1 -1
- cherrypy_foundation/tools/jinja2.py +1 -1
- cherrypy_foundation/tools/ratelimit.py +33 -19
- cherrypy_foundation/tools/secure_headers.py +1 -1
- cherrypy_foundation/tools/sessions_timeout.py +23 -21
- cherrypy_foundation/tools/tests/test_auth.py +20 -3
- cherrypy_foundation/tools/tests/test_auth_mfa.py +6 -4
- cherrypy_foundation/tools/tests/test_i18n.py +1 -1
- cherrypy_foundation/tools/tests/test_jinja2.py +2 -2
- cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +2 -2
- cherrypy_foundation/widgets.py +2 -2
- cherrypy_foundation-1.0.0a17.dist-info/METADATA +71 -0
- {cherrypy_foundation-1.0.0a15.dist-info → cherrypy_foundation-1.0.0a17.dist-info}/RECORD +48 -46
- {cherrypy_foundation-1.0.0a15.dist-info → cherrypy_foundation-1.0.0a17.dist-info}/WHEEL +1 -1
- cherrypy_foundation/tools/errors.py +0 -27
- cherrypy_foundation-1.0.0a15.dist-info/METADATA +0 -42
- {cherrypy_foundation-1.0.0a15.dist-info → cherrypy_foundation-1.0.0a17.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a15.dist-info → cherrypy_foundation-1.0.0a17.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# MFA tool for cherrypy
|
|
2
|
-
# Copyright (C) 2022-
|
|
2
|
+
# Copyright (C) 2022-2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -21,6 +21,8 @@ from typing import Optional
|
|
|
21
21
|
|
|
22
22
|
import cherrypy
|
|
23
23
|
|
|
24
|
+
from cherrypy_foundation.sessions import session_lock
|
|
25
|
+
|
|
24
26
|
from ..passwd import check_password, hash_password
|
|
25
27
|
|
|
26
28
|
MFA_CODE = '_auth_mfa_code'
|
|
@@ -83,11 +85,11 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
83
85
|
|
|
84
86
|
length = self._code_length()
|
|
85
87
|
code = ''.join(secrets.choice(string.digits) for _ in range(length))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
with session_lock() as session:
|
|
89
|
+
session[MFA_USER_KEY] = cherrypy.request.login
|
|
90
|
+
session[MFA_CODE] = hash_password(code)
|
|
91
|
+
session[MFA_CODE_TIME] = session.now()
|
|
92
|
+
session[MFA_CODE_ATTEMPT] = 0
|
|
91
93
|
return code
|
|
92
94
|
|
|
93
95
|
def _is_verified(self) -> bool:
|
|
@@ -96,24 +98,24 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
96
98
|
return False
|
|
97
99
|
|
|
98
100
|
# Check if our username match current login user.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
with session_lock() as session:
|
|
102
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
103
|
+
return False
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
# Check if the current IP is trusted
|
|
106
|
+
ip_list = session.get(MFA_TRUSTED_IP_LIST) or []
|
|
107
|
+
if cherrypy.request.remote.ip not in ip_list:
|
|
108
|
+
return False
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
# Check if user ever pass the verification.
|
|
111
|
+
verified_at: Optional[datetime.datetime] = session.get(MFA_VERIFICATION_TIME)
|
|
112
|
+
if not verified_at:
|
|
113
|
+
return False
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
# Check trusted duration time
|
|
116
|
+
trust_minutes = self._trust_duration_minutes()
|
|
117
|
+
if (verified_at + datetime.timedelta(minutes=trust_minutes)) <= session.now():
|
|
118
|
+
return False
|
|
117
119
|
|
|
118
120
|
return True
|
|
119
121
|
|
|
@@ -125,24 +127,26 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
125
127
|
if not hasattr(cherrypy.serving, 'session'):
|
|
126
128
|
return True
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
with session_lock() as session:
|
|
131
|
+
if session.get(MFA_USER_KEY) != cherrypy.request.login:
|
|
132
|
+
return True
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
# Check if code is defined.
|
|
135
|
+
hash_ = session.get(MFA_CODE)
|
|
136
|
+
if not hash_:
|
|
137
|
+
return True
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
# Check issued time.
|
|
140
|
+
issued_at: Optional[datetime.datetime] = session.get(MFA_CODE_TIME)
|
|
141
|
+
if not issued_at or (
|
|
142
|
+
(issued_at + datetime.timedelta(minutes=self._code_timeout_minutes())) < session.now()
|
|
143
|
+
):
|
|
144
|
+
return True
|
|
141
145
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
# Check number of attempt.
|
|
147
|
+
attempts = int(session.get(MFA_CODE_ATTEMPT, 0))
|
|
148
|
+
if attempts >= self._max_attempts():
|
|
149
|
+
return True
|
|
146
150
|
|
|
147
151
|
return False
|
|
148
152
|
|
|
@@ -190,55 +194,55 @@ class CheckAuthMfa(cherrypy.Tool):
|
|
|
190
194
|
if not hasattr(cherrypy.serving, 'session'):
|
|
191
195
|
return False
|
|
192
196
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
197
|
+
with session_lock() as session:
|
|
198
|
+
stored_hash = session.get(MFA_CODE)
|
|
199
|
+
is_expired = self.is_code_expired()
|
|
200
|
+
if self.is_code_expired():
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
# Always perform the hash check regardless of expiration
|
|
204
|
+
# to prevent timing attacks
|
|
205
|
+
code_valid = False
|
|
206
|
+
if stored_hash:
|
|
207
|
+
code_valid = check_password(code, stored_hash)
|
|
208
|
+
|
|
209
|
+
# Check all conditions after hash verification
|
|
210
|
+
if is_expired or not code_valid:
|
|
211
|
+
if not is_expired: # Only increment if not expired
|
|
212
|
+
session[MFA_CODE_ATTEMPT] = attempts = int(session.get(MFA_CODE_ATTEMPT, 0)) + 1
|
|
213
|
+
cherrypy.log(
|
|
214
|
+
f'verification failed user={cherrypy.request.login} ip={cherrypy.request.remote.ip} attempts={attempts}',
|
|
215
|
+
context='MFA',
|
|
216
|
+
severity=logging.WARNING,
|
|
217
|
+
)
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
# Success: trust this device/IP for the configured duration
|
|
221
|
+
session[MFA_VERIFICATION_TIME] = session.now()
|
|
222
|
+
ip_list = list({*(session.get(MFA_TRUSTED_IP_LIST) or []), cherrypy.request.remote.ip})
|
|
223
|
+
# Cap the list to avoid unbounded growth (keep most recent N)
|
|
224
|
+
max_ips = self._max_trusted_ips()
|
|
225
|
+
if len(ip_list) > max_ips:
|
|
226
|
+
ip_list = ip_list[-max_ips:]
|
|
227
|
+
session[MFA_TRUSTED_IP_LIST] = ip_list
|
|
228
|
+
|
|
229
|
+
# Clear the one-time code
|
|
230
|
+
session[MFA_CODE] = None
|
|
231
|
+
session[MFA_CODE_TIME] = None
|
|
232
|
+
session[MFA_CODE_ATTEMPT] = 0
|
|
233
|
+
|
|
234
|
+
# Honor “remember this device” option by making the session persistent
|
|
235
|
+
if hasattr(cherrypy.tools, 'sessions_timeout'):
|
|
236
|
+
try:
|
|
237
|
+
cherrypy.tools.sessions_timeout.set_persistent(bool(persistent))
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# Rotate session id to prevent fixation
|
|
242
|
+
session.regenerate()
|
|
209
243
|
cherrypy.log(
|
|
210
|
-
f'verification
|
|
211
|
-
context='MFA',
|
|
212
|
-
severity=logging.WARNING,
|
|
244
|
+
f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
|
|
213
245
|
)
|
|
214
|
-
return False
|
|
215
|
-
|
|
216
|
-
# Success: trust this device/IP for the configured duration
|
|
217
|
-
session[MFA_VERIFICATION_TIME] = session.now()
|
|
218
|
-
ip_list = list({*(session.get(MFA_TRUSTED_IP_LIST) or []), cherrypy.request.remote.ip})
|
|
219
|
-
# Cap the list to avoid unbounded growth (keep most recent N)
|
|
220
|
-
max_ips = self._max_trusted_ips()
|
|
221
|
-
if len(ip_list) > max_ips:
|
|
222
|
-
ip_list = ip_list[-max_ips:]
|
|
223
|
-
session[MFA_TRUSTED_IP_LIST] = ip_list
|
|
224
|
-
|
|
225
|
-
# Clear the one-time code
|
|
226
|
-
session[MFA_CODE] = None
|
|
227
|
-
session[MFA_CODE_TIME] = None
|
|
228
|
-
session[MFA_CODE_ATTEMPT] = 0
|
|
229
|
-
|
|
230
|
-
# Honor “remember this device” option by making the session persistent
|
|
231
|
-
if hasattr(cherrypy.tools, 'sessions_timeout'):
|
|
232
|
-
try:
|
|
233
|
-
cherrypy.tools.sessions_timeout.set_persistent(bool(persistent))
|
|
234
|
-
except Exception:
|
|
235
|
-
pass
|
|
236
|
-
|
|
237
|
-
# Rotate session id to prevent fixation
|
|
238
|
-
session.regenerate()
|
|
239
|
-
cherrypy.log(
|
|
240
|
-
f'verification successful user={cherrypy.request.login} ip={cherrypy.request.remote.ip}', context='MFA'
|
|
241
|
-
)
|
|
242
246
|
return True
|
|
243
247
|
|
|
244
248
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Internationalisation tool for cherrypy
|
|
2
|
-
# Copyright (C) 2012-
|
|
2
|
+
# Copyright (C) 2012-2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Ratelimit tools for cherrypy
|
|
2
|
-
# Copyright (C) 2022-
|
|
2
|
+
# Copyright (C) 2022-2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -22,6 +22,8 @@ from collections import namedtuple
|
|
|
22
22
|
|
|
23
23
|
import cherrypy
|
|
24
24
|
|
|
25
|
+
from cherrypy_foundation.sessions import session_lock
|
|
26
|
+
|
|
25
27
|
Tracker = namedtuple('Tracker', ['token', 'hits', 'timeout'])
|
|
26
28
|
|
|
27
29
|
|
|
@@ -148,9 +150,27 @@ class FileRateLimit(_DataStore):
|
|
|
148
150
|
class Ratelimit(cherrypy.Tool):
|
|
149
151
|
CONTEXT = 'TOOLS.RATELIMIT'
|
|
150
152
|
|
|
153
|
+
_datastores = {}
|
|
154
|
+
|
|
151
155
|
def __init__(self, priority=60):
|
|
152
156
|
super().__init__('before_handler', self.check_ratelimit, 'ratelimit', priority)
|
|
153
157
|
|
|
158
|
+
def _get_datastore(self, storage_class, **kwargs):
|
|
159
|
+
"""
|
|
160
|
+
Create new datastore or return existing datastore.
|
|
161
|
+
"""
|
|
162
|
+
# Default to RAM if storage class is not defined.
|
|
163
|
+
if storage_class is None:
|
|
164
|
+
kwargs = {}
|
|
165
|
+
storage_class = RamRateLimit
|
|
166
|
+
# Lookup for matching storage.
|
|
167
|
+
key = (storage_class, str(kwargs))
|
|
168
|
+
datastore = self._datastores.get(key)
|
|
169
|
+
if datastore is None:
|
|
170
|
+
# Create new storage if not found.
|
|
171
|
+
self._datastores[key] = datastore = storage_class(**kwargs)
|
|
172
|
+
return datastore
|
|
173
|
+
|
|
154
174
|
def check_ratelimit(
|
|
155
175
|
self,
|
|
156
176
|
delay=3600,
|
|
@@ -161,7 +181,8 @@ class Ratelimit(cherrypy.Tool):
|
|
|
161
181
|
methods=None,
|
|
162
182
|
debug=False,
|
|
163
183
|
hit=1,
|
|
164
|
-
|
|
184
|
+
storage_class=None,
|
|
185
|
+
**storage_kwargs,
|
|
165
186
|
):
|
|
166
187
|
"""
|
|
167
188
|
Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request). After 25 request within the same hour.
|
|
@@ -174,11 +195,10 @@ class Ratelimit(cherrypy.Tool):
|
|
|
174
195
|
scope: if specify, define the scope of rate limit. Default to path_info.
|
|
175
196
|
methods: if specify, only the methods in the list will be rate limited.
|
|
176
197
|
"""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
# Check if limit is enabled
|
|
198
|
+
if delay <= 0:
|
|
199
|
+
raise ValueError('Invalid delay: %s' % delay)
|
|
180
200
|
if limit <= 0:
|
|
181
|
-
|
|
201
|
+
raise ValueError('Invalid limit: %s' % limit)
|
|
182
202
|
|
|
183
203
|
# Check if this 'method' should be rate limited
|
|
184
204
|
request = cherrypy.request
|
|
@@ -187,13 +207,8 @@ class Ratelimit(cherrypy.Tool):
|
|
|
187
207
|
cherrypy.log(f'skip rate limit for HTTP method {request.method}', context=self.CONTEXT)
|
|
188
208
|
return
|
|
189
209
|
|
|
190
|
-
#
|
|
191
|
-
datastore =
|
|
192
|
-
if datastore is None:
|
|
193
|
-
# Create storage using storage class
|
|
194
|
-
storage_class = conf.get('storage_class', RamRateLimit)
|
|
195
|
-
datastore = storage_class(**conf)
|
|
196
|
-
self._ratelimit_datastore = datastore
|
|
210
|
+
# Create storage using storage class
|
|
211
|
+
datastore = self._get_datastore(storage_class, **storage_kwargs)
|
|
197
212
|
|
|
198
213
|
# Identifier: prefer authenticated user; else client IP
|
|
199
214
|
identifier = getattr(cherrypy.serving.request, 'login', None) or cherrypy.request.remote.ip
|
|
@@ -226,7 +241,8 @@ class Ratelimit(cherrypy.Tool):
|
|
|
226
241
|
cherrypy.log(f'block access to path_info={request.path_info}', context=self.CONTEXT)
|
|
227
242
|
if logout:
|
|
228
243
|
if hasattr(cherrypy.serving, 'session'):
|
|
229
|
-
|
|
244
|
+
with session_lock() as session:
|
|
245
|
+
session.clear()
|
|
230
246
|
raise cherrypy.HTTPRedirect("/")
|
|
231
247
|
raise cherrypy.HTTPError(return_status)
|
|
232
248
|
|
|
@@ -240,12 +256,10 @@ class Ratelimit(cherrypy.Tool):
|
|
|
240
256
|
|
|
241
257
|
def reset(self):
|
|
242
258
|
"""
|
|
243
|
-
Used to reset the ratelimit.
|
|
259
|
+
Used to reset the ratelimit. (for unit test)
|
|
244
260
|
"""
|
|
245
|
-
datastore
|
|
246
|
-
|
|
247
|
-
return
|
|
248
|
-
datastore.reset()
|
|
261
|
+
for datastore in self._datastores.values():
|
|
262
|
+
datastore.reset()
|
|
249
263
|
|
|
250
264
|
|
|
251
265
|
cherrypy.tools.ratelimit = Ratelimit()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Session timeout tool for cherrypy
|
|
2
|
-
# Copyright (C) 2012-
|
|
2
|
+
# Copyright (C) 2012-2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -21,6 +21,8 @@ import time
|
|
|
21
21
|
import cherrypy
|
|
22
22
|
from cherrypy.lib import httputil
|
|
23
23
|
|
|
24
|
+
from cherrypy_foundation.sessions import session_lock
|
|
25
|
+
|
|
24
26
|
SESSION_PERSISTENT = '_session_persistent'
|
|
25
27
|
SESSION_START_TIME = '_session_start_time'
|
|
26
28
|
|
|
@@ -57,13 +59,13 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
57
59
|
absolute_timeout = int(cfg.get('tools.sessions_timeout.absolute_timeout', SESSION_DEFAULT_ABSOLUTE_TIMEOUT))
|
|
58
60
|
return idle_timeout, persistent_timeout, absolute_timeout
|
|
59
61
|
|
|
60
|
-
def _set_cookie_max_age(self, max_age: int) -> None:
|
|
62
|
+
def _set_cookie_max_age(self, session, max_age: int) -> None:
|
|
61
63
|
"""Adjust only Max-Age and Expires for the session cookie, keep other flags intact."""
|
|
62
64
|
cookie_name = self._cookie_name()
|
|
63
65
|
cookie = cherrypy.serving.response.cookie
|
|
64
66
|
# Ensure the cookie key exists (CherryPy sets it when session id is issued/regenerated).
|
|
65
67
|
if cookie_name not in cookie:
|
|
66
|
-
cookie[cookie_name] =
|
|
68
|
+
cookie[cookie_name] = session.id
|
|
67
69
|
cookie[cookie_name]['max-age'] = str(max(0, max_age))
|
|
68
70
|
cookie[cookie_name]['expires'] = httputil.HTTPDate(time.time() + max(0, max_age))
|
|
69
71
|
|
|
@@ -73,24 +75,21 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
73
75
|
return 0
|
|
74
76
|
return int(math.ceil(seconds / 60.0))
|
|
75
77
|
|
|
76
|
-
def _ensure_start_time(self) -> None:
|
|
78
|
+
def _ensure_start_time(self, session) -> None:
|
|
77
79
|
"""Ensure the session has a stable start time anchor for absolute timeout."""
|
|
78
|
-
session = cherrypy.serving.session
|
|
79
80
|
if SESSION_START_TIME not in session or not isinstance(session.get(SESSION_START_TIME), datetime.datetime):
|
|
80
81
|
session[SESSION_START_TIME] = session.now()
|
|
81
82
|
|
|
82
|
-
def _expire_and_restart(self, make_persistent: bool | None = None) -> None:
|
|
83
|
+
def _expire_and_restart(self, session, make_persistent: bool | None = None) -> None:
|
|
83
84
|
"""Expire current session and start a fresh one. Optionally set persistent flag."""
|
|
84
|
-
session = cherrypy.serving.session
|
|
85
85
|
session.clear()
|
|
86
86
|
session.regenerate()
|
|
87
87
|
session[SESSION_START_TIME] = session.now()
|
|
88
88
|
if make_persistent is not None:
|
|
89
89
|
session[SESSION_PERSISTENT] = bool(make_persistent)
|
|
90
90
|
|
|
91
|
-
def _update_session_timeout(self) -> None:
|
|
92
|
-
|
|
93
|
-
self._ensure_start_time()
|
|
91
|
+
def _update_session_timeout(self, session) -> None:
|
|
92
|
+
self._ensure_start_time(session)
|
|
94
93
|
|
|
95
94
|
now: datetime.datetime = session.now()
|
|
96
95
|
start: datetime.datetime = session[SESSION_START_TIME]
|
|
@@ -120,9 +119,9 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
120
119
|
# Expired due to either sliding window or absolute cap
|
|
121
120
|
# Start a fresh session; do NOT automatically re-mark persistent here.
|
|
122
121
|
# Authentication/Remember me logic elsewhere can call set_persistent(True) again after login.
|
|
123
|
-
self._expire_and_restart()
|
|
122
|
+
self._expire_and_restart(session)
|
|
124
123
|
# After regeneration, set a sensible session.timeout baseline:
|
|
125
|
-
|
|
124
|
+
session.timeout = idle_timeout_min
|
|
126
125
|
return
|
|
127
126
|
|
|
128
127
|
# Apply per-request remaining minutes to CherryPy's session timeout
|
|
@@ -130,7 +129,7 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
130
129
|
|
|
131
130
|
if is_persistent:
|
|
132
131
|
# Keep cookie lifetime aligned with remaining time so it can survive browser restarts.
|
|
133
|
-
self._set_cookie_max_age(remaining_sec)
|
|
132
|
+
self._set_cookie_max_age(session, remaining_sec)
|
|
134
133
|
else:
|
|
135
134
|
# For non-persistent, let CherryPy manage the session cookie (session or transient cookie).
|
|
136
135
|
# If you want strict enforcement client-side too, uncomment the next line:
|
|
@@ -143,23 +142,26 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
143
142
|
return
|
|
144
143
|
|
|
145
144
|
# Ensure configured values are honored (method signature kept for CherryPy Tool interface)
|
|
146
|
-
|
|
145
|
+
with session_lock() as session:
|
|
146
|
+
self._update_session_timeout(session)
|
|
147
147
|
|
|
148
148
|
def set_persistent(self, value: bool = True) -> None:
|
|
149
149
|
"""Mark current session as persistent (or not) and reset the start window."""
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
with session_lock() as session:
|
|
151
|
+
session[SESSION_PERSISTENT] = bool(value)
|
|
152
|
+
# Reset absolute window anchor when the persistence policy changes
|
|
153
|
+
session[SESSION_START_TIME] = session.now()
|
|
154
|
+
self._update_session_timeout(session)
|
|
155
155
|
|
|
156
156
|
def is_persistent(self) -> bool:
|
|
157
157
|
"""Return True if the session is marked persistent."""
|
|
158
|
-
|
|
158
|
+
with session_lock() as session:
|
|
159
|
+
return bool(session.get(SESSION_PERSISTENT, False))
|
|
159
160
|
|
|
160
161
|
def get_session_start_time(self) -> datetime.datetime | None:
|
|
161
162
|
"""Return the session start time if any."""
|
|
162
|
-
|
|
163
|
+
with session_lock() as session:
|
|
164
|
+
return session.get(SESSION_START_TIME, None)
|
|
163
165
|
|
|
164
166
|
|
|
165
167
|
cherrypy.tools.sessions_timeout = SessionsTimeout()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
# You should have received a copy of the GNU General Public License
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
import tempfile
|
|
18
18
|
from collections import namedtuple
|
|
19
19
|
from urllib.parse import urlencode
|
|
20
20
|
|
|
21
21
|
import cherrypy
|
|
22
|
+
from cherrypy.lib.sessions import FileSession
|
|
22
23
|
from cherrypy.test import helper
|
|
23
24
|
|
|
24
25
|
from .. import auth # noqa
|
|
@@ -42,7 +43,7 @@ def user_from_key_func(userkey):
|
|
|
42
43
|
return None
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
@cherrypy.tools.sessions()
|
|
46
|
+
@cherrypy.tools.sessions(locking='explicit', storage_class=FileSession)
|
|
46
47
|
@cherrypy.tools.auth(
|
|
47
48
|
user_lookup_func=user_lookup_func,
|
|
48
49
|
user_from_key_func=user_from_key_func,
|
|
@@ -68,8 +69,24 @@ class Root:
|
|
|
68
69
|
class AuthManagerTest(helper.CPWebCase):
|
|
69
70
|
interactive = False
|
|
70
71
|
|
|
72
|
+
@classmethod
|
|
73
|
+
def setup_class(cls):
|
|
74
|
+
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-session')
|
|
75
|
+
cls.session_dir = cls.tempdir.name
|
|
76
|
+
super().setup_class()
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def teardown_class(cls):
|
|
80
|
+
cls.tempdir.cleanup()
|
|
81
|
+
super().teardown_class()
|
|
82
|
+
|
|
71
83
|
@classmethod
|
|
72
84
|
def setup_server(cls):
|
|
85
|
+
cherrypy.config.update(
|
|
86
|
+
{
|
|
87
|
+
'tools.sessions.storage_path': cls.session_dir,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
73
90
|
cherrypy.tree.mount(Root(), '/')
|
|
74
91
|
|
|
75
92
|
def test_auth_redirect(self):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
# You should have received a copy of the GNU General Public License
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
|
-
|
|
18
17
|
import datetime
|
|
19
18
|
from collections import namedtuple
|
|
20
19
|
from urllib.parse import urlencode
|
|
@@ -23,6 +22,8 @@ import cherrypy
|
|
|
23
22
|
from cherrypy.lib.sessions import RamSession
|
|
24
23
|
from cherrypy.test import helper
|
|
25
24
|
|
|
25
|
+
from cherrypy_foundation.sessions import session_lock
|
|
26
|
+
|
|
26
27
|
from ..auth import AUTH_LAST_PASSWORD_AT
|
|
27
28
|
from ..auth_mfa import (
|
|
28
29
|
MFA_CODE_TIME,
|
|
@@ -64,7 +65,7 @@ def user_from_key_func(userkey):
|
|
|
64
65
|
return None
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
@cherrypy.tools.sessions()
|
|
68
|
+
@cherrypy.tools.sessions(locking='explicit')
|
|
68
69
|
@cherrypy.tools.auth(
|
|
69
70
|
user_lookup_func=user_lookup_func,
|
|
70
71
|
user_from_key_func=user_from_key_func,
|
|
@@ -103,7 +104,8 @@ class Root:
|
|
|
103
104
|
code = cherrypy.tools.auth_mfa.generate_code()
|
|
104
105
|
# Here the code should be send by email, SMS or any other means.
|
|
105
106
|
# For our test we store it in the session.
|
|
106
|
-
|
|
107
|
+
with session_lock() as session:
|
|
108
|
+
session['code'] = code
|
|
107
109
|
html += "<p>new verification code sent to your email</p>\n"
|
|
108
110
|
return html
|
|
109
111
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Copyright (C)
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2026 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -49,7 +49,7 @@ class RateLimitTest(helper.CPWebCase):
|
|
|
49
49
|
|
|
50
50
|
@classmethod
|
|
51
51
|
def setup_server(cls):
|
|
52
|
-
rate_limit_storage_class =
|
|
52
|
+
rate_limit_storage_class = None
|
|
53
53
|
if cls.rate_limit_dir:
|
|
54
54
|
rate_limit_storage_class = ratelimit.FileRateLimit
|
|
55
55
|
cherrypy.config.update(
|