Skip to content

Commit 508e446

Browse files
committed
Prevent registering recently-deleted usernames
1 parent 3ddb0f7 commit 508e446

File tree

4 files changed

+63
-23
lines changed

4 files changed

+63
-23
lines changed

h/accounts/schemas.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import codecs
22
import logging
3+
from datetime import datetime, timedelta
34
from functools import lru_cache
45

56
import colander
67
import deform
78
from markupsafe import Markup
9+
from sqlalchemy import select
810

911
from h import i18n, models
1012
from h.models.user import (
@@ -16,6 +18,7 @@
1618
from h.schemas import validators
1719
from h.schemas.base import CSRFSchema
1820
from h.schemas.forms.accounts.util import PASSWORD_MIN_LENGTH
21+
from h.util.user import format_userid
1922

2023
_ = i18n.TranslationString
2124
log = logging.getLogger(__name__)
@@ -46,11 +49,21 @@ def unique_email(node, value):
4649

4750
def unique_username(node, value):
4851
"""Colander validator that ensures the username does not exist."""
52+
exc = colander.Invalid(node, _("This username is already taken."))
4953
request = node.bindings["request"]
5054
user = models.User.get_by_username(request.db, value, request.default_authority)
5155
if user: # pragma: no cover
52-
msg = _("This username is already taken.")
53-
raise colander.Invalid(node, msg)
56+
raise exc
57+
58+
if request.db.scalars(
59+
select(models.UserDeletion.id)
60+
.where(
61+
models.UserDeletion.userid
62+
== format_userid(value, request.default_authority)
63+
)
64+
.where(models.UserDeletion.requested_at > datetime.now() - timedelta(days=31))
65+
).first():
66+
raise exc
5467

5568

5669
def email_node(**kwargs):

h/models/user.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from h.db import Base
99
from h.exceptions import InvalidUserId
10-
from h.util.user import split_user
10+
from h.util.user import format_userid, split_user
1111

1212
USERNAME_MIN_LENGTH = 3
1313
USERNAME_MAX_LENGTH = 30
@@ -231,7 +231,7 @@ def username(cls): # pylint:disable=no-self-argument
231231

232232
@hybrid_property
233233
def userid(self):
234-
return f"acct:{self.username}@{self.authority}"
234+
return format_userid(self.username, self.authority)
235235

236236
@userid.comparator
237237
def userid(cls): # pylint: disable=no-self-argument

h/util/user.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ def split_user(userid):
1919
if match:
2020
return {"username": match.groups()[0], "domain": match.groups()[1]}
2121
raise InvalidUserId(userid)
22+
23+
24+
def format_userid(username, authority):
25+
return f"acct:{username}@{authority}"

tests/unit/h/accounts/schemas_test.py

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timedelta
12
from unittest.mock import Mock
23

34
import colander
@@ -6,6 +7,7 @@
67

78
from h.accounts import schemas
89
from h.services.user_password import UserPasswordService
10+
from h.util.user import format_userid
911

1012
pytestmark = pytest.mark.usefixtures("pyramid_config")
1113

@@ -72,7 +74,6 @@ def test_it_is_valid_when_authorized_users_email(
7274
schemas.unique_email(dummy_node, "[email protected]")
7375

7476

75-
@pytest.mark.usefixtures("user_model")
7677
class TestRegisterSchema:
7778
def test_it_is_invalid_when_password_too_short(self, pyramid_request):
7879
schema = schemas.RegisterSchema().bind(request=pyramid_request)
@@ -81,26 +82,21 @@ def test_it_is_invalid_when_password_too_short(self, pyramid_request):
8182
schema.deserialize({"password": "a"})
8283
assert exc.value.asdict()["password"] == ("Must be 8 characters or more.")
8384

84-
def test_it_is_invalid_when_username_too_short(self, pyramid_request, user_model):
85+
def test_it_is_invalid_when_username_too_short(self, pyramid_request):
8586
schema = schemas.RegisterSchema().bind(request=pyramid_request)
86-
user_model.get_by_username.return_value = None
8787

8888
with pytest.raises(colander.Invalid) as exc:
8989
schema.deserialize({"username": "a"})
9090
assert exc.value.asdict()["username"] == ("Must be 3 characters or more.")
9191

92-
def test_it_is_invalid_when_username_too_long(self, pyramid_request, user_model):
92+
def test_it_is_invalid_when_username_too_long(self, pyramid_request):
9393
schema = schemas.RegisterSchema().bind(request=pyramid_request)
94-
user_model.get_by_username.return_value = None
9594

9695
with pytest.raises(colander.Invalid) as exc:
9796
schema.deserialize({"username": "a" * 500})
9897
assert exc.value.asdict()["username"] == ("Must be 30 characters or less.")
9998

100-
def test_it_is_invalid_with_invalid_characters_in_username(
101-
self, pyramid_request, user_model
102-
):
103-
user_model.get_by_username.return_value = None
99+
def test_it_is_invalid_with_invalid_characters_in_username(self, pyramid_request):
104100
schema = schemas.RegisterSchema().bind(request=pyramid_request)
105101

106102
with pytest.raises(colander.Invalid) as exc:
@@ -128,24 +124,51 @@ def test_it_is_invalid_when_privacy_accepted_missing(self, pyramid_request):
128124

129125
assert exc.value.asdict()["privacy_accepted"] == "Required"
130126

131-
def test_it_validates_with_valid_payload(self, pyramid_csrf_request, user_model):
132-
user_model.get_by_username.return_value = None
133-
user_model.get_by_email.return_value = None
127+
def test_it_is_invalid_when_user_recently_deleted(
128+
self, factories, pyramid_request, valid_params
129+
):
130+
"""If an account with the same username was recently deleted it should be invalid."""
131+
schema = schemas.RegisterSchema().bind(request=pyramid_request)
132+
factories.UserDeletion(
133+
userid=format_userid(
134+
username=valid_params["username"],
135+
authority=pyramid_request.default_authority,
136+
)
137+
)
138+
139+
with pytest.raises(colander.Invalid) as exc:
140+
schema.deserialize(valid_params)
134141

142+
assert exc.value.asdict() == {"username": "This username is already taken."}
143+
144+
def test_it_validates_with_valid_payload(
145+
self, pyramid_csrf_request, valid_params, factories
146+
):
135147
schema = schemas.RegisterSchema().bind(request=pyramid_csrf_request)
136-
params = {
148+
# A user with the same username was deleted over a month ago.
149+
# This should not prevent registration.
150+
factories.UserDeletion(
151+
userid=format_userid(
152+
valid_params["username"], pyramid_csrf_request.default_authority
153+
),
154+
requested_at=datetime.now() - timedelta(days=32),
155+
)
156+
157+
result = schema.deserialize(valid_params)
158+
159+
assert result == dict(
160+
valid_params, privacy_accepted=True, comms_opt_in=None, csrf_token=None
161+
)
162+
163+
@pytest.fixture
164+
def valid_params(self):
165+
return {
137166
"username": "filbert",
138167
"email": "[email protected]",
139168
"password": "sdlkfjlk3j3iuei",
140169
"privacy_accepted": "true",
141170
}
142171

143-
result = schema.deserialize(params)
144-
145-
assert result == dict(
146-
params, privacy_accepted=True, comms_opt_in=None, csrf_token=None
147-
)
148-
149172

150173
@pytest.mark.usefixtures("models", "user_password_service")
151174
class TestEmailChangeSchema:

0 commit comments

Comments
 (0)