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 2019/05/10 15:18:49 UTC

[airavata-django-portal] 01/04: AIRAVATA-2925 Forgot/reset password forms

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

machristie pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 660375444ff69c6c1e79cb7c5e911c47c3a357bf
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri May 10 10:19:48 2019 -0400

    AIRAVATA-2925 Forgot/reset password forms
---
 django_airavata/apps/auth/forms.py                 |  43 +++++++
 django_airavata/apps/auth/iam_admin_client.py      |   6 +
 .../auth/migrations/0004_password_reset_request.py |  64 ++++++++++
 django_airavata/apps/auth/models.py                |   9 ++
 .../django_airavata_auth/forgot_password.html      |  68 +++++++++++
 .../django_airavata_auth/reset_password.html       |  66 +++++++++++
 django_airavata/apps/auth/urls.py                  |   3 +
 django_airavata/apps/auth/views.py                 | 130 +++++++++++++++++++--
 8 files changed, 381 insertions(+), 8 deletions(-)

diff --git a/django_airavata/apps/auth/forms.py b/django_airavata/apps/auth/forms.py
index 2d54d5d..f78dd84 100644
--- a/django_airavata/apps/auth/forms.py
+++ b/django_airavata/apps/auth/forms.py
@@ -99,3 +99,46 @@ class ResendEmailVerificationLinkForm(forms.Form):
                                       'placeholder': 'Username'}),
         min_length=6,
         validators=[USERNAME_VALIDATOR])
+
+
+class ForgotPasswordForm(forms.Form):
+    error_css_class = "is-invalid"
+    username = forms.CharField(
+        label='Username',
+        widget=forms.TextInput(attrs={'class': 'form-control',
+                                      'placeholder': 'Username'}),
+        min_length=6,
+        validators=[USERNAME_VALIDATOR],
+        help_text=USERNAME_VALIDATOR.message)
+
+
+class ResetPasswordForm(forms.Form):
+    error_css_class = "is-invalid"
+
+    password = forms.CharField(
+        label='Password',
+        widget=forms.PasswordInput(attrs={'class': 'form-control',
+                                          'placeholder': 'Password'}),
+        min_length=8,
+        max_length=48,
+        validators=[PASSWORD_VALIDATOR],
+        help_text=PASSWORD_VALIDATOR.message)
+    password_again = forms.CharField(
+        label='Password (again)',
+        widget=forms.PasswordInput(attrs={'class': 'form-control',
+                                          'placeholder': 'Password (again)'}))
+
+    def clean(self):
+        cleaned_data = super().clean()
+        password = cleaned_data.get('password')
+        password_again = cleaned_data.get('password_again')
+
+        if password and password_again and password != password_again:
+            self.add_error(
+                'password',
+                forms.ValidationError("Passwords do not match"))
+            self.add_error(
+                'password_again',
+                forms.ValidationError("Passwords do not match"))
+
+        return cleaned_data
diff --git a/django_airavata/apps/auth/iam_admin_client.py b/django_airavata/apps/auth/iam_admin_client.py
index 7783ce7..6dc2ceb 100644
--- a/django_airavata/apps/auth/iam_admin_client.py
+++ b/django_airavata/apps/auth/iam_admin_client.py
@@ -45,3 +45,9 @@ def is_user_exist(username):
 def get_user(username):
     authz_token = utils.get_service_account_authz_token()
     return iamadmin_client_pool.getUser(authz_token, username)
