feat: password reset workflow
ci/woodpecker/push/woodpecker Pipeline was successful Details

fixes: https://gitea.hostea.org/Hostea/support/issues/2
wip-forget-password
Hostea dashboard 2022-07-10 12:32:37 +05:30
parent 060e9b84d4
commit 6c31555a52
Signed by: realaravinth
GPG Key ID: AD9F0F08E855ED88
12 changed files with 450 additions and 4 deletions

View File

@ -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,
),
),
],
),
]

View File

@ -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,))

View File

@ -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 %}
<div class="dialogue-box__container">
<h2>Reset password</h2>
<form
action="{% url 'accounts.password.reset.new' %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<label class="form__label" for="email">
Email
<input
class="form__input"
name="email"
id="email"
type="email"
/>
</label>
<div class="form__action-container">
<button class="form__submit" type="submit">Send Password Reset Link</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -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 %}
<div class="dialogue-box__container">
<h2>Reset password</h2>
<p>Verification link is sent to email address: {{email}}</p>
<form
action="{% url 'accounts.password.reset.resend' public_ref=public_ref %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<div class="form__action-container">
<button class="form__submit" type="submit">Click here to resend email</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -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 %}
<div class="dialogue-box__container">
<h2>Reset Password</h2>
<form
action="{% url 'accounts.password.reset' challenge=challenge %}"
method="POST"
class="form"
accept-charset="utf-8"
>
{% include "common/components/error.html" %} {% csrf_token %}
<label class="form__label" for="password">
password
<input
class="form__input"
name="password"
required
id="password"
type="password"
/>
</label>
<label class="form__label" for="confirm_password">
Re-enter Password
<input
class="form__input"
name="confirm_password"
required
id="confirm_password"
type="password"
/>
</label>
<div class="form__action-container">
<button class="form__submit" type="submit">Reset Password</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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/<str:challenge>/", 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/<str:challenge>/",
reset_password,
name="accounts.password.reset",
),
path(
"accounts/password/reset/challenge/<str:public_ref>/",
password_resend_verification_link_pending,
name="accounts.password.reset.resend",
),
path("", protected_view, name="accounts.home"),
]

View File

@ -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],
)

View File

@ -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")

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Forgot Your Password?{% endblock %}
{% block content %}
<h1>Forgot your password?</h1>
<p>Enter your email address below, and we'll email instructions for setting a new one.</p>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Send me instructions!">
</form>
{% endblock %}