Skip to content

Commit 3e07a63

Browse files
committed
feat(admin): manually execute domain check
Signed-off-by: Mike Fiedler <[email protected]>
1 parent e562f5e commit 3e07a63

File tree

8 files changed

+184
-39
lines changed

8 files changed

+184
-39
lines changed

tests/unit/accounts/test_core.py

+3
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/admin/test_routes.py

+7
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/",

warehouse/accounts/__init__.py

+10
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

+7
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/services.py

+51
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,47 @@ 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, api_key):
987+
self._http = session
988+
self.api_key = api_key
989+
990+
@classmethod
991+
def create_service(cls, _context, request: Request) -> DomainrDomainStatusService:
992+
domainr_api_key = request.registry.settings.get("domainr.api_key")
993+
return cls(session=request.http, api_key=domainr_api_key)
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+
# bail early if no api key is set, so we don't send failing requests
1001+
if not self.api_key:
1002+
return []
1003+
1004+
try:
1005+
resp = self._http.get(
1006+
"https://api.domainr.com/v2/status",
1007+
params={"client_id": self.api_key, "domain": domain},
1008+
timeout=5,
1009+
)
1010+
resp.raise_for_status()
1011+
except requests.RequestException as exc:
1012+
logger.warning("Error contacting Domainr: %r", exc)
1013+
return []
1014+
1015+
return resp.json()["status"][0]["status"].split()

warehouse/admin/routes.py

