diff --git a/bugsink/urls.py b/bugsink/urls.py index db41675..7d2dee8 100644 --- a/bugsink/urls.py +++ b/bugsink/urls.py @@ -7,7 +7,7 @@ from django.contrib.auth import views as auth_views from alerts.views import debug_email as debug_alerts_email from users.views import debug_email as debug_users_email from bugsink.app_settings import get_settings -from users.views import signup, confirm_email, resend_confirmation +from users.views import signup, confirm_email, resend_confirmation, request_reset_password, reset_password from .views import home, trigger_error, favicon @@ -24,6 +24,9 @@ urlpatterns = [ path("accounts/resend-confirmation/", resend_confirmation, name="resend_confirmation"), path("accounts/confirm-email//", confirm_email, name="confirm_email"), + path("accounts/request-reset-password/", request_reset_password, name="request_reset_password"), + path("accounts/reset-password//", reset_password, name="reset_password"), + path("accounts/login/", auth_views.LoginView.as_view(template_name="bugsink/login.html"), name="login"), path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), diff --git a/templates/bugsink/login.html b/templates/bugsink/login.html index 7c2e683..10af06c 100644 --- a/templates/bugsink/login.html +++ b/templates/bugsink/login.html @@ -44,7 +44,7 @@
- Forgot password? + Forgot password? {% if registration_enabled %}Create an account{% endif %}
diff --git a/templates/signup.html b/templates/signup.html index 7234ee6..456a4ba 100644 --- a/templates/signup.html +++ b/templates/signup.html @@ -54,7 +54,7 @@ diff --git a/users/forms.py b/users/forms.py index f8cd92a..9f9d1a3 100644 --- a/users/forms.py +++ b/users/forms.py @@ -2,7 +2,7 @@ import urllib.parse from django import forms from django.urls import reverse -from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm +from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm, SetPasswordForm as BaseSetPasswordForm from django.core.validators import EmailValidator from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -11,6 +11,11 @@ from django.forms import ModelForm from django.utils.html import escape, mark_safe +def _(x): + # dummy gettext + return x + + UserModel = get_user_model() @@ -68,3 +73,26 @@ class UserCreationForm(BaseUserCreationForm): class ResendConfirmationForm(forms.Form): email = forms.EmailField() + + +class RequestPasswordResetForm(forms.Form): + email = forms.EmailField() + + def clean_email(self): + email = self.cleaned_data['email'] + if not UserModel.objects.filter(username=email).exists(): + # Many sites say "if the email is registered, we've sent you an email with a password reset link" instead. + # The idea is not to leak information about which emails are registered. But in our setup we're already + # leaking that information in the signup form. At least for now, I'm erring on the side of + # user-friendliness. see https://news.ycombinator.com/item?id=33718202 + raise ValidationError("This email is not registered.") + + return email + + +class SetPasswordForm(BaseSetPasswordForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['new_password1'].help_text = "At least 8 characters" + + self.fields['new_password2'].help_text = None # "Confirm password" is descriptive enough diff --git a/users/tasks.py b/users/tasks.py index c38dcad..9689adc 100644 --- a/users/tasks.py +++ b/users/tasks.py @@ -19,3 +19,17 @@ def send_confirm_email(email, token): "confirm_url": reverse("confirm_email", kwargs={"token": token}), }, ) + + +@shared_task +def send_reset_email(email, token): + send_rendered_email( + subject="Reset your password", + base_template_name="users/reset_password_email", + recipient_list=[email], + context={ + "site_title": get_settings().SITE_TITLE, + "base_url": get_settings().BASE_URL + "/", + "reset_url": reverse("reset_email", kwargs={"token": token}), + }, + ) diff --git a/users/templates/users/request_reset_password.html b/users/templates/users/request_reset_password.html new file mode 100644 index 0000000..540ea46 --- /dev/null +++ b/users/templates/users/request_reset_password.html @@ -0,0 +1,41 @@ +{% extends "barest_base.html" %} +{% load static %} + +{% block title %}Resend confirmation · {{ site_title }}{% endblock %} + +{% block content %} + +
{# the cyan background #} +
{# the centered box #} +
{# the logo #} + Bugsink +
+ +
+ +
+ {% csrf_token %} + +
+ + {% if form.email.errors %} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.email.help_text %} +
{{ form.email.help_text|safe }}
+ {% endif %} +
+ + +
+ + +
+ +
+
+ +{% endblock %} diff --git a/users/templates/users/resend_confirmation.html b/users/templates/users/resend_confirmation.html index 8f476c0..5299d0f 100644 --- a/users/templates/users/resend_confirmation.html +++ b/users/templates/users/resend_confirmation.html @@ -31,7 +31,7 @@ diff --git a/users/templates/users/reset_password.html b/users/templates/users/reset_password.html new file mode 100644 index 0000000..67bc486 --- /dev/null +++ b/users/templates/users/reset_password.html @@ -0,0 +1,53 @@ +{% extends "barest_base.html" %} +{% load static %} + +{% block title %}Reset password · {{ site_title }}{% endblock %} + +{% block content %} + +
{# the cyan background #} +
{# the centered box #} +
{# the logo #} + Bugsink +
+ +
+ +
+ {% csrf_token %} + +
+ + {% if form.new_password1.errors %} + {% for error in form.new_password1.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.new_password1.help_text %} +
{{ form.new_password1.help_text|safe }}
+ {% endif %} +
+ +
+ + {% if form.new_password2.errors %} + {% for error in form.new_password2.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.new_password2.help_text %} +
{{ form.new_password2.help_text|safe }}
+ {% endif %} +
+ + + +
+ + +
+ +
+
+ +{% endblock %} diff --git a/users/templates/users/reset_password_email.html b/users/templates/users/reset_password_email.html new file mode 100644 index 0000000..bddf016 --- /dev/null +++ b/users/templates/users/reset_password_email.html @@ -0,0 +1,519 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/users/templates/users/reset_password_email.txt b/users/templates/users/reset_password_email.txt new file mode 100644 index 0000000..3c12d98 --- /dev/null +++ b/users/templates/users/reset_password_email.txt @@ -0,0 +1,5 @@ +Someone (hopefully you) has requested to reset the password for the account associated with this email address. + +If it was you, please follow the link below to reset your password. + +{{ reset_url }} diff --git a/users/templates/users/reset_password_email_sent.html b/users/templates/users/reset_password_email_sent.html new file mode 100644 index 0000000..e19a97d --- /dev/null +++ b/users/templates/users/reset_password_email_sent.html @@ -0,0 +1,21 @@ +{% extends "barest_base.html" %} +{% load static %} + +{% block title %}Password reset sent · {{ site_title }}{% endblock %} + +{% block content %} + +
{# the cyan background #} +
{# the centered box #} +
{# the logo #} + Bugsink +
+ +
+ A password reset link has been sent to your email address. Please check your inbox and follow the instructions to reset your password. +
+ +
+
+ +{% endblock %} diff --git a/users/views.py b/users/views.py index de08dc8..f2bf3cf 100644 --- a/users/views.py +++ b/users/views.py @@ -8,9 +8,9 @@ from django.utils import timezone from bugsink.app_settings import get_settings, CB_ANYBODY -from .forms import UserCreationForm, ResendConfirmationForm +from .forms import UserCreationForm, ResendConfirmationForm, RequestPasswordResetForm, SetPasswordForm from .models import EmailVerification -from .tasks import send_confirm_email +from .tasks import send_confirm_email, send_reset_email UserModel = get_user_model() @@ -43,7 +43,7 @@ def signup(request): return render(request, "signup.html", {"form": form}) -def confirm_email(request, token): +def confirm_email(request, token=None): # clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron job-like EmailVerification.objects.filter( created_at__lt=timezone.now() - timedelta(get_settings().USER_REGISTRATION_VERIFY_EMAIL_EXPIRY)).delete() @@ -87,12 +87,73 @@ def resend_confirmation(request): return render(request, "users/resend_confirmation.html", {"form": form}) +def request_reset_password(request): + # something like this exists in Django too; copy-paste-modify from the other views was more simple than thoroughly + # understanding the Django implementation and hooking into it. + + if request.method == 'POST': + form = RequestPasswordResetForm(request.POST) + + if form.is_valid(): + user = UserModel.objects.get(username=form.cleaned_data['email']) + # if not user.is_active no separate branch for this: password-reset implies email-confirmation + + # we reuse the EmailVerification model for password resets; security wise it doesn't matter, because the + # visiting any link with the token implies control over the email account; and we have defined that such + # control implies both verification and password-resetting. + verification = EmailVerification.objects.create(user=user, email=user.username) + send_reset_email.delay(user.username, verification.token) + return render(request, "users/reset_password_email_sent.html", {"email": user.username}) + + else: + form = RequestPasswordResetForm() + + return render(request, "users/request_reset_password.html", {"form": form}) + + +def reset_password(request, token=None): + # clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron + # job-like thing + EmailVerification.objects.filter( + created_at__lt=timezone.now() - timedelta(get_settings().USER_REGISTRATION_VERIFY_EMAIL_EXPIRY)).delete() + + try: + verification = EmailVerification.objects.get(token=token) + except EmailVerification.DoesNotExist: + # good enough (though a special page might be prettier) + raise Http404("Invalid or expired token") + + user = verification.user + + if request.method == 'POST': + form = SetPasswordForm(user, request.POST) + if form.is_valid(): + user.is_active = True # password-reset implies email-confirmation + user.set_password(form.cleaned_data['new_password1']) + user.save() + + verification.delete() + + login(request, verification.user) + return redirect('home') + + else: + form = SetPasswordForm(user) + + return render(request, "users/reset_password.html", {"form": form}) + + DEBUG_CONTEXTS = { "confirm_email": { "site_title": get_settings().SITE_TITLE, "base_url": get_settings().BASE_URL + "/", "confirm_url": "http://example.com/confirm-email/1234567890abcdef", # nonsense to avoid circular import }, + "reset_password_email": { + "site_title": get_settings().SITE_TITLE, + "base_url": get_settings().BASE_URL + "/", + "reset_url": "http://example.com/reset-password/1234567890abcdef", # nonsense to avoid circular import + }, }