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()