From a2b2cb9723dd012fecc5b6d07e17ea5735268d6c Mon Sep 17 00:00:00 2001 From: Joel Davies Date: Mon, 28 Oct 2024 11:41:05 +0000 Subject: [PATCH] Add authentication from ims-api #6 --- README.md | 54 +++++++---- docker-compose.yml | 1 + keys/.keep | 0 object_storage_api/.env.example | 3 + object_storage_api/auth/__init__.py | 0 object_storage_api/auth/jwt_bearer.py | 68 +++++++++++++ object_storage_api/core/config.py | 37 ++++++- object_storage_api/core/consts.py | 18 ++++ object_storage_api/main.py | 27 +++++- test/e2e/conftest.py | 7 +- test/e2e/test_jwt_bearer.py | 67 +++++++++++++ test/keys/jwt-key.pub | 1 + test/mock_data.py | 34 ++++++- test/pytest.ini | 3 + test/unit/auth/__init__.py | 0 test/unit/auth/test_jwt_bearer.py | 133 ++++++++++++++++++++++++++ 16 files changed, 425 insertions(+), 28 deletions(-) create mode 100644 keys/.keep create mode 100644 object_storage_api/auth/__init__.py create mode 100644 object_storage_api/auth/jwt_bearer.py create mode 100644 object_storage_api/core/consts.py create mode 100644 test/e2e/test_jwt_bearer.py create mode 100644 test/keys/jwt-key.pub create mode 100644 test/unit/auth/__init__.py create mode 100644 test/unit/auth/test_jwt_bearer.py diff --git a/README.md b/README.md index d48f2c2..e0f3f34 100644 --- a/README.md +++ b/README.md @@ -151,23 +151,37 @@ values loaded from the `.env` file. Listed below are the environment variables supported by the application. -| Environment Variable | Description | Mandatory | Default Value | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------ | -| `API__TITLE` | The title of the API which is added to the generated OpenAPI. | No | `Object Storage Service API` | -| `API__DESCRIPTION` | The description of the API which is added to the generated OpenAPI. | No | `This is the API for the Object Storage Service` | -| `API__ROOT_PATH` | (If using a proxy) The path prefix handled by a proxy that is not seen by the app. | No | ` ` | -| `API__ALLOWED_CORS_HEADERS` | The list of headers that are allowed to be included in cross-origin requests. | Yes | | -| `API__ALLOWED_CORS_ORIGINS` | The list of origins (domains) that are allowed to make cross-origin requests. | Yes | | -| `API__ALLOWED_CORS_METHODS` | The list of methods that are allowed to be used to make cross-origin requests. | Yes | | -| `DATABASE__PROTOCOL` | The protocol component (i.e. `mongodb`) to use for the connection string for the `MongoClient` to connect to the database. | Yes | | -| `DATABASE__USERNAME` | The database username to use for the connection string for the `MongoClient` to connect to the database. | Yes | | -| `DATABASE__PASSWORD` | The database password to use for the connection string for the `MongoClient` to connect to the database. | Yes | | -| `DATABASE__HOST_AND_OPTIONS` | The host (and optional port number) component as well specific options (if any) to use for the connection string for the `MongoClient` to connect to the database. The host component is the name or IP address of the host where the `mongod` instance is running, whereas the options are `=` pairs (i.e. `?authMechanism=SCRAM-SHA-256&authSource=admin`) specific to the connection.
  • For a replica set `mongod` instance(s), specify the hostname(s) and any options as listed in the replica set configuration - `prod-mongodb-1:27017,prod-mongodb-2:27017,prod-mongodb-3:27017/?authMechanism=SCRAM-SHA-256&authSource=admin`
  • For a standalone `mongod` instance, specify the hostname and any options - `prod-mongodb:27017/?authMechanism=SCRAM-SHA-256&authSource=admin`
