pycrossauth 0.0.2__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.
- crossauth_backend/__init__.py +59 -0
- crossauth_backend/auth.py +132 -0
- crossauth_backend/common/__init__.py +2 -0
- crossauth_backend/common/error.py +498 -0
- crossauth_backend/common/interfaces.py +287 -0
- crossauth_backend/common/jwt.py +41 -0
- crossauth_backend/common/logger.py +202 -0
- crossauth_backend/cookieauth.py +575 -0
- crossauth_backend/crypto.py +457 -0
- crossauth_backend/oauth/__init__.py +2 -0
- crossauth_backend/oauth/client.py +1063 -0
- crossauth_backend/oauth/clientmanager.py +157 -0
- crossauth_backend/oauth/resserver.py +34 -0
- crossauth_backend/oauth/tokenconsumer.py +412 -0
- crossauth_backend/oauth/wellknown.py +108 -0
- crossauth_backend/session.py +450 -0
- crossauth_backend/storage.py +626 -0
- crossauth_backend/storageimpl/__init__.py +2 -0
- crossauth_backend/storageimpl/inmemorystorage.py +132 -0
- crossauth_backend/storageimpl/sqlalchemystorage.py +294 -0
- crossauth_backend/utils.py +150 -0
- crossauth_fastapi/__init__.py +23 -0
- crossauth_fastapi/fastapioauthclient.py +2185 -0
- crossauth_fastapi/fastapiresserver.py +300 -0
- crossauth_fastapi/fastapiserver.py +437 -0
- crossauth_fastapi/fastapiserverbase.py +62 -0
- crossauth_fastapi/fastapisession.py +654 -0
- crossauth_fastapi/fastapisessionadapter.py +94 -0
- pycrossauth-0.0.2.dist-info/LICENSE +203 -0
- pycrossauth-0.0.2.dist-info/METADATA +257 -0
- pycrossauth-0.0.2.dist-info/NOTICE +5 -0
- pycrossauth-0.0.2.dist-info/RECORD +34 -0
- pycrossauth-0.0.2.dist-info/WHEEL +5 -0
- pycrossauth-0.0.2.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
from crossauth_backend.crypto import Crypto
|
|
2
|
+
from crossauth_backend.common.error import CrossauthError, ErrorCode
|
|
3
|
+
from crossauth_backend.common.logger import CrossauthLogger, j
|
|
4
|
+
from crossauth_backend.common.interfaces import Key, PartialKey, KeyPrefix
|
|
5
|
+
from crossauth_backend.storage import KeyStorage, UserStorage, UserAndSecrets
|
|
6
|
+
from crossauth_backend.utils import set_parameter, ParamType
|
|
7
|
+
from crossauth_backend.storage import UserStorage, UserStorageGetOptions
|
|
8
|
+
from typing import Mapping, Any, TypedDict, Literal, NotRequired, Optional, Callable, NamedTuple
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from nulltype import NullType, Null
|
|
11
|
+
CSRF_LENGTH = 16
|
|
12
|
+
SESSIONID_LENGTH = 16
|
|
13
|
+
|
|
14
|
+
class CookieOptions(TypedDict, total=False):
|
|
15
|
+
"""
|
|
16
|
+
Optional parameters when setting cookies,
|
|
17
|
+
|
|
18
|
+
These match the HTTP cookie parameters of the same name.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
domain : str
|
|
22
|
+
expires : datetime
|
|
23
|
+
maxAge : int
|
|
24
|
+
httpOnly : bool
|
|
25
|
+
path : str
|
|
26
|
+
secure : bool
|
|
27
|
+
sameSite : bool | Literal["lax", "strict", "none"]
|
|
28
|
+
|
|
29
|
+
def to_cookie_serialize_options(options: CookieOptions) -> Mapping[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
**vars(options),
|
|
32
|
+
'path': options["path"] if "path" in options else "/"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Cookie(TypedDict, total=True):
|
|
36
|
+
""" Object encapsulating a cookie name, value and options. """
|
|
37
|
+
|
|
38
|
+
name : str
|
|
39
|
+
value : str
|
|
40
|
+
options : CookieOptions
|
|
41
|
+
|
|
42
|
+
class DoubleSubmitCsrfTokenOptions(CookieOptions):
|
|
43
|
+
""" Options for double-submit csrf tokens """
|
|
44
|
+
|
|
45
|
+
cookie_name : NotRequired[str]
|
|
46
|
+
header_name : NotRequired[str]
|
|
47
|
+
secret: NotRequired[str]
|
|
48
|
+
|
|
49
|
+
class DoubleSubmitCsrfToken:
|
|
50
|
+
"""
|
|
51
|
+
Class for creating and validating CSRF tokens according to the double-submit cookie pattern.
|
|
52
|
+
|
|
53
|
+
CSRF token is send as a cookie plus either a header or a hidden form field.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def header_name(self):
|
|
58
|
+
return self._header_name
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def cookie_name(self):
|
|
62
|
+
return self._cookie_name
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def domain(self):
|
|
66
|
+
return self._domain
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def httpOnly(self):
|
|
70
|
+
return self._httpOnly
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def path(self):
|
|
74
|
+
return self._path
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def secure(self):
|
|
78
|
+
return self._secure
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def sameSite(self):
|
|
82
|
+
return self._sameSite
|
|
83
|
+
|
|
84
|
+
def __init__(self, options: DoubleSubmitCsrfTokenOptions = DoubleSubmitCsrfTokenOptions()):
|
|
85
|
+
"""
|
|
86
|
+
Constructor
|
|
87
|
+
|
|
88
|
+
:param DoubleSubmitCsrfTokenOptions options: See :class:`DoubleSubmitCsrfTokenOptions`
|
|
89
|
+
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
self._header_name = "X-CROSSAUTH-CSRF"
|
|
93
|
+
self._cookie_name = options["cookie_name"] if "cookie_name" in options else "CSRFTOKEN"
|
|
94
|
+
self._domain = options["domain"] if "domain" in options else None
|
|
95
|
+
self._httpOnly = options["httpOnly"] if "httpOnly" in options else False
|
|
96
|
+
self._path = options["path"] if "path" in options else "/"
|
|
97
|
+
self._secure = options["secure"] if "secure" in options else True
|
|
98
|
+
self._sameSite = options["sameSite"] if "sameSite" in options else "lax"
|
|
99
|
+
|
|
100
|
+
self.__secret = ""
|
|
101
|
+
|
|
102
|
+
# header options
|
|
103
|
+
set_parameter("header_name", ParamType.String, self, options, "CSRF_HEADER_NAME", protected=True)
|
|
104
|
+
|
|
105
|
+
# cookie options
|
|
106
|
+
set_parameter("cookie_name", ParamType.String, self, options, "CSRF_COOKIE_NAME", protected=True)
|
|
107
|
+
set_parameter("domain", ParamType.String, self, options, "CSRF_COOKIE_DOMAIN", protected=True)
|
|
108
|
+
set_parameter("httpOnly", ParamType.Boolean, self, options, "CSRF_COOKIE_HTTPONLY", protected=True)
|
|
109
|
+
set_parameter("path", ParamType.String, self, options, "CSRF_COOKIE_PATH", protected=True)
|
|
110
|
+
set_parameter("secure", ParamType.Boolean, self, options, "CSRF_COOKIE_SECURE", protected=True)
|
|
111
|
+
set_parameter("sameSite", ParamType.String, self, options, "CSRF_COOKIE_SAMESITE", protected=True)
|
|
112
|
+
|
|
113
|
+
# hasher options
|
|
114
|
+
set_parameter("secret", ParamType.String, self, options, "SECRET", True)
|
|
115
|
+
|
|
116
|
+
def create_csrf_token(self) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Creates a session key and saves in storage
|
|
119
|
+
|
|
120
|
+
Date created is the current date/time on the server.
|
|
121
|
+
|
|
122
|
+
:return: a random CSRF token.
|
|
123
|
+
"""
|
|
124
|
+
return Crypto.random_value(CSRF_LENGTH)
|
|
125
|
+
|
|
126
|
+
def make_csrf_cookie(self, token: str) -> Cookie:
|
|
127
|
+
"""
|
|
128
|
+
Returns a :class:`Cookie` object with the given session key.
|
|
129
|
+
|
|
130
|
+
:param str token: the value of the csrf token, with signature
|
|
131
|
+
|
|
132
|
+
:return a :class:`Cookie` object,
|
|
133
|
+
"""
|
|
134
|
+
cookie_value = Crypto.sign_secure_token(token, self.__secret)
|
|
135
|
+
options : CookieOptions = {
|
|
136
|
+
"path": self.path,
|
|
137
|
+
"secure": self.secure,
|
|
138
|
+
"httpOnly": self.httpOnly}
|
|
139
|
+
if (self.domain is not None): options["domain"] = self.domain
|
|
140
|
+
options["sameSite"] = self.sameSite
|
|
141
|
+
|
|
142
|
+
return Cookie(name=self.cookie_name, value=cookie_value, options=options)
|
|
143
|
+
|
|
144
|
+
def make_csrf_form_or_header_token(self, token: str) -> str:
|
|
145
|
+
return self.mask_csrf_token(token)
|
|
146
|
+
|
|
147
|
+
def unsign_cookie(self, cookie_value: str) -> str:
|
|
148
|
+
return Crypto.unsign_secure_token(cookie_value, self.__secret)
|
|
149
|
+
|
|
150
|
+
def make_csrf_cookie_string(self, cookie_value: str) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Takes a session ID and creates a string representation of the cookie (value of the HTTP `Cookie` header).
|
|
153
|
+
|
|
154
|
+
:param str cookie_value the value to put in the cookie
|
|
155
|
+
|
|
156
|
+
:return: a string representation of the cookie and options.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
cookie = f"{self.cookie_name}={cookie_value}; SameSite={self.sameSite}"
|
|
160
|
+
if self.domain:
|
|
161
|
+
cookie += f"; {self.domain}"
|
|
162
|
+
if self.path:
|
|
163
|
+
cookie += f"; {self.path}"
|
|
164
|
+
if self.httpOnly:
|
|
165
|
+
cookie += "; httpOnly"
|
|
166
|
+
if self.secure:
|
|
167
|
+
cookie += "; secure"
|
|
168
|
+
return cookie
|
|
169
|
+
|
|
170
|
+
def mask_csrf_token(self, token: str) -> str:
|
|
171
|
+
mask = Crypto.random_value(CSRF_LENGTH)
|
|
172
|
+
masked_token = Crypto.xor(token, mask)
|
|
173
|
+
return f"{mask}.{masked_token}"
|
|
174
|
+
|
|
175
|
+
def unmask_csrf_token(self, mask_and_token: str) -> str:
|
|
176
|
+
parts = mask_and_token.split(".")
|
|
177
|
+
if len(parts) != 2:
|
|
178
|
+
raise CrossauthError(ErrorCode.InvalidCsrf, "CSRF token in header or form not in correct format")
|
|
179
|
+
mask = parts[0]
|
|
180
|
+
masked_token = parts[1]
|
|
181
|
+
return Crypto.xor(masked_token, mask)
|
|
182
|
+
|
|
183
|
+
def validate_double_submit_csrf_token(self, cookie_value: str, form_or_header_name: str) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Validates the passed CSRF token.
|
|
186
|
+
|
|
187
|
+
To be valid:
|
|
188
|
+
- The signature in the cookie must match the token in the cookie
|
|
189
|
+
- The token in the cookie must matched the value in the form or header after unmasking
|
|
190
|
+
|
|
191
|
+
:param str cookie_value: the CSRF cookie value to validate.
|
|
192
|
+
:param str form_or_header_name the value from the csrf_token form header or the X-CROSSAUTH-CSRF header.
|
|
193
|
+
|
|
194
|
+
:raises :class:`crossauth_backend.CrossauthError` with :class:`ErrorCode` of `InvalidKey`
|
|
195
|
+
"""
|
|
196
|
+
form_or_header_token = self.unmask_csrf_token(form_or_header_name)
|
|
197
|
+
try:
|
|
198
|
+
cookie_token = Crypto.unsign_secure_token(cookie_value, self.__secret)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
CrossauthLogger.logger().error(j({"err": str(e)}))
|
|
201
|
+
raise CrossauthError(ErrorCode.InvalidCsrf, "Invalid CSRF cookie")
|
|
202
|
+
|
|
203
|
+
if cookie_token != form_or_header_token:
|
|
204
|
+
CrossauthLogger.logger().warn(j({"msg": "Invalid CSRF token received - form/header value does not match",
|
|
205
|
+
"csrfCookieHash": Crypto.hash(cookie_value)}))
|
|
206
|
+
raise CrossauthError(ErrorCode.InvalidCsrf)
|
|
207
|
+
|
|
208
|
+
def validate_csrf_cookie(self, cookie_value: str) -> str:
|
|
209
|
+
"""
|
|
210
|
+
Validates the passed CSRF cookie (doesn't check it matches the token, just that the cookie is valid).
|
|
211
|
+
|
|
212
|
+
To be valid:
|
|
213
|
+
- The signature in the cookie must match the token in the cookie
|
|
214
|
+
- The token in the cookie must matched the value in the form or header after unmasking
|
|
215
|
+
|
|
216
|
+
:param str cookie_value: the CSRF cookie value to validate.
|
|
217
|
+
|
|
218
|
+
:raises :class:`crossauth_backend.CrossauthError` with :class:`ErrorCode` of `InvalidKey`
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
return Crypto.unsign_secure_token(cookie_value, self.__secret)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
CrossauthLogger.logger().error(j({"err": str(e)}))
|
|
224
|
+
raise CrossauthError(ErrorCode.InvalidCsrf, "Invalid CSRF cookie")
|
|
225
|
+
|
|
226
|
+
class UserAndKey(NamedTuple):
|
|
227
|
+
user: UserAndSecrets|None
|
|
228
|
+
key: Key
|
|
229
|
+
|
|
230
|
+
class SessionCookieOptions(CookieOptions, total=False): # Also inherit from TokenEmailerOptions
|
|
231
|
+
"""
|
|
232
|
+
Options for double-submit csrf tokens
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
user_storage: UserStorage
|
|
236
|
+
"""
|
|
237
|
+
If user login is enabled, you must provide the user storage class
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
cookie_name: str
|
|
241
|
+
"""Name of cookie. Defaults to "CSRFTOKEN" """
|
|
242
|
+
|
|
243
|
+
hash_session_id: bool
|
|
244
|
+
"""If true, session IDs are stored in hashed form in the key storage. Default False."""
|
|
245
|
+
|
|
246
|
+
idle_timeout: int
|
|
247
|
+
"""
|
|
248
|
+
If non zero, sessions will time out after self number of seconds have elapsed without activity.
|
|
249
|
+
Default 0 (no timeout)
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
persist: bool
|
|
253
|
+
"""If true, sessions cookies will be persisted between browser sessions. Default True"""
|
|
254
|
+
|
|
255
|
+
secret: str
|
|
256
|
+
"""App secret"""
|
|
257
|
+
|
|
258
|
+
filter_function: Callable[[Key], bool]
|
|
259
|
+
"""
|
|
260
|
+
self will be called with the session key to filter sessions
|
|
261
|
+
before returning. Function should return true if the session is valid or false otherwise.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
class CookieReturn(NamedTuple):
|
|
265
|
+
userid: str|int|None
|
|
266
|
+
value: str
|
|
267
|
+
created: datetime
|
|
268
|
+
expires: datetime | None
|
|
269
|
+
|
|
270
|
+
class SessionCookie:
|
|
271
|
+
"""
|
|
272
|
+
Class for session management using a session id cookie.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def idle_timeout(self):
|
|
277
|
+
return self._idle_timeout
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def cookie_name(self):
|
|
281
|
+
return self._cookie_name
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def maxAge(self):
|
|
285
|
+
return self._maxAge
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def domain(self):
|
|
289
|
+
return self._domain
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def httpOnly(self):
|
|
293
|
+
return self._httpOnly
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def path(self):
|
|
297
|
+
return self._path
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def secure(self):
|
|
301
|
+
return self._secure
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def sameSite(self):
|
|
305
|
+
return self._sameSite
|
|
306
|
+
|
|
307
|
+
def __init__(self, key_storage : KeyStorage, options: SessionCookieOptions = {}):
|
|
308
|
+
"""
|
|
309
|
+
Constructor
|
|
310
|
+
|
|
311
|
+
:param crossauth_backend.KeyStorage key_storage: where to store session keys
|
|
312
|
+
|
|
313
|
+
"""
|
|
314
|
+
self.__persist : bool = True
|
|
315
|
+
self._idle_timeout : int = 0
|
|
316
|
+
self.__filter_function : Callable[[Key], bool] | None = None
|
|
317
|
+
|
|
318
|
+
## cookie settings
|
|
319
|
+
self._cookie_name : str = "SESSIONID"
|
|
320
|
+
self._maxAge : int = 60*60*24*4; # 4 weeks
|
|
321
|
+
self._domain : str | None = None
|
|
322
|
+
self._httpOnly : bool = False
|
|
323
|
+
self._path : str = "/"
|
|
324
|
+
self._secure : bool = True
|
|
325
|
+
self._sameSite : bool | Literal["lax", "strict", "none"] | None = "lax"
|
|
326
|
+
|
|
327
|
+
## hasher settings
|
|
328
|
+
self.__secret : str = ""
|
|
329
|
+
|
|
330
|
+
self.__user_storage = options["user_storage"] if "user_storage" in options else None
|
|
331
|
+
self.key_storage = key_storage
|
|
332
|
+
set_parameter("idle_timeout", ParamType.Number, self, options, "SESSION_IDLE_TIMEOUT", protected=True)
|
|
333
|
+
set_parameter("persist", ParamType.Number, self, options, "PERSIST_SESSION_ID")
|
|
334
|
+
self.filter_function = options['filterFunction'] if 'filterFunction' in options else None
|
|
335
|
+
|
|
336
|
+
# cookie settings
|
|
337
|
+
set_parameter("cookie_name", ParamType.String, self, options, "SESSION_COOKIE_NAME", protected=True)
|
|
338
|
+
set_parameter("maxAge", ParamType.String, self, options, "SESSION_COOKIE_maxAge", protected=True)
|
|
339
|
+
set_parameter("domain", ParamType.String, self, options, "SESSION_COOKIE_DOMAIN", protected=True)
|
|
340
|
+
set_parameter("httpOnly", ParamType.Boolean, self, options, "SESSIONCOOKIE_HTTPONLY", protected=True)
|
|
341
|
+
set_parameter("path", ParamType.String, self, options, "SESSION_COOKIE_PATH", protected=True)
|
|
342
|
+
set_parameter("secure", ParamType.Boolean, self, options, "SESSION_COOKIE_SECURE", protected=True)
|
|
343
|
+
set_parameter("sameSite", ParamType.String, self, options, "SESSION_COOKIE_SAMESITE", protected=True)
|
|
344
|
+
|
|
345
|
+
# hasher settings
|
|
346
|
+
self.__secret = options["secret"] if "secret" in options else ""
|
|
347
|
+
|
|
348
|
+
def _expiry(self, date_created: datetime) -> datetime | None:
|
|
349
|
+
expires = None
|
|
350
|
+
if self.maxAge > 0:
|
|
351
|
+
expires = date_created + timedelta(0, self.maxAge)
|
|
352
|
+
return expires
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def hash_session_id(session_id: str) -> str:
|
|
356
|
+
"""
|
|
357
|
+
Returns a hash of a session ID, with the session ID prefix for storing
|
|
358
|
+
in the storage table.
|
|
359
|
+
:param str session_id the session ID to hash
|
|
360
|
+
|
|
361
|
+
:return: a base64-url-encoded string that can go into the storage
|
|
362
|
+
"""
|
|
363
|
+
return KeyPrefix.session + Crypto.hash(session_id)
|
|
364
|
+
|
|
365
|
+
async def create_session_key(self, userid: str | int | None, extra_fields: Mapping[str, Any] = {}) -> Key:
|
|
366
|
+
"""
|
|
367
|
+
Creates a session key and saves in storage
|
|
368
|
+
|
|
369
|
+
Date created is the current date/time on the server.
|
|
370
|
+
|
|
371
|
+
In the unlikely event of the key already existing, it is retried up to 10 times before throwing
|
|
372
|
+
an error with ErrorCode.KeyExists
|
|
373
|
+
|
|
374
|
+
:param str | int | None userid: the user ID to store with the session key.
|
|
375
|
+
:param Dict[str, Any]|None extra_fields: Any fields in here will also be added to the session
|
|
376
|
+
record
|
|
377
|
+
|
|
378
|
+
:return: the new session key
|
|
379
|
+
|
|
380
|
+
:raises :class:`crossauth_backend.CrossauthError`: with
|
|
381
|
+
:class:`ErrorCode` `KeyExists` if maximum
|
|
382
|
+
attempts exceeded trying to create a unique session id
|
|
383
|
+
"""
|
|
384
|
+
max_tries = 10
|
|
385
|
+
num_tries = 0
|
|
386
|
+
session_id = Crypto.random_value(SESSIONID_LENGTH)
|
|
387
|
+
date_created = datetime.now()
|
|
388
|
+
expires = self._expiry(date_created)
|
|
389
|
+
succeeded = False
|
|
390
|
+
|
|
391
|
+
extra_fields_copy = {**extra_fields}
|
|
392
|
+
while num_tries < max_tries and not succeeded:
|
|
393
|
+
hashed_session_id = self.hash_session_id(session_id)
|
|
394
|
+
try:
|
|
395
|
+
if self.idle_timeout > 0 and userid:
|
|
396
|
+
extra_fields_copy['lastActivity'] = datetime.now()
|
|
397
|
+
data : str|None = None
|
|
398
|
+
if ("data" in extra_fields):
|
|
399
|
+
data = extra_fields["data"]
|
|
400
|
+
extra_fields = {**extra_fields}
|
|
401
|
+
del extra_fields["data"]
|
|
402
|
+
await self.key_storage.save_key(userid, hashed_session_id, date_created, expires, data, extra_fields)
|
|
403
|
+
succeeded = True
|
|
404
|
+
except Exception as e:
|
|
405
|
+
ce = CrossauthError.as_crossauth_error(e)
|
|
406
|
+
if ce.code in (ErrorCode.KeyExists, ErrorCode.InvalidKey):
|
|
407
|
+
num_tries += 1
|
|
408
|
+
session_id = Crypto.random_value(SESSIONID_LENGTH)
|
|
409
|
+
if num_tries > max_tries:
|
|
410
|
+
CrossauthLogger.logger().error({"msg": "Max attempts exceeded trying to create session ID"})
|
|
411
|
+
raise CrossauthError(ErrorCode.KeyExists)
|
|
412
|
+
else:
|
|
413
|
+
CrossauthLogger.logger().debug(j({"err": ce}))
|
|
414
|
+
raise e
|
|
415
|
+
|
|
416
|
+
key : Key = {
|
|
417
|
+
"value": session_id,
|
|
418
|
+
"created": date_created,
|
|
419
|
+
"expires": expires or Null,
|
|
420
|
+
}
|
|
421
|
+
if (userid is not None): key["userid"] = userid
|
|
422
|
+
return key
|
|
423
|
+
|
|
424
|
+
def make_cookie(self, session_key: Key, persist: Optional[bool] = None) -> Cookie:
|
|
425
|
+
"""
|
|
426
|
+
Returns a :class:`Cookie` object with the given session key.
|
|
427
|
+
|
|
428
|
+
This class is compatible, for example, with Express.
|
|
429
|
+
|
|
430
|
+
:param crossauth_backend.Key session_key: the value of the session key
|
|
431
|
+
:param bool|None persist: if passed, overrides the persistSessionId setting
|
|
432
|
+
|
|
433
|
+
:return: a :class:`Cookie` object,
|
|
434
|
+
|
|
435
|
+
"""
|
|
436
|
+
signed_value = Crypto.sign_secure_token( session_key['value'], self.__secret)
|
|
437
|
+
options : CookieOptions = {}
|
|
438
|
+
if persist is None:
|
|
439
|
+
persist = self.__persist
|
|
440
|
+
if self.domain:
|
|
441
|
+
options['domain'] = self.domain
|
|
442
|
+
if 'expires' in session_key and type(session_key['expires']) != NullType and persist:
|
|
443
|
+
options['expires'] = session_key['expires'] # type: ignore
|
|
444
|
+
if self.path:
|
|
445
|
+
options['path'] = self.path
|
|
446
|
+
options['sameSite'] = self.sameSite
|
|
447
|
+
if self.httpOnly:
|
|
448
|
+
options['httpOnly'] = self.httpOnly
|
|
449
|
+
if self.secure:
|
|
450
|
+
options['secure'] = self.secure
|
|
451
|
+
return {
|
|
452
|
+
'name': self.cookie_name,
|
|
453
|
+
'value': signed_value,
|
|
454
|
+
'options': options
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
def make_cookie_string(self, cookie: Cookie) -> str:
|
|
458
|
+
"""
|
|
459
|
+
Takes a session ID and creates a string representation of the cookie
|
|
460
|
+
(value of the HTTP `Cookie` header).
|
|
461
|
+
|
|
462
|
+
:param Cookie cookie: the cookie vlaues to make a string from
|
|
463
|
+
|
|
464
|
+
:return: a string representation of the cookie and options.
|
|
465
|
+
"""
|
|
466
|
+
cookie_string = f"{cookie['name']}={cookie['value']}"
|
|
467
|
+
if self.sameSite:
|
|
468
|
+
cookie_string += f"; SameSite={self.sameSite}"
|
|
469
|
+
if 'expires' in cookie['options']:
|
|
470
|
+
expires = cookie['options']['expires'].strftime('%a, %d %b %Y %H:%M:%S %Z')
|
|
471
|
+
cookie_string += f"; expires={expires}"
|
|
472
|
+
if self.domain:
|
|
473
|
+
cookie_string += f"; domain={self.domain}"
|
|
474
|
+
if self.path:
|
|
475
|
+
cookie_string += f"; path={self.path}"
|
|
476
|
+
if self.httpOnly:
|
|
477
|
+
cookie_string += "; httpOnly"
|
|
478
|
+
if self.secure:
|
|
479
|
+
cookie_string += "; secure"
|
|
480
|
+
return cookie_string
|
|
481
|
+
|
|
482
|
+
async def update_session_key(self, session_key: PartialKey) -> None:
|
|
483
|
+
"""
|
|
484
|
+
Updates a session record in storage
|
|
485
|
+
:param crossauth_backend.PartialKey session_key: the fields to update. `value` must be set, and
|
|
486
|
+
will not be updated. All other defined fields will be updated.
|
|
487
|
+
|
|
488
|
+
:raises :class:`crossauth_backend.CrossauthError`: if the session does
|
|
489
|
+
not exist.
|
|
490
|
+
"""
|
|
491
|
+
if 'value' not in session_key:
|
|
492
|
+
raise CrossauthError(ErrorCode.InvalidKey, "No session when updating activity")
|
|
493
|
+
session_key['value'] = self.hash_session_id(session_key['value'])
|
|
494
|
+
await self.key_storage.update_key(session_key)
|
|
495
|
+
|
|
496
|
+
def unsign_cookie(self, cookie_value: str) -> str:
|
|
497
|
+
"""
|
|
498
|
+
Unsigns a cookie and returns the original value.
|
|
499
|
+
:param str cookie_value: the signed cookie value
|
|
500
|
+
|
|
501
|
+
:return: the unsigned value
|
|
502
|
+
|
|
503
|
+
:raises :class:`crossauth_backend.CrossauthError`: if the signature
|
|
504
|
+
is invalid.
|
|
505
|
+
"""
|
|
506
|
+
return Crypto.unsign_secure_token(cookie_value, self.__secret)
|
|
507
|
+
|
|
508
|
+
async def get_user_for_session_id(self, session_id: str, options: UserStorageGetOptions = {}) -> UserAndKey:
|
|
509
|
+
"""
|
|
510
|
+
Returns the user matching the given session key in session storage, or throws an exception.
|
|
511
|
+
|
|
512
|
+
Looks the user up in the :class:`crossauth_backend.UserStorage` instance passed to the constructor.
|
|
513
|
+
|
|
514
|
+
Undefined will also fail is CookieAuthOptions.filterFunction is defined and returns false,
|
|
515
|
+
|
|
516
|
+
:param str session_id: the value in the session cookie
|
|
517
|
+
:param crossauth_backend.UserStorageGetOptions options: See :class:`crossauth_backend.UserStorageGetOptions`
|
|
518
|
+
|
|
519
|
+
:return: a :class:`crossauth_backend.User` object, with the password hash removed, and the:class:`crossauth_backend.Key` with the unhashed
|
|
520
|
+
session_id
|
|
521
|
+
|
|
522
|
+
:raises :class:`crossauth_backend.CrossauthError`: with :class:`ErrorCode` set to `InvalidSessionId` or `Expired`.
|
|
523
|
+
"""
|
|
524
|
+
key = await self.get_session_key(session_id)
|
|
525
|
+
if not self.__user_storage:
|
|
526
|
+
return UserAndKey(None, key)
|
|
527
|
+
if 'userid' in key and type(key['userid']) is not NullType:
|
|
528
|
+
user = await self.__user_storage.get_user_by_id(key['userid'], options) # type: ignore
|
|
529
|
+
return UserAndKey(user, key)
|
|
530
|
+
else:
|
|
531
|
+
return UserAndKey(None, key)
|
|
532
|
+
|
|
533
|
+
async def get_session_key(self, session_id: str) -> Key:
|
|
534
|
+
"""
|
|
535
|
+
Returns the user matching the given session key in session storage, or throws an exception.
|
|
536
|
+
|
|
537
|
+
Looks the user up in the :class:`UserStorage` instance passed to the constructor.
|
|
538
|
+
|
|
539
|
+
Undefined will also fail is CookieAuthOptions.filterFunction is defined and returns false,
|
|
540
|
+
|
|
541
|
+
:param str session_id: the unsigned value of the session cookie
|
|
542
|
+
|
|
543
|
+
:return: a :class:`crossauth_backend.User` object, with the password hash removed.
|
|
544
|
+
|
|
545
|
+
:raises :class:`crossauth_backend.CrossauthError`: with
|
|
546
|
+
:class:`ErrorCode` set to `InvalidSessionId`,
|
|
547
|
+
`Expired` or `UserNotExist`.
|
|
548
|
+
"""
|
|
549
|
+
now = datetime.now()
|
|
550
|
+
hashed_session_id = self.hash_session_id(session_id)
|
|
551
|
+
key = await self.key_storage.get_key(hashed_session_id)
|
|
552
|
+
key['value'] = session_id # storage only has hashed version
|
|
553
|
+
if 'expires' in key:
|
|
554
|
+
expires = key['expires']
|
|
555
|
+
if type(expires) is not NullType and now > expires: # type: ignore
|
|
556
|
+
CrossauthLogger.logger().warn(j({"msg": "Session id in cookie expired in key storage", "hashedSessionCookie": Crypto.hash(session_id)}))
|
|
557
|
+
raise CrossauthError(ErrorCode.Expired)
|
|
558
|
+
if key.get('userid') and self.idle_timeout > 0 and 'lastactive' in key and now > key['lastactive'] + timedelta(0, self.idle_timeout):
|
|
559
|
+
CrossauthLogger.logger().warn(j({"msg": "Session cookie with expired idle time received", "hashedSessionCookie": Crypto.hash(session_id)}))
|
|
560
|
+
raise CrossauthError(ErrorCode.Expired)
|
|
561
|
+
if self.filter_function and not self.filter_function(key):
|
|
562
|
+
CrossauthLogger.logger().warn(j({"msg": "Filter function on session id in cookie failed", "hashedSessionCookie": Crypto.hash(session_id)}))
|
|
563
|
+
raise CrossauthError(ErrorCode.InvalidKey)
|
|
564
|
+
return key
|
|
565
|
+
|
|
566
|
+
async def delete_all_for_user(self, userid: str | int, except_key: str|None = None) -> None:
|
|
567
|
+
"""
|
|
568
|
+
Deletes all keys for the given user
|
|
569
|
+
:param str|int userid: the user to delete keys for
|
|
570
|
+
:param str|None except_key: if defined, don't delete this key
|
|
571
|
+
|
|
572
|
+
"""
|
|
573
|
+
if except_key:
|
|
574
|
+
except_key = self.hash_session_id(except_key)
|
|
575
|
+
await self.key_storage.delete_all_for_user(userid, KeyPrefix.session, except_key)
|