You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ma...@apache.org on 2021/04/29 21:29:39 UTC

[airavata-django-portal] 01/03: AIRAVATA-3455 Verify email change before updating user profile

This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3319-handle-missing-name-and-email-attributes-from-cilo
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 91cb9c49aadf40837569b2db4c07cd097cfc3437
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Apr 29 15:35:23 2021 -0400

    AIRAVATA-3455 Verify email change before updating user profile
---
 .../auth/migrations/0008_auto_20210422_1838.py     | 59 ++++++++++++++++++++++
 django_airavata/apps/auth/models.py                | 11 ++++
 django_airavata/apps/auth/serializers.py           | 48 ++++++++++++++++++
 .../js/components/UserProfileEditor.vue            |  2 +-
 django_airavata/apps/auth/urls.py                  |  4 +-
 django_airavata/apps/auth/views.py                 | 40 +++++++++++++++
 6 files changed, 162 insertions(+), 2 deletions(-)

diff --git a/django_airavata/apps/auth/migrations/0008_auto_20210422_1838.py b/django_airavata/apps/auth/migrations/0008_auto_20210422_1838.py
new file mode 100644
index 0000000..9a327b0
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0008_auto_20210422_1838.py
@@ -0,0 +1,59 @@
+# Generated by Django 2.2.17 on 2021-04-22 18:38
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+from django_airavata.apps.auth.models import VERIFY_EMAIL_CHANGE_TEMPLATE
+
+
+def default_templates(apps, schema_editor):
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    verify_email_template = EmailTemplate(
+        template_type=VERIFY_EMAIL_CHANGE_TEMPLATE,
+        subject="{{first_name}} {{last_name}} ({{username}}), "
+                "Please Verify Your New Email Address in {{portal_title}}",
+        body="""
+        <p>
+        Dear {{first_name}} {{last_name}},
+        </p>
+
+        <p>
+        Before your email address change can be processed, you need to verify
+        your new email address ({{email}}). Click the link below to verify your email
+        address:
+        </p>
+
+        <p><a href="{{url}}">{{url}}</a></p>
+        """.strip())
+    verify_email_template.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('django_airavata_auth', '0007_auto_20200917_1610'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='emailtemplate',
+            name='template_type',
+            field=models.IntegerField(choices=[(1, 'Verify Email Template'), (2, 'New User Email Template'), (3, 'Password Reset Email Template'), (4, 'User Added to Group Template'), (5, 'Verify Email Change Template')], primary_key=True, serialize=False),
+        ),
+        migrations.CreateModel(
+            name='PendingEmailChange',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('email_address', models.EmailField(max_length=254)),
+                ('verification_code', models.CharField(default=uuid.uuid4, max_length=36, unique=True)),
+                ('created_date', models.DateTimeField(auto_now_add=True)),
+                ('verified', models.BooleanField(default=False)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.RunPython(default_templates, migrations.RunPython.noop),
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 082a4a1..c8c0da3 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -7,6 +7,7 @@ VERIFY_EMAIL_TEMPLATE = 1
 NEW_USER_EMAIL_TEMPLATE = 2
 PASSWORD_RESET_EMAIL_TEMPLATE = 3
 USER_ADDED_TO_GROUP_TEMPLATE = 4
+VERIFY_EMAIL_CHANGE_TEMPLATE = 5
 
 
 class EmailVerification(models.Model):
@@ -24,6 +25,7 @@ class EmailTemplate(models.Model):
         (NEW_USER_EMAIL_TEMPLATE, 'New User Email Template'),
         (PASSWORD_RESET_EMAIL_TEMPLATE, 'Password Reset Email Template'),
         (USER_ADDED_TO_GROUP_TEMPLATE, 'User Added to Group Template'),
+        (VERIFY_EMAIL_CHANGE_TEMPLATE, 'Verify Email Change Template'),
     )
     template_type = models.IntegerField(
         primary_key=True, choices=TEMPLATE_TYPE_CHOICES)
@@ -73,3 +75,12 @@ class UserInfo(models.Model):
 
     def __str__(self):
         return f"{self.claim}={self.value}"
+
+
+class PendingEmailChange(models.Model):
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    email_address = models.EmailField()
+    verification_code = models.CharField(
+        max_length=36, unique=True, default=uuid.uuid4)
+    created_date = models.DateTimeField(auto_now_add=True)
+    verified = models.BooleanField(default=False)
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index 6b51cef..310b4a0 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -1,14 +1,62 @@
+import logging
+
+from django.conf import settings
 from django.contrib.auth import get_user_model
+from django.db.transaction import atomic
+from django.template import Context
+from django.urls import reverse
 from rest_framework import serializers
 
+from . import models, utils
+
+logger = logging.getLogger(__name__)
+
 
 class UserSerializer(serializers.ModelSerializer):
+
+    # TODO: add a lookup of most recent PendingEmailChange if any
     class Meta:
         model = get_user_model()
         fields = ['id', 'username', 'first_name', 'last_name', 'email']
 
+    @atomic
     def update(self, instance, validated_data):
+        request = self.context['request']
         instance.first_name = validated_data['first_name']
         instance.last_name = validated_data['last_name']
+        if instance.email != validated_data['email']:
+            # Delete any unverified pending email changes
+            models.PendingEmailChange.objects.filter(user=request.user, verified=False).delete()
+            # Email doesn't get updated until it is verified. Create a pending
+            # email change record in the meantime
+            pending_email_change = models.PendingEmailChange.objects.create(user=request.user, email_address=validated_data['email'])
+            self._send_email_verification_link(pending_email_change)
         instance.save()
+        # save in the user profile service too
+        user_profile_client = request.profile_service['user_profile']
+        airavata_user_profile = user_profile_client.getUserProfileById(
+            request.authz_token, request.user.username, settings.GATEWAY_ID)
+        airavata_user_profile.firstName = instance.first_name
+        airavata_user_profile.lastName = instance.last_name
+        user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
         return instance
+
+    def _send_email_verification_link(self, pending_email_change):
+
+        request = self.context['request']
+        verification_uri = request.build_absolute_uri(
+            reverse(
+                'django_airavata_auth:verify_email_change', kwargs={
+                    'code': pending_email_change.verification_code}))
+        logger.debug(
+            "verification_uri={}".format(verification_uri))
+
+        context = Context({
+            "username": pending_email_change.user.username,
+            "email": pending_email_change.email_address,
+            "first_name": pending_email_change.user.first_name,
+            "last_name": pending_email_change.user.last_name,
+            "portal_title": settings.PORTAL_TITLE,
+            "url": verification_uri,
+        })
+        utils.send_email_to_user(models.VERIFY_EMAIL_CHANGE_TEMPLATE, context)
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
index aecfc97..844b3a0 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/UserProfileEditor.vue
@@ -10,7 +10,7 @@
       <b-form-input v-model="user.last_name" />
     </b-form-group>
     <b-form-group label="Email">
-      <b-form-input disabled :value="user.email" />
+      <b-form-input v-model="user.email" />
     </b-form-group>
     <b-button variant="primary" @click="$emit('save', user)">Save</b-button>
     <b-button>Cancel</b-button>
diff --git a/django_airavata/apps/auth/urls.py b/django_airavata/apps/auth/urls.py
index 9f193c3..d8de4a2 100644
--- a/django_airavata/apps/auth/urls.py
+++ b/django_airavata/apps/auth/urls.py
@@ -32,5 +32,7 @@ urlpatterns = [
         views.login_desktop_success, name="login_desktop_success"),
     url(r'^refreshed-token-desktop$', views.refreshed_token_desktop,
         name="refreshed_token_desktop"),
-    url(r'^user-profile/', views.user_profile),
+    url(r'^user-profile/', views.user_profile, name="user_profile"),
+    url(r'^verify-email-change/(?P<code>[\w-]+)/$', views.verify_email_change,
+        name="verify_email_change"),
 ]
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index e599abe..3fc0c40 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -8,6 +8,7 @@ from django.contrib import messages
 from django.contrib.auth import authenticate, get_user_model, login, logout
 from django.contrib.auth.decorators import login_required
 from django.core.exceptions import ObjectDoesNotExist
