Skip to content
This repository has been archived by the owner on Feb 22, 2023. It is now read-only.

Commit

Permalink
Add command to resend verification emails (#694)
Browse files Browse the repository at this point in the history
* Add command to resend verification emails - WIP

* Add unit tests

* Test not sending for verified

* Fix lint errors

* Add unit test using the view

* Lint
  • Loading branch information
sarayourfriend authored May 17, 2022
1 parent d9f83e5 commit 017b716
Show file tree
Hide file tree
Showing 3 changed files with 627 additions and 1 deletion.
225 changes: 225 additions & 0 deletions api/catalog/management/commands/resendoauthverification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import argparse
from dataclasses import dataclass

from django.conf import settings
from django.core.mail import send_mail
from django.db import transaction
from django.db.models import Q
from rest_framework.reverse import reverse

from django_redis import get_redis_connection
from django_tqdm import BaseCommand

from catalog.api.models.oauth import (
OAuth2Registration,
OAuth2Verification,
ThrottledApplication,
)


def get_input(text):
"""
Wrapped ``input`` to allow patching in unittests
"""
return input(text)


verification_msg_template = """
The Openverse API OAuth2 email verification process has recently been fixed.
We have detected that you attempted to register an application using this email.
To verify your Openverse API credentials, click on the following link:
{link}
If you believe you received this message in error, please disregard it.
"""


@dataclass
class Result:
saved_application_name: str
deleted_applications: int
deleted_registrations: int
deleted_verifications: int


class Command(BaseCommand):
help = "Resends verification emails for unverified Oauth applications."
"""
This command is meant to be used a single time in production to remediate
failed email sending.
It stores a cache of successfully sent emails in Redis, so running it multiple
times (in case of failure) should not be an issue.
"""

processed_key = "resendoauthverification:processed"

def add_arguments(self, parser):
parser.add_argument(
"--dry_run",
help="Count the records that will be removed but don't apply any changes.",
type=bool,
default=True,
action=argparse.BooleanOptionalAction,
)

@transaction.atomic
def _handle_email(self, email, dry):
"""
1. Get all application IDs for the email
2. Use the one with the lowest ID as the "original" attempt
3. Delete the rest
4. Delete OAuth2Registrations for the email not associated
with the "original" application
5. Delete OAuth2Verifications for the email not associated
with the "original" application
This ignores the fact that someone could have tried to register
multiple unverified but distinct applications under the same email.
This is unlikely given that none of the requests would have worked
and that the "feature" isn't explicitly documented anyway.
"""
application_ids = list(
OAuth2Registration.objects.filter(email=email)
.select_related("associated_application")
.order_by("id")
.values_list("pk", flat=True)
)

application_to_verify = ThrottledApplication.objects.get(pk=application_ids[0])

deleted_applications = 0
deleted_registrations = 0
deleted_verifications = 0
if len(application_ids) > 1:
applications_to_delete_ids = application_ids[1:]
deleted_applications = len(applications_to_delete_ids)
if not dry:
ThrottledApplication.objects.filter(
pk__in=applications_to_delete_ids
).delete()

registrations_to_delete = OAuth2Registration.objects.filter(
email=email
).exclude(name=application_to_verify.name)
deleted_registrations = registrations_to_delete.count()
if not dry:
registrations_to_delete.delete()

verifications_to_delete = OAuth2Verification.objects.filter(
email=email
).exclude(associated_application=application_to_verify)
deleted_verifications = verifications_to_delete.count()
if not dry:
verifications_to_delete.delete()

if not dry:
verification = OAuth2Verification.objects.get(
associated_application=application_to_verify
)
token = verification.code
# We don't have access to `request.build_absolute_uri` so we
# have to build it ourselves for the production endpoint
link = (
f"https://api.openverse.engineering/{reverse('verify-email', [token])}"
)
verification_msg = verification_msg_template.format(link=link)
send_mail(
subject="Verify your API credentials",
message=verification_msg,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[verification.email],
fail_silently=False,
)

return Result(
saved_application_name=application_to_verify.name,
deleted_applications=deleted_applications,
deleted_registrations=deleted_registrations,
deleted_verifications=deleted_verifications,
)

def handle(self, *args, **options):
dry = options["dry_run"]
if not dry:
self.info(
self.style.WARNING(
"This is NOT a dry run. Are you sure you wish to proceed? "
"Respond 'yes' in all uppercase to proceed.\n"
)
)
if get_input(": ") != "YES":
self.error("Exiting.")
exit(1)

redis = get_redis_connection("default")

already_processed_emails = [
email.decode("utf-8") for email in redis.smembers(self.processed_key)
]

emails_with_verified_applications = OAuth2Verification.objects.filter(
Q(associated_application__verified=True)
| Q(email__in=already_processed_emails)
).values_list("email", flat=True)

emails_with_zero_verified_applications = list(
OAuth2Verification.objects.exclude(
email__in=emails_with_verified_applications
)
.values_list("email", flat=True)
.distinct()
)

count_to_process = len(emails_with_zero_verified_applications)
results = []
errored_emails = []

with self.tqdm(total=count_to_process) as progress:
for email in emails_with_zero_verified_applications:
try:
results.append(self._handle_email(email, dry))
if not dry:
redis.sadd(self.processed_key, email)
except BaseException as err:
errored_emails.append(email)
self.error(f"Unable to process {email}: " f"{err}")

progress.update(1)

if errored_emails:
joined = "\n".join(errored_emails)
self.info(
self.style.WARNING(
f"The following emails were unable to be processed.\n\n"
f"{joined}"
"\n\nPlease check the output above for the error related"
"to each email."
)
)

formatted_results = "\n\n".join(
(
f"Application name: {result.saved_application_name}\n"
f"Cleaned related application count: {result.deleted_applications}\n"
f"Cleaned related verification count: {result.deleted_verifications}\n"
f"Cleaned related registration count: {result.deleted_registrations}\n"
)
for result in results
)

self.info(
self.style.SUCCESS(
f"The following applications had email verifications sent.\n\n"
f"{formatted_results}"
)
)

if dry:
self.info(
self.style.WARNING(
"The above was a dry run and no records were actually affected."
)
)
25 changes: 24 additions & 1 deletion api/test/factory/models/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
from factory.django import DjangoModelFactory
from oauth2_provider.models import AccessToken

from catalog.api.models.oauth import ThrottledApplication
from catalog.api.models.oauth import (
OAuth2Registration,
OAuth2Verification,
ThrottledApplication,
)


class ThrottledApplicationFactory(DjangoModelFactory):
class Meta:
model = ThrottledApplication

name = Faker("md5")
client_type = Faker(
"random_choice_field", choices=ThrottledApplication.CLIENT_TYPES
)
Expand All @@ -19,6 +24,24 @@ class Meta:
)


class OAuth2RegistrationFactory(DjangoModelFactory):
class Meta:
model = OAuth2Registration

name = Faker("md5")
description = Faker("catch_phrase")
email = Faker("email")


class OAuth2VerificationFactory(DjangoModelFactory):
class Meta:
model = OAuth2Verification

associated_application = factory.SubFactory(ThrottledApplicationFactory)
email = Faker("email")
code = Faker("md5")


class AccessTokenFactory(DjangoModelFactory):
class Meta:
model = AccessToken
Expand Down
Loading

0 comments on commit 017b716

Please sign in to comment.