Skip to content

Commit 3029513

Browse files
committed
basic_auth + tests
1 parent c8a07ae commit 3029513

File tree

7 files changed

+336
-0
lines changed

7 files changed

+336
-0
lines changed

stac_fastapi/core/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"overrides",
1919
"geojson-pydantic",
2020
"pygeofilter==0.2.1",
21+
"typing_extensions==4.4.0",
2122
]
2223

2324
setup(

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from stac_fastapi.core.extensions import QueryExtension
1414
from stac_fastapi.core.session import Session
15+
from stac_fastapi.elasticsearch.basic_auth import apply_basic_auth
1516
from stac_fastapi.elasticsearch.config import ElasticsearchSettings
1617
from stac_fastapi.elasticsearch.database_logic import (
1718
DatabaseLogic,
@@ -77,6 +78,8 @@
7778
app = api.app
7879
app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
7980

81+
apply_basic_auth(api)
82+
8083

8184
@app.on_event("startup")
8285
async def _startup_event() -> None:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Basic Authentication Module."""
2+
3+
import json
4+
import os
5+
import secrets
6+
from typing import Any, Dict
7+
8+
from fastapi import Depends, HTTPException, Request, status
9+
from fastapi.routing import APIRoute
10+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
11+
from typing_extensions import Annotated
12+
13+
from stac_fastapi.api.app import StacApi
14+
15+
security = HTTPBasic()
16+
17+
_BASIC_AUTH: Dict[str, Any] = {}
18+
19+
20+
def has_access(
21+
request: Request, credentials: Annotated[HTTPBasicCredentials, Depends(security)]
22+
) -> str:
23+
"""Check if the provided credentials match the expected \
24+
username and password stored in environment variables for basic authentication.
25+
26+
Args:
27+
request (Request): The FastAPI request object.
28+
credentials (HTTPBasicCredentials): The HTTP basic authentication credentials.
29+
30+
Returns:
31+
str: The username if authentication is successful.
32+
33+
Raises:
34+
HTTPException: If authentication fails due to incorrect username or password.
35+
"""
36+
global _BASIC_AUTH
37+
38+
users = _BASIC_AUTH.get("users")
39+
user: Dict[str, Any] = next(
40+
(u for u in users if u.get("username") == credentials.username), {}
41+
)
42+
43+
if not user:
44+
raise HTTPException(
45+
status_code=status.HTTP_401_UNAUTHORIZED,
46+
detail="Incorrect username or password",
47+
headers={"WWW-Authenticate": "Basic"},
48+
)
49+
50+
# Compare the provided username and password with the correct ones using compare_digest
51+
if not secrets.compare_digest(
52+
credentials.username.encode("utf-8"), user.get("username").encode("utf-8")
53+
) or not secrets.compare_digest(
54+
credentials.password.encode("utf-8"), user.get("password").encode("utf-8")
55+
):
56+
raise HTTPException(
57+
status_code=status.HTTP_401_UNAUTHORIZED,
58+
detail="Incorrect username or password",
59+
headers={"WWW-Authenticate": "Basic"},
60+
)
61+
62+
permissions = user.get("permissions", [])
63+
path = request.url.path
64+
method = request.method
65+
66+
if permissions == "*":
67+
return credentials.username
68+
for permission in permissions:
69+
if permission["path"] == path and method in permission.get("method", []):
70+
return credentials.username
71+
72+
raise HTTPException(
73+
status_code=status.HTTP_403_FORBIDDEN,
74+
detail=f"Insufficient permissions for [{method} {path}]",
75+
)
76+
77+
78+
def apply_basic_auth(api: StacApi) -> None:
79+
"""Apply basic authentication to the provided FastAPI application \
80+
based on environment variables for username, password, and endpoints.
81+
82+
Args:
83+
api (StacApi): The FastAPI application.
84+
85+
Raises:
86+
HTTPException: If there are issues with the configuration or format
87+
of the environment variables.
88+
"""
89+
global _BASIC_AUTH
90+
91+
basic_auth_json_str = os.environ.get("BASIC_AUTH")
92+
if not basic_auth_json_str:
93+
print("Basic authentication disabled.")
94+
return
95+
96+
try:
97+
_BASIC_AUTH = json.loads(basic_auth_json_str)
98+
except json.JSONDecodeError as exception:
99+
print(f"Invalid JSON format for BASIC_AUTH. {exception=}")
100+
raise
101+
public_endpoints = _BASIC_AUTH.get("public_endpoints", [])
102+
users = _BASIC_AUTH.get("users")
103+
if not users:
104+
raise Exception("Invalid JSON format for BASIC_AUTH. Key 'users' undefined.")
105+
106+
app = api.app
107+
for route in app.routes:
108+
if isinstance(route, APIRoute):
109+
for method in route.methods:
110+
endpoint = {"path": route.path, "method": method}
111+
if endpoint not in public_endpoints:
112+
api.add_route_dependencies([endpoint], [Depends(has_access)])
113+
114+
print("Basic authentication enabled.")

stac_fastapi/opensearch/stac_fastapi/opensearch/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
TransactionExtension,
2222
)
2323
from stac_fastapi.extensions.third_party import BulkTransactionExtension
24+
from stac_fastapi.opensearch.basic_auth import apply_basic_auth
2425
from stac_fastapi.opensearch.config import OpensearchSettings
2526
from stac_fastapi.opensearch.database_logic import (
2627
DatabaseLogic,
@@ -77,6 +78,8 @@
7778
app = api.app
7879
app.root_path = os.getenv("STAC_FASTAPI_ROOT_PATH", "")
7980

81+
apply_basic_auth(api)
82+
8083

8184
@app.on_event("startup")
8285
async def _startup_event() -> None:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Basic Authentication Module."""
2+
3+
import json
4+
import os
5+
import secrets
6+
from typing import Any, Dict
7+
8+
from fastapi import Depends, HTTPException, Request, status
9+
from fastapi.routing import APIRoute
10+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
11+
from typing_extensions import Annotated
12+
13+
from stac_fastapi.api.app import StacApi
14+
15+
security = HTTPBasic()
16+
17+
_BASIC_AUTH: Dict[str, Any] = {}
18+
19+
20+
def has_access(
21+
request: Request, credentials: Annotated[HTTPBasicCredentials, Depends(security)]
22+
) -> str:
23+
"""Check if the provided credentials match the expected \
24+
username and password stored in environment variables for basic authentication.
25+
26+
Args:
27+
request (Request): The FastAPI request object.
28+
credentials (HTTPBasicCredentials): The HTTP basic authentication credentials.
29+
30+
Returns:
31+
str: The username if authentication is successful.
32+
33+
Raises:
34+
HTTPException: If authentication fails due to incorrect username or password.
35+
"""
36+
global _BASIC_AUTH
37+
38+
users = _BASIC_AUTH.get("users")
39+
user: Dict[str, Any] = next(
40+
(u for u in users if u.get("username") == credentials.username), {}
41+
)
42+
43+
if not user:
44+
raise HTTPException(
45+
status_code=status.HTTP_401_UNAUTHORIZED,
46+
detail="Incorrect username or password",
47+
headers={"WWW-Authenticate": "Basic"},
48+
)
49+
50+
# Compare the provided username and password with the correct ones using compare_digest
51+
if not secrets.compare_digest(
52+
credentials.username.encode("utf-8"), user.get("username").encode("utf-8")
53+
) or not secrets.compare_digest(
54+
credentials.password.encode("utf-8"), user.get("password").encode("utf-8")
55+
):
56+
raise HTTPException(
57+
status_code=status.HTTP_401_UNAUTHORIZED,
58+
detail="Incorrect username or password",
59+
headers={"WWW-Authenticate": "Basic"},
60+
)
61+
62+
permissions = user.get("permissions", [])
63+
path = request.url.path
64+
method = request.method
65+
66+
if permissions == "*":
67+
return credentials.username
68+
for permission in permissions:
69+
if permission["path"] == path and method in permission.get("method", []):
70+
return credentials.username
71+
72+
raise HTTPException(
73+
status_code=status.HTTP_403_FORBIDDEN,
74+
detail=f"Insufficient permissions for [{method} {path}]",
75+
)
76+
77+
78+
def apply_basic_auth(api: StacApi) -> None:
79+
"""Apply basic authentication to the provided FastAPI application \
80+
based on environment variables for username, password, and endpoints.
81+
82+
Args:
83+
api (StacApi): The FastAPI application.
84+
85+
Raises:
86+
HTTPException: If there are issues with the configuration or format
87+
of the environment variables.
88+
"""
89+
global _BASIC_AUTH
90+
91+
basic_auth_json_str = os.environ.get("BASIC_AUTH")
92+
if not basic_auth_json_str:
93+
print("Basic authentication disabled.")
94+
return
95+
96+
try:
97+
_BASIC_AUTH = json.loads(basic_auth_json_str)
98+
except json.JSONDecodeError as exception:
99+
print(f"Invalid JSON format for BASIC_AUTH. {exception=}")
100+
raise
101+
public_endpoints = _BASIC_AUTH.get("public_endpoints", [])
102+
users = _BASIC_AUTH.get("users")
103+
if not users:
104+
raise Exception("Invalid JSON format for BASIC_AUTH. Key 'users' undefined.")
105+
106+
app = api.app
107+
for route in app.routes:
108+
if isinstance(route, APIRoute):
109+
for method in route.methods:
110+
endpoint = {"path": route.path, "method": method}
111+
if endpoint not in public_endpoints:
112+
api.add_route_dependencies([endpoint], [Depends(has_access)])
113+
114+
print("Basic authentication enabled.")
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os
2+
3+
import pytest
4+
5+
6+
@pytest.mark.asyncio
7+
async def test_get_search_not_authenticated(app_client_basic_auth, ctx):
8+
"""Test public endpoint search without authentication"""
9+
if not os.getenv("BASIC_AUTH"):
10+
pytest.skip()
11+
params = {"id": ctx.item["id"]}
12+
13+
response = await app_client_basic_auth.get("/search", params=params)
14+
15+
assert response.status_code == 200
16+
assert response.json()["features"][0]["geometry"] == ctx.item["geometry"]
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_post_search_authenticated(app_client_basic_auth, ctx):
21+
"""Test protected post search with reader auhtentication"""
22+
if not os.getenv("BASIC_AUTH"):
23+
pytest.skip()
24+
params = {"id": ctx.item["id"]}
25+
headers = {"Authorization": "Basic cmVhZGVyOnJlYWRlcg=="}
26+
27+
response = await app_client_basic_auth.post("/search", json=params, headers=headers)
28+
29+
assert response.status_code == 200
30+
assert response.json()["features"][0]["geometry"] == ctx.item["geometry"]
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_delete_resource_insufficient_permissions(app_client_basic_auth):
35+
"""Test protected delete collection with reader auhtentication"""
36+
if not os.getenv("BASIC_AUTH"):
37+
pytest.skip()
38+
headers = {
39+
"Authorization": "Basic cmVhZGVyOnJlYWRlcg=="
40+
} # Assuming this is a valid authorization token
41+
42+
response = await app_client_basic_auth.delete(
43+
"/collections/test-collection", headers=headers
44+
)
45+
46+
assert (
47+
response.status_code == 403
48+
) # Expecting a 403 status code for insufficient permissions
49+
assert response.json() == {
50+
"detail": "Insufficient permissions for [DELETE /collections/test-collection]"
51+
}

stac_fastapi/tests/conftest.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
create_collection_index,
2626
create_index_templates,
2727
)
28+
from stac_fastapi.opensearch.basic_auth import apply_basic_auth
2829
else:
2930
from stac_fastapi.elasticsearch.config import (
3031
ElasticsearchSettings as SearchSettings,
@@ -35,6 +36,7 @@
3536
create_collection_index,
3637
create_index_templates,
3738
)
39+
from stac_fastapi.elasticsearch.basic_auth import apply_basic_auth
3840

3941
from stac_fastapi.extensions.core import ( # FieldsExtension,
4042
ContextExtension,
@@ -222,3 +224,51 @@ async def app_client(app):
222224

223225
async with AsyncClient(app=app, base_url="http://test-server") as c:
224226
yield c
227+
228+
229+
@pytest_asyncio.fixture(scope="session")
230+
async def app_basic_auth():
231+
settings = AsyncSettings()
232+
extensions = [
233+
TransactionExtension(
234+
client=TransactionsClient(
235+
database=database, session=None, settings=settings
236+
),
237+
settings=settings,
238+
),
239+
ContextExtension(),
240+
SortExtension(),
241+
FieldsExtension(),
242+
QueryExtension(),
243+
TokenPaginationExtension(),
244+
FilterExtension(),
245+
]
246+
247+
post_request_model = create_post_request_model(extensions)
248+
249+
stac_api = StacApi(
250+
settings=settings,
251+
client=CoreClient(
252+
database=database,
253+
session=None,
254+
extensions=extensions,
255+
post_request_model=post_request_model,
256+
),
257+
extensions=extensions,
258+
search_get_request_model=create_get_request_model(extensions),
259+
search_post_request_model=post_request_model,
260+
)
261+
262+
os.environ["BASIC_AUTH"] = '{"public_endpoints":[{"path":"/","method":"GET"},{"path":"/search","method":"GET"}],"users":[{"username":"admin","password":"admin","permissions":"*"},{"username":"reader","password":"reader","permissions":[{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["POST"]},{"path":"/collections","method":["GET"]},{"path":"/collections/{collection_id}","method":["GET"]},{"path":"/collections/{collection_id}/items","method":["GET"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}'
263+
apply_basic_auth(stac_api)
264+
265+
return stac_api.app
266+
267+
268+
@pytest_asyncio.fixture(scope="session")
269+
async def app_client_basic_auth(app_basic_auth):
270+
await create_index_templates()
271+
await create_collection_index()
272+
273+
async with AsyncClient(app=app_basic_auth, base_url="http://test-server") as c:
274+
yield c

0 commit comments

Comments
 (0)