Repository: shapiromatron/hawc (language: python)

Issue:
add sentry (optionally)
Add the ability to optionally add [sentry](https://sentry.io/) error monitoring.  This setting is opt-in by environment variables, and if unspecified, is not enabled.


Discussion / hints:

Fix patch:
diff --git a/docs/docs/deployment.md b/docs/docs/deployment.md
index 12250841ff..24f41a3846 100644
--- a/docs/docs/deployment.md
+++ b/docs/docs/deployment.md
@@ -96,3 +96,21 @@ Fields include:
 - `ALLOW_RIS_IMPORTS`: If true, RIS imports are available when working on assessments. If false, the button is not there, but it's still possible for users to import via RIS if the can find the correct URL. Defaults to true.
 - `ANONYMOUS_ACCOUNT_CREATION`: If true, anonymous users can create accounts. If false, only staff can create new accounts via the admin. Defaults to true.
 - `THIS_IS_AN_EXAMPLE`: Does nothing; used to test configuration.
+
+### Application monitoring
+
+Application performance and monitoring can be optionally enabled using [Sentry](https://sentry.io/). To enable, add these two environment variables:
+
+- `HAWC_SENTRY_DSN` - site key, used for sentry data ingestion
+- `HAWC_SENTRY_SETTINGS`: JSON string of settings to pass to [client](https://docs.sentry.io/platforms/python/guides/django/configuration/options/), for example: `{"traces_sample_rate": 0.1, "send_default_pii": false}`
+
+By default, sentry integration is disabled.
+
+### Human verification
+
+To prevent bots from attempting to login to the server, you can optionally enable [Turnstyle](https://www.cloudflare.com/products/turnstile/). To enable, set these two additional environment variables:
+
+- `TURNSTYLE_SITE`: site key, this is used by the client to interact (public)
+- `TURNSTYLE_KEY`: secret key, used by the server to verify the response (private)
+
+By default. human verification is disabled.
diff --git a/hawc/apps/common/auth/__init__.py b/hawc/apps/common/auth/__init__.py
new file mode 100644
index 0000000000..5740380582
--- /dev/null
+++ b/hawc/apps/common/auth/__init__.py
@@ -0,0 +1,5 @@
+from .token import TokenBackend
+
+__all__ = [
+    "TokenBackend",
+]
diff --git a/hawc/apps/common/auth.py b/hawc/apps/common/auth/token.py
similarity index 100%
rename from hawc/apps/common/auth.py
rename to hawc/apps/common/auth/token.py
diff --git a/hawc/apps/common/auth/turnstyle.py b/hawc/apps/common/auth/turnstyle.py
new file mode 100644
index 0000000000..eb1e76a6df
--- /dev/null
+++ b/hawc/apps/common/auth/turnstyle.py
@@ -0,0 +1,35 @@
+from typing import Optional
+
+import pydantic
+import requests
+from django.conf import settings
+
+
+class SiteVerifyRequest(pydantic.BaseModel):
+    secret: str
+    response: str
+    remoteip: Optional[str]
+
+
+class SiteVerifyResponse(pydantic.BaseModel):
+    success: bool
+    challenge_ts: Optional[str]
+    hostname: Optional[str]
+    error_codes: list[str] = pydantic.Field(alias="error-codes", default_factory=list)
+    action: Optional[str]
+    cdata: Optional[str]
+
+
+def validate(turnstile_response: str, user_ip: Optional[str] = None) -> SiteVerifyResponse:
+    url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
+    model = SiteVerifyRequest(
+        secret=settings.TURNSTYLE_KEY, response=turnstile_response, remoteip=user_ip
+    )
+    resp = requests.post(url, data=model.dict())
+    if resp.status_code != 200:
+        model = SiteVerifyResponse(success=False, hostname=None)
+        model.error_codes.extend(
+            [f"Failure status code: {resp.status_code}", f"Failure details: {resp.text}"]
+        )
+        return model
+    return SiteVerifyResponse(**resp.json())
diff --git a/hawc/apps/myuser/forms.py b/hawc/apps/myuser/forms.py
index 9598bfdabc..64f8798496 100644
--- a/hawc/apps/myuser/forms.py
+++ b/hawc/apps/myuser/forms.py
@@ -13,6 +13,7 @@
 
 from ...constants import AuthProvider
 from ..assessment.autocomplete import AssessmentAutocomplete
+from ..common.auth.turnstyle import validate
 from ..common.autocomplete import AutocompleteMultipleChoiceField
 from ..common.forms import BaseFormHelper
 from ..common.helper import url_query
@@ -271,10 +272,16 @@ class HAWCAuthenticationForm(AuthenticationForm):
     def __init__(self, *args, **kwargs):
         self.next_url = kwargs.pop("next_url")
         super().__init__(*args, **kwargs)
+        self.enable_turnstyle = len(settings.TURNSTYLE_SITE) > 0
 
     def get_extra_text(self) -> str:
+        challenge = (
+            f'<div class="cf-turnstile mb-3" data-sitekey="{settings.TURNSTYLE_SITE}"></div>'
+            if self.enable_turnstyle
+            else ""
+        )
         text = f"""<a role="button" class="btn btn-light" href="{reverse("home")}">Cancel</a>
-        <br/><br/>
+        <br/><br/>{challenge}
         <a href="{reverse("user:reset_password")}">Forgot your password?</a>"""
         if AuthProvider.external in settings.AUTH_PROVIDERS:
             url = reverse("user:external_auth")
@@ -315,6 +322,13 @@ def clean(self):
                 )
             elif not self.user_cache.is_active:
                 raise forms.ValidationError(self.error_messages["inactive"])
+
+        if settings.TURNSTYLE_SITE:
+            token = self.data.get("cf-turnstile-response", "")
+            response = validate(token)
+            if not response.success:
+                raise forms.ValidationError("Failed bot challenge - are you human?")
+
         return self.cleaned_data
 
 
diff --git a/hawc/main/settings/base.py b/hawc/main/settings/base.py
index 410b0fcead..fa8270cafb 100644
--- a/hawc/main/settings/base.py
+++ b/hawc/main/settings/base.py
@@ -195,6 +195,8 @@
 SESSION_COOKIE_NAME = os.getenv("HAWC_SESSION_COOKIE_NAME", "sessionid")
 SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
 SESSION_CACHE_ALIAS = "default"
+TURNSTYLE_SITE = os.environ.get("TURNSTYLE_SITE", "")
+TURNSTYLE_KEY = os.environ.get("TURNSTYLE_KEY", "")
 INCLUDE_ADMIN = bool(os.environ.get("HAWC_INCLUDE_ADMIN", "True") == "True")
 
 # Server URL settings
diff --git a/hawc/templates/registration/login.html b/hawc/templates/registration/login.html
index 4257769746..eeac6553e0 100644
--- a/hawc/templates/registration/login.html
+++ b/hawc/templates/registration/login.html
@@ -8,6 +8,9 @@
 {% endblock content %}
 
 {% block extrajs %}
+  {% if form.enable_turnstyle %}
+  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
+  {% endif %}
   <script type="text/javascript">
     document.getElementById('id_username').focus();
   </script>


Test patch:
diff --git a/tests/data/cassettes/apps.myuser.test_views/TestLoginView.test_turnstyle.yaml b/tests/data/cassettes/apps.myuser.test_views/TestLoginView.test_turnstyle.yaml
new file mode 100644
index 0000000000..e2970ad254
--- /dev/null
+++ b/tests/data/cassettes/apps.myuser.test_views/TestLoginView.test_turnstyle.yaml
@@ -0,0 +1,46 @@
+interactions:
+- request:
+    body: secret=secret&response=
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '23'
+      Content-Type:
+      - application/x-www-form-urlencoded
+      User-Agent:
+      - python-requests/2.27.1
+    method: POST
+    uri: https://challenges.cloudflare.com/turnstile/v0/siteverify
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAAAxzKywnAIBQEwF72rA3YSvAguhEhfngbTyG9BzLneaCdMyWEM12iA82m+TwLhXCg
+        N6mN6ttY+/ZGrTlERIdOKdV/xfcDAAD//wMA9OTj90gAAAA=
+    headers:
+      CF-RAY:
+      - 79fe1c52d9a1b06f-ATL
+      Connection:
+      - keep-alive
+      Content-Encoding:
+      - gzip
+      Content-Type:
+      - application/json
+      Date:
+      - Mon, 27 Feb 2023 04:17:24 GMT
+      Server:
+      - cloudflare
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+      alt-svc:
+      - h3=":443"; ma=86400, h3-29=":443"; ma=86400
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/tests/hawc/apps/myuser/test_views.py b/tests/hawc/apps/myuser/test_views.py
index fb567f81cb..ff4c7d2ec2 100644
--- a/tests/hawc/apps/myuser/test_views.py
+++ b/tests/hawc/apps/myuser/test_views.py
@@ -115,6 +115,31 @@ def test_create_account_link(self):
 
         settings.HAWC_FEATURES.ANONYMOUS_ACCOUNT_CREATION = True
 
+    @pytest.mark.vcr
+    def test_turnstyle(self, settings):
+        url = reverse("user:login")
+        success = {"username": "pm@hawcproject.org", "password": "pw"}
+
+        # no turnstyle by default
+        c = Client()
+        resp = c.get(url)
+        assert b"challenges.cloudflare.com/turnstile" not in resp.content
+        assert b'data-sitekey="https://test-me.org"' not in resp.content
+        resp = c.post(url, data=success)
+        assert resp.status_code == 302
+        assert resp.url == "/portal/"
+
+        # turnstyle if enabled
+        c = Client()
+        settings.TURNSTYLE_SITE = "https://test-me.org"
+        settings.TURNSTYLE_KEY = "secret"
+        resp = c.get(url)
+        assert b"challenges.cloudflare.com/turnstile" in resp.content
+        assert b'data-sitekey="https://test-me.org"' in resp.content
+        resp = c.post(url, data=success)
+        assert resp.status_code == 200
+        assert resp.context["form"].errors == {"__all__": ["Failed bot challenge - are you human?"]}
+
 
 class ExternalAuthSetup(ExternalAuth):
     # mock user metdata handler for test case