Skip to content

Commit 4ee912c

Browse files
feat: add SHA256 password hashers (#14)
* refactor: reorganize tests * feat: add sha256 hasher * style: fix ruff config * 1.0.0
1 parent 2a74a1c commit 4ee912c

File tree

5 files changed

+108
-7
lines changed

5 files changed

+108
-7
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ fmt format: # Run code formatters
88
black .
99

1010
test: # Run tests
11-
pytest --ds=sample_project.settings -v sample_project ninja_apikey/tests.py
11+
pytest --ds=sample_project.settings -v sample_project ninja_apikey/tests
1212

1313
cov test-cov: # Run tests with coverage
14-
pytest --ds=sample_project.settings --cov=ninja_apikey --cov-report=term-missing --cov-report=xml -v sample_project ninja_apikey/tests.py
14+
pytest --ds=sample_project.settings --cov=ninja_apikey --cov-report=term-missing --cov-report=xml -v sample_project ninja_apikey/tests
1515

1616
build: # Build project
1717
make install

ninja_apikey/hashers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import hashlib
2+
3+
from django.contrib.auth.hashers import BasePasswordHasher, mask_hash, must_update_salt
4+
from django.utils.crypto import constant_time_compare
5+
from django.utils.translation import gettext_noop as _
6+
7+
8+
class SHA256PasswordHasher(BasePasswordHasher):
9+
"""
10+
This was based on the Django's SHA1PasswordHasher from Django 5.0.6:
11+
https://github.com/django/django/blob/5.0.6/django/contrib/auth/hashers.py#L645
12+
"""
13+
14+
algorithm = "sha256"
15+
16+
def encode(self, password, salt):
17+
self._check_encode_args(password, salt)
18+
hash = hashlib.sha256((salt + password).encode()).hexdigest()
19+
return "%s$%s$%s" % (self.algorithm, salt, hash)
20+
21+
def decode(self, encoded):
22+
algorithm, salt, hash = encoded.split("$", 2)
23+
assert algorithm == self.algorithm
24+
return {
25+
"algorithm": algorithm,
26+
"hash": hash,
27+
"salt": salt,
28+
}
29+
30+
def verify(self, password, encoded):
31+
decoded = self.decode(encoded)
32+
encoded_2 = self.encode(password, decoded["salt"])
33+
return constant_time_compare(encoded, encoded_2)
34+
35+
def safe_summary(self, encoded):
36+
decoded = self.decode(encoded)
37+
return {
38+
_("algorithm"): decoded["algorithm"],
39+
_("salt"): mask_hash(decoded["salt"], show=2),
40+
_("hash"): mask_hash(decoded["hash"]),
41+
}
42+
43+
def must_update(self, encoded):
44+
decoded = self.decode(encoded)
45+
return must_update_salt(decoded["salt"], self.salt_entropy)
46+
47+
def harden_runtime(self, password, encoded):
48+
pass
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from django.contrib.auth.hashers import (
2+
check_password,
3+
identify_hasher,
4+
is_password_usable,
5+
make_password,
6+
)
7+
from django.test.utils import override_settings
8+
9+
from ninja_apikey.hashers import SHA256PasswordHasher
10+
11+
12+
@override_settings(PASSWORD_HASHERS=["ninja_apikey.hashers.SHA256PasswordHasher"])
13+
def test_sha256():
14+
"""
15+
Based on https://github.com/django/django/blob/5.0.6/tests/auth_tests/test_hashers.py#L110
16+
"""
17+
encoded = make_password("lètmein", "seasalt", hasher="sha256")
18+
assert (
19+
encoded
20+
== "sha256$seasalt$e0327e0c88846ec7f85601380e86c72a5242e3455a1ae0f736f349858f126eb9"
21+
)
22+
assert is_password_usable(encoded) is True
23+
assert check_password("lètmein", encoded) is True
24+
assert check_password("lètmeinz", encoded) is False
25+
assert identify_hasher(encoded).algorithm == "sha256"
26+
27+
28+
@override_settings(PASSWORD_HASHERS=["ninja_apikey.hashers.SHA256PasswordHasher"])
29+
def test_blank_password():
30+
"""
31+
Based on https://github.com/django/django/blob/5.0.6/tests/auth_tests/test_hashers.py#L110
32+
"""
33+
blank_encoded = make_password("", "seasalt", "sha256")
34+
assert blank_encoded.startswith("sha256$")
35+
assert is_password_usable(blank_encoded) is True
36+
assert check_password("", blank_encoded) is True
37+
assert check_password(" ", blank_encoded) is False
38+
39+
40+
@override_settings(PASSWORD_HASHERS=["ninja_apikey.hashers.SHA256PasswordHasher"])
41+
def test_entropy_check():
42+
"""
43+
Based on https://github.com/django/django/blob/5.0.6/tests/auth_tests/test_hashers.py#L110
44+
"""
45+
hasher = SHA256PasswordHasher()
46+
encoded_weak_salt = make_password("lètmein", "iodizedsalt")
47+
encoded_strong_salt = make_password("lètmein", hasher.salt())
48+
assert hasher.must_update(encoded_weak_salt) is True
49+
assert hasher.must_update(encoded_strong_salt) is False

ninja_apikey/tests.py renamed to ninja_apikey/tests/test_security.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
from django.utils import timezone
1212
from django.utils.crypto import get_random_string
1313

14-
from .admin import APIKeyAdmin
15-
from .models import APIKey
16-
from .security import check_apikey, generate_key
14+
from ninja_apikey.admin import APIKeyAdmin
15+
from ninja_apikey.models import APIKey
16+
from ninja_apikey.security import check_apikey, generate_key
1717

1818

1919
def test_apikey_validation():

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ requires = ["flit_core >=3.2,<4"]
33
build-backend = "flit_core.buildapi"
44

55

6+
67
[project]
78
name = "ninja-api-key"
89
description = "Django Ninja API Key Authentication"
9-
version = "0.2.2"
10+
version = "1.0.0"
1011
authors = [
1112
{name = "Lucas Rangel Cezimbra", email="lucas@cezimbra.tec.br"},
1213
{name = "Maximilian Wassink", email="wassink.maximilian@protonmail.com"},
@@ -55,11 +56,14 @@ test = [
5556
]
5657

5758

59+
5860
[tool.flit.module]
5961
name = "ninja_apikey"
6062

6163

6264
[tool.ruff]
65+
line-length = 88
66+
67+
[tool.ruff.lint]
6368
select = ["E", "F", "I"]
6469
ignore = ["E501"]
65-
line-length = 88

0 commit comments

Comments
 (0)