diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml deleted file mode 100644 index 81946d59d..000000000 --- a/.github/workflows/deploy-dev.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy (dev) - -on: - workflow_dispatch: - push: - branches: - - dev - paths: - - '.github/workflows/deploy-*.yml' - - 'benefits/**' - - 'bin/**' - - Dockerfile - - gunicorn.conf.py - - nginx.conf - - requirements.txt - -defaults: - run: - shell: bash - -jobs: - deploy: - runs-on: ubuntu-latest - environment: dev - concurrency: dev - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Docker Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v3 - with: - builder: ${{ steps.buildx.outputs.name }} - cache-from: type=gha,scope=cal-itp - cache-to: type=gha,scope=cal-itp,mode=max - context: . - push: true - tags: | - ghcr.io/${{ github.repository }}:dev - ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml deleted file mode 100644 index ce5358580..000000000 --- a/.github/workflows/deploy-test.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Deploy (test) - -on: - workflow_dispatch: - push: - branches: - - test - -defaults: - run: - shell: bash - -jobs: - deploy: - runs-on: ubuntu-latest - environment: test - concurrency: test - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Docker Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v2 - - - name: Build, tag, and push image to GitHub Container Registry - uses: docker/build-push-action@v3 - with: - builder: ${{ steps.buildx.outputs.name }} - cache-from: type=gha,scope=cal-itp - cache-to: type=gha,scope=cal-itp,mode=max - context: . - push: true - tags: | - ghcr.io/${{ github.repository }}:test - ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy.yml similarity index 73% rename from .github/workflows/deploy-prod.yml rename to .github/workflows/deploy.yml index f8d17edab..b23a79dfc 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy.yml @@ -1,9 +1,11 @@ -name: Deploy (prod) +name: Deploy on: workflow_dispatch: push: branches: + - dev + - test - prod defaults: @@ -13,13 +15,16 @@ defaults: jobs: deploy: runs-on: ubuntu-latest - environment: prod - concurrency: prod + environment: ${{ github.ref_name }} + concurrency: ${{ github.ref_name }} steps: - name: Checkout uses: actions/checkout@v3 + - name: Write commit SHA to file + run: echo "${{ github.sha }}" >> benefits/static/sha.txt + - name: Docker Login to GitHub Container Registry uses: docker/login-action@v2 with: @@ -35,10 +40,11 @@ jobs: uses: docker/build-push-action@v3 with: builder: ${{ steps.buildx.outputs.name }} + build-args: GIT-SHA=${{ github.sha }} cache-from: type=gha,scope=cal-itp cache-to: type=gha,scope=cal-itp,mode=max context: . push: true tags: | - ghcr.io/${{ github.repository }}:prod + ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/labeler-back-end.yml b/.github/workflows/labeler-back-end.yml new file mode 100644 index 000000000..66d4658be --- /dev/null +++ b/.github/workflows/labeler-back-end.yml @@ -0,0 +1,16 @@ +name: Label back-end + +on: + pull_request: + types: [opened] + paths: + - 'benefits/**/*.py' + +jobs: + label-actions: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "back-end" diff --git a/.github/workflows/labeler-docker.yml b/.github/workflows/labeler-docker.yml new file mode 100644 index 000000000..1a837bcde --- /dev/null +++ b/.github/workflows/labeler-docker.yml @@ -0,0 +1,18 @@ +name: Label docker + +on: + pull_request: + types: [opened] + paths: + - '.devcontainer/**' + - '.dockerignore' + - 'Dockerfile' + +jobs: + label-actions: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "docker" diff --git a/.github/workflows/labeler-front-end.yml b/.github/workflows/labeler-front-end.yml new file mode 100644 index 000000000..b60164d99 --- /dev/null +++ b/.github/workflows/labeler-front-end.yml @@ -0,0 +1,17 @@ +name: Label front-end + +on: + pull_request: + types: [opened] + paths: + - 'benefits/**/templates/**' + - 'benefits/static/**' + +jobs: + label-actions: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "front-end" diff --git a/.github/workflows/labeler-i18n.yml b/.github/workflows/labeler-i18n.yml new file mode 100644 index 000000000..99586a290 --- /dev/null +++ b/.github/workflows/labeler-i18n.yml @@ -0,0 +1,16 @@ +name: Label i18n + +on: + pull_request: + types: [opened] + paths: + - 'benefits/locale/**' + +jobs: + label-actions: + runs-on: ubuntu-latest + steps: + - name: add-label + uses: andymckay/labeler@master + with: + add-labels: "i18n" diff --git a/.github/workflows/tests-ui.yml b/.github/workflows/tests-ui.yml index f892635f1..99c27ba1a 100644 --- a/.github/workflows/tests-ui.yml +++ b/.github/workflows/tests-ui.yml @@ -40,34 +40,6 @@ jobs: -v ${{ github.workspace }}/fixtures:/home/calitp/app/fixtures \ benefits_client:${{ github.sha }} - - name: Start server - run: | - docker run \ - --detach \ - -p 5000:5000 \ - ghcr.io/cal-itp/eligibility-server:main - - - name: Run UI automation tests - uses: cypress-io/github-action@v2 - env: - CYPRESS_baseUrl: http://localhost:8000 - with: - command: npm run cypress:ui - working-directory: tests/cypress - wait-on: http://localhost:8000/healthcheck - - - uses: actions/upload-artifact@v3 - if: failure() - with: - name: cypress-screenshots - path: tests/cypress/screenshots - - - uses: actions/upload-artifact@v3 - if: failure() - with: - name: cypress-videos - path: tests/cypress/videos - - name: Run Lighthouse tests for a11y uses: treosh/lighthouse-ci-action@9.3.0 with: diff --git a/.gitignore b/.gitignore index c2e99a47b..307ea6946 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ fixtures/*.json !fixtures/??_*.json static/ !benefits/static +benefits/static/sha.txt __pycache__/ .coverage .DS_Store diff --git a/README.md b/README.md index 6466086cf..ec0dd1f00 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Benefits -Transit benefits enrollment, minus the paperwork. +Cal-ITP Benefits is an application that enables automated eligibility verification and enrollment for transit benefits onto customers’ existing contactless bank (credit/debit) cards. View the technical documentation online: diff --git a/benefits/core/__init__.py b/benefits/core/__init__.py index 4f8b9243d..e69de29bb 100644 --- a/benefits/core/__init__.py +++ b/benefits/core/__init__.py @@ -1,10 +0,0 @@ -""" -The core application: Houses base templates and reusable models and components. -""" -from django.apps import AppConfig - - -class CoreAppConfig(AppConfig): - name = "benefits.core" - label = "core" - verbose_name = "Core" diff --git a/benefits/core/apps.py b/benefits/core/apps.py new file mode 100644 index 000000000..4f8b9243d --- /dev/null +++ b/benefits/core/apps.py @@ -0,0 +1,10 @@ +""" +The core application: Houses base templates and reusable models and components. +""" +from django.apps import AppConfig + + +class CoreAppConfig(AppConfig): + name = "benefits.core" + label = "core" + verbose_name = "Core" diff --git a/benefits/core/context_processors.py b/benefits/core/context_processors.py index 4e0140bf6..d9120a861 100644 --- a/benefits/core/context_processors.py +++ b/benefits/core/context_processors.py @@ -18,12 +18,12 @@ def authentication(request): if verifier: data = { - "required": verifier.requires_authentication, + "required": verifier.is_auth_required, "logged_in": session.logged_in(request), "sign_out_route": reverse("oauth:logout"), } - if verifier.requires_authentication: + if verifier.is_auth_required: auth_provider = verifier.auth_provider data["sign_in_button_label"] = auth_provider.sign_in_button_label data["sign_out_button_label"] = auth_provider.sign_out_button_label diff --git a/benefits/core/middleware.py b/benefits/core/middleware.py index 94fb659fa..47daf7709 100644 --- a/benefits/core/middleware.py +++ b/benefits/core/middleware.py @@ -142,8 +142,23 @@ class LoginRequired(MiddlewareMixin): def process_view(self, request, view_func, view_args, view_kwargs): # only require login if verifier requires it verifier = session.verifier(request) - if not verifier or not verifier.requires_authentication or session.logged_in(request): + if not verifier or not verifier.is_auth_required or session.logged_in(request): # pass through return None return redirect("oauth:login") + + +# https://github.com/census-instrumentation/opencensus-python/issues/766 +class LogErrorToAzure(MiddlewareMixin): + def __init__(self, get_response): + self.get_response = get_response + # wait to do this here to be sure the handler is initialized + self.azure_logger = logging.getLogger("azure") + + def process_exception(self, request, exception): + # https://stackoverflow.com/a/45532289 + msg = getattr(exception, "message", repr(exception)) + self.azure_logger.exception(msg, exc_info=exception) + + return None diff --git a/benefits/core/migrations/0001_initial.py b/benefits/core/migrations/0001_initial.py index ac83c0da4..f01238a99 100644 --- a/benefits/core/migrations/0001_initial.py +++ b/benefits/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-06-13 23:20 +# Generated by Django 3.2.13 on 2022-06-17 23:20 from django.db import migrations, models import django.db.models.deletion @@ -17,6 +17,11 @@ class Migration(migrations.Migration): ("id", models.AutoField(primary_key=True, serialize=False)), ("sign_in_button_label", models.TextField()), ("sign_out_button_label", models.TextField()), + ("client_name", models.TextField()), + ("client_id", models.TextField()), + ("authority", models.TextField()), + ("scope", models.TextField(null=True)), + ("claim", models.TextField(null=True)), ], ), migrations.CreateModel( @@ -33,44 +38,27 @@ class Migration(migrations.Migration): fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), ("name", models.TextField()), - ("api_url", models.TextField()), - ("api_auth_header", models.TextField()), - ("api_auth_key", models.TextField()), - ( - "jwe_cek_enc", - models.TextField(help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode"), - ), - ("jwe_encryption_alg", models.TextField(help_text="The JWE-compatible encryption algorithm")), - ("jws_signing_alg", models.TextField(help_text="The JWS-compatible signing algorithm")), - ("auth_scope", models.TextField(null=True)), - ("auth_claim", models.TextField(null=True)), + ("api_url", models.TextField(null=True)), + ("api_auth_header", models.TextField(null=True)), + ("api_auth_key", models.TextField(null=True)), + ("jwe_cek_enc", models.TextField(null=True)), + ("jwe_encryption_alg", models.TextField(null=True)), + ("jws_signing_alg", models.TextField(null=True)), ("selection_label", models.TextField()), ("selection_label_description", models.TextField(null=True)), ("start_content_title", models.TextField()), ("start_item_name", models.TextField()), ("start_item_description", models.TextField()), ("start_blurb", models.TextField()), - ("form_title", models.TextField()), - ("form_content_title", models.TextField()), - ("form_blurb", models.TextField()), - ("form_sub_label", models.TextField()), - ("form_sub_placeholder", models.TextField()), - ( - "form_sub_pattern", - models.TextField( - help_text="A regular expression used to validate the 'sub' API field before sending to this verifier", - null=True, - ), - ), - ("form_name_label", models.TextField()), - ("form_name_placeholder", models.TextField()), - ( - "form_name_max_length", - models.PositiveSmallIntegerField( - help_text="The maximum length accepted for the 'name' API field before sending to this verifier", - null=True, - ), - ), + ("form_title", models.TextField(null=True)), + ("form_content_title", models.TextField(null=True)), + ("form_blurb", models.TextField(null=True)), + ("form_sub_label", models.TextField(null=True)), + ("form_sub_placeholder", models.TextField(null=True)), + ("form_sub_pattern", models.TextField(null=True)), + ("form_name_label", models.TextField(null=True)), + ("form_name_placeholder", models.TextField(null=True)), + ("form_name_max_length", models.PositiveSmallIntegerField(null=True)), ("unverified_title", models.TextField()), ("unverified_content_title", models.TextField()), ("unverified_blurb", models.TextField()), @@ -78,7 +66,10 @@ class Migration(migrations.Migration): "auth_provider", models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to="core.authprovider"), ), - ("eligibility_types", models.ManyToManyField(to="core.EligibilityType")), + ( + "eligibility_type", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="core.eligibilitytype"), + ), ], ), migrations.CreateModel( @@ -102,8 +93,8 @@ class Migration(migrations.Migration): name="PemData", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), - ("text", models.TextField(help_text="The data in utf-8 encoded PEM text format.")), - ("label", models.TextField(help_text="Human description of the PEM data.")), + ("text", models.TextField()), + ("label", models.TextField()), ], ), migrations.CreateModel( @@ -118,7 +109,7 @@ class Migration(migrations.Migration): ("info_url", models.URLField()), ("phone", models.TextField()), ("active", models.BooleanField(default=False)), - ("jws_signing_alg", models.TextField(help_text="The JWS-compatible signing algorithm.")), + ("jws_signing_alg", models.TextField()), ("eligibility_types", models.ManyToManyField(to="core.EligibilityType")), ("eligibility_verifiers", models.ManyToManyField(to="core.EligibilityVerifier")), ( @@ -127,53 +118,30 @@ class Migration(migrations.Migration): ), ( "private_key", - models.ForeignKey( - help_text="The Agency's private key, used to sign tokens created on behalf of this Agency.", - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="core.pemdata", - ), + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata"), ), ], ), migrations.AddField( model_name="paymentprocessor", name="client_cert", - field=models.ForeignKey( - help_text="The certificate used for client certificate authentication to the API.", - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="core.pemdata", - ), + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata"), ), migrations.AddField( model_name="paymentprocessor", name="client_cert_private_key", - field=models.ForeignKey( - help_text="The private key, used to sign the certificate.", - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="core.pemdata", - ), + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata"), ), migrations.AddField( model_name="paymentprocessor", name="client_cert_root_ca", - field=models.ForeignKey( - help_text="The root CA bundle, used to verify the server.", - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="core.pemdata", - ), + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata"), ), migrations.AddField( model_name="eligibilityverifier", name="public_key", field=models.ForeignKey( - help_text="The Verifier's public key, used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier.", # noqa - on_delete=django.db.models.deletion.PROTECT, - related_name="+", - to="core.pemdata", + null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata" ), ), ] diff --git a/benefits/core/models.py b/benefits/core/models.py index a397dbf2c..688ae8a3c 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -14,8 +14,10 @@ class PemData(models.Model): """API Certificate or Key in PEM format.""" id = models.AutoField(primary_key=True) - text = models.TextField(help_text="The data in utf-8 encoded PEM text format.") - label = models.TextField(help_text="Human description of the PEM data.") + # The data in utf-8 encoded PEM text format + text = models.TextField() + # Human description of the PEM data + label = models.TextField() def __str__(self): return self.label @@ -27,6 +29,11 @@ class AuthProvider(models.Model): id = models.AutoField(primary_key=True) sign_in_button_label = models.TextField() sign_out_button_label = models.TextField() + client_name = models.TextField() + client_id = models.TextField() + authority = models.TextField() + scope = models.TextField(null=True) + claim = models.TextField(null=True) class EligibilityType(models.Model): @@ -56,39 +63,41 @@ def get_many(ids): class EligibilityVerifier(models.Model): """An entity that verifies eligibility.""" - # fmt: off id = models.AutoField(primary_key=True) name = models.TextField() - api_url = models.TextField() - api_auth_header = models.TextField() - api_auth_key = models.TextField() - eligibility_types = models.ManyToManyField(EligibilityType) - public_key = models.ForeignKey(PemData, help_text="The Verifier's public key, used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier.", related_name="+", on_delete=models.PROTECT) # noqa: 503 - jwe_cek_enc = models.TextField(help_text="The JWE-compatible Content Encryption Key (CEK) key-length and mode") - jwe_encryption_alg = models.TextField(help_text="The JWE-compatible encryption algorithm") - jws_signing_alg = models.TextField(help_text="The JWS-compatible signing algorithm") + api_url = models.TextField(null=True) + api_auth_header = models.TextField(null=True) + api_auth_key = models.TextField(null=True) + eligibility_type = models.ForeignKey(EligibilityType, on_delete=models.PROTECT) + # public key is used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier + public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True) + # The JWE-compatible Content Encryption Key (CEK) key-length and mode + jwe_cek_enc = models.TextField(null=True) + # The JWE-compatible encryption algorithm + jwe_encryption_alg = models.TextField(null=True) + # The JWS-compatible signing algorithm + jws_signing_alg = models.TextField(null=True) auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True) - auth_scope = models.TextField(null=True) - auth_claim = models.TextField(null=True) selection_label = models.TextField() selection_label_description = models.TextField(null=True) start_content_title = models.TextField() start_item_name = models.TextField() start_item_description = models.TextField() start_blurb = models.TextField() - form_title = models.TextField() - form_content_title = models.TextField() - form_blurb = models.TextField() - form_sub_label = models.TextField() - form_sub_placeholder = models.TextField() - form_sub_pattern = models.TextField(null=True, help_text="A regular expression used to validate the 'sub' API field before sending to this verifier") # noqa: 503 - form_name_label = models.TextField() - form_name_placeholder = models.TextField() - form_name_max_length = models.PositiveSmallIntegerField(null=True, help_text="The maximum length accepted for the 'name' API field before sending to this verifier") # noqa: 503 + form_title = models.TextField(null=True) + form_content_title = models.TextField(null=True) + form_blurb = models.TextField(null=True) + form_sub_label = models.TextField(null=True) + form_sub_placeholder = models.TextField(null=True) + # A regular expression used to validate the 'sub' API field before sending to this verifier + form_sub_pattern = models.TextField(null=True) + form_name_label = models.TextField(null=True) + form_name_placeholder = models.TextField(null=True) + # The maximum length accepted for the 'name' API field before sending to this verifier + form_name_max_length = models.PositiveSmallIntegerField(null=True) unverified_title = models.TextField() unverified_content_title = models.TextField() unverified_blurb = models.TextField() - # fmt: on def __str__(self): return self.name @@ -99,9 +108,15 @@ def public_key_data(self): return self.public_key.text @property - def requires_authentication(self): + def is_auth_required(self): + """True if this Verifier requires authentication. False otherwise.""" return self.auth_provider is not None + @property + def uses_auth_verification(self): + """True if this Verifier verifies via the auth provider. False otherwise.""" + return self.is_auth_required and self.auth_provider.scope and self.auth_provider.claim + @staticmethod def by_id(id): """Get an EligibilityVerifier instance by its ID.""" @@ -112,7 +127,6 @@ def by_id(id): class PaymentProcessor(models.Model): """An entity that processes payments for transit agencies.""" - # fmt: off id = models.AutoField(primary_key=True) name = models.TextField() api_base_url = models.TextField() @@ -122,13 +136,15 @@ class PaymentProcessor(models.Model): card_tokenize_url = models.TextField() card_tokenize_func = models.TextField() card_tokenize_env = models.TextField() - client_cert = models.ForeignKey(PemData, help_text="The certificate used for client certificate authentication to the API.", related_name="+", on_delete=models.PROTECT) # noqa: 503 - client_cert_private_key = models.ForeignKey(PemData, help_text="The private key, used to sign the certificate.", related_name="+", on_delete=models.PROTECT) # noqa: 503 - client_cert_root_ca = models.ForeignKey(PemData, help_text="The root CA bundle, used to verify the server.", related_name="+", on_delete=models.PROTECT) # noqa: 503 + # The certificate used for client certificate authentication to the API + client_cert = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) + # The private key, used to sign the certificate + client_cert_private_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) + # The root CA bundle, used to verify the server. + client_cert_root_ca = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) customer_endpoint = models.TextField() customers_endpoint = models.TextField() group_endpoint = models.TextField() - # fmt: on def __str__(self): return self.name @@ -137,7 +153,6 @@ def __str__(self): class TransitAgency(models.Model): """An agency offering transit service.""" - # fmt: off id = models.AutoField(primary_key=True) slug = models.TextField() short_name = models.TextField() @@ -150,9 +165,10 @@ class TransitAgency(models.Model): eligibility_types = models.ManyToManyField(EligibilityType) eligibility_verifiers = models.ManyToManyField(EligibilityVerifier) payment_processor = models.ForeignKey(PaymentProcessor, on_delete=models.PROTECT) - private_key = models.ForeignKey(PemData, help_text="The Agency's private key, used to sign tokens created on behalf of this Agency.", related_name="+", on_delete=models.PROTECT) # noqa: 503 - jws_signing_alg = models.TextField(help_text="The JWS-compatible signing algorithm.") - # fmt: on + # The Agency's private key, used to sign tokens created on behalf of this Agency + private_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT) + # The JWS-compatible signing algorithm + jws_signing_alg = models.TextField() def __str__(self): return self.long_name @@ -173,7 +189,7 @@ def types_to_verify(self, eligibility_verifier): """List of eligibility types to verify for this agency.""" # compute set intersection of agency and verifier type ids agency_types = set(self.eligibility_types.values_list("id", flat=True)) - verifier_types = set(eligibility_verifier.eligibility_types.values_list("id", flat=True)) + verifier_types = {eligibility_verifier.eligibility_type.id} supported_types = list(agency_types & verifier_types) return EligibilityType.get_many(supported_types) diff --git a/benefits/core/session.py b/benefits/core/session.py index ff8a0a30c..7cb20185d 100644 --- a/benefits/core/session.py +++ b/benefits/core/session.py @@ -24,6 +24,7 @@ _LANG = "lang" _LIMITCOUNTER = "limitcounter" _LIMITUNTIL = "limituntil" +_OAUTH_CLAIM = "oauth_claim" _OAUTH_TOKEN = "oauth_token" _ORIGIN = "origin" _START = "start" @@ -61,6 +62,7 @@ def context_dict(request): _ENROLLMENT_TOKEN_EXP: enrollment_token_expiry(request), _LANG: language(request), _OAUTH_TOKEN: oauth_token(request), + _OAUTH_CLAIM: oauth_claim(request), _ORIGIN: origin(request), _LIMITUNTIL: rate_limit_time(request), _START: start(request), @@ -160,6 +162,12 @@ def oauth_token(request): return request.session.get(_OAUTH_TOKEN) +def oauth_claim(request): + """Get the oauth claim from the request's session, or None""" + logger.debug("Get session oauth claim") + return request.session.get(_OAUTH_CLAIM) + + def origin(request): """Get the origin for the request's session, or None.""" logger.debug("Get session origin") @@ -202,6 +210,7 @@ def reset(request): request.session[_ENROLLMENT_TOKEN] = None request.session[_ENROLLMENT_TOKEN_EXP] = None request.session[_OAUTH_TOKEN] = None + request.session[_OAUTH_CLAIM] = None request.session[_VERIFIER] = None if _UID not in request.session or not request.session[_UID]: @@ -262,6 +271,7 @@ def update( enrollment_token=None, enrollment_token_exp=None, oauth_token=None, + oauth_claim=None, origin=None, verifier=None, ): @@ -291,6 +301,9 @@ def update( if oauth_token is not None: logger.debug(f"Update session {_OAUTH_TOKEN}") request.session[_OAUTH_TOKEN] = oauth_token + if oauth_claim is not None: + logger.debug(f"Update session {_OAUTH_CLAIM}") + request.session[_OAUTH_CLAIM] = oauth_claim if origin is not None: logger.debug(f"Update session {_ORIGIN}") request.session[_ORIGIN] = origin diff --git a/benefits/core/templates/core/help.html b/benefits/core/templates/core/help.html index 139fc225d..b46505805 100644 --- a/benefits/core/templates/core/help.html +++ b/benefits/core/templates/core/help.html @@ -30,10 +30,13 @@

