forked from Hostea/dashboard
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):
|
def pending_url(self):
|
||||||
return reverse("accounts.verify.pending", args=(self.public_ref,))
|
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 oauth2_provider.models import get_application_model
|
||||||
|
|
||||||
from .models import AccountConfirmChallenge
|
from .models import AccountConfirmChallenge, PasswordResetChallenge
|
||||||
from .management.commands.rm_unverified_users import (
|
from .management.commands.rm_unverified_users import (
|
||||||
Command as CleanUnverifiedUsersCommand,
|
Command as CleanUnverifiedUsersCommand,
|
||||||
)
|
)
|
||||||
|
@ -158,6 +158,71 @@ class LoginTest(TestCase):
|
||||||
self.assertEqual(resp.headers["location"], reverse("dash.instances.list"))
|
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):
|
class RegistrationTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.username = "register_user"
|
self.username = "register_user"
|
||||||
|
|
|
@ -25,6 +25,9 @@ from .views import (
|
||||||
resend_verification_email_view,
|
resend_verification_email_view,
|
||||||
verification_pending_view,
|
verification_pending_view,
|
||||||
sudo,
|
sudo,
|
||||||
|
password_reset_send_verificaiton_link,
|
||||||
|
password_resend_verification_link_pending,
|
||||||
|
reset_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
@ -44,5 +47,20 @@ urlpatterns = [
|
||||||
),
|
),
|
||||||
path("accounts/verify/<str:challenge>/", verify_account, name="accounts.verify"),
|
path("accounts/verify/<str:challenge>/", verify_account, name="accounts.verify"),
|
||||||
path("accounts/sudo/", sudo, name="accounts.sudo"),
|
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"),
|
path("", protected_view, name="accounts.home"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -30,6 +30,55 @@ def gen_secret() -> str:
|
||||||
return get_random_string(32)
|
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):
|
def send_verification_email(request, challenge):
|
||||||
verification_link = (
|
verification_link = (
|
||||||
f"{request.scheme}://{request.get_host()}{challenge.verification_link()}"
|
f"{request.scheme}://{request.get_host()}{challenge.verification_link()}"
|
||||||
|
@ -52,7 +101,7 @@ def send_verification_email(request, challenge):
|
||||||
send_mail(
|
send_mail(
|
||||||
subject="[Hostea] Please confirm your email address",
|
subject="[Hostea] Please confirm your email address",
|
||||||
message=body,
|
message=body,
|
||||||
from_email=f"No reply Hostea<{sender}>", # TODO read from settings.py
|
from_email=f"No reply Hostea<{sender}>",
|
||||||
recipient_list=[email],
|
recipient_list=[email],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -25,8 +25,8 @@ from django.urls import reverse
|
||||||
|
|
||||||
from dash.utils import footer_ctx
|
from dash.utils import footer_ctx
|
||||||
|
|
||||||
from .models import AccountConfirmChallenge
|
from .models import AccountConfirmChallenge, PasswordResetChallenge
|
||||||
from .utils import send_verification_email, ConfirmAccess
|
from .utils import send_verification_email, ConfirmAccess, send_password_reset_email
|
||||||
from .decorators import redirect_if_authenticated
|
from .decorators import redirect_if_authenticated
|
||||||
|
|
||||||
|
|
||||||
|
@ -242,3 +242,107 @@ def sudo(request):
|
||||||
|
|
||||||
ConfirmAccess.set(request=request)
|
ConfirmAccess.set(request=request)
|
||||||
return redirect(request.POST["next"])
|
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