Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions isic/login/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django_recaptcha.fields import ReCaptchaField
from django_recaptcha.widgets import ReCaptchaV2Checkbox
from resonant_utils.allauth import FullNameSignupForm


class CaptchaSignupForm(FullNameSignupForm):
captcha = ReCaptchaField(widget=ReCaptchaV2Checkbox)

field_order = [*FullNameSignupForm.field_order, "captcha"]
33 changes: 33 additions & 0 deletions isic/login/templates/allauth/elements/fields.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% comment %}
Overrides django-auth-style, which builds every input from widget metadata instead of
calling the widget. reCAPTCHA widgets declare an input type of "hidden", so they would
otherwise render as an empty hidden input, with no challenge and no script.
{% endcomment %}
{% load allauth %}
{% for bound_field in attrs.form %}
{% if bound_field.field.widget.recaptcha_response_name %}
{% comment %}
The reCAPTCHA iframe has a fixed width. django-auth-style ships a prebuilt stylesheet
that never scanned this template, so centering has to be an inline style.
{% endcomment %}
<fieldset class="fieldset" style="justify-items: center">
{{ bound_field }}
{% if bound_field.errors %}
<ul class="errorlist text-error">
{% for error in bound_field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</fieldset>
{% else %}
{% element field unlabeled=attrs.unlabeled name=bound_field.name type=bound_field.field.widget.input_type required=bound_field.field.required value=bound_field.value id=bound_field.auto_id errors=bound_field.errors placeholder=bound_field.field.widget.attrs.placeholder tabindex=bound_field.field.widget.attrs.tabindex|default:None autocomplete=bound_field.field.widget.attrs.autocomplete style=bound_field.field.widget.attrs.style choices=bound_field.field.choices %}
{% slot label %}
{{ bound_field.label }}
{% endslot %}
{% slot help_text %}
{{ bound_field.field.help_text }}
{% endslot %}
{% endelement %}
{% endif %}
{% endfor %}
45 changes: 45 additions & 0 deletions isic/login/tests/test_signup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from django.contrib.auth.models import User
from django.urls import reverse
from django_recaptcha.client import RecaptchaResponse
from django_recaptcha.constants import TEST_PUBLIC_KEY
from faker import Faker
import pytest

fake = Faker()


@pytest.mark.django_db
def test_signup_requires_captcha(client, settings, mocker):
password = fake.password(length=20)
email = fake.email()
form_data = {
"first_name": fake.first_name(),
"last_name": fake.last_name(),
"email": email,
"password1": password,
"password2": password,
"g-recaptcha-response": fake.pystr(),
}
signup_url = reverse("account_signup")

challenge = client.get(signup_url)

assert challenge.status_code == 200
assert 'class="g-recaptcha' in challenge.content.decode()
assert TEST_PUBLIC_KEY in challenge.content.decode()

submit = mocker.patch("django_recaptcha.fields.client.submit")
submit.return_value = RecaptchaResponse(is_valid=False, error_codes=["invalid-input-response"])

rejected = client.post(signup_url, form_data)

assert rejected.status_code == 200
assert "Error verifying reCAPTCHA" in rejected.content.decode()
assert not User.objects.filter(email=email).exists()

submit.return_value = RecaptchaResponse(is_valid=True)

accepted = client.post(signup_url, form_data)

assert accepted.status_code == 302
assert User.objects.filter(email=email).exists()
8 changes: 2 additions & 6 deletions isic/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"django.contrib.staticfiles",
"django_extensions",
"django_filters",
"django_recaptcha",
"markdownify",
# Install "ninja" to force Swagger to be served locally, so it can be overridden
"ninja",
Expand Down Expand Up @@ -139,12 +140,7 @@
# Make Django and Allauth redirects consistent, but both may be changed.
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
ACCOUNT_SIGNUP_FORM_CLASS = "resonant_utils.allauth.FullNameSignupForm"

# Signups are closed while an automated signup campaign is abusing the confirmation
# emails. An amount of 0 rejects every POST to the signup view; this dict is merged
# into allauth's defaults, so the other rate limits are unaffected.
ACCOUNT_RATE_LIMITS = {"signup": "0/m/ip"}
ACCOUNT_SIGNUP_FORM_CLASS = "isic.login.forms.CaptchaSignupForm"

SOCIALACCOUNT_PROVIDERS: dict[str, dict[str, Any]] = {}

Expand Down
3 changes: 3 additions & 0 deletions isic/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@

SECRET_KEY = "insecure-secret"

# Django reCAPTCHA falls back to Google's test keys, which accept any response
SILENCED_SYSTEM_CHECKS = ["django_recaptcha.recaptcha_test_key_error"]

# This is typically only overridden when running from Docker.
INTERNAL_IPS = InternalIPS(env.list("DJANGO_INTERNAL_IPS", cast=str, default=["127.0.0.1"]))
CORS_ALLOWED_ORIGIN_REGEXES = env.list(
Expand Down
3 changes: 3 additions & 0 deletions isic/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
# (specifically when DEBUG is True), "localhost" and "127.0.0.1" will be added.
ALLOWED_HOSTS: list[str] = env.list("DJANGO_ALLOWED_HOSTS", cast=str)

RECAPTCHA_PUBLIC_KEY: str = env.str("DJANGO_RECAPTCHA_PUBLIC_KEY")
RECAPTCHA_PRIVATE_KEY: str = env.str("DJANGO_RECAPTCHA_PRIVATE_KEY")

STORAGES.update(
{
"default": {
Expand Down
3 changes: 3 additions & 0 deletions isic/settings/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

SECRET_KEY = "insecure-secret"

# Django reCAPTCHA falls back to Google's test keys, which accept any response
SILENCED_SYSTEM_CHECKS = ["django_recaptcha.recaptcha_test_key_error"]

# Use a fast, insecure hasher to speed up tests
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"django-markdownify==0.9.7",
"django-ninja==1.4.5",
"django-oauth-toolkit==1.7.1",
"django-recaptcha==4.1.0",
"django-redis==7.0.0",
"django-resonant-settings[allauth,celery]==0.51.1",
"django-resonant-utils[allauth,s3_storage]==0.19.0",
Expand Down
14 changes: 14 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.