feat: password reset workflow
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
fixes: https://gitea.hostea.org/Hostea/support/issues/2wip-forget-password
parent
060e9b84d4
commit
6c31555a52
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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,))
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 %}
|
Loading…
Reference in New Issue