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

Add authentication from ims-api #6 #58

Open
wants to merge 2 commits into
base: develop
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
54 changes: 34 additions & 20 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file added keys/.keep
Empty file.
3 changes: 3 additions & 0 deletions object_storage_api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
68 changes: 68 additions & 0 deletions object_storage_api/auth/jwt_bearer.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 35 additions & 2 deletions object_storage_api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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.
Expand Down Expand Up @@ -76,6 +108,7 @@ class Config(BaseSettings):
"""

api: APIConfig
authentication: AuthenticationConfig
database: DatabaseConfig
object_storage: ObjectStorageConfig
attachment: AttachmentConfig
Expand Down
18 changes: 18 additions & 0 deletions object_storage_api/core/consts.py
Original file line number Diff line number Diff line change
@@ -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}")
27 changes: 24 additions & 3 deletions object_storage_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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("/")
Expand Down
7 changes: 6 additions & 1 deletion test/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
Module providing test fixtures for the e2e tests.
"""

from test.mock_data import VALID_ACCESS_TOKEN

import pytest
from fastapi.testclient import TestClient

from object_storage_api.core.database import get_database
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:
Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions test/e2e/test_jwt_bearer.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions test/keys/jwt-key.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7ZEUHxLoeqG86Qzwpz5btyG1JzYiPVvb3pdCmdWYtU/trUYm5OUcHKph9ZWHtx3sQhTtSwvOax6wi/887pzss6oiokEh3k3xLdXiMeEE/MeCT7AYzVre0kZDLxLuslO3x5HJGG4vltPUOsR7nncVqaBOubNzvQXaczm4l+sO8n/O4I9FODgFMW1h7y3TFhPT3xOPXLwqmHvo6Lk7FkQ97421vFGeSKErt4S9P2OHmeiHLom6eZkHDAIITQ631o61h4wrSKGMqq32q3TKEdAHPBSAgcr2Lui59tFiPiBARqtygyxuIT1dlDk4YbslUkt1wlGVX69h6fx1FWuso5Gh7
34 changes: 32 additions & 2 deletions test/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -35,8 +67,6 @@
}
}

# ---------------------------- ATTACHMENTS -----------------------------

# Required values only

ATTACHMENT_POST_DATA_REQUIRED_VALUES_ONLY = {
Expand Down
3 changes: 3 additions & 0 deletions test/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file added test/unit/auth/__init__.py
Empty file.
Loading