plain.password
Password authentication for Plain.
Usage
To enable password authentication in your Plain application, add the PasswordLoginView
to your urls.py
:
# app/urls.py
from plain.urls import path
from plain.passwords.views import PasswordLoginView
urlpatterns = [
path('login/', PasswordLoginView.as_view(), name='login'),
# ...
]
This sets up a basic login view where users can authenticate using their username and password.
FAQs
How do I customize the login form?
To customize the login form, you can subclass PasswordLoginForm
and override its fields or methods as needed. Then, set the form_class
attribute in your PasswordLoginView
to use your custom form.
# app/forms.py
from plain.passwords.forms import PasswordLoginForm
class MyCustomLoginForm(PasswordLoginForm):
# Add custom fields or override methods here
pass
# app/views.py
from plain.passwords.views import PasswordLoginView
from .forms import MyCustomLoginForm
class MyPasswordLoginView(PasswordLoginView):
form_class = MyCustomLoginForm
Update your urls.py
to use your custom view:
# app/urls.py
from plain.urls import path
from .views import MyPasswordLoginView
urlpatterns = [
path('login/', MyPasswordLoginView.as_view(), name='login'),
# ...
]
1import base64
2import binascii
3import functools
4import hashlib
5import importlib
6import math
7import warnings
8
9from plain.exceptions import ImproperlyConfigured
10from plain.runtime import settings
11from plain.utils.crypto import (
12 RANDOM_STRING_CHARS,
13 constant_time_compare,
14 get_random_string,
15 pbkdf2,
16)
17from plain.utils.module_loading import import_string
18
19
20def check_password(password, encoded, setter=None, preferred="default"):
21 """
22 Return a boolean of whether the raw password matches the three
23 part encoded digest.
24
25 If setter is specified, it'll be called when you need to
26 regenerate the password.
27 """
28 if not password:
29 return False
30
31 preferred = get_hasher(preferred)
32 try:
33 hasher = identify_hasher(encoded)
34 except ValueError:
35 # encoded is gibberish or uses a hasher that's no longer installed.
36 return False
37
38 hasher_changed = hasher.algorithm != preferred.algorithm
39 must_update = hasher_changed or preferred.must_update(encoded)
40 is_correct = hasher.verify(password, encoded)
41
42 # If the hasher didn't change (we don't protect against enumeration if it
43 # does) and the password should get updated, try to close the timing gap
44 # between the work factor of the current encoded password and the default
45 # work factor.
46 if not is_correct and not hasher_changed and must_update:
47 hasher.harden_runtime(password, encoded)
48
49 if setter and is_correct and must_update:
50 setter(password)
51 return is_correct
52
53
54def hash_password(password, salt=None, hasher="default"):
55 """
56 Turn a plain-text password into a hash for database storage
57
58 Same as encode() but generate a new random salt. If password is None then
59 return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
60 which disallows logins. Additional random string reduces chances of gaining
61 access to admin or superuser accounts. See ticket #20079 for more info.
62 """
63 if not isinstance(password, bytes | str):
64 raise TypeError(
65 f"Password must be a string or bytes, got {type(password).__qualname__}."
66 )
67 hasher = get_hasher(hasher)
68 salt = salt or hasher.salt()
69 return hasher.encode(password, salt)
70
71
72@functools.lru_cache
73def get_hashers():
74 hashers = []
75 for hasher_path in settings.PASSWORD_HASHERS:
76 hasher_cls = import_string(hasher_path)
77 hasher = hasher_cls()
78 if not getattr(hasher, "algorithm"):
79 raise ImproperlyConfigured(
80 f"hasher doesn't specify an algorithm name: {hasher_path}"
81 )
82 hashers.append(hasher)
83 return hashers
84
85
86@functools.lru_cache
87def get_hashers_by_algorithm():
88 return {hasher.algorithm: hasher for hasher in get_hashers()}
89
90
91def get_hasher(algorithm="default"):
92 """
93 Return an instance of a loaded password hasher.
94
95 If algorithm is 'default', return the default hasher. Lazily import hashers
96 specified in the project's settings file if needed.
97 """
98 if hasattr(algorithm, "algorithm"):
99 return algorithm
100
101 elif algorithm == "default":
102 return get_hashers()[0]
103
104 else:
105 hashers = get_hashers_by_algorithm()
106 try:
107 return hashers[algorithm]
108 except KeyError:
109 raise ValueError(
110 f"Unknown password hashing algorithm '{algorithm}'. "
111 "Did you specify it in the PASSWORD_HASHERS "
112 "setting?"
113 )
114
115
116def identify_hasher(encoded):
117 """
118 Return an instance of a loaded password hasher.
119
120 Identify hasher algorithm by examining encoded hash, and call
121 get_hasher() to return hasher. Raise ValueError if
122 algorithm cannot be identified, or if hasher is not loaded.
123 """
124 # Ancient versions of Plain created plain MD5 passwords and accepted
125 # MD5 passwords with an empty salt.
126 if (len(encoded) == 32 and "$" not in encoded) or (
127 len(encoded) == 37 and encoded.startswith("md5$$")
128 ):
129 algorithm = "unsalted_md5"
130 # Ancient versions of Plain accepted SHA1 passwords with an empty salt.
131 elif len(encoded) == 46 and encoded.startswith("sha1$$"):
132 algorithm = "unsalted_sha1"
133 else:
134 algorithm = encoded.split("$", 1)[0]
135 return get_hasher(algorithm)
136
137
138def mask_hash(hash, show=6, char="*"):
139 """
140 Return the given hash, with only the first ``show`` number shown. The
141 rest are masked with ``char`` for security reasons.
142 """
143 masked = hash[:show]
144 masked += char * len(hash[show:])
145 return masked
146
147
148def must_update_salt(salt, expected_entropy):
149 # Each character in the salt provides log_2(len(alphabet)) bits of entropy.
150 return len(salt) * math.log2(len(RANDOM_STRING_CHARS)) < expected_entropy
151
152
153class BasePasswordHasher:
154 """
155 Abstract base class for password hashers
156
157 When creating your own hasher, you need to override algorithm,
158 verify(), encode() and safe_summary().
159
160 PasswordHasher objects are immutable.
161 """
162
163 algorithm = None
164 library = None
165 salt_entropy = 128
166
167 def _load_library(self):
168 if self.library is not None:
169 if isinstance(self.library, tuple | list):
170 name, mod_path = self.library
171 else:
172 mod_path = self.library
173 try:
174 module = importlib.import_module(mod_path)
175 except ImportError as e:
176 raise ValueError(
177 f"Couldn't load {self.__class__.__name__!r} algorithm library: {e}"
178 )
179 return module
180 raise ValueError(
181 f"Hasher {self.__class__.__name__!r} doesn't specify a library attribute"
182 )
183
184 def salt(self):
185 """
186 Generate a cryptographically secure nonce salt in ASCII with an entropy
187 of at least `salt_entropy` bits.
188 """
189 # Each character in the salt provides
190 # log_2(len(alphabet)) bits of entropy.
191 char_count = math.ceil(self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS)))
192 return get_random_string(char_count, allowed_chars=RANDOM_STRING_CHARS)
193
194 def verify(self, password, encoded):
195 """Check if the given password is correct."""
196 raise NotImplementedError(
197 "subclasses of BasePasswordHasher must provide a verify() method"
198 )
199
200 def _check_encode_args(self, password, salt):
201 if password is None:
202 raise TypeError("password must be provided.")
203 if not salt or "$" in salt:
204 raise ValueError("salt must be provided and cannot contain $.")
205
206 def encode(self, password, salt):
207 """
208 Create an encoded database value.
209
210 The result is normally formatted as "algorithm$salt$hash" and
211 must be fewer than 128 characters.
212 """
213 raise NotImplementedError(
214 "subclasses of BasePasswordHasher must provide an encode() method"
215 )
216
217 def decode(self, encoded):
218 """
219 Return a decoded database value.
220
221 The result is a dictionary and should contain `algorithm`, `hash`, and
222 `salt`. Extra keys can be algorithm specific like `iterations` or
223 `work_factor`.
224 """
225 raise NotImplementedError(
226 "subclasses of BasePasswordHasher must provide a decode() method."
227 )
228
229 def safe_summary(self, encoded):
230 """
231 Return a summary of safe values.
232
233 The result is a dictionary and will be used where the password field
234 must be displayed to construct a safe representation of the password.
235 """
236 raise NotImplementedError(
237 "subclasses of BasePasswordHasher must provide a safe_summary() method"
238 )
239
240 def must_update(self, encoded):
241 return False
242
243 def harden_runtime(self, password, encoded):
244 """
245 Bridge the runtime gap between the work factor supplied in `encoded`
246 and the work factor suggested by this hasher.
247
248 Taking PBKDF2 as an example, if `encoded` contains 20000 iterations and
249 `self.iterations` is 30000, this method should run password through
250 another 10000 iterations of PBKDF2. Similar approaches should exist
251 for any hasher that has a work factor. If not, this method should be
252 defined as a no-op to silence the warning.
253 """
254 warnings.warn(
255 "subclasses of BasePasswordHasher should provide a harden_runtime() method"
256 )
257
258
259class PBKDF2PasswordHasher(BasePasswordHasher):
260 """
261 Secure password hashing using the PBKDF2 algorithm (recommended)
262
263 Configured to use PBKDF2 + HMAC + SHA256.
264 The result is a 64 byte binary string. Iterations may be changed
265 safely but you must rename the algorithm if you change SHA256.
266 """
267
268 algorithm = "pbkdf2_sha256"
269 iterations = 720000
270 digest = hashlib.sha256
271
272 def encode(self, password, salt, iterations=None):
273 self._check_encode_args(password, salt)
274 iterations = iterations or self.iterations
275 hash = pbkdf2(password, salt, iterations, digest=self.digest)
276 hash = base64.b64encode(hash).decode("ascii").strip()
277 return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, hash) # noqa: UP031
278
279 def decode(self, encoded):
280 algorithm, iterations, salt, hash = encoded.split("$", 3)
281 assert algorithm == self.algorithm
282 return {
283 "algorithm": algorithm,
284 "hash": hash,
285 "iterations": int(iterations),
286 "salt": salt,
287 }
288
289 def verify(self, password, encoded):
290 decoded = self.decode(encoded)
291 encoded_2 = self.encode(password, decoded["salt"], decoded["iterations"])
292 return constant_time_compare(encoded, encoded_2)
293
294 def safe_summary(self, encoded):
295 decoded = self.decode(encoded)
296 return {
297 "algorithm": decoded["algorithm"],
298 "iterations": decoded["iterations"],
299 "salt": mask_hash(decoded["salt"]),
300 "hash": mask_hash(decoded["hash"]),
301 }
302
303 def must_update(self, encoded):
304 decoded = self.decode(encoded)
305 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
306 return (decoded["iterations"] != self.iterations) or update_salt
307
308 def harden_runtime(self, password, encoded):
309 decoded = self.decode(encoded)
310 extra_iterations = self.iterations - decoded["iterations"]
311 if extra_iterations > 0:
312 self.encode(password, decoded["salt"], extra_iterations)
313
314
315class PBKDF2SHA1PasswordHasher(PBKDF2PasswordHasher):
316 """
317 Alternate PBKDF2 hasher which uses SHA1, the default PRF
318 recommended by PKCS #5. This is compatible with other
319 implementations of PBKDF2, such as openssl's
320 PKCS5_PBKDF2_HMAC_SHA1().
321 """
322
323 algorithm = "pbkdf2_sha1"
324 digest = hashlib.sha1
325
326
327class Argon2PasswordHasher(BasePasswordHasher):
328 """
329 Secure password hashing using the argon2 algorithm.
330
331 This is the winner of the Password Hashing Competition 2013-2015
332 (https://password-hashing.net). It requires the argon2-cffi library which
333 depends on native C code and might cause portability issues.
334 """
335
336 algorithm = "argon2"
337 library = "argon2"
338
339 time_cost = 2
340 memory_cost = 102400
341 parallelism = 8
342
343 def encode(self, password, salt):
344 argon2 = self._load_library()
345 params = self.params()
346 data = argon2.low_level.hash_secret(
347 password.encode(),
348 salt.encode(),
349 time_cost=params.time_cost,
350 memory_cost=params.memory_cost,
351 parallelism=params.parallelism,
352 hash_len=params.hash_len,
353 type=params.type,
354 )
355 return self.algorithm + data.decode("ascii")
356
357 def decode(self, encoded):
358 argon2 = self._load_library()
359 algorithm, rest = encoded.split("$", 1)
360 assert algorithm == self.algorithm
361 params = argon2.extract_parameters("$" + rest)
362 variety, *_, b64salt, hash = rest.split("$")
363 # Add padding.
364 b64salt += "=" * (-len(b64salt) % 4)
365 salt = base64.b64decode(b64salt).decode("latin1")
366 return {
367 "algorithm": algorithm,
368 "hash": hash,
369 "memory_cost": params.memory_cost,
370 "parallelism": params.parallelism,
371 "salt": salt,
372 "time_cost": params.time_cost,
373 "variety": variety,
374 "version": params.version,
375 "params": params,
376 }
377
378 def verify(self, password, encoded):
379 argon2 = self._load_library()
380 algorithm, rest = encoded.split("$", 1)
381 assert algorithm == self.algorithm
382 try:
383 return argon2.PasswordHasher().verify("$" + rest, password)
384 except argon2.exceptions.VerificationError:
385 return False
386
387 def safe_summary(self, encoded):
388 decoded = self.decode(encoded)
389 return {
390 "algorithm": decoded["algorithm"],
391 "variety": decoded["variety"],
392 "version": decoded["version"],
393 "memory cost": decoded["memory_cost"],
394 "time cost": decoded["time_cost"],
395 "parallelism": decoded["parallelism"],
396 "salt": mask_hash(decoded["salt"]),
397 "hash": mask_hash(decoded["hash"]),
398 }
399
400 def must_update(self, encoded):
401 decoded = self.decode(encoded)
402 current_params = decoded["params"]
403 new_params = self.params()
404 # Set salt_len to the salt_len of the current parameters because salt
405 # is explicitly passed to argon2.
406 new_params.salt_len = current_params.salt_len
407 update_salt = must_update_salt(decoded["salt"], self.salt_entropy)
408 return (current_params != new_params) or update_salt
409
410 def harden_runtime(self, password, encoded):
411 # The runtime for Argon2 is too complicated to implement a sensible
412 # hardening algorithm.
413 pass
414
415 def params(self):
416 argon2 = self._load_library()
417 # salt_len is a noop, because we provide our own salt.
418 return argon2.Parameters(
419 type=argon2.low_level.Type.ID,
420 version=argon2.low_level.ARGON2_VERSION,
421 salt_len=argon2.DEFAULT_RANDOM_SALT_LENGTH,
422 hash_len=argon2.DEFAULT_HASH_LENGTH,
423 time_cost=self.time_cost,
424 memory_cost=self.memory_cost,
425 parallelism=self.parallelism,
426 )
427
428
429class BCryptSHA256PasswordHasher(BasePasswordHasher):
430 """
431 Secure password hashing using the bcrypt algorithm (recommended)
432
433 This is considered by many to be the most secure algorithm but you
434 must first install the bcrypt library. Please be warned that
435 this library depends on native C code and might cause portability
436 issues.
437 """
438
439 algorithm = "bcrypt_sha256"
440 digest = hashlib.sha256
441 library = ("bcrypt", "bcrypt")
442 rounds = 12
443
444 def salt(self):
445 bcrypt = self._load_library()
446 return bcrypt.gensalt(self.rounds)
447
448 def encode(self, password, salt):
449 bcrypt = self._load_library()
450 password = password.encode()
451 # Hash the password prior to using bcrypt to prevent password
452 # truncation as described in #20138.
453 if self.digest is not None:
454 # Use binascii.hexlify() because a hex encoded bytestring is str.
455 password = binascii.hexlify(self.digest(password).digest())
456
457 data = bcrypt.hashpw(password, salt)
458 return "{}${}".format(self.algorithm, data.decode("ascii"))
459
460 def decode(self, encoded):
461 algorithm, empty, algostr, work_factor, data = encoded.split("$", 4)
462 assert algorithm == self.algorithm
463 return {
464 "algorithm": algorithm,
465 "algostr": algostr,
466 "checksum": data[22:],
467 "salt": data[:22],
468 "work_factor": int(work_factor),
469 }
470
471 def verify(self, password, encoded):
472 algorithm, data = encoded.split("$", 1)
473 assert algorithm == self.algorithm
474 encoded_2 = self.encode(password, data.encode("ascii"))
475 return constant_time_compare(encoded, encoded_2)
476
477 def safe_summary(self, encoded):
478 decoded = self.decode(encoded)
479 return {
480 "algorithm": decoded["algorithm"],
481 "work factor": decoded["work_factor"],
482 "salt": mask_hash(decoded["salt"]),
483 "checksum": mask_hash(decoded["checksum"]),
484 }
485
486 def must_update(self, encoded):
487 decoded = self.decode(encoded)
488 return decoded["work_factor"] != self.rounds
489
490 def harden_runtime(self, password, encoded):
491 _, data = encoded.split("$", 1)
492 salt = data[:29] # Length of the salt in bcrypt.
493 rounds = data.split("$")[2]
494 # work factor is logarithmic, adding one doubles the load.
495 diff = 2 ** (self.rounds - int(rounds)) - 1
496 while diff > 0:
497 self.encode(password, salt.encode("ascii"))
498 diff -= 1
499
500
501class BCryptPasswordHasher(BCryptSHA256PasswordHasher):
502 """
503 Secure password hashing using the bcrypt algorithm
504
505 This is considered by many to be the most secure algorithm but you
506 must first install the bcrypt library. Please be warned that
507 this library depends on native C code and might cause portability
508 issues.
509
510 This hasher does not first hash the password which means it is subject to
511 bcrypt's 72 bytes password truncation. Most use cases should prefer the
512 BCryptSHA256PasswordHasher.
513 """
514
515 algorithm = "bcrypt"
516 digest = None
517
518
519class ScryptPasswordHasher(BasePasswordHasher):
520 """
521 Secure password hashing using the Scrypt algorithm.
522 """
523
524 algorithm = "scrypt"
525 block_size = 8
526 maxmem = 0
527 parallelism = 1
528 work_factor = 2**14
529
530 def encode(self, password, salt, n=None, r=None, p=None):
531 self._check_encode_args(password, salt)
532 n = n or self.work_factor
533 r = r or self.block_size
534 p = p or self.parallelism
535 hash_ = hashlib.scrypt(
536 password.encode(),
537 salt=salt.encode(),
538 n=n,
539 r=r,
540 p=p,
541 maxmem=self.maxmem,
542 dklen=64,
543 )
544 hash_ = base64.b64encode(hash_).decode("ascii").strip()
545 return "%s$%d$%s$%d$%d$%s" % (self.algorithm, n, salt, r, p, hash_) # noqa: UP031
546
547 def decode(self, encoded):
548 algorithm, work_factor, salt, block_size, parallelism, hash_ = encoded.split(
549 "$", 6
550 )
551 assert algorithm == self.algorithm
552 return {
553 "algorithm": algorithm,
554 "work_factor": int(work_factor),
555 "salt": salt,
556 "block_size": int(block_size),
557 "parallelism": int(parallelism),
558 "hash": hash_,
559 }
560
561 def verify(self, password, encoded):
562 decoded = self.decode(encoded)
563 encoded_2 = self.encode(
564 password,
565 decoded["salt"],
566 decoded["work_factor"],
567 decoded["block_size"],
568 decoded["parallelism"],
569 )
570 return constant_time_compare(encoded, encoded_2)
571
572 def safe_summary(self, encoded):
573 decoded = self.decode(encoded)
574 return {
575 "algorithm": decoded["algorithm"],
576 "work factor": decoded["work_factor"],
577 "block size": decoded["block_size"],
578 "parallelism": decoded["parallelism"],
579 "salt": mask_hash(decoded["salt"]),
580 "hash": mask_hash(decoded["hash"]),
581 }
582
583 def must_update(self, encoded):
584 decoded = self.decode(encoded)
585 return (
586 decoded["work_factor"] != self.work_factor
587 or decoded["block_size"] != self.block_size
588 or decoded["parallelism"] != self.parallelism
589 )
590
591 def harden_runtime(self, password, encoded):
592 # The runtime for Scrypt is too complicated to implement a sensible
593 # hardening algorithm.
594 pass