Skip to content

Commit aaa7a72

Browse files
authored
feat: add domain verification (#17832)
* feat: add domain verification columns to Email Signed-off-by: Mike Fiedler <[email protected]> * feat(admin): surface domain status in UI Not the most beautiful, but good enough to start with. It was interesting to learn about the ability to reflect back some data from the underlying object through the form, thanks `unverify_reason`! Signed-off-by: Mike Fiedler <[email protected]> * feat(admin): manually execute domain check Signed-off-by: Mike Fiedler <[email protected]> * chore: add missing comment to migrations Signed-off-by: Mike Fiedler <[email protected]> --------- Signed-off-by: Mike Fiedler <[email protected]>
1 parent 162ccc7 commit aaa7a72

File tree

16 files changed

+360
-26
lines changed

16 files changed

+360
-26
lines changed

dev/environment

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,6 @@ HELPDESK_BACKEND="warehouse.helpdesk.services.ConsoleHelpDeskService"
9191

9292
# HELPDESK_NOTIFICATION_SERVICE_URL="https://..."
9393
HELPDESK_NOTIFICATION_BACKEND="warehouse.helpdesk.services.ConsoleAdminNotificationService"
94+
95+
# Example of Domain Status configuration
96+
# DOMAIN_STATUS_BACKEND="warehouse.accounts.services.DomainrDomainStatusService client_id=some_client_id"

tests/conftest.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@
4444

4545
from warehouse import admin, config, static
4646
from warehouse.accounts import services as account_services
47-
from warehouse.accounts.interfaces import ITokenService, IUserService
47+
from warehouse.accounts.interfaces import (
48+
IDomainStatusService,
49+
ITokenService,
50+
IUserService,
51+
)
4852
from warehouse.admin.flags import AdminFlag, AdminFlagValue
4953
from warehouse.attestations import services as attestations_services
5054
from warehouse.attestations.interfaces import IIntegrityService
@@ -169,6 +173,7 @@ def pyramid_services(
169173
notification_service,
170174
query_results_cache_service,
171175
search_service,
176+
domain_status_service,
172177
):
173178
services = _Services()
174179

@@ -194,6 +199,7 @@ def pyramid_services(
194199
services.register_service(notification_service, IAdminNotificationService)
195200
services.register_service(query_results_cache_service, IQueryResultsCache)
196201
services.register_service(search_service, ISearchService)
202+
services.register_service(domain_status_service, IDomainStatusService)
197203

198204
return services
199205

@@ -543,6 +549,11 @@ def search_service():
543549
return search_services.NullSearchService()
544550

545551

552+
@pytest.fixture
553+
def domain_status_service():
554+
return account_services.NullDomainStatusService()
555+
556+
546557
class QueryRecorder:
547558
def __init__(self):
548559
self.queries = []

tests/unit/accounts/test_core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from warehouse import accounts
1919
from warehouse.accounts.interfaces import (
20+
IDomainStatusService,
2021
IEmailBreachedService,
2122
IPasswordBreachedService,
2223
ITokenService,
@@ -25,6 +26,7 @@
2526
from warehouse.accounts.services import (
2627
HaveIBeenPwnedEmailBreachedService,
2728
HaveIBeenPwnedPasswordBreachedService,
29+
NullDomainStatusService,
2830
TokenServiceFactory,
2931
database_login_factory,
3032
)
@@ -186,6 +188,7 @@ def test_includeme(monkeypatch):
186188
HaveIBeenPwnedEmailBreachedService.create_service,
187189
IEmailBreachedService,
188190
),
191+
pretend.call(NullDomainStatusService.create_service, IDomainStatusService),
189192
pretend.call(RateLimit("10 per 5 minutes"), IRateLimiter, name="user.login"),
190193
pretend.call(RateLimit("10 per 5 minutes"), IRateLimiter, name="ip.login"),
191194
pretend.call(

tests/unit/accounts/test_services.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from warehouse.accounts import services
3131
from warehouse.accounts.interfaces import (
3232
BurnedRecoveryCode,
33+
IDomainStatusService,
3334
IEmailBreachedService,
3435
InvalidRecoveryCode,
3536
IPasswordBreachedService,
@@ -1635,3 +1636,79 @@ def test_factory(self):
16351636

16361637
assert isinstance(svc, services.NullEmailBreachedService)
16371638
assert svc.get_email_breach_count("[email protected]") == 0
1639+
1640+
1641+
class TestNullDomainStatusService:
1642+
def test_verify_service(self):
1643+
assert verifyClass(IDomainStatusService, services.NullDomainStatusService)
1644+
1645+
def test_get_domain_status(self):
1646+
svc = services.NullDomainStatusService()
1647+
assert svc.get_domain_status("example.com") == ["active"]
1648+
1649+
def test_factory(self):
1650+
context = pretend.stub()
1651+
request = pretend.stub()
1652+
svc = services.NullDomainStatusService.create_service(context, request)
1653+
1654+
assert isinstance(svc, services.NullDomainStatusService)
1655+
assert svc.get_domain_status("example.com") == ["active"]
1656+
1657+
1658+
class TestDomainrDomainStatusService:
1659+
def test_verify_service(self):
1660+
assert verifyClass(IDomainStatusService, services.DomainrDomainStatusService)
1661+
1662+
def test_successful_domain_status_check(self):
1663+
response = pretend.stub(
1664+
json=lambda: {
1665+
"status": [{"domain": "example.com", "status": "undelegated inactive"}]
1666+
},
1667+
raise_for_status=lambda: None,
1668+
)
1669+
session = pretend.stub(get=pretend.call_recorder(lambda *a, **kw: response))
1670+
svc = services.DomainrDomainStatusService(
1671+
session=session, client_id="some_client_id"
1672+
)
1673+
1674+
assert svc.get_domain_status("example.com") == ["undelegated", "inactive"]
1675+
assert session.get.calls == [
1676+
pretend.call(
1677+
"https://api.domainr.com/v2/status",
1678+
params={"client_id": "some_client_id", "domain": "example.com"},
1679+
timeout=5,
1680+
)
1681+
]
1682+
1683+
def test_domainr_exception_returns_empty(self):
1684+
class DomainrException(requests.HTTPError):
1685+
def __init__(self):
1686+
self.response = pretend.stub(status_code=400)
1687+
1688+
response = pretend.stub(raise_for_status=pretend.raiser(DomainrException))
1689+
session = pretend.stub(get=pretend.call_recorder(lambda *a, **kw: response))
1690+
svc = services.DomainrDomainStatusService(
1691+
session=session, client_id="some_client_id"
1692+
)
1693+
1694+
assert svc.get_domain_status("example.com") == []
1695+
assert session.get.calls == [
1696+
pretend.call(
1697+
"https://api.domainr.com/v2/status",
1698+
params={"client_id": "some_client_id", "domain": "example.com"},
1699+
timeout=5,
1700+
)
1701+
]
1702+
1703+
def test_factory(self):
1704+
context = pretend.stub()
1705+
request = pretend.stub(
1706+
http=pretend.stub(),
1707+
registry=pretend.stub(
1708+
settings={"domain_status.client_id": "some_client_id"}
1709+
),
1710+
)
1711+
svc = services.DomainrDomainStatusService.create_service(context, request)
1712+
1713+
assert svc._http is request.http
1714+
assert svc.client_id == "some_client_id"

tests/unit/admin/test_routes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ def test_includeme():
8989
factory="warehouse.accounts.models:UserFactory",
9090
traverse="/{username}",
9191
),
92+
pretend.call(
93+
"admin.user.email_domain_check",
94+
"/admin/users/{username}/email_domain_check/",
95+
domain=warehouse,
96+
factory="warehouse.accounts.models:UserFactory",
97+
traverse="/{username}",
98+
),
9299
pretend.call(
93100
"admin.user.delete",
94101
"/admin/users/{username}/delete/",

tests/unit/admin/views/test_users.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,3 +1539,26 @@ def test_no_recovery_codes_provided(self, db_request, monkeypatch, user_service)
15391539
]
15401540
assert result.status_code == 303
15411541
assert result.location == "/foobar"
1542+
1543+
1544+
class TestUserEmailDomainCheck:
1545+
def test_user_email_domain_check(self, db_request):
1546+
user = UserFactory.create(with_verified_primary_email=True)
1547+
db_request.POST["email_address"] = user.primary_email.email
1548+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/foobar")
1549+
db_request.session = pretend.stub(
1550+
flash=pretend.call_recorder(lambda *a, **kw: None)
1551+
)
1552+
1553+
result = views.user_email_domain_check(user, db_request)
1554+
1555+
assert isinstance(result, HTTPSeeOther)
1556+
assert result.headers["Location"] == "/foobar"
1557+
assert db_request.session.flash.calls == [
1558+
pretend.call(
1559+
f"Domain status check for '{user.primary_email.domain}' completed",
1560+
queue="success",
1561+
)
1562+
]
1563+
assert user.primary_email.domain_last_checked is not None
1564+
assert user.primary_email.domain_last_status == ["active"]

warehouse/accounts/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from celery.schedules import crontab
1414

1515
from warehouse.accounts.interfaces import (
16+
IDomainStatusService,
1617
IEmailBreachedService,
1718
IPasswordBreachedService,
1819
ITokenService,
@@ -25,6 +26,7 @@
2526
from warehouse.accounts.services import (
2627
HaveIBeenPwnedEmailBreachedService,
2728
HaveIBeenPwnedPasswordBreachedService,
29+
NullDomainStatusService,
2830
NullEmailBreachedService,
2931
NullPasswordBreachedService,
3032
TokenServiceFactory,
@@ -131,6 +133,14 @@ def includeme(config):
131133
breached_email_class.create_service, IEmailBreachedService
132134
)
133135

136+
# Register our domain status service.
137+
domain_status_class = config.maybe_dotted(
138+
config.registry.settings.get("domain_status.backend", NullDomainStatusService)
139+
)
140+
config.register_service_factory(
141+
domain_status_class.create_service, IDomainStatusService
142+
)
143+
134144
# Register our security policies.
135145
config.set_security_policy(
136146
MultiSecurityPolicy(

warehouse/accounts/interfaces.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,10 @@ def get_email_breach_count(email: str) -> int | None:
298298
"""
299299
Returns count of times the email appears in verified breaches.
300300
"""
301+
302+
303+
class IDomainStatusService(Interface):
304+
def get_domain_status(domain: str) -> list[str]:
305+
"""
306+
Returns a list of status strings for the given domain.
307+
"""

warehouse/accounts/models.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
select,
3030
sql,
3131
)
32-
from sqlalchemy.dialects.postgresql import CITEXT, UUID as PG_UUID
32+
from sqlalchemy.dialects.postgresql import ARRAY, CITEXT, UUID as PG_UUID
3333
from sqlalchemy.exc import NoResultFound
3434
from sqlalchemy.ext.hybrid import hybrid_property
3535
from sqlalchemy.orm import Mapped, mapped_column
@@ -424,6 +424,15 @@ class Email(db.ModelBase):
424424
unverify_reason: Mapped[UnverifyReasons | None]
425425
transient_bounces: Mapped[int] = mapped_column(server_default=sql.text("0"))
426426

427+
# Domain validation information
428+
domain_last_checked: Mapped[datetime.datetime | None] = mapped_column(
429+
comment="Last time domain was checked with the domain validation service.",
430+
)
431+
domain_last_status: Mapped[list[str] | None] = mapped_column(
432+
ARRAY(String),
433+
comment="Status strings returned by the domain validation service.",
434+
)
435+
427436
@property
428437
def domain(self):
429438
return self.email.split("@")[-1].lower()

warehouse/accounts/services.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
from __future__ import annotations
14+
1315
import collections
1416
import datetime
1517
import functools
@@ -18,6 +20,7 @@
1820
import logging
1921
import os
2022
import secrets
23+
import typing
2124
import urllib.parse
2225

2326
import passlib.exc
@@ -35,6 +38,7 @@
3538

3639
from warehouse.accounts.interfaces import (
3740
BurnedRecoveryCode,
41+
IDomainStatusService,
3842
IEmailBreachedService,
3943
InvalidRecoveryCode,
4044
IPasswordBreachedService,
@@ -62,6 +66,9 @@
6266
from warehouse.rate_limiting import DummyRateLimiter, IRateLimiter
6367
from warehouse.utils.crypto import BadData, SignatureExpired, URLSafeTimedSerializer
6468

69+
if typing.TYPE_CHECKING:
70+
from pyramid.request import Request
71+
6572
logger = logging.getLogger(__name__)
6673

6774
PASSWORD_FIELD = "password"
@@ -962,3 +969,43 @@ def create_service(cls, context, request):
962969
def get_email_breach_count(self, email):
963970
# This service allows *every* email as a non-breached email.
964971
return 0
972+
973+
974+
@implementer(IDomainStatusService)
975+
class NullDomainStatusService:
976+
@classmethod
977+
def create_service(cls, _context, _request):
978+
return cls()
979+
980+
def get_domain_status(self, _domain: str) -> list[str]:
981+
return ["active"]
982+
983+
984+
@implementer(IDomainStatusService)
985+
class DomainrDomainStatusService:
986+
def __init__(self, session, client_id):
987+
self._http = session
988+
self.client_id = client_id
989+
990+
@classmethod
991+
def create_service(cls, _context, request: Request) -> DomainrDomainStatusService:
992+
domainr_client_id = request.registry.settings.get("domain_status.client_id")
993+
return cls(session=request.http, client_id=domainr_client_id)
994+
995+
def get_domain_status(self, domain: str) -> list[str]:
996+
"""
997+
Check if a domain is available or not.
998+
See https://domainr.com/docs/api/v2/status
999+
"""
1000+
try:
1001+
resp = self._http.get(
1002+
"https://api.domainr.com/v2/status",
1003+
params={"client_id": self.client_id, "domain": domain},
1004+
timeout=5,
1005+
)
1006+
resp.raise_for_status()
1007+
except requests.RequestException as exc:
1008+
logger.warning("Error contacting Domainr: %r", exc)
1009+
return []
1010+
1011+
return resp.json()["status"][0]["status"].split()

0 commit comments

Comments
 (0)