Skip to content

feat: reCAPTCHA implementation #Closes Implement reCAPTCHA on the Ion login page #1765

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

Open
wants to merge 12 commits into
base: dev
Choose a base branch
from
Open
66 changes: 62 additions & 4 deletions intranet/apps/auth/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import random
import re
import requests
import time
from datetime import timedelta
from typing import Container, Tuple
@@ -37,7 +38,18 @@
logger = logging.getLogger(__name__)
auth_logger = logging.getLogger("intranet_auth")

RECAPTCHA_CHECKBOX_SITE_KEY = "6LdfuB4rAAAAAE1GH-_UHRUs7sdJgubF3zs6A3G9"
RECAPTCHA_CHECKBOX_SECRET_KEY = "6LdfuB4rAAAAAPmSnTQnVuo7k55hcrp_rXQh46QU"
RECAPTCHA_INVISIBLE_SITE_KEY = "6LfRLSMrAAAAACyTFuw-9PCbz5QL4gkPBqAEXGd2"
RECAPTCHA_INVISIBLE_SECRET_KEY = "6LfRLSMrAAAAAN4doo03GxKx5Mfyd_u_PZi9GARX"
def user_ip(request):
if "HTTP_X_REAL_IP" in request.META:
ip = request.META["HTTP_X_REAL_IP"]
else:
ip = request.META.get("REMOTE_ADDR", "")

if isinstance(ip, set):
ip = ip[0]
def log_auth(request, success):
if "HTTP_X_REAL_IP" in request.META:
ip = request.META["HTTP_X_REAL_IP"]
@@ -135,6 +147,8 @@ def index_view(request, auth_form=None, force_login=False, added_context=None, h

sports_events, school_events = get_week_sports_school_events()

show_checkbox_captcha = is_suspected_bot(request)

data = {
"auth_form": auth_form,
"request": request,
@@ -148,14 +162,17 @@ def index_view(request, auth_form=None, force_login=False, added_context=None, h
"school_events": school_events,
"should_not_index_page": has_next_page,
"show_tjstar": settings.TJSTAR_BANNER_START_DATE <= timezone.now().date() <= settings.TJSTAR_DATE,
}
"show_checkbox_captcha": show_checkbox_captcha,
"recaptcha_checkbox_site_key": RECAPTCHA_CHECKBOX_SITE_KEY,
"recaptcha_invisible_site_key": RECAPTCHA_INVISIBLE_SITE_KEY,
}
schedule = schedule_context(request)
data.update(schedule)
if added_context is not None:
data.update(added_context)
return render(request, "auth/login.html", data)


def is_suspected_bot(request):
return request.session.get("failed_login_attempts", 0) >= 3
class LoginView(View):
"""Log in and redirect a user."""

@@ -173,12 +190,50 @@ def post(self, request):
return index_view(request, added_context={"auth_message": "Your username format is incorrect."})

form = AuthenticateForm(data=request.POST)
recaptcha_response = request.POST.get("g-recaptcha-response")
captcha_was_required = is_suspected_bot(request)
if not recaptcha_response:
logger.warning(f"Login attempt for {username} missing reCAPTCHA token. Checkbox expected: {captcha_was_required}.")
return index_view(
request,
auth_form=form,
added_context={"auth_message": "reCAPTCHA verification failed. Please try again or ensure JavaScript is enabled."},
)


secret_key_to_use = RECAPTCHA_CHECKBOX_SECRET_KEY if captcha_was_required else RECAPTCHA_INVISIBLE_SECRET_KEY

try:
r = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={
"secret": secret_key_to_use,
"response": recaptcha_response,
"remoteip": request.META.get("REMOTE_ADDR"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do this:

if "HTTP_X_REAL_IP" in request.META:
            ip = request.META["HTTP_X_REAL_IP"]
else:
            ip = (request.META.get("REMOTE_ADDR", ""),)

if isinstance(ip, set):
            ip = ip[0]            

(from: https://github.com/tjcsl/ion/blob/dev/intranet/middleware/access_log.py)

if you want you could abstract this to a helper function and replace all instances in Ion accessing the IP with that helper function

this is needed because Ion is behind a reverse proxy

},
timeout=10
)
r.raise_for_status()
result = r.json()

if not result.get("success"):
logger.warning(f"reCAPTCHA verification failed for {username}. Result: {result.get('error-codes')}")
return index_view(
request,
auth_form=form,
added_context={"auth_message": "reCAPTCHA verification failed. Please try again."},
)
except requests.exceptions.RequestException as e:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it fails to connect to reCAPTCHA, let's let the login proceed without it, just in case something weird is going on we don't want to lock out everyone. It should send an email to [email protected] though saying reCAPTCHA can't be reached, and also possibly send a warning error after logging in saying that recaptcha couldn't be reached.

logger.error(f"Could not verify reCAPTCHA for {username} due to network error: {e}")
return index_view(
request,
auth_form=form,
added_context={"auth_message": "Could not verify your request. Please try again later."},
)
if request.session.test_cookie_worked():
request.session.delete_test_cookie()
else:
logger.warning("No cookie support detected! This could cause problems.")

if form.is_valid():
reset_user, _ = get_user_model().objects.get_or_create(username="RESET_PASSWORD", user_type="service", id=999999)
if form.get_user() == reset_user:
@@ -189,6 +244,8 @@ def post(self, request):

log_auth(request, "success{}".format(" - first login" if not request.user.first_login else ""))

request.session["failed_login_attempts"] = 0

default_next_page = "index"

if request.user.is_student and settings.ENABLE_PRE_EIGHTH_CLOSE_SIGNUP_REDIRECT:
@@ -247,6 +304,7 @@ def post(self, request):

return response
else:
request.session["failed_login_attempts"] = request.session.get("failed_login_attempts", 0) + 1
log_auth(request, "failed")
logger.info("Login failed as %s", request.POST.get("username", "unknown"))
return index_view(request, auth_form=form)
25 changes: 23 additions & 2 deletions intranet/templates/auth/login.html
Original file line number Diff line number Diff line change
@@ -32,6 +32,17 @@

{% block js %}
{{ block.super }}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if JS is disabled? does it block the user from logging in? I think that would be the best way to go

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh right, it does because the backend checks for g-recaptcha thing right

function onRecaptchaSubmitInvisible(token) {
var form = document.forms['auth_form'];
if (form) {
form.submit();
} else {
console.error("Could not find form 'auth_form' to submit.");
}
}
</script>
<script src="{% static 'js/schedule.js' %}"></script>
<script src="{% static 'js/login.js' %}"></script>
<script src="{% static 'js/vendor/spin.min.js' %}"></script>
@@ -103,13 +114,23 @@ <h1>TJ Intranet</h1>
<i class="fas fa-question-circle"></i>
<span class="tooltiptext">{{ field.help_text }}</span>
</div>
{% comment %} <span class="help-text" style="font-size: 10px; color: grey;">{{ field.help_text }}</span> {% endcomment %}
{% endif %}
<br>
{% endif %}
{% endfor %}
<div id="username-warning"></div>
<input type="submit" value="Login">
{% if show_recaptcha %}
<div class="g-recaptcha" data-sitekey="{{ recaptcha_checkbox_site_key }}"></div>
<input type="submit" value="Login">
{% else %}
<button
class="g-recaptcha"
data-sitekey="{{ recaptcha_invisible_site_key }}"
data-callback="onRecaptchaSubmitInvisible"
data-size="invisible"
type="submit"
>Login</button>
{% endif %}
<div class='spinner-container'></div>
{% for field in auth_form %}
{% if field.name == "trust_device" %}