+
+
+def reset_user_password(username, new_password):
+    authz_token = utils.get_service_account_authz_token()
+    return iamadmin_client_pool.resetUserPassword(
+        authz_token, username, new_password)
diff --git a/django_airavata/apps/auth/migrations/0004_password_reset_request.py b/django_airavata/apps/auth/migrations/0004_password_reset_request.py
new file mode 100644
index 0000000..e8fed01
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0004_password_reset_request.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.20 on 2019-05-07 15:49
+from __future__ import unicode_literals
+
+import uuid
+
+from django.db import migrations, models
+
+from django_airavata.apps.auth.models import PASSWORD_RESET_EMAIL_TEMPLATE
+
+
+def default_templates(apps, schema_editor):
+
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    verify_email_template = EmailTemplate(
+        template_type=PASSWORD_RESET_EMAIL_TEMPLATE,
+        subject="{{first_name}} {{last_name}} ({{username}}), "
+                "Reset your password in {{portal_title}}",
+        body="""
+        <p>
+        Dear {{first_name}} {{last_name}},
+        </p>
+
+        <p>
+        Please click the link below to reset your password. This link is
+        valid for 24 hours.
+        </p>
+
+        <p><a href="{{url}}">{{url}}</a></p>
+
+        <p>If you didn't request to reset your password, just ignore this message.</p>
+        """.strip())
+    verify_email_template.save()
+
+
+def delete_default_templates(apps, schema_editor):
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    EmailTemplate.objects.filter(
+        template_type=PASSWORD_RESET_EMAIL_TEMPLATE).delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0003_default_email_templates'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PasswordResetRequest',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('username', models.CharField(max_length=64)),
+                ('reset_code', models.CharField(default=uuid.uuid4, max_length=36, unique=True)),
+                ('created_date', models.DateTimeField(auto_now_add=True)),
+            ],
+        ),
+        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')], primary_key=True, serialize=False),
+        ),
+        migrations.RunPython(default_templates, reverse_code=delete_default_templates)
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index e169759..5e4d109 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -4,6 +4,7 @@ from django.db import models
 
 VERIFY_EMAIL_TEMPLATE = 1
 NEW_USER_EMAIL_TEMPLATE = 2
+PASSWORD_RESET_EMAIL_TEMPLATE = 3
 
 
 class EmailVerification(models.Model):
@@ -18,6 +19,7 @@ class EmailTemplate(models.Model):
     TEMPLATE_TYPE_CHOICES = (
         (VERIFY_EMAIL_TEMPLATE, 'Verify Email Template'),
         (NEW_USER_EMAIL_TEMPLATE, 'New User Email Template'),
+        (PASSWORD_RESET_EMAIL_TEMPLATE, 'Password Reset Email Template'),
     )
     template_type = models.IntegerField(
         primary_key=True, choices=TEMPLATE_TYPE_CHOICES)
@@ -31,3 +33,10 @@ class EmailTemplate(models.Model):
             if self.template_type == choice[0]:
                 return choice[1]
         return "Unknown"
