diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 653e981..4189710 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -78,9 +78,18 @@ jobs: docker build . --rm --target fastapi --build-arg PYTHON_VERSION=${{ matrix.python-version }} -t ghcr.io/br3ndonland/inboard:fastapi - name: Run Docker containers for testing run: | - docker run -d -p 80:80 ghcr.io/br3ndonland/inboard:base - docker run -d -p 81:80 ghcr.io/br3ndonland/inboard:starlette - docker run -d -p 82:80 ghcr.io/br3ndonland/inboard:fastapi + docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" \ + -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + ghcr.io/br3ndonland/inboard:base + docker run -d -p 81:80 \ + -e "BASIC_AUTH_USERNAME=test_user" \ + -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + ghcr.io/br3ndonland/inboard:starlette + docker run -d -p 82:80 \ + -e "BASIC_AUTH_USERNAME=test_user" \ + -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + ghcr.io/br3ndonland/inboard:fastapi - name: Smoke test Docker containers run: | handle_error_code() { @@ -113,10 +122,10 @@ jobs: smoke_test :80 smoke_test :81 smoke_test :82 - smoke_test -a test_username:plunge-germane-tribal-pillar :81/status - smoke_test -a test_username:plunge-germane-tribal-pillar :82/status - smoke_test_xfail -a test_username:plunge-germane-tribal-incorrect :81/status - smoke_test_xfail -a test_username:plunge-germane-tribal-incorrect :82/status + smoke_test -a test_user:r4ndom_bUt_memorable :81/status + smoke_test -a test_user:r4ndom_bUt_memorable :82/status + smoke_test_xfail -a test_user:incorrect_password :81/status + smoke_test_xfail -a test_user:incorrect_password :82/status smoke_test_xfail :81/status smoke_test_xfail :82/status - name: Push Docker images with latest Python version to registry diff --git a/.vscode/launch.json b/.vscode/launch.json index c2f49a3..f8bb3d5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,8 +11,8 @@ "module": "inboard.start", "env": { "APP_MODULE": "inboard.app.main_fastapi:app", - "BASIC_AUTH_USERNAME": "test_username", - "BASIC_AUTH_PASSWORD": "plunge-germane-tribal-pillar", + "BASIC_AUTH_USERNAME": "test_user", + "BASIC_AUTH_PASSWORD": "r4ndom_bUt_memorable", "LOG_FORMAT": "uvicorn", "LOG_LEVEL": "debug", "PORT": "8000", @@ -37,8 +37,8 @@ "inboard" ], "env": { - "BASIC_AUTH_USERNAME": "test_username", - "BASIC_AUTH_PASSWORD": "plunge-germane-tribal-pillar", + "BASIC_AUTH_USERNAME": "test_user", + "BASIC_AUTH_PASSWORD": "r4ndom_bUt_memorable", "WITH_RELOAD": "true" }, "jinja": true diff --git a/README.md b/README.md index 0707bdd..2d7a64e 100644 --- a/README.md +++ b/README.md @@ -498,24 +498,33 @@ docker build . --rm --target starlette -t localhost/br3ndonland/inboard:starlett cd inboard docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ -e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \ -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:base docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ -e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \ -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:fastapi docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ -e "LOG_LEVEL=debug" -e "PROCESS_MANAGER=uvicorn" -e "WITH_RELOAD=true" \ -v $(pwd)/inboard:/app/inboard localhost/br3ndonland/inboard:starlette # Run Docker container with Gunicorn and Uvicorn -docker run -d -p 80:80 localhost/br3ndonland/inboard:base -docker run -d -p 80:80 localhost/br3ndonland/inboard:fastapi -docker run -d -p 80:80 localhost/br3ndonland/inboard:starlette +docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + localhost/br3ndonland/inboard:base +docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + localhost/br3ndonland/inboard:fastapi +docker run -d -p 80:80 \ + -e "BASIC_AUTH_USERNAME=test_user" -e "BASIC_AUTH_PASSWORD=r4ndom_bUt_memorable" \ + localhost/br3ndonland/inboard:starlette -# Test HTTP Basic Auth when running the FastAPI or Starlette images: -http :80/status -a test_username:plunge-germane-tribal-pillar +# Test HTTP Basic auth when running the FastAPI or Starlette images: +http :80/status -a test_user:r4ndom_bUt_memorable ``` Change the port numbers to run multiple containers simultaneously (`-p 81:80`). diff --git a/inboard/app/main_starlette.py b/inboard/app/main_starlette.py index 71ce833..c69deb1 100644 --- a/inboard/app/main_starlette.py +++ b/inboard/app/main_starlette.py @@ -21,7 +21,7 @@ def on_auth_error(request: Request, e: Exception) -> JSONResponse: return JSONResponse( - {"detail": "Incorrect username or password", "error": str(e)}, status_code=401 + {"error": "Incorrect username or password", "detail": str(e)}, status_code=401 ) diff --git a/inboard/app/utilities_fastapi.py b/inboard/app/utilities_fastapi.py index b4968aa..89e1fd6 100644 --- a/inboard/app/utilities_fastapi.py +++ b/inboard/app/utilities_fastapi.py @@ -1,6 +1,6 @@ import os +import secrets from pathlib import Path -from secrets import compare_digest from typing import List, Optional import toml @@ -10,17 +10,21 @@ async def basic_auth(credentials: HTTPBasicCredentials = Depends(HTTPBasic())) -> str: - correct_username = compare_digest( - credentials.username, str(os.getenv("BASIC_AUTH_USERNAME", "test_username")) - ) - correct_password = compare_digest( - credentials.password, - str(os.getenv("BASIC_AUTH_PASSWORD", "plunge-germane-tribal-pillar")), - ) + """Authenticate a FastAPI request with HTTP Basic auth.""" + basic_auth_username = os.getenv("BASIC_AUTH_USERNAME") + basic_auth_password = os.getenv("BASIC_AUTH_PASSWORD") + if not (basic_auth_username and basic_auth_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Server HTTP Basic auth credentials not set", + headers={"WWW-Authenticate": "Basic"}, + ) + correct_username = secrets.compare_digest(credentials.username, basic_auth_username) + correct_password = secrets.compare_digest(credentials.password, basic_auth_password) if not (correct_username and correct_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="HTTP Basic auth credentials not correct", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username diff --git a/inboard/app/utilities_starlette.py b/inboard/app/utilities_starlette.py index e4a24d1..ef1d427 100644 --- a/inboard/app/utilities_starlette.py +++ b/inboard/app/utilities_starlette.py @@ -1,6 +1,6 @@ import base64 import os -from secrets import compare_digest +import secrets from typing import Optional, Tuple from starlette.authentication import ( @@ -13,26 +13,27 @@ class BasicAuth(AuthenticationBackend): + """Configure HTTP Basic auth for Starlette.""" + async def authenticate( self, request: HTTPConnection ) -> Optional[Tuple[AuthCredentials, SimpleUser]]: + """Authenticate a Starlette request with HTTP Basic auth.""" if "Authorization" not in request.headers: return None - - auth = request.headers["Authorization"] try: + auth = request.headers["Authorization"] + basic_auth_username = os.getenv("BASIC_AUTH_USERNAME") + basic_auth_password = os.getenv("BASIC_AUTH_PASSWORD") + if not (basic_auth_username and basic_auth_password): + raise AuthenticationError("Server HTTP Basic auth credentials not set") scheme, credentials = auth.split() decoded = base64.b64decode(credentials).decode("ascii") username, _, password = decoded.partition(":") - correct_username = compare_digest( - username, str(os.getenv("BASIC_AUTH_USERNAME", "test_username")) - ) - correct_password = compare_digest( - password, - str(os.getenv("BASIC_AUTH_PASSWORD", "plunge-germane-tribal-pillar")), - ) + correct_username = secrets.compare_digest(username, basic_auth_username) + correct_password = secrets.compare_digest(password, basic_auth_password) if not (correct_username and correct_password): - raise AuthenticationError("Invalid basic auth credentials") + raise AuthenticationError("HTTP Basic auth credentials not correct") return AuthCredentials(["authenticated"]), SimpleUser(username) except Exception: raise diff --git a/tests/app/test_main.py b/tests/app/test_main.py index cdbb5b9..7b0b56b 100644 --- a/tests/app/test_main.py +++ b/tests/app/test_main.py @@ -168,7 +168,7 @@ def test_get_root(self, clients: List[TestClient]) -> None: def test_gets_with_basic_auth( self, basic_auth: tuple, clients: List[TestClient], endpoint: str ) -> None: - """Test `GET` requests to endpoints that require HTTP Basic Auth.""" + """Test `GET` requests to endpoints that require HTTP Basic auth.""" for client in clients: assert client.get(endpoint).status_code in [401, 403] response = client.get(endpoint, auth=basic_auth) @@ -182,7 +182,7 @@ def test_gets_with_basic_auth( def test_gets_with_basic_auth_incorrect( self, basic_auth: tuple, clients: List[TestClient], endpoint: str ) -> None: - """Test `GET` requests to Basic Auth endpoints with incorrect credentials.""" + """Test `GET` requests with incorrect HTTP Basic auth credentials.""" basic_auth_username, basic_auth_password = basic_auth for client in clients: assert client.get(endpoint).status_code in [401, 403] @@ -197,17 +197,59 @@ def test_gets_with_basic_auth_incorrect( assert response.status_code == 200 @pytest.mark.parametrize("endpoint", ["/health", "/status"]) - def test_gets_with_starlette_auth_exception( + def test_gets_with_fastapi_auth_incorrect_credentials( + self, clients: List[TestClient], endpoint: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test FastAPI `GET` requests with incorrect HTTP Basic auth credentials.""" + monkeypatch.setenv("BASIC_AUTH_USERNAME", "test_user") + monkeypatch.setenv("BASIC_AUTH_PASSWORD", "r4ndom_bUt_memorable") + fastapi_client = clients[0] + response = fastapi_client.get(endpoint, auth=("user", "pass")) + assert isinstance(fastapi_client.app, FastAPI) + assert response.status_code in [401, 403] + assert response.json() == {"detail": "HTTP Basic auth credentials not correct"} + + @pytest.mark.parametrize("endpoint", ["/health", "/status"]) + def test_gets_with_fastapi_auth_no_credentials( self, clients: List[TestClient], endpoint: str ) -> None: - """Test Starlette `GET` requests with incorrect Basic Auth credentials.""" + """Test FastAPI `GET` requests without HTTP Basic auth credentials set.""" + fastapi_client = clients[0] + response = fastapi_client.get(endpoint, auth=("user", "pass")) + assert isinstance(fastapi_client.app, FastAPI) + assert response.status_code in [401, 403] + assert response.json() == { + "detail": "Server HTTP Basic auth credentials not set" + } + + @pytest.mark.parametrize("endpoint", ["/health", "/status"]) + def test_gets_with_starlette_auth_incorrect_credentials( + self, clients: List[TestClient], endpoint: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test Starlette `GET` requests with incorrect HTTP Basic auth credentials.""" + monkeypatch.setenv("BASIC_AUTH_USERNAME", "test_user") + monkeypatch.setenv("BASIC_AUTH_PASSWORD", "r4ndom_bUt_memorable") starlette_client = clients[1] + response = starlette_client.get(endpoint, auth=("user", "pass")) assert isinstance(starlette_client.app, Starlette) + assert response.status_code in [401, 403] + assert response.json() == { + "detail": "HTTP Basic auth credentials not correct", + "error": "Incorrect username or password", + } + + @pytest.mark.parametrize("endpoint", ["/health", "/status"]) + def test_gets_with_starlette_auth_no_credentials( + self, clients: List[TestClient], endpoint: str + ) -> None: + """Test Starlette `GET` requests without HTTP Basic auth credentials set.""" + starlette_client = clients[1] response = starlette_client.get(endpoint, auth=("user", "pass")) + assert isinstance(starlette_client.app, Starlette) assert response.status_code in [401, 403] assert response.json() == { - "detail": "Incorrect username or password", - "error": "Invalid basic auth credentials", + "detail": "Server HTTP Basic auth credentials not set", + "error": "Incorrect username or password", } def test_get_status_message( @@ -245,4 +287,4 @@ def test_get_user( assert response.status_code == 200 assert "application" not in response.json().keys() assert "status" not in response.json().keys() - assert response.json()["username"] == "test_username" + assert response.json()["username"] == "test_user" diff --git a/tests/conftest.py b/tests/conftest.py index 8b869ae..c6ac46f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,10 +27,10 @@ def app_module_tmp_path(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture def basic_auth( monkeypatch: pytest.MonkeyPatch, - username: str = "test_username", - password: str = "plunge-germane-tribal-pillar", + username: str = "test_user", + password: str = "r4ndom_bUt_memorable", ) -> tuple: - """Set username and password for HTTP Basic Auth.""" + """Set username and password for HTTP Basic auth.""" monkeypatch.setenv("BASIC_AUTH_USERNAME", username) monkeypatch.setenv("BASIC_AUTH_PASSWORD", password) assert os.getenv("BASIC_AUTH_USERNAME") == username