Merge pull request #877 from GSA/notify-admin-338

initial for login.gov integration
This commit is contained in:
Kenneth Kehl
2023-11-22 10:11:46 -08:00
committed by GitHub
12 changed files with 1183 additions and 406 deletions

View File

@@ -54,6 +54,7 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -67,6 +68,7 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
- name: Check for changes to egress config
id: changed-egress-config

View File

@@ -54,6 +54,8 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -67,6 +69,7 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
- name: Check for changes to egress config
id: changed-egress-config

View File

@@ -59,6 +59,7 @@ jobs:
ADMIN_CLIENT_SECRET: ${{ secrets.ADMIN_CLIENT_SECRET }}
NEW_RELIC_LICENSE_KEY: ${{ secrets.NEW_RELIC_LICENSE_KEY }}
NR_BROWSER_KEY: ${{ secrets.NR_BROWSER_KEY }}
LOGIN_PEM: ${{ secrets.LOGIN_PEM }}
with:
cf_username: ${{ secrets.CLOUDGOV_USERNAME }}
cf_password: ${{ secrets.CLOUDGOV_PASSWORD }}
@@ -72,6 +73,7 @@ jobs:
--var ADMIN_CLIENT_SECRET="$ADMIN_CLIENT_SECRET"
--var NEW_RELIC_LICENSE_KEY="$NEW_RELIC_LICENSE_KEY"
--var NR_BROWSER_KEY="$NR_BROWSER_KEY"
--var LOGIN_PEM="$LOGIN_PEM"
- name: Check for changes to egress config
id: changed-egress-config

View File

@@ -1,8 +1,13 @@
import os
import time
import uuid
import jwt
import requests
from flask import (
Markup,
abort,
current_app,
flash,
redirect,
render_template,
@@ -21,22 +26,84 @@ from app.utils import hide_from_search_engines
from app.utils.login import is_safe_redirect_url
def _get_access_token(code, state):
client_id = os.getenv("LOGIN_DOT_GOV_CLIENT_ID")
access_token_url = os.getenv("LOGIN_DOT_GOV_ACCESS_TOKEN_URL")
keystring = os.getenv("LOGIN_PEM")
payload = {
"iss": client_id,
"sub": client_id,
"aud": access_token_url,
"jti": str(uuid.uuid4()),
# JWT expiration time (10 minute maximum)
"exp": int(time.time()) + (10 * 60),
}
token = jwt.encode(payload, keystring, algorithm="RS256")
base_url = f"{access_token_url}?"
cli_assert = f"client_assertion={token}"
cli_assert_type = "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
code_param = f"code={code}"
url = f"{base_url}{cli_assert}&{cli_assert_type}&{code_param}&grant_type=authorization_code"
headers = {"Authorization": "Bearer %s" % token}
response = requests.post(url, headers=headers)
current_app.logger.info(f"GOT A RESPONSE {response.json()}")
access_token = response.json()["access_token"]
return access_token
def _get_user_email(access_token):
headers = {"Authorization": "Bearer %s" % access_token}
user_info_url = os.getenv("LOGIN_DOT_GOV_USER_INFO_URL")
user_attributes = requests.get(
user_info_url,
headers=headers,
)
user_email = user_attributes.json()["email"]
return user_email
@main.route("/sign-in", methods=(["GET", "POST"]))
@hide_from_search_engines
def sign_in():
# start login.gov
code = request.args.get("code")
state = request.args.get("state")
login_gov_error = request.args.get("error")
if code and state:
access_token = _get_access_token(code, state)
user_email = _get_user_email(access_token)
redirect_url = request.args.get("next")
# activate the user
user = user_api_client.get_user_by_email(user_email)
activate_user(user["id"])
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
elif login_gov_error:
current_app.logger.error(f"login.gov error: {login_gov_error}")
raise Exception(f"Could not login with login.gov {login_gov_error}")
# end login.gov
redirect_url = request.args.get("next")
if os.getenv("NOTIFY_E2E_TEST_EMAIL"):
current_app.logger.warning("E2E TESTS ARE ENABLED.")
current_app.logger.warning(
"If you are getting a 404 on signin, comment out E2E vars in .env file!"
)
user = user_api_client.get_user_by_email(os.getenv("NOTIFY_E2E_TEST_EMAIL"))
activate_user(user["id"])
return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url))
current_app.logger.info(f"current user is {current_user}")
if current_user and current_user.is_authenticated:
if redirect_url and is_safe_redirect_url(redirect_url):
return redirect(redirect_url)
return redirect(url_for("main.show_accounts_or_dashboard"))
form = LoginForm()
current_app.logger.info("Got the login form")
password_reset_url = url_for(".forgot_password", next=request.args.get("next"))
if form.validate_on_submit():
@@ -84,11 +151,14 @@ def sign_in():
)
other_device = current_user.logged_in_elsewhere()
notify_env = os.getenv("NOTIFY_ENVIRONMENT")
current_app.logger.info("should render the sign in template")
return render_template(
"views/signin.html",
form=form,
again=bool(redirect_url),
other_device=other_device,
notify_env_is_dev=bool(notify_env == "development"),
password_reset_url=password_reset_url,
)

View File

