diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 37da0fcf9..9063a1f42 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,15 +1,12 @@ FROM benefits_client:latest # install devcontainer requirements -COPY .devcontainer/requirements.txt .devcontainer/requirements.txt -RUN pip install -r .devcontainer/requirements.txt +RUN pip install -e .[dev,test] +# docs requirements are in a separate file for the GitHub Action COPY docs/requirements.txt docs/requirements.txt RUN pip install -r docs/requirements.txt -COPY tests/pytest/requirements.txt tests/pytest/requirements.txt -RUN pip install -r tests/pytest/requirements.txt - # install pre-commit environments in throwaway Git repository # https://stackoverflow.com/a/68758943 COPY .pre-commit-config.yaml . diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 555a41401..8f192c4a0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,6 +20,7 @@ }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ + "bungcip.better-toml", "batisteo.vscode-django", "bpruitt-goddard.mermaid-markdown-syntax-highlighting", "eamodio.gitlens", diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt deleted file mode 100644 index adb07efea..000000000 --- a/.devcontainer/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -black -djlint -flake8 -pre-commit diff --git a/.dockerignore b/.dockerignore index 5ea37e022..c4198b1d4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ .flake8 .*ignore *.db +*.egg-info diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 39db2af14..96c61c651 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,7 @@ version: 2 updates: - package-ecosystem: "pip" - directory: "/appcontainer" # main requirements.txt + directory: "/" # pyproject.toml schedule: interval: "daily" commit-message: diff --git a/.github/workflows/labeler-deploy-dev.yml b/.github/workflows/labeler-deploy-dev.yml index f2e34da5f..b2441c058 100644 --- a/.github/workflows/labeler-deploy-dev.yml +++ b/.github/workflows/labeler-deploy-dev.yml @@ -5,11 +5,11 @@ on: branches: [dev] types: [opened] paths: - - '.github/workflows/deploy-*.yml' - - 'benefits/**' - - 'bin/**' + - ".github/workflows/deploy-*.yml" + - "benefits/**" + - "bin/**" - Dockerfile - - requirements.txt + - pyproject.toml jobs: label-deployment-dev: diff --git a/.github/workflows/tests-pytest.yml b/.github/workflows/tests-pytest.yml index 120883142..84b4a9f58 100644 --- a/.github/workflows/tests-pytest.yml +++ b/.github/workflows/tests-pytest.yml @@ -18,10 +18,10 @@ jobs: with: python-version-file: .github/workflows/.python-version cache: pip - cache-dependency-path: "**/requirements.txt" + cache-dependency-path: "**/pyproject.toml" - name: Install Python dependencies - run: pip install -r appcontainer/requirements.txt -r tests/pytest/requirements.txt + run: pip install -e .[test] - name: Run setup run: ./bin/init.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index acd12a620..cdd00d11b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: args: ["--maxkb=1500"] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black types: @@ -61,6 +61,6 @@ repos: types_or: [javascript, css] - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.19.16 + rev: v1.23.0 hooks: - id: djlint-django diff --git a/appcontainer/Dockerfile b/appcontainer/Dockerfile index 8165f5938..4aa3cf9df 100644 --- a/appcontainer/Dockerfile +++ b/appcontainer/Dockerfile @@ -1,15 +1,19 @@ FROM ghcr.io/cal-itp/docker-python-web:main -# install python dependencies -COPY appcontainer/requirements.txt requirements.txt -RUN pip install -r requirements.txt +# upgrade pip +RUN python -m pip install --upgrade pip -# copy Django utility script -COPY manage.py manage.py +# overwrite default nginx.conf +COPY appcontainer/nginx.conf /etc/nginx/nginx.conf +COPY appcontainer/proxy.conf /home/calitp/run/proxy.conf # copy source files -COPY bin/ bin/ -COPY benefits/ benefits/ +COPY manage.py manage.py +COPY bin bin +COPY benefits benefits +COPY pyproject.toml pyproject.toml + +RUN pip install -e . # ensure $USER can compile messages in the locale directories USER root diff --git a/appcontainer/nginx.conf b/appcontainer/nginx.conf new file mode 100644 index 000000000..d85fb2b7f --- /dev/null +++ b/appcontainer/nginx.conf @@ -0,0 +1,92 @@ +worker_processes auto; +error_log stderr warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + accept_mutex on; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + gzip on; + keepalive_timeout 5; + + log_format main '[$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /dev/stdout main; + + upstream app_server { + # fail_timeout=0 means we always retry an upstream even if it failed + # to return a good HTTP response + server unix:/home/calitp/run/gunicorn.sock fail_timeout=0; + } + + # maps $binary_ip_address to $limit variable if request is of type POST + map $request_method $limit { + default ""; + POST $binary_remote_addr; + } + + # define a zone with 10mb memory, rate limit to 12 requests/min (~= 1 request/5 seconds) on applied locations + # $limit will eval to $binary_remote_addr for POST requests using the above map + # requests with an empty key value (e.g. GET) are not affected + # http://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req_zone + limit_req_zone $limit zone=rate_limit:10m rate=12r/m; + + server { + listen 8000; + + keepalive_timeout 65; + + # 404 known scraping path targets + # case-insensitive regex matches the given path fragment anywhere in the request path + location ~* /(\.?git|api|app|assets|ats|bootstrap|bower|cgi|content|credentials|docker|doc|env|example|swagger|web) { + access_log off; + log_not_found off; + return 404; + } + + # 404 known scraping file targets + # case-insensitive regex matches the given file extension anywhere in the request path + location ~* /.*\.(asp|axd|cgi|com|env|json|php|xml|ya?ml) { + access_log off; + log_not_found off; + return 404; + } + + location /favicon.ico { + access_log off; + log_not_found off; + expires 1y; + add_header Cache-Control public; + } + + # path for static files + location /static/ { + alias /home/calitp/app/static/; + expires 1y; + add_header Cache-Control public; + } + + location / { + # checks for static file, if not found proxy to app + try_files $uri @proxy_to_app; + } + + # apply rate limit to these paths + # case-insensitive regex matches path + location ~* ^/(eligibility/confirm)$ { + limit_req zone=rate_limit; + include /home/calitp/run/proxy.conf; + } + + # app path + location @proxy_to_app { + include /home/calitp/run/proxy.conf; + } + } +} diff --git a/appcontainer/proxy.conf b/appcontainer/proxy.conf new file mode 100644 index 000000000..a4e310387 --- /dev/null +++ b/appcontainer/proxy.conf @@ -0,0 +1,8 @@ +# the core app proxy directives +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header Host $http_host; +# we don't want nginx trying to do something clever with +# redirects, we set the Host: header above already. +proxy_redirect off; +proxy_pass http://app_server; diff --git a/appcontainer/requirements.txt b/appcontainer/requirements.txt deleted file mode 100644 index d2261cfdf..000000000 --- a/appcontainer/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -Authlib==1.2.0 -Django==4.1.7 -django-csp==3.7 -eligibility-api==2023.01.1 -requests==2.28.2 -sentry-sdk==1.18.0 -six==1.16.0 diff --git a/benefits/__init__.py b/benefits/__init__.py index 544748407..024e2c4d0 100644 --- a/benefits/__init__.py +++ b/benefits/__init__.py @@ -1,3 +1,3 @@ -__version__ = "2023.04.1" +__version__ = "2023.04.2" VERSION = __version__ diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py index 93ec20cf2..4df16d6fa 100644 --- a/benefits/core/middleware.py +++ b/benefits/core/middleware.py @@ -2,12 +2,10 @@ The core application: middleware definitions for request/response cycle. """ import logging -import time from django.conf import settings -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse from django.shortcuts import redirect -from django.template import loader from django.template.response import TemplateResponse from django.utils.decorators import decorator_from_middleware from django.utils.deprecation import MiddlewareMixin @@ -40,43 +38,6 @@ def process_request(self, request): return user_error(request) -class RateLimit(MiddlewareMixin): - """Middleware checks settings and session to ensure rate limit is respected.""" - - def process_request(self, request): - if not settings.RATE_LIMIT_ENABLED: - logger.debug("Rate Limiting is not configured") - return None - - if request.method in settings.RATE_LIMIT_METHODS: - session.increment_rate_limit_counter(request) - else: - # bail early if the request method doesn't match - return None - - counter = session.rate_limit_counter(request) - reset_time = session.rate_limit_time(request) - now = int(time.time()) - - if counter > settings.RATE_LIMIT: - if reset_time > now: - logger.warning("Rate limit exceeded") - home = viewmodels.Button.home(request) - page = viewmodels.ErrorPage.server_error( - title="Rate limit error", - headline="Rate limit error", - paragraphs=["You have reached the rate limit. Please try again."], - button=home, - ) - t = loader.get_template("400.html") - return HttpResponseBadRequest(t.render(page.context_dict())) - else: - # enough time has passed, reset the rate limit - session.reset_rate_limit(request) - - return None - - class EligibleSessionRequired(MiddlewareMixin): """Middleware raises an exception for sessions lacking confirmed eligibility.""" diff --git a/benefits/core/migrations/0002_data.py b/benefits/core/migrations/0002_data.py index d7439b95e..ef6eaea20 100644 --- a/benefits/core/migrations/0002_data.py +++ b/benefits/core/migrations/0002_data.py @@ -89,19 +89,34 @@ def load_data(app, *args, **kwargs): -----END CERTIFICATE----- """ - payment_processor_client_cert = PemData.objects.create( - text=os.environ.get("PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), - label="Payment processor client certificate", + mst_payment_processor_client_cert = PemData.objects.create( + text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), + label="MST payment processor client certificate", ) - payment_processor_client_cert_private_key = PemData.objects.create( - text=os.environ.get("PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), - label="Payment processor client certificate private key", + mst_payment_processor_client_cert_private_key = PemData.objects.create( + text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), + label="MST payment processor client certificate private key", ) - payment_processor_client_cert_root_ca = PemData.objects.create( - text=os.environ.get("PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), - label="Payment processor client certificate root CA", + mst_payment_processor_client_cert_root_ca = PemData.objects.create( + text=os.environ.get("MST_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), + label="MST payment processor client certificate root CA", + ) + + sacrt_payment_processor_client_cert = PemData.objects.create( + text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT", dummy_cert_text), + label="SacRT payment processor client certificate", + ) + + sacrt_payment_processor_client_cert_private_key = PemData.objects.create( + text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_PRIVATE_KEY", client_private_key.text), + label="SacRT payment processor client certificate private key", + ) + + sacrt_payment_processor_client_cert_root_ca = PemData.objects.create( + text=os.environ.get("SACRT_PAYMENT_PROCESSOR_CLIENT_CERT_ROOT_CA", dummy_cert_text), + label="SacRT payment processor client certificate root CA", ) AuthProvider = app.get_model("core", "AuthProvider") @@ -204,18 +219,35 @@ def load_data(app, *args, **kwargs): PaymentProcessor = app.get_model("core", "PaymentProcessor") - payment_processor = PaymentProcessor.objects.create( - name=os.environ.get("PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), - api_base_url=os.environ.get("PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), - api_access_token_endpoint=os.environ.get("PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), - api_access_token_request_key=os.environ.get("PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), - api_access_token_request_val=os.environ.get("PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), - card_tokenize_url=os.environ.get("PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), - card_tokenize_func=os.environ.get("PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), - card_tokenize_env=os.environ.get("PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), - client_cert=payment_processor_client_cert, - client_cert_private_key=payment_processor_client_cert_private_key, - client_cert_root_ca=payment_processor_client_cert_root_ca, + mst_payment_processor = PaymentProcessor.objects.create( + name=os.environ.get("MST_PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), + api_base_url=os.environ.get("MST_PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), + api_access_token_endpoint=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), + api_access_token_request_key=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), + api_access_token_request_val=os.environ.get("MST_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), + card_tokenize_url=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), + card_tokenize_func=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), + card_tokenize_env=os.environ.get("MST_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), + client_cert=mst_payment_processor_client_cert, + client_cert_private_key=mst_payment_processor_client_cert_private_key, + client_cert_root_ca=mst_payment_processor_client_cert_root_ca, + customer_endpoint="customer", + customers_endpoint="customers", + group_endpoint="group", + ) + + sacrt_payment_processor = PaymentProcessor.objects.create( + name=os.environ.get("SACRT_PAYMENT_PROCESSOR_NAME", "Test Payment Processor"), + api_base_url=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_BASE_URL", "http://server:8000"), + api_access_token_endpoint=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_ENDPOINT", "access-token"), + api_access_token_request_key=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_KEY", "request_access"), + api_access_token_request_val=os.environ.get("SACRT_PAYMENT_PROCESSOR_API_ACCESS_TOKEN_REQUEST_VAL", "REQUEST_ACCESS"), + card_tokenize_url=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_URL", "http://server:8000/static/tokenize.js"), + card_tokenize_func=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_FUNC", "tokenize"), + card_tokenize_env=os.environ.get("SACRT_PAYMENT_PROCESSOR_CARD_TOKENIZE_ENV", "test"), + client_cert=sacrt_payment_processor_client_cert, + client_cert_private_key=sacrt_payment_processor_client_cert_private_key, + client_cert_root_ca=sacrt_payment_processor_client_cert_root_ca, customer_endpoint="customer", customers_endpoint="customers", group_endpoint="group", @@ -240,7 +272,7 @@ def load_data(app, *args, **kwargs): private_key=client_private_key, public_key=client_public_key, jws_signing_alg=os.environ.get("MST_AGENCY_JWS_SIGNING_ALG", "RS256"), - payment_processor=payment_processor, + payment_processor=mst_payment_processor, eligibility_index_intro=_("eligibility.pages.index.p[0].mst"), ) mst_agency.eligibility_types.set([mst_senior_type, mst_courtesy_card_type]) @@ -258,7 +290,7 @@ def load_data(app, *args, **kwargs): private_key=client_private_key, public_key=client_public_key, jws_signing_alg=os.environ.get("SACRT_AGENCY_JWS_SIGNING_ALG", "RS256"), - payment_processor=payment_processor, + payment_processor=sacrt_payment_processor, eligibility_index_intro=_("eligibility.pages.index.p[0].sacrt"), ) sacrt_agency.eligibility_types.set([sacrt_senior_type]) diff --git a/benefits/core/session.py b/benefits/core/session.py index 7cb20185d..c26968cee 100644 --- a/benefits/core/session.py +++ b/benefits/core/session.py @@ -6,7 +6,6 @@ import time import uuid -from django.conf import settings from django.urls import reverse from . import models @@ -22,8 +21,6 @@ _ENROLLMENT_TOKEN = "enrollment_token" _ENROLLMENT_TOKEN_EXP = "enrollment_token_exp" _LANG = "lang" -_LIMITCOUNTER = "limitcounter" -_LIMITUNTIL = "limituntil" _OAUTH_CLAIM = "oauth_claim" _OAUTH_TOKEN = "oauth_token" _ORIGIN = "origin" @@ -54,7 +51,6 @@ def context_dict(request): logger.debug("Get session context dict") return { _AGENCY: agency(request).slug if active_agency(request) else None, - _LIMITCOUNTER: rate_limit_counter(request), _DEBUG: debug(request), _DID: did(request), _ELIGIBILITY: eligibility(request), @@ -64,7 +60,6 @@ def context_dict(request): _OAUTH_TOKEN: oauth_token(request), _OAUTH_CLAIM: oauth_claim(request), _ORIGIN: origin(request), - _LIMITUNTIL: rate_limit_time(request), _START: start(request), _UID: uid(request), _VERIFIER: verifier(request), @@ -174,33 +169,6 @@ def origin(request): return request.session.get(_ORIGIN) -def rate_limit_counter(request): - """Get this session's rate limit counter.""" - logger.debug("Get rate limit counter") - return request.session.get(_LIMITCOUNTER) - - -def increment_rate_limit_counter(request): - """Adds 1 to this session's rate limit counter.""" - logger.debug("Increment rate limit counter") - c = rate_limit_counter(request) - request.session[_LIMITCOUNTER] = int(c) + 1 - - -def reset_rate_limit(request): - """Reset this session's rate limit counter and time.""" - logger.debug("Reset rate limit") - request.session[_LIMITCOUNTER] = 0 - # get the current time in Unix seconds, then add RATE_LIMIT_PERIOD seconds - request.session[_LIMITUNTIL] = int(time.time()) + settings.RATE_LIMIT_PERIOD - - -def rate_limit_time(request): - """Get this session's rate limit time, a Unix timestamp after which the session's rate limt resets.""" - logger.debug("Get rate limit time") - return request.session.get(_LIMITUNTIL) - - def reset(request): """Reset the session for the request.""" logger.debug("Reset session") @@ -219,7 +187,6 @@ def reset(request): u = str(uuid.uuid4()) request.session[_UID] = u request.session[_DID] = str(uuid.UUID(hashlib.sha512(bytes(u, "utf8")).hexdigest()[:32])) - reset_rate_limit(request) def start(request): diff --git a/benefits/core/templates/core/base.html b/benefits/core/templates/core/base.html index 06c712d3e..7567bf0ed 100644 --- a/benefits/core/templates/core/base.html +++ b/benefits/core/templates/core/base.html @@ -27,7 +27,10 @@ CA State Template v6.0.7 does not include jQuery See https://github.com/Office-of-Digital-Services/California-State-Web-Template/releases/tag/v6.0.7 {% endcomment %} - + {% include "core/includes/analytics.html" with api_key=analytics.api_key uid=analytics.uid did=analytics.did %} @@ -125,9 +128,12 @@

{{ page.headline }}

But we aren't using CA State Template Javascript, so include Bootstrap directly {% endcomment %} - + - - {% if request.recaptcha %}{% endif %} + {% if request.recaptcha %} + + {% endif %} diff --git a/benefits/core/templates/core/includes/analytics.html b/benefits/core/templates/core/includes/analytics.html index bb1ef84a6..04302a603 100644 --- a/benefits/core/templates/core/includes/analytics.html +++ b/benefits/core/templates/core/includes/analytics.html @@ -1,9 +1,10 @@ -