diff --git a/app/.well-known/security.txt b/app/.well-known/security.txt new file mode 100644 index 000000000..f95ac3c33 --- /dev/null +++ b/app/.well-known/security.txt @@ -0,0 +1,2 @@ +Contact: mailto:notify-support@gsa.gov +Expires: 2035-10-15T23:59:59Z diff --git a/app/assets/javascripts/activityChart.js b/app/assets/javascripts/activityChart.js index 19d8bcc49..62c1e6e3e 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -33,30 +33,43 @@ .attr('id', 'tooltip') .style('display', 'none'); } - // Create legend + + // Calculate total messages + const totalMessages = d3.sum(deliveredData) + d3.sum(failedData); + + // Create legend only if there are messages const legendContainer = d3.select('.chart-legend'); legendContainer.selectAll('*').remove(); // Clear any existing legend - const legendData = [ - { label: 'Delivered', color: COLORS.delivered }, - { label: 'Failed', color: COLORS.failed } - ]; + if (totalMessages > 0) { + // Show legend if there are messages + const legendData = [ + { label: 'Delivered', color: COLORS.delivered }, + { label: 'Failed', color: COLORS.failed } + ]; - const legendItem = legendContainer.selectAll('.legend-item') - .data(legendData) - .enter() - .append('div') - .attr('class', 'legend-item'); + const legendItem = legendContainer.selectAll('.legend-item') + .data(legendData) + .enter() + .append('div') + .attr('class', 'legend-item'); - legendItem.append('div') - .attr('class', 'legend-rect') - .style('background-color', d => d.color) - .style('display', 'inline-block') - .style('margin-right', '5px'); + legendItem.append('div') + .attr('class', 'legend-rect') + .style('background-color', d => d.color) + .style('display', 'inline-block') + .style('margin-right', '5px'); - legendItem.append('span') - .attr('class', 'legend-label') - .text(d => d.label); + legendItem.append('span') + .attr('class', 'legend-label') + .text(d => d.label); + + // Ensure the legend is shown + legendContainer.style('display', 'flex'); + } else { + // Hide the legend if there are no messages + legendContainer.style('display', 'none'); + } const x = d3.scaleBand() .domain(labels) diff --git a/app/main/views/index.py b/app/main/views/index.py index 7745e053b..79ff2474c 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -1,18 +1,11 @@ import os import secrets +from urllib.parse import unquote -from flask import ( - abort, - current_app, - redirect, - render_template, - request, - session, - url_for, -) +from flask import abort, current_app, redirect, render_template, request, url_for from flask_login import current_user -from app import status_api_client +from app import redis_client, status_api_client from app.formatters import apply_html_class, convert_markdown_template from app.main import main from app.main.views.pricing import CURRENT_SMS_RATE @@ -40,6 +33,7 @@ def check_guidance_feature(): def index(): if current_user and current_user.is_authenticated: return redirect(url_for("main.choose_account")) + token = generate_token( str(request.remote_addr), current_app.config["SECRET_KEY"], @@ -48,8 +42,12 @@ def index(): url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") # handle unit tests + current_app.logger.warning(f"############### {str(request.remote_addr)}") + nonce = secrets.token_urlsafe() - session["nonce"] = nonce + + redis_key = f"login-nonce-{unquote(nonce)}" + redis_client.set(redis_key, nonce) if url is not None: url = url.replace("NONCE", nonce) diff --git a/app/main/views/manage_users.py b/app/main/views/manage_users.py index 1b80c659e..b55341e08 100644 --- a/app/main/views/manage_users.py +++ b/app/main/views/manage_users.py @@ -77,6 +77,11 @@ def invite_user(service_id, user_id=None): form.login_authentication.data = "sms_auth" if form.validate_on_submit(): + if not form.permissions or len(form.permissions) == 0: + return render_template( + "views/user-has-no-permissions.html", + user_to_invite=user_to_invite, + ) email_address = form.email_address.data invited_user = InvitedUser.create( current_user.id, diff --git a/app/main/views/register.py b/app/main/views/register.py index 19187a47c..cc6850055 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -165,6 +165,7 @@ def set_up_your_profile(): if redis_client.get(f"invitedata-{state}") is None: access_token = sign_in._get_access_token(code, state) + debug_msg("Got the access token for login.gov") user_email, user_uuid = sign_in._get_user_email_and_uuid(access_token) debug_msg( diff --git a/app/main/views/security_policy.py b/app/main/views/security_policy.py index 35ffd359e..cb87cfc1c 100644 --- a/app/main/views/security_policy.py +++ b/app/main/views/security_policy.py @@ -1,4 +1,4 @@ -from flask import redirect +from flask import send_from_directory from app.main import main @@ -6,6 +6,4 @@ from app.main import main @main.route("/.well-known/security.txt", methods=["GET"]) @main.route("/security.txt", methods=["GET"]) def security_policy(): - # See GDS Way security policy which this implements - # https://gds-way.cloudapps.digital/standards/vulnerability-disclosure.html#vulnerability-disclosure-and-security-txt - return redirect("https://vdp.cabinetoffice.gov.uk/.well-known/security.txt") + return send_from_directory(".well-known", "security.txt") diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 6f8d83609..a326202f3 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -1,8 +1,8 @@ -import json import os import secrets import time import uuid +from urllib.parse import unquote import jwt import requests @@ -14,18 +14,17 @@ from flask import ( redirect, render_template, request, - session, url_for, ) from flask_login import current_user -from app import login_manager, user_api_client +from app import login_manager, redis_client, user_api_client from app.main import main from app.main.views.index import error from app.main.views.verify import activate_user from app.models.user import User from app.utils import hide_from_search_engines -from app.utils.login import is_safe_redirect_url +from app.utils.login import get_id_token, is_safe_redirect_url from app.utils.time import is_less_than_days_ago from app.utils.user import is_gov_user from notifications_utils.url_safe_token import generate_token @@ -43,7 +42,6 @@ def _reformat_keystring(orig): # pragma: no cover def _get_access_token(code, state): # pragma: no cover client_id = os.getenv("LOGIN_DOT_GOV_CLIENT_ID") access_token_url = os.getenv("LOGIN_DOT_GOV_ACCESS_TOKEN_URL") - certs_url = os.getenv("LOGIN_DOT_GOV_CERTS_URL") keystring = os.getenv("LOGIN_PEM") if " " in keystring: keystring = _reformat_keystring(keystring) @@ -66,32 +64,13 @@ def _get_access_token(code, state): # pragma: no cover response = requests.post(url, headers=headers) response_json = response.json() - try: - encoded_id_token = response_json["id_token"] - except KeyError as e: - current_app.logger.exception(f"Error when getting id token {response_json}") - raise KeyError(f"'access_token' {response.json()}") from e - - # Getting Login.gov signing keys for unpacking the id_token correctly. - jwks = requests.get(certs_url).json() - public_keys = { - jwk["kid"]: { - "key": jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)), - "algo": jwk["alg"], - } - for jwk in jwks["keys"] - } - kid = jwt.get_unverified_header(encoded_id_token)["kid"] - pub_key = public_keys[kid]["key"] - algo = public_keys[kid]["algo"] - id_token = jwt.decode( - encoded_id_token, pub_key, audience=client_id, algorithms=[algo] - ) - + id_token = get_id_token(response_json) nonce = id_token["nonce"] - saved_nonce = session.pop("nonce") - if nonce != saved_nonce: - current_app.logger.error(f"Nonce Error: {nonce} != {saved_nonce}") + redis_key = f"login-nonce-{unquote(nonce)}" + stored_nonce = redis_client.get(redis_key).decode("utf8") + + if nonce != stored_nonce: + current_app.logger.error(f"Nonce Error: {nonce} != {stored_nonce}") abort(403) try: @@ -232,7 +211,8 @@ def sign_in(): # pragma: no cover url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") nonce = secrets.token_urlsafe() - session["nonce"] = nonce + redis_key = f"-{unquote(nonce)}" + redis_client.set(redis_key, nonce) # handle unit tests if url is not None: diff --git a/app/notify_client/invite_api_client.py b/app/notify_client/invite_api_client.py index d410ceec5..711cc1f55 100644 --- a/app/notify_client/invite_api_client.py +++ b/app/notify_client/invite_api_client.py @@ -1,3 +1,7 @@ +import secrets +from urllib.parse import unquote + +from app import redis_client from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache from app.utils.user_permissions import ( all_ui_permissions, @@ -32,6 +36,13 @@ class InviteApiClient(NotifyAdminAPIClient): "folder_permissions": folder_permissions, } data = _attach_current_user(data) + + # make and store the nonce + nonce = secrets.token_urlsafe() + redis_key = f"login-nonce-{unquote(nonce)}" + redis_client.set(f"{redis_key}", nonce) # save the nonce to redis. + data["nonce"] = nonce # This is passed to api for the invite url. + resp = self.post(url=f"/service/{service_id}/invite", data=data) return resp["data"] diff --git a/app/templates/base.html b/app/templates/base.html index bcec20cb0..139270070 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -99,7 +99,7 @@ {% if current_user.is_authenticated %} {% block sessionUserWarning %} - +

diff --git a/app/templates/views/activity/all-activity.html b/app/templates/views/activity/all-activity.html index 1067bc07b..b925a7802 100644 --- a/app/templates/views/activity/all-activity.html +++ b/app/templates/views/activity/all-activity.html @@ -60,7 +60,7 @@

Sent jobs

- +
Table showing all sent jobs for this service
@@ -103,7 +103,10 @@ {{ job.created_by.name }} {% if job.time_left != "Data no longer available" %} - File Download Icon + + + Download report link + {% else %} N/A {% endif %} diff --git a/app/templates/views/dashboard/dashboard.html b/app/templates/views/dashboard/dashboard.html index 6c554d515..41cba8ff2 100644 --- a/app/templates/views/dashboard/dashboard.html +++ b/app/templates/views/dashboard/dashboard.html @@ -38,7 +38,7 @@
@@ -60,6 +60,7 @@