-
Notifications
You must be signed in to change notification settings - Fork 19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Issue #2810] Connect all the components of the /users/token endpoint together #3004
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,12 @@ | ||
import logging | ||
|
||
import src.adapters.db as db | ||
import src.adapters.db.flask_db as flask_db | ||
from src.api import response | ||
from src.api.route_utils import raise_flask_error | ||
from src.api.users import user_schemas | ||
from src.api.users.user_blueprint import user_blueprint | ||
from src.auth.api_key_auth import api_key_auth | ||
from src.services.users.login_gov_token_handler import process_login_gov_token | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
@@ -15,22 +17,13 @@ | |
) | ||
@user_blueprint.output(user_schemas.UserTokenResponseSchema) | ||
@user_blueprint.auth_required(api_key_auth) | ||
def user_token(x_oauth_login_gov: dict) -> response.ApiResponse: | ||
@flask_db.with_db_session() | ||
def user_token(db_session: db.Session, x_oauth_login_gov: dict) -> response.ApiResponse: | ||
logger.info("POST /v1/users/token") | ||
|
||
if x_oauth_login_gov: | ||
data = { | ||
"token": "the token goes here!", | ||
"user": { | ||
"user_id": "abc-...", | ||
"email": "[email protected]", | ||
"external_user_type": "login_gov", | ||
}, | ||
"is_user_new": True, | ||
} | ||
return response.ApiResponse(message="Success", data=data) | ||
with db_session.begin(): | ||
# UserTokenHeaderSchema validates that the header is present, so safe to fetch this way | ||
result = process_login_gov_token(x_oauth_login_gov["x_oauth_login_gov"], db_session) | ||
|
||
message = "Missing X-OAuth-login-gov header" | ||
logger.info(message) | ||
|
||
raise_flask_error(400, message) | ||
logger.info("Successfully generated token for user") | ||
return response.ApiResponse(message="Success", data=result) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import logging | ||
|
||
from sqlalchemy import select | ||
from sqlalchemy.orm import selectinload | ||
|
||
import src.adapters.db as db | ||
from src.api.route_utils import raise_flask_error | ||
from src.auth.api_jwt_auth import create_jwt_for_user | ||
from src.auth.auth_errors import JwtValidationError | ||
from src.auth.login_gov_jwt_auth import validate_token | ||
from src.constants.lookup_constants import ExternalUserType | ||
from src.db.models.user_models import LinkExternalUser, User | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def process_login_gov_token(token: str, db_session: db.Session) -> dict: | ||
|
||
try: | ||
login_gov_user = validate_token(token) | ||
except JwtValidationError as e: | ||
logger.info("Login.gov token validation failed", extra={"auth.issue": e.message}) | ||
raise_flask_error(401, e.message) | ||
|
||
external_user: LinkExternalUser | None = db_session.execute( | ||
select(LinkExternalUser) | ||
.where(LinkExternalUser.external_user_id == login_gov_user.user_id) | ||
# We only support login.gov right now, so this does nothing, but let's | ||
# be explicit just in case. | ||
.where(LinkExternalUser.external_user_type == ExternalUserType.LOGIN_GOV) | ||
.options(selectinload("*")) | ||
).scalar() | ||
|
||
is_user_new = external_user is None | ||
|
||
# If we didn't find anything, we want to create the user | ||
if external_user is None: | ||
external_user = _create_login_gov_user(login_gov_user.user_id, db_session) | ||
|
||
# Update fields on the external user table | ||
external_user.email = login_gov_user.email | ||
|
||
# Flush the records to the DB so any auto-generated IDs and similar are populated | ||
# prior to us trying to work with the user further. | ||
# NOTE: This doesn't commit yet - but effectively moves the cache from memory to the DB transaction | ||
db_session.flush() | ||
|
||
token, _ = create_jwt_for_user(external_user.user, db_session) | ||
|
||
# TODO - make a pydantic object? return token + user? Figure it out | ||
return _build_response(token, external_user, is_user_new) | ||
|
||
|
||
def _create_login_gov_user(external_user_id: str, db_session: db.Session) -> LinkExternalUser: | ||
user = User() | ||
db_session.add(user) | ||
|
||
external_user = LinkExternalUser( | ||
user=user, | ||
external_user_type=ExternalUserType.LOGIN_GOV, | ||
external_user_id=external_user_id, | ||
# note we set other params in the calling method to also handle updates | ||
) | ||
db_session.add(external_user) | ||
|
||
return external_user | ||
|
||
|
||
def _build_response(token: str, external_user: LinkExternalUser, is_user_new: bool) -> dict: | ||
return { | ||
"token": token, | ||
"user": { | ||
"user_id": external_user.user_id, | ||
"email": external_user.email, | ||
"external_user_type": external_user.external_user_type, | ||
}, | ||
"is_user_new": is_user_new, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,10 +9,13 @@ | |
import moto | ||
import pytest | ||
from apiflask import APIFlask | ||
from cryptography.hazmat.primitives import serialization | ||
from cryptography.hazmat.primitives.asymmetric import rsa | ||
from sqlalchemy import text | ||
|
||
import src.adapters.db as db | ||
import src.app as app_entry | ||
import src.auth.login_gov_jwt_auth as login_gov_jwt_auth | ||
import tests.src.db.models.factories as factories | ||
from src.adapters import search | ||
from src.constants.schema import Schemas | ||
|
@@ -217,6 +220,60 @@ def opportunity_index_alias(search_client, monkeypatch_session): | |
return alias | ||
|
||
|
||
#################### | ||
# Auth | ||
#################### | ||
|
||
|
||
def _generate_rsa_key_pair(): | ||
# Rather than define a private/public key, generate one for the tests | ||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048) | ||
|
||
private_key = key.private_bytes( | ||
encoding=serialization.Encoding.PEM, | ||
format=serialization.PrivateFormat.TraditionalOpenSSL, | ||
encryption_algorithm=serialization.NoEncryption(), | ||
) | ||
|
||
public_key = key.public_key().public_bytes( | ||
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo | ||
) | ||
|
||
return private_key, public_key | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def rsa_key_pair(): | ||
return _generate_rsa_key_pair() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def private_rsa_key(rsa_key_pair): | ||
return rsa_key_pair[0] | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def public_rsa_key(rsa_key_pair): | ||
return rsa_key_pair[1] | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def other_rsa_key_pair(): | ||
return _generate_rsa_key_pair() | ||
|
||
|
||
@pytest.fixture(scope="session") | ||
def setup_login_gov_auth(monkeypatch_session, public_rsa_key): | ||
# TODO - describe | ||
def override_method(config): | ||
config.public_keys = [public_rsa_key] | ||
|
||
monkeypatch_session.setattr(login_gov_jwt_auth, "_refresh_keys", override_method) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note for self - what if I instead made a dummy endpoint that returned the keys? That would at least validate the parsing logic |
||
|
||
monkeypatch_session.setenv("LOGIN_GOV_ENDPOINT", "http://localhost:3000") | ||
monkeypatch_session.setenv("LOGIN_GOV_CLIENT_ID", "AUDIENCE_TEST") | ||
|
||
|
||
#################### | ||
# Test App & Client | ||
#################### | ||
|
@@ -225,7 +282,7 @@ def opportunity_index_alias(search_client, monkeypatch_session): | |
# Make app session scoped so the database connection pool is only created once | ||
# for the test session. This speeds up the tests. | ||
@pytest.fixture(scope="session") | ||
def app(db_client, opportunity_index_alias) -> APIFlask: | ||
def app(db_client, opportunity_index_alias, setup_login_gov_auth) -> APIFlask: | ||
return app_entry.create_app() | ||
|
||
|
||
|
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should look into defining this different in some way? Got a weird email directly from GitHub complaining about:
I can mark as a false positive, but maybe this is a problem for some reason.