From dd95549bf370ec4b6804a889fa725e1d32d0c041 Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Fri, 16 Aug 2024 10:48:34 +0200 Subject: [PATCH] add profile email verification procedure fixes #186 --- ..._profileemail_token_expiration_and_more.py | 17 ------- scipost_django/profiles/models.py | 26 +++++++++- .../_hx_profile_emails_table_row.html | 13 +++-- .../profiles/verify_profile_email.html | 45 ++++++++++++++++++ scipost_django/profiles/urls.py | 17 ++++--- scipost_django/profiles/views.py | 47 +++++++++++++++++-- scipost_django/templates/bi/clock-fill.html | 8 ++++ .../email/profiles/verify_profile_email.html | 26 ++++++++++ .../email/profiles/verify_profile_email.json | 11 +++++ 9 files changed, 177 insertions(+), 33 deletions(-) create mode 100644 scipost_django/profiles/templates/profiles/verify_profile_email.html create mode 100644 scipost_django/templates/bi/clock-fill.html create mode 100644 scipost_django/templates/email/profiles/verify_profile_email.html create mode 100644 scipost_django/templates/email/profiles/verify_profile_email.json diff --git a/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py b/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py index 9f57cba80..498000498 100644 --- a/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py +++ b/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py @@ -5,14 +5,6 @@ from django.db import migrations, models from django.utils import timezone -def reset_profile_email_verification_tokens(apps, schema_editor): - ProfileEmail = apps.get_model("profiles", "ProfileEmail") - for email in ProfileEmail.objects.all(): - email.verification_token = secrets.token_urlsafe(40) - email.verified = email.primary and email.still_valid - email.save() - - class Migration(migrations.Migration): dependencies = [ ("profiles", "0041_profile_orcid_authenticated"), @@ -29,13 +21,4 @@ class Migration(migrations.Migration): name="verification_token", field=models.CharField(max_length=128, null=True, unique=False), ), - migrations.RunPython( - reset_profile_email_verification_tokens, - reverse_code=migrations.RunPython.noop, - ), - migrations.AlterField( - model_name="profileemail", - name="verification_token", - field=models.CharField(max_length=128, unique=True, null=False), - ), ] diff --git a/scipost_django/profiles/models.py b/scipost_django/profiles/models.py index 9c0ae4758..6301a0a29 100644 --- a/scipost_django/profiles/models.py +++ b/scipost_django/profiles/models.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" import datetime +import secrets from django.contrib.contenttypes.fields import GenericRelation from django.db.models import Q @@ -12,6 +13,7 @@ from django.db.models.functions import Concat from django.shortcuts import get_object_or_404 from django.utils import timezone +from mails.utils import DirectMailUtil from scipost.behaviors import orcid_validator from scipost.constants import TITLE_CHOICES, TITLE_DR from scipost.models import Contributor @@ -213,7 +215,7 @@ class ProfileEmail(models.Model): email = models.EmailField() still_valid = models.BooleanField(default=True) verified = models.BooleanField(default=False) - verification_token = models.CharField(max_length=128, unique=True) + verification_token = models.CharField(max_length=128, null=True) token_expiration = models.DateTimeField(default=timezone.now) added_by = models.ForeignKey( "scipost.Contributor", @@ -232,6 +234,28 @@ class ProfileEmail(models.Model): def __str__(self): return self.email + def reset_verification_token(self): + self.verification_token = secrets.token_urlsafe(40) + self.token_expiration = timezone.now() + datetime.timedelta(hours=48) + self.save() + + @property + def has_token_expired(self): + return timezone.now() > self.token_expiration + + def send_verification_email(self): + if self.has_token_expired: + self.reset_verification_token() + + mail_sender = DirectMailUtil("profiles/verify_profile_email", object=self) + mail_sender.send_mail() + + def get_verification_url(self): + return reverse( + "profiles:verify_profile_email", + kwargs={"email_id": self.id, "token": self.verification_token}, + ) + def get_profiles(slug): """ diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html b/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html index 831d19dd5..d2fc5016c 100644 --- a/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html +++ b/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html @@ -20,6 +20,8 @@ {% if profile_mail.verified %} <span class="text-success">{% include "bi/check-circle-fill.html" %}</span> + {% elif not profile_mail.has_token_expired %} + <span class="text-warning">{% include "bi/clock-fill.html" %}</span> {% else %} <span class="text-danger">{% include "bi/x-circle-fill.html" %}</span> {% endif %} @@ -46,16 +48,17 @@ </button> {% endif %} - {% comment %} {% if is_mail_owner or perms.scipost.can_verify_profile_emails %} - <!-- TODO: Implement proper verification of emails --> + {% if is_mail_owner or perms.scipost.can_verify_profile_emails %} <button type="button" class="btn btn-sm btn-light py-0" hx-target="closest tr" hx-swap="outerHTML" + {% if not profile_mail.has_token_expired %}hx-confirm="Your previous verification code has not expired yet. Are you sure you want to resend the verification email?"{% endif %} hx-confirm="This will send a verification email to the address. Are you sure?" - hx-patch="{% url 'profiles:_hx_profile_email_toggle_verified' profile_mail.id %}">Verify</button> - <!-- TODO END--> - {% endif %} {% endcomment %} + hx-patch="{% url 'profiles:_hx_profile_email_request_verification' profile_mail.id %}" + {% if profile_mail.verified %}disabled{% endif %} + >Verify</button> + {% endif %} {% if is_mail_owner or perms.scipost.can_mark_profile_emails_primary %} <button type="button" diff --git a/scipost_django/profiles/templates/profiles/verify_profile_email.html b/scipost_django/profiles/templates/profiles/verify_profile_email.html new file mode 100644 index 000000000..12f06961f --- /dev/null +++ b/scipost_django/profiles/templates/profiles/verify_profile_email.html @@ -0,0 +1,45 @@ +{% extends "profiles/base.html" %} + +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item">Email Verification</span> +{% endblock %} + +{% block content %} + <h1>Email Verification</h1> + <div class="w-100 text-center"> + + <div class="fs-2"> + + {% if was_previously_verified %} + <p> + <span class="text-success me-3">{% include "bi/check-circle-fill.html" %}</span> + Your email address has already been verified. + </p> + {% elif profile_email.verified %} + <p> + <span class="text-success me-3">{% include "bi/check-circle-fill.html" %}</span> + Your email address has been successfully verified. + </p> + {% else %} + <p> + <span class="text-danger me-3">{% include "bi/x-circle-fill.html" %}</span> + Your email address could not be verified. + </p> + + {% if profile_email.has_token_expired %} + <p class="fs-5"> + Your token has expired, please request a new one from the <a href="{% url "scipost:personal_page" %}">personal page</a>. + </p> + {% elif not is_token_correct %} + <p class="fs-5">The token you provided is incorrect, please verify that you copied it correctly.</p> + {% endif %} + + {% endif %} + + </div> + + <p class="fs-5 text-muted">You may now close this window.</p> + </div> + +{% endblock %} diff --git a/scipost_django/profiles/urls.py b/scipost_django/profiles/urls.py index a328161ec..69fa6aa98 100644 --- a/scipost_django/profiles/urls.py +++ b/scipost_django/profiles/urls.py @@ -93,6 +93,11 @@ urlpatterns = [ "emails/<int:email_id>/", include( [ + path( + "delete", + views._hx_profile_email_delete, + name="_hx_profile_email_delete", + ), path( "make_primary", views._hx_profile_email_mark_primary, @@ -104,14 +109,14 @@ urlpatterns = [ name="_hx_profile_email_toggle_valid", ), path( - "toggle_verified", - views._hx_profile_email_toggle_verified, - name="_hx_profile_email_toggle_verified", + "request_verification", + views._hx_profile_email_request_verification, + name="_hx_profile_email_request_verification", ), path( - "delete", - views._hx_profile_email_delete, - name="_hx_profile_email_delete", + "verify/<str:token>", + views.verify_profile_email, + name="verify_profile_email", ), ] ), diff --git a/scipost_django/profiles/views.py b/scipost_django/profiles/views.py index 901af4b62..0acf5f389 100644 --- a/scipost_django/profiles/views.py +++ b/scipost_django/profiles/views.py @@ -5,6 +5,7 @@ __license__ = "AGPL v3" from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import BadRequest from django.template.response import TemplateResponse from django.urls import reverse, reverse_lazy from django.db import transaction @@ -529,12 +530,25 @@ def _hx_profile_email_toggle_valid(request, email_id): @permission_required_htmx("scipost.can_verify_profile_emails") -def _hx_profile_email_toggle_verified(request, email_id): +def _hx_profile_email_request_verification(request, email_id): """Toggle verified/unverified status of ProfileEmail.""" profile_email = get_object_or_404(ProfileEmail, pk=email_id) - if request.method == "PATCH": - profile_email.verified = not profile_email.verified - profile_email.save() + + if not request.method == "PATCH": + raise BadRequest("Invalid request method") + + if not profile_email.verified: + profile_email.send_verification_email() + messages.success( + request, + f"Verification email sent to {profile_email.email}.", + ) + else: + messages.warning( + request, + f"{profile_email.email} is already verified.", + ) + return TemplateResponse( request, "profiles/_hx_profile_emails_table_row.html", @@ -542,6 +556,31 @@ def _hx_profile_email_toggle_verified(request, email_id): ) +def verify_profile_email(request, email_id, token: str): + """Verify a ProfileEmail.""" + profile_email = get_object_or_404(ProfileEmail, pk=email_id) + + is_token_correct = profile_email.verification_token == token + was_previously_verified = profile_email.verified + if ( + not profile_email.has_token_expired + and is_token_correct + and not was_previously_verified + ): + profile_email.verified = True + profile_email.save() + + return TemplateResponse( + request, + "profiles/verify_profile_email.html", + { + "profile_email": profile_email, + "is_token_correct": is_token_correct, + "was_previously_verified": was_previously_verified, + }, + ) + + @permission_required_htmx("scipost.can_delete_profile_emails") def _hx_profile_email_delete(request, email_id): """Delete ProfileEmail.""" diff --git a/scipost_django/templates/bi/clock-fill.html b/scipost_django/templates/bi/clock-fill.html new file mode 100644 index 000000000..d9bdcd85b --- /dev/null +++ b/scipost_django/templates/bi/clock-fill.html @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + fill="currentColor" + class="bi bi-clock-fill" + viewBox="0 0 16 16"> + <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" /> +</svg> diff --git a/scipost_django/templates/email/profiles/verify_profile_email.html b/scipost_django/templates/email/profiles/verify_profile_email.html new file mode 100644 index 000000000..338c8e16b --- /dev/null +++ b/scipost_django/templates/email/profiles/verify_profile_email.html @@ -0,0 +1,26 @@ +<p>Dear {{ object.profile.get_title_display }} {{ object.profile.last_name }},</p> + +<p>To verify your email address, please click the following link:</p> + +<p> + <a style="font-weight: bold; + font-size: 2em" + href="https://{{ domain }}{{ object.get_verification_url }}">Verify Email</a> +</p> + +<p> + If the direct link is not working, you can also copy and paste the following link in your browser: https://{{ domain }}{{ object.get_verification_url }} +</p> + +<p> + If you did not request this verification, it is possible that a SciPost Administrator has requested it on your behalf. + Rest assured that your email address and SciPost account are safe. +</p> + +<p> + Best regards, + <br /> + The SciPost Team. +</p> + +{% include 'email/_footer.html' %} diff --git a/scipost_django/templates/email/profiles/verify_profile_email.json b/scipost_django/templates/email/profiles/verify_profile_email.json new file mode 100644 index 000000000..234e0443d --- /dev/null +++ b/scipost_django/templates/email/profiles/verify_profile_email.json @@ -0,0 +1,11 @@ +{ + "subject": "SciPost: Email address verification", + "recipient_list": [ + "email" + ], + "bcc": [ + "registration@" + ], + "from_name": "SciPost Registration", + "from_email": "registration@" +} -- GitLab