+
+
+class PasswordResetRequest(models.Model):
+    username = models.CharField(max_length=64)
+    reset_code = models.CharField(
+        max_length=36, unique=True, default=uuid.uuid4)
+    created_date = models.DateTimeField(auto_now_add=True)
diff --git a/django_airavata/apps/auth/templates/django_airavata_auth/forgot_password.html b/django_airavata/apps/auth/templates/django_airavata_auth/forgot_password.html
new file mode 100644
index 0000000..573c13d
--- /dev/null
+++ b/django_airavata/apps/auth/templates/django_airavata_auth/forgot_password.html
@@ -0,0 +1,68 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+<main class="main-content">
+<div class="container">
+
+  <div class="row">
+    <div class="col">
+      <div class="card">
+        <div class="card-body">
+          <h5 class="card-title">Forgot Password?</h5>
+          <p class="card-text">If you forgot your password you can reset it. Just enter the username for your account and click Email Reset Link.</p>
+          {% if messages %}
+            {% for message in messages %}
+            <div class="alert {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}alert-danger{% elif message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}alert-success{% else %}alert-secondary{% endif %}" role="alert">
+              {{ message }}
+            </div>
+            {% endfor %}
+          {% endif %}
+          <form action="{% url 'django_airavata_auth:forgot_password' %}" method="post">
+            {% for error in form.non_field_errors %}
+            <div class="alert alert-danger" role="alert">
+              {{ error }}
+            </div>
+            {% endfor %}
+            {% csrf_token %}
+
+            {% for field in form %}
+            <div class="form-group">
+              <label for="{{ field.id_for_label }}">{{ field.label }}</label>
+              <input id="{{ field.id_for_label }}" type="{{ field.field.widget.input_type }}"
+                class="form-control{% if field.errors %} is-invalid{% endif %}" name="{{ field.name }}"
+                placeholder="{{ field.field.widget.attrs.placeholder }}"
+                aria-describedby="{{ field.id_for_label }}-help"
+                {% if field.value %} value="{{ field.value }}" {% endif %}
+                {% if field.field.required %} required {% endif %} />
+              {% if field.help_text %}
+              <small id="{{ field.id_for_label }}-help" class="form-text text-muted">
+                {{ field.help_text | escape }}
+              </small>
+              {% endif %}
+              <div class="invalid-feedback">
+                {% if field.errors|length == 1 %}
+                  {{ field.errors|first| escape }}
+                {% else %}
+                  <ul>
+                    {% for error in field.errors %}
+                    <li>{{ error | escape }}</li>
+                    {% endfor %}
+                  </ul>
+                {% endif %}
+              </div>
+            </div>
+            {% endfor %}
+
+            <button type="submit" class="btn btn-primary btn-block">
+              Email Reset Link
+            </button>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+</main>
+
+{% endblock %}
diff --git a/django_airavata/apps/auth/templates/django_airavata_auth/reset_password.html b/django_airavata/apps/auth/templates/django_airavata_auth/reset_password.html
new file mode 100644
index 0000000..0d367f8
--- /dev/null
+++ b/django_airavata/apps/auth/templates/django_airavata_auth/reset_password.html
@@ -0,0 +1,66 @@
+{% extends 'base.html' %}
+
+{% block content %}
+
+<main class="main-content">
+<div class="container">
+
+  <div class="row">
+    <div class="col">
+      <div class="card">
+        <div class="card-body">
+          <h5 class="card-title">Reset Password</h5>
+          {% if messages %}
+            {% for message in messages %}
+            <div class="alert {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}alert-danger{% elif message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %}alert-success{% else %}alert-secondary{% endif %}" role="alert">
+              {{ message }}
+            </div>
+            {% endfor %}
+          {% endif %}
+          <form action="{% url 'django_airavata_auth:reset_password' code %}" method="post">
+            {% for error in form.non_field_errors %}
+            <div class="alert alert-danger" role="alert">
+              {{ error }}
+            </div>
+            {% endfor %}
+            {% csrf_token %}
+            {% for field in form %}
+            <div class="form-group">
+              <label for="{{ field.id_for_label }}">{{ field.label }}</label>
+              <input id="{{ field.id_for_label }}" type="{{ field.field.widget.input_type }}"
+                class="form-control{% if field.errors %} is-invalid{% endif %}" name="{{ field.name }}"
+                placeholder="{{ field.field.widget.attrs.placeholder }}"
+                aria-describedby="{{ field.id_for_label }}-help"
+                {% if field.value %} value="{{ field.value }}" {% endif %}
+                {% if field.field.required %} required {% endif %} />
+              {% if field.help_text %}
+              <small id="{{ field.id_for_label }}-help" class="form-text text-muted">
+                {{ field.help_text | escape }}
+              </small>
+              {% endif %}
+              <div class="invalid-feedback">
+                {% if field.errors|length == 1 %}
+                  {{ field.errors|first| escape }}
+                {% else %}
+                  <ul>
+                    {% for error in field.errors %}
+                    <li>{{ error | escape }}</li>
+                    {% endfor %}
+                  </ul>
+                {% endif %}
+              </div>
+            </div>
+            {% endfor %}
+
+            <button type="submit" class="btn btn-primary btn-block">
+              Reset
+            </button>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+</main>
+
+{% endblock %}
diff --git a/django_airavata/apps/auth/urls.py b/django_airavata/apps/auth/urls.py
index 4905665..658df10 100644
--- a/django_airavata/apps/auth/urls.py
+++ b/django_airavata/apps/auth/urls.py
@@ -20,4 +20,7 @@ urlpatterns = [
         name="verify_email"),
     url(r'^resend-email-link/', views.resend_email_link,
         name="resend_email_link"),
+    url(r'^forgot-password/$', views.forgot_password, name="forgot_password"),
+    url(r'^reset-password/(?P<code>[\w-]+)/$', views.reset_password,
+        name="reset_password"),
 ]
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index e8e90c9..72ddf9a 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -1,4 +1,5 @@
 import logging
+from datetime import datetime, timedelta, timezone
 from urllib.parse import quote
 
 from django.conf import settings
