-
Notifications
You must be signed in to change notification settings - Fork 102
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
base: dev
Are you sure you want to change the base?
Changes from all commits
63b2362
c734080
7ddab18
77b57f5
4be7e0d
6b89357
97a1762
ee1b6e3
5d0f1f6
b3e6e3e
81c5147
8c17287
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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"), | ||
}, | ||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" %} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do this:
(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