Email verification

This commit is contained in:
Klaas van Schelven
2024-05-30 09:35:01 +02:00
parent 0d6d716600
commit 9990f58d9a
11 changed files with 700 additions and 9 deletions

View File

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

View File

@@ -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/<str:token>/", 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/<str:template_name>/', debug_email),
path('debug-alerts-email/<str:template_name>/', debug_alerts_email),
path('debug-users-email/<str:template_name>/', debug_users_email),
path('trigger-error/', trigger_error),
path("__debug__/", include("debug_toolbar.urls")),
]

View File

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

View File

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

View File

@@ -1,11 +1,24 @@
from django.contrib.auth.models import AbstractUser
import secrets
# > If youre starting a new project, its 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 youll 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 youre starting a new project, its 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 youll 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})"

21
users/tasks.py Normal file
View File

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

View File

@@ -0,0 +1,519 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="color-scheme: light dark; supported-color-schemes: light dark;">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.u-margin-bottom-none {
margin-bottom: 0;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important; height: 100%; -webkit-text-size-adjust: none; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; background-color: #F2F4F6; color: #51545E; margin: 0;" bgcolor="#F2F4F6">
<span class="preheader" style="display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;">{# As I understand it, this hidden div is specifically meant to be shown in email clients' preview (preview of message content in list of emails) #}Verify your email</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #F2F4F6; margin: 0; padding: 0;" bgcolor="#F2F4F6">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td class="email-masthead" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;" align="center">
<a href="{{ base_url }}" class="f-fallback email-masthead_name" style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
{{ site_title }}
</a>
</td>
</tr>
{# Email Body #}
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation" style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #FFFFFF; margin: 0 auto; padding: 0;" bgcolor="#FFFFFF">
{# Body content #}
<tr>
<td class="content-cell" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; padding: 45px;">
<div class="f-fallback">
<h1 style="margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;" align="left">Email verification</h1>
<p style="font-size: 16px; line-height: 1.625; color: #51545E; margin: 1.1875em 0 1.1875em;">
Someone has registered a Bugsink account with this email address. If it was you, please click the button below to verify your email address.
</p>
{# Action #}
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
{# Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design #}
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<a href="{{ confirm_url }}" class="f-fallback button button--green" target="_blank" style="color: #51545E; background-color: #A5F3FC; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box; border-color: #A5F3FC; border-style: solid; border-width: 10px 18px;"><b>Confirm email</b></a>
</td>
</tr>
</table>
</td>
</tr>
</table>
{# Sub copy #}
<table class="body-sub" role="presentation" style="margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;">
<tr>
<td style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: .4em 0 0;">Copyable link:</p>
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: 0 0 1.1875em;">{{ confirm_url }}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

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

View File

@@ -0,0 +1,23 @@
{% extends "barest_base.html" %}
{% load static %}
{% block title %}Verification email sent · {{ site_title }}{% endblock %}
{% block content %}
<div class="bg-cyan-100 h-screen overflow-y-scroll flex items-center justify-center"> {# the cyan background #}
<div class="bg-white lg:w-5/12 md:6/12 w-10/12"> {# the centered box #}
<div class="bg-slate-200 absolute left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full p-4 md:p-8"> {# the logo #}
<a href="/"><img src="{% static 'images/bugsink-logo.png' %}" class="h-8 w-8 md:h-16 md:w-16" alt="Bugsink"></a>
</div>
<div class="p-12 md:pt-24 md:pl-24 md:pr-24 md:pb-16">
A verification email has been sent to your email address. Please verify your email address to complete the registration process.
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "barest_base.html" %}
{% load static %}
{% block title %}Email verification successful · {{ site_title }}{% endblock %}
{% block content %}
<div class="bg-cyan-100 h-screen overflow-y-scroll flex items-center justify-center"> {# the cyan background #}
<div class="bg-white lg:w-5/12 md:6/12 w-10/12"> {# the centered box #}
<div class="bg-slate-200 absolute left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full p-4 md:p-8"> {# the logo #}
<a href="/"><img src="{% static 'images/bugsink-logo.png' %}" class="h-8 w-8 md:h-16 md:w-16" alt="Bugsink"></a>
</div>
<div class="p-12 md:pt-24 md:pl-24 md:pr-24 md:pb-16">
Email verification successful. You can now <a href="{% url 'login' %}"class="text-cyan-500">log in</a> to your account.
</div>
</div>
</div>
{% endblock %}

View File

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