Skip to content

Commit 0fd5e5e

Browse files
MelissaAutumnmarkstos
authored andcommitted
Add UsernameBlockListEntry and tie it to is_reserved, and the admin panel. (Fixes #831)
1 parent 3e7a3a2 commit 0fd5e5e

6 files changed

Lines changed: 115 additions & 5 deletions

File tree

src/thunderbird_accounts/authentication/admin/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
from django.contrib import admin
44
from django.contrib.admin.models import LogEntry
55

6-
from thunderbird_accounts.authentication.admin.models import CustomUserAdmin, AllowListEntryAdmin, LogEntryAdmin
7-
from thunderbird_accounts.authentication.models import User, AllowListEntry
6+
from thunderbird_accounts.authentication.admin.models import (
7+
CustomUserAdmin,
8+
AllowListEntryAdmin,
9+
LogEntryAdmin,
10+
UsernameBlockListEntryAdmin,
11+
)
12+
from thunderbird_accounts.authentication.models import User, AllowListEntry, UsernameBlockListEntry
813

914
# Register the User admin here
1015
admin.site.register(User, CustomUserAdmin)
1116
admin.site.register(AllowListEntry, AllowListEntryAdmin)
1217
admin.site.register(LogEntry, LogEntryAdmin)
18+
admin.site.register(UsernameBlockListEntry, UsernameBlockListEntryAdmin)

src/thunderbird_accounts/authentication/admin/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,12 @@ def has_view_permission(self, request, obj=None):
163163

164164
def get_queryset(self, request):
165165
return super().get_queryset(request).prefetch_related('content_type')
166+
167+
class UsernameBlockListEntryAdmin(admin.ModelAdmin):
168+
search_fields = ('pattern',)
169+
list_filter = ['created_at', 'updated_at']
170+
list_display = (
171+
'pattern',
172+
'created_at',
173+
'updated_at',
174+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 6.0.5 on 2026-05-12 18:35
2+
3+
import django.core.validators
4+
import uuid
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('authentication', '0015_add_allowlist_discount'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='user',
17+
name='username',
18+
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 3–150 characters.', max_length=150, unique=True, validators=[django.core.validators.MinLengthValidator(3)], verbose_name='username'),
19+
),
20+
migrations.CreateModel(
21+
name='UsernameBlockListEntry',
22+
fields=[
23+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
24+
('created_at', models.DateTimeField(auto_now_add=True)),
25+
('updated_at', models.DateTimeField(auto_now=True)),
26+
('pattern', models.CharField(help_text='A full or partial string using wildcard `*`.<br><b>Example:</b> <code>thunderbird</code> to block <code>thunderbird@example.com</code>, or <code>thunderbird*</code> to block usernames like <code>thunderbird-admin@example.com</code>', unique=True, verbose_name='pattern')),
27+
],
28+
options={
29+
'verbose_name_plural': 'Username block list entries',
30+
'abstract': False,
31+
'indexes': [models.Index(fields=['uuid'], name='authenticat_uuid_11f328_idx'), models.Index(fields=['created_at'], name='authenticat_created_81fb2a_idx'), models.Index(fields=['updated_at'], name='authenticat_updated_6ecea2_idx'), models.Index(fields=['pattern'], name='authenticat_pattern_e6bc1f_idx')],
32+
},
33+
),
34+
]

src/thunderbird_accounts/authentication/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,33 @@ def __str__(self):
137137
if self.user:
138138
return f'Allow List Entry [{self.uuid}] {self.email} - {self.user.username}'
139139
return f'Allow List Entry [{self.uuid}] {self.email} - <Unassociated>'
140+
141+
class UsernameBlockListEntry(BaseModel):
142+
"""Username Block List Entry
143+
144+
This is a small model that holds full strings or partial string that
145+
will be checked against the username along-side the full reversed list.
146+
147+
These are unique to cut down on entries to check.
148+
"""
149+
150+
pattern = models.CharField(
151+
_('pattern'),
152+
unique=True,
153+
help_text=_(
154+
"A full or partial string using wildcard `*`."
155+
'<br>'
156+
'<b>Example:</b> <code>thunderbird</code> to block <code>thunderbird@example.com</code>,'
157+
' or <code>thunderbird*</code> to block usernames like <code>thunderbird-admin@example.com</code>'
158+
),
159+
)
160+
161+
class Meta(BaseModel.Meta):
162+
verbose_name_plural = 'Username block list entries'
163+
indexes = [
164+
*BaseModel.Meta.indexes,
165+
models.Index(fields=['pattern']),
166+
]
167+
168+
def __str__(self):
169+
return f'Blocked Username Entry [{self.uuid}] {self.pattern}'

src/thunderbird_accounts/authentication/reserved.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from fnmatch import fnmatch
12
import re
23

34
# brand related names, and also help/support
@@ -102,4 +103,15 @@
102103

103104
def is_reserved(test_string: str) -> bool:
104105
"""Checks the address or random string is a reserved name which should fail user or alias creation if so."""
105-
return any(r.match(test_string) for r in regexes)
106+
from thunderbird_accounts.authentication.models import UsernameBlockListEntry
107+
108+
matches = any(r.match(test_string) for r in regexes)
109+
if matches:
110+
return True
111+
112+
# This most likely won't scale well in the future...
113+
entries = UsernameBlockListEntry.objects.all()
114+
115+
# This is a filename search, but it's simpler than regex so hopefully it won't be a footgun. :)
116+
# https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch
117+
return any(fnmatch(test_string, entry.pattern) for entry in entries)

src/thunderbird_accounts/authentication/tests/test_utils.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
21
from django.test import TestCase, override_settings
32

4-
from thunderbird_accounts.authentication.models import AllowListEntry, User
3+
from thunderbird_accounts.authentication.models import AllowListEntry, User, UsernameBlockListEntry
54
from thunderbird_accounts.authentication.reserved import is_reserved, servers, support
65
from thunderbird_accounts.authentication.utils import is_email_in_allow_list
76

7+
88
class IsReservedUnitTests(TestCase):
99
def test_brand_names(self):
1010
for brand in ['thunderbird', 'thundermail', 'tbpro', 'mozilla', 'firefox', 'help', 'support', 'mzla']:
@@ -95,6 +95,25 @@ def test_partial_matches_should_pass(self):
9595
for name in ['user123', 'myusernamex', 'rooted', 'teamwork', 'contacting']:
9696
self.assertFalse(is_reserved(name))
9797

98+
def test_username_block_list_entries(self):
99+
block_list_entries = ['skeletons', 'dog*', 'pizza']
100+
for name in block_list_entries:
101+
name = name.replace('*', '')
102+
self.assertFalse(is_reserved(name))
103+
104+
for name in block_list_entries:
105+
UsernameBlockListEntry.objects.create(pattern=name)
106+
107+
# Add some entries that will pass with the wildcard entry (dog)
108+
block_list_entries += ['dog-dog', 'dog-skeleton']
109+
# Add some entries that will fail the wildcard entry (dog)
110+
not_reserved_entries = ['skeleton-dog', 'skeletons2']
111+
112+
for name in block_list_entries:
113+
self.assertTrue(is_reserved(name))
114+
for name in not_reserved_entries:
115+
self.assertFalse(is_reserved(name))
116+
98117

99118
@override_settings(USE_ALLOW_LIST=True)
100119
class IsEmailInAllowListUnitTests(TestCase):

0 commit comments

Comments
 (0)