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

Include authentication in endpoints #112

Merged
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
AWS_BUCKET_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
SECRET_ACCESS_KEY=
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,40 @@ If you don't have a certificate for the processor, you can create a self-signed

As you are using a self-signed certificate you will need to import the created CA (using the `.pem` file) in your browser as a trusted CA.

##### Configuring OAuth2 in the endpoints

By default, all the endpoints exposed by the processors are accessible by everyone with access to the LAN. To avoid this vulnerability, the processor includes the possibility of configuring OAuth2 to keep your API secure.

To configure OAuth2 in the processor you need to follow these steps:
1. Enabling OAuth2 in the API by setting in `True` the parameter `UseAuthToken` (included in the `API` section).
2. Set into the container the env `SECRET_ACCESS_KEY`. This env is used to encode the JWT token. An easy way to do that is
to create a `.env` file (following the template `.env.example`) and pass the flag ```--env-file .env ``` when you run the processor.
3. Create an API user. You can do that in two ways:
1. Using the `create_api_user.py` script:

Inside the docker container, execute the script `python3 create_api_user.py --user=<USER> --password=<PASSWORD>`. For example, if you are using an x86 device, you can execute the following script.
```bash
docker run -it -p HOST_PORT:8000 -v "$PWD":/repo -e TZ=`./timezone.sh` neuralet/smart-social-distancing:latest-x86_64 python3 create_api_user.py --user=<USER> --password=<PASSWORD>
```
2. Using the `/auth/create_api_user` endpoint:
Send a POST request to the endpoint `http://<PROCESSOR_HOST>:<PROCESSOR_PORT>/auth/create_api_user` with the following body:
```
{
"user": <USER>,
"password": <PASSWORD>
}
```

After executing one of these steps, the `user` and `password` (hashed) will be stored in the file `/repo/data/auth/api_user.txt` inside the container. To avoid losing that file when the container is restarted, we recommend mounting the `/repo` directory as a volume.
4. Request a valid token. You can obtain one by sending a PUT request to the endpoint `http://<PROCESSOR_HOST>:<PROCESSOR_PORT>/auth/access_token` with the following body:
```
{
"user": <USER>,
"password": <PASSWORD>
}
```
The obtained token will be valid for 1 week (then a new one must be requested from the API) and needs to be sent as an `Authorization` header in all the requests. If you don't send the token (when the `UseAuthToken` attribute is set in `True`), you will receive a `401 Unauthorized` response.

##### Run on Jetson Nano
* You need to have JetPack 4.3 installed on your Jetson Nano.