| Yes | | -| `DATABASE__NAME` | The name of the database to use for the `MongoClient` to connect to the database. | Yes | | -| `OBJECT_STORAGE__ENDPOINT_URL` | The URL of the object storage S3 endpoint. | Yes | | -| `OBJECT_STORAGE__ACCESS_KEY` | The access key to use to authenticate with the S3 object storage. | Yes | | -| `OBJECT_STORAGE__SECRET_ACCESS_KEY` | The secret access key to use to authenticate with the S3 object storage. | Yes | | -| `OBJECT_STORAGE__BUCKET_NAME` | The name of the S3 bucket to use for object storage. | Yes | | -| `OBJECT_STORAGE__PRESIGNED_URL_EXPIRY_SECONDS` | The expiry time of presigned URLs. | Yes | | -| `ATTACHMENT__MAX_SIZE_BYTES` | The maximum file size of an attachment given in bytes. | Yes | | -| `IMAGE__THUMBNAIL_MAX_SIZE_BYTES` | The maximum width/height of generated image thumbnails. The actual width and height should maintain the original aspect ratio but neither the width nor height will exceed this value. | Yes | | +| Environment Variable | Description | Mandatory | Default Value | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------ | +| `API__TITLE` | The title of the API which is added to the generated OpenAPI. | No | `Object Storage Service API` | +| `API__DESCRIPTION` | The description of the API which is added to the generated OpenAPI. | No | `This is the API for the Object Storage Service` | +| `API__ROOT_PATH` | (If using a proxy) The path prefix handled by a proxy that is not seen by the app. | No | ` ` | +| `API__ALLOWED_CORS_HEADERS` | The list of headers that are allowed to be included in cross-origin requests. | Yes | | +| `API__ALLOWED_CORS_ORIGINS` | The list of origins (domains) that are allowed to make cross-origin requests. | Yes | | +| `API__ALLOWED_CORS_METHODS` | The list of methods that are allowed to be used to make cross-origin requests. | Yes | | +| `AUTHENTICATION__ENABLED` | Whether JWT auth is enabled. | Yes | | +| `AUTHENTICATION__PUBLIC_KEY_PATH` | The path to the public key to be used for decoding JWT access token signed by the corresponding private key. | If JWT auth enabled | | +| `AUTHENTICATION__JWT_ALGORITHM` | The algorithm to use to decode the JWT access token. | If JWT auth enabled | | +| `DATABASE__PROTOCOL` | The protocol component (i.e. `mongodb`) to use for the connection string for the `MongoClient` to connect to the database. | Yes | | +| `DATABASE__USERNAME` | The database username to use for the connection string for the `MongoClient` to connect to the database. | Yes | | +| `DATABASE__PASSWORD` | The database password to use for the connection string for the `MongoClient` to connect to the database. | Yes | | +| `DATABASE__HOST_AND_OPTIONS` | The host (and optional port number) component as well specific options (if any) to use for the connection string for the `MongoClient` to connect to the database. The host component is the name or IP address of the host where the `mongod` instance is running, whereas the options are `=` pairs (i.e. `?authMechanism=SCRAM-SHA-256&authSource=admin`) specific to the connection.
  • For a replica set `mongod` instance(s), specify the hostname(s) and any options as listed in the replica set configuration - `prod-mongodb-1:27017,prod-mongodb-2:27017,prod-mongodb-3:27017/?authMechanism=SCRAM-SHA-256&authSource=admin`
  • For a standalone `mongod` instance, specify the hostname and any options - `prod-mongodb:27017/?authMechanism=SCRAM-SHA-256&authSource=admin`