@@ -1,12 +1,39 @@
from flask import redirect, url_for
import os
import requests
from flask import current_app, redirect, url_for
from flask_login import current_user
from app.main import main
# ask login.gov if we really need manual logout and what's up with one hour sessions
# ask login.gov how they recommend approaching dev environment
# ask Tim Donaworth the same for #2
@main.route("/sign-out", methods=(["GET"]))
def _sign_out_at_login_dot_gov():
base_url = os.getenv("LOGIN_DOT_GOV_BASE_LOGOUT_URL")
client_id = f"client_id={os.getenv('LOGIN_DOT_GOV_CLIENT_ID')}"
post_logout_redirect_uri = (
f"post_logout_redirect_uri={os.getenv('LOGIN_DOT_GOV_SIGNOUT_REDIRECT')}"
)
url = f"{base_url}{client_id}&{post_logout_redirect_uri}"
current_app.logger.info(f"url={url}")
response = requests.post(url)
# response = requests.post(url)
current_app.logger.info(f"login.gov response: {response.text}")
@main.route("/sign-out", methods=(["GET", "POST"]))
def sign_out():
# An AnonymousUser does not have an id
current_app.logger.info("HIT THE REGULAR SIGN OUT")
if current_user.is_authenticated:
# TODO This doesn't work yet, due to problems above.
current_user.sign_out()
return redirect(os.getenv("LOGIN_DOT_GOV_LOGOUT_URL"))
return redirect(url_for("main.index"))

View File

@@ -39,6 +39,7 @@ class UserApiClient(NotifyAdminAPIClient):
return self.get("/user/{}".format(user_id))
def get_user_by_email(self, email_address):
current_app.logger.info(f"Going to get user by email {email_address}")
user_data = self.post("/user/email", data={"email": email_address})
return user_data["data"]

View File

@@ -28,11 +28,12 @@
{% endif %}
{% else %}
<h1 class="font-body-2xl margin-bottom-3">Sign in</h1>
<!-- Removing temporarily for pilot -->
<!-- <p>
If you do not have an account, you can
<a class="usa-link" href="{{ url_for('.register') }}">create one now</a>.
</p> -->
{% if notify_env_is_dev %}
<p>
Test login.gov authentication:
<a class="usa-link" href="https://idp.int.identitysandbox.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov&nonce=01234567890123456789012345&prompt=select_account&redirect_uri=http://localhost:6012/sign-in&response_type=code&scope=openid+email&state=abcdefghijklmnopabcdefghijklmnop">Login.gov</a>.
</p>
{% endif %}
{% endif %}
{% call form_wrapper(autocomplete=True) %}

23
docs/login_dot_gov.md Normal file
View File

@@ -0,0 +1,23 @@
# Integrating with login.gov
How to integrate with the login.gov sandbox: https://dashboard.int.identitysandbox.gov
1. Create a team and a user over in the login.gov sandbox.
2. Create a test app:
a. you will need to create a unique client id that looks like: urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov
b. Select OpenIdConnect and private key JWT
c. select authentication only
d. select MFA required + remember device 30 days only (AAL1)
e. set redirect urls like: http://localhost:6012/sign-in
3. generate a cert: openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private.pem -out public.crt
4. Upload the public.crt to your app in the sandbox
5. put the private.pem contents and public.crt contents in github secrets (?)
## Open Issues
1. The logout functionality is not working. The URL in sign_out.py does work by itself, but for some reason a
requests.post(url) fails.

1420
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@ pytz = "==2023.3.post1"
rtreelib = "==0.2.0"
werkzeug = "^3.0.1"
wtforms = "~=3.1"
poetry-plugin-dotenv = "^0.5.3"
[tool.poetry.group.dev.dependencies]
@@ -64,4 +65,4 @@ vulture = "^2.10"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
build-backend = "poetry.core.masonry.api"

View File

@@ -21,6 +21,7 @@ NOTIFY_E2E_TEST_EMAIL=fake.user@example.com
NOTIFY_E2E_TEST_PASSWORD="don't write secrets to the sample file"
NOTIFY_E2E_AUTH_STATE_PATH=playwright/.auth/
#############################################################
# Local Docker setup
@@ -41,3 +42,11 @@ NR_TRUST_KEY=562946
NR_AGENT_ID=1134289521
NR_APP_ID=1013682065
NR_BROWSER_KEY="don't write secrets to the sample file"
# Login.gov
LOGIN_DOT_GOV_CLIENT_ID="urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov"
LOGIN_DOT_GOV_USER_INFO_URL="https://idp.int.identitysandbox.gov/api/openid_connect/userinfo"
LOGIN_DOT_GOV_ACCESS_TOKEN_URL="https://idp.int.identitysandbox.gov/api/openid_connect/token"
LOGIN_DOT_GOV_LOGOUT_URL="https://idp.int.identitysandbox.gov/openid_connect/logout?client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov&post_logout_redirect_uri=http://localhost:6012/sign-out"
LOGIN_DOT_GOV_BASE_LOGOUT_URL="https://idp.int.identitysandbox.gov/openid_connect/logout?"
LOGIN_DOT_GOV_SIGNOUT_REDIRECT="http://localhost:6012/sign-out"

View File

@@ -1,16 +1,17 @@
from flask import url_for
from tests.conftest import SERVICE_ONE_ID
def test_render_sign_out_redirects_to_sign_in(client_request):
# TODO with the change to using login.gov, we no longer redirect directly to the sign in page.
# Instead we redirect to login.gov which redirects us to the sign in page. However, the
# test for the expected redirect being "/" is buried in conftest and looks fragile.
# After we move to login.gov officially and get rid of other forms of signing it, it should
# be refactored.
with client_request.session_transaction() as session:
assert session
client_request.get(
"main.sign_out",
_expected_redirect=url_for(
"main.index",
),
_expected_status=302,
)
with client_request.session_transaction() as session:
assert not session
@@ -42,9 +43,6 @@ def test_sign_out_user(
client_request.get(
"main.sign_out",
_expected_status=302,
_expected_redirect=url_for(
"main.index",
),
)
with client_request.session_transaction() as session:
assert session.get("user_id") is None