From 6c31555a5253592dbbaea73e3775cf97f23e71b9 Mon Sep 17 00:00:00 2001 From: Hostea dashboard Date: Sun, 10 Jul 2022 12:32:37 +0530 Subject: [PATCH] feat: password reset workflow fixes: https://gitea.hostea.org/Hostea/support/issues/2 --- .../migrations/0006_passwordresetchallenge.py | 52 +++++++++ accounts/models.py | 33 ++++++ .../accounts/auth/password-reset-form.html | 28 +++++ .../password-reset-resend-verification.html | 20 ++++ .../accounts/auth/password-reset.html | 40 +++++++ .../accounts/emails/password-changed.txt | 14 +++ .../accounts/emails/password-reset-link.txt | 9 ++ accounts/tests.py | 67 ++++++++++- accounts/urls.py | 18 +++ accounts/utils.py | 51 ++++++++- accounts/views.py | 108 +++++++++++++++++- .../registration/password_reset_form.html | 14 +++ 12 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 accounts/migrations/0006_passwordresetchallenge.py create mode 100644 accounts/templates/accounts/auth/password-reset-form.html create mode 100644 accounts/templates/accounts/auth/password-reset-resend-verification.html create mode 100644 accounts/templates/accounts/auth/password-reset.html create mode 100644 accounts/templates/accounts/emails/password-changed.txt create mode 100644 accounts/templates/accounts/emails/password-reset-link.txt create mode 100644 templates/registration/password_reset_form.html diff --git a/accounts/migrations/0006_passwordresetchallenge.py b/accounts/migrations/0006_passwordresetchallenge.py new file mode 100644 index 0000000..936f7ea --- /dev/null +++ b/accounts/migrations/0006_passwordresetchallenge.py @@ -0,0 +1,52 @@ +# Generated by Django 4.0.3 on 2022-07-10 06:14 + +import accounts.utils +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("accounts", "0005_accountconfirmchallenge_created_at"), + ] + + operations = [ + migrations.CreateModel( + name="PasswordResetChallenge", + fields=[ + ( + "public_ref", + models.CharField( + default=accounts.utils.gen_secret, + editable=False, + max_length=32, + unique=True, + verbose_name="Public referece to challenge text", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "challenge_text", + models.CharField( + default=accounts.utils.gen_secret, + editable=False, + max_length=32, + primary_key=True, + serialize=False, + unique=True, + verbose_name="Challenge text", + ), + ), + ( + "owned_by", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 041503f..9a6186e 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -52,3 +52,36 @@ class AccountConfirmChallenge(models.Model): def pending_url(self): return reverse("accounts.verify.pending", args=(self.public_ref,)) + + +class PasswordResetChallenge(models.Model): + owned_by = models.OneToOneField(User, on_delete=models.CASCADE) + public_ref = models.CharField( + "Public referece to challenge text", + unique=True, + max_length=32, + default=gen_secret, + editable=False, + ) + created_at = models.DateTimeField(auto_now_add=True, blank=True) + + challenge_text = models.CharField( + "Challenge text", + unique=True, + max_length=32, + default=gen_secret, + editable=False, + primary_key=True, + ) + + def __str__(self): + return f"{self.challenge_text}" + + def verification_link(self): + """ + Get verification link + """ + return reverse("accounts.password.reset", args=(self.challenge_text,)) + + def pending_url(self): + return reverse("accounts.password.reset.resend", args=(self.public_ref,)) diff --git a/accounts/templates/accounts/auth/password-reset-form.html b/accounts/templates/accounts/auth/password-reset-form.html new file mode 100644 index 0000000..d810b4e --- /dev/null +++ b/accounts/templates/accounts/auth/password-reset-form.html @@ -0,0 +1,28 @@ +{% extends "common/components/base.html" %} +{% block title %} Reset Password| Hostea Dashboard{% endblock %} +{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} +{% block main %} +
+

Reset password

+
+ {% include "common/components/error.html" %} {% csrf_token %} + +
+ +
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/auth/password-reset-resend-verification.html b/accounts/templates/accounts/auth/password-reset-resend-verification.html new file mode 100644 index 0000000..916d00d --- /dev/null +++ b/accounts/templates/accounts/auth/password-reset-resend-verification.html @@ -0,0 +1,20 @@ +{% extends "common/components/base.html" %} +{% block title %} Reset Password | Hostea Dashboard{% endblock %} +{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} +{% block main %} +
+

Reset password

+

Verification link is sent to email address: {{email}}

+
+ {% include "common/components/error.html" %} {% csrf_token %} +
+ +
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/auth/password-reset.html b/accounts/templates/accounts/auth/password-reset.html new file mode 100644 index 0000000..867faf8 --- /dev/null +++ b/accounts/templates/accounts/auth/password-reset.html @@ -0,0 +1,40 @@ +{% extends "common/components/base.html" %} +{% block title %} Reset Password | Hostea Dashboard{% endblock %} +{% block nav %} {% include "common/components/nav/pub.html" %} {% endblock %} +{% block main %} +
+

