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.
Files changed (34) hide show
  1. crossauth_backend/__init__.py +59 -0
  2. crossauth_backend/auth.py +132 -0
  3. crossauth_backend/common/__init__.py +2 -0
  4. crossauth_backend/common/error.py +498 -0
  5. crossauth_backend/common/interfaces.py +287 -0
  6. crossauth_backend/common/jwt.py +41 -0
  7. crossauth_backend/common/logger.py +202 -0
  8. crossauth_backend/cookieauth.py +575 -0
  9. crossauth_backend/crypto.py +457 -0
  10. crossauth_backend/oauth/__init__.py +2 -0
  11. crossauth_backend/oauth/client.py +1063 -0
  12. crossauth_backend/oauth/clientmanager.py +157 -0
  13. crossauth_backend/oauth/resserver.py +34 -0
  14. crossauth_backend/oauth/tokenconsumer.py +412 -0
  15. crossauth_backend/oauth/wellknown.py +108 -0
  16. crossauth_backend/session.py +450 -0
  17. crossauth_backend/storage.py +626 -0
  18. crossauth_backend/storageimpl/__init__.py +2 -0
  19. crossauth_backend/storageimpl/inmemorystorage.py +132 -0
  20. crossauth_backend/storageimpl/sqlalchemystorage.py +294 -0
  21. crossauth_backend/utils.py +150 -0
  22. crossauth_fastapi/__init__.py +23 -0
  23. crossauth_fastapi/fastapioauthclient.py +2185 -0
  24. crossauth_fastapi/fastapiresserver.py +300 -0
  25. crossauth_fastapi/fastapiserver.py +437 -0
  26. crossauth_fastapi/fastapiserverbase.py +62 -0
  27. crossauth_fastapi/fastapisession.py +654 -0
  28. crossauth_fastapi/fastapisessionadapter.py +94 -0
  29. pycrossauth-0.0.2.dist-info/LICENSE +203 -0
  30. pycrossauth-0.0.2.dist-info/METADATA +257 -0
  31. pycrossauth-0.0.2.dist-info/NOTICE +5 -0
  32. pycrossauth-0.0.2.dist-info/RECORD +34 -0
  33. pycrossauth-0.0.2.dist-info/WHEEL +5 -0
  34. pycrossauth-0.0.2.dist-info/top_level.txt +2 -0
