mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-07-13 17:59:45 -04:00
Merge pull request #877 from GSA/notify-admin-338
initial for login.gov integration
This commit is contained in:
2
.github/workflows/deploy-demo.yml
vendored
2
.github/workflows/deploy-demo.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/deploy-prod.yml
vendored
3
.github/workflows/deploy-prod.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
23
docs/login_dot_gov.md
Normal 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
1420
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user