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

Update HTTP Basic auth for FastAPI and Starlette #32

Merged
merged 1 commit into from
Apr 18, 2021
Merged
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
23 changes: 16 additions & 7 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 1 addition & 1 deletion inboard/app/main_starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand Down
22 changes: 13 additions & 9 deletions inboard/app/utilities_fastapi.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
import secrets
from pathlib import Path
from secrets import compare_digest
from typing import List, Optional

import toml
Expand All @@ -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
Expand Down
23 changes: 12 additions & 11 deletions inboard/app/utilities_starlette.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import base64
import os
from secrets import compare_digest
import secrets
from typing import Optional, Tuple

from starlette.authentication import (
Expand All @@ -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
56 changes: 49 additions & 7 deletions tests/app/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand All @@ -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(
Expand Down Expand Up @@ -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"
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down