Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,27 @@ Django Pwned Passwords Validator
================================

This package provides a password validator for Django that checks submitted
passwords against the `Pwned Passwords API <https://haveibeenpwned.com/API/v2>`_.
passwords against the `Pwned Passwords API <https://haveibeenpwned.com/API/v2>`_
to help your users protect themselves against credential stuffing attacks.

To protect the security of the password being checked a range search is used. Specifically,
only the first 5 characters of a SHA-1 password hash are sent to the API. The
validator then locally looks for the full hash in the range returned.

The package also provides a custom authentication backend that wraps the
standard model backend so passwords of existing users can be checked when
they login.


Installation
~~~~~~~~~~~~

.. code-block:: sh

pip install django-pwned-validator

Modify your `settings.py` to install the app and enable the validator:
Modify your `settings.py` to install the app, then add the validator and
the custom authentication backend:

.. code-block:: python

Expand All @@ -31,7 +38,29 @@ Modify your `settings.py` to install the app and enable the validator:
...
]

AUTHENTICATION_BACKENDS = [
'pwned.backends.PwnedModelBackend',
]

We would also suggest removing the `CommonPasswordValidator` as it fills a
very similar role and this validator uses the same help text.


Settings
~~~~~~~~

.. code-block:: python

PWNED = {
'ENDPOINT': 'https://api.pwnedpasswords.com/range/',
'OCCURRENCE_NOTIFY_THRESHOLD': 1, # How many occurrences cause a validation error
'PREFIX_LENGTH': 5,
'RECORD_HITS': True,
'TIMEOUT': 2, # The default is conservative but will cut off some requests; typical is 200ms
'USER_AGENT': 'github.com/craigloftus/django-pwned-validator',
}


Compatibility
~~~~~~~~~~~~~
Supports Django 1.11.x and 2.0 on Python 3.5 and 3.6.
Supports Django 1.11, 2.0, 2.1 and 2.2 on Python 3.5 and 3.6.
9 changes: 9 additions & 0 deletions pwned/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.contrib import admin

from .models import PwnedRecord


@admin.register(PwnedRecord)
class PwnedRecordAdmin(admin.ModelAdmin):
list_display = ('email', 'created', 'count', 'notified',)

27 changes: 27 additions & 0 deletions pwned/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.contrib.auth.backends import ModelBackend

from .client import PwnedClient
from .models import PwnedRecord
from .settings import get_config


class PwnedBackendMixin:

client = PwnedClient

def authenticate(self, request, username=None, password=None):
user = super().authenticate(request, username=username, password=password)
# If authenticated, check supplied password against API
if user and password:
pwned_client = self.client()
count = pwned_client.count_occurrences(password)
if count and get_config()['RECORD_HITS']:
PwnedRecord.objects.create(
email = user.email,
count = count,
)
return user


class PwnedModelBackend(PwnedBackendMixin, ModelBackend):
pass
24 changes: 24 additions & 0 deletions pwned/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 2.2 on 2019-05-03 13:21

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='PwnedRecord',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(blank=True, max_length=254)),
('count', models.PositiveIntegerField(db_index=True)),
('notified', models.BooleanField(db_index=True, default=False)),
('created', models.DateTimeField(auto_now_add=True)),
],
),
]
Empty file added pwned/migrations/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions pwned/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.contrib.auth import get_user_model
from django.db import models


UserModel = get_user_model()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import.



class PwnedRecord(models.Model):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that this app should be providing this model at all, especially with fields (notified) that it doesn't itself use. To me this seems like a good candidate for a signal instead, which projects can then do what they like with - and would allow a much wider set up use cases without creating a potentially unused table in the database.

email = models.EmailField(blank=True)
count = models.PositiveIntegerField(db_index=True)
notified = models.BooleanField(db_index=True, default=False)
created = models.DateTimeField(auto_now_add=True)
5 changes: 3 additions & 2 deletions pwned/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@

DEFAULTS = {
'ENDPOINT': 'https://api.pwnedpasswords.com/range/',
'TIMEOUT': 2, # The default is conservative but will cut off some requests; average is 280ms
'OCCURRENCE_NOTIFY_THRESHOLD': 1, # How many occurrences cause a email or validation error
'PREFIX_LENGTH': 5,
'OCCURRENCE_THRESHOLD': 1, # How many occurrences is too many
'RECORD_HITS': True,
'TIMEOUT': 2, # The default is conservative but will cut off some requests; typical is 200ms
'USER_AGENT': 'github.com/craigloftus/django-pwned-validator',
}

Expand Down
17 changes: 14 additions & 3 deletions pwned/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@
from django.utils.translation import gettext_lazy as _

from .client import PwnedClient
from .models import PwnedRecord
from .settings import get_config


@deconstructible
class PwnedValidator:
message = _('This password is known to be weak')
code = 'invalid'
message = _("This is a commonly used password")
code = "invalid"
client = PwnedClient

def validate(self, password, user=None):
pwned_client = self.client()
count = pwned_client.count_occurrences(password)
if count >= get_config()['OCCURRENCE_THRESHOLD']:

if count and user and get_config()['RECORD_HITS']:
PwnedRecord.objects.create(
email = user.email,
count = count,
)

if count >= get_config()['OCCURRENCE_NOTIFY_THRESHOLD']:
raise ValidationError(self.message, code=self.code)

def get_help_text(self):
return _("Your password can't be a commonly used password.")
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
'requests',
],
extras_require={
'test': ['django<2.1', 'pytest', 'pytest-cov', 'pytest-django', 'pytest-vcr',],
'test': ['django<2.3', 'pytest', 'pytest-cov', 'pytest-django', 'pytest-vcr',],
},
classifiers=[
'Environment :: Web Environment',
Expand Down
Loading