{% translate "core.pages.help.payment_options" %}

{% translate "core.pages.help.login_gov" %}

{% translate "core.pages.help.login_gov.p[0]" %}

{% translate "core.pages.help.login_gov.p[1]" %}

-

- {% blocktranslate with website="https://login.gov"%}core.pages.help.login_gov.p[2][0]{{ website }}{% endblocktranslate %} - {% blocktranslate with website="https://login.gov/help/"%}core.pages.help.login_gov.p[2][1]{{ website }}{% endblocktranslate %} -

+

{% translate "core.pages.help.login_gov.p[2]" %}

+ +

{% translate "core.pages.help.login_gov_verify" %}

+

{% translate "core.pages.help.login_gov_verify.p[0]" %}

+

{% translate "core.pages.help.login_gov_verify.p[1]" %}

+

{% translate "core.pages.help.login_gov_verify.p[2]" %}

+

{% translate "core.pages.help.login_gov_verify.p[3]" %}

{% translate "core.pages.help.littlepay" %}

{% translate "core.pages.help.littlepay.p[0]" %}

diff --git a/benefits/core/templates/core/includes/debug.html b/benefits/core/templates/core/includes/debug.html index a21aa97a7..82d839e5a 100644 --- a/benefits/core/templates/core/includes/debug.html +++ b/benefits/core/templates/core/includes/debug.html @@ -3,7 +3,7 @@ {% block content %} DEBUG MODE {% if debug %} - + { {% for key, value in debug.items %}"{{ key }}": "{{ value }}"{% if not forloop.last %}, {% endif %}{% endfor %} } {% endif %} diff --git a/benefits/eligibility/__init__.py b/benefits/eligibility/__init__.py index e7d07247b..e69de29bb 100644 --- a/benefits/eligibility/__init__.py +++ b/benefits/eligibility/__init__.py @@ -1,10 +0,0 @@ -""" -The eligibility application: Verifies eligibility for benefits. -""" -from django.apps import AppConfig - - -class EligibilityAppConfig(AppConfig): - name = "benefits.eligibility" - label = "eligibility" - verbose_name = "Eligibility Verification" diff --git a/benefits/eligibility/apps.py b/benefits/eligibility/apps.py new file mode 100644 index 000000000..e7d07247b --- /dev/null +++ b/benefits/eligibility/apps.py @@ -0,0 +1,10 @@ +""" +The eligibility application: Verifies eligibility for benefits. +""" +from django.apps import AppConfig + + +class EligibilityAppConfig(AppConfig): + name = "benefits.eligibility" + label = "eligibility" + verbose_name = "Eligibility Verification" diff --git a/benefits/eligibility/urls.py b/benefits/eligibility/urls.py index d50b00ba4..3ba04b87a 100644 --- a/benefits/eligibility/urls.py +++ b/benefits/eligibility/urls.py @@ -12,4 +12,5 @@ path("", views.index, name="index"), path("start", views.start, name="start"), path("confirm", views.confirm, name="confirm"), + path("unverified", views.unverified, name="unverified"), ] diff --git a/benefits/eligibility/api.py b/benefits/eligibility/verify.py similarity index 68% rename from benefits/eligibility/api.py rename to benefits/eligibility/verify.py index f5c0f1f09..5d9485fa2 100644 --- a/benefits/eligibility/api.py +++ b/benefits/eligibility/verify.py @@ -1,26 +1,11 @@ -""" -The eligibility application: Eligibility Verification API helpers. -""" - from django.conf import settings from eligibility_api.client import Client -from benefits.core import session - - -def get_verified_types(request, form): - """ - Helper calls the eligibility verification API with user input. - Returns None and updates form with user input error(s). - Returns a list of verified eligibility types, or an empty list when no types were verified. - """ +def eligibility_from_api(verifier, form, agency): sub, name = form.cleaned_data.get("sub"), form.cleaned_data.get("name") - agency = session.agency(request) - verifier = session.verifier(request) - client = Client( verify_url=verifier.api_url, headers={verifier.api_auth_header: verifier.api_auth_key}, @@ -45,3 +30,10 @@ def get_verified_types(request, form): return list(response.eligibility) else: return [] + + +def eligibility_from_oauth(verifier, oauth_claim, agency): + if verifier.uses_auth_verification and verifier.auth_provider.claim == oauth_claim: + return list(map(lambda t: t.name, agency.types_to_verify(verifier))) + else: + return [] diff --git a/benefits/eligibility/views.py b/benefits/eligibility/views.py index bd2075dea..fbaeec523 100644 --- a/benefits/eligibility/views.py +++ b/benefits/eligibility/views.py @@ -1,7 +1,6 @@ """ The eligibility application: view definitions for the eligibility verification flow. """ -from django.conf import settings from django.contrib import messages from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -13,7 +12,7 @@ from benefits.core.middleware import AgencySessionRequired, LoginRequired, RateLimit, VerifierSessionRequired from benefits.core.models import EligibilityVerifier from benefits.core.views import ROUTE_HELP, TEMPLATE_PAGE -from . import analytics, api, forms +from . import analytics, forms, verify ROUTE_INDEX = "eligibility:index" @@ -76,7 +75,6 @@ def start(request): button = viewmodels.Button.primary(text=_("eligibility.buttons.continue"), url=reverse(ROUTE_CONFIRM)) - payment_options_link = f"{reverse(ROUTE_HELP)}#payment-options" media = [ dict( icon=viewmodels.Icon("bankcardcheck", pgettext("image alt text", "core.icons.bankcardcheck")), @@ -86,24 +84,13 @@ def start(request): viewmodels.Button.link( classes="btn-text btn-link", text=_("eligibility.pages.start.bankcard.button[0].link"), - url=payment_options_link, - ), - viewmodels.Button.link( - classes="btn-text btn-link", - text=_("eligibility.pages.start.bankcard.button[1].link"), - url=payment_options_link, + url=f"{reverse(ROUTE_HELP)}#payment-options", ), ], ), ] - if verifier.requires_authentication: - if settings.OAUTH_CLIENT_NAME is None: - raise Exception("EligibilityVerifier requires authentication, but OAUTH_CLIENT_NAME is None") - - oauth_help_link = f"{reverse(ROUTE_HELP)}#login-gov" - oauth_help_more_link = f"{reverse(ROUTE_HELP)}#login-gov-verify-items" - + if verifier.is_auth_required: media.insert( 0, dict( @@ -114,14 +101,7 @@ def start(request): viewmodels.Button.link( classes="btn-text btn-link", text=_("eligibility.pages.start.oauth.link_text"), - url=oauth_help_link, - rel="noopener noreferrer", - ), - viewmodels.Button.link( - classes="btn-text btn-link", - text=_("eligibility.pages.start.oauth.link_text[2]"), - url=oauth_help_more_link, - rel="noopener noreferrer", + url=f"{reverse(ROUTE_HELP)}#login-gov", ), ], bullets=[ @@ -158,6 +138,7 @@ def start(request): ctx = page.context_dict() ctx["title"] = _(verifier.start_content_title) ctx["media"] = media + ctx["info_link"] = f"{reverse(ROUTE_HELP)}#about" return TemplateResponse(request, TEMPLATE_START, ctx) @@ -169,53 +150,63 @@ def start(request): def confirm(request): """View handler for the eligibility verification form.""" - verifier = session.verifier(request) + # GET from an already verified user, no need to verify again + if request.method == "GET" and session.eligible(request): + eligibility = session.eligibility(request) + return verified(request, [eligibility.name]) - page = viewmodels.Page( - title=_(verifier.form_title), - content_title=_(verifier.form_content_title), - paragraphs=[_(verifier.form_blurb)], - form=forms.EligibilityVerificationForm(auto_id=True, label_suffix="", verifier=verifier), - classes="text-lg-center", - ) + verifier = session.verifier(request) - # POST form submission, process form data - if request.method == "POST": + # GET for OAuth verification + if request.method == "GET" and verifier.uses_auth_verification: analytics.started_eligibility(request) - form = forms.EligibilityVerificationForm(data=request.POST, verifier=verifier) + verified_types = verify.eligibility_from_oauth(verifier, session.oauth_claim(request), session.agency(request)) + if verified_types: + return verified(request, verified_types) + else: + return unverified(request) - # form was not valid, allow for correction/resubmission - if not form.is_valid(): - if recaptcha.has_error(form): - messages.error(request, "Recaptcha failed. Please try again.") + # GET/POST for Eligibility API verification + else: + page = viewmodels.Page( + title=_(verifier.form_title), + content_title=_(verifier.form_content_title), + paragraphs=[_(verifier.form_blurb)], + form=forms.EligibilityVerificationForm(auto_id=True, label_suffix="", verifier=verifier), + classes="text-lg-center", + ) - page.forms = [form] - return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) + # POST form submission, process form data + if request.method == "POST": + analytics.started_eligibility(request) - # form is valid, make Eligibility Verification request to get the verified types - verified_types = api.get_verified_types(request, form) + form = forms.EligibilityVerificationForm(data=request.POST, verifier=verifier) + # form was not valid, allow for correction/resubmission + if not form.is_valid(): + if recaptcha.has_error(form): + messages.error(request, "Recaptcha failed. Please try again.") - # form was not valid, allow for correction/resubmission - if verified_types is None: - analytics.returned_error(request, form.errors) - page.forms = [form] - return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) - # no types were verified - elif len(verified_types) == 0: - return unverified(request) - # type(s) were verified - else: - return verified(request, verified_types) + page.forms = [form] + return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) - # GET from an already verified user, no need to verify again - elif session.eligible(request): - eligibility = session.eligibility(request) - return verified(request, [eligibility.name]) + # form is valid, make Eligibility Verification request to get the verified types + verified_types = verify.eligibility_from_api(verifier, form, session.agency(request)) - # GET from an unverified user, present the form - else: - return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) + # form was not valid, allow for correction/resubmission + if verified_types is None: + analytics.returned_error(request, form.errors) + page.forms = [form] + return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) + # no types were verified + elif len(verified_types) == 0: + return unverified(request) + # type(s) were verified + else: + return verified(request, verified_types) + # GET from an unverified user, see if verifier can get verified types and if not, present the form + else: + return TemplateResponse(request, TEMPLATE_CONFIRM, page.context_dict()) @decorator_from_middleware(AgencySessionRequired) @@ -231,7 +222,6 @@ def verified(request, verified_types): @decorator_from_middleware(AgencySessionRequired) -@decorator_from_middleware(LoginRequired) @decorator_from_middleware(VerifierSessionRequired) def unverified(request): """View handler for the unverified eligibility page.""" diff --git a/benefits/enrollment/__init__.py b/benefits/enrollment/__init__.py index 79267ad47..e69de29bb 100644 --- a/benefits/enrollment/__init__.py +++ b/benefits/enrollment/__init__.py @@ -1,10 +0,0 @@ -""" -The enrollment application: Allows user to enroll payment device for benefits. -""" -from django.apps import AppConfig - - -class EnrollmentAppConfig(AppConfig): - name = "benefits.enrollment" - label = "enrollment" - verbose_name = "Benefits Enrollment" diff --git a/benefits/enrollment/apps.py b/benefits/enrollment/apps.py new file mode 100644 index 000000000..79267ad47 --- /dev/null +++ b/benefits/enrollment/apps.py @@ -0,0 +1,10 @@ +""" +The enrollment application: Allows user to enroll payment device for benefits. +""" +from django.apps import AppConfig + + +class EnrollmentAppConfig(AppConfig): + name = "benefits.enrollment" + label = "enrollment" + verbose_name = "Benefits Enrollment" diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index d4cb563f5..65f769588 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -3,7 +3,6 @@ """ import logging -from django.conf import settings from django.http import JsonResponse from django.template.response import TemplateResponse from django.urls import reverse @@ -78,7 +77,7 @@ def index(request): content_title=_("enrollment.pages.index.content_title"), icon=viewmodels.Icon("idcardcheck", pgettext("image alt text", "core.icons.idcardcheck")), paragraphs=[_("enrollment.pages.index.p[0]"), _("enrollment.pages.index.p[1]"), _("enrollment.pages.index.p[2]")], - classes="text-lg-center", + classes="text-lg-center no-image-mobile", forms=[tokenize_retry_form, tokenize_success_form], buttons=[ viewmodels.Button.primary( @@ -121,6 +120,7 @@ def retry(request): if form.is_valid(): agency = session.agency(request) page = viewmodels.Page( + classes="no-image-mobile", title=_("enrollment.pages.retry.title"), icon=viewmodels.Icon("bankcardquestion", pgettext("image alt text", "core.icons.bankcardquestion")), content_title=_("enrollment.pages.retry.title"), @@ -144,20 +144,18 @@ def success(request): verifier = session.verifier(request) icon = viewmodels.Icon("bankcardcheck", pgettext("image alt text", "core.icons.bankcardcheck")) page = viewmodels.Page( + classes="no-image-mobile", title=_("enrollment.pages.success.title"), content_title=_("enrollment.pages.success.content_title"), ) - if verifier.requires_authentication: - if settings.OAUTH_CLIENT_NAME is None: - raise Exception("EligibilityVerifier requires authentication, but OAUTH_CLIENT_NAME is None") - + if verifier.is_auth_required: if session.logged_in(request): page.buttons = [viewmodels.Button.logout()] - page.classes = ["logged-in"] + page.classes = ["no-image-mobile", "logged-in"] page.icon = icon else: - page.classes = ["logged-out"] + page.classes = ["no-image-mobile", "logged-out"] page.content_title = _("enrollment.pages.success.logout.title") page.noimage = True else: diff --git a/benefits/locale/en/LC_MESSAGES/django.po b/benefits/locale/en/LC_MESSAGES/django.po index 82102fe2d..12fa02e6a 100644 --- a/benefits/locale/en/LC_MESSAGES/django.po +++ b/benefits/locale/en/LC_MESSAGES/django.po @@ -25,8 +25,8 @@ msgstr "" msgid "core.pages.help.about.p[1]" msgstr "" -"We partner with the California DMV to confirm age for age-based discounts and " -"local transporation agencies to confirm specific discount programs. Once verified, " +"We partner with Login.gov to confirm age for age-based discounts, and we " +"partner with local transporation agencies to confirm specific discount programs. Once verified, " "discounts are attached to your bank card through our payment partner, Littlepay, so " "that when you pay for your transit ride, you get your discount automatically." @@ -69,12 +69,24 @@ msgstr "Creating an account is simple. You will need an email address and method "authenticating your account—which will use either a landline, mobile phone, or " "backup codes that must be printed or written down." -msgid "core.pages.help.login_gov.p[2][0]%(website)s" +msgid "core.pages.help.login_gov.p[2]" msgstr "To learn more about Login.gov, please visit the " -"Login.gov website " +"Login.gov website or the Login.gov Help Center." -msgid "core.pages.help.login_gov.p[2][1]%(website)s" -msgstr "or the Login.gov Help Center." +msgid "core.pages.help.login_gov_verify" +msgstr "How do I verify my identity on Login.gov?" + +msgid "core.pages.help.login_gov_verify.p[0]" +msgstr "To verify your identity, Login.gov asks that you upload a photograph of your state-issued ID and share your phone number and other personal information, which is then verified against authoritative sources. These requirements are in addition to meeting the requirements for two-factor authentication." + +msgid "core.pages.help.login_gov_verify.p[1]" +msgstr "You cannot verify your identity on Login.gov without a state-issued ID, which can be either a driver’s license or non-driver’s license state-issued ID card." + +msgid "core.pages.help.login_gov_verify.p[2]" +msgstr "If you do not have a phone plan that is in your name, Login.gov can send you the verification code by mail which takes approximately 3-5 days." + +msgid "core.pages.help.login_gov_verify.p[3]" +msgstr "Verifying your identity makes sure the right person has access to the right information. We are using Login.gov’s simple and secure process to keep your sensitive information safe. To learn more about identity verification on Login.gov, please visit their Help Center." msgid "core.pages.help.littlepay" msgstr "What is Littlepay?" @@ -190,7 +202,7 @@ msgstr "Note: Must include a Visa or Mastercard logo." #: benefits/core/templates/core/payment-options.html:15 msgid "core.pages.payment_options.p[3]" msgstr "" -"Don't have access to a bank card? You can request a contactless card from " +"Don’t have access to a bank card? You can request a contactless card from " "one of the companies that offer free contactless prepaid debit cards, such " "as the " @@ -223,7 +235,7 @@ msgstr "Bus icon with flat tire" #: benefits/core/viewmodels.py:177 msgid "core.pages.server_error.content_title" -msgstr "Unfortunately, our system is having a problem right now." +msgstr "Sorry! Service for this site is down." #: benefits/core/viewmodels.py:183 benefits/core/viewmodels.py:184 msgid "core.pages.server_error.title" @@ -231,11 +243,11 @@ msgstr "Service is down" #: benefits/core/viewmodels.py:185 msgid "core.pages.server_error.p[0]" -msgstr "We should be back in operation soon!" +msgstr "We should be back in operation soon." #: benefits/core/viewmodels.py:185 msgid "core.pages.server_error.p[1]" -msgstr "Please check back later." +msgstr "Please refresh the page in a few minutes." #: benefits/core/viewmodels.py:193 msgid "core.pages.not_found.title" @@ -243,11 +255,11 @@ msgstr "Page not found" #: benefits/core/viewmodels.py:194 msgid "core.pages.not_found.content_title" -msgstr "We can’t find that page" +msgstr "Sorry! We can’t find that page." #: benefits/core/viewmodels.py:195 msgid "core.pages.not_found.p[0]" -msgstr "It looks like that page doesn’t exist or it was moved." +msgstr "The page you are looking for might be somewhere else or may not exist anymore." #: benefits/core/viewmodels.py:218 msgid "core.buttons.wait" @@ -269,7 +281,7 @@ msgstr "" msgid "core.pages.agency_index.p[0]%(info_link)s" msgstr "You can tap your credit or debit card when you board, " "and your discount will automatically apply every time you ride." -" Learn more about Cal-ITP Benefits." +" Learn more about Cal-ITP Benefits." msgid "core.pages.agency_index.p[1]" msgstr "You will need your California driver’s license or ID " @@ -366,10 +378,7 @@ msgid "eligibility.pages.start.bankcard.text" msgstr "Your card must be a contactless debit or credit card by Visa or Mastercard." msgid "eligibility.pages.start.bankcard.button[0].link" -msgstr "Don’t have a bank card?" - -msgid "eligibility.pages.start.bankcard.button[1].link" -msgstr "Unsure if you have a contactless card?" +msgstr "Don’t have a bank card or unsure if your card is contactless?" #: benefits/eligibility/views.py:80 msgid "eligibility.buttons.continue" @@ -379,13 +388,10 @@ msgid "eligibility.pages.start.oauth.heading" msgstr "A Login.gov account with identity verification" msgid "eligibility.pages.start.oauth.details" -msgstr "Login.gov is a safe way to sign in to government services. If you do not have an account already, you will be able to create one. You will also need to provide proof of your identity. Some of the items you can use to do that include:" +msgstr "Login.gov is a safe way to sign in to government services. Benefits uses Login.gov to verify your age. If you do not have an account already, you will be able to create one. You will also need to verify your identity, which will require these items:" msgid "eligibility.pages.start.oauth.link_text" -msgstr "Learn more about Login.gov" - -msgid "eligibility.pages.start.oauth.link_text[2]" -msgstr "Don’t have access to these items?" +msgstr "Learn more about Login.gov and providing proof of identity" msgid "eligibility.pages.start.oauth.required_items[0]" msgstr "Your state-issued ID card" @@ -394,7 +400,7 @@ msgid "eligibility.pages.start.oauth.required_items[1]" msgstr "Your Social Security number" msgid "eligibility.pages.start.oauth.required_items[2]" -msgstr "Don’t have access to these items?" +msgstr "A phone number with a phone plan associated with your name" #: benefits/eligibility/views.py:80 msgid "eligibility.buttons.signin" @@ -405,12 +411,17 @@ msgid "eligibility.buttons.signout" msgstr "Sign out of Login.gov" #: benefits/eligibility/views.py:170 -msgid "eligibility.pages.unverified.dmv.title" -msgstr "Age not confirmed" +msgid "eligibility.pages.unverified.oauth.title" +msgstr "Eligibility Error" #: benefits/eligibility/views.py:171 -msgid "eligibility.pages.unverified.dmv.content_title" -msgstr "We can’t confirm your age" +msgid "eligibility.pages.unverified.oauth.content_title" +msgstr "Your eligibility could not be verified." + +msgid "eligibility.pages.unverified.oauth.p[0]" +msgstr "" +"You may still be eligible for a discount, but we can’t verify your age " +"with Login.gov." #: benefits/eligibility/views.py:172 msgctxt "image alt text" @@ -419,7 +430,7 @@ msgstr "Identification card icon with question mark" #: benefits/eligibility/views.py:173 msgid "eligibility.pages.unverified.p[1]" -msgstr "Reach out to your transit provider for assistance." +msgstr "That’s okay! You may still be eligible for our program. Please reach out to your local transit provider for assistance." #: benefits/enrollment/templates/enrollment/success.html:7 msgctxt "image alt text" @@ -432,12 +443,12 @@ msgstr "Connect Card" #: benefits/enrollment/views.py:30 msgid "enrollment.pages.index.content_title" -msgstr "Great! You’re eligible for a discount!" +msgstr "Let’s link your card to your discount." #: benefits/enrollment/views.py:32 msgid "enrollment.pages.index.p[0]" msgstr "" -"Next, we need to link your discount to your bank-issued card through our payment partner Littlepay." +"Now that you are logged in we can link your transit discount to your card through our payment partner, Littlepay." #: benefits/enrollment/views.py:32 msgid "enrollment.pages.index.p[1]" @@ -500,7 +511,7 @@ msgid "enrollment.pages.success.p3%(help_link)s" msgstr "For more information on what to expect when using your connected bank card please reach out to your local transit provider or check out our " "help page." -msgid "eligibility.pages.index.dmv.label" +msgid "eligibility.pages.index.oauth.label" msgstr "Senior Discount Program" msgid "eligibility.pages.index.mst.label" @@ -528,6 +539,9 @@ msgstr "CA driver’s license or ID number" msgid "eligibility.forms.confirm.dmv.fields.name" msgstr "Last name (as it appears on ID)" +msgid "eligibility.pages.start.oauth.content_title" +msgstr "You will need a few items to connect your discount:" + msgid "eligibility.pages.start.dmv.content_title" msgstr "You will need a few items to connect your discount:" @@ -540,6 +554,7 @@ msgstr "" msgid "eligibility.pages.confirm.dmv.title" msgstr "Confirm Eligibility" + msgid "eligibility.pages.unverified.dmv.p[0]" msgstr "" "You may still be eligible for a discount, but we can’t verify your age " @@ -574,7 +589,7 @@ msgstr "Let’s see if we can find you in our system" msgid "eligibility.pages.confirm.mst.p[0]" msgstr "" -"Please input your Courtesy Card number and last name below. If you're a " +"Please input your Courtesy Card number and last name below. If you’re a " "current MST Courtesy Cardholder, we can confirm that you are eligible for " "a discount. We do not save your information." diff --git a/benefits/locale/es/LC_MESSAGES/django.po b/benefits/locale/es/LC_MESSAGES/django.po index 876d2e1ef..2764233a1 100644 --- a/benefits/locale/es/LC_MESSAGES/django.po +++ b/benefits/locale/es/LC_MESSAGES/django.po @@ -23,7 +23,7 @@ msgstr "" msgid "core.pages.help.about.p[1]" msgstr "" -"Nos asociamos con el DMV de California para confirmar la edad para los descuentos basados en la edad y las agencias de transporte locales para confirmar los programas de descuento específicos. Una vez verificados, los descuentos se adjuntan a su tarjeta bancaria a través de nuestro socio de pago, Littlepay, para que cuando pague su viaje en transporte, obtenga su descuento automáticamente.?" +"Nos asociamos con Login.gov para confirmar la edad para los descuentos basados en la edad y nos asociamos con las agencias de transporte locales para confirmar los programas de descuento específicos. Una vez verificados, los descuentos se adjuntan a su tarjeta bancaria a través de nuestro socio de pago, Littlepay, para que cuando pague su viaje en transporte, obtenga su descuento automáticamente." msgid "core.pages.help.payment_options" msgstr "Opciones de pago" @@ -56,12 +56,24 @@ msgstr "Login.gov es una forma segura de iniciar sesión en las agencias guberna msgid "core.pages.help.login_gov.p[1]" msgstr "Crear una cuenta es simple. Necesitará una dirección de correo electrónico y un método para autenticar su cuenta—que utilizará un teléfono fijo o un teléfono móvil o códigos de seguridad que deben imprimirse o anotarse." -msgid "core.pages.help.login_gov.p[2][0]%(website)s" +msgid "core.pages.help.login_gov.p[2]" msgstr "Para obtener más información sobre Login.gov, visite " -"Login.gov " +"Login.gov o Login.gov: Centro de ayuda." -msgid "core.pages.help.login_gov.p[2][1]%(website)s" -msgstr "o Login.gov: Centro de ayuda." +msgid "core.pages.help.login_gov_verify" +msgstr "¿Cómo verifico mi identidad en Login.gov?" + +msgid "core.pages.help.login_gov_verify.p[0]" +msgstr "​​Para verificar su identidad, Login.gov le pide que suba una fotografía de su identificación emitida por el estado y comparta su número de teléfono y otra información personal, que luego se verificara con fuentes autorizadas. Estos requisitos son adicionales para cumplir con los requisitos para la autentificación de dos factores." + +msgid "core.pages.help.login_gov_verify.p[1]" +msgstr "No puede verificar su identidad en Login.gov sin una identificación emitida por el estado, que puede ser una licencia de conducir o una tarjeta de identificación emitida por el estado que no sea una licencia de conducir." + +msgid "core.pages.help.login_gov_verify.p[2]" +msgstr "Si no tiene un plan telefónico que esté a su nombre, Login.gov puede enviarle el código de verificación por correo, lo que toma aproximadamente de 3 a 5 días." + +msgid "core.pages.help.login_gov_verify.p[3]" +msgstr "Verificar su identidad asegura que la persona adecuada tenga acceso a la información correcta. Estamos utilizando el proceso simple y seguro de Login.gov’s para mantener segura su información confidencial. Para obtener más información sobre la verificación de identidad en Login.gov, visite su Login.gov: Centro de ayuda." msgid "core.pages.help.littlepay" msgstr "¿Qué es Littlepay?" @@ -210,7 +222,7 @@ msgstr "Icono de autobus con llanta ponchada" #: benefits/core/viewmodels.py:177 msgid "core.pages.server_error.content_title" -msgstr "El servicio no está funcionando" +msgstr "¡Lo sentimos! El servicio para este sitio está caído." #: benefits/core/viewmodels.py:183 benefits/core/viewmodels.py:184 msgid "core.pages.server_error.title" @@ -218,11 +230,11 @@ msgstr "El servicio no está funcionando" #: benefits/core/viewmodels.py:185 msgid "core.pages.server_error.p[0]" -msgstr "¡Pronto volveremos a estar en funcionamiento!" +msgstr "Deberíamos volver a funcionar pronto." #: benefits/core/viewmodels.py:185 msgid "core.pages.server_error.p[1]" -msgstr "Por favor, vuelva más tarde." +msgstr "Por favor, actualice la página en unos minutos." #: benefits/core/viewmodels.py:193 msgid "core.pages.not_found.title" @@ -230,11 +242,11 @@ msgstr "No podemos encontrar esa página" #: benefits/core/viewmodels.py:194 msgid "core.pages.not_found.content_title" -msgstr "No podemos encontrar esa página" +msgstr "¡Lo sentimos! No podemos encontrar esa página." #: benefits/core/viewmodels.py:195 msgid "core.pages.not_found.p[0]" -msgstr "Parece que esa página no existe o se ha movido." +msgstr "La página que estás buscando puede estar en otro lugar o puede que ya no exista." #: benefits/core/viewmodels.py:218 msgid "core.buttons.wait" @@ -254,7 +266,7 @@ msgstr "" msgid "core.pages.agency_index.p[0]%(info_link)s" -msgstr "Puede acercar su tarjeta de crédito o débito cuando aborde, y su descuento se aplicará automáticamente cada vez que viaje. Conozca más sobre beneficios de Cal-ITP." +msgstr "Puede acercar su tarjeta de crédito o débito cuando aborde, y su descuento se aplicará automáticamente cada vez que viaje. Conozca más sobre beneficios de Cal-ITP." msgid "core.pages.agency_index.p[1]" msgstr "Necesitará su licencia de conducir o tarjeta de identificación de California y su tarjeta sin contacto emitida por el banco para comenzar." @@ -263,7 +275,7 @@ msgid "core.pages.agency_index.p[2]" msgstr "El programa de beneficios de Cal-ITP actualmente solo está abierto a personas de 65 años o más." msgid "core.pages.agency_index.p[3]" -msgstr "TODO: Connect your bank card to your public transit discount with Cal-ITP Benefits." +msgstr "Conecta tu tarjeta bancaria para tu descuento en el transporte público con Cal-ITP Benefits." msgid "core.pages.agency_index.button.label" msgstr "¡Conecta tu tarjeta bancaria a tu descuento hoy!" @@ -283,7 +295,7 @@ msgstr "Comencemos" #: benefits/core/views.py:86 benefits/core/views.py:106 msgid "core.buttons.back" -msgstr "Regresar" +msgstr "Regresar a la página principal" #: benefits/core/views.py:91 msgid "core.pages.help.p[1]" @@ -297,7 +309,7 @@ msgid "core.icons.bankcard" msgstr "Icono de tarjeta bancaria" msgid "core.pages.start.headline" -msgstr "TODO: Connect your bank card to your public transit discount with Cal-ITP Benefits." +msgstr "Conecta tu tarjeta bancaria para tu descuento en el transporte público con Cal-ITP Benefits." #: benefits/eligibility/forms.py:37 msgid "eligibility.forms.confirm.submit" @@ -344,58 +356,59 @@ msgstr "TODO: Computer monitor with checkmark" #: benefits/eligibility/views.py:74 msgid "eligibility.pages.start.bankcard.title" -msgstr "Proporcione los datos de su tarjeta bancaria" +msgstr "Los datos de su tarjeta bancaria" #: benefits/eligibility/views.py:75 msgid "eligibility.pages.start.bankcard.text" -msgstr "Debe ser una tarjeta de débito o crédito sin contacto de Visa o Mastercard." +msgstr "Su tarjeta debe ser una tarjeta de débito o crédito sin contacto de Visa o Mastercard." msgid "eligibility.pages.start.bankcard.button[0].link" -msgstr "¿No tienes tarjeta bancaria?" - -msgid "eligibility.pages.start.bankcard.button[1].link" -msgstr "¿No está seguro si tiene una tarjeta sin contacto?" +msgstr "¿No tienes tarjeta bancaria o no está seguro si tiene una tarjeta sin contacto?" #: benefits/eligibility/views.py:80 msgid "eligibility.buttons.continue" msgstr "Continuar" msgid "eligibility.pages.start.oauth.heading" -msgstr "Inicia sesión en Login.gov" +msgstr "Una cuenta de Login.gov con verificación de identidad" msgid "eligibility.pages.start.oauth.details" -msgstr "Login.gov es una forma segura de iniciar sesión en los servicios gubernamentales." +msgstr "Login.gov es una forma segura de iniciar sesión en servicios gubernamentales. Benefits utiliza Login.gov para verificar su edad. Si aún no tiene una cuenta, podrá crear una. También deberá verificar su identidad, lo que requerirá estos artículos:" msgid "eligibility.pages.start.oauth.link_text" -msgstr "Conozca más sobre Login.gov" - -msgid "eligibility.pages.start.oauth.link_text[2]" -msgstr "TODO: Don’t have access to these items?" +msgstr "Conozca más sobre Login.gov y cómo proporcionar una prueba de identidad." msgid "eligibility.pages.start.oauth.required_items[0]" -msgstr "TODO: Your state-issued ID card" +msgstr "Su tarjeta de identificación emitida por el estado" msgid "eligibility.pages.start.oauth.required_items[1]" -msgstr "TODO: Your Social Security number" +msgstr "Su número de Seguro Social" msgid "eligibility.pages.start.oauth.required_items[2]" -msgstr "TODO: Don’t have access to these items?" +msgstr "Un número de teléfono con un plan de teléfono asociado con su nombre" + +msgid "eligibility.pages.start.oauth.content_title" +msgstr "Necesitará algunos artículos para conectar su descuento:" #: benefits/eligibility/views.py:80 msgid "eligibility.buttons.signin" -msgstr "Crear una cuenta o iniciar sesión con" +msgstr "Comencemos con" #: benefits/eligibility/views.py:80 msgid "eligibility.buttons.signout" msgstr "Cierre sesión de Login.gov" #: benefits/eligibility/views.py:170 -msgid "eligibility.pages.unverified.dmv.title" -msgstr "Edad no confirmada" +msgid "eligibility.pages.unverified.oauth.title" +msgstr "Error de elegibilidad" #: benefits/eligibility/views.py:171 -msgid "eligibility.pages.unverified.dmv.content_title" -msgstr "No podemos confirmar su edad" +msgid "eligibility.pages.unverified.oauth.content_title" +msgstr "No se pudo verificar su elegibilidad." + +msgid "eligibility.pages.unverified.oauth.p[0]" +msgstr "" +"¡Esta bien! Aún puede ser elegible para nuestro programa. " #: benefits/eligibility/views.py:172 msgctxt "image alt text" @@ -404,7 +417,7 @@ msgstr "Icono de tarjeta de identificación con signo de interrogación" #: benefits/eligibility/views.py:173 msgid "eligibility.pages.unverified.p[1]" -msgstr "Comuníquese con su proveedor de transporte público para obtener ayuda." +msgstr "Comuníquese con su proveedor de tránsito local para obtener asistencia." #: benefits/enrollment/templates/enrollment/success.html:7 msgctxt "image alt text" @@ -417,12 +430,12 @@ msgstr "Tarjeta de conexión" #: benefits/enrollment/views.py:30 msgid "enrollment.pages.index.content_title" -msgstr "¡Genial! ¡Eres elegible para un descuento!" +msgstr "Vamos a vincular su tarjeta con su descuento." #: benefits/enrollment/views.py:32 msgid "enrollment.pages.index.p[0]" msgstr "" -"A continuación, tenemos que vincular su descuento a su tarjeta bancaria a través de nuestro socio de pago Littlepay." +"Ahora que ha iniciado sesión, podemos vincular su descuento de tránsito a su tarjeta a través de nuestro socio de pagos, Littlepay." #: benefits/enrollment/views.py:32 msgid "enrollment.pages.index.p[1]" @@ -441,7 +454,7 @@ msgstr "¿Qué pasa si no tengo una tarjeta bancaria?" #: benefits/enrollment/views.py:127 benefits/enrollment/views.py:129 msgid "enrollment.pages.retry.title" -msgstr "No pudimos conectar tu tarjeta bancaria" +msgstr "No pudimos conectar tú tarjeta bancaria" #: benefits/enrollment/views.py:128 msgctxt "image alt text" @@ -452,22 +465,21 @@ msgstr "" #: benefits/enrollment/views.py:130 msgid "enrollment.pages.retry.p[0]" msgstr "" -"Puede intentarlo de nuevo o puede comunicarse con su proveedor de transporte " -"público para obtener ayuda." +"Puede intentarlo de nuevo o comunicarse con su proveedor de transporte local para obtener ayuda." #: benefits/enrollment/views.py:133 msgid "core.buttons.retry" -msgstr "Inténtalo de nuevo" +msgstr "Intentar otra vez" msgid "enrollment.pages.success.title" -msgstr "Éxito" +msgstr "Cierre sesión" #: benefits/enrollment/views.py:147 benefits/enrollment/views.py:149 msgid "enrollment.pages.success.content_title" msgstr "¡Éxito! Su descuento está ahora vinculado a su tarjeta bancaria." msgid "enrollment.pages.success.logout.title" -msgstr "Ha sido desconectado exitosamente. Gracias por utilizar los beneficios de Cal-ITP!" +msgstr "Ha cerrado la sesión correctamente. ¡Gracias por usar los beneficios de Cal-ITP!" #: benefits/enrollment/views.py:150 msgid "enrollment.pages.success.p1" @@ -479,7 +491,7 @@ msgstr "Al abordar el tránsito, pague con esta misma tarjeta física y su descu msgid "enrollment.pages.success.p3%(help_link)s" msgstr "" -"Para obtener más información sobre lo que puede esperar al utilizar su tarjeta bancaria conectada, comuníquese con su proveedor de tránsito local o consulte " +"Para más información, consulte nuestro " "página de ayuda." msgid "enrollment.pages.success.p3" @@ -488,7 +500,7 @@ msgstr "Si estás en una computadora pública o compartida, no olvides hacer cli msgid "enrollment.pages.success.p4" msgstr "." -msgid "eligibility.pages.index.dmv.label" +msgid "eligibility.pages.index.oauth.label" msgstr "TODO: Senior Discount Program" msgid "eligibility.pages.index.mst.label" @@ -511,7 +523,7 @@ msgstr "" "Por favor, ingrese su número de licencia/identificación y su apellido abajo. Si tiene 65 años o más, podemos confirmar que es eligible para un descuento para personas mayores cuando viaja en transporte público. No guardamos la información que ingresa aquí." msgid "eligibility.pages.start.dmv.content_title" -msgstr "Hay tres pasos para vincular su descuento de tránsito a su tarjeta bancaria." +msgstr "Necesitará algunos artículos para conectar su descuento:" msgid "eligibility.forms.confirm.dmv.fields.sub" msgstr "Licencia de conducir de California o número de identificación" diff --git a/benefits/logging.py b/benefits/logging.py new file mode 100644 index 000000000..8c92e13ce --- /dev/null +++ b/benefits/logging.py @@ -0,0 +1,49 @@ +def get_config(level="INFO", enable_azure=False): + config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "[{asctime}] {levelname} {name}:{lineno} {message}", + "datefmt": "%d/%b/%Y %H:%M:%S", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "root": { + "handlers": ["console"], + "level": level, + }, + "loggers": { + "django": { + "handlers": ["console"], + "propagate": False, + }, + }, + } + + print("ENABLE AZURE: ", enable_azure) + + if enable_azure: + # enable Azure Insights logging + + # https://docs.microsoft.com/en-us/azure/azure-monitor/app/opencensus-python#configure-logging-for-django-applications + config["handlers"]["azure"] = { + "class": "opencensus.ext.azure.log_exporter.AzureLogHandler", + # send all logs + "logging_sampling_rate": 1.0, + } + + # create custom logger + # https://github.com/census-instrumentation/opencensus-python/issues/1130#issuecomment-1161898856 + config["loggers"]["azure"] = { + "handlers": ["azure"], + "level": level, + } + + return config diff --git a/benefits/oauth/__init__.py b/benefits/oauth/__init__.py index d56d39df5..e69de29bb 100644 --- a/benefits/oauth/__init__.py +++ b/benefits/oauth/__init__.py @@ -1,10 +0,0 @@ -""" -The oauth application: Implements OAuth-based authentication -""" -from django.apps import AppConfig - - -class OAuthAppConfig(AppConfig): - name = "benefits.oauth" - label = "oauth" - verbose_name = "Benefits OAuth" diff --git a/benefits/oauth/analytics.py b/benefits/oauth/analytics.py index 10dceebff..74bb8af50 100644 --- a/benefits/oauth/analytics.py +++ b/benefits/oauth/analytics.py @@ -2,7 +2,6 @@ The oauth application: analytics implementation. """ from benefits.core import analytics as core, session -from django.conf import settings class OAuthEvent(core.Event): @@ -10,7 +9,8 @@ class OAuthEvent(core.Event): def __init__(self, request, event_type): super().__init__(request, event_type) - self.update_event_properties(auth_provider=settings.OAUTH_CLIENT_NAME) + verifier = session.verifier(request) + self.update_event_properties(auth_provider=verifier.auth_provider.client_name) class StartedSignInEvent(OAuthEvent): diff --git a/benefits/oauth/apps.py b/benefits/oauth/apps.py new file mode 100644 index 000000000..34936b058 --- /dev/null +++ b/benefits/oauth/apps.py @@ -0,0 +1,22 @@ +""" +The oauth application: Implements OAuth-based authentication +""" +from django.apps import AppConfig + + +class OAuthAppConfig(AppConfig): + name = "benefits.oauth" + label = "oauth" + verbose_name = "Benefits OAuth" + + def ready(self): + # delay import until the ready() function is called, signaling that + # Django has loaded all the apps and models + from .client import oauth, register_providers + + # wrap registration in try/catch + # even though we are in a ready() function, sometimes it's called early? + try: + register_providers(oauth) + except Exception: + pass diff --git a/benefits/oauth/client.py b/benefits/oauth/client.py index c938a7833..7bc19ce3a 100644 --- a/benefits/oauth/client.py +++ b/benefits/oauth/client.py @@ -1,29 +1,54 @@ -import logging +""" +The oauth application: helpers for working with OAuth clients. +""" -from django.conf import settings +import logging from authlib.integrations.django_client import OAuth - -_OAUTH_CLIENT = None +from benefits.core.models import AuthProvider logger = logging.getLogger(__name__) +oauth = OAuth() + + +def _client_kwargs(scope=None): + """ + Generate the OpenID Connect client_kwargs, with optional extra scope(s). + + `scope` should be a space-separated list of scopes to add. + """ + scopes = ["openid", scope] if scope else ["openid"] + return {"code_challenge_method": "S256", "scope": " ".join(scopes)} + -def instance(): +def _server_metadata_url(authority): """ - Get the OAuth client instance using the OAUTH_CLIENT_NAME setting. + Generate the OpenID Connect server_metadata_url for an OAuth authority server. + + `authority` should be a fully qualified HTTPS domain name, e.g. https://example.com. """ - global _OAUTH_CLIENT - if not _OAUTH_CLIENT: - if settings.OAUTH_CLIENT_NAME: - logger.debug(f"Using OAuth client configuration: {settings.OAUTH_CLIENT_NAME}") - - _oauth = OAuth() - _oauth.register(settings.OAUTH_CLIENT_NAME) - _OAUTH_CLIENT = _oauth.create_client(settings.OAUTH_CLIENT_NAME) - else: - raise Exception("OAUTH_CLIENT_NAME is not configured") - - return _OAUTH_CLIENT + return f"{authority}/.well-known/openid-configuration" + + +def register_providers(oauth_registry): + """ + Register OAuth clients into the given registry, using configuration from AuthProvider models. + + Adapted from https://stackoverflow.com/a/64174413. + """ + logger.info("Registering OAuth clients") + + providers = AuthProvider.objects.all() + + for provider in providers: + logger.debug(f"Registering OAuth client: {provider.client_name}") + + oauth_registry.register( + provider.client_name, + client_id=provider.client_id, + server_metadata_url=_server_metadata_url(provider.authority), + client_kwargs=_client_kwargs(provider.scope), + ) diff --git a/benefits/oauth/redirects.py b/benefits/oauth/redirects.py index fafa26451..52753a07b 100644 --- a/benefits/oauth/redirects.py +++ b/benefits/oauth/redirects.py @@ -1,18 +1,14 @@ from django.shortcuts import redirect from django.utils.http import urlencode -from . import client - -def deauthorize_redirect(token, redirect_uri): +def deauthorize_redirect(oauth_client, token, redirect_uri): """Helper implements OIDC signout via the `end_session_endpoint`.""" # Authlib has not yet implemented `end_session_endpoint` as the OIDC Session Management 1.0 spec is still in draft # See https://github.com/lepture/authlib/issues/331#issuecomment-827295954 for more # # The implementation here was adapted from the same ticket: https://github.com/lepture/authlib/issues/331#issue-838728145 - oauth_client = client.instance() - metadata = oauth_client.load_server_metadata() end_session_endpoint = metadata.get("end_session_endpoint") diff --git a/benefits/oauth/urls.py b/benefits/oauth/urls.py index a270bd712..8395d8003 100644 --- a/benefits/oauth/urls.py +++ b/benefits/oauth/urls.py @@ -8,6 +8,7 @@ # /oauth path("login", views.login, name="login"), path("authorize", views.authorize, name="authorize"), + path("cancel", views.cancel, name="cancel"), path("logout", views.logout, name="logout"), path("post_logout", views.post_logout, name="post_logout"), ] diff --git a/benefits/oauth/views.py b/benefits/oauth/views.py index 15e37e7e2..41456302e 100644 --- a/benefits/oauth/views.py +++ b/benefits/oauth/views.py @@ -2,9 +2,12 @@ from django.shortcuts import redirect from django.urls import reverse +from django.utils.decorators import decorator_from_middleware from benefits.core import session -from . import analytics, client, redirects +from benefits.core.middleware import VerifierSessionRequired +from . import analytics, redirects +from .client import oauth logger = logging.getLogger(__name__) @@ -13,26 +16,37 @@ ROUTE_AUTH = "oauth:authorize" ROUTE_START = "eligibility:start" ROUTE_CONFIRM = "eligibility:confirm" +ROUTE_UNVERIFIED = "eligibility:unverified" ROUTE_POST_LOGOUT = "oauth:post_logout" +@decorator_from_middleware(VerifierSessionRequired) def login(request): """View implementing OIDC authorize_redirect.""" - oauth_client = client.instance() + verifier = session.verifier(request) + oauth_client = oauth.create_client(verifier.auth_provider.client_name) - analytics.started_sign_in(request) + if not oauth_client: + raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}") route = reverse(ROUTE_AUTH) redirect_uri = redirects.generate_redirect_uri(request, route) logger.debug(f"OAuth authorize_redirect with redirect_uri: {redirect_uri}") + analytics.started_sign_in(request) + return oauth_client.authorize_redirect(request, redirect_uri) +@decorator_from_middleware(VerifierSessionRequired) def authorize(request): """View implementing OIDC token authorization.""" - oauth_client = client.instance() + verifier = session.verifier(request) + oauth_client = oauth.create_client(verifier.auth_provider.client_name) + + if not oauth_client: + raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}") logger.debug("Attempting to authorize OAuth access token") token = oauth_client.authorize_access_token(request) @@ -43,17 +57,41 @@ def authorize(request): logger.debug("OAuth access token authorized") - # we store the id_token in the user's session - # this is the minimal amount of information needed later to log the user out - session.update(request, oauth_token=token["id_token"]) + # We store the id_token in the user's session. This is the minimal amount of information needed later to log the user out. + id_token = token["id_token"] + + # We store the returned claim in case it can be used later in eligibility verification. + verifier_claim = verifier.auth_provider.claim + stored_claim = None + + if verifier_claim: + userinfo = token.get("userinfo") + # the claim comes back in userinfo like { "claim": "True" | "False" } + claim_flag = (userinfo.get(verifier_claim) if userinfo else "false").lower() == "true" + # if userinfo contains our claim and the flag is true, store the *claim* + stored_claim = verifier_claim if claim_flag else None + + session.update(request, oauth_token=id_token, oauth_claim=stored_claim) analytics.finished_sign_in(request) return redirect(ROUTE_CONFIRM) +def cancel(request): + """View implementing cancellation of OIDC authorization.""" + return redirect(ROUTE_UNVERIFIED) + + +@decorator_from_middleware(VerifierSessionRequired) def logout(request): """View implementing OIDC and application sign out.""" + verifier = session.verifier(request) + oauth_client = oauth.create_client(verifier.auth_provider.client_name) + + if not oauth_client: + raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}") + analytics.started_sign_out(request) # overwrite the oauth session token, the user is signed out of the app @@ -67,7 +105,7 @@ def logout(request): # send the user through the end_session_endpoint, redirecting back to # the post_logout route - return redirects.deauthorize_redirect(token, redirect_uri) + return redirects.deauthorize_redirect(oauth_client, token, redirect_uri) def post_logout(request): diff --git a/benefits/settings.py b/benefits/settings.py index 8a1ff7772..daa49734c 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -2,6 +2,7 @@ Django settings for benefits project. """ import os +import benefits.logging def _filter_empty(ls): @@ -64,7 +65,30 @@ def _filter_empty(ls): ) if DEBUG: - MIDDLEWARE.extend(["benefits.core.middleware.DebugSession"]) + MIDDLEWARE.append("benefits.core.middleware.DebugSession") + + +# Azure Insights +# https://docs.microsoft.com/en-us/azure/azure-monitor/app/opencensus-python-request#tracking-django-applications + +ENABLE_AZURE_INSIGHTS = "APPLICATIONINSIGHTS_CONNECTION_STRING" in os.environ +print("ENABLE_AZURE_INSIGHTS: ", ENABLE_AZURE_INSIGHTS) +if ENABLE_AZURE_INSIGHTS: + MIDDLEWARE.extend( + [ + "opencensus.ext.django.middleware.OpencensusMiddleware", + "benefits.core.middleware.LogErrorToAzure", + ] + ) + +# only used if enabled above +OPENCENSUS = { + "TRACE": { + "SAMPLER": "opencensus.trace.samplers.ProbabilitySampler(rate=1)", + "EXPORTER": "opencensus.ext.azure.trace_exporter.AzureExporter()", + } +} + CSRF_COOKIE_AGE = None CSRF_COOKIE_SAMESITE = "Strict" @@ -166,21 +190,6 @@ def _filter_empty(ls): ] ) -# OAuth configuration - -OAUTH_AUTHORITY = os.environ.get("DJANGO_OAUTH_AUTHORITY", "http://example.com") -OAUTH_CLIENT_NAME = os.environ.get("DJANGO_OAUTH_CLIENT_NAME", "benefits-oauth-client-name") -OAUTH_CLIENT_ID = os.environ.get("DJANGO_OAUTH_CLIENT_ID", "benefits-oauth-client-id") - -if OAUTH_CLIENT_NAME: - AUTHLIB_OAUTH_CLIENTS = { - OAUTH_CLIENT_NAME: { - "client_id": OAUTH_CLIENT_ID, - "server_metadata_url": f"{OAUTH_AUTHORITY}/.well-known/openid-configuration", - "client_kwargs": {"code_challenge_method": "S256", "scope": "openid"}, - } - } - # Internationalization LANGUAGE_CODE = "en" @@ -207,27 +216,8 @@ def _filter_empty(ls): STATIC_ROOT = os.path.join(BASE_DIR, "static") # Logging configuration - LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING") -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "format": "[{asctime}] {levelname} {name}:{lineno} {message}", - "datefmt": "%d/%b/%Y %H:%M:%S", - "style": "{", - }, - }, - "handlers": { - "default": {"class": "logging.StreamHandler", "formatter": "default"}, - }, - "root": { - "handlers": ["default"], - "level": LOG_LEVEL, - }, - "loggers": {"django": {"handlers": ["default"], "propagate": False}}, -} +LOGGING = benefits.logging.get_config(LOG_LEVEL, enable_azure=ENABLE_AZURE_INSIGHTS) # Analytics configuration diff --git a/benefits/static/css/styles.css b/benefits/static/css/styles.css index 026d68d73..0682160e7 100644 --- a/benefits/static/css/styles.css +++ b/benefits/static/css/styles.css @@ -14,10 +14,6 @@ body { src: local("PublicSans"), url("../fonts/PublicSans-Bold.woff") format("woff"); } -.bg-danger { - display: none; -} - body, h1, h2, @@ -67,7 +63,6 @@ label { .main-content .main-primary a:not(.btn) { background: none; - text-decoration: underline !important; } /* making the sticky footer */ @@ -87,6 +82,10 @@ main { flex-shrink: 0 !important; } +main#main-content.main-content { + position: relative; +} + /* All pages but the Start Page */ main .main-row { min-height: calc(100vh - 120px); @@ -98,9 +97,11 @@ main .main-row .col-lg-6.image { } .one-column-image { - background: 48% 75% url("/static/img/ridertappingbankcard.png") no-repeat; + background: url("/static/img/rider-tapping-bank-card-horizontal.png"); background-size: cover; - height: 131px; + background-repeat: no-repeat; + background-position: center center; + height: 292px; } footer { @@ -110,14 +111,16 @@ footer { } footer.global-footer .footer-links a { - font-size: 16px; - color: #0590cd; + font-size: 18px; + color: #73b3e7; + text-decoration: underline; } footer.global-footer .footer-links a:hover, footer.global-footer .footer-links a:focus, footer.global-footer .footer-links a:active { color: #9b74d7; + text-decoration: none; } /* class styles */ @@ -262,7 +265,7 @@ footer.global-footer .footer-links a:active { margin-top: 100px; margin-bottom: 60px; font-size: 24px; - line-height: 30px; + line-height: 36px; } .media-list { @@ -302,12 +305,8 @@ footer.global-footer .footer-links a:active { font-size: 18px; letter-spacing: 0.05em; line-height: 26.1px; -} - -.media-list .media .media-body--heading { font-weight: 700; padding-left: 0; - line-height: 26.1px; margin-bottom: 16px; } @@ -334,12 +333,10 @@ footer.global-footer .footer-links a:active { .media-list .media .media-body .media-body--links .btn-lg { font-size: 16px; - line-height: 20px; letter-spacing: 0.05em; padding: 0 0 6px 0; text-align: left; - width: 100%; - margin: 0; + width: fit-content; } .media-list .media .media-body .media-body--links .btn-lg:hover { @@ -358,6 +355,7 @@ footer.global-footer .footer-links a:active { display: flex; align-items: flex-end; justify-content: right; + margin-bottom: 30px; } .eligibility-start .main-container .buttons .btn { @@ -382,13 +380,18 @@ footer.global-footer .footer-links a:active { /* Enrollment Success */ .enrollment-success #login { - padding: 0.3rem 0.6rem 0.1rem 0.6rem; - vertical-align: middle; + padding: 0 0.5rem 0.3rem 0.6rem; + display: inline-block; + margin: 0; + width: inherit; + border-radius: 0; } .enrollment-success #login .fallback-text { + padding-top: 14px; + margin: 8px auto 0 auto; + display: block; width: 7rem; - vertical-align: inherit; } .logged-out.enrollment-success .success-image { @@ -453,7 +456,7 @@ footer.global-footer .footer-links a:active { /* Header */ .navbar.navbar-expand-sm.navbar-dark.bg-primary { - padding: 14.5px 1rem; + padding: 8.5px 1rem; } .navbar-brand { @@ -482,6 +485,7 @@ footer.global-footer .footer-links a:active { .container.content h1.icon-title { padding-bottom: 1.5rem; + line-height: 36px; } .container.content h1.icon-title span.icon { @@ -618,12 +622,24 @@ footer.global-footer .footer-links a:active { } @media (max-width: 992px) { + .one-column-image { + height: 131px; + } + + .navbar.navbar-expand-sm.navbar-dark.bg-primary { + padding: 14.5px 1rem; + } + .container.content .btn-lg { padding: 1.1875rem 0.813rem; } /* Mobile With Image */ + .no-image-mobile .col-lg-6.image { + display: none !important; + } + .with-image main .main-row .col-lg-6.image { background-position: center bottom; height: 240px; @@ -632,13 +648,15 @@ footer.global-footer .footer-links a:active { } .signout-row .container .signout-link { - font-size: 16px; + font-size: 18px; color: #2b6597; background: none; - padding: 10px 30px; + padding: 5px 10px; border-radius: 3px; border: 2px solid #2b6597; margin-top: 20px; + letter-spacing: 0.02em; + font-weight: 500; } .signout-row .container .signout-link:hover { @@ -646,7 +664,15 @@ footer.global-footer .footer-links a:active { } .signout-row { - top: 308px; + top: 240px; + } + + .no-image-mobile .signout-row { + top: 0; + } + + .no-image-mobile .main-primary .container-fluid { + margin-top: 72px; } /* Eligibility Start */ @@ -672,12 +698,13 @@ footer.global-footer .footer-links a:active { } .media-list .media .media-line .icon { - width: 90px; - height: 90px; + width: 92px; + height: 92px; + margin: 0 auto 42px auto; } .media-list .media { - flex-direction: row; + flex-direction: column; } .media-list .media .media-line { @@ -689,54 +716,38 @@ footer.global-footer .footer-links a:active { } .media-list .media .media-body--heading { - height: 90px; + font-size: 20px; + line-height: 30px; } - .media-list .media .media-body--details { + .media-list .media .media-body--details p { padding-bottom: 25px; - margin-left: -100px; + margin-left: 0; font-size: 20px; line-height: 30px; + letter-spacing: 0.05em; + } + + .media-list .media .media-body--items li { + font-size: 20px; + line-height: 30px; + letter-spacing: 0.05em; } .media-list .media .media-body--links { - margin-left: -100px; + margin-left: 0; float: right; } - .eligibility-start .main-content .container strong a { - display: block; - line-height: 15px; - letter-spacing: 0.05em; - padding: 6px; - text-align: left; - width: 100%; - margin: 18px 0; - font-size: 18px; - text-decoration: none !important; - letter-spacing: 0.2px; - border: 2px solid #046b99; - border-radius: 0.3rem; - font-weight: 500; - width: 212px; - line-height: 22.5px; + .eligibility-start .main-content .container strong .info-link { + display: none; } .media-list .media .media-body .media-body--links .btn-lg { - letter-spacing: 0.02em; - padding: 6px; - text-align: left; - width: 100%; - margin: 0; font-size: 18px; - text-decoration: none; - letter-spacing: 0.2px; - border: 2px solid #046b99; - margin-bottom: 24px; - font-weight: 500; - width: 212px; - line-height: 22.5px; - border-radius: 3px; + line-height: 27px; + font-weight: 700; + letter-spacing: 0.05em; display: block; } diff --git a/benefits/static/img/rider-tapping-bank-card-horizontal.png b/benefits/static/img/rider-tapping-bank-card-horizontal.png new file mode 100644 index 000000000..9bec4f8e8 Binary files /dev/null and b/benefits/static/img/rider-tapping-bank-card-horizontal.png differ diff --git a/benefits/urls.py b/benefits/urls.py index d4f6ddfec..a8030ddee 100644 --- a/benefits/urls.py +++ b/benefits/urls.py @@ -2,7 +2,7 @@ benefits URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/3.1/topics/http/urls/ + https://docs.djangoproject.com/en/3.2/topics/http/urls/ """ import logging @@ -21,6 +21,7 @@ path("eligibility/", include("benefits.eligibility.urls")), path("enrollment/", include("benefits.enrollment.urls")), path("i18n/", include("django.conf.urls.i18n")), + path("oauth/", include("benefits.oauth.urls")), ] if settings.ADMIN: @@ -30,9 +31,3 @@ urlpatterns.append(path("admin/", admin.site.urls)) else: logger.debug("Skip url registrations for admin") - -if settings.OAUTH_CLIENT_NAME: - logger.info("Register oauth urls") - urlpatterns.append(path("oauth/", include("benefits.oauth.urls"))) -else: - logger.debug("Skip url registrations for oauth") diff --git a/bin/makemigrations.sh b/bin/makemigrations.sh new file mode 100755 index 000000000..7b5bcb355 --- /dev/null +++ b/bin/makemigrations.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -eu + +# remove existing migration file + +rm -f benefits/core/migrations/0001_initial.py + +# regenerate + +python manage.py makemigrations + +# reformat with black + +python -m black benefits/core/migrations/0001_initial.py diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 0017c3a11..7bf9e2819 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -149,3 +149,13 @@ devcontainer, check the [`DJANGO_LOCAL_PORT`](#django_local_port). [deployment]: ../deployment/README.md [getting-started_create-env]: ../getting-started/README.md#create-an-environment-file + +## Azure + +### `APPLICATIONINSIGHTS_CONNECTION_STRING` + +!!! tldr "Azure docs" + + [Azure Monitor connection strings](https://docs.microsoft.com/en-us/azure/azure-monitor/app/sdk-connection-string) + +Enables [log collection](../../deployment/infrastructure/#logs). Set the value in quotes, e.g. `APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=…"`. diff --git a/docs/configuration/oauth.md b/docs/configuration/oauth.md index fcdb3d6c9..1e5596df9 100644 --- a/docs/configuration/oauth.md +++ b/docs/configuration/oauth.md @@ -14,53 +14,49 @@ This section describes the related settings and how to configure the application Benefits uses the open-source [Authlib](https://authlib.org/) for OAuth and OIDC client implementation. See the Authlib docs for more details about what features are available. Specifically, from Authlib we: -1. Register an OAuth `client` using the configured [Django settings](#django-settings) +1. Create an OAuth client using the [Django configuration](#django-configuration) 1. Call `client.authorize_redirect()` to send the user into the OIDC server's authentication flow, with our authorization callback URL 1. Upon the user returning from the OIDC Server with an access token, call `client.authorize_access_token()` to get a validated id token from the OIDC server -## Environment variables +## Django configuration -!!! warning +OAuth settings are configured as instances of the [`AuthProvider` model](../development/models-migrations.md). - The following environment variables are all required for OAuth configuration +The [sample fixtures](./fixtures.md) contain example `AuthProvider` configurations; create new entries to integrate with +real Open ID Connect providers. -### `DJANGO_OAUTH_AUTHORITY` +Authlib's [Django OpenID Connect Client example][authlib-django-oidc] for Google could be adapted into a Benefits fixture, +(with extraneous fields omitted) like: -Base address of the OAuth/OIDC server to use for authorization. +```json +{ + "model": "core.authprovider", + "pk": 1, + "fields": { + "client_name": "google", + "client_id": "google-client-id", + "authority": "https://accounts.google.com", + "scope": "profile email", + } +} +``` -### `DJANGO_OAUTH_CLIENT_ID` +## Django usage -This application's client ID, as registered with the OAuth/OIDC server. +The [`benefits.oauth.client`][oauth-client] module defines helpers for registering OAuth clients, and creating instances for +use in e.g. views. -### `DJANGO_OAUTH_CLIENT_NAME` +* `register_providers(oauth_registry)` uses data from `AuthProvider` instances to register clients into the given registry +* `oauth` is an `authlib.integrations.django_client.OAuth` instance -The internal label of the OAuth client within this application. +Providers are registered into this instance once in the [`OAuthAppConfig.ready()`][oauth-app-ready] function at application +startup. -See the [`OAUTH_CLIENT_NAME`](#oauth_client_name) setting for more. +Consumers call `oauth.create_client(client_name)` with the name of a previously registered client to obtain an Authlib client +instance. -## Django settings - -There are a few relevant settings defined in [`benefits/settings.py`][benefits-settings] related to OAuth. - -### `OAUTH_CLIENT_NAME` - -A `str` defining the application's internal label for the OAuth client that is used. - -The app uses the value of this variable to further configure the OAuth feature, or skip that configuration for empty or `None`. - -The value is initialized from the [`DJANGO_OAUTH_CLIENT_NAME`](#django_oauth_client_name) environment variable. - -### `AUTHLIB_OAUTH_CLIENTS` - -!!! tldr "Authlib docs" - - Read more about [configuring Authlib for Django](https://docs.authlib.org/en/latest/client/django.html#configuration) - -A `dict` of OAuth client configurations this app may use. - -By default, contains a single entry, keyed by [`OAUTH_CLIENT_NAME`](#oauth_client_name) and using the other -[`DJANGO_OAUTH_*` environment variables](#environment-variables) to populate the client's settings. - -[benefits-settings]: https://github.com/cal-itp/benefits/blob/dev/benefits/settings.py +[authlib-django-oidc]: https://docs.authlib.org/en/latest/client/django.html#django-openid-connect-client +[oauth-app-ready]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/__init__.py +[oauth-client]: https://github.com/cal-itp/benefits/blob/dev/benefits/oauth/client.py diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 6b9033a53..6db7df75e 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -17,6 +17,8 @@ Registry (GHCR)][ghcr]. GitHub POSTs a [webhook][gh-webhooks] to the Azure Web App when an [image is published to GHCR][gh-webhook-event], telling Azure to restart the app and pull the latest image. +You can view what Git commit is deployed for a given environment by visitng the URL path `/static/sha.txt`. + ## Configuration [Configuration settings](../configuration/README.md) are stored as Application Configuration variables in Azure. diff --git a/docs/deployment/infrastructure.md b/docs/deployment/infrastructure.md index 16b0d8dcd..e1aebc439 100644 --- a/docs/deployment/infrastructure.md +++ b/docs/deployment/infrastructure.md @@ -63,6 +63,20 @@ flowchart LR We have [ping tests](https://docs.microsoft.com/en-us/azure/azure-monitor/app/monitor-web-app-availability) set up to notify about availability of the dev, test, and prod deployments. Alerts go to [#benefits-notify](https://cal-itp.slack.com/archives/C022HHSEE3F). +## Logs + +We send application logs to [Azure Monitor Logs](https://docs.microsoft.com/en-us/azure/azure-monitor/logs/data-platform-logs). To find them: + +1. [Open Application Insights.](https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/microsoft.insights%2Fcomponents) +1. Click the resource corresponding to the environment. +1. In the navigation, under `Monitoring`, click `Logs`. +1. In the Query Editor, type `requests` or `traces`, and click `Run`. + - [What each means](https://docs.microsoft.com/en-us/azure/azure-monitor/app/opencensus-python#telemetry-type-mappings) + +You should see recent log output. Note [there is some latency](https://docs.microsoft.com/en-us/azure/azure-monitor/logs/data-ingestion-time). + +See [`Failures`](https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-exceptions#diagnose-failures-using-the-azure-portal) in the sidebar (or `exceptions` under `Logs`) for application errors/exceptions. + ## Making changes 1. Get access to the Azure account through the DevSecOps team. diff --git a/docs/development/models-migrations.md b/docs/development/models-migrations.md new file mode 100644 index 000000000..6c900e170 --- /dev/null +++ b/docs/development/models-migrations.md @@ -0,0 +1,43 @@ +# Django models and migrations + +!!! example "Models and migrations" + + [`benefits/core/models.py`][core-models] + + [`benefits/core/migrations/0001_initial.py`][core-migrations] + +Cal-ITP Benefits defines a number of [models][core-models] in the core application, used throughout the codebase to configure +different parts of the UI and logic. + +The Cal-ITP Benefits database is a simple read-only Sqlite database, initialized from the [fixture configuration](../configuration/fixtures.md) files. + +## Migrations + +The database is rebuilt from scratch each time the container starts, so we maintain a single [migration][core-migrations] file. + +This file always represents the current schema of the database and matches the current structure of the model classes. + +## Updating models + +When models are updated, the migration should be updated as well. + +A simple helper script exists to regenerate the migration file based on the current state of models in the local directory: + +[`bin/makemigrations.sh`][makemigrations] + +```bash +bin/makemigrations.sh +``` + +This script: + +1. Deletes the existing migrations file +1. Runs the django `makemigrations` command +1. Formats the newly regenerated file with `black` + +This will result in a simple diff of changes on the same migration file. Commit these changes (including the timestamp!) along +with the model changes. + +[core-models]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/models.py +[core-migrations]: https://github.com/cal-itp/benefits/blob/dev/benefits/core/migrations/0001_initial.py +[makemigrations]: https://github.com/cal-itp/benefits/blob/dev/bin/makemigrations.sh diff --git a/fixtures/02_authprovider.json b/fixtures/02_authprovider.json index 08c055b50..488a4ba0d 100644 --- a/fixtures/02_authprovider.json +++ b/fixtures/02_authprovider.json @@ -4,7 +4,12 @@ "pk": 1, "fields": { "sign_in_button_label": "eligibility.buttons.signin", - "sign_out_button_label": "eligibility.buttons.signout" + "sign_out_button_label": "eligibility.buttons.signout", + "client_name": "benefits-oauth-client-name", + "client_id": "benefits-oauth-client-id", + "authority": "https://example.com", + "scope": "verify:type1", + "claim": "type1" } } ] diff --git a/fixtures/03_eligibilityverifier.json b/fixtures/03_eligibilityverifier.json index 15480809e..a959ec9e8 100644 --- a/fixtures/03_eligibilityverifier.json +++ b/fixtures/03_eligibilityverifier.json @@ -7,14 +7,12 @@ "api_url": "http://server:5000/verify", "api_auth_header": "X-Server-API-Key", "api_auth_key": "server-auth-token", - "eligibility_types": [1, 2], + "eligibility_type": 1, "public_key": 1, "jwe_cek_enc": "A256CBC-HS512", "jwe_encryption_alg": "RSA-OAEP", "jws_signing_alg": "RS256", "auth_provider": 1, - "auth_scope": "", - "auth_claim": "", "selection_label": "eligibility.pages.index.dmv.label", "selection_label_description": null, "start_content_title": "eligibility.pages.start.dmv.content_title", @@ -43,7 +41,7 @@ "api_url": "http://server:5000/verify", "api_auth_header": "X-Server-API-Key", "api_auth_key": "server-auth-token", - "eligibility_types": [1, 2], + "eligibility_type": 2, "public_key": 1, "jwe_cek_enc": "A256CBC-HS512", "jwe_encryption_alg": "RSA-OAEP", @@ -68,5 +66,23 @@ "unverified_content_title": "eligibility.pages.unverified.mst.content_title", "unverified_blurb": "eligibility.pages.unverified.mst.p[0]" } + }, + { + "model": "core.eligibilityverifier", + "pk": 3, + "fields": { + "name": "OAuth claims via Login.gov", + "eligibility_type": 1, + "auth_provider": 1, + "selection_label": "eligibility.pages.index.oauth.label", + "selection_label_description": null, + "start_content_title": "eligibility.pages.start.oauth.content_title", + "start_item_name": "eligibility.pages.start.oauth.items[0].title", + "start_item_description": "eligibility.pages.start.oauth.items[0].text", + "start_blurb": "eligibility.pages.start.oauth.p[0]", + "unverified_title": "eligibility.pages.unverified.oauth.title", + "unverified_content_title": "eligibility.pages.unverified.oauth.content_title", + "unverified_blurb": "eligibility.pages.unverified.oauth.p[0]" + } } ] diff --git a/fixtures/05_transitagency.json b/fixtures/05_transitagency.json index 8a6d439a4..d62c2af40 100644 --- a/fixtures/05_transitagency.json +++ b/fixtures/05_transitagency.json @@ -12,7 +12,8 @@ "phone": "800-555-5555", "active": true, "eligibility_types": [ - 1 + 1, + 2 ], "eligibility_verifiers": [ 1, @@ -35,10 +36,8 @@ "info_url": "https://www.example.com/help", "phone": "321-555-5555", "active": true, - "eligibility_types": [ - 2 - ], - "eligibility_verifiers": [2], + "eligibility_types": [1], + "eligibility_verifiers": [1], "private_key": 2, "jws_signing_alg": "RS256", "payment_processor": 1 diff --git a/requirements.txt b/requirements.txt index aa5da1421..475e09b8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,7 @@ Django==3.2.13 django-csp==3.7 git+https://github.com/cal-itp/eligibility-api#egg=eligibility_api gunicorn==20.1.0 +opencensus-ext-azure==1.1.4 +opencensus-ext-django==0.7.6 requests==2.28.0 six==1.16.0 diff --git a/terraform/app_service.tf b/terraform/app_service.tf index 0593daaff..75ae07d9f 100644 --- a/terraform/app_service.tf +++ b/terraform/app_service.tf @@ -10,7 +10,7 @@ resource "azurerm_service_plan" "main" { } } -# app_settings, sticky_settings, and storage_account are managed manually through the portal since they contain secrets +# app_settings and storage_account are managed manually through the portal since they contain secrets resource "azurerm_linux_web_app" "main" { name = "AS-CDT-PUB-VIP-CALITP-P-001" @@ -20,7 +20,7 @@ resource "azurerm_linux_web_app" "main" { https_only = true site_config { - ftps_state = "AllAllowed" + ftps_state = "Disabled" } identity { @@ -40,8 +40,39 @@ resource "azurerm_linux_web_app" "main" { } } + # Confusingly named argument; these are settings / environment variables that should be unique to each slot. Also known as "deployment slot settings". + # https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/linux_web_app#app_setting_names + # https://docs.microsoft.com/en-us/azure/app-service/deploy-staging-slots#which-settings-are-swapped + sticky_settings { + app_setting_names = [ + # custom config + "ANALYTICS_KEY", + "DJANGO_ALLOWED_HOSTS", + "DJANGO_INIT_PATH", + "DJANGO_LOG_LEVEL", + "DJANGO_TRUSTED_ORIGINS", + + # populated through auto-instrumentation + # https://docs.microsoft.com/en-us/azure/azure-monitor/app/azure-web-apps#enable-application-insights + "APPINSIGHTS_INSTRUMENTATIONKEY", + "APPINSIGHTS_PROFILERFEATURE_VERSION", + "APPINSIGHTS_SNAPSHOTFEATURE_VERSION", + "APPLICATIONINSIGHTS_CONFIGURATION_CONTENT", + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "ApplicationInsightsAgent_EXTENSION_VERSION", + "DiagnosticServices_EXTENSION_VERSION", + "InstrumentationEngine_EXTENSION_VERSION", + "SnapshotDebugger_EXTENSION_VERSION", + "XDT_MicrosoftApplicationInsights_BaseExtensions", + "XDT_MicrosoftApplicationInsights_Mode", + "XDT_MicrosoftApplicationInsights_NodeJS", + "XDT_MicrosoftApplicationInsights_PreemptSdk", + "XDT_MicrosoftApplicationInsightsJava", + ] + } + lifecycle { - ignore_changes = [app_settings, sticky_settings, tags] + ignore_changes = [app_settings, tags] } } @@ -51,7 +82,7 @@ resource "azurerm_linux_web_app_slot" "dev" { app_service_id = azurerm_linux_web_app.main.id site_config { - ftps_state = "AllAllowed" + ftps_state = "Disabled" vnet_route_all_enabled = true } @@ -88,7 +119,7 @@ resource "azurerm_linux_web_app_slot" "test" { app_service_id = azurerm_linux_web_app.main.id site_config { - ftps_state = "AllAllowed" + ftps_state = "Disabled" vnet_route_all_enabled = true } diff --git a/terraform/monitor.tf b/terraform/monitor.tf index 7dd763e45..6147ca0cb 100644 --- a/terraform/monitor.tf +++ b/terraform/monitor.tf @@ -21,6 +21,19 @@ resource "azurerm_application_insights" "dev" { } } +resource "azurerm_application_insights" "test" { + name = "AI-CDT-PUB-VIP-CALITP-P-001-test" + application_type = "web" + location = data.azurerm_resource_group.prod.location + resource_group_name = data.azurerm_resource_group.prod.name + sampling_percentage = 0 + workspace_id = azurerm_log_analytics_workspace.main.id + + lifecycle { + ignore_changes = [tags] + } +} + resource "azurerm_application_insights" "prod" { name = "AI-CDT-PUB-VIP-CALITP-P-001" application_type = "web" diff --git a/terraform/storage.tf b/terraform/storage.tf index c5d0d5cae..737b29434 100644 --- a/terraform/storage.tf +++ b/terraform/storage.tf @@ -5,6 +5,11 @@ resource "azurerm_storage_account" "main" { account_tier = "Standard" account_replication_type = "RAGRS" + blob_properties { + last_access_time_enabled = true + versioning_enabled = true + } + lifecycle { ignore_changes = [tags] } diff --git a/tests/cypress/package-lock.json b/tests/cypress/package-lock.json index a5f79ca35..28db68410 100644 --- a/tests/cypress/package-lock.json +++ b/tests/cypress/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "AGPL-3.0-or-later", "devDependencies": { - "cypress": "^10.1.0" + "cypress": "^10.2.0" } }, "node_modules/@colors/colors": { @@ -523,9 +523,9 @@ } }, "node_modules/cypress": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.1.0.tgz", - "integrity": "sha512-aQ4JVZVib4Xd9FZW8IRZfKelUvqF4y5A+oUbNvn8TlsBmEfIg3m5Xd6Mt6PVU/jHiVJ9Psl905B3ZPnrDcmyuQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.2.0.tgz", + "integrity": "sha512-+i9lY5ENlfi2mJwsggzR+XASOIgMd7S/Gd3/13NCpv596n3YSplMAueBTIxNLcxDpTcIksp+9pM3UaDrJDpFqA==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -2202,9 +2202,9 @@ } }, "cypress": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.1.0.tgz", - "integrity": "sha512-aQ4JVZVib4Xd9FZW8IRZfKelUvqF4y5A+oUbNvn8TlsBmEfIg3m5Xd6Mt6PVU/jHiVJ9Psl905B3ZPnrDcmyuQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.2.0.tgz", + "integrity": "sha512-+i9lY5ENlfi2mJwsggzR+XASOIgMd7S/Gd3/13NCpv596n3YSplMAueBTIxNLcxDpTcIksp+9pM3UaDrJDpFqA==", "dev": true, "requires": { "@cypress/request": "^2.88.10", diff --git a/tests/cypress/package.json b/tests/cypress/package.json index 9e57cdb0c..ec3dc9406 100644 --- a/tests/cypress/package.json +++ b/tests/cypress/package.json @@ -14,6 +14,6 @@ "license": "AGPL-3.0-or-later", "private": true, "devDependencies": { - "cypress": "^10.1.0" + "cypress": "^10.2.0" } } diff --git a/tests/cypress/specs/ui/help.cy.js b/tests/cypress/specs/ui/help.cy.js index 19cb4cfac..2d5e5681e 100644 --- a/tests/cypress/specs/ui/help.cy.js +++ b/tests/cypress/specs/ui/help.cy.js @@ -62,6 +62,7 @@ describe("Help page spec", () => { cy.get("h2").contains("What is Cal-ITP Benefits?"); cy.get("h2").contains("Payment options"); cy.get("h2").contains("What is Login.gov?"); + cy.get("h2").contains("How do I verify my identity on Login.gov?"); cy.get("h2").contains("What is Littlepay?"); cy.get("h2").contains("Questions?"); }); diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index 97cd9c93c..e3bf7a3d5 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -103,7 +103,7 @@ def mocked_session_verifier(mocker, first_verifier): @pytest.fixture def mocked_session_verifier_auth_required(mocker, first_verifier, mocked_session_verifier): mock_verifier = mocker.Mock(spec=first_verifier) - mock_verifier.requires_authentication = True + mock_verifier.is_auth_required = True mocked_session_verifier.return_value = mock_verifier return mocked_session_verifier @@ -111,7 +111,8 @@ def mocked_session_verifier_auth_required(mocker, first_verifier, mocked_session @pytest.fixture def mocked_session_verifier_auth_not_required(mocked_session_verifier_auth_required): # mocked_session_verifier_auth_required.return_value is the Mock(spec=first_verifier) from that fixture - mocked_session_verifier_auth_required.return_value.requires_authentication = False + mocked_session_verifier_auth_required.return_value.is_auth_required = False + mocked_session_verifier_auth_required.return_value.uses_auth_verification = False return mocked_session_verifier_auth_required diff --git a/tests/pytest/core/test_middleware_login_required.py b/tests/pytest/core/test_middleware_login_required.py index d0584ad6d..8063863f1 100644 --- a/tests/pytest/core/test_middleware_login_required.py +++ b/tests/pytest/core/test_middleware_login_required.py @@ -18,7 +18,7 @@ def decorated_view(mocked_view): @pytest.fixture def require_login(mocker): mock_verifier = mocker.Mock(spec=EligibilityVerifier) - mock_verifier.requires_authentication = True + mock_verifier.is_auth_required = True mocker.patch("benefits.core.session.verifier", return_value=mock_verifier) @@ -37,7 +37,7 @@ def test_login_auth_required(app_request, mocked_view, decorated_view): def test_login_auth_not_required(app_request, mocked_view, decorated_view): verifier = EligibilityVerifier.objects.filter(auth_provider__isnull=True).first() assert verifier - assert not verifier.requires_authentication + assert not verifier.is_auth_required session.update(app_request, verifier=verifier) decorated_view(app_request) diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py new file mode 100644 index 000000000..d758cbaf3 --- /dev/null +++ b/tests/pytest/core/test_models.py @@ -0,0 +1,13 @@ +import pytest + + +@pytest.mark.django_db +def test_EligibilityVerifier_uses_auth_verification_True(first_verifier): + assert first_verifier.uses_auth_verification + + +@pytest.mark.django_db +def test_EligibilityVerifier_uses_auth_verification_False(first_verifier): + first_verifier.auth_provider = None + + assert not first_verifier.uses_auth_verification diff --git a/tests/pytest/core/test_session.py b/tests/pytest/core/test_session.py index d45be4e51..d312a06b0 100644 --- a/tests/pytest/core/test_session.py +++ b/tests/pytest/core/test_session.py @@ -241,10 +241,12 @@ def test_reset_enrollment(app_request): @pytest.mark.django_db def test_reset_oauth(app_request): app_request.session[session._OAUTH_TOKEN] = "oauthtoken456" + app_request.session[session._OAUTH_CLAIM] = "claim" session.reset(app_request) assert session.oauth_token(app_request) is None + assert session.oauth_claim(app_request) is None @pytest.mark.django_db diff --git a/tests/pytest/core/test_views.py b/tests/pytest/core/test_views.py index f1c73a518..88a772db8 100644 --- a/tests/pytest/core/test_views.py +++ b/tests/pytest/core/test_views.py @@ -4,7 +4,7 @@ from benefits.core.models import TransitAgency import benefits.core.session -from benefits.core.views import ROUTE_INDEX, ROUTE_HELP, TEMPLATE_AGENCY, bad_request, csrf_failure +from benefits.core.views import ROUTE_ELIGIBILITY, ROUTE_INDEX, ROUTE_HELP, TEMPLATE_AGENCY, bad_request, csrf_failure ROUTE_AGENCY = "core:agency_index" @@ -36,43 +36,45 @@ def test_index_multiple_agencies(client): @pytest.mark.django_db def test_index_single_agency(mocker, client, session_reset_spy): - agencies = TransitAgency.all_active()[:1] - mocker.patch("benefits.core.models.TransitAgency.all_active", return_value=agencies) + # all_active set to ABC + agency = TransitAgency.by_slug("abc") + mocker.patch("benefits.core.models.TransitAgency.all_active", return_value=[agency]) path = reverse(ROUTE_INDEX) response = client.get(path) - assert response.status_code == 302 - assert response.url == agencies[0].index_url session_reset_spy.assert_called_once() + assert response.status_code == 302 + assert response.url == agency.index_url @pytest.mark.django_db -def test_index_single_agency_single_verifier(mocker, client): +def test_agency_index_single_verifier(mocker, client, session_reset_spy, mocked_session_update): # Agency set to DEFTl, which only has 1 verifier - agencies = TransitAgency.all_active()[1:] - mocker.patch("benefits.core.models.TransitAgency.all_active", return_value=agencies) + agency = TransitAgency.by_slug("deftl") + mocker.patch("benefits.core.models.TransitAgency.all_active", return_value=[agency]) - path = reverse(ROUTE_INDEX) - response = client.get(path, follow=True) + response = client.get(agency.index_url) - # Setting follow to True allows the test to go thorugh redirects and returns the redirect_chain attr - # https://docs.djangoproject.com/en/3.2/topics/testing/tools/#making-requests - assert response.redirect_chain[0] == ("/deftl", 302) - assert response.redirect_chain[1] == ("/eligibility/", 302) - assert response.redirect_chain[-1] == ("/eligibility/start", 302) + session_reset_spy.assert_called_once() + mocked_session_update.assert_called_once() + + assert response.status_code == 302 + assert response.url == reverse(ROUTE_ELIGIBILITY) @pytest.mark.django_db -def test_agency_index_multiple_verifier(first_agency, client, session_reset_spy): +def test_agency_index_multiple_verifier(mocker, client, session_reset_spy, mocked_session_update): # Agency set to ABC, which has 2 verifiers - path = reverse(ROUTE_AGENCY, kwargs={"agency": first_agency.slug}) + agency = TransitAgency.by_slug("abc") + mocker.patch("benefits.core.models.TransitAgency.all_active", return_value=[agency]) - response = client.get(path) + response = client.get(agency.index_url) + session_reset_spy.assert_called_once() + mocked_session_update.assert_called_once() assert response.status_code == 200 assert response.template_name == TEMPLATE_AGENCY - session_reset_spy.assert_called_once() @pytest.mark.django_db diff --git a/tests/pytest/eligibility/test_api.py b/tests/pytest/eligibility/test_api.py deleted file mode 100644 index 8fb5a31ac..000000000 --- a/tests/pytest/eligibility/test_api.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest - -from benefits.eligibility.api import get_verified_types -from benefits.eligibility.forms import EligibilityVerificationForm - - -@pytest.fixture -def form(mocker): - return mocker.Mock(spec=EligibilityVerificationForm, cleaned_data={"name": "Garcia", "sub": "A1234567"}) - - -@pytest.fixture -def mock_api_client_verify(mocker): - return mocker.patch("benefits.eligibility.api.Client.verify") - - -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_eligibility_request_session") -def test_get_verified_types_error(mocker, app_request, mock_api_client_verify, form): - api_errors = {"name": "Name error"} - api_response = mocker.Mock(error=api_errors) - mock_api_client_verify.return_value = api_response - - response = get_verified_types(app_request, form) - - assert response is None - form.add_api_errors.assert_called_once_with(api_errors) - - -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_eligibility_request_session") -def test_get_verified_types_verified_types(mocker, app_request, mock_api_client_verify, form): - verified_types = ["type1", "type2"] - api_response = mocker.Mock(eligibility=verified_types, error=None) - mock_api_client_verify.return_value = api_response - - response = get_verified_types(app_request, form) - - assert response == verified_types - form.add_api_errors.assert_not_called() - - -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_eligibility_request_session") -def test_get_verified_types_no_verified_types(mocker, app_request, mock_api_client_verify, form): - verified_types = [] - api_response = mocker.Mock(eligibility=verified_types, error=None) - mock_api_client_verify.return_value = api_response - - response = get_verified_types(app_request, form) - - assert response == verified_types - form.add_api_errors.assert_not_called() diff --git a/tests/pytest/eligibility/test_verify.py b/tests/pytest/eligibility/test_verify.py new file mode 100644 index 000000000..6486565d6 --- /dev/null +++ b/tests/pytest/eligibility/test_verify.py @@ -0,0 +1,86 @@ +import pytest + +from benefits.eligibility.forms import EligibilityVerificationForm +from benefits.eligibility.verify import eligibility_from_api, eligibility_from_oauth + + +@pytest.fixture +def form(mocker): + return mocker.Mock(spec=EligibilityVerificationForm, cleaned_data={"name": "Garcia", "sub": "A1234567"}) + + +@pytest.fixture +def mock_api_client_verify(mocker): + return mocker.patch("benefits.eligibility.verify.Client.verify") + + +@pytest.mark.django_db +def test_eligibility_from_api_error(mocker, first_agency, first_verifier, mock_api_client_verify, form): + api_errors = {"name": "Name error"} + api_response = mocker.Mock(error=api_errors) + mock_api_client_verify.return_value = api_response + + response = eligibility_from_api(first_verifier, form, first_agency) + + assert response is None + form.add_api_errors.assert_called_once_with(api_errors) + + +@pytest.mark.django_db +def test_eligibility_from_api_verified_types(mocker, first_agency, first_verifier, mock_api_client_verify, form): + verified_types = ["type1", "type2"] + api_response = mocker.Mock(eligibility=verified_types, error=None) + mock_api_client_verify.return_value = api_response + + response = eligibility_from_api(first_verifier, form, first_agency) + + assert response == verified_types + form.add_api_errors.assert_not_called() + + +@pytest.mark.django_db +def test_eligibility_from_api_no_verified_types(mocker, first_agency, first_verifier, mock_api_client_verify, form): + verified_types = [] + api_response = mocker.Mock(eligibility=verified_types, error=None) + mock_api_client_verify.return_value = api_response + + response = eligibility_from_api(first_verifier, form, first_agency) + + assert response == verified_types + form.add_api_errors.assert_not_called() + + +@pytest.mark.django_db +def test_eligibility_from_oauth_auth_not_required(mocked_session_verifier_auth_not_required, first_agency): + # mocked_session_verifier_auth_not_required is Mocked version of the session.verifier() function + # call it (with a None request) to return a verifier object + verifier = mocked_session_verifier_auth_not_required(None) + + types = eligibility_from_oauth(verifier, "claim", first_agency) + + assert types == [] + + +@pytest.mark.django_db +def test_eligibility_from_oauth_auth_claim_mismatch(mocked_session_verifier_auth_required, first_agency): + # mocked_session_verifier_auth_required is Mocked version of the session.verifier() function + # call it (with a None request) to return a verifier object + verifier = mocked_session_verifier_auth_required(None) + verifier.auth_claim = "claim" + + types = eligibility_from_oauth(verifier, "some_other_claim", first_agency) + + assert types == [] + + +@pytest.mark.django_db +def test_eligibility_from_oauth_auth_claim_match(mocked_session_verifier_auth_required, first_eligibility, first_agency): + # mocked_session_verifier_auth_required is Mocked version of the session.verifier() function + # call it (with a None request) to return a verifier object + verifier = mocked_session_verifier_auth_required.return_value + verifier.auth_provider.claim = "claim" + verifier.eligibility_type = first_eligibility + + types = eligibility_from_oauth(verifier, "claim", first_agency) + + assert types == [first_eligibility.name] diff --git a/tests/pytest/eligibility/test_views.py b/tests/pytest/eligibility/test_views.py index e38999e5e..2de197e7e 100644 --- a/tests/pytest/eligibility/test_views.py +++ b/tests/pytest/eligibility/test_views.py @@ -120,17 +120,6 @@ def test_index_post_valid_form(client, first_verifier, mocked_session_update): assert mocked_session_update.call_args.kwargs["verifier"] == first_verifier -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_verifier_form", "mocked_session_verifier_auth_required") -def test_start_verifier_auth_required_no_oauth_client(mocker, client): - mock_settings = mocker.patch("benefits.eligibility.views.settings") - mock_settings.OAUTH_CLIENT_NAME = None - - path = reverse(ROUTE_START) - with pytest.raises(Exception, match=r"OAUTH_CLIENT_NAME"): - client.get(path) - - @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_verifier_form", "mocked_session_verifier_auth_required") def test_start_verifier_auth_required_logged_in(mocker, client): @@ -182,8 +171,11 @@ def test_start_without_verifier(client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_eligibility_auth_request") -def test_confirm_get_unverified(client): +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required") +def test_confirm_get_unverified(mocker, client): + mock_page = mocker.patch("benefits.eligibility.views.viewmodels.Page") + mock_page.return_value.context_dict.return_value = {"page": {"title": "page title", "content_title": "page content title"}} + path = reverse(ROUTE_CONFIRM) response = client.get(path) @@ -192,7 +184,7 @@ def test_confirm_get_unverified(client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_eligibility_auth_request", "mocked_session_eligibility") +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_eligibility", "mocked_session_verifier_auth_not_required") def test_confirm_get_verified(client, mocked_session_update): path = reverse(ROUTE_CONFIRM) response = client.get(path) @@ -202,6 +194,33 @@ def test_confirm_get_verified(client, mocked_session_update): mocked_session_update.assert_called_once() +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_eligibility_auth_request") +def test_confirm_get_oauth_verified(mocker, client, first_eligibility, mocked_session_update, mocked_analytics_module): + mocker.patch("benefits.eligibility.verify.eligibility_from_oauth", return_value=[first_eligibility]) + + path = reverse(ROUTE_CONFIRM) + response = client.get(path) + + mocked_session_update.assert_called_once() + mocked_analytics_module.returned_success.assert_called_once() + assert response.status_code == 302 + assert response.url == reverse(ROUTE_ENROLLMENT) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_eligibility_auth_request", "mocked_session_update") +def test_confirm_get_oauth_unverified(mocker, client, mocked_analytics_module): + mocker.patch("benefits.eligibility.verify.eligibility_from_oauth", return_value=[]) + + path = reverse(ROUTE_CONFIRM) + response = client.get(path) + + mocked_analytics_module.returned_fail.assert_called_once() + assert response.status_code == 200 + assert response.template_name == TEMPLATE_UNVERIFIED + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_eligibility_auth_request") def test_confirm_post_invalid_form(client, invalid_form_data, mocked_analytics_module): @@ -230,7 +249,7 @@ def test_confirm_post_recaptcha_fail(mocker, client, invalid_form_data): @pytest.mark.django_db @pytest.mark.usefixtures("mocked_eligibility_auth_request") def test_confirm_post_valid_form_eligibility_error(mocker, client, form_data, mocked_analytics_module): - mocker.patch("benefits.eligibility.views.api.get_verified_types", return_value=None) + mocker.patch("benefits.eligibility.verify.eligibility_from_api", return_value=None) path = reverse(ROUTE_CONFIRM) response = client.post(path, form_data) @@ -243,7 +262,7 @@ def test_confirm_post_valid_form_eligibility_error(mocker, client, form_data, mo @pytest.mark.django_db @pytest.mark.usefixtures("mocked_eligibility_auth_request") def test_confirm_post_valid_form_eligibility_unverified(mocker, client, form_data, mocked_analytics_module): - mocker.patch("benefits.eligibility.views.api.get_verified_types", return_value=[]) + mocker.patch("benefits.eligibility.verify.eligibility_from_api", return_value=[]) path = reverse(ROUTE_CONFIRM) response = client.post(path, form_data) @@ -258,10 +277,8 @@ def test_confirm_post_valid_form_eligibility_unverified(mocker, client, form_dat def test_confirm_post_valid_form_eligibility_verified( mocker, client, form_data, mocked_session_eligibility, mocked_session_update, mocked_analytics_module ): - # mocked_session_eligibility is a fixture that mocks benefits.core.session.eligibility(request) - # call it here, passing a None request, to get the return value from the mock - eligibility = mocked_session_eligibility(None) - mocker.patch("benefits.eligibility.views.api.get_verified_types", return_value=[eligibility]) + eligibility = mocked_session_eligibility.return_value + mocker.patch("benefits.eligibility.verify.eligibility_from_api", return_value=[eligibility]) path = reverse(ROUTE_CONFIRM) response = client.post(path, form_data) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 09aa1ce20..582f526b1 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -169,17 +169,6 @@ def test_success_no_verifier(client): client.get(path) -@pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") -def test_success_no_oauth_client(mocker, client): - mock_settings = mocker.patch("benefits.enrollment.views.settings") - mock_settings.OAUTH_CLIENT_NAME = None - - path = reverse(ROUTE_SUCCESS) - with pytest.raises(Exception, match=r"OAUTH_CLIENT_NAME"): - client.get(path) - - @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_verifier_auth_required") def test_success_authentication_logged_in(mocker, client): diff --git a/tests/pytest/oauth/conftest.py b/tests/pytest/oauth/conftest.py index 4190f0dd4..ddc6d7be3 100644 --- a/tests/pytest/oauth/conftest.py +++ b/tests/pytest/oauth/conftest.py @@ -1,10 +1,24 @@ +from authlib.integrations.django_client import OAuth from authlib.integrations.django_client.apps import DjangoOAuth2App import pytest +@pytest.fixture +def mocked_oauth_registry(mocker): + return mocker.Mock(spec=OAuth) + + +@pytest.fixture +def mocked_oauth_client_registry(mocker, mocked_oauth_registry): + return mocker.patch("benefits.oauth.client.oauth", mocked_oauth_registry) + + @pytest.fixture def mocked_oauth_client(mocker): - mock_client = mocker.Mock(spec=DjangoOAuth2App) - mocker.patch("benefits.oauth.client.instance", return_value=mock_client) - return mock_client + return mocker.Mock(spec=DjangoOAuth2App) + + +@pytest.fixture +def mocked_oauth_create_client(mocker, mocked_oauth_client): + return mocker.patch("benefits.oauth.client.oauth.create_client", return_value=mocked_oauth_client) diff --git a/tests/pytest/oauth/test_app.py b/tests/pytest/oauth/test_app.py new file mode 100644 index 000000000..93b1aaeb6 --- /dev/null +++ b/tests/pytest/oauth/test_app.py @@ -0,0 +1,23 @@ +import benefits +from benefits.oauth.apps import OAuthAppConfig + + +def test_ready_registers_clients(mocker): + mock_registry = mocker.patch("benefits.oauth.client.oauth") + mock_register_providers = mocker.patch("benefits.oauth.client.register_providers") + + app = OAuthAppConfig("oauth", benefits) + app.ready() + + mock_register_providers.assert_called_once_with(mock_registry) + + +def test_ready_register_exception(mocker): + mocker.patch("benefits.oauth.client.oauth") + mocker.patch("benefits.oauth.client.register_providers", side_effect=Exception) + + app = OAuthAppConfig("oauth", benefits) + app.ready() + + # we expect no Exception to be raised + assert app diff --git a/tests/pytest/oauth/test_client.py b/tests/pytest/oauth/test_client.py index 9df5167cc..309a35746 100644 --- a/tests/pytest/oauth/test_client.py +++ b/tests/pytest/oauth/test_client.py @@ -1,29 +1,57 @@ import pytest -import benefits.oauth.client as client +from benefits.core.models import AuthProvider +from benefits.oauth.client import _client_kwargs, _server_metadata_url, register_providers -@pytest.fixture -def no_oauth_client_name(mocker): - return mocker.patch("benefits.oauth.client.settings.OAUTH_CLIENT_NAME", None) +def test_client_kwargs(): + kwargs = _client_kwargs() + assert kwargs["code_challenge_method"] == "S256" + assert "openid" in kwargs["scope"] -@pytest.mark.django_db -@pytest.mark.usefixtures("no_oauth_client_name") -def test_instance_no_oauth_client_name(): - with pytest.raises(Exception, match=r"OAUTH_CLIENT_NAME"): - client.instance() + +def test_client_kwargs_scope(): + kwargs = _client_kwargs("scope1") + + assert kwargs["code_challenge_method"] == "S256" + assert "openid" in kwargs["scope"] + assert "scope1" in kwargs["scope"] + + +def test_server_metadata_url(): + url = _server_metadata_url("https://example.com") + + assert url.startswith("https://example.com") + assert url.endswith("openid-configuration") @pytest.mark.django_db -def test_instance_oauth_client_name(): - assert not client._OAUTH_CLIENT +def test_register_providers(mocker, mocked_oauth_registry): + mock_providers = [] + + for i in range(3): + p = mocker.Mock(spec=AuthProvider) + p.client_name = f"client_name_{i}" + p.client_id = f"client_id_{i}" + mock_providers.append(p) + + mocked_client_provider = mocker.patch("benefits.oauth.client.AuthProvider") + mocked_client_provider.objects.all.return_value = mock_providers + + mocker.patch("benefits.oauth.client._client_kwargs", return_value={"client": "kwargs"}) + mocker.patch("benefits.oauth.client._server_metadata_url", return_value="https://metadata.url") - oauth_client = client.instance() + register_providers(mocked_oauth_registry) - assert oauth_client - assert client._OAUTH_CLIENT is oauth_client + mocked_client_provider.objects.all.assert_called_once() - oauth_client2 = client.instance() + for provider in mock_providers: + i = mock_providers.index(provider) - assert oauth_client is oauth_client2 + mocked_oauth_registry.register.assert_any_call( + f"client_name_{i}", + client_id=f"client_id_{i}", + server_metadata_url="https://metadata.url", + client_kwargs={"client": "kwargs"}, + ) diff --git a/tests/pytest/oauth/test_redirects.py b/tests/pytest/oauth/test_redirects.py index f4c3443a8..227e13a0c 100644 --- a/tests/pytest/oauth/test_redirects.py +++ b/tests/pytest/oauth/test_redirects.py @@ -4,7 +4,7 @@ def test_deauthorize_redirect(mocked_oauth_client): mocked_oauth_client.load_server_metadata.return_value = {"end_session_endpoint": "https://server/endsession"} - result = deauthorize_redirect("token", "https://localhost/redirect_uri") + result = deauthorize_redirect(mocked_oauth_client, "token", "https://localhost/redirect_uri") mocked_oauth_client.load_server_metadata.assert_called() assert result.status_code == 302 diff --git a/tests/pytest/oauth/test_views.py b/tests/pytest/oauth/test_views.py index ee4db0ad2..e035dcae3 100644 --- a/tests/pytest/oauth/test_views.py +++ b/tests/pytest/oauth/test_views.py @@ -6,7 +6,7 @@ from benefits.core import session from benefits.core.views import ROUTE_INDEX -from benefits.oauth.views import ROUTE_START, ROUTE_CONFIRM, login, authorize, logout, post_logout +from benefits.oauth.views import ROUTE_START, ROUTE_CONFIRM, ROUTE_UNVERIFIED, login, authorize, cancel, logout, post_logout import benefits.oauth.views @@ -15,19 +15,44 @@ def mocked_analytics_module(mocked_analytics_module): return mocked_analytics_module(benefits.oauth.views) -def test_login(mocked_oauth_client, mocked_analytics_module, app_request): +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_login_no_oauth_client(mocked_oauth_create_client, app_request): + mocked_oauth_create_client.return_value = None + + with pytest.raises(Exception, match=r"oauth_client"): + login(app_request) + + +@pytest.mark.django_db +def test_login(mocked_oauth_create_client, mocked_session_verifier_auth_required, mocked_analytics_module, app_request): assert not session.logged_in(app_request) + mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_redirect.return_value = HttpResponse("authorize redirect") login(app_request) + mocked_verifier = mocked_session_verifier_auth_required.return_value + mocked_oauth_create_client.assert_called_once_with(mocked_verifier.auth_provider.client_name) mocked_oauth_client.authorize_redirect.assert_called_with(app_request, "https://testserver/oauth/authorize") mocked_analytics_module.started_sign_in.assert_called_once() assert not session.logged_in(app_request) -def test_authorize_fail(mocked_oauth_client, app_request): +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_authorize_no_oauth_client(mocked_oauth_create_client, app_request): + mocked_oauth_create_client.return_value = None + + with pytest.raises(Exception, match=r"oauth_client"): + authorize(app_request) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_authorize_fail(mocked_oauth_create_client, app_request): + mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = None assert not session.logged_in(app_request) @@ -40,7 +65,10 @@ def test_authorize_fail(mocked_oauth_client, app_request): assert result.url == reverse(ROUTE_START) -def test_authorize_success(mocked_oauth_client, mocked_analytics_module, app_request): +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_authorize_success(mocked_oauth_create_client, mocked_analytics_module, app_request): + mocked_oauth_client = mocked_oauth_create_client.return_value mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token"} result = authorize(app_request) @@ -53,12 +81,87 @@ def test_authorize_success(mocked_oauth_client, mocked_analytics_module, app_req assert result.url == reverse(ROUTE_CONFIRM) -def test_logout(mocker, mocked_analytics_module, app_request): - # logout internally calls _deauthorize_redirect +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_analytics_module") +@pytest.mark.parametrize("flag", ["true", "True", "tRuE"]) +def test_authorize_success_with_claim_true( + mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request, flag +): + verifier = mocked_session_verifier_auth_required.return_value + verifier.auth_provider.claim = "claim" + mocked_oauth_client = mocked_oauth_create_client.return_value + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": flag}} + + result = authorize(app_request) + + mocked_oauth_client.authorize_access_token.assert_called_with(app_request) + assert session.oauth_claim(app_request) == "claim" + assert result.status_code == 302 + assert result.url == reverse(ROUTE_CONFIRM) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_analytics_module") +@pytest.mark.parametrize("flag", ["false", "False", "fAlSe"]) +def test_authorize_success_with_claim_false( + mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request, flag +): + verifier = mocked_session_verifier_auth_required.return_value + verifier.auth_provider.claim = "claim" + mocked_oauth_client = mocked_oauth_create_client.return_value + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": flag}} + + result = authorize(app_request) + + mocked_oauth_client.authorize_access_token.assert_called_with(app_request) + assert session.oauth_claim(app_request) is None + assert result.status_code == 302 + assert result.url == reverse(ROUTE_CONFIRM) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_analytics_module") +def test_authorize_success_without_claim(mocked_session_verifier_auth_required, mocked_oauth_create_client, app_request): + verifier = mocked_session_verifier_auth_required.return_value + verifier.auth_provider.claim = "" + mocked_oauth_client = mocked_oauth_create_client.return_value + mocked_oauth_client.authorize_access_token.return_value = {"id_token": "token", "userinfo": {"claim": "True"}} + + result = authorize(app_request) + + mocked_oauth_client.authorize_access_token.assert_called_with(app_request) + assert session.oauth_claim(app_request) is None + assert result.status_code == 302 + assert result.url == reverse(ROUTE_CONFIRM) + + +def test_cancel(app_request): + unverified_route = reverse(ROUTE_UNVERIFIED) + + result = cancel(app_request) + + assert result.status_code == 302 + assert result.url == unverified_route + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_logout_no_oauth_client(mocked_oauth_create_client, app_request): + mocked_oauth_create_client.return_value = None + + with pytest.raises(Exception, match=r"oauth_client"): + logout(app_request) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_verifier_auth_required") +def test_logout(mocker, mocked_oauth_create_client, mocked_analytics_module, app_request): + # logout internally calls deauthorize_redirect # this mocks that function and a success response # and returns a spy object we can use to validate calls message = "logout successful" - spy = mocker.patch("benefits.oauth.views.redirects.deauthorize_redirect", return_value=HttpResponse(message)) + mocked_oauth_client = mocked_oauth_create_client.return_value + mocked_redirect = mocker.patch("benefits.oauth.views.redirects.deauthorize_redirect", return_value=HttpResponse(message)) token = "token" session.update(app_request, oauth_token=token) @@ -66,7 +169,7 @@ def test_logout(mocker, mocked_analytics_module, app_request): result = logout(app_request) - spy.assert_called_with(token, "https://testserver/oauth/post_logout") + mocked_redirect.assert_called_with(mocked_oauth_client, token, "https://testserver/oauth/post_logout") mocked_analytics_module.started_sign_out.assert_called_once() assert result.status_code == 200 assert message in str(result.content) diff --git a/tests/pytest/test_logging.py b/tests/pytest/test_logging.py new file mode 100644 index 000000000..2fdcb06d9 --- /dev/null +++ b/tests/pytest/test_logging.py @@ -0,0 +1,13 @@ +from benefits.logging import get_config + + +def test_get_config_no_azure(): + config = get_config() + assert "azure" not in config["handlers"] + assert "azure" not in config["loggers"] + + +def test_get_config_with_azure(): + config = get_config(enable_azure=True) + assert "azure" in config["handlers"] + assert "azure" in config["loggers"]