Skip to content
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

Include ICAT user's investigation and instrument IDs in JWT access token #137

Open
wants to merge 7 commits into
base: port-over-to-fastapi-#133
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions scigateway_auth/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ class BlacklistedJWTError(Exception):
"""


class ICATAuthenticationError(Exception):
class ICATServerError(Exception):
"""
Exception raised when there are ICAT authentication related errors/issues.
Exception raised when there is a problem with the ICAT server.
"""


class InvalidCredentialsError(Exception):
"""
Exception raised when invalid credentials are provided.
"""


Expand Down
33 changes: 23 additions & 10 deletions scigateway_auth/routers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from scigateway_auth.common.config import config
from scigateway_auth.common.exceptions import (
BlacklistedJWTError,
ICATAuthenticationError,
ICATServerError,
InvalidCredentialsError,
InvalidJWTError,
JWTRefreshError,
UsernameMismatchError,
)
from scigateway_auth.common.schemas import LoginDetailsPostRequestSchema
from scigateway_auth.src.authentication import ICATAuthenticator
from scigateway_auth.src.icat_client import ICATClient
from scigateway_auth.src.jwt_handler import JWTHandler

logger = logging.getLogger()
Expand All @@ -35,7 +36,7 @@
def get_authenticators():
logger.info("Getting a list of valid ICAT authenticators")
try:
return ICATAuthenticator.get_authenticators()
return ICATClient.get_authenticators()
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
Expand All @@ -57,10 +58,17 @@ def login(
) -> JSONResponse:
logger.info("Authenticating a user")
try:
icat_session_id = ICATAuthenticator.authenticate(login_details.mnemonic, login_details.credentials)
icat_username = ICATAuthenticator.get_username(icat_session_id)

access_token = jwt_handler.get_access_token(icat_session_id, icat_username)
icat_session_id = ICATClient.authenticate(login_details.mnemonic, login_details.credentials)
icat_username = ICATClient.get_username(icat_session_id)
icat_user_instrument_ids = ICATClient.get_user_instrument_ids(icat_session_id, icat_username)
icat_user_investigation_ids = ICATClient.get_user_investigation_ids(icat_session_id, icat_username)

access_token = jwt_handler.get_access_token(
icat_session_id,
icat_username,
icat_user_instrument_ids,
icat_user_investigation_ids,
)
refresh_token = jwt_handler.get_refresh_token(icat_username)

response = JSONResponse(content=access_token)
Expand All @@ -74,9 +82,14 @@ def login(
path=f"{config.api.root_path}/refresh",
)
return response
except ICATAuthenticationError as exc:
logger.exception(exc.args)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except InvalidCredentialsError as exc:
message = "Invalid credentials provided"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=message) from exc
except ICATServerError as exc:
message = "Something went wrong"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=message) from exc


@router.post(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Module for providing a class for handling authentication.
Module for providing a class for interacting with ICAT.
"""

import json
Expand All @@ -9,26 +9,26 @@
import requests

from scigateway_auth.common.config import config
from scigateway_auth.common.exceptions import ICATAuthenticationError
from scigateway_auth.common.exceptions import ICATServerError, InvalidCredentialsError
from scigateway_auth.common.schemas import UserCredentialsPostRequestSchema

logger = logging.getLogger()


class ICATAuthenticator:
class ICATClient:
"""
Class for managing authentication against an ICAT authenticator.
Class for managing interactions with an ICAT server.
"""

@staticmethod
def authenticate(mnemonic: str, credentials: UserCredentialsPostRequestSchema = None) -> str:
"""
Sends an authentication request to the ICAT authenticator and returns a session ID.
Sends an authentication request to the ICAT server and returns a session ID.

:param mnemonic: The ICAT mnemonic to use to authenticate.
:param credentials: The ICAT credentials to authenticate with.
:raises ICATAuthenticationError: If there is a problem with the ICAT authenticator or the login details are
invalid.
:raises InvalidCredentialsError: If the user credentials are invalid.
:raises ICATError: If there is a problem with the ICAT server.
:return: The ICAT session ID.
"""
logger.info("Authenticating at %s with mnemonic: %s", config.icat_server.url, mnemonic)
Expand All @@ -53,16 +53,18 @@ def authenticate(mnemonic: str, credentials: UserCredentialsPostRequestSchema =
)
if response.status_code == 200:
return response.json()["sessionId"]
elif response.status_code == 403:
raise InvalidCredentialsError(response.json()["message"])
else:
raise ICATAuthenticationError(response.json()["message"])
raise ICATServerError(response.json()["message"])