+7
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ def includeme(config):
8787
factory="warehouse.accounts.models:UserFactory",
8888
traverse="/{username}",
8989
)
90+
config.add_route(
91+
"admin.user.email_domain_check",
92+
"/admin/users/{username}/email_domain_check/",
93+
factory="warehouse.accounts.models:UserFactory",
94+
domain=warehouse,
95+
traverse="/{username}",
96+
)
9097
config.add_route(
9198
"admin.user.delete",
9299
"/admin/users/{username}/delete/",

warehouse/admin/templates/admin/users/detail.html

+68-39
Original file line numberDiff line numberDiff line change
@@ -522,49 +522,45 @@ <h3 class="card-title">Emails</h3>
522522
<div class="alert alert-danger"><i class="icon fa fa-ban"></i> {{ error }}</div>
523523
{% endfor %}
524524
{% endif %}
525-
526-
{% for field in emails_form.emails.entries %}
527-
<div class="row">
528-
<div class="col-sm-7">
529-
{{ render_field("Email", field.email, "email-" ~ loop.index0, class="form-control", placeholder="Email", permission=perms_admin_users_email_write)}}
530-
</div>
531-
<div class="col-sm">
532-
{{ render_checkbox("Primary", field.primary, "email-primary-" ~ loop.index0, permission=perms_admin_users_email_write)}}
533-
</div>
534-
<div class="col-sm">
535-
{{ render_checkbox("Verified", field.verified, "email-verified-" ~ loop.index0, permission=perms_admin_users_email_write) }}
536-
</div>
537-
<div class="col-sm">
538-
{{ render_checkbox("Public", field.public, "email-public-" ~ loop.index0, permission=perms_admin_users_email_write) }}
539-
</div>
540-
{% if breached_email_count[field.email.data] %}
541-
<div class="col-sm">
542-
Breaches: {{ breached_email_count[field.email.data] }}
543-
</div>
544-
{% endif %}
545-
{% if field.unverify_reason.data %}
546-
<div class="col">
547-
<i class="fa fa-times text-red"></i> Unverify Reason: {{ field.unverify_reason.data.value }}
548-
</div>
549-
{% endif %}
550-
</div>
551525
<div class="row">
552526
<div class="col">
553-
Domain Status: {{ field.domain_last_status.data }}
554-
</div>
555-
<div class="col">
556-
Last checked: {{ field.domain_last_checked.data }}
527+
{% for field in emails_form.emails.entries %}
528+
<div class="row">
529+
<div class="col-sm-6">
530+
{{ render_field("Email", field.email, "email-" ~ loop.index0, class="form-control", placeholder="Email", permission=perms_admin_users_email_write) }}
531+
</div>
532+
<div class="col-sm">
533+
{{ render_checkbox("Primary", field.primary, "email-primary-" ~ loop.index0, permission=perms_admin_users_email_write) }}
534+
</div>
535+
<div class="col-sm">
536+
{{ render_checkbox("Verified", field.verified, "email-verified-" ~ loop.index0, permission=perms_admin_users_email_write) }}
537+
</div>
538+
<div class="col-sm">
539+
{{ render_checkbox("Public", field.public, "email-public-" ~ loop.index0, permission=perms_admin_users_email_write) }}
540+
</div>
541+
{% if breached_email_count[field.email.data] %}
542+
<div class="col-sm">Breaches: {{ breached_email_count[field.email.data] }}</div>
543+
{% endif %}
544+
{% if field.unverify_reason.data %}
545+
<div class="col">
546+
<i class="fa fa-times text-red"></i> Unverify Reason: {{ field.unverify_reason.data.value }}
547+
</div>
548+
{% endif %}
549+
</div>
550+
<div class="row">
551+
<div class="col-sm">Domain Status: {{ field.domain_last_status.data }}</div>
552+
<div class="col-sm">Last checked: {{ field.domain_last_checked.data }}</div>
553+
<div class="col-sm">
554+
<button type="button"
555+
class="btn btn-sm btn-info"
556+
data-toggle="modal"
557+
data-target="#checkEmailDomain-{{ field.email.id }}">Check now</button>
558+
</div>
559+
</div>
560+
{% if not loop.last %}<hr />{% endif %}
561+
{% endfor %}
557562
</div>
558-
{# TODO: Uncomment after implementing an admin route to run the check and flash, but needs to work for each email #}
559-
{# <div class="col">#}
560-
{# <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#checkDomainModal">#}
561-
{# <i class="fa-solid fa-magnifying-glass"></i>#}
562-
{# Check Domain Now #}
563-
{# </button>#}
564-
{# </div>#}
565563
</div>
566-
{% if not loop.last %}<hr />{% endif %}
567-
{% endfor %}
568564
</div>
569565

570566
<div class="card-footer">
@@ -1027,4 +1023,37 @@ <h3 class="card-title">Journals (most recent)</h3>
10271023

10281024
</div>
10291025
</div>
1026+
{% for email in emails_form.emails.entries %}
1027+
<div class="modal fade"
1028+
id="checkEmailDomain-{{ email.id }}-email"
1029+
tabindex="-1"
1030+
role="dialog">
1031+
<form method="POST"
1032+
action="{{ request.route_path('admin.user.email_domain_check', username=user.username, email_address=email.email) }}">
1033+
<input name="csrf_token"
1034+
type="hidden"
1035+
value="{{ request.session.get_csrf_token() }}">
1036+
<input name="email_address" type="hidden" value="{{ email.email.data }}">
1037+
<div class="modal-dialog" role="document">
1038+
<div class="modal-content">
1039+
<div class="modal-header">
1040+
<h4 class="modal-title" id="checkDomainStatusModalLabel">Check domain status?</h4>
1041+
<button type="button" class="close" data-dismiss="modal">
1042+
<span>&times;</span>
1043+
</button>
1044+
</div>
1045+
<div class="modal-body">
1046+
<p>Perform a domain status lookup check and update the database with the results.</p>
1047+
<p>Check domain for email address:</p>
1048+
<code>{{ email.email.data }}</code>
1049+
</div>
1050+
<div class="modal-footer">
1051+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
1052+
<button type="submit" class="btn btn-info">Check domain</button>
1053+
</div>
1054+
</div>
1055+
</div>
1056+
</form>
1057+
</div>
1058+
{% endfor %}
10301059
{% endblock %}

warehouse/admin/views/users.py

+31
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from warehouse.accounts.interfaces import (
3030
BurnedRecoveryCode,
31+
IDomainStatusService,
3132
IEmailBreachedService,
3233
InvalidRecoveryCode,
3334
IUserService,
@@ -665,3 +666,33 @@ def user_burn_recovery_codes(user, request):
665666

666667
request.session.flash(f"Burned {n_burned} recovery code(s)", queue="success")
667668
return HTTPSeeOther(request.route_path("admin.user.detail", username=user.username))
669+
670+
671+
@view_config(
672+
route_name="admin.user.email_domain_check",
673+
require_methods=["POST"],
674+
permission=Permissions.AdminUsersEmailWrite,
675+
uses_session=True,
676+
require_csrf=True,
677+
context=User,
678+
)
679+
def user_email_domain_check(user, request):
680+
"""
681+
Run a background check on the email domain of the user.
682+
Flash the user that the check has been done.
683+
"""
684+
email_address = request.params.get("email_address")
685+
email = request.db.scalar(select(Email).where(Email.email == email_address))
686+
687+
domain_status_service = request.find_service(IDomainStatusService)
688+
domain_status = domain_status_service.get_domain_status(email_address)
689+
690+
# set the domain status to the email address
691+
email.domain_last_checked = datetime.datetime.now(datetime.UTC)
692+
email.domain_last_status = domain_status
693+
request.db.add(email)
694+
695+
request.session.flash(
696+
f"Domain status check for {email_address!r} completed", queue="success"
697+
)
698+
return HTTPSeeOther(request.route_path("admin.user.detail", username=user.username))

0 commit comments

Comments
 (0)