Skip to content

Commit

Permalink
Merge pull request #258 from freelawproject/feat-handle-user-authenti…
Browse files Browse the repository at this point in the history
…cation

feat(user): Adds logic to handle authentication.
  • Loading branch information
mlissner committed Jun 25, 2023
2 parents a3d1541 + 586355b commit e7bcb84
Show file tree
Hide file tree
Showing 34 changed files with 1,227 additions and 19 deletions.
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 %}
<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
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
1 change: 1 addition & 0 deletions bc/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
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
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",
),
]
Loading

0 comments on commit e7bcb84

Please sign in to comment.