+from django.db.transaction import atomic
 from django.forms import ValidationError
 from django.http import HttpResponseBadRequest, JsonResponse
 from django.shortcuts import redirect, render, resolve_url
@@ -543,3 +544,42 @@ class UserViewSet(viewsets.ModelViewSet):
     @action(detail=False)
     def current(self, request):
         return redirect(reverse('django_airavata_auth:user-detail', kwargs={'pk': request.user.id}))
+
+
+@login_required
+@atomic
+def verify_email_change(request, code):
+    try:
+        pending_email_change = models.PendingEmailChange.objects.get(user=request.user, verification_code=code)
+        pending_email_change.verified = True
+        pending_email_change.save()
+        request.user.email = pending_email_change.email_address
+        request.user.save()
+
+        user_profile_client = request.profile_service['user_profile']
+        airavata_user_profile = user_profile_client.getUserProfileById(
+            request.authz_token, request.user.username, settings.GATEWAY_ID)
+        airavata_user_profile.emails = [pending_email_change.email_address]
+        user_profile_client.updateUserProfile(request.authz_token, airavata_user_profile)
+
+        # TODO: add success message
+        return redirect(reverse('django_airavata_auth:user_profile'))
+    except ObjectDoesNotExist:
+        # if doesn't exist, give user a form where they can enter their
+        # username to resend verification code
+        logger.exception("PendingEmailChange object doesn't exist for "
+                         "code {}".format(code))
+        # TODO: add error message
+        # messages.error(
+        #     request,
+        #     "Email verification failed. Please enter your username and we "
+        #     "will send you another email verification link.")
+        return redirect(reverse('django_airavata_auth:user_profile'))
+    except Exception:
+        logger.exception("Email change verification processing failed!")
+        # TODO: add error message
+        # messages.error(
+        #     request,
+        #     "Email verification failed. Please try clicking the email "
+        #     "verification link again later.")
+        return redirect(reverse('django_airavata_auth:user_profile'))