Skip to content

Commit

Permalink
Merge pull request #13 from pedro-cf/basic-auth
Browse files Browse the repository at this point in the history
Basic Authentication
  • Loading branch information
jonhealy1 authored Apr 23, 2024
2 parents ad755fd + 1bf5804 commit 5bcac32
Show file tree
Hide file tree
Showing 11 changed files with 445 additions and 1 deletion.
41 changes: 40 additions & 1 deletion .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
- name: Run test suite against Mongo
run: |
pipenv run pytest -svvv
pipenv run pytest -k "not basic_auth" -svvv
env:
MONGO_HOST: 172.17.0.1
BACKEND: mongo
Expand All @@ -62,3 +62,42 @@ jobs:
MONGO_USER: root
MONGO_PASS: example
MONGO_PORT: 27017

- name: Run test suite against Mongo w/ Basic Auth
run: |
pipenv run pytest -k "basic_auth" -svvv
env:
MONGO_HOST: 172.17.0.1
BACKEND: mongo
APP_HOST: 0.0.0.0
APP_PORT: 8084
ENVIRONMENT: testing
MONGO_DB: stac
MONGO_USER: root
MONGO_PASS: example
MONGO_PORT: 27017
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"]}
]
}
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0/

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


## [v3.0.0]
Expand Down
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,82 @@ make test
```shell
make ingest
```

## Basic Auth

#### Environment Variable Configuration

Basic authentication is an optional feature. You can enable it by setting the environment variable `BASIC_AUTH` as a JSON string.

Example:
```
BASIC_AUTH={"users":[{"username":"user","password":"pass","permissions":"*"}]}
```

### User Permissions Configuration

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.

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.
```json
{
"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"]}
]
}
]
}
```


### Public Endpoints Configuration

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.

Example: This example demonstrates the configuration for public endpoints, allowing access without authentication to read-only endpoints.
```json
{
"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": "*"
}
]
}
```

### Basic Authentication Configurations

See `docker-compose.basic_auth_protected.yml` and `docker-compose.basic_auth_public.yml` for basic authentication configurations.
51 changes: 51 additions & 0 deletions docker-compose.basic_auth_protected.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
version: '3.9'

services:
app-mongo:
container_name: stac-fastapi-mongo
image: stac-utils/stac-fastapi-mongo
restart: always
build:
context: .
dockerfile: dockerfiles/Dockerfile.dev.mongo
environment:
- APP_HOST=0.0.0.0
- APP_PORT=8084
- RELOAD=true
- ENVIRONMENT=local
- BACKEND=mongo
- MONGO_DB=stac
- MONGO_HOST=mongo
- MONGO_USER=root
- MONGO_PASS=example
- MONGO_PORT=27017
- 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"]}]}]}
ports:
- "8084:8084"
volumes:
- ./stac_fastapi:/app/stac_fastapi
- ./scripts:/app/scripts
depends_on:
- mongo
command:
bash -c "./scripts/wait-for-it-es.sh mongo-container:27017 && python -m stac_fastapi.mongo.app"

mongo:
container_name: mongo-container
image: mongo:7.0.5
hostname: mongo
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
ports:
- "27017:27017"

mongo-express:
image: mongo-express
restart: always
ports:
- "8081:8081"
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=root
- ME_CONFIG_MONGODB_ADMINPASSWORD=example
- ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/
51 changes: 51 additions & 0 deletions docker-compose.basic_auth_public.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
version: '3.9'

services:
app-mongo:
container_name: stac-fastapi-mongo
image: stac-utils/stac-fastapi-mongo
restart: always
build:
context: .
dockerfile: dockerfiles/Dockerfile.dev.mongo
environment:
- APP_HOST=0.0.0.0
- APP_PORT=8084
- RELOAD=true
- ENVIRONMENT=local
- BACKEND=mongo
- MONGO_DB=stac
- MONGO_HOST=mongo
- MONGO_USER=root
- MONGO_PASS=example
- MONGO_PORT=27017
- 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"]}]}]}
ports:
- "8084:8084"
volumes:
- ./stac_fastapi:/app/stac_fastapi
- ./scripts:/app/scripts
depends_on:
- mongo
command:
bash -c "./scripts/wait-for-it-es.sh mongo-container:27017 && python -m stac_fastapi.mongo.app"

