Skip to content

Commit d704a36

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

File tree

5 files changed

+84
-1
lines changed

5 files changed

+84
-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: 29 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,16 @@ 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:
95+
self.email_hash = hashlib.md5(self.email.lower().encode()).digest()
96+
kwargs["update_fields"] = {*kwargs.pop("update_fields", {}), "email_hash"}
97+
super().save(*args, **kwargs)
98+
7199
def _legacy_get_session_auth_hash(self):
72100
# RemovedInDjango40Warning: pre-Django 3.1 hashes will be invalid.
73101
key_salt = "mailauth.contrib.user.models.EmailUserManager.get_session_auth_hash"

mailauth/forms.py

Lines changed: 6 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
@@ -9,6 +10,7 @@
910
from django.urls import reverse
1011

1112
from mailauth.backends import MailAuthBackend
13+
from mailauth.contrib.user.models import AbstractEmailUser
1214

1315

1416
class BaseLoginForm(forms.Form):
@@ -104,6 +106,10 @@ def __init__(self, request, *args, **kwargs):
104106
self.fields[self.field_name] = field
105107

106108
def get_users(self, email=None):
109+
if self.field_name == "email" and hasattr(get_user_model(), "email_hash"):
110+
email_hash = hashlib.md5(email.lower().encode()).hexdigest()
111+
return get_user_model().objects.filter(email_hash=email_hash).iterator()
112+
107113
if connection.vendor == "postgresql":
108114
query = {self.field_name: email}
109115
else:

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)