| Yes | | +| `DATABASE__NAME` | The name of the database to use for the `MongoClient` to connect to the database. | Yes | | +| `OBJECT_STORAGE__ENDPOINT_URL` | The URL of the object storage S3 endpoint. | Yes | | +| `OBJECT_STORAGE__ACCESS_KEY` | The access key to use to authenticate with the S3 object storage. | Yes | | +| `OBJECT_STORAGE__SECRET_ACCESS_KEY` | The secret access key to use to authenticate with the S3 object storage. | Yes | | +| `OBJECT_STORAGE__BUCKET_NAME` | The name of the S3 bucket to use for object storage. | Yes | | +| `OBJECT_STORAGE__PRESIGNED_URL_EXPIRY_SECONDS` | The expiry time of presigned URLs. | Yes | | +| `ATTACHMENT__MAX_SIZE_BYTES` | The maximum file size of an attachment given in bytes. | Yes | | +| `IMAGE__THUMBNAIL_MAX_SIZE_BYTES` | The maximum width/height of generated image thumbnails. The actual width and height should maintain the original aspect ratio but neither the width nor height will exceed this value. | Yes | | + +### JWT Authentication/Authorization + +This microservice supports JWT authentication/authorization and this can be enabled or disabled by setting +the `AUTHENTICATION__ENABLED` environment variable to `True` or `False`. When enabled, all the endpoints require a JWT +access token to be supplied. This ensures that only authenticated and authorized users can access the resources. To +decode the JWT access token, the application needs the public key that corresponding to the private key used for +encoding the token. Once the JWT access token is decoded successfully, it checks that it has a `username` in the +payload, and it has not expired. This means that any microservice can be used to generate JWT access tokens so long as +it meets the above criteria. The [LDAP-JWT Authentication Service](https://github.com/ral-facilities/ldap-jwt-auth) is +a microservice that provides user authentication against an LDAP server and returns a JWT access token. diff --git a/docker-compose.yml b/docker-compose.yml index 750acca..0323df8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: build: . volumes: - ./object_storage_api:/object-storage-api-run/object_storage_api + - ./keys:/object-storage-api-run/keys restart: on-failure ports: - 8002:8000 diff --git a/keys/.keep b/keys/.keep new file mode 100644 index 0000000..e69de29 diff --git a/object_storage_api/.env.example b/object_storage_api/.env.example index a68d062..4344364 100644 --- a/object_storage_api/.env.example +++ b/object_storage_api/.env.example @@ -6,6 +6,9 @@ API__ROOT_PATH= API__ALLOWED_CORS_HEADERS=["*"] API__ALLOWED_CORS_ORIGINS=["*"] API__ALLOWED_CORS_METHODS=["*"] +AUTHENTICATION__ENABLED=true +AUTHENTICATION__PUBLIC_KEY_PATH=./keys/jwt-key.pub +AUTHENTICATION__JWT_ALGORITHM=RS256 DATABASE__PROTOCOL=mongodb DATABASE__USERNAME=root DATABASE__PASSWORD=example diff --git a/object_storage_api/auth/__init__.py b/object_storage_api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/object_storage_api/auth/jwt_bearer.py b/object_storage_api/auth/jwt_bearer.py new file mode 100644 index 0000000..140c89b --- /dev/null +++ b/object_storage_api/auth/jwt_bearer.py @@ -0,0 +1,68 @@ +""" +Module for providing an implementation of the `JWTBearer` class. +""" + +import logging + +import jwt +from fastapi import HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from object_storage_api.core.config import config +from object_storage_api.core.consts import PUBLIC_KEY + +# pylint:disable=fixme +# TODO: This file is identical to the one in inventory-management-system-api - Use common repo? + + +logger = logging.getLogger() + + +class JWTBearer(HTTPBearer): + """ + Extends the FastAPI `HTTPBearer` class to provide JSON Web Token (JWT) based authentication/authorization. + """ + + def __init__(self, auto_error: bool = True) -> None: + """ + Initialize the `JWTBearer`. + + :param auto_error: If `True`, it automatically raises `HTTPException` if the HTTP Bearer token is not provided + (in an `Authorization` header). + """ + super().__init__(auto_error=auto_error) + + async def __call__(self, request: Request) -> str: + """ + Callable method for JWT access token authentication/authorization. + + This method is called when `JWTBearer` is used as a dependency in a FastAPI route. It performs authentication/ + authorization by calling the parent class method and then verifying the JWT access token. + :param request: The FastAPI `Request` object. + :return: The JWT access token if authentication is successful. + :raises HTTPException: If the supplied JWT access token is invalid or has expired. + """ + credentials: HTTPAuthorizationCredentials = await super().__call__(request) + + if not self._is_jwt_access_token_valid(credentials.credentials): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token or expired token") + + return credentials.credentials + + def _is_jwt_access_token_valid(self, access_token: str) -> bool: + """ + Check if the JWT access token is valid. + + It does this by checking that it was signed by the corresponding private key and has not expired. It also + requires the payload to contain a username. + :param access_token: The JWT access token to check. + :return: `True` if the JWT access token is valid and its payload contains a username, `False` otherwise. + """ + logger.info("Checking if JWT access token is valid") + try: + payload = jwt.decode(access_token, PUBLIC_KEY, algorithms=[config.authentication.jwt_algorithm]) + except Exception: # pylint: disable=broad-exception-caught) + logger.exception("Error decoding JWT access token") + payload = None + + return payload is not None and "username" in payload diff --git a/object_storage_api/core/config.py b/object_storage_api/core/config.py index 9b5fdbf..d816c3f 100644 --- a/object_storage_api/core/config.py +++ b/object_storage_api/core/config.py @@ -3,9 +3,9 @@ """ from pathlib import Path -from typing import List +from typing import List, Optional -from pydantic import BaseModel, ConfigDict, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationInfo, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,6 +22,38 @@ class APIConfig(BaseModel): allowed_cors_methods: List[str] +# pylint:disable=fixme +# TODO: Some of this file is identical to the one in inventory-management-system-api - Use common repo? + + +class AuthenticationConfig(BaseModel): + """ + Configuration model for the JWT access token authentication/authorization. + """ + + enabled: bool + public_key_path: Optional[str] = Field(default=None, validate_default=True) + jwt_algorithm: Optional[str] = Field(default=None, validate_default=True) + + @field_validator("public_key_path", "jwt_algorithm") + @classmethod + def validate_optional_fields(cls, field_value: str, info: ValidationInfo) -> Optional[str]: + """ + Validator for the `public_key_path` and `jwt_algorithm` fields to make them mandatory if the value of the + `enabled` is `True` + + It checks if the `enabled` field has been set to `True` and raises a `TypeError` if this is the case. + + :param field_value: The value of the field. + :param info: Validation info from pydantic. + :raises ValueError: If no value is provided for the field when `enabled` is set to `True`. + :return: The value of the field. + """ + if ("enabled" in info.data and info.data["enabled"] is True) and field_value is None: + raise ValueError("Field required") + return field_value + + class DatabaseConfig(BaseModel): """ Configuration model for the database. @@ -76,6 +108,7 @@ class Config(BaseSettings): """ api: APIConfig + authentication: AuthenticationConfig database: DatabaseConfig object_storage: ObjectStorageConfig attachment: AttachmentConfig diff --git a/object_storage_api/core/consts.py b/object_storage_api/core/consts.py new file mode 100644 index 0000000..bdb741f --- /dev/null +++ b/object_storage_api/core/consts.py @@ -0,0 +1,18 @@ +""" +Contains constants used in multiple places so they are easier to change +""" + +# pylint:disable=fixme +# TODO: Some of this file is identical to the one in inventory-management-system-api - Use common repo? + +import sys + +from object_storage_api.core.config import config + +if config.authentication.enabled: + # Read the content of the public key file into a constant. This is used for decoding of JWT access tokens. + try: + with open(config.authentication.public_key_path, "r", encoding="utf-8") as file: + PUBLIC_KEY = file.read() + except FileNotFoundError as exc: + sys.exit(f"Cannot find public key: {exc}") diff --git a/object_storage_api/main.py b/object_storage_api/main.py index 3caf050..065d325 100644 --- a/object_storage_api/main.py +++ b/object_storage_api/main.py @@ -4,7 +4,7 @@ import logging -from fastapi import FastAPI, Request, status +from fastapi import Depends, FastAPI, Request, status from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -69,6 +69,25 @@ async def custom_general_exception_handler(_: Request, exc: Exception) -> JSONRe return JSONResponse(content={"detail": "Something went wrong"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) +# pylint:disable=fixme +# TODO: The auth code in this file is identical to the one in inventory-management-system-api - Use common repo? + + +def get_router_dependencies() -> list: + """ + Get the list of dependencies for the API routers. + :return: List of dependencies + """ + dependencies = [] + # Include the `JWTBearer` as a dependency if authentication is enabled + if config.authentication.enabled is True: + # pylint:disable=import-outside-toplevel + from object_storage_api.auth.jwt_bearer import JWTBearer + + dependencies.append(Depends(JWTBearer())) + return dependencies + + app.add_middleware( CORSMiddleware, allow_origins=config.api.allowed_cors_origins, @@ -77,8 +96,10 @@ async def custom_general_exception_handler(_: Request, exc: Exception) -> JSONRe allow_headers=config.api.allowed_cors_headers, ) -app.include_router(attachment.router) -app.include_router(image.router) +router_dependencies = get_router_dependencies() + +app.include_router(attachment.router, dependencies=router_dependencies) +app.include_router(image.router, dependencies=router_dependencies) @app.get("/") diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index 80384f2..bdafdbc 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -2,6 +2,8 @@ Module providing test fixtures for the e2e tests. """ +from test.mock_data import VALID_ACCESS_TOKEN + import pytest from fastapi.testclient import TestClient @@ -9,6 +11,9 @@ from object_storage_api.core.object_store import object_storage_config, s3_client from object_storage_api.main import app +# pylint:disable=fixme +# TODO: This is identical to the one in inventory-management-system-api - Use common repo? + @pytest.fixture(name="test_client") def fixture_test_client() -> TestClient: @@ -17,7 +22,7 @@ def fixture_test_client() -> TestClient: :return: The test client. """ - return TestClient(app) + return TestClient(app, headers={"Authorization": f"Bearer {VALID_ACCESS_TOKEN}"}) @pytest.fixture(name="cleanup_database_collections", autouse=True) diff --git a/test/e2e/test_jwt_bearer.py b/test/e2e/test_jwt_bearer.py new file mode 100644 index 0000000..3a79ada --- /dev/null +++ b/test/e2e/test_jwt_bearer.py @@ -0,0 +1,67 @@ +""" +End-to-end tests for the `JWTBearer` routers' dependency. +""" + +# pylint:disable=fixme +# TODO: This file is identical to the one in inventory-management-system-api - Use common repo? + +from test.mock_data import ( + EXPIRED_ACCESS_TOKEN, + INVALID_ACCESS_TOKEN, + VALID_ACCESS_TOKEN, + VALID_ACCESS_TOKEN_MISSING_USERNAME, +) + +import pytest +from fastapi.routing import APIRoute + + +@pytest.mark.parametrize( + "headers, expected_response_message", + [ + pytest.param( + {"Authorization": f"Bearer {INVALID_ACCESS_TOKEN}"}, + "Invalid token or expired token", + id="invalid_bearer_token", + ), + pytest.param( + {"Authorization": f"Bearer {EXPIRED_ACCESS_TOKEN}"}, + "Invalid token or expired token", + id="expired_bearer_token", + ), + pytest.param( + {"Authorization": f"Bearer {VALID_ACCESS_TOKEN_MISSING_USERNAME}"}, + "Invalid token or expired token", + id="missing_username_in_bearer_token", + ), + pytest.param( + {"Authorization": ""}, + "Not authenticated", + id="empty_authorization_header", + ), + pytest.param( + {"Authorization": "Bearer "}, + "Not authenticated", + id="missing_bearer_token", + ), + pytest.param( + {"Authorization": f"Invalid-Bearer {VALID_ACCESS_TOKEN}"}, + "Invalid authentication credentials", + id="invalid_authorization_scheme", + ), + ], +) +def test_jwt_bearer_authorization_request(test_client, headers, expected_response_message): + """ + Test the `JWTBearer` routers' dependency on all the API routes. + """ + api_routes = [ + api_route for api_route in test_client.app.routes if isinstance(api_route, APIRoute) and api_route.path != "/" + ] + + for api_route in api_routes: + for method in ["GET", "DELETE", "PATCH", "POST", "PUT"]: + if method in api_route.methods: + response = test_client.request(method, api_route.path, headers=headers) + assert response.status_code == 403 + assert response.json()["detail"] == expected_response_message diff --git a/test/keys/jwt-key.pub b/test/keys/jwt-key.pub new file mode 100644 index 0000000..3454107 --- /dev/null +++ b/test/keys/jwt-key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7ZEUHxLoeqG86Qzwpz5btyG1JzYiPVvb3pdCmdWYtU/trUYm5OUcHKph9ZWHtx3sQhTtSwvOax6wi/887pzss6oiokEh3k3xLdXiMeEE/MeCT7AYzVre0kZDLxLuslO3x5HJGG4vltPUOsR7nncVqaBOubNzvQXaczm4l+sO8n/O4I9FODgFMW1h7y3TFhPT3xOPXLwqmHvo6Lk7FkQ97421vFGeSKErt4S9P2OHmeiHLom6eZkHDAIITQ631o61h4wrSKGMqq32q3TKEdAHPBSAgcr2Lui59tFiPiBARqtygyxuIT1dlDk4YbslUkt1wlGVX69h6fx1FWuso5Gh7 diff --git a/test/mock_data.py b/test/mock_data.py index 3423a2c..a05e979 100644 --- a/test/mock_data.py +++ b/test/mock_data.py @@ -17,9 +17,41 @@ from bson import ObjectId +# ---------------------------- GENERAL ----------------------------- + # Used for _GET_DATA's as when comparing these will not be possible to know at runtime CREATED_MODIFIED_GET_DATA_EXPECTED = {"created_time": ANY, "modified_time": ANY} +# ---------------------------- AUTHENTICATION ----------------------------- + +# pylint:disable=fixme +# TODO: The below access tokens are identical to the ones in inventory-management-system-api - Use common repo? + +VALID_ACCESS_TOKEN = ( + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwiZXhwIjoyNTM0MDIzMDA3OTl9.bagU2Wix8wKzydVU_L3Z" + "ZuuMAxGxV4OTuZq_kS2Fuwm839_8UZOkICnPTkkpvsm1je0AWJaIXLGgwEa5zUjpG6lTrMMmzR9Zi63F0NXpJqQqoOZpTBMYBaggsXqFkdsv-yAKUZ" + "8MfjCEyk3UZ4PXZmEcUZcLhKcXZr4kYJPjio2e5WOGpdjK6q7s-iHGs9DQFT_IoCnw9CkyOKwYdgpB35hIGHkNjiwVSHpyKbFQvzJmIv5XCTSRYqq0" + "1fldh-QYuZqZeuaFidKbLRH610o2-1IfPMUr-yPtj5PZ-AaX-XTLkuMqdVMCk0_jeW9Os2BPtyUDkpcu1fvW3_S6_dK3nQ" +) + +VALID_ACCESS_TOKEN_MISSING_USERNAME = ( + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjI1MzQwMjMwMDc5OX0.h4Hv_sq4-ika1rpuRx7k3pp0cF_BZ65WVSbIHS7oh9SjPpGHt" + "GhVHU1IJXzFtyA9TH-68JpAZ24Dm6bXbH6VJKoc7RCbmJXm44ufN32ga7jDqXH340oKvi_wdhEHaCf2HXjzsHHD7_D6XIcxU71v2W5_j8Vuwpr3SdX" + "6ea_yLIaCDWynN6FomPtUepQAOg3c7DdKohbJD8WhKIDV8UKuLtFdRBfN4HEK5nNs0JroROPhcYM9L_JIQZpdI0c83fDFuXQC-cAygzrSnGJ6O4DyS" + "cNL3VBNSmNTBtqYOs1szvkpvF9rICPgbEEJnbS6g5kmGld3eioeuDJIxeQglSbxog" +) + +EXPIRED_ACCESS_TOKEN = ( + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwiZXhwIjotNjIxMzU1OTY4MDB9.G_cfC8PNYE5yERyyQNRk" + "9mTmDusU_rEPgm7feo2lWQF6QMNnf8PUN-61FfMNRVE0QDSvAmIMMNEOa8ma0JHZARafgnYJfn1_FSJSoRxC740GpG8EFSWrpM-dQXnoD263V9FlK-" + "On6IbhF-4Rh9MdoxNyZk2Lj7NvCzJ7gbgbgYM5-sJXLxB-I5LfMfuYM3fx2cRixZFA153l46tFzcMVBrAiBxl_LdyxTIOPfHF0UGlaW2UtFi02gyBU" + "4E4wTOqPc4t_CSi1oBSbY7h9O63i8IU99YsOCdvZ7AD3ePxyM1xJR7CFHycg9Z_IDouYnJmXpTpbFMMl7SjME3cVMfMrAQ" +) + +INVALID_ACCESS_TOKEN = VALID_ACCESS_TOKEN + "1" + +# ---------------------------- ATTACHMENTS ----------------------------- + # Used for _POST_RESPONSE_DATA's as when comparing most of these are not possible to know at runtime arguably we dont # need to put the fields in but it ensures we capture potential changes to how boto3 functions ATTACHMENT_UPLOAD_INFO_POST_RESPONSE_DATA_EXPECTED = { @@ -35,8 +67,6 @@ } } -# ---------------------------- ATTACHMENTS ----------------------------- - # Required values only ATTACHMENT_POST_DATA_REQUIRED_VALUES_ONLY = { diff --git a/test/pytest.ini b/test/pytest.ini index 5233cfa..e85bd41 100644 --- a/test/pytest.ini +++ b/test/pytest.ini @@ -9,6 +9,9 @@ env = API__ALLOWED_CORS_HEADERS=["*"] API__ALLOWED_CORS_ORIGINS=["*"] API__ALLOWED_CORS_METHODS=["*"] + AUTHENTICATION__ENABLED=true + AUTHENTICATION__PUBLIC_KEY_PATH=./test/keys/jwt-key.pub + AUTHENTICATION__JWT_ALGORITHM=RS256 DATABASE__PROTOCOL=mongodb DATABASE__USERNAME=root DATABASE__PASSWORD=example diff --git a/test/unit/auth/__init__.py b/test/unit/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/auth/test_jwt_bearer.py b/test/unit/auth/test_jwt_bearer.py new file mode 100644 index 0000000..7462d3b --- /dev/null +++ b/test/unit/auth/test_jwt_bearer.py @@ -0,0 +1,133 @@ +""" +Unit test for the `JWTBearer` class. +""" + +# pylint:disable=fixme +# TODO: This file is identical to the one in inventory-management-system-api - Use common repo? + +from test.mock_data import EXPIRED_ACCESS_TOKEN, INVALID_ACCESS_TOKEN, VALID_ACCESS_TOKEN +from unittest.mock import Mock, patch + +import pytest +from fastapi import HTTPException, Request +from jwt import ExpiredSignatureError, InvalidTokenError + +from object_storage_api.auth.jwt_bearer import JWTBearer + + +@pytest.fixture(name="request_mock") +def fixture_request_mock() -> Mock: + """ + Fixture to create an empty `Request` mock. + :return: Mocked `Request` instance + """ + request_mock = Mock(Request) + request_mock.headers = {} + return request_mock + + +@patch("object_storage_api.auth.jwt_bearer.jwt.decode") +async def test_jwt_bearer_authorization_request(jwt_decode_mock, request_mock): + """ + Test `JWTBearer` with valid access token. + """ + jwt_decode_mock.return_value = {"exp": 253402300799, "username": "username"} + request_mock.headers = {"Authorization": f"Bearer {VALID_ACCESS_TOKEN}"} + + jwt_bearer = JWTBearer() + await jwt_bearer(request_mock) + + +@patch("object_storage_api.auth.jwt_bearer.jwt.decode") +async def test_jwt_bearer_authorization_request_invalid_bearer_token(jwt_decode_mock, request_mock): + """ + Test `JWTBearer` with invalid access token. + """ + jwt_decode_mock.side_effect = InvalidTokenError() + request_mock.headers = {"Authorization": f"Bearer {INVALID_ACCESS_TOKEN}"} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Invalid token or expired token" + + +@patch("object_storage_api.auth.jwt_bearer.jwt.decode") +async def test_jwt_bearer_authorization_request_expired_bearer_token(jwt_decode_mock, request_mock): + """ + Test `JWTBearer` with expired access token. + """ + jwt_decode_mock.side_effect = ExpiredSignatureError() + request_mock.headers = {"Authorization": f"Bearer {EXPIRED_ACCESS_TOKEN}"} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Invalid token or expired token" + + +@patch("object_storage_api.auth.jwt_bearer.jwt.decode") +async def test_jwt_bearer_authorization_request_missing_username_in_bearer_token(jwt_decode_mock, request_mock): + """ + Test `JWTBearer` with missing username in access token. + """ + jwt_decode_mock.return_value = {"exp": 253402300799} + request_mock.headers = {"Authorization": f"Bearer {VALID_ACCESS_TOKEN}"} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Invalid token or expired token" + + +async def test_jwt_bearer_authorization_request_missing_authorization_header(request_mock): + """ + Test `JWTBearer` with missing authorization header. + """ + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Not authenticated" + + +async def test_jwt_bearer_authorization_request_empty_authorization_header(request_mock): + """ + Test `JWTBearer` with empty authorization header. + """ + request_mock.headers = {"Authorization": ""} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Not authenticated" + + +async def test_jwt_bearer_authorization_request_missing_bearer_token(request_mock): + """ + Test `JWTBearer` with missing access token. + """ + request_mock.headers = {"Authorization": "Bearer "} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Not authenticated" + + +async def test_jwt_bearer_authorization_request_invalid_authorization_scheme(request_mock): + """ + Test `JWTBearer` with invalid authorization scheme. + """ + request_mock.headers = {"Authorization": f"Invalid-Bearer {VALID_ACCESS_TOKEN}"} + + jwt_bearer = JWTBearer() + + with pytest.raises(HTTPException) as exc: + await jwt_bearer(request_mock) + assert str(exc.value) == "403: Invalid authentication credentials"