+
+
+ {% if invalid or expired%}
+ {% include 'includes/inlines/exclamation-triangle.svg' %}
+ {% elif success or already_confirmed %}
+ {% include 'includes/inlines/check-circle.svg'%}
+ {% endif %}
+
+
+
+ {% if invalid %}
+ Invalid Confirmation Link
+ {% elif expired %}
+ Expired Confirmation Link
+ {% elif success or already_confirmed %}
+ Email address has been confirmed
+ {% endif %}
+
+
+
+
+
+ {% url 'email_confirmation_request' as email_request %}
+ {% if invalid %}
+ The link you have used to confirm your account is invalid. Please try clicking the link again or you may request
+ {% include 'includes/regular-anchor.html' with text='a new confirmation link' href=email_request%}.
+ {% elif expired %}
+ Your confirmation link has expired. Please try clicking the link again or you may request
+ {% include 'includes/regular-anchor.html' with text='a new confirmation link' href=email_request%}.
+ {% elif success or already_confirmed %}
+ Thank you for successfully confirming your email address.
+ {% endif %}
+
+
+ {% if invalid or expired %}
+
+ Please {% include 'includes/regular-anchor.html' with text='contact us' href="https://free.law/contact/"%} if you need assistance. We are always happy to help.
+
+ {% elif success or already_confirmed %}
+
+ {% url "sign-in" as sign_in %}
+ {% include 'includes/action-button.html' with link=sign_in text='Sign Into Your Account' size="sm" color='saffron' %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/bc/users/templates/register/login.html b/bc/users/templates/register/login.html
index 104399dc..cc482768 100644
--- a/bc/users/templates/register/login.html
+++ b/bc/users/templates/register/login.html
@@ -7,9 +7,11 @@
+ {% url 'register' as register %}
+ {% include 'includes/regular-anchor.html' with text='Register' href=register%} |
+ {% url 'password_reset' as password_reset %}
+ {% include 'includes/regular-anchor.html' with text='Forgot username/password' href=password_reset %}
+
{% endblock %}
diff --git a/bc/users/templates/register/logout.html b/bc/users/templates/register/logout.html
new file mode 100644
index 00000000..e5b582ec
--- /dev/null
+++ b/bc/users/templates/register/logout.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block title %}Sign Out{% endblock %}
+
+{% block content %}
+
+
+
+ {% include 'includes/inlines/check-circle.svg'%}
+
+
+
+ Password Reset Complete
+
+
+
+
+
+ {% url "sign-in" as sign_in %}
+ {% include 'includes/action-button.html' with link=sign_in text='Sign In to Continue' size="sm" color='saffron' %}
+
+
+
+{% endblock %}
diff --git a/bc/users/templates/register/password_reset_confirm.html b/bc/users/templates/register/password_reset_confirm.html
new file mode 100644
index 00000000..56496f73
--- /dev/null
+++ b/bc/users/templates/register/password_reset_confirm.html
@@ -0,0 +1,57 @@
+{% extends "base.html" %}
+
+{% block title %}Forgot password{% endblock %}
+
+{% block content %}
+
+ {% if validlink %}
+
+ {% if form.errors %}
+
+
There were errors with your submission.
+
+ {% endif %}
+
+ Enter New Password
+
+
+
+ {% else %}
+
+
+
+ {% include 'includes/inlines/exclamation-triangle.svg' %}
+
+
+
+ Password reset unsuccessful.
+
+
+
+
The password reset link was invalid, possibly because it has already been used. Sometimes email providers click links in your emails and cause this problem. Please try again or
+ contact us and we'll be happy to help.
+
+ {% endif %}
+{% endblock %}
diff --git a/bc/users/templates/register/password_reset_done.html b/bc/users/templates/register/password_reset_done.html
new file mode 100644
index 00000000..3c9fe1cb
--- /dev/null
+++ b/bc/users/templates/register/password_reset_done.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block title %}Forgot password{% endblock %}
+
+{% block content %}
+
+
+
+ {% include 'includes/inlines/check-circle.svg'%}
+
+
+
+ Password Instructions Sent
+
+
+
+
We have emailed your username and instructions for resetting your
+ password to the email address you provided.
+
+
You should be receiving them shortly.
+
+{% endblock %}
diff --git a/bc/users/templates/register/password_reset_email.html b/bc/users/templates/register/password_reset_email.html
new file mode 100644
index 00000000..2145fea6
--- /dev/null
+++ b/bc/users/templates/register/password_reset_email.html
@@ -0,0 +1,13 @@
+You're receiving this email because you requested a password reset for your account at {{ site_name }}.
+
+Please go to the following page and choose a new password:
+
+{{protocol}}://{{domain}}{% url "confirm_password" uidb64=uid token=token %}
+
+Your username, in case you've forgotten it is: {{ user.username }}
+
+Thanks for using our site!
+
+~{{ site_name }}
+
+If you did not initiate this reset, do not be concerned. Simply ignore this email, and the password reset will not occur.
diff --git a/bc/users/templates/register/password_reset_form.html b/bc/users/templates/register/password_reset_form.html
new file mode 100644
index 00000000..89790d85
--- /dev/null
+++ b/bc/users/templates/register/password_reset_form.html
@@ -0,0 +1,50 @@
+{% extends "base.html" %}
+
+{% block title %}Forgot password{% endblock %}
+
+{% block content %}
+
+
+
+
+ Reset Your Password
+
+
+
+ {% if form.errors %}
+
+
There were errors with your submission.
+
+ {% endif %}
+
+
Enter your email address and we will send you the username on the account
+ and instructions for resetting your password.
+
+
+
+
+
If you are unable to reset your account, please
+ {% include 'includes/regular-anchor.html' with text='contact us for assistance' href="https://free.law/contact/"%}.
+
+
+
+{% endblock %}
diff --git a/bc/users/templates/register/register.html b/bc/users/templates/register/register.html
new file mode 100644
index 00000000..08acd095
--- /dev/null
+++ b/bc/users/templates/register/register.html
@@ -0,0 +1,60 @@
+{% extends "base.html" %}
+{% block title %}Register an Account{% endblock %}
+
+{% block content %}
+
+
+ {% if form.errors %}
+
+
There were errors with your submission.
+
+ {% endif %}
+
+ Register a New Account
+
+
+
+ {% url 'password_reset' as password_reset %}
+ {% include 'includes/regular-anchor.html' with text='Forgot username/password?' href=password_reset %}
+
+
+
+{% endblock %}
diff --git a/bc/users/templates/register/registration_complete.html b/bc/users/templates/register/registration_complete.html
new file mode 100644
index 00000000..d052a6c6
--- /dev/null
+++ b/bc/users/templates/register/registration_complete.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% block title %}Successful Registration{% endblock %}
+
+{% block content %}
+
+
+
+ {% include 'includes/inlines/check-circle.svg'%}
+
+
+
+ Registration Complete
+
+
+
+
We have sent you an email to confirm the following address:
+
+
{{email}}
+
+
If this address is not correct, please register again using your correct email address.
+
To log in, please click the link in that email within five days. That will confirm your account.
+
If you do not receive the confirmation email:
+
+
If you are still unable to confirm your email address, please
+ {% include 'includes/regular-anchor.html' with text='contact us for assistance' href="https://free.law/contact/"%}
+ We are always happy to help.
+
+
+ {% include 'includes/action-button.html' with link=redirect_to text='Return to Where you Left Off' size="sm" color='saffron' %}
+
+
+{% endblock %}
diff --git a/bc/users/templates/register/request_email_confirmation.html b/bc/users/templates/register/request_email_confirmation.html
new file mode 100644
index 00000000..c774d773
--- /dev/null
+++ b/bc/users/templates/register/request_email_confirmation.html
@@ -0,0 +1,49 @@
+{% extends "base.html" %}
+{% block title %}Send Email Confirmation{% endblock %}
+
+{% block content %}
+
+
+
+
+ Request Email Confirmation Link
+
+
+
+
+ {% if form.errors %}
+
+ {% for error in form.errors %}
+
{{ error|safe }}
+ {% endfor %}
+
+ {% endif %}
+
+
Enter your email address and we will send you a confirmation email for your account.
+
+
+
+ If you are unable to confirm your email address, please
+ {% include 'includes/regular-anchor.html' with text='contact us for assistance' href="https://free.law/contact/"%}.
+
+
+{% endblock %}
diff --git a/bc/users/templates/register/request_email_confirmation_success.html b/bc/users/templates/register/request_email_confirmation_success.html
new file mode 100644
index 00000000..0aad34c8
--- /dev/null
+++ b/bc/users/templates/register/request_email_confirmation_success.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+{% block title %}Successful Registration{% endblock %}
+
+{% block content %}
+
+
+
+ {% include 'includes/inlines/check-circle.svg'%}
+
+
+
+ Email Confirmation Sent
+
+
+
+
+ We have sent a confirmation message to your email address. To log in, please verify your email address within five days.
+
+
+ If you are unable to confirm your email address or have any questions, please
+ {% include 'includes/regular-anchor.html' with text='contact us for help' href="https://free.law/contact/"%}.
+
+
+{% endblock %}
diff --git a/bc/users/urls.py b/bc/users/urls.py
index e8f3bb4a..c6a6ab41 100644
--- a/bc/users/urls.py
+++ b/bc/users/urls.py
@@ -1,15 +1,86 @@
from django.contrib.auth import views as auth_views
-from django.urls import path
+from django.urls import path, re_path
+from django.views.generic import TemplateView
from bc.core.utils.network import ratelimiter_unsafe_10_per_m
+from .forms import ConfirmedEmailAuthenticationForm
+from .views import (
+ RateLimitedPasswordResetView,
+ confirm_email,
+ register,
+ register_success,
+ request_email_confirmation,
+)
+
urlpatterns = [
# Sign in page
path(
"sign-in/",
ratelimiter_unsafe_10_per_m(
- auth_views.LoginView.as_view(template_name="register/login.html")
+ auth_views.LoginView.as_view(
+ template_name="register/login.html",
+ authentication_form=ConfirmedEmailAuthenticationForm,
+ )
),
name="sign-in",
- )
+ ),
+ path(
+ "sign-out/",
+ auth_views.LogoutView.as_view(template_name="register/logout.html"),
+ name="logout",
+ ),
+ path(
+ "register/",
+ register,
+ name="register",
+ ),
+ path(
+ "register/success/",
+ register_success,
+ name="register_success",
+ ),
+ re_path(
+ r"^email/confirm/(?P
[0-9]+/[A-Za-z0-9_=-]+/[A-Za-z0-9_=-]+)/$",
+ confirm_email,
+ name="email_confirm",
+ ),
+ path(
+ "email-confirmation/request/",
+ request_email_confirmation,
+ name="email_confirmation_request",
+ ),
+ path(
+ "email-confirmation/success/",
+ TemplateView.as_view(
+ template_name="register/request_email_confirmation_success.html"
+ ),
+ name="email_confirmation_request_success",
+ ),
+ path(
+ "reset-password/",
+ RateLimitedPasswordResetView.as_view(),
+ name="password_reset",
+ ),
+ path(
+ "reset-password/instructions-sent/",
+ auth_views.PasswordResetDoneView.as_view(
+ template_name="register/password_reset_done.html",
+ ),
+ name="password_reset_done",
+ ),
+ re_path(
+ r"^confirm-password/(?P[0-9A-Za-z_\-]+)/(?P.+)/$",
+ auth_views.PasswordResetConfirmView.as_view(
+ template_name="register/password_reset_confirm.html",
+ ),
+ name="confirm_password",
+ ),
+ path(
+ "reset-password/complete/",
+ auth_views.PasswordResetCompleteView.as_view(
+ template_name="register/password_reset_complete.html"
+ ),
+ name="password_reset_complete",
+ ),
]
diff --git a/bc/users/utils/email.py b/bc/users/utils/email.py
new file mode 100644
index 00000000..06cd0788
--- /dev/null
+++ b/bc/users/utils/email.py
@@ -0,0 +1,140 @@
+from typing import NotRequired, TypedDict
+
+from django.conf import settings
+
+
+class EmailType(TypedDict):
+ subject: str
+ body: str
+ from_email: str
+ to: NotRequired[list[str]]
+
+
+emails: dict[str, EmailType] = {
+ "account_deleted": {
+ "subject": "User deleted their account on Bots.law!",
+ "body": "Sad day indeed. Somebody deleted their account completely, "
+ "blowing it to smithereens. The user that deleted their "
+ "account was: \n\n"
+ " - %s\n\n"
+ "Can't keep 'em all, I suppose.\n\n",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ "to": [a[1] for a in settings.MANAGERS],
+ },
+ "take_out_requested": {
+ "subject": "User wants their data. Need to send it to them.",
+ "body": "A user has requested their data in accordance with GDPR. "
+ "This means that if they're a EU citizen, you have to provide "
+ "them with their data. Their username and email are:\n\n"
+ " - %s\n"
+ " - %s\n\n"
+ "Good luck getting this taken care of.",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ "to": [a[1] for a in settings.MANAGERS],
+ },
+ "email_changed_successfully": {
+ "subject": "Email changed successfully on Bots.law",
+ "body": "Hello %s,\n\n"
+ "You have successfully changed your email address at "
+ "Bots.law. Please confirm this change by clicking the "
+ "following link within five days:\n\n"
+ " https://bots.law/email/confirm/%s\n\n"
+ "Thanks for using our site,\n\n"
+ "The Free Law Project Team\n\n"
+ "------------------\n"
+ "For questions or comments, please see our contact page, "
+ "https://free.law/contact/.",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ },
+ "notify_old_address": {
+ "subject": "This email address is no longer in use on Bots.law",
+ "body": "Hello %s,\n\n"
+ "A moment ago somebody, hopefully you, changed the email address on "
+ "your Bots.law account. Previously, it used:\n\n"
+ " %s\n\n"
+ "But now it is set to:\n\n"
+ " %s\n\n"
+ "If you made this change, no action is needed. If you did not make "
+ "this change, please get in touch with us as soon as possible by "
+ "sending a message to:\n\n"
+ " security@free.law\n\n"
+ "Thanks for using our site,\n\n"
+ "The Free Law Project Team\n\n",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ },
+ "confirm_your_new_account": {
+ "subject": "Confirm your account on Bots.law",
+ "body": "Hello, %s, and thanks for signing up for an account on "
+ "Bots.law.\n\n"
+ "To send you emails, we need you to activate your account with "
+ "Bots.law. To activate your account, click this link "
+ "within five days:\n\n"
+ " https://bots.law/email/confirm/%s/\n\n"
+ "Thanks for using Bots.law and joining our community,\n\n"
+ "The Free Law Project Team\n\n"
+ "-------------------\n"
+ "For questions or comments, please see our contact page, "
+ "https://free.law/contact/.",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ },
+ "confirm_existing_account": {
+ "subject": "Confirm your account on Bots.law",
+ "body": "Hello,\n\n"
+ "Somebody, probably you, has asked that we send an email "
+ "confirmation link to this address.\n\n"
+ "If this was you, please confirm your email address by "
+ "clicking the following link within five days:\n\n"
+ "https://bots.law/email/confirm/%s\n\n"
+ "If this was not you, you can disregard this email.\n\n"
+ "Thanks for using our site,\n"
+ "The Free Law Project Team\n\n"
+ "-------\n"
+ "For questions or comments, please visit our contact page, "
+ "https://free.law/contact/\n"
+ "We're always happy to hear from you.",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ },
+ # Used both when people want to confirm an email address and when they
+ # want to reset their password, with one small tweak in the wording.
+ "no_account_found": {
+ "subject": "Password reset and username information on "
+ "Bots.law.com",
+ "body": "Hello,\n\n"
+ ""
+ "Somebody — probably you — has asked that we send %s "
+ "instructions to this address. If this was you, "
+ "we regret to inform you that we do not have an account with "
+ "this email address. This sometimes happens when people "
+ "have have typos in their email address when they sign up or "
+ "change their email address.\n\n"
+ ""
+ "If you think that may have happened to you, the solution is "
+ "to simply create a new account using your email address:\n\n"
+ ""
+ " https://bots.law%s\n\n"
+ ""
+ "That usually will fix the problem.\n\n"
+ ""
+ "If this was not you, you can ignore this email.\n\n"
+ ""
+ "Thanks for using our site,\n\n"
+ ""
+ "The Free Law Project Team\n\n"
+ "-------\n"
+ "For questions or comments, please visit our contact page, "
+ "https://free.law/contact/\n"
+ "We're always happy to hear from you.",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ },
+ "new_account_created": {
+ "subject": "New user confirmed on Bots.law: %s",
+ "body": "A new user has signed up on Bots.law and they'll be "
+ "automatically welcomed soon!\n\n"
+ " Their name is: %s\n"
+ " Their email address is: %s\n\n"
+ "Sincerely,\n\n"
+ "The Bots.law Bots",
+ "from_email": settings.DEFAULT_FROM_EMAIL,
+ "to": [a[1] for a in settings.MANAGERS],
+ },
+}
diff --git a/bc/users/views.py b/bc/users/views.py
new file mode 100644
index 00000000..62b21b87
--- /dev/null
+++ b/bc/users/views.py
@@ -0,0 +1,223 @@
+from datetime import timedelta
+from email.utils import parseaddr
+
+from django.conf import settings
+from django.contrib.auth.views import PasswordResetView
+from django.core.mail import send_mail
+from django.core.signing import BadSignature, SignatureExpired
+from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.utils.http import urlencode
+from django.utils.timezone import now
+from django.views.decorators.debug import (
+ sensitive_post_parameters,
+ sensitive_variables,
+)
+
+from bc.core.utils.network import (
+ ratelimiter_unsafe_10_per_m,
+ ratelimiter_unsafe_2000_per_h,
+)
+from bc.core.utils.urls import get_redirect_or_login_url
+
+from .forms import (
+ CustomPasswordResetForm,
+ EmailConfirmationForm,
+ OptInConsentForm,
+ RegisterForm,
+)
+from .models import User
+from .utils.email import EmailType, emails
+
+
+@sensitive_post_parameters("password1", "password2")
+@sensitive_variables("cd")
+@ratelimiter_unsafe_10_per_m
+@ratelimiter_unsafe_2000_per_h
+def register(request: HttpRequest) -> HttpResponse:
+ """allow only an anonymous user to register"""
+ if not request.user.is_anonymous:
+ # The user is already logged in. Direct them to their settings page as
+ # a logical fallback
+ return HttpResponseRedirect(reverse("homepage"))
+
+ redirect_to = get_redirect_or_login_url(request, "next")
+ if request.method == "POST":
+ form = RegisterForm(request.POST)
+ consent_form = OptInConsentForm(request.POST)
+
+ if form.is_valid() and consent_form.is_valid():
+ cd = form.cleaned_data
+ user = User.objects.create_user(
+ cd["username"], cd["email"], cd["password1"]
+ )
+
+ if cd["first_name"]:
+ user.first_name = cd["first_name"]
+ if cd["last_name"]:
+ user.last_name = cd["last_name"]
+
+ signed_pk = user.get_signed_pk()
+ email: EmailType = emails["confirm_your_new_account"]
+ send_mail(
+ email["subject"],
+ email["body"] % (user.username, signed_pk),
+ email["from_email"],
+ [user.email],
+ )
+ email = emails["new_account_created"]
+ send_mail(
+ email["subject"] % user.username,
+ email["body"]
+ % (
+ user.get_full_name() or "Not provided",
+ user.email,
+ ),
+ email["from_email"],
+ email["to"],
+ )
+
+ user.save(
+ update_fields=[
+ "first_name",
+ "last_name",
+ ]
+ )
+ query_string = urlencode(
+ {"next": redirect_to, "email": user.email}
+ )
+ return redirect(f"{reverse('register_success')}?{query_string}")
+ else:
+ form = RegisterForm()
+ consent_form = OptInConsentForm()
+
+ return render(
+ request,
+ "register/register.html",
+ {"form": form, "consent_form": consent_form},
+ )
+
+
+def register_success(request: HttpRequest) -> HttpResponse:
+ """
+ Let the user know they have been registered and allow them
+ to continue where they left off.
+ """
+ redirect_to = get_redirect_or_login_url(request, "next")
+ email = request.GET.get("email", "")
+ default_from = parseaddr(settings.DEFAULT_FROM_EMAIL)[1]
+ return render(
+ request,
+ "register/registration_complete.html",
+ {
+ "redirect_to": redirect_to,
+ "email": email,
+ "default_from": default_from,
+ "private": True,
+ },
+ )
+
+
+@sensitive_variables("activation_key")
+def confirm_email(request, signed_pk):
+ """Confirms email addresses for a user and sends an email to the admins.
+
+ Checks if a hash in a confirmation link is valid, and if so sets the user's
+ email address as valid.
+ """
+ try:
+ pk = User.signer.unsign(signed_pk, max_age=timedelta(days=5))
+ user = User.objects.get(pk=pk)
+ except SignatureExpired:
+ return render(
+ request,
+ "register/confirm.html",
+ {"expired": True},
+ )
+ except (BadSignature, User.DoesNotExist):
+ return render(
+ request,
+ "register/confirm.html",
+ {"invalid": True},
+ )
+
+ if user.email_confirmed:
+ return render(
+ request,
+ "register/confirm.html",
+ {"already_confirmed": True},
+ )
+
+ user.email_confirmed = True
+ user.save(update_fields=["email_confirmed"])
+
+ return render(request, "register/confirm.html", {"success": True})
+
+
+@sensitive_variables(
+ "activation_key",
+ "email",
+ "cd",
+ "confirmation_email",
+)
+@ratelimiter_unsafe_10_per_m
+@ratelimiter_unsafe_2000_per_h
+def request_email_confirmation(request: HttpRequest) -> HttpResponse:
+ """Send an email confirmation email"""
+ if request.method == "POST":
+ form = EmailConfirmationForm(request.POST)
+ if form.is_valid():
+ cd = form.cleaned_data
+ user = User.objects.filter(email__iexact=cd["email"]).first()
+ if not user:
+ # Normally, we'd throw an error here, but instead we pretend it
+ # was a success. Meanwhile, we send an email saying that a
+ # request was made, but we don't have an account with that
+ # email address.
+ email: EmailType = emails["no_account_found"]
+ message = email["body"] % (
+ "email confirmation",
+ reverse("register"),
+ )
+ send_mail(
+ email["subject"],
+ message,
+ email["from_email"],
+ [cd["email"]],
+ )
+ return HttpResponseRedirect(
+ reverse("email_confirmation_request_success")
+ )
+
+ signed_pk = user.get_signed_pk()
+ confirmation_email: EmailType = emails["confirm_existing_account"]
+ send_mail(
+ confirmation_email["subject"],
+ confirmation_email["body"] % signed_pk,
+ confirmation_email["from_email"],
+ [user.email],
+ )
+ return HttpResponseRedirect(
+ reverse("email_confirmation_request_success")
+ )
+ else:
+ form = EmailConfirmationForm()
+ return render(
+ request,
+ "register/request_email_confirmation.html",
+ {"form": form},
+ )
+
+
+@method_decorator(ratelimiter_unsafe_10_per_m, name="post")
+@method_decorator(ratelimiter_unsafe_2000_per_h, name="post")
+class RateLimitedPasswordResetView(PasswordResetView):
+ """
+ Custom Password reset view with rate limiting
+ """
+
+ template_name = "register/password_reset_form.html"
+ email_template_name = "register/password_reset_email.html"
+ form_class = CustomPasswordResetForm
diff --git a/poetry.lock b/poetry.lock
index 7756d725..5f6600aa 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -534,6 +534,21 @@ files = [
[package.extras]
graph = ["objgraph (>=1.7.2)"]
+[[package]]
+name = "disposable-email-domains"
+version = "0.0.90"
+description = "A set of disposable email domains"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "disposable-email-domains-0.0.90.tar.gz", hash = "sha256:505573f4afb902341c1c0f7c9830a0ad8c889fd88dec51de40e5822d6e12a038"},
+ {file = "disposable_email_domains-0.0.90-py2.py3-none-any.whl", hash = "sha256:50aff9fab321b561b0facc7c1124fb5c6e7c1fc3062c9117929630b7072048f4"},
+]
+
+[package.extras]
+dev = ["check-manifest"]
+
[[package]]
name = "distlib"
version = "0.3.6"
@@ -634,6 +649,18 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.0)", "pytes
docs = ["furo (>=2021.8.17b43,<2021.9.0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"]
+[[package]]
+name = "django-hcaptcha"
+version = "0.2.0"
+description = "Django hCaptcha provides a simple way to protect your django forms using hCaptcha"
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+ {file = "django-hCaptcha-0.2.0.tar.gz", hash = "sha256:b2519eaf0cc97865ac72f825301122c5cf61e1e4852d6895994160222acb6c1a"},
+ {file = "django_hCaptcha-0.2.0-py3-none-any.whl", hash = "sha256:18804fb38a01827b6c65d111bac31265c1b96fcf52d7a54c3e2d2cb1c62ddcde"},
+]
+
[[package]]
name = "django-htmx"
version = "1.14.0"
@@ -2251,4 +2278,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "6e9570725459850ef97a5b821df0a5d689bffb312d9d33ba211173c4f1863e7a"
+content-hash = "9ba8d027bc69b12ea274856a873b0af67aa1190abf99826294c637bfe5f5b252"
diff --git a/pyproject.toml b/pyproject.toml
index a9bdf3b2..0777932a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,8 @@ django-ses = "^3.4.1"
django-htmx = "^1.14.0"
factory-boy = "^3.2.1"
faker = "^18.7.0"
+disposable-email-domains = "^0.0.90"
+django-hcaptcha = "^0.2.0"
[tool.poetry.group.dev.dependencies]
black = "^23.1.0"