Skip to content

Commit 6dd1f48

Browse files
committed
Add email hash field to simplify GDPR deletions
1 parent 94c227a commit 6dd1f48

File tree

6 files changed

+115
-1
lines changed

6 files changed

+115
-1
lines changed

docs/customizing.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ API documentation
119119

120120
.. autoattribute:: mailauth.contrib.user.models.AbstractEmailUser.email
121121
:noindex:
122+
.. autoattribute:: mailauth.contrib.user.models.AbstractEmailUser.email_hash
123+
:noindex:
122124
.. autoattribute:: mailauth.contrib.user.models.AbstractEmailUser.session_salt
123125
:noindex:
124126

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from django.db import migrations, models
2+
3+
try:
4+
from django.contrib.postgres.fields import CIEmailField
5+
except ImportError:
6+
CIEmailField = models.EmailField
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("mailauth_user", "0004_auto_20200812_0722"),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="emailuser",
18+
name="email_hash",
19+
field=models.TextField(db_index=True, null=True),
20+
),
21+
migrations.AlterField(
22+
model_name="emailuser",
23+
name="email",
24+
field=CIEmailField(
25+
blank=True,
26+
db_index=True,
27+
max_length=254,
28+
null=True,
29+
unique=True,
30+
verbose_name="email address",
31+
),
32+
),
33+
migrations.RunSQL(
34+
"UPDATE mailauth_user_emailuser SET email_hash = md5(email)",
35+
"UPDATE mailauth_user_emailuser SET email_hash = NULL",
36+
),
37+
migrations.AlterField(
38+
model_name="emailuser",
39+
name="email_hash",
40+
field=models.TextField(db_index=True, default=0, max_length=16),
41+
),
42+
]

mailauth/contrib/user/models.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import hashlib
2+
13
from django.conf import settings
24
from django.contrib.auth.base_user import BaseUserManager
35
from django.contrib.auth.models import AbstractUser
@@ -18,6 +20,7 @@ def _create_user(self, email, **extra_fields):
1820
"""Create and save a user with the given email."""
1921
email = self.normalize_email(email)
2022
user = self.model(email=email, **extra_fields)
23+
user.email_hash = hashlib.md5(email.lower().encode()).hexdigest()
2124
user.save(using=self._db)
2225
return user
2326

@@ -50,9 +53,24 @@ class AbstractEmailUser(AbstractUser):
5053
username = None
5154
password = None
5255

53-
email = CIEmailField(_("email address"), unique=True, db_index=True)
56+
email = CIEmailField(
57+
_("email address"), blank=True, null=True, unique=True, db_index=True
58+
)
5459
"""Unique and case insensitive to serve as a better username."""
5560

61+
email_hash = models.TextField(db_index=True, editable=False)
62+
"""
63+
A hash of the email address, to erase the email address without loosing the user.
64+
65+
GDPR may require us to erase the email address, yet we still want to be able to
66+
retain non-personal information for statistical or other legal purposes. We may
67+
also want to be able to authenticate users without ever storing the email address.
68+
69+
A hash, being non-reversible in nature, means that the personal information is
70+
not stored and you may only processes personal information during runtime for
71+
the explicit purpose of authenticating the user.
72+
"""
73+
5674
session_salt = models.CharField(
5775
max_length=12,
5876
editable=False,
@@ -68,6 +86,19 @@ def has_usable_password(self):
6886
class Meta(AbstractUser.Meta):
6987
abstract = True
7088

89+
def __init__(self, *args, **kwargs):
90+
super().__init__(*args, **kwargs)
91+
self._email = self.email
92+
93+
def save(self, *args, **kwargs):
94+
if self.email and ((self._email != self.email) or not self.pk):
95+
self.email_hash = hashlib.md5(self.email.lower().encode()).digest()
96+
try:
97+
kwargs["update_fields"] = {*kwargs.pop("update_fields"), "email_hash"}
98+
except KeyError:
99+
pass
100+
super().save(*args, **kwargs)
101+
71102
def _legacy_get_session_auth_hash(self):
72103
# RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
73104
key_salt = "mailauth.contrib.user.models.EmailUserManager.get_session_auth_hash"

mailauth/forms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import hashlib
12
import urllib
23

34
from django import forms
@@ -104,6 +105,10 @@ def __init__(self, request, *args, **kwargs):
104105
self.fields[self.field_name] = field
105106

106107
def get_users(self, email=None):
108+
if self.field_name == "email" and hasattr(get_user_model(), "email_hash"):
109+
email_hash = hashlib.md5(email.lower().encode()).hexdigest()
110+
return get_user_model().objects.filter(email_hash=email_hash).iterator()
111+
107112
if connection.vendor == "postgresql":
108113
query = {self.field_name: email}
109114
else:

tests/contrib/auth/test_models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ def test_password_field(self):
6060
with pytest.raises(FieldDoesNotExist):
6161
user.password
6262

63+
def test_init(self):
64+
user = EmailUser(email="spiderman@avengers.com")
65+
assert user._email == "spiderman@avengers.com"
66+
user.email = "ironman@avengers.com"
67+
assert user._email == "spiderman@avengers.com"
68+
69+
@pytest.mark.django_db
70+
def test_save(self):
71+
user = EmailUser(email="spiderman@avengers.com")
72+
assert user._email == "spiderman@avengers.com"
73+
user.save()
74+
assert (
75+
user.email_hash
76+
== b"\xac\xbf \xaf,\xa5\xf6\xe3\xe6\xb5\xe0\x88\xd8\xe9\x96\x81"
77+
)
78+
79+
@pytest.mark.django_db
80+
def test_save__update_fields(self):
81+
user = EmailUser(email="spiderman@avengers.com")
82+
assert user._email == "spiderman@avengers.com"
83+
user.save()
84+
assert (
85+
user.email_hash
86+
== b"\xac\xbf \xaf,\xa5\xf6\xe3\xe6\xb5\xe0\x88\xd8\xe9\x96\x81"
87+
)
88+
user.email = "ironman@avengers.com"
89+
user.save(update_fields=["email"])
90+
assert user.email_hash == b"7\x12\x0b\x80V\xf5\xdeUi\xcb(v\xac\xf9\xf5 "
91+
6392

6493
class TestEmailUserManager:
6594
def test_create_user(self, db):

tests/test_forms.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ def test_get_users(self, db, user):
3838
assert list(EmailLoginForm(request=None).get_users("spiderman@avengers.com"))
3939
assert list(EmailLoginForm(request=None).get_users("SpiderMan@Avengers.com"))
4040
assert not list(EmailLoginForm(request=None).get_users("SpiderMan@dc.com"))
41+
42+
def test_get_users__no_hash(self, db, user):
43+
form = EmailLoginForm(request=None)
44+
form.field_name = "pk"
45+
assert list(form.get_users(user.pk))

0 commit comments

Comments
 (0)