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 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=""" +

+ Dear {{first_name}} {{last_name}}, +

+ +

+ Please click the link below to reset your password. This link is + valid for 24 hours. +

+ +

{{url}}

+ +

If you didn't request to reset your password, just ignore this message.

+ """.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 %} + +
+
+ +
+
+
+
+
Forgot Password?
+

If you forgot your password you can reset it. Just enter the username for your account and click Email Reset Link.

+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ {% for error in form.non_field_errors %} + + {% endfor %} + {% csrf_token %} + + {% for field in form %} +
+ + + {% if field.help_text %} + + {{ field.help_text | escape }} + + {% endif %} +
+ {% if field.errors|length == 1 %} + {{ field.errors|first| escape }} + {% else %} +
    + {% for error in field.errors %} +
  • {{ error | escape }}
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} + + +
+
+
+
+
+
+
+ +{% 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 %} + +
+
+ +
+
+
+
+
Reset Password
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
+ {% for error in form.non_field_errors %} + + {% endfor %} + {% csrf_token %} + {% for field in form %} +
+ + + {% if field.help_text %} + + {{ field.help_text | escape }} + + {% endif %} +
+ {% if field.errors|length == 1 %} + {{ field.errors|first| escape }} + {% else %} +
    + {% for error in field.errors %} +
  • {{ error | escape }}
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endfor %} + + +
+
+
+
+
+
+
+ +{% 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[\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()