@staticmethod
def get_username(session_id: str) -> str:
"""
Sends a request to ICAT to retrieve the user's username from a session ID.

:param session_id: The session ID of the user who we want to get the username for.
:raises ICATAuthenticationError: If there is a problem with the ICAT authenticator or the session ID is invalid.
:raises ICATError: If there is a problem with the ICAT server or the session ID is invalid.
:return: The user's ICAT username.
"""
logger.info("Retrieving username for session ID '%s' at %s", session_id, config.icat_server.url)
Expand All @@ -74,7 +76,7 @@ def get_username(session_id: str) -> str:
if response.status_code == 200:
return response.json()["userName"]
else:
raise ICATAuthenticationError(response.json()["message"])
raise ICATServerError(response.json()["message"])

@staticmethod
def get_authenticators() -> list[dict[str, Any]]:
Expand All @@ -98,8 +100,7 @@ def refresh(session_id: str) -> None:
Sends a request to ICAT to refresh a session ID.

:param session_id: The session ID to refresh.
:raises ICATAuthenticationError: If there is a problem with the ICAT authenticator or the session ID cannot be
refreshed.
:raises ICATError: If there is a problem with the ICAT server or the session ID cannot be refreshed.
"""
logger.info("Refreshing session ID %s at %s", session_id, config.icat_server.url)
response = requests.put(
Expand All @@ -108,4 +109,56 @@ def refresh(session_id: str) -> None:
timeout=config.icat_server.request_timeout_seconds,
)
if response.status_code != 204:
raise ICATAuthenticationError("The session ID was unable to be refreshed")
raise ICATServerError("The session ID was unable to be refreshed")

@staticmethod
def get_user_instrument_ids(session_id: str, username: str) -> list[int]:
"""
Get the IDs of the instruments where the user is an instrument scientist.

:param session_id: The session ID of the user to use when querying ICAT.
:param username: The user's ICAT username.
"""
# The username here comes directly from ICAT rather than a user's input. The ICAT server also defends against
# SQL injections.
query = (
"SELECT i.id FROM Instrument i JOIN i.instrumentScientists as isc JOIN isc.user u " # noqa: S608
f"WHERE u.name = {username!r}"
)
return ICATClient._fetch_ids_by_query(session_id, query)

@staticmethod
def get_user_investigation_ids(session_id: str, username: str) -> list[int]:
"""
Get the IDs of the investigations where the user is an investigation user.

:param session_id: The session ID of the user to use when querying ICAT.
:param username: The user's ICAT username.
"""
# The username here comes directly from ICAT rather than a user's input. The ICAT server also defends against
# SQL injections.
query = (
"SELECT i.id FROM Investigation i JOIN i.investigationUsers as iu JOIN iu.user u " # noqa: S608
f"WHERE u.name = {username!r}"
)
return ICATClient._fetch_ids_by_query(session_id, query)

@staticmethod
def _fetch_ids_by_query(session_id: str, query: str) -> list[int]:
"""
Sends a request to ICAT to get the entity IDs specified in the provided query.

:param session_id: The session ID of the user to use when querying ICAT.
:raises ICATError: If there is a problem with the ICAT server or the session ID or query is invalid.
"""
response = requests.get(
f"{config.icat_server.url}/entityManager",
params={"sessionId": session_id, "query": query},
verify=config.icat_server.certificate_validation,
timeout=config.icat_server.request_timeout_seconds,
)

if response.status_code == 200:
return response.json()
else:
raise ICATServerError(response.json()["message"])
16 changes: 13 additions & 3 deletions scigateway_auth/src/jwt_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
JWTRefreshError,
UsernameMismatchError,
)
from scigateway_auth.src.authentication import ICATAuthenticator
from scigateway_auth.src.icat_client import ICATClient

logger = logging.getLogger()

Expand All @@ -27,16 +27,26 @@ class JWTHandler:
Class for handling JWTs.
"""