Expand Down Expand Up @@ -259,6 +293,7 @@ All the configurations are grouped in *sections* and some of them can vary depen
- `[Api]`
- `Host`: Configures the host IP of the processor's API (inside docker). We recommend don't change that value and keep it as *0.0.0.0*.
- `Post`: Configures the port of the processor's API (inside docker). Take care that if you change the default value (*8000*) you will need to change the startup command to expose the configured endpoint.
- `UseAuthToken`: A boolean parameter to enable/disable OAuth2 in the API. If you set this value in *True* remember to follow the steps explained in the section [Configuring OAuth2 in the endpoints](#configuring-oauth2-in-the-endpoints).
- `SSLEnabled`: A boolean parameter to enable/disable https/ssl in the API. We recommend setting this value in *True*.
- `SSLCertificateFile`: Specifies the location of the SSL certificate (required when you have *SSL enabled*). If you generate it following the steps defined in this Readme you should put */repo/certs/<your_ip>.crt*
- [`SSLKeyFile`]: Specifies the location of the SSL key file (required when you have *SSL enabled*). If you generate it following the steps defined in this Readme you should put */repo/certs/<your_ip>.key*
Expand Down Expand Up @@ -380,6 +415,7 @@ The available endpoints are grouped in the following subapis:
- `/metrics`: a set of endpoints to retrieve the data generated by the metrics periodic task.
- `/export`: a set of endpoints to export (in csv and zip format) all the data generated by the processor.
- `/slack`: a set of endpoints required to configure Slack correctly in the processor. We recommend to use these endpoints from the [UI](https://beta.lanthorn.ai) instead of calling them directly.
- `/auth`: a set of endpoints required to configure OAuth2 in the processors' endpoints.

Additionally, the API exposes 2 endpoints to stop/start the video processing
- `PUT PROCESSOR_IP:PROCESSOR_PORT/start-process-video`: Sends command `PROCESS_VIDEO_CFG` to core and returns the response. It starts to process the video adressed in the configuration file. If the response is `true`, it means the core is going to try to process the video (no guarantee if it will do it), and if the response is `false`, it means the process can not be started now (e.g. another process is already requested and running)
Expand Down
14 changes: 14 additions & 0 deletions api/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError

from libs.utils.auth import validate_jwt_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


def validate_token(token: str = Depends(oauth2_scheme)):
try:
validate_jwt_token(token)
except JWTError as e:
raise HTTPException(status_code=401, detail=str(e))
1 change: 1 addition & 0 deletions api/models/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class ApiDTO(SnakeModel):
host: str = Field("0.0.0.0")
port: int = Field(8000)
useAuthToken: bool = Field(False, example=False)
SSLEnabled: Optional[bool] = Field(False)
SSLCertificateFile: Optional[str] = Field("", example="/repo/certs/0_0_0_0.crt")
SSLKeyFile: Optional[str] = Field("", example="/repo/certs/0_0_0_0.key")
10 changes: 10 additions & 0 deletions api/models/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from .base import SnakeModel


class AuthDTO(SnakeModel):
user: str
password: str


class Token(SnakeModel):
token: str
49 changes: 27 additions & 22 deletions api/processor_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@
import os
import logging

from fastapi import FastAPI, status, Request
from fastapi import Depends, FastAPI, status, Request
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.openapi.utils import get_openapi
from share.commands import Commands

from libs.utils.loggers import get_area_log_directory, get_source_log_directory, get_screenshots_directory

from .dependencies import validate_token
from .queue_manager import QueueManager
from .routers.app import app_router
from .routers.api import api_router
from .routers.areas import areas_router
from .routers.area_loggers import area_loggers_router
from .routers.auth import auth_router
from .routers.core import core_router
from .routers.cameras import cameras_router
from .routers.classifier import classifier_router
Expand All @@ -28,6 +29,7 @@
from .routers.slack import slack_router
from .routers.source_loggers import source_loggers_router
from .routers.source_post_processors import source_post_processors_router
from .routers.static import static_router
from .routers.tracker import tracker_router
from .settings import Settings

Expand Down Expand Up @@ -61,25 +63,30 @@ def create_fastapi_app(self):

# Create and return a fastapi instance
app = FastAPI()

app.include_router(config_router, prefix="/config", tags=["Config"])
app.include_router(cameras_router, prefix="/cameras", tags=["Cameras"])
app.include_router(areas_router, prefix="/areas", tags=["Areas"])
app.include_router(app_router, prefix="/app", tags=["App"])
app.include_router(api_router, prefix="/api", tags=["Api"])
app.include_router(core_router, prefix="/core", tags=["Core"])
app.include_router(detector_router, prefix="/detector", tags=["Detector"])
app.include_router(classifier_router, prefix="/classifier", tags=["Classifier"])
app.include_router(tracker_router, prefix="/tracker", tags=["Tracker"])
dependencies = []
if self.settings.config.get_boolean("API", "UseAuthToken"):
dependencies = [Depends(validate_token)]

app.include_router(config_router, prefix="/config", tags=["Config"], dependencies=dependencies)
app.include_router(cameras_router, prefix="/cameras", tags=["Cameras"], dependencies=dependencies)
app.include_router(areas_router, prefix="/areas", tags=["Areas"], dependencies=dependencies)
app.include_router(app_router, prefix="/app", tags=["App"], dependencies=dependencies)
app.include_router(api_router, prefix="/api", tags=["Api"], dependencies=dependencies)
app.include_router(core_router, prefix="/core", tags=["Core"], dependencies=dependencies)
app.include_router(detector_router, prefix="/detector", tags=["Detector"], dependencies=dependencies)
app.include_router(classifier_router, prefix="/classifier", tags=["Classifier"], dependencies=dependencies)
app.include_router(tracker_router, prefix="/tracker", tags=["Tracker"], dependencies=dependencies)
app.include_router(source_post_processors_router, prefix="/source_post_processors",
tags=["Source Post Processors"])
app.include_router(source_loggers_router, prefix="/source_loggers", tags=["Source Loggers"])
app.include_router(area_loggers_router, prefix="/area_loggers", tags=["Area Loggers"])
app.include_router(periodic_tasks_router, prefix="/periodic_tasks", tags=["Periodic Tasks"])
app.include_router(area_metrics_router, prefix="/metrics/areas", tags=["Metrics"])
app.include_router(camera_metrics_router, prefix="/metrics/cameras", tags=["Metrics"])
app.include_router(export_router, prefix="/export", tags=["Export Data"])
app.include_router(slack_router, prefix="/slack", tags=["Slack"])
tags=["Source Post Processors"], dependencies=dependencies)
app.include_router(source_loggers_router, prefix="/source_loggers", tags=["Source Loggers"], dependencies=dependencies)
app.include_router(area_loggers_router, prefix="/area_loggers", tags=["Area Loggers"], dependencies=dependencies)
app.include_router(periodic_tasks_router, prefix="/periodic_tasks", tags=["Periodic Tasks"], dependencies=dependencies)
app.include_router(area_metrics_router, prefix="/metrics/areas", tags=["Metrics"], dependencies=dependencies)
app.include_router(camera_metrics_router, prefix="/metrics/cameras", tags=["Metrics"], dependencies=dependencies)
app.include_router(export_router, prefix="/export", tags=["Export Data"], dependencies=dependencies)
app.include_router(slack_router, prefix="/slack", tags=["Slack"], dependencies=dependencies)
app.include_router(auth_router, prefix="/auth", tags=["Auth"])
app.include_router(static_router, prefix="/static", dependencies=dependencies)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
Expand All @@ -97,8 +104,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
app.add_middleware(CORSMiddleware, allow_origins="*", allow_credentials=True, allow_methods=["*"],
allow_headers=["*"])

app.mount("/static", StaticFiles(directory="/repo/data/processor/static"), name="static")

@app.put("/start-process-video", response_model=bool)
async def process_video_cfg():
"""
Expand Down
3 changes: 3 additions & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ schedule==0.6.0
slackclient==2.8.2
uvicorn==0.11.8
yagmail==0.11.224
passlib==1.7.2
bcrypt==3.2.0
python-jose==3.2.0
30 changes: 30 additions & 0 deletions api/routers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from fastapi import APIRouter, status
from starlette.exceptions import HTTPException

from api.models.auth import AuthDTO, Token
from libs.utils.auth import create_access_token, create_api_user, validate_user_credentials

auth_router = APIRouter()


@auth_router.post("/create_api_user", status_code=status.HTTP_204_NO_CONTENT)
async def create_user(auth_info: AuthDTO):
"""
Creates the API user (if it's not already created) with the given password.
"""
try:
create_api_user(auth_info.user, auth_info.password)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@auth_router.put("/access_token", response_model=Token)
async def get_access_token(auth_info: AuthDTO):
if not validate_user_credentials(auth_info.user, auth_info.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return {
"token": create_access_token(auth_info.user)
}
15 changes: 15 additions & 0 deletions api/routers/static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os

from fastapi import APIRouter, status
from fastapi.responses import FileResponse
from starlette.exceptions import HTTPException

static_router = APIRouter()


@static_router.get("/gstreamer/{camera_id}/{file_name}", include_in_schema=False)
async def get_video(camera_id: str, file_name: str):
file_path = f"/repo/data/processor/static/gstreamer/{camera_id}/{file_name}"
if not os.path.exists(file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found.")
return FileResponse(file_path)
1 change: 1 addition & 0 deletions config-coral.ini
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ HeatmapResolution = 150,150
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
1 change: 1 addition & 0 deletions config-jetson.ini
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ HeatmapResolution = 150,150
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
1 change: 1 addition & 0 deletions config-x86-gpu-tensorrt.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
1 change: 1 addition & 0 deletions config-x86-gpu.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
1 change: 1 addition & 0 deletions config-x86-openvino.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
1 change: 1 addition & 0 deletions config-x86.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[API]
Host = 0.0.0.0
Port = 8000
UseAuthToken = False
SSLEnabled = False
SSLCertificateFile =
SSLKeyFile =
Expand Down
10 changes: 10 additions & 0 deletions create_api_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import argparse

from libs.utils.auth import create_api_user

if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--user', required=True)
parser.add_argument('--password', required=True)
args = parser.parse_args()
create_api_user(args.user, args.password)
54 changes: 54 additions & 0 deletions libs/utils/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json
import logging
import os

from datetime import datetime, timedelta
from jose import jwt
from passlib.context import CryptContext

logger = logging.getLogger(__name__)

API_USER_CREDENTIALS_FOLDER = "/repo/data/auth/"
API_USER_PATH = f"{API_USER_CREDENTIALS_FOLDER}/api_user.txt"

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def create_api_user(user: str, password: str):
if os.path.isfile(API_USER_PATH) and os.path.getsize(API_USER_PATH) != 0:
# API credential already created
logger.error("API credentials already created.")
raise Exception("Error creating user. API credentials already created.")
os.makedirs(API_USER_CREDENTIALS_FOLDER, exist_ok=True)
with open(API_USER_PATH, "w+") as api_user_file:
api_user_credentials = {
"user": user,
"password": pwd_context.hash(password)
}
json.dump(api_user_credentials, api_user_file)


def validate_user_credentials(user: str, password: str) -> bool:
if not os.path.isfile(API_USER_PATH) or os.path.getsize(API_USER_PATH) == 0:
logger.error("API credentials doesn't exit.")
return False

with open(API_USER_PATH, "r") as api_user_file:
stored_credentials = json.load(api_user_file)

if stored_credentials["user"] != user or not pwd_context.verify(password, stored_credentials["password"]):
return False

return True


def create_access_token(user: str):
# Set a week (60*24*7=10080) as expiration date
expire_date = datetime.utcnow() + timedelta(minutes=10080)
data = {"sub": user}
data.update({"exp": expire_date})
return jwt.encode(data, os.environ.get("SECRET_ACCESS_KEY"), algorithm="HS256")


def validate_jwt_token(token):
jwt.decode(token, os.environ.get("SECRET_ACCESS_KEY"), algorithms=["HS256"])