From 9990f58d9ae50e2a4186a2d2699bcf615ccba5d3 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 30 May 2024 09:35:01 +0200 Subject: [PATCH] Email verification --- bugsink/app_settings.py | 2 + bugsink/urls.py | 10 +- users/admin.py | 5 +- users/migrations/0002_emailverification.py | 26 + users/models.py | 21 +- users/tasks.py | 21 + users/templates/users/confirm_email.html | 519 ++++++++++++++++++ users/templates/users/confirm_email.txt | 5 + users/templates/users/confirm_email_sent.html | 23 + users/templates/users/email_confirmed.html | 21 + users/views.py | 56 +- 11 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 users/migrations/0002_emailverification.py create mode 100644 users/tasks.py create mode 100644 users/templates/users/confirm_email.html create mode 100644 users/templates/users/confirm_email.txt create mode 100644 users/templates/users/confirm_email_sent.html create mode 100644 users/templates/users/email_confirmed.html diff --git a/bugsink/app_settings.py b/bugsink/app_settings.py index ac43f80..65e45ff 100644 --- a/bugsink/app_settings.py +++ b/bugsink/app_settings.py @@ -23,6 +23,8 @@ DEFAULTS = { # Users, teams, projects "USER_REGISTRATION": CB_ANYBODY, # who can register new users. default: anybody, i.e. users can register themselves + "USER_REGISTRATION_VERIFY_EMAIL": True, + "USER_REGISTRATION_VERIFY_EMAIL_EXPIRY": 3 * 24 * 60 * 60, # 7 days # System inner workings: "DIGEST_IMMEDIATELY": True, diff --git a/bugsink/urls.py b/bugsink/urls.py index a429b40..e56579b 100644 --- a/bugsink/urls.py +++ b/bugsink/urls.py @@ -4,9 +4,10 @@ from django.contrib import admin from django.urls import include, path from django.contrib.auth import views as auth_views -from alerts.views import debug_email +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 +from users.views import signup, confirm_email from .views import home, trigger_error, favicon @@ -20,6 +21,8 @@ urlpatterns = [ path('', home, name='home'), path("accounts/signup/", signup, name="signup"), + path("accounts/confirm-email//", confirm_email, name="confirm_email"), + 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"), @@ -35,7 +38,8 @@ urlpatterns = [ if settings.DEBUG: urlpatterns += [ - path('debug-email-alerts//', debug_email), + path('debug-alerts-email//', debug_alerts_email), + path('debug-users-email//', debug_users_email), path('trigger-error/', trigger_error), path("__debug__/", include("debug_toolbar.urls")), ] diff --git a/users/admin.py b/users/admin.py index f91be8f..64874f3 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,5 +1,8 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .models import User +from .models import User, EmailVerification admin.site.register(User, UserAdmin) + + +admin.site.register(EmailVerification) diff --git a/users/migrations/0002_emailverification.py b/users/migrations/0002_emailverification.py new file mode 100644 index 0000000..720a739 --- /dev/null +++ b/users/migrations/0002_emailverification.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.13 on 2024-05-29 15:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import secrets + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='EmailVerification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('token', models.CharField(default=secrets.token_urlsafe, max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/users/models.py b/users/models.py index 22a9930..d8d7fe1 100644 --- a/users/models.py +++ b/users/models.py @@ -1,11 +1,24 @@ -from django.contrib.auth.models import AbstractUser +import secrets -# > If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default User -# > model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to -# > customize it in the future if the need arises +from django.db import models +from django.contrib.auth.models import AbstractUser +from django.conf import settings class User(AbstractUser): + # > If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default + # > User model is sufficient for you. This model behaves identically to the default user model, but you’ll be able + # > to customize it in the future if the need arises class Meta: db_table = 'auth_user' + + +class EmailVerification(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + email = models.EmailField() # redundant, but future-proof for when we allow multiple emails per user + token = models.CharField(max_length=64, default=secrets.token_urlsafe, blank=False, null=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user} ({self.email})" diff --git a/users/tasks.py b/users/tasks.py new file mode 100644 index 0000000..c38dcad --- /dev/null +++ b/users/tasks.py @@ -0,0 +1,21 @@ +from django.urls import reverse + +from snappea.decorators import shared_task + +from bugsink.app_settings import get_settings + +from alerts.utils import send_rendered_email + + +@shared_task +def send_confirm_email(email, token): + send_rendered_email( + subject="Confirm your email address", + base_template_name="users/confirm_email", + recipient_list=[email], + context={ + "site_title": get_settings().SITE_TITLE, + "base_url": get_settings().BASE_URL + "/", + "confirm_url": reverse("confirm_email", kwargs={"token": token}), + }, + ) diff --git a/users/templates/users/confirm_email.html b/users/templates/users/confirm_email.html new file mode 100644 index 0000000..3c7acba --- /dev/null +++ b/users/templates/users/confirm_email.html @@ -0,0 +1,519 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/users/templates/users/confirm_email.txt b/users/templates/users/confirm_email.txt new file mode 100644 index 0000000..02210c0 --- /dev/null +++ b/users/templates/users/confirm_email.txt @@ -0,0 +1,5 @@ +Someone has registered a Bugsink account with this email address. + +If it was you, please follow the link below to verify your email address. + +{{ confirm_url }} diff --git a/users/templates/users/confirm_email_sent.html b/users/templates/users/confirm_email_sent.html new file mode 100644 index 0000000..9447101 --- /dev/null +++ b/users/templates/users/confirm_email_sent.html @@ -0,0 +1,23 @@ +{% extends "barest_base.html" %} +{% load static %} + +{% block title %}Verification email sent · {{ site_title }}{% endblock %} + +{% block content %} + +
{# the cyan background #} +
{# the centered box #} +
{# the logo #} + Bugsink +
+ +
+ + A verification email has been sent to your email address. Please verify your email address to complete the registration process. + +
+ +
+
+ +{% endblock %} diff --git a/users/templates/users/email_confirmed.html b/users/templates/users/email_confirmed.html new file mode 100644 index 0000000..f4a8360 --- /dev/null +++ b/users/templates/users/email_confirmed.html @@ -0,0 +1,21 @@ +{% extends "barest_base.html" %} +{% load static %} + +{% block title %}Email verification successful · {{ site_title }}{% endblock %} + +{% block content %} + +
{# the cyan background #} +
{# the centered box #} +
{# the logo #} + Bugsink +
+ +
+ Email verification successful. You can now log in to your account. +
+ +
+
+ +{% endblock %} diff --git a/users/views.py b/users/views.py index f13f7ba..4b4aa54 100644 --- a/users/views.py +++ b/users/views.py @@ -1,11 +1,16 @@ -from django.contrib.auth import login # , authenticate +from datetime import timedelta + +from django.contrib.auth import login from django.shortcuts import render, redirect from django.contrib.auth import get_user_model from django.http import Http404 +from django.utils import timezone from bugsink.app_settings import get_settings, CB_ANYBODY from .forms import UserCreationForm +from .models import EmailVerification +from .tasks import send_confirm_email UserModel = get_user_model() @@ -19,6 +24,16 @@ def signup(request): form = UserCreationForm(request.POST) if form.is_valid(): + if get_settings().USER_REGISTRATION_VERIFY_EMAIL: + user = form.save(commit=False) + user.is_active = False + user.save() + + verification = EmailVerification.objects.create(user=user, email=user.username) + send_confirm_email.delay(user.username, verification.token) + + return render(request, "users/confirm_email_sent.html", {"email": user.username}) + user = form.save() login(request, user) return redirect('home') @@ -26,3 +41,42 @@ def signup(request): form = UserCreationForm() return render(request, "signup.html", {"form": form}) + + +def confirm_email(request, token): + # 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() + + 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") + + verification.user.is_active = True + verification.user.save() + verification.delete() + + # I don't want to log the user in based on the verification email alone; although in principle doing so would not + # be something fundamentally more insecure than what we do in the password-reset loop (in both cases access to the + # email is enough to get access to Bugsink), better to err on the side of security. + # If we ever want to introduce a more user-friendly approach, we could make automatic login dependent on some + # (signed) cookie that's being set when registring. i.e.: if you've just recently entered your password in the same + # browser, it works. + # login(request, verification.user) + + return render(request, "users/email_confirmed.html") + + +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 + }, +} + + +def debug_email(request, template_name): + return render(request, 'users/' + template_name + ".html", DEBUG_CONTEXTS[template_name])