Skip to content

Commit 7561c76

Browse files
committed
Add count_threshold option
Fixes #3
1 parent 1a1df9f commit 7561c76

File tree

3 files changed

+59
-6
lines changed

3 files changed

+59
-6
lines changed

README.md

+25-3
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ AUTH_PASSWORD_VALIDATORS = [
3434

3535
## Validators
3636

37-
### PwnedPasswordValidator(request_timeout=1.5)
37+
### PwnedPasswordValidator(request_timeout=1.5, count_threshold=1)
3838

3939
This validator uses the [Pwned Passwords API] to check for compromised passwords.
4040

@@ -54,8 +54,30 @@ AUTH_PASSWORD_VALIDATORS = [
5454

5555
You can set the API request timeout with the `request_timeout` parameter (in seconds).
5656

57+
You can set the `count_threshold` to reject a password if it appears more than
58+
a certain number of times in the Pwned Passwords data set.
59+
By default, this threshold is set to `1`.
60+
For instance, setting `count_threshold=2` means the password will be rejected
61+
if it appears in the data set at least twice.
62+
63+
Example configuration:
64+
65+
```python
66+
AUTH_PASSWORD_VALIDATORS = [
67+
# ...
68+
{
69+
"NAME": "django_pwned.validators.PwnedPasswordValidator",
70+
"OPTIONS": {
71+
"request_timeout": 2,
72+
"count_threshold": 5,
73+
},
74+
},
75+
# ...
76+
]
77+
```
78+
5779
If for any reason (connection issues, timeout, ...) the request to Pwned API fails,
58-
this validator skips checking password.
80+
this validator skips checking password and logs a message.
5981

6082
### GitHubLikePasswordValidator(min_length=8, safe_length=15)
6183

@@ -64,7 +86,7 @@ Validates whether the password is at least:
6486
- 8 characters long, if it includes a number and a lowercase letter, or
6587
- 15 characters long with any combination of characters
6688

67-
Based on Github's documentation about [creating a strong password].
89+
Based on GitHub's documentation about [creating a strong password].
6890

6991
You may want to disable Django's `NumericPasswordValidator`
7092
and `MinimumLengthValidator` if you want to use

django_pwned/validators.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ class PwnedPasswordValidator:
2020
Password validator which checks Django's list of common passwords and the Pwned Passwords database.
2121
"""
2222

23-
def __init__(self, request_timeout: float = 1.5):
23+
def __init__(self, request_timeout: float = 1.5, count_threshold: int = 1):
2424
self.request_timeout = request_timeout
25+
self.count_threshold = count_threshold
2526

2627
def validate(self, password: str, user=None):
2728
# First, check Django's list of common passwords
@@ -35,7 +36,7 @@ def validate(self, password: str, user=None):
3536
log.warning("Skipped Pwned Passwords check due to error: %r", e)
3637
return
3738

38-
if count > 0:
39+
if count >= self.count_threshold:
3940
raise ValidationError(
4041
_("Password is in a list of passwords commonly used on other websites."),
4142
code="password_pwned",

tests/test_pwned.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_pwned_api__leaked_password():
4242
"0BD86C0E684498894064E4AB86B9420CA0E:763",
4343
"2019C3022C39C4E5FD5A92ECD102E87476D:3",
4444
"3C56EAB73498B6A58EF5692C4E4937B4466:81",
45-
"5565F95194F23408B0EA21A0D02C4EEA81D:2",
45+
"5565F95194F23408B0EA21A0D02C4EEA81D:1",
4646
"6E9AC9194DA65040917139BA238B3900354:2,103",
4747
"BB48AB53E4E454BC487CA6400380C05D41A:5",
4848
"D55A6ED26C1DE9350D40771822316CC4B29:3",
@@ -78,6 +78,36 @@ def test_pwned_api__strong_password():
7878
assert len(responses.calls) == 1
7979

8080

81+
@responses.activate
82+
def test_pwned_api__count_threshold():
83+
leaked_password = r"pass-word"
84+
leaked_password_hash = "43BEF3EAB34187D71D7E1D9CC307C5E7C07665A8"
85+
responses.add(
86+
responses.GET,
87+
API_ENDPOINT.format(leaked_password_hash[:5]),
88+
body="\n".join(
89+
[
90+
"0BD86C0E684498894064E4AB86B9420CA0E:763",
91+
"3EAB34187D71D7E1D9CC307C5E7C07665A8:3",
92+
"3C56EAB73498B6A58EF5692C4E4937B4466:81",
93+
"5565F95194F23408B0EA21A0D02C4EEA81D:1",
94+
"6E9AC9194DA65040917139BA238B3900354:2,103",
95+
"BB48AB53E4E454BC487CA6400380C05D41A:5",
96+
"D55A6ED26C1DE9350D40771822316CC4B29:3",
97+
]
98+
),
99+
)
100+
with pytest.raises(ValidationError):
101+
PwnedPasswordValidator().validate(leaked_password)
102+
with pytest.raises(ValidationError):
103+
PwnedPasswordValidator(count_threshold=2).validate(leaked_password)
104+
with pytest.raises(ValidationError):
105+
PwnedPasswordValidator(count_threshold=3).validate(leaked_password)
106+
PwnedPasswordValidator(count_threshold=4).validate(leaked_password)
107+
PwnedPasswordValidator(count_threshold=5).validate(leaked_password)
108+
assert len(responses.calls) == 5
109+
110+
81111
@responses.activate
82112
def test_pwned_api__connection_error():
83113
strong_password = r"SGz=L.%U\;Os$,k]%U2m"

0 commit comments

Comments
 (0)