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 %}
+
+{% 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}}
+
+
+{% 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 %}
+
+{% 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.
+
+
+{% endblock %}