Skip to content

Commit 5bcac32

Browse files
authored
Merge pull request #13 from pedro-cf/basic-auth
Basic Authentication
2 parents ad755fd + 1bf5804 commit 5bcac32

File tree

11 files changed

+445
-1
lines changed

11 files changed

+445
-1
lines changed

.github/workflows/cicd.yml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
5252
- name: Run test suite against Mongo
5353
run: |
54-
pipenv run pytest -svvv
54+
pipenv run pytest -k "not basic_auth" -svvv
5555
env:
5656
MONGO_HOST: 172.17.0.1
5757
BACKEND: mongo
@@ -62,3 +62,42 @@ jobs:
6262
MONGO_USER: root
6363
MONGO_PASS: example
6464
MONGO_PORT: 27017
65+
66+
- name: Run test suite against Mongo w/ Basic Auth
67+
run: |
68+
pipenv run pytest -k "basic_auth" -svvv
69+
env:
70+
MONGO_HOST: 172.17.0.1
71+
BACKEND: mongo
72+
APP_HOST: 0.0.0.0
73+
APP_PORT: 8084
74+
ENVIRONMENT: testing
75+
MONGO_DB: stac
76+
MONGO_USER: root
77+
MONGO_PASS: example
78+
MONGO_PORT: 27017
79+
BASIC_AUTH: >
80+
{
81+
"public_endpoints": [
82+
{"path": "/","method": "GET"},
83+
{"path": "/search","method": "GET"}
84+
],
85+
"users": [
86+
{"username": "admin", "password": "admin", "permissions": "*"},
87+
{
88+
"username": "reader",
89+
"password": "reader",
90+
"permissions": [
91+
{"path": "/conformance","method": ["GET"]},
92+
{"path": "/collections/{collection_id}/items/{item_id}","method": ["GET"]},
93+
{"path": "/search","method": ["POST"]},
94+
{"path": "/collections","method": ["GET"]},
95+
{"path": "/collections/{collection_id}","method": ["GET"]},
96+
{"path": "/collections/{collection_id}/items","method": ["GET"]},
97+
{"path": "/queryables","method": ["GET"]},
98+
{"path": "/queryables/collections/{collection_id}/queryables","method": ["GET"]},
99+
{"path": "/_mgmt/ping","method": ["GET"]}
100+
]
101+
}
102+
]
103+
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0/
2424

2525
- Removed bulk transactions extension from app.py
2626
- Fixed pagination issue with MongoDB. Fixes [#1](https://github.com/Healy-Hyperspatial/stac-fastapi-mongo/issues/1)
27+
- Added option to include Basic Auth. [#12](https://github.com/Healy-Hyperspatial/stac-fastapi-mongo/issues/12)
2728

2829

2930
## [v3.0.0]

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,82 @@ make test
8181
```shell
8282
make ingest
8383
```
84+
85+
## Basic Auth
86+
87+
#### Environment Variable Configuration
88+
89+
Basic authentication is an optional feature. You can enable it by setting the environment variable `BASIC_AUTH` as a JSON string.
90+
91+
Example:
92+
```
93+
BASIC_AUTH={"users":[{"username":"user","password":"pass","permissions":"*"}]}
94+
```
95+
96+
### User Permissions Configuration
97+
98+
In order to set endpoints with specific access permissions, you can configure the `users` key with a list of user objects. Each user object should contain the username, password, and their respective permissions.
99+
100+
Example: This example illustrates the configuration for two users: an **admin** user with full permissions (*) and a **reader** user with limited permissions to specific read-only endpoints.
101+
```json
102+
{
103+
"users": [
104+
{
105+
"username": "admin",
106+
"password": "admin",
107+
"permissions": "*"
108+
},
109+
{
110+
"username": "reader",
111+
"password": "reader",
112+
"permissions": [
113+
{"path": "/", "method": ["GET"]},
114+
{"path": "/conformance", "method": ["GET"]},
115+
{"path": "/collections/{collection_id}/items/{item_id}", "method": ["GET"]},
116+
{"path": "/search", "method": ["GET", "POST"]},
117+
{"path": "/collections", "method": ["GET"]},
118+
{"path": "/collections/{collection_id}", "method": ["GET"]},
119+
{"path": "/collections/{collection_id}/items", "method": ["GET"]},
120+
{"path": "/queryables", "method": ["GET"]},
121+
{"path": "/queryables/collections/{collection_id}/queryables", "method": ["GET"]},
122+
{"path": "/_mgmt/ping", "method": ["GET"]}
123+
]
124+
}
125+
]
126+
}
127+
```
128+
129+
130+
### Public Endpoints Configuration
131+
132+
In order to set endpoints with public access, you can configure the public_endpoints key with a list of endpoint objects. Each endpoint object should specify the path and method of the endpoint.
133+
134+
Example: This example demonstrates the configuration for public endpoints, allowing access without authentication to read-only endpoints.
135+
```json
136+
{
137+
"public_endpoints": [
138+
{"path": "/", "method": "GET"},
139+
{"path": "/conformance", "method": "GET"},
140+
{"path": "/collections/{collection_id}/items/{item_id}", "method": "GET"},
141+
{"path": "/search", "method": "GET"},
142+
{"path": "/search", "method": "POST"},
143+
{"path": "/collections", "method": "GET"},
144+
{"path": "/collections/{collection_id}", "method": "GET"},
145+
{"path": "/collections/{collection_id}/items", "method": "GET"},
146+
{"path": "/queryables", "method": "GET"},
147+
{"path": "/queryables/collections/{collection_id}/queryables", "method": "GET"},
148+
{"path": "/_mgmt/ping", "method": "GET"}
149+
],
150+
"users": [
151+
{
152+
"username": "admin",
153+
"password": "admin",
154+
"permissions": "*"
155+
}
156+
]
157+
}
158+
```
159+
160+
### Basic Authentication Configurations
161+
162+
See `docker-compose.basic_auth_protected.yml` and `docker-compose.basic_auth_public.yml` for basic authentication configurations.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
version: '3.9'
2+
3+
services:
4+
app-mongo:
5+
container_name: stac-fastapi-mongo
6+
image: stac-utils/stac-fastapi-mongo
7+
restart: always
8+
build:
9+
context: .
10+
dockerfile: dockerfiles/Dockerfile.dev.mongo
11+
environment:
12+
- APP_HOST=0.0.0.0
13+
- APP_PORT=8084
14+
- RELOAD=true
15+
- ENVIRONMENT=local
16+
- BACKEND=mongo
17+
- MONGO_DB=stac
18+
- MONGO_HOST=mongo
19+
- MONGO_USER=root
20+
- MONGO_PASS=example
21+
- MONGO_PORT=27017
22+
- BASIC_AUTH={"users":[{"username":"admin","password":"admin","permissions":"*"},{"username":"reader","password":"reader","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET"]},{"path":"/search","method":["GET","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"]}]}]}
23+
ports:
24+
- "8084:8084"
25+
volumes:
26+
- ./stac_fastapi:/app/stac_fastapi
27+
- ./scripts:/app/scripts
28+
depends_on:
29+
- mongo
30+
command:
31+
bash -c "./scripts/wait-for-it-es.sh mongo-container:27017 && python -m stac_fastapi.mongo.app"
32+
33+
mongo:
34+
container_name: mongo-container
35+
image: mongo:7.0.5
36+
hostname: mongo
37+
environment:
38+
- MONGO_INITDB_ROOT_USERNAME=root
39+
- MONGO_INITDB_ROOT_PASSWORD=example
40+
ports:
41+
- "27017:27017"
42+
43+
mongo-express:
44+
image: mongo-express
45+
restart: always
46+
ports:
47+
- "8081:8081"
48+
environment:
49+
- ME_CONFIG_MONGODB_ADMINUSERNAME=root
50+
- ME_CONFIG_MONGODB_ADMINPASSWORD=example
51+
- ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
version: '3.9'
2+
3+
services:
4+
app-mongo:
5+
container_name: stac-fastapi-mongo
6+
image: stac-utils/stac-fastapi-mongo
7+
restart: always
8+
build:
9+
context: .
10+
dockerfile: dockerfiles/Dockerfile.dev.mongo
11+
environment:
12+
- APP_HOST=0.0.0.0
13+
- APP_PORT=8084
14+
- RELOAD=true
15+
- ENVIRONMENT=local
16+
- BACKEND=mongo
17+
- MONGO_DB=stac
18+
- MONGO_HOST=mongo
19+
- MONGO_USER=root
20+
- MONGO_PASS=example
21+
- MONGO_PORT=27017
22+
- BASIC_AUTH={"public_endpoints":[{"path":"/","method":"GET"},{"path":"/conformance","method":"GET"},{"path":"/collections/{collection_id}/items/{item_id}","method":"GET"},{"path":"/search","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"}],"users":[{"username":"admin","password":"admin","permissions":[{"path":"/","method":["GET"]},{"path":"/conformance","method":["GET"]},{"path":"/collections/{collection_id}/items/{item_id}","method":["GET","POST","PUT","DELETE"]},{"path":"/search","method":["GET","POST"]},{"path":"/collections","method":["GET","PUT","POST"]},{"path":"/collections/{collection_id}","method":["GET","DELETE"]},{"path":"/collections/{collection_id}/items","method":["GET","POST"]},{"path":"/queryables","method":["GET"]},{"path":"/queryables/collections/{collection_id}/queryables","method":["GET"]},{"path":"/_mgmt/ping","method":["GET"]}]}]}
23+
ports:
24+
- "8084:8084"
25+
volumes:
26+
- ./stac_fastapi:/app/stac_fastapi
27+
- ./scripts:/app/scripts
28+
depends_on:
29+
- mongo
30+
command:
31+
bash -c "./scripts/wait-for-it-es.sh mongo-container:27017 && python -m stac_fastapi.mongo.app"
32+
33+
mongo:
34+
container_name: mongo-container
35+
image: mongo:7.0.5
36+
hostname: mongo
37+
environment:
38+
- MONGO_INITDB_ROOT_USERNAME=root
39+
- MONGO_INITDB_ROOT_PASSWORD=example
40+
ports:
41+
- "27017:27017"
42+
43+
mongo-express:
44+
image: mongo-express
45+
restart: always
46+
ports:
47+
- "8081:8081"
48+
environment:
49+
- ME_CONFIG_MONGODB_ADMINUSERNAME=root
50+
- ME_CONFIG_MONGODB_ADMINPASSWORD=example
51+
- ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"pymongo==4.6.2",
1212
"uvicorn",
1313
"starlette",
14+
"typing_extensions==4.4.0",
1415
]
1516

1617
extra_reqs = {

stac_fastapi/mongo/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
TokenPaginationExtension,
1818
TransactionExtension,
1919
)
20+
from stac_fastapi.mongo.basic_auth import apply_basic_auth
2021

2122
# from stac_fastapi.extensions.third_party import BulkTransactionExtension
2223
from stac_fastapi.mongo.config import AsyncMongoDBSettings
@@ -71,6 +72,8 @@
7172
)
7273
app = api.app
7374

75+
apply_basic_auth(api)
76+
7477

7578
@app.on_event("startup")
7679
async def _startup_event() -> None:

stac_fastapi/mongo/basic_auth.py

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/tests/basic_auth/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)