def get_access_token(self, icat_session_id: str, icat_username: str) -> str:
def get_access_token(
self,
icat_session_id: str,
icat_username: str,
icat_user_instrument_ids: list[int],
icat_user_investigation_ids: list[int],
) -> str:
"""
Generate a payload and return a signed JWT access token.

:param icat_session_id: The ICAT session ID.
:param icat_username: The user's ICAT username.
:param icat_user_instrument_ids: The IDs of the instruments where the user is an instrument scientist.
:param icat_user_investigation_ids: The IDs of the investigations where the user is an investigation user.
:return: The signed JWT access token.
"""
logger.info("Getting an access token")
payload = {
"instruments": icat_user_instrument_ids,
"investigations": icat_user_investigation_ids,
"sessionId": icat_session_id,
"username": icat_username,
"userIsAdmin": self._is_user_admin(icat_username),
Expand Down Expand Up @@ -83,7 +93,7 @@ def refresh_access_token(self, access_token: str, refresh_token: str):
access_token_payload["exp"] = datetime.now(timezone.utc) + timedelta(
minutes=config.authentication.access_token_validity_minutes,
)
ICATAuthenticator.refresh(access_token_payload["sessionId"])
ICATClient.refresh(access_token_payload["sessionId"])
return self._pack_jwt(access_token_payload)
except Exception as exc:
message = "Unable to refresh access token"
Expand Down
44 changes: 24 additions & 20 deletions test/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,39 @@
"""

VALID_ACCESS_TOKEN_NON_ADMIN = (
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiJ0ZXN0LXNlc3Npb24taWQiLCJ1c2VybmFtZSI6InRlc3QtdXNlcm5hbWUiL"
"CJ1c2VySXNBZG1pbiI6ZmFsc2UsImV4cCI6MjUzNDAyMzAwNzk5fQ.dwjtuXX76o4tE6db6bjef5CphiS3hrHPRYW7nuBz_nzjgqNLdOl6BobYbWZQ"
"AXhDo8JzDRhNxaIvbvk-NSf72utxqo6WeMOQSNZtdOuqewpVTFQoPe6dLorgpVdKepIfT8AAKKtXyaKtVxYPfEzJMzK8eZM3pK4yWmUjOgCixoZ322"
"_tSaZUUbqkEV7885ohSL_L5NxoJ7n5nV5n5gFoPFZUSvZzk_7NLZHPSXjAgTrprsvBsTH1ivi-yos4up4lNZ2pstOgPP0sYbQZoMJubB0dQcKefLEg"
"3JSu-laX_4l9geVHCAsYOFHjEPzH0X0Q4nDY5BO976PEEJcXAtfNKw"
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnN0cnVtZW50cyI6WzEsMyw1XSwiaW52ZXN0aWdhdGlvbnMiOlsyLDQsNiw4LDEwXSwic2Vzc"
"2lvbklkIjoidGVzdC1zZXNzaW9uLWlkIiwidXNlcm5hbWUiOiJ0ZXN0LXVzZXJuYW1lIiwidXNlcklzQWRtaW4iOmZhbHNlLCJleHAiOjI1MzQwMjM"
"wMDc5OX0.chLKggjZ5AtKlSQB459LMBOgrlV4XLG_wxGaC7n7SIwxa6xlafPcwg56mRipRmKQXeRxBfkRqSPND3y6d5EuLeY9GmgCaQ1OekYG1OtcB"
"crD98IA9ejX4OKe-cOfVAJHPY9O8rkqUtAPXhiCFZTZWjq5MC7OXeXk9kwBRNQLAnN0vvDTtnC8hKc3NIHn51GVy3bA17igHzSmTQQAjCxi0Z6m0EU"
"jc00pug3ALPPe8t0BLEZq2CEIQSIe1qFWkRsDXt75nlFfi1uk4XzdRbmDbKa_4wNddyEcEAV3OApXOBAzyaSeihlpl7cviIyjhIgYFjSidSHCGQh2g"
"kBb_vHwTQ"
)

EXPIRED_ACCESS_TOKEN_NON_ADMIN = (
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiJ0ZXN0LXNlc3Npb24taWQiLCJ1c2VybmFtZSI6InRlc3QtdXNlcm5hbWUiL"
"CJ1c2VySXNBZG1pbiI6ZmFsc2UsImV4cCI6LTYyMTM1NTk2ODAwfQ.YcWGenSm0noq0TAaSZhb1ZVYFxuk-aXIX_XlPh-FoxsBxTZy1dnh-TKLRzom"
"wdzP0bMbcU3LZKRMYScpcRSSnANwzCFZLhPId0OBLOiyoBMXtK2xsMsYeapcKPbhvqYxDLGMB--n1uniKGUGnGTI3SwJhBup_6SLnjRMmw5fze_sb4"
"w1G88o8vcLK_Ii2D9ZauZTI-LVcy-C9z8Y6sGXxfGIceUlb8RtFz1RJwVT87wYvtqN72fzMVgjOv_AvYj5HjD2wHKpb50-0lxzonrc0sdQe_yAzjoa"
"b6RBeydd5JpP7zb-NTJunsX8vbtRq4fpjUTqiCsJGN8Q0g_2Soq7Ew"
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnN0cnVtZW50cyI6WzEsMyw1XSwiaW52ZXN0aWdhdGlvbnMiOlsyLDQsNiw4LDEwXSwic2Vzc"
"2lvbklkIjoidGVzdC1zZXNzaW9uLWlkIiwidXNlcm5hbWUiOiJ0ZXN0LXVzZXJuYW1lIiwidXNlcklzQWRtaW4iOmZhbHNlLCJleHAiOi02MjEzNTU"
"5NjgwMH0.g4axBoo-Q1ylLA22clOyhwSE9p35HnR44YsfMREOTvMLausy2L1bslPFgR9ELcM_MK38D9xwT6w06Sqcw2G7-t8SWIrZN7tlLvxL3XDEw"
"V5Gw2RJ7TSW18akDbl3qdk7clxFRUZfhcjMTd2U2lvkYrSDVgaz7yjWv8JZg8Bkf06LCMRTr8BJ7Bj9XHLZ16GNqls2_gXxY-_QmEcWlUB7Gc4oJCJ"
"IRItyimg7PcczTzKaOkxc1Eg9Xl86RJZRogzJTJJXZG_hE-5QpICRWeFqMQnk0tBvvBsp6EbMqmw1ouPvS1hc-4S92LVi6ULRpMN1ANv7ntVGbNTPA"
"BWytw76SA"
)

EXPECTED_ACCESS_TOKEN_NON_ADMIN = (
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiJ0ZXN0LXNlc3Npb24taWQiLCJ1c2VybmFtZSI6InRlc3QtdXNlcm5hbWUiL"
"CJ1c2VySXNBZG1pbiI6ZmFsc2UsImV4cCI6MTcwNTQ4NzQwMH0.jjjHhjXuixjJsqlfRI8vNR76yHIwEB_e1iwTOvLLHkzeb03HPQDpBOr4UtZCTuH"
"xy38hwKkYz9g-gdsyqn7_WuPkikmMm7Rwsb8G4DT10FnzxWvgBfAzR_ubc0jTDvuaaCj3GfNssnMzBrRVUcwyNOGvsz2N7wjdalK-anK-SJToxmThF"
"xmButHZl_x8NTEC5_KEnbf4roU0v5ZQ4gNfAn-2yTHHRyqrnXlVq9nse_85NC1Wz_zliBpQfN1yafPKae2HobPYeW_PhV4y3yLVJ8zlRED6y577XIm"
"qibAEQlGFyfhKzQZIVOBGUkAjvKiL_zUQv4RliHdd4INDt24LeA"
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnN0cnVtZW50cyI6WzEsMyw1XSwiaW52ZXN0aWdhdGlvbnMiOlsyLDQsNiw4LDEwXSwic2Vzc"
"2lvbklkIjoidGVzdC1zZXNzaW9uLWlkIiwidXNlcm5hbWUiOiJ0ZXN0LXVzZXJuYW1lIiwidXNlcklzQWRtaW4iOmZhbHNlLCJleHAiOjE3MDU0ODc"
"0MDB9.iIzGRkWfeISxU4ZxA7OFsoKGECsA78lbn8u5pmI1CcSmT9SdpW3aPEhcsFf95_QBTpP_nlz3O9Z3IMK_V3jOfMzt6OHKtzTL-OxDVO1qkXyB"
"mMq87V0yrkAHvo92TeeNj9iIbmDizlbETF_OY4cwyFa7dY0THCh-rm4JwvhPPjuYNbkTx4FLDq4BNBV0TpmOd6faaCKqhgYIftlbcUMJ9wRyz3BIjW"
"aYe0uxzY52VGlp-0qOrNqHx_0_r4kYkbjqfl7KQwfqGJGEYGCxNZx_8pjBQ0JHvFQSkPL8ZMsLX1BdIkeklL9uaOdtjM6HJz7z_Otdi0LinTNUIv4J"
"J1jH6A"
)

EXPECTED_ACCESS_TOKEN_ADMIN = (
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiJ0ZXN0LXNlc3Npb24taWQiLCJ1c2VybmFtZSI6InRlc3QtdXNlcm5hbWUiL"
"CJ1c2VySXNBZG1pbiI6dHJ1ZSwiZXhwIjoxNzA1NDg3NDAwfQ.MJRiZgFiD3X7Xb7Bcz5girfsTDXU3gtd06RDx0X22nCsJi6YCxD35mcVr4Bv6hdM"
"Qb81qkLazvi9GiNEze9Q1nKNfuyyXi_D6-9lpwKqYFkqfrE52NiZwin3yrT0gAVRHlWSOCNV4oUSeaTM8MYFt75rEU99LWptfzpEjsierDAB7lMgb6"
"HX6XtCa5MtZ1z7dBN4b5jVr4STnYIgpOazpV-sArpN5jB21eUNODr8BxCD287eHixEbur0yrzpJ-vbGCJdH2m_wdpbVJdbD98X-ydc_Mmz6LSukh1n"
"rNj77RuynVMceRF98fGVMb7OQ2QJZNhc8ZizqAxOjxAeHehG5A"
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnN0cnVtZW50cyI6WzEsMyw1XSwiaW52ZXN0aWdhdGlvbnMiOlsyLDQsNiw4LDEwXSwic2Vzc"
"2lvbklkIjoidGVzdC1zZXNzaW9uLWlkIiwidXNlcm5hbWUiOiJ0ZXN0LXVzZXJuYW1lIiwidXNlcklzQWRtaW4iOnRydWUsImV4cCI6MTcwNTQ4NzQ"
"wMH0.hLrWKverRTvrSRRzWUSdtQV64ueQiVrbmG3U3g5LvuISMp_p9h3lz6y9WrSD1v88yeS7JaqaZTjSIh3LtVFK7oKHZt8VlrRzTl1C8DCG1-6H2"
"C41kvi23NJpWe0F-tV1kwJMtZVTFLun-3C93_UJu3rYGlLLBmfKJuipQ-XblvDlHFik6SaUVUs4ZXvv40rXAFuhOrTC-0Pf7U9sxEMoF5qznCMeSBy"
"8gCxg04R_-V8H-uLtC4BzDcbjkmrDbb7sWSJWh3kaCPZqvCF7xqVbk9JpcI4Jcm6N5FpmStQhPD0_dmTi_je1cO1uVk6fCtMNSmYsoN5_wn5xvMXzC"
"TWRvw"
)

VALID_REFRESH_TOKEN = (
Expand Down
Loading
Loading