@@ -297,8 +298,6 @@ def _create_and_send_email_verification_link(
     logger.debug(
         "verification_uri={}".format(verification_uri))
 
-    verify_email_template = models.EmailTemplate.objects.get(
-        pk=models.VERIFY_EMAIL_TEMPLATE)
     context = Context({
         "username": username,
         "email": email,
@@ -307,11 +306,126 @@ def _create_and_send_email_verification_link(
         "portal_title": settings.PORTAL_TITLE,
         "url": verification_uri,
     })
-    subject = Template(verify_email_template.subject).render(context)
-    body = Template(verify_email_template.body).render(context)
-    msg = EmailMessage(subject=subject, body=body,
-                       from_email="{} <{}>".format(
-                           settings.PORTAL_TITLE, settings.SERVER_EMAIL),
-                       to=["{} {} <{}>".format(first_name, last_name, email)])
+    _send_email_to_user(models.VERIFY_EMAIL_TEMPLATE, context)
+
+
+def forgot_password(request):
+    if request.method == 'POST':
+        form = forms.ForgotPasswordForm(request.POST)
+        if form.is_valid():
+            try:
+                username = form.cleaned_data['username']
+                user_exists = iam_admin_client.is_user_exist(username)
+                if user_exists:
+                    _create_and_send_password_reset_request_link(
+                        request, username)
+                # Always display this message even if you doesn't exist. Don't
+                # reveal whether a user with that username exists.
+                messages.success(
+                    request,
+                    "Reset password request processed successfully. We've "
+                    "sent an email with a password reset link to the email "
+                    "address associated with the username you provided. You "
+                    "can use that link within the next 24 hours to set a new "
+                    "password.")
+                return redirect(
+                    reverse('django_airavata_auth:forgot_password'))
+            except Exception as e:
+                logger.exception(
+                    "Failed to generate password reset request for user",
+                    exc_info=e)
+                form.add_error(None, ValidationError(str(e)))
+    else:
+        form = forms.ForgotPasswordForm()
+    return render(request, 'django_airavata_auth/forgot_password.html', {
+        'form': form
+    })
+
+
+def _create_and_send_password_reset_request_link(request, username):
+    password_reset_request = models.PasswordResetRequest(username=username)
+    password_reset_request.save()
+
+    verification_uri = request.build_absolute_uri(
+        reverse(
+            'django_airavata_auth:reset_password', kwargs={
+                'code': password_reset_request.reset_code}))
+    logger.debug(
+        "password reset verification_uri={}".format(verification_uri))
+
+    user = iam_admin_client.get_user(username)
+    context = Context({
+        "username": username,
+        "email": user.emails[0],
+        "first_name": user.firstName,
+        "last_name": user.lastName,
+        "portal_title": settings.PORTAL_TITLE,
+        "url": verification_uri,
+    })
+    _send_email_to_user(models.PASSWORD_RESET_EMAIL_TEMPLATE, context)
+
+
+def reset_password(request, code):
+    try:
+        password_reset_request = models.PasswordResetRequest.objects.get(
+            reset_code=code)
+    except ObjectDoesNotExist as e:
+        messages.error(
+            request,
+            "Reset password link is invalid. Please try again.")
+        return redirect(reverse('django_airavata_auth:forgot_password'))
+
+    now = datetime.now(timezone.utc)
+    if now - password_reset_request.created_date > timedelta(days=1):
+        password_reset_request.delete()
+        messages.error(
+            request,
+            "Reset password link has expired. Please try again.")
+        return redirect(reverse('django_airavata_auth:forgot_password'))
+
+    if request.method == "POST":
+        form = forms.ResetPasswordForm(request.POST)
+        if form.is_valid():
+            try:
+                password = form.cleaned_data['password']
+                success = iam_admin_client.reset_user_password(
+                    password_reset_request.username, password)
+                if not success:
+                    messages.error(
+                        request, "Failed to reset password. Please try again.")
+                    return redirect(
+                        reverse('django_airavata_auth:forgot_password'))
+                else:
+                    password_reset_request.delete()
+                    messages.success(
+                        request,
+                        "You may now log in with your new password.")
+                    return redirect(
+                        reverse('django_airavata_auth:login_with_password'))
+            except Exception as e:
+                logger.exception(
+                    "Failed to reset password for user", exc_info=e)
+                form.add_error(None, ValidationError(str(e)))
+    else:
+        form = forms.ResetPasswordForm()
+    return render(request, 'django_airavata_auth/reset_password.html', {
+        'form': form,
+        'code': code
+    })
+
+
+def _send_email_to_user(template_id, context):
+    email_template = models.EmailTemplate.objects.get(
+        pk=template_id)
+    subject = Template(email_template.subject).render(context)
+    body = Template(email_template.body).render(context)
+    msg = EmailMessage(
+        subject=subject,
+        body=body,
+        from_email="{} <{}>".format(settings.PORTAL_TITLE,
+                                    settings.SERVER_EMAIL),
+        to=["{} {} <{}>".format(context['first_name'],
+                                context['last_name'],
+                                context['email'])])
     msg.content_subtype = 'html'
     msg.send()