Reset Password

+
+ {% include "common/components/error.html" %} {% csrf_token %} + + + +
+ +
+
+
+{% endblock %} diff --git a/accounts/templates/accounts/emails/password-changed.txt b/accounts/templates/accounts/emails/password-changed.txt new file mode 100644 index 0000000..8637abc --- /dev/null +++ b/accounts/templates/accounts/emails/password-changed.txt @@ -0,0 +1,14 @@ +Hello {{ username }}, + +You have a new password! + +Your password for signing in to Hostea was recently changed. If you made this change, then we're all set. + +If you did not make this change, please reset your password to secure your account. + +{% url 'accounts.password.reset.new' %} + +Either way, feel free to reach out with any questions you might have. We're here to help. + +Cheers, +Hostea team diff --git a/accounts/templates/accounts/emails/password-reset-link.txt b/accounts/templates/accounts/emails/password-reset-link.txt new file mode 100644 index 0000000..3644af8 --- /dev/null +++ b/accounts/templates/accounts/emails/password-reset-link.txt @@ -0,0 +1,9 @@ +Hello {{ email }}, + +Please click on the link below to reset your password: +{{ link }} + +If you don't recognise this activity, please delete this mail. + +Cheers, +Hostea team diff --git a/accounts/tests.py b/accounts/tests.py index fbb4bd2..95d7840 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -33,7 +33,7 @@ from django.conf import settings from oauth2_provider.models import get_application_model -from .models import AccountConfirmChallenge +from .models import AccountConfirmChallenge, PasswordResetChallenge from .management.commands.rm_unverified_users import ( Command as CleanUnverifiedUsersCommand, ) @@ -158,6 +158,71 @@ class LoginTest(TestCase): self.assertEqual(resp.headers["location"], reverse("dash.instances.list")) +class ResetPasswordTest(TestCase): + def setUp(self): + self.username = "reset_password_user" + register_util(t=self, username=self.username) + + def reset_password(self): + c = Client() + payload = { + "email": self.email, + } + resp = c.get(reverse("accounts.password.reset.new")) + self.assertEqual(resp.status_code == 200) + + resp = c.post(reverse("accounts.password.reset.new"), payload) + self.assertEqual(resp.status_code == 302) + challenge = PasswordResetChallenge.objects.filter(owned_by=self.user) + self.assertEqual(resp.headers["location"] == challenge.pending_url(), True) + + password_reset_mail = mail.outbox.pop() + self.assertEqual("reset your password" in password_reset_mail, True) + self.assertEqual(challenge.verification_link() in password_reset_mail, True) + + resp = c.get(self.challenge.verification_link()) + self.assertEqual(resp.status_code == 200) + + new_password = "newpasdasdf234234" + + # passwords don't match + payload = { + "password": new_password, + "confirm_password": self.password, + } + resp = c.post(self.challenge.verification_link(), payload) + self.assertEqual(resp.status_code == 400) + + # change password + payload["confirm_password"] = new_password + resp = c.post(self.challenge.verification_link(), payload) + self.assertEqual(resp.status_code == 302) + self.assertEqual(resp.headers["location"], reverse("accounts.login")) + + # verify password changed notification email was sent + password_updated_email = mail.outbox.pop() + self.assertEqual( + "Your password for signing in to Hostea was recently changed. If you made this change, then we're all set." + in password_updated_email, + True, + ) + self.assertEqual(reverse("accounts.reset.new") in password_updated_email, True) + + # trying to login with old password + payload = { + "login": self.username, + "password": self.password, + } + resp = self.client.post(reverse("accounts.login"), payload) + self.assertEqual(resp.status_code, 401) + self.assertEqual(b"Login Failed" in resp.content, True) + + payload["password"] = new_password + resp = c.post(reverse("accounts.login"), payload) + self.assertEqual(resp.status_code, 302) + self.assertEqual(resp.headers["location"], reverse("accounts.home")) + + class RegistrationTest(TestCase): def setUp(self): self.username = "register_user" diff --git a/accounts/urls.py b/accounts/urls.py index 4249dde..9b85a06 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -25,6 +25,9 @@ from .views import ( resend_verification_email_view, verification_pending_view, sudo, + password_reset_send_verificaiton_link, + password_resend_verification_link_pending, + reset_password, ) urlpatterns = [ @@ -44,5 +47,20 @@ urlpatterns = [ ), path("accounts/verify//", verify_account, name="accounts.verify"), path("accounts/sudo/", sudo, name="accounts.sudo"), + path( + "accounts/password/reset/challenge/", + password_reset_send_verificaiton_link, + name="accounts.password.reset.new", + ), + path( + "accounts/password/reset//", + reset_password, + name="accounts.password.reset", + ), + path( + "accounts/password/reset/challenge//", + password_resend_verification_link_pending, + name="accounts.password.reset.resend", + ), path("", protected_view, name="accounts.home"), ] diff --git a/accounts/utils.py b/accounts/utils.py index 4daa79d..df0a5fe 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -30,6 +30,55 @@ def gen_secret() -> str: return get_random_string(32) +def send_password_changed_email(request): + ctx = { + "username": request.user.username, + } + + body = render_to_string( + "accounts/emails/password-changed.txt", + context=ctx, + ) + + email = request.user.email + + sender = settings.DEFAULT_FROM_EMAIL + + send_mail( + subject="[Hostea] Password changed", + message=body, + from_email=f"No reply Hostea<{sender}>", + recipient_list=[email], + ) + + +def send_password_reset_email(request, challenge): + verification_link = ( + f"{request.scheme}://{request.get_host()}{challenge.verification_link()}" + ) + + ctx = { + "link": verification_link, + "email": challenge.owned_by.email, + } + + body = render_to_string( + "accounts/emails/password-reset-link.txt", + context=ctx, + ) + + email = challenge.owned_by.email + + sender = settings.DEFAULT_FROM_EMAIL + + send_mail( + subject="[Hostea] Password reset link", + message=body, + from_email=f"No reply Hostea<{sender}>", + recipient_list=[email], + ) + + def send_verification_email(request, challenge): verification_link = ( f"{request.scheme}://{request.get_host()}{challenge.verification_link()}" @@ -52,7 +101,7 @@ def send_verification_email(request, challenge): send_mail( subject="[Hostea] Please confirm your email address", message=body, - from_email=f"No reply Hostea<{sender}>", # TODO read from settings.py + from_email=f"No reply Hostea<{sender}>", recipient_list=[email], ) diff --git a/accounts/views.py b/accounts/views.py index 338d619..60b4e12 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -25,8 +25,8 @@ from django.urls import reverse from dash.utils import footer_ctx -from .models import AccountConfirmChallenge -from .utils import send_verification_email, ConfirmAccess +from .models import AccountConfirmChallenge, PasswordResetChallenge +from .utils import send_verification_email, ConfirmAccess, send_password_reset_email from .decorators import redirect_if_authenticated @@ -242,3 +242,107 @@ def sudo(request): ConfirmAccess.set(request=request) return redirect(request.POST["next"]) + + +@redirect_if_authenticated +@csrf_protect +def password_reset_send_verificaiton_link(request): + def default_ctx(): + return { + "title": "Reset Password", + "footer": footer_ctx(), + } + + if request.method == "GET": + ctx = default_ctx() + return render(request, "accounts/auth/password-reset-form.html", ctx) + + challenge = None + + email = request.POST["email"] + User = get_user_model() + user = get_object_or_404(User, email=email) + if not PasswordResetChallenge.objects.filter(owned_by=user).exists(): + challenge = PasswordResetChallenge(owned_by=user) + challenge.save() + send_password_reset_email(request, challenge=challenge) + else: + challenge = PasswordResetChallenge.objects.get(owned_by=user) + return redirect(challenge.pending_url()) + + +@redirect_if_authenticated +@csrf_protect +def password_resend_verification_link_pending(request, public_ref): + challenge = get_object_or_404(PasswordResetChallenge, public_ref=public_ref) + + if request.method == "GET": + ctx = { + "email": challenge.owned_by.email, + "public_ref": challenge.public_ref, + } + + return render( + request, + "accounts/auth/password-reset-resend-verification.html", + context=ctx, + ) + + send_password_reset_email(request, challenge=challenge) + ctx = { + "email": challenge.owned_by.email, + "public_ref": challenge.public_ref, + } + + return render( + request, "accounts/auth/password-reset-resend-verification.html", context=ctx + ) + + +@csrf_protect +def reset_password(request, challenge): + def default_ctx(challenge): + return { + "title": "Reset Password", + "footer": footer_ctx(), + "challenge": challenge, + } + + challenge = get_object_or_404(PasswordResetChallenge, challenge_text=challenge) + + if request.method == "GET": + ctx = default_ctx(challenge=challenge) + return render(request, "accounts/auth/password-reset.html", ctx) + + confirm_password = request.POST["confirm_password"] + password = request.POST["password"] + + if password != confirm_password: + ctx = default_ctx(challenge=challenge) + ctx["error"] = { + "title": "Reset Password Failed", + "reason": "Passwords don't match, please verify input", + } + return render( + request, "accounts/auth/password-reset.html", status=400, context=ctx + ) + + user = challenge.owned_by + try: + validate_password(password, user=user) + except ValidationError as err: + ctx = default_ctx(challenge=challenge) + reason = "" + for r in err: + reason += r + " " + + ctx["error"] = {"title": "Reset Password Failed", "reason": reason} + return render( + request, "accounts/auth/password-reset.html", status=400, context=ctx + ) + + user.set_password(password) + user.save() + challenge.delete() + send_password_reset_email(request) + return redirect("accounts.login") diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..1d39fc3 --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}Forgot Your Password?{% endblock %} + +{% block content %} +

Forgot your password?

+

Enter your email address below, and we'll email instructions for setting a new one.

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %}