Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(user): Adds logic to handle authentication. #258

Merged
merged 38 commits into from
Jun 25, 2023
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
61da00f
feat(templates): Add a dropdown with the profile and logout button
ERosendo May 30, 2023
a3cc2f2
feat(users): Register endpoint to logout
ERosendo May 30, 2023
7ae3927
build(deps): Add disposable-email-domains as a dependency
ERosendo Jun 1, 2023
51d60d1
feat(template): Add template to show anchor tags
ERosendo Jun 1, 2023
cfc7ac9
feat(core): Adds a new module where we can put crypto tools
ERosendo Jun 1, 2023
fcdb7b2
feat(core): Adds a module where we can put email templates
ERosendo Jun 1, 2023
b3e7738
feat(core): Add a helper method to sanitize url redirection
ERosendo Jun 1, 2023
4f01528
feat(settings): Add the MANAGERS variable
ERosendo Jun 1, 2023
ff6d048
feat(settings): Adds the DEFAULT_FROM_EMAIL variable
ERosendo Jun 1, 2023
5a8d9de
feat(users): Add a receiver method to handle users signals
ERosendo Jun 1, 2023
5c8841b
feat(user): Add custom form to authenticate users
ERosendo Jun 1, 2023
bdca71b
feat(user): Add custom form to register users
ERosendo Jun 1, 2023
75778e2
feat(user): Add a custom form to reset the password
ERosendo Jun 1, 2023
37ac940
feat(user): Tweak the login template to show form errors
ERosendo Jun 1, 2023
311f549
feat(user): Add templates related to the user registration process
ERosendo Jun 1, 2023
31866f2
feat(user): Add templates to reset the user's password
ERosendo Jun 1, 2023
de0f97a
feat(user): Add template to request email confirmation link
ERosendo Jun 1, 2023
0699132
feat(user): Add views to register users and confirm their email
ERosendo Jun 1, 2023
cb1cc69
feat(user): Add endpoints to register, reset password and confirm emails
ERosendo Jun 1, 2023
404a321
feat(core): Use sha256 instead of sha1 to make activation keys
ERosendo Jun 9, 2023
712360a
Merge branch 'main' into feat-handle-user-authentication
ERosendo Jun 20, 2023
2ef14bc
refactor(templates): Removes the visited modifier from anchors
ERosendo Jun 20, 2023
9bfbe0f
feat(templates): Use saffron yellow for unvisited link
ERosendo Jun 21, 2023
f66f1a7
refactor(user): Add an early exit to the register view
ERosendo Jun 21, 2023
bebd562
refactor(template): Tweak the text in the successful registration page
ERosendo Jun 21, 2023
1aba9f9
docs(users): Add docstring to the superuser_creation signal
ERosendo Jun 21, 2023
906a1d1
docs(users): Tweak the docstring for the CustomPasswordResetForm class
ERosendo Jun 21, 2023
71ac918
refactor(user): Remove bootstrap classes from the email input widget
ERosendo Jun 21, 2023
1520b20
feat(users): Use a Signed URL instead of CL approach to confirm email.
ERosendo Jun 21, 2023
24b1aa8
build(deps): Add django-hcaptcha as a dependency
ERosendo Jun 21, 2023
bb41ed1
feat(settings): Add module to store hcaptcha settings
ERosendo Jun 21, 2023
d1b7985
feat(settings): Tweak CSP directives
ERosendo Jun 21, 2023
3ddc597
feat(user): Add hcaptcha to /register/
ERosendo Jun 21, 2023
248221a
feat(core): Add a helper to throttle requests based on paths
ERosendo Jun 21, 2023
28b6dd7
feat(users): Add the new ratelimit decorator to the views
ERosendo Jun 21, 2023
e40b400
feat(user): Tweak the URL patterns
ERosendo Jun 21, 2023
26b6bbc
fix(settings): Import settings from the hcaptcha module
ERosendo Jun 24, 2023
586355b
refactor(users): Removes remains of a comment
ERosendo Jun 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
45 changes: 41 additions & 4 deletions bc/assets/templates/includes/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,47 @@
<div class="w-28 text-sm font-medium mr-1">
{% include './action-button.html' with link="https://free.law/donate/" text="Donate" size='sm' color='saffron' %}
</div>
<div class="w-20 text-sm font-medium sm:text-md {% if user.is_authenticated %}hidden{% endif %}">
{% url 'sign-in' as sign_in %}
{% include './action-button.html' with link=sign_in text="Sign In" size='sm' %}
</div>
{% if user.is_authenticated %}
mlissner marked this conversation as resolved.
Show resolved Hide resolved
<div class="w-28 text-sm font-medium sm:text-md">
<button id="profileButton" data-dropdown-toggle="profile-dropdown" class="flex justify-around items-center w-full text-center whitespace-nowrap no-underline border border-transparent rounded-md shadow-sm text-bcb-black bg-white hover:bg-gray-100 p-2" aria-label="profile button">
<div class="w-4 h-4">
{% include './inlines/profile.svg' %}
</div>
Profile
<div class="w-5 h-5">
{% include './inlines/caret.svg' %}
</div>
</button>
</div>
<div class="absolute top-16 translate-x-[106px] z-50">
<div id="profile-dropdown" class="z-10 bg-white hidden divide-y divide-gray-100 rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 overflow-hidden max-w-xs">
<div class="relative grid px-6 py-6 gap-5 text-gray-700 dark:text-gray-400" aria-labelledby="dropdown-profile-menu">
<a href="{% url 'big_cases_about' %}" class="flex items-start -m-3 p-3 hover:bg-gray-100 text-gray-900" aria-label="Account">
<div class="h-4 w-4 relative flex-shrink-0 ">
{% include './inlines/profile.svg' %}
</div>
<div class="ml-2">
<p class="text-sm font-medium">Account</p>
</div>
</a>

<a href="{% url 'logout' %}" class="flex items-start -m-3 p-3 hover:bg-gray-100 text-gray-900">
<div class="h-4 w-4 relative flex-shrink-0 ">
{% include './inlines/logout.svg' %}
</div>
<div class="ml-2">
<p class="text-sm font-medium">Sign Out</p>
</div>
</a>
</div>
</div>
</div>
{% else %}
<div class="w-20 text-sm font-medium sm:text-md">
{% url 'sign-in' as sign_in %}
{% include './action-button.html' with link=sign_in text="Sign In" size='sm' %}
</div>
{% endif %}
</div>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions bc/assets/templates/includes/inlines/logout.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions bc/assets/templates/includes/inlines/profile.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions bc/assets/templates/includes/regular-anchor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<a href={{href}} class="underline text-yellow-600 hover:text-yellow-800" aria-label="{{text}}">{{text}}</a>
18 changes: 18 additions & 0 deletions bc/core/utils/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
)
59 changes: 59 additions & 0 deletions bc/core/utils/urls.py
mlissner marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions bc/settings/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"bc.web",
# other apps
"django_rq",
"hcaptcha",
"tailwind",
"django_htmx",
]
Expand Down Expand Up @@ -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/")
Expand Down
3 changes: 3 additions & 0 deletions bc/settings/project/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@bots.law>"
13 changes: 11 additions & 2 deletions bc/settings/project/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
12 changes: 12 additions & 0 deletions bc/settings/third_party/hcaptcha.py
ERosendo marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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="")
4 changes: 4 additions & 0 deletions bc/users/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
114 changes: 114 additions & 0 deletions bc/users/forms.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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",
),
]
12 changes: 6 additions & 6 deletions bc/users/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
Expand Down
Loading
Loading