diff --git a/.env.example b/.env.example index 461bb60b..136d79b0 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ AWS_CLOUDFRONT_DISTRIBUTION_ID="" # courtlistener.py COURTLISTENER_API_KEY="" +# hcaptcha.py +HCAPTCHA_SITEKEY="" +HCAPTCHA_SECRET="" + # mastodon.py MASTODON_ACCOUNT="" MASTODON_EMAIL="" diff --git a/bc/assets/templates/includes/header.html b/bc/assets/templates/includes/header.html index b7d1c76f..7855156f 100644 --- a/bc/assets/templates/includes/header.html +++ b/bc/assets/templates/includes/header.html @@ -140,10 +140,47 @@
{% include './action-button.html' with link="https://free.law/donate/" text="Donate" size='sm' color='saffron' %}
-
- {% url 'sign-in' as sign_in %} - {% include './action-button.html' with link=sign_in text="Sign In" size='sm' %} -
+ {% if user.is_authenticated %} +
+ +
+
+ +
+ {% else %} +
+ {% url 'sign-in' as sign_in %} + {% include './action-button.html' with link=sign_in text="Sign In" size='sm' %} +
+ {% endif %} diff --git a/bc/assets/templates/includes/inlines/logout.svg b/bc/assets/templates/includes/inlines/logout.svg new file mode 100644 index 00000000..e7d688dc --- /dev/null +++ b/bc/assets/templates/includes/inlines/logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/bc/assets/templates/includes/inlines/profile.svg b/bc/assets/templates/includes/inlines/profile.svg new file mode 100644 index 00000000..ae4314b1 --- /dev/null +++ b/bc/assets/templates/includes/inlines/profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/bc/assets/templates/includes/regular-anchor.html b/bc/assets/templates/includes/regular-anchor.html new file mode 100644 index 00000000..9b8e84e9 --- /dev/null +++ b/bc/assets/templates/includes/regular-anchor.html @@ -0,0 +1 @@ +{{text}} diff --git a/bc/core/utils/network.py b/bc/core/utils/network.py index 132e5554..4abeb5f2 100644 --- a/bc/core/utils/network.py +++ b/bc/core/utils/network.py @@ -25,6 +25,17 @@ def strip_port_to_make_ip_key(group: str, request: HttpRequest) -> str: return header.split(":")[0] +def get_path_to_make_key(group: str, request: HttpRequest) -> str: + """Return a string representing the full path to the requested page. This + helper makes a good key to create a global limit to throttle requests. + + :param group: Unused: The group key from the ratelimiter + :param request: The HTTP request from the user + :return: A key that can be used to throttle request to a single URL if needed. + """ + return request.path + + ratelimiter_unsafe_10_per_m = ratelimit( key=strip_port_to_make_ip_key, rate="10/m", @@ -38,3 +49,10 @@ def strip_port_to_make_ip_key(group: str, request: HttpRequest) -> str: method=ratelimit.UNSAFE, block=True, ) + +ratelimiter_unsafe_2000_per_h = ratelimit( + key=get_path_to_make_key, + rate="2000/h", + method=ratelimit.UNSAFE, + block=True, +) diff --git a/bc/core/utils/urls.py b/bc/core/utils/urls.py new file mode 100644 index 00000000..3577eff1 --- /dev/null +++ b/bc/core/utils/urls.py @@ -0,0 +1,59 @@ +from django.conf import settings +from django.http import HttpRequest +from django.urls import reverse +from django.utils.http import url_has_allowed_host_and_scheme + + +def get_redirect_or_login_url(request: HttpRequest, field_name: str) -> str: + """Get the redirect if it's safe, or send the user to the login page + + :param request: The HTTP request + :param field_name: The field where the redirect is located + :return: Either the value requested or the default LOGIN_REDIRECT_URL, if + a sanity or security check failed. + """ + url = request.GET.get(field_name, "") + is_safe = is_safe_url(url, request) + if not is_safe: + return settings.LOGIN_REDIRECT_URL + return url + + +def is_safe_url(url: str, request: HttpRequest) -> bool: + """Check whether a redirect URL is safe + + Much of this code was grabbed from Django: + + 1. Prevent open redirect attacks. Imagine getting an email: + + Subject: Your account is conformed + Body: Click here to continue: https://www.courtlistener.com/?next=https://cortlistener.com/evil/thing + + Without proper redirect sanitation, a user might click that link, and get + redirected to courtlistener.com, which could be a spoof of the real thing. + + 1. Prevent illogical redirects. Like, don't let people redirect back to + the sign-in or register page. + + 1. Prevent garbage URLs (like empty ones or ones with spaces) + + 1. Prevent dangerous URLs (like JavaScript) + + :param url: The URL to check + :param request: The user request + :return True if safe, else False + """ + sign_in_url = reverse("sign-in") in url + register_in_url = reverse("register") in url + # Fixes security vulnerability reported upstream to Python, where + # whitespace can be provided in the scheme like "java\nscript:alert(bad)" + garbage_url = any([c in url for c in ["\n", "\r", " "]]) + no_url = not url + not_safe_url = not url_has_allowed_host_and_scheme( + url, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ) + if any([sign_in_url, register_in_url, garbage_url, no_url, not_safe_url]): + return False + return True diff --git a/bc/settings/__init__.py b/bc/settings/__init__.py index 0de0deb5..63942a0d 100644 --- a/bc/settings/__init__.py +++ b/bc/settings/__init__.py @@ -5,6 +5,7 @@ from .project.testing import * from .third_party.aws import * from .third_party.courtlistener import * +from .third_party.hcaptcha import * from .third_party.mastodon import * from .third_party.pacer import * from .third_party.redis import * diff --git a/bc/settings/django.py b/bc/settings/django.py index ecdfc486..bff58e2e 100644 --- a/bc/settings/django.py +++ b/bc/settings/django.py @@ -36,6 +36,7 @@ "bc.web", # other apps "django_rq", + "hcaptcha", "tailwind", "django_htmx", ] @@ -127,6 +128,13 @@ USE_TZ = True +MANAGERS = [ + ( + env("MANAGER_NAME", default="Joe Schmoe"), + env("MANAGER_EMAIL", default="joe@courtlistener.com"), + ) +] + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ STATIC_URL = env.str("STATIC_URL", default="static/") diff --git a/bc/settings/project/email.py b/bc/settings/project/email.py index 49f91ad3..5357a3c0 100644 --- a/bc/settings/project/email.py +++ b/bc/settings/project/email.py @@ -9,3 +9,6 @@ EMAIL_BACKEND = "django_ses.SESBackend" AWS_SES_REGION_NAME = "us-west-2" AWS_SES_REGION_ENDPOINT = "email.us-west-2.amazonaws.com" + + +DEFAULT_FROM_EMAIL = "Bots.law " diff --git a/bc/settings/project/security.py b/bc/settings/project/security.py index d2016e1b..b27dcd3d 100644 --- a/bc/settings/project/security.py +++ b/bc/settings/project/security.py @@ -19,8 +19,17 @@ X_FRAME_OPTIONS = "DENY" SECURE_REFERRER_POLICY = "same-origin" CSP_CONNECT_SRC = ("'self'", "https://plausible.io/") -CSP_SCRIPT_SRC = ("'self'", AWS_S3_CUSTOM_DOMAIN, "https://plausible.io/") -CSP_DEFAULT_SRC = ("'self'", AWS_S3_CUSTOM_DOMAIN) +CSP_SCRIPT_SRC = ( + "'self'", + AWS_S3_CUSTOM_DOMAIN, + "https://plausible.io/", + "https://hcaptcha.com/", +) +CSP_DEFAULT_SRC = ( + "'self'", + AWS_S3_CUSTOM_DOMAIN, + "https://newassets.hcaptcha.com/", +) RATELIMIT_VIEW = "bc.web.views.ratelimited" diff --git a/bc/settings/third_party/hcaptcha.py b/bc/settings/third_party/hcaptcha.py new file mode 100644 index 00000000..53d0ca5e --- /dev/null +++ b/bc/settings/third_party/hcaptcha.py @@ -0,0 +1,12 @@ +import environ + +env = environ.FileAwareEnv() + +from ..project.testing import TESTING + +if TESTING: + HCAPTCHA_SITEKEY = "10000000-ffff-ffff-ffff-000000000001" + HCAPTCHA_SECRET = "0x0000000000000000000000000000000000000000" +else: + HCAPTCHA_SITEKEY = env("HCAPTCHA_SITEKEY", default="") + HCAPTCHA_SECRET = env("HCAPTCHA_SECRET", default="") diff --git a/bc/users/apps.py b/bc/users/apps.py index f9a3dbad..bdcb7af1 100644 --- a/bc/users/apps.py +++ b/bc/users/apps.py @@ -3,3 +3,7 @@ class UsersConfig(AppConfig): name = "bc.users" + + def ready(self): + # Implicitly connect a signal handlers decorated with @receiver. + from bc.users import signals diff --git a/bc/users/forms.py b/bc/users/forms.py new file mode 100644 index 00000000..7a426204 --- /dev/null +++ b/bc/users/forms.py @@ -0,0 +1,114 @@ +from disposable_email_domains import blocklist +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import ( + AuthenticationForm, + PasswordResetForm, + UserCreationForm, +) +from django.contrib.auth.models import AbstractBaseUser +from django.core.mail import send_mail +from django.urls import reverse +from hcaptcha.fields import hCaptchaField + +from .utils.email import EmailType, emails + + +class ConfirmedEmailAuthenticationForm(AuthenticationForm): + """ + Tweak the AuthenticationForm class to ensure that only users with + confirmed email addresses can log in. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + def confirm_login_allowed(self, user: AbstractBaseUser) -> None: + """Make sure the user is active and has a confirmed email address + + If the given user cannot log in, this method should raise a + ``forms.ValidationError``. + + If the given user may log in, this method should return None. + """ + if not user.is_active: # type: ignore + raise forms.ValidationError( + self.error_messages["inactive"], + code="inactive", + ) + + if not user.email_confirmed: # type: ignore + raise forms.ValidationError( + "Please validate your email address to log in." + ) + + +class RegisterForm(UserCreationForm): + class Meta: + model = get_user_model() + fields = [ + "username", + "email", + "password1", + "password2", + "first_name", + "last_name", + ] + + def clean_email(self): + email = self.cleaned_data.get("email") + _, domain_part = email.rsplit("@", 1) + if domain_part in blocklist: + raise forms.ValidationError( + f"{domain_part} is a blocked email provider", + code="bad_email_domain", + ) + return email + + +class OptInConsentForm(forms.Form): + consent = forms.BooleanField( + error_messages={ + "required": "To create a new account, you must agree below.", + }, + required=True, + ) + hcaptcha = hCaptchaField() + + +class EmailConfirmationForm(forms.Form): + email = forms.EmailField( + widget=forms.EmailInput( + attrs={ + "placeholder": "Your Email Address", + "autocomplete": "email", + "autofocus": "on", + } + ), + required=True, + ) + + +class CustomPasswordResetForm(PasswordResetForm): + """ + Tweaks the PasswordResetForm class to ensure we send a message + even if we don't find an account with the recipient address + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def save(self, *args, **kwargs) -> None: + """Override the usual save method to send a message if we don't find + any accounts + """ + recipient_addr = self.cleaned_data["email"] + users = self.get_users(recipient_addr) + if not len(list(users)): + email: EmailType = emails["no_account_found"] + body = email["body"] % ("password reset", reverse("register")) + send_mail( + email["subject"], body, email["from_email"], [recipient_addr] + ) + else: + super().save(*args, **kwargs) diff --git a/bc/users/migrations/0004_remove_user_activation_key_remove_user_key_expires.py b/bc/users/migrations/0004_remove_user_activation_key_remove_user_key_expires.py new file mode 100644 index 00000000..1f20cfc1 --- /dev/null +++ b/bc/users/migrations/0004_remove_user_activation_key_remove_user_key_expires.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.7 on 2023-06-21 14:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0003_user_activation_key_user_email_confirmed_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="activation_key", + ), + migrations.RemoveField( + model_name="user", + name="key_expires", + ), + ] diff --git a/bc/users/models.py b/bc/users/models.py index 8c5eec34..a3060e8a 100644 --- a/bc/users/models.py +++ b/bc/users/models.py @@ -1,4 +1,5 @@ from django.contrib.auth.models import AbstractUser +from django.core.signing import TimestampSigner from django.db import models from bc.core.models import AbstractDateTimeModel @@ -9,18 +10,17 @@ class User(AbstractDateTimeModel, AbstractUser): help_text="The email address of the user.", unique=True, ) - activation_key = models.CharField(max_length=40, default="") - key_expires = models.DateTimeField( - help_text="The time and date when the user's activation_key expires", - blank=True, - null=True, - ) email_confirmed = models.BooleanField( help_text="The user has confirmed their email address", default=False, ) affiliation = models.TextField(help_text="User's affiliations", blank=True) + signer = TimestampSigner(sep="/", salt="user.Users") + + def get_signed_pk(self): + return self.signer.sign(self.pk) + @property def name(self): if self.get_full_name(): diff --git a/bc/users/signals.py b/bc/users/signals.py new file mode 100644 index 00000000..c95f9141 --- /dev/null +++ b/bc/users/signals.py @@ -0,0 +1,25 @@ +from datetime import timedelta + +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils.timezone import now + + +@receiver( + post_save, + sender=settings.AUTH_USER_MODEL, + dispatch_uid="create_superuser_object", +) +def superuser_creation(sender, instance, created, **kwargs): + """ + Populates fields related to authentication in the user model for + records created using the createsuperuser command. + + We need to do this to allow superuser accounts to login using the + custom form that implements the ConfirmedEmailAuthenticationForm class. + """ + if created and instance.is_superuser: + instance.email_confirmed = True + + instance.save(update_fields=["email_confirmed"]) diff --git a/bc/users/templates/register/confirm.html b/bc/users/templates/register/confirm.html new file mode 100644 index 00000000..b9541df6 --- /dev/null +++ b/bc/users/templates/register/confirm.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Email confirmation{% endblock %} + +{% block content %} +
+
+
+ {% 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 @@

Sign In

{% if form.errors %} - {% for error in form.non_field_errors %} -

{{ error|safe }}

- {% endfor %} + {% endif %}
@@ -28,6 +30,12 @@

Sign In

{% include 'includes/submit-button.html' with value='Sign In' %}
+
+ {% 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' %} +
+

+ You Have Successfully Signed Out +

+
+

Why don't you try one of the following pages?

+
+ {% url 'homepage' as homepage %} + {% include 'includes/transparent-button.html' with link=homepage text='Homepage'%} + {% url 'big_cases_about' as big_cases_about %} + {% include 'includes/transparent-button.html' with link=big_cases_about text='About Me'%} +
+
+
+{% endblock %} diff --git a/bc/users/templates/register/password_reset_complete.html b/bc/users/templates/register/password_reset_complete.html new file mode 100644 index 00000000..1957cc0b --- /dev/null +++ b/bc/users/templates/register/password_reset_complete.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Password Reset Complete{% 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 %} + + {% endif %} +

+ Enter New Password +

+
+
+ {% csrf_token %} + {% for field in form %} +
+
+ {{ field.label_tag }} + {% if field.field.required %} + * + {% endif %} +
+ {{ field }} + {% if field.errors %} +
+ {{field.errors}} +
+ {% endif %} +
+ {% endfor %} + + {% include 'includes/submit-button.html' with value='Reset My 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 %} + + {% endif %} + +

Enter your email address and we will send you the username on the account + and instructions for resetting your password.

+ +
+
+
+ {% csrf_token %} +
+ +
+ +
+ +
+
+
+
+ + +

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 %} + + {% endif %} +

+ Register a New Account +

+
+
+ {% csrf_token %} + {% for field in form %} +
+
+ {{ field.label_tag }} + {% if field.field.required %} + * + {% endif %} +
+ {{ field }} + {% if field.errors %} +
+ {{field.errors}} +
+ {% endif %} +
+ {% endfor %} +
+
+ {{ consent_form.consent }} +
+ +
+
+ {{ consent_form.hcaptcha }} + {% if consent_form.hcaptcha.errors %} +
+ {{consent_form.hcaptcha.errors}} +
+ {% endif %} +
+ + {% include 'includes/submit-button.html' with value='Create 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:

+
    +
  • Is the email address above correct? If not, please register again.
  • +
  • Check your spam folder. We sometimes get wrongly categorized as a spammer. If the email is in your spam folder, please mark the message as NOT SPAM and add {{ default_from }} to your contacts. +
  • +
  • If it's not in your spam folder, you may + {% url 'email_confirmation_request' as email_request %} + {% include 'includes/regular-anchor.html' with text='request another confirmation email' href=email_request %} + from us. This can sometimes help if the email gets lost in transit.

    +
  • +
+

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 %} + + {% endif %} + +

Enter your email address and we will send you a confirmation email for your account.

+ +
+
+
+ {% csrf_token %} +
+ +
+ +
+ +
+
+
+
+

+ 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"