@@ -0,0 +1,457 @@
1
+ # Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
2
+ import os
3
+ import base64
4
+ import json
5
+ import hashlib
6
+ import hmac
7
+ import secrets
8
+ from datetime import datetime
9
+ from typing import Optional, Dict, Any
10
+ from Crypto.Cipher import AES
11
+ from Crypto.Util.Padding import pad, unpad
12
+ from typing import TypedDict
13
+
14
+ from crossauth_backend.common.error import ErrorCode, CrossauthError
15
+ from crossauth_backend.utils import MapGetter
16
+
17
+ PBKDF2_DIGEST = os.getenv("PBKDF2_DIGEST", "sha256")
18
+ PBKDF2_ITERATIONS = int(os.getenv("PBKDF2_ITERATIONS", 600_000))
19
+ PBKDF2_KEYLENGTH = int(os.getenv("PBKDF2_KEYLENGTH", 32)) # in bytes, before base64
20
+ PBKDF2_SALTLENGTH = int(os.getenv("PBKDF2_SALTLENGTH", 16)) # in bytes, before base64
21
+
22
+ SIGN_DIGEST = "sha256"
23
+
24
+ class PasswordHash(TypedDict):
25
+ """
26
+ An object that contains all components of a hashed password. Hashing is done with PBKDF2
27
+ """
28
+
29
+ hashed_password : str
30
+ """ The actual hashed password in Base64 format """
31
+
32
+ salt : str
33
+ """ The random salt used to create the hashed password """
34
+
35
+ iterations : int
36
+ """ Number of iterations for PBKDF2 """
37
+
38
+ use_secret : bool
39
+ """ If true, secret (application secret) is also used to hash the password"""
40
+
41
+ key_len : int
42
+ """ The key length parameter passed to PBKDF2 - hash will be this number of characters long """
43
+
44
+ digest : str
45
+ """ The digest algorithm to use, eg `sha512` """
46
+
47
+
48
+ class HashOptions(TypedDict, total=False):
49
+ """
50
+ Option parameters for :class:`Crypto.passwordHash`
51
+ """
52
+
53
+ salt : str
54
+ """ A salt to prepend to the message before hashing """
55
+
56
+ encode : bool
57
+ """ Whether to Base64-URL-encode the result """
58
+
59
+ secret : str
60
+ """ A secret to append to the salt when hashing, or undefined for no secret """
61
+
62
+ iterations : int
63
+ """ Number of PBKDF2 iterations """
64
+
65
+ key_len : int
66
+ """ Length (before Base64-encoding) of the PBKDF2 key being generated """
67
+
68
+ digest : str
69
+ """ PBKDF2 digest method """
70
+
71
+ class Crypto:
72
+ """ Provides cryptographic functions """
73
+
74
+ @staticmethod
75
+ async def passwords_equal(plaintext: str, encoded_hash: str, secret: Optional[str] = None) -> bool:
76
+ """
77
+ Returns true if the plaintext password, when hashed, equals the one in the hash, using
78
+ it's hasher settings
79
+ :param str plaintext: the plaintext password
80
+ :param str encoded_hash: the previously-hashed version
81
+ :param str|None secret: if `useHash`in `encodedHash` is true, uses as a pepper for the hasher
82
+
83
+ :return: true if they are equal, false otherwise
84
+ """
85
+ hash = Crypto.decode_password_hash(encoded_hash)
86
+ secret1 : str|None = None
87
+ if hash["use_secret"]:
88
+ secret1 = secret
89
+ options : HashOptions = {}
90
+ options["salt"] = MapGetter[str].get_or_raise(hash, "salt")
91
+ options["encode"] = False
92
+ if (secret1 is not None): options["secret"] = secret1
93
+ options["iterations"] = MapGetter[int].get_or_raise(hash, "iterations")
94
+ options["key_len"] = MapGetter[int].get_or_raise(hash, "key_len")
95
+
96
+ new_hash = await Crypto.password_hash(plaintext, options)
97
+ if len(new_hash) != len(hash["hashed_password"]):
98
+ raise CrossauthError(ErrorCode.PasswordInvalid)
99
+ return hmac.compare_digest(new_hash, hash["hashed_password"])
100
+
101
+ @staticmethod
102
+ def base64_decode(encoded: str) -> str:
103
+ """
104
+ Decodes a string from base64 to UTF-8
105
+ :param str encoded: base64-encoded text
106
+
107
+ :return: URF-8 text
108
+ """
109
+ return base64.urlsafe_b64decode(Crypto.base64_pad(encoded)).decode('utf-8')
110
+
111
+ @staticmethod
112
+ def base64_encode(text: str) -> str:
113
+ """
114
+ Base64-encodes UTF-8 text
115
+ :param str: text UTF-8 text
116
+
117
+ :return: Base64 text
118
+ """
119
+ return base64.urlsafe_b64encode(text.encode('utf-8')).decode('utf-8')
120
+
121
+ @staticmethod
122
+ def decode_password_hash(hash: str) -> PasswordHash:
123
+ """
124
+ Splits a hashed password into its component parts. Return it as a :class:`PasswordHash`.
125
+
126
+ The format of the hash should be
127
+ ```
128
+ digest:int key_len:iterations:useSecret:salt:hashedPassword
129
+ ```
130
+ The hashed password part is the Base64 encoding of the PBKDF2 password.
131
+ :param str hash: the hassed password to decode. See above for format
132
+
133
+ :return: :class:`PasswordHash` object containing the deecoded hash components
134
+ """
135
+ parts = hash.split(':')
136
+ if len(parts) != 7:
137
+ raise CrossauthError(ErrorCode.InvalidHash)
138
+ if parts[0] != "pbkdf2":
139
+ raise CrossauthError(ErrorCode.UnsupportedAlgorithm)
140
+ try:
141
+ return {
142
+ "hashed_password": parts[6],
143
+ "salt": parts[5],
144
+ "use_secret": parts[4] != "0",
145
+ "iterations": int(parts[3]),
146
+ "key_len": int(parts[2]),
147
+ "digest": parts[1]
148
+ }
149
+ except Exception:
150
+ raise CrossauthError(ErrorCode.InvalidHash)
151
+
152
+ @staticmethod
153
+ def encode_password_hash(hashed_password: str, salt: str, use_secret: bool, iterations: int, key_len: int, digest: str) -> str:
154
+ """
155
+ Encodes a hashed password into the string format it is stored as.
156
+
157
+ See :func:`decodePasswordHash` for the format it is stored in.
158
+
159
+ :param str hashed_password: the Base64-encoded PBKDF2 hash of the password
160
+ :param str salt: the salt used for the password.
161
+ :param bool use_secret: whether or not to use the application secret as part
162
+ of the hash.
163
+ :param int iterations: the number of PBKDF2 iterations
164
+ :param int key_len: the key length PBKDF2 parameter - results in a hashed password this length, before Base64,
165
+ :param str digest: The digest algorithm, eg `pbkdf2`
166
+
167
+ :return: a string encode the above parameters.
168
+ """
169
+ return f"pbkdf2:{digest}:{key_len}:{iterations}:{1 if use_secret else 0}:{salt}:{hashed_password}"
170
+
171
+ @staticmethod
172
+ def random_salt() -> str:
173
+ """
174
+ Creates a random salt
175
+
176
+ :return: random salt as a base64 encoded string
177
+ """
178
+ return Crypto.random_value(PBKDF2_SALTLENGTH)
179
+
180
+ @staticmethod
181
+ def random_value(length: int) -> str:
182
+ """
183
+ Creates a random string encoded as in base64url
184
+ :param int length: length of the string to create
185
+
186
+ :return: the random value as a string. Number of bytes will be greater as it is base64 encoded.
187
+ """
188
+ return base64.urlsafe_b64encode(secrets.token_bytes(length)).decode('utf-8').replace("=", "")
189
+
190
+ Base32 = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
191
+
192
+ @staticmethod
193
+ def random_base32(length: int, dash_every: Optional[int] = None) -> str:
194
+ """
195
+ Creates a random base-23 string
196
+ :param int length: length of the string to create
197
+
198
+ :return: the random value as a string. Number of bytes will be greater as it is base64 encoded.
199
+ """
200
+ bytes = secrets.token_bytes(length)
201
+ str_value = ''.join(Crypto.Base32[i % 32] for i in bytes)
202
+ if dash_every:
203
+ return '-'.join(str_value[i:i + dash_every] for i in range(0, len(str_value), dash_every))
204
+ return str_value
205
+
206
+ @staticmethod
207
+ def uuid() -> str:
208
+ """ Creates a UUID """
209
+ return str(secrets.token_hex(16))
210
+
211
+ @staticmethod
212
+ def hash(plaintext: str) -> str:
213
+ """
214
+ Standard hash using SHA256 (not PBKDF2 or HMAC)
215
+
216
+ :param :str plaintext: text to hash
217
+
218
+ :return: the string containing the hash
219
+ """
220
+ return Crypto.sha256(plaintext)
221
+
222
+ @staticmethod
223
+ def base64url_to_base64(s : str) -> str:
224
+ s = s.translate(dict(zip(map(ord, u'-_'), u'+/')))
225
+ match (len(s) % 4):
226
+ case 0:
227
+ return s
228
+ case 1:
229
+ return s + "==="
230
+ case 2:
231
+ return s + "=="
232
+ case 3:
233
+ return "="
234
+ case _:
235
+ return s
236
+
237
+ @staticmethod
238
+ def base64_pad(s : str) -> str:
239
+ match (len(s) % 4):
240
+ case 0:
241
+ return s
242
+ case 1:
243
+ return s + "==="
244
+ case 2:
245
+ return s + "=="
246
+ case 3:
247
+ return s + "="
248
+ case _:
249
+ return s
250
+
251
+ @staticmethod
252
+ def base64_to_base64url(s : str) -> str:
253
+ return s.translate(dict(zip(map(ord, u'+/'), u'-_'))).replace("=","")
254
+
255
+ @staticmethod
256
+ def str_to_base64url(s : str) -> str:
257
+ return base64.urlsafe_b64encode(s.encode('utf-8')).decode().replace("=", "")
258
+
259
+ @staticmethod
260
+ def base64url_to_str(s: str) -> str:
261
+ return base64.urlsafe_b64decode(Crypto.base64_pad(s)).decode()
262
+
263
+ @staticmethod
264
+ def sha256(plaintext: str) -> str:
265
+ """
266
+ Standard hash using SHA256 (not PBKDF2 or HMAC)
267
+
268
+ :param str plaintext: text to hash
269
+ :return: the string containing the hash
270
+ """
271
+ d = hashlib.sha256(plaintext.encode()).digest()
272
+ return base64.urlsafe_b64encode(d).decode().replace("=", "")
273
+
274
+ @staticmethod
275
+ async def password_hash(plaintext: str, options: HashOptions = {}) -> str:
276
+ """
277
+ Hashes a password and returns it as a base64 or base64url encoded string
278
+ :param str plaintext: password to hash
279
+ :param HashOptions options:
280
+ - `salt`: salt to use. Make a random one if not passed
281
+ - `secret`: optional application secret password to apply as a pepper
282
+ - `encode`: if true, returns the full string as it should be stored in the database.
283
+
284
+ :returns: the string containing the hash and the values to decode it
285
+ """
286
+ salt = MapGetter[str].get_or_none(options, "salt") or Crypto.random_salt()
287
+ use_secret = MapGetter[bool].get(options, "use_secret", False)
288
+ secret = MapGetter[str].get(options, "sedcret", "")
289
+ salt_and_secret = f"{salt}!{secret}" if use_secret else salt
290
+
291
+ iterations = MapGetter[int].get(options, "iterations", PBKDF2_ITERATIONS)
292
+ key_len = MapGetter[int].get(options, "key_len", PBKDF2_KEYLENGTH)
293
+ digest = MapGetter[str].get(options, "digest", PBKDF2_DIGEST)
294
+
295
+ hash_bytes = hashlib.pbkdf2_hmac(digest, plaintext.encode(), salt_and_secret.encode(), iterations, dklen=key_len)
296
+ password_hash = base64.urlsafe_b64encode(hash_bytes).decode('utf-8')
297
+ if MapGetter[int].get(options, "encode", False):
298
+ password_hash = Crypto.encode_password_hash(password_hash, salt, use_secret, iterations, key_len, digest)
299
+ return password_hash
300
+
301
+ @staticmethod
302
+ def signable_token(payload: Dict[str, Any], salt: Optional[str] = None, timestamp: Optional[int] = None) -> str:
303
+ """
304
+ hash is of a JSON containing the payload, timestamp and optionally
305
+ a salt.
306
+ :param Dict[str, Any]: payload the payload to hash
307
+ :param str|None salt: optional salt (use if the payload is small)
308
+ :param int|None timestamp: time the token will expire
309
+
310
+ :return: a Base64-URL-encoded string that can be hashed.
311
+ """
312
+ if salt is None:
313
+ salt = Crypto.random_salt()
314
+ if timestamp is None:
315
+ timestamp = int(datetime.now().timestamp()*1000)
316
+ return base64.urlsafe_b64encode(json.dumps({**payload, 't': timestamp, 's': salt}).encode()).decode()
317
+
318
+ @staticmethod
319
+ def sign(payload: Dict[str, Any], secret: str, salt: Optional[str] = None, timestamp: Optional[int] = None) -> str:
320
+ """
321
+ Signs a JSON payload by creating a hash, using a secret and
322
+ optionally also a salt and timestamp
323
+
324
+ :param Dict[str, Any] payload: object to sign (will be stringified as a JSON)
325
+ :param str secret: secret key, which must be a string
326
+ :param str|None salt: optionally, a salt to concatenate with the payload (must be a string)
327
+ :paramint|None timestamp: optionally, a timestamp to include in the signed date as a Unix date
328
+
329
+ :return: Base64-url encoded hash
330
+ """
331
+ payloadStr = Crypto.signable_token(payload, salt, timestamp)
332
+ hmac_signature = hmac.new(secret.encode(), payloadStr.encode(), hashlib.sha256).hexdigest()
333
+ return f"{payloadStr}.{hmac_signature}"
334
+
335
+ @staticmethod
336
+ def sign_secure_token(payload: str, secret: str) -> str:
337
+ """
338
+ This can be called for a string payload that is a cryptographically
339
+ secure random string. No salt is added and the token is assumed to
340
+ be Base64Url already
341
+
342
+ :param str payload: string to sign
343
+ :param str secret: the secret to sign with
344
+
345
+ :return: Base64-url encoded hash
346
+ """
347
+ hmac_signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
348
+ return f"{payload}.{hmac_signature}"
349
+
350
+ @staticmethod
351
+ def unsign(signed_message: str, secret: str, expiry: Optional[int] = None) -> Dict[str, Any]:
352
+ """
353
+ Validates a signature and, if valid, return the unstringified payload
354
+ :param str signed_message: signed message (base64-url encoded)
355
+ :param str secret: secret key, which must be a string
356
+ :param int|None expiry: if set, validation will fail if the timestamp in the payload is after this date
357
+
358
+ :return: if signature is valid, the payload as an object
359
+
360
+ :raises :class:`crossauth_backend.CrossauthError`: with
361
+ :class:`ErrorCode` of `InvalidKey` if signature
362
+ is invalid or has expired.
363
+ """
364
+ parts = signed_message.split(".")
365
+ if len(parts) != 2:
366
+ raise CrossauthError(ErrorCode.InvalidKey)
367
+ msg = parts[0]
368
+ sig = parts[1]
369
+ payload = json.loads(base64.urlsafe_b64decode(msg).decode())
370
+ if expiry:
371
+ expire_time = payload['t'] + expiry * 1000
372
+ if expire_time > datetime.now().timestamp():
373
+ raise CrossauthError(ErrorCode.Expired)
374
+ new_sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
375
+ if new_sig != sig:
376
+ raise CrossauthError(ErrorCode.InvalidKey, "Signature does not match payload")
377
+ return payload
378
+
379
+ @staticmethod
380
+ def unsign_secure_token(signed_message: str, secret: str, expiry: Optional[int] = None) -> str:
381
+ """
382
+ Validates a signature signed with `signSecureToken` and, if valid,
383
+ return the unstringified payload
384
+ :param str signed_message: signed message (base64-url encoded)
385
+ :param str secret: secret key, which must be a string
386
+
387
+ :return: if signature is valid, the payload as a string
388
+
389
+ :raises :class:`crossauth_backend.CrossauthError`: with
390
+ {:class:`ErrorCode` of `InvalidKey` if signature
391
+ is invalid or has expired.
392
+ """
393
+ parts = signed_message.split(".")
394
+ if len(parts) != 2:
395
+ raise CrossauthError(ErrorCode.InvalidKey)
396
+ msg = parts[0]
397
+ sig = parts[1]
398
+ payload = msg
399
+ new_sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
400
+ if new_sig != sig:
401
+ raise CrossauthError(ErrorCode.InvalidKey, "Signature does not match payload")
402
+ return payload
403
+
404
+ @staticmethod
405
+ def xor(value: str, mask: str) -> str:
406
+ """
407
+ XOR's two arrays of base64url-encoded strings
408
+ :param str value: to XOR
409
+ :param str mask: mask to XOR it with
410
+
411
+ :return: an XOR'r string
412
+ """
413
+ value_array = base64.urlsafe_b64decode(Crypto.base64_pad(value))
414
+ mask_array = base64.urlsafe_b64decode(Crypto.base64_pad(mask))
415
+ result_array = bytes(b ^ m for b, m in zip(value_array, mask_array))
416
+ return base64.urlsafe_b64encode(result_array).decode().replace("=", "")
417
+
418
+
419
+ @staticmethod
420
+ def symmetric_encrypt(plaintext: str, key_string: str, iv : bytes|None = None) -> str:
421
+ """
422
+ Symmetric encryption using a key that must be a string
423
+
424
+ :param str plaintext: Text to encrypt
425
+ :param str key_string: the symmetric key
426
+ :param iv bytes|None: the iv value. In None, a random one is created
427
+
428
+ :return: Encrypted text Base64-url encoded.
429
+ """
430
+ if (iv is None): iv = secrets.token_bytes(16)
431
+
432
+ key = base64.urlsafe_b64decode(Crypto.base64_pad(key_string))
433
+ cipher = AES.new(key, AES.MODE_CBC, iv=iv, use_aesni=False) # type: ignore
434
+ padded_plaintext = pad(plaintext.encode(), AES.block_size)
435
+ encrypted = cipher.encrypt(padded_plaintext)
436
+
437
+ return f"{base64.urlsafe_b64encode(iv).decode().replace("=", "")}.{base64.urlsafe_b64encode(encrypted).decode().replace("=", "")}"
438
+
439
+ @staticmethod
440
+ def symmetric_decrypt(ciphertext: str, key_string: str) -> str:
441
+ """
442
+ Symmetric decryption using a key that must be a string
443
+
444
+ :param str ciphertext: Base64-url encoded ciphertext
445
+ :param str key_string: the symmetric key
446
+
447
+ :return: Decrypted text
448
+ """
449
+ key = base64.urlsafe_b64decode(Crypto.base64_pad(key_string))
450
+ parts = ciphertext.split(".")
451
+ if len(parts) != 2:
452
+ raise CrossauthError(ErrorCode.InvalidHash, "Not AES-256-CBC ciphertext")
453
+ iv = base64.urlsafe_b64decode(Crypto.base64_pad(parts[0]))
454
+ encrypted_text = base64.urlsafe_b64decode(Crypto.base64_pad(parts[1]))
455
+ cipher = AES.new(key, AES.MODE_CBC, iv=iv) # type: ignore
456
+ decrypted = unpad(cipher.decrypt(encrypted_text), AES.block_size)
457
+ return decrypted.decode()
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
2
+