From 7d255a353f2dd0348e7e2df6193f0b672b46ba14 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 28 Mar 2024 07:21:29 -0400 Subject: [PATCH] Add email domain blocklist (#15672) --- tests/unit/accounts/test_forms.py | 55 ++++- tests/unit/admin/test_routes.py | 7 + tests/unit/admin/views/test_users.py | 60 +++++ tests/unit/manage/test_forms.py | 24 +- tests/unit/manage/test_views.py | 2 +- warehouse/accounts/forms.py | 23 +- warehouse/accounts/models.py | 20 ++ warehouse/accounts/views.py | 1 + warehouse/admin/routes.py | 7 + .../admin/templates/admin/users/detail.html | 46 +++- warehouse/admin/views/users.py | 42 +++- warehouse/locale/messages.pot | 210 +++++++++--------- warehouse/manage/views/__init__.py | 4 +- .../1fdecaf73541_add_prohibitedemaildomain.py | 60 +++++ 14 files changed, 439 insertions(+), 122 deletions(-) create mode 100644 warehouse/migrations/versions/1fdecaf73541_add_prohibitedemaildomain.py diff --git a/tests/unit/accounts/test_forms.py b/tests/unit/accounts/test_forms.py index 718a22c8409b..7cecb64ebb07 100644 --- a/tests/unit/accounts/test_forms.py +++ b/tests/unit/accounts/test_forms.py @@ -28,7 +28,7 @@ NoRecoveryCodes, TooManyFailedLogins, ) -from warehouse.accounts.models import DisableReason +from warehouse.accounts.models import DisableReason, ProhibitedEmailDomain from warehouse.captcha import recaptcha from warehouse.events.tags import EventTag from warehouse.utils.webauthn import AuthenticationRejectedError @@ -399,6 +399,9 @@ def test_validate(self): ) form = forms.RegistrationForm( + request=pretend.stub( + db=pretend.stub(query=lambda *a: pretend.stub(scalar=lambda: False)) + ), formdata=MultiDict( { "username": "myusername", @@ -419,6 +422,7 @@ def test_validate(self): def test_password_confirm_required_error(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"password_confirm": ""}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) @@ -435,6 +439,7 @@ def test_passwords_mismatch_error(self, pyramid_config): find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) ) form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict( {"new_password": "password", "password_confirm": "mismatch"} ), @@ -454,6 +459,7 @@ def test_passwords_match_success(self): find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) ) form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict( { "new_password": "MyStr0ng!shPassword", @@ -471,6 +477,7 @@ def test_passwords_match_success(self): def test_email_required_error(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"email": ""}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) @@ -483,8 +490,9 @@ def test_email_required_error(self): assert form.email.errors.pop() == "This field is required." @pytest.mark.parametrize("email", ["bad", "foo]bar@example.com", ""]) - def test_invalid_email_error(self, pyramid_config, email): + def test_invalid_email_error(self, pyramid_request, email): form = forms.RegistrationForm( + request=pyramid_request, formdata=MultiDict({"email": email}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) @@ -500,6 +508,9 @@ def test_invalid_email_error(self, pyramid_config, email): def test_exotic_email_success(self): form = forms.RegistrationForm( + request=pretend.stub( + db=pretend.stub(query=lambda *a: pretend.stub(scalar=lambda: False)) + ), formdata=MultiDict({"email": "foo@n--tree.net"}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) @@ -511,8 +522,12 @@ def test_exotic_email_success(self): form.validate() assert len(form.email.errors) == 0 - def test_email_exists_error(self, pyramid_config): + def test_email_exists_error(self, pyramid_request): + pyramid_request.db = pretend.stub( + query=lambda *a: pretend.stub(scalar=lambda: False) + ) form = forms.RegistrationForm( + request=pyramid_request, formdata=MultiDict({"email": "foo@bar.com"}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: pretend.stub()) @@ -528,8 +543,9 @@ def test_email_exists_error(self, pyramid_config): "Use a different email." ) - def test_prohibited_email_error(self, pyramid_config): + def test_disposable_email_error(self, pyramid_request): form = forms.RegistrationForm( + request=pyramid_request, formdata=MultiDict({"email": "foo@bearsarefuzzy.com"}), user_service=pretend.stub( find_userid_by_email=pretend.call_recorder(lambda _: None) @@ -545,8 +561,30 @@ def test_prohibited_email_error(self, pyramid_config): "different email." ) + def test_prohibited_email_error(self, db_request): + domain = ProhibitedEmailDomain(domain="wutang.net") + db_request.db.add(domain) + + form = forms.RegistrationForm( + request=db_request, + formdata=MultiDict({"email": "foo@wutang.net"}), + user_service=pretend.stub( + find_userid_by_email=pretend.call_recorder(lambda _: None) + ), + captcha_service=pretend.stub(enabled=True), + breach_service=pretend.stub(check_password=lambda pw, tags=None: False), + ) + + assert not form.validate() + assert ( + str(form.email.errors.pop()) + == "You can't use an email address from this domain. Use a " + "different email." + ) + def test_recaptcha_disabled(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"g_recpatcha_response": ""}), user_service=pretend.stub(), captcha_service=pretend.stub( @@ -562,6 +600,7 @@ def test_recaptcha_disabled(self): def test_recaptcha_required_error(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"g_recaptcha_response": ""}), user_service=pretend.stub(), captcha_service=pretend.stub( @@ -575,6 +614,7 @@ def test_recaptcha_required_error(self): def test_recaptcha_error(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"g_recaptcha_response": "asd"}), user_service=pretend.stub(), captcha_service=pretend.stub( @@ -588,6 +628,7 @@ def test_recaptcha_error(self): def test_username_exists(self, pyramid_config): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"username": "foo"}), user_service=pretend.stub( find_userid=pretend.call_recorder(lambda name: 1), @@ -608,6 +649,7 @@ def test_username_exists(self, pyramid_config): def test_username_prohibted(self, pyramid_config): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"username": "foo"}), user_service=pretend.stub( username_is_prohibited=lambda a: True, @@ -628,6 +670,7 @@ def test_username_prohibted(self, pyramid_config): @pytest.mark.parametrize("username", ["_foo", "bar_", "foo^bar", "boo\0far"]) def test_username_is_valid(self, username, pyramid_config): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"username": username}), user_service=pretend.stub( find_userid=pretend.call_recorder(lambda _: None), @@ -656,6 +699,7 @@ def test_password_strength(self): ) for pwd, valid in cases: form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"new_password": pwd, "password_confirm": pwd}), user_service=pretend.stub(), captcha_service=pretend.stub( @@ -669,6 +713,7 @@ def test_password_strength(self): def test_password_breached(self): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"new_password": "password"}), user_service=pretend.stub( find_userid=pretend.call_recorder(lambda _: None) @@ -693,6 +738,7 @@ def test_password_breached(self): def test_name_too_long(self, pyramid_config): form = forms.RegistrationForm( + request=pretend.stub(), formdata=MultiDict({"full_name": "hello " * 50}), user_service=pretend.stub( find_userid=pretend.call_recorder(lambda _: None) @@ -720,6 +766,7 @@ class TestRequestPasswordResetForm: ) def test_validate(self, form_input): form = forms.RequestPasswordResetForm( + request=pretend.stub(), formdata=MultiDict({"username_or_email": form_input}), ) assert form.validate() diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index 5c2066452dfa..09cba574612f 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -76,6 +76,13 @@ def test_includeme(): factory="warehouse.accounts.models:UserFactory", traverse="/{username}", ), + pretend.call( + "admin.user.freeze", + "/admin/users/{username}/freeze/", + domain=warehouse, + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", + ), pretend.call( "admin.user.reset_password", "/admin/users/{username}/reset_password/", diff --git a/tests/unit/admin/views/test_users.py b/tests/unit/admin/views/test_users.py index 82a964ed46d4..c94f99753dcb 100644 --- a/tests/unit/admin/views/test_users.py +++ b/tests/unit/admin/views/test_users.py @@ -20,6 +20,7 @@ from warehouse.accounts.interfaces import IEmailBreachedService, IUserService from warehouse.accounts.models import ( DisableReason, + ProhibitedEmailDomain, ProhibitedUserName, RecoveryCode, WebAuthn, @@ -396,6 +397,65 @@ def test_user_delete_redirects_actual_name(self, db_request): ] +class TestUserFreeze: + def test_freezes_user(self, db_request, monkeypatch): + user = UserFactory.create() + verified_email = EmailFactory.create(user=user, verified=True, primary=True) + EmailFactory.create(user=user, verified=False, primary=False) + + db_request.matchdict["username"] = str(user.username) + db_request.params = {"username": user.username} + db_request.route_path = pretend.call_recorder(lambda a: "/foobar") + db_request.user = UserFactory.create() + + result = views.user_freeze(user, db_request) + + db_request.db.flush() + + assert db_request.db.get(User, user.id).is_frozen + prohibition = db_request.db.query(ProhibitedEmailDomain).one() + assert prohibition.domain == verified_email.domain + + assert db_request.route_path.calls == [pretend.call("admin.user.list")] + assert result.status_code == 303 + assert result.location == "/foobar" + + def test_freezes_user_bad_confirm(self, db_request, monkeypatch): + user = UserFactory.create(is_frozen=False) + EmailFactory.create(user=user, verified=True, primary=True) + + db_request.matchdict["username"] = str(user.username) + db_request.params = {"username": "wrong"} + db_request.route_path = pretend.call_recorder(lambda a, **k: "/foobar") + + result = views.user_freeze(user, db_request) + + db_request.db.flush() + + assert not db_request.db.get(User, user.id).is_frozen + assert not db_request.db.query(ProhibitedEmailDomain).all() + assert db_request.route_path.calls == [ + pretend.call("admin.user.detail", username=user.username) + ] + assert result.status_code == 303 + assert result.location == "/foobar" + + def test_user_freeze_redirects_actual_name(self, db_request): + user = UserFactory.create(username="wu-tang") + db_request.matchdict["username"] = "Wu-Tang" + db_request.current_route_path = pretend.call_recorder( + lambda username: "/user/the-redirect/" + ) + + result = views.user_freeze(user, db_request) + + assert isinstance(result, HTTPMovedPermanently) + assert result.headers["Location"] == "/user/the-redirect/" + assert db_request.current_route_path.calls == [ + pretend.call(username=user.username) + ] + + class TestUserResetPassword: def test_resets_password(self, db_request, monkeypatch): user = UserFactory.create() diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py index 9939ca379b2d..f58bb61754bc 100644 --- a/tests/unit/manage/test_forms.py +++ b/tests/unit/manage/test_forms.py @@ -173,6 +173,9 @@ def test_validate(self): user_id = pretend.stub() user_service = pretend.stub(find_userid_by_email=lambda _: None) form = forms.AddEmailForm( + request=pretend.stub( + db=pretend.stub(query=lambda *a: pretend.stub(scalar=lambda: False)) + ), formdata=MultiDict({"email": "foo@bar.com"}), user_id=user_id, user_service=user_service, @@ -182,9 +185,13 @@ def test_validate(self): assert form.user_service is user_service assert form.validate(), str(form.errors) - def test_email_exists_error(self, pyramid_config): + def test_email_exists_error(self, pyramid_request): + pyramid_request.db = pretend.stub( + query=lambda *a: pretend.stub(scalar=lambda: False) + ) user_id = pretend.stub() form = forms.AddEmailForm( + request=pyramid_request, formdata=MultiDict({"email": "foo@bar.com"}), user_id=user_id, user_service=pretend.stub(find_userid_by_email=lambda _: user_id), @@ -197,8 +204,12 @@ def test_email_exists_error(self, pyramid_config): "Use a different email." ) - def test_email_exists_other_account_error(self, pyramid_config): + def test_email_exists_other_account_error(self, pyramid_request): + pyramid_request.db = pretend.stub( + query=lambda *a: pretend.stub(scalar=lambda: False) + ) form = forms.AddEmailForm( + request=pyramid_request, formdata=MultiDict({"email": "foo@bar.com"}), user_id=pretend.stub(), user_service=pretend.stub(find_userid_by_email=lambda _: pretend.stub()), @@ -211,8 +222,12 @@ def test_email_exists_other_account_error(self, pyramid_config): "Use a different email." ) - def test_prohibited_email_error(self, pyramid_config): + def test_prohibited_email_error(self, pyramid_request): + pyramid_request.db = pretend.stub( + query=lambda *a: pretend.stub(scalar=lambda: False) + ) form = forms.AddEmailForm( + request=pyramid_request, formdata=MultiDict({"email": "foo@bearsarefuzzy.com"}), user_service=pretend.stub(find_userid_by_email=lambda _: None), user_id=pretend.stub(), @@ -227,6 +242,9 @@ def test_prohibited_email_error(self, pyramid_config): def test_email_too_long_error(self, pyramid_config): form = forms.AddEmailForm( + request=pretend.stub( + db=pretend.stub(query=lambda *a: pretend.stub(scalar=lambda: False)) + ), formdata=MultiDict({"email": f"{'x' * 300}@bar.com"}), user_service=pretend.stub(find_userid_by_email=lambda _: None), user_id=pretend.stub(), diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 39c50e033f54..830d1b9b4a18 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -147,7 +147,7 @@ def test_default_response(self, monkeypatch, public_email, expected_public_email ) ] assert add_email_cls.calls == [ - pretend.call(user_id=user_id, user_service=user_service) + pretend.call(request=request, user_id=user_id, user_service=user_service) ] assert change_pass_cls.calls == [ pretend.call( diff --git a/warehouse/accounts/forms.py b/warehouse/accounts/forms.py index 4dfb22d39707..016e37a5f495 100644 --- a/warehouse/accounts/forms.py +++ b/warehouse/accounts/forms.py @@ -22,6 +22,8 @@ import wtforms import wtforms.fields +from sqlalchemy import exists + import warehouse.utils.otp as otp import warehouse.utils.webauthn as webauthn @@ -32,7 +34,7 @@ NoRecoveryCodes, TooManyFailedLogins, ) -from warehouse.accounts.models import DisableReason +from warehouse.accounts.models import DisableReason, ProhibitedEmailDomain from warehouse.accounts.services import RECOVERY_CODE_BYTES from warehouse.captcha import recaptcha from warehouse.email import ( @@ -269,21 +271,30 @@ class NewEmailMixin: ] ) + def __init__(self, *args, request, **kwargs): + self.request = request + super().__init__(*args, **kwargs) + def validate_email(self, field): # Additional checks for the validity of the address try: Address(addr_spec=field.data) except (ValueError, HeaderParseError): raise wtforms.validators.ValidationError( - _("The email address isn't valid. Try again.") + self.request._("The email address isn't valid. Try again.") ) # Check if the domain is valid domain = field.data.split("@")[-1] - if domain in disposable_email_domains.blocklist: + if ( + domain in disposable_email_domains.blocklist + or self.request.db.query( + exists().where(ProhibitedEmailDomain.domain == domain) + ).scalar() + ): raise wtforms.validators.ValidationError( - _( + self.request._( "You can't use an email address from this domain. Use a " "different email." ) @@ -294,14 +305,14 @@ def validate_email(self, field): if userid and userid == self.user_id: raise wtforms.validators.ValidationError( - _( + self.request._( "This email address is already being used by this account. " "Use a different email." ) ) if userid: raise wtforms.validators.ValidationError( - _( + self.request._( "This email address is already being used " "by another account. Use a different email." ) diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index b9dcf2ded4ed..9618347ba49b 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -346,6 +346,26 @@ class Email(db.ModelBase): unverify_reason: Mapped[UnverifyReasons | None] transient_bounces: Mapped[int] = mapped_column(server_default=sql.text("0")) + @property + def domain(self): + return self.email.split("@")[-1].lower() + + +class ProhibitedEmailDomain(db.Model): + __tablename__ = "prohibited_email_domains" + __repr__ = make_repr("domain") + + created: Mapped[datetime_now] + domain: Mapped[str] = mapped_column(unique=True) + _prohibited_by: Mapped[UUID | None] = mapped_column( + "prohibited_by", + PG_UUID(as_uuid=True), + ForeignKey("users.id"), + index=True, + ) + prohibited_by: Mapped[User] = orm.relationship(User) + comment: Mapped[str] = mapped_column(server_default="") + class ProhibitedUserName(db.Model): __tablename__ = "prohibited_user_names" diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index 8a3ad8086b10..139976739111 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -679,6 +679,7 @@ def register(request, _form_class=RegistrationForm): ) form = _form_class( + request=request, formdata=post_body, user_service=user_service, captcha_service=captcha_service, diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index 440e7d0d2bfc..abcce14cdfc2 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -74,6 +74,13 @@ def includeme(config): factory="warehouse.accounts.models:UserFactory", traverse="/{username}", ) + config.add_route( + "admin.user.freeze", + "/admin/users/{username}/freeze/", + domain=warehouse, + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", + ) config.add_route( "admin.user.reset_password", "/admin/users/{username}/reset_password/", diff --git a/warehouse/admin/templates/admin/users/detail.html b/warehouse/admin/templates/admin/users/detail.html index 87298c20ce66..b96afac62481 100644 --- a/warehouse/admin/templates/admin/users/detail.html +++ b/warehouse/admin/templates/admin/users/detail.html @@ -103,6 +103,9 @@

Actions

+ @@ -112,7 +115,48 @@

Actions

-