mongo:
container_name: mongo-container
image: mongo:7.0.5
hostname: mongo
environment:
- MONGO_INITDB_ROOT_USERNAME=root
- MONGO_INITDB_ROOT_PASSWORD=example
ports:
- "27017:27017"

mongo-express:
image: mongo-express
restart: always
ports:
- "8081:8081"
environment:
- ME_CONFIG_MONGODB_ADMINUSERNAME=root
- ME_CONFIG_MONGODB_ADMINPASSWORD=example
- ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"pymongo==4.6.2",
"uvicorn",
"starlette",
"typing_extensions==4.4.0",
]

extra_reqs = {
Expand Down
3 changes: 3 additions & 0 deletions stac_fastapi/mongo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TokenPaginationExtension,
TransactionExtension,
)
from stac_fastapi.mongo.basic_auth import apply_basic_auth

# from stac_fastapi.extensions.third_party import BulkTransactionExtension
from stac_fastapi.mongo.config import AsyncMongoDBSettings
Expand Down Expand Up @@ -71,6 +72,8 @@
)
app = api.app

apply_basic_auth(api)


@app.on_event("startup")
async def _startup_event() -> None:
Expand Down
114 changes: 114 additions & 0 deletions stac_fastapi/mongo/basic_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Basic Authentication Module."""

import json
import os
import secrets
from typing import Any, Dict

from fastapi import Depends, HTTPException, Request, status
from fastapi.routing import APIRoute
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing_extensions import Annotated

from stac_fastapi.api.app import StacApi

security = HTTPBasic()

_BASIC_AUTH: Dict[str, Any] = {}


def has_access(
request: Request, credentials: Annotated[HTTPBasicCredentials, Depends(security)]
) -> str:
"""Check if the provided credentials match the expected \
username and password stored in environment variables for basic authentication.
Args:
request (Request): The FastAPI request object.
credentials (HTTPBasicCredentials): The HTTP basic authentication credentials.
Returns:
str: The username if authentication is successful.
Raises:
HTTPException: If authentication fails due to incorrect username or password.
"""
global _BASIC_AUTH

users = _BASIC_AUTH.get("users")
user: Dict[str, Any] = next(
(u for u in users if u.get("username") == credentials.username), {}
)

if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

# Compare the provided username and password with the correct ones using compare_digest
if not secrets.compare_digest(
credentials.username.encode("utf-8"), user.get("username").encode("utf-8")
) or not secrets.compare_digest(
credentials.password.encode("utf-8"), user.get("password").encode("utf-8")
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)

permissions = user.get("permissions", [])
path = request.url.path
method = request.method

if permissions == "*":
return credentials.username
for permission in permissions:
if permission["path"] == path and method in permission.get("method", []):
return credentials.username

raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions for [{method} {path}]",
)


def apply_basic_auth(api: StacApi) -> None:
"""Apply basic authentication to the provided FastAPI application \
based on environment variables for username, password, and endpoints.
Args:
api (StacApi): The FastAPI application.
Raises:
HTTPException: If there are issues with the configuration or format
of the environment variables.
"""
global _BASIC_AUTH

basic_auth_json_str = os.environ.get("BASIC_AUTH")
if not basic_auth_json_str:
print("Basic authentication disabled.")
return

try:
_BASIC_AUTH = json.loads(basic_auth_json_str)
except json.JSONDecodeError as exception:
print(f"Invalid JSON format for BASIC_AUTH. {exception=}")
raise
public_endpoints = _BASIC_AUTH.get("public_endpoints", [])
users = _BASIC_AUTH.get("users")
if not users:
raise Exception("Invalid JSON format for BASIC_AUTH. Key 'users' undefined.")

app = api.app
for route in app.routes:
if isinstance(route, APIRoute):
for method in route.methods:
endpoint = {"path": route.path, "method": method}
if endpoint not in public_endpoints:
api.add_route_dependencies([endpoint], [Depends(has_access)])

print("Basic authentication enabled.")
Empty file.
Loading

0 comments on commit 5bcac32

Please sign in to comment.