From 4cb2efc613a92842f09473465e86a54b0be4a69c Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Mon, 31 Jan 2022 13:05:22 -0800 Subject: [PATCH 1/2] Secure innkeeper. Make innkeeper sub-application Signed-off-by: Jason Sherman --- .../templates/traction_api_deployment.yaml | 2 + charts/traction/values.yaml | 2 + scripts/docker-compose.yaml | 1 + services/traction/api/core/config.py | 12 +++- .../endpoints/dependencies/jwt_security.py | 22 +++++++ .../dependencies}/tenant_security.py | 31 ---------- services/traction/api/endpoints/routes/api.py | 7 --- .../api/endpoints/routes/connections.py | 14 +---- .../traction/api/endpoints/routes/ledger.py | 8 +-- .../traction/api/endpoints/routes/tenants.py | 21 ------- services/traction/api/innkeeper_main.py | 58 +++++++++++++++++++ services/traction/api/main.py | 7 ++- services/traction/api/tenant_main.py | 35 +++++------ services/traction/requirements.txt | 1 + 14 files changed, 121 insertions(+), 100 deletions(-) create mode 100644 services/traction/api/endpoints/dependencies/jwt_security.py rename services/traction/api/{ => endpoints/dependencies}/tenant_security.py (68%) delete mode 100644 services/traction/api/endpoints/routes/api.py delete mode 100644 services/traction/api/endpoints/routes/tenants.py create mode 100644 services/traction/api/innkeeper_main.py diff --git a/charts/traction/templates/traction_api_deployment.yaml b/charts/traction/templates/traction_api_deployment.yaml index 74071a214..0da596040 100644 --- a/charts/traction/templates/traction_api_deployment.yaml +++ b/charts/traction/templates/traction_api_deployment.yaml @@ -48,6 +48,8 @@ spec: initialDelaySeconds: 60 periodSeconds: 30 env: + - name: TRACTION_API_ADMIN_USER + value: {{ .Values.traction_api.api.adminuser }} - name: TRACTION_API_ADMIN_KEY valueFrom: secretKeyRef: diff --git a/charts/traction/values.yaml b/charts/traction/values.yaml index cc6563e26..81cb403d6 100755 --- a/charts/traction/values.yaml +++ b/charts/traction/values.yaml @@ -228,6 +228,8 @@ traction_api: db: admin: tractionadminuser user: tractionuser + api: + adminuser: innkeeper serviceAccount: # -- Specifies whether a service account should be created diff --git a/scripts/docker-compose.yaml b/scripts/docker-compose.yaml index 6ae88f981..3b6a4303e 100755 --- a/scripts/docker-compose.yaml +++ b/scripts/docker-compose.yaml @@ -19,6 +19,7 @@ services: - TRACTION_DB_ADMIN_PWD=${TRACTION_PSQL_ADMIN_PWD} - TRACTION_DB_USER=${TRACTION_PSQL_USER} - TRACTION_DB_USER_PWD=${TRACTION_PSQL_USER_PWD} + - TRACTION_API_ADMIN_USER=${TRACTION_API_ADMIN_USER} - TRACTION_API_ADMIN_KEY=${TRACTION_API_ADMIN_KEY} - ACAPY_ADMIN_URL=${ACAPY_ADMIN_URL} - ACAPY_ADMIN_URL_API_KEY=${ACAPY_ADMIN_URL_API_KEY} diff --git a/services/traction/api/core/config.py b/services/traction/api/core/config.py index 48674a6ae..38391e38e 100644 --- a/services/traction/api/core/config.py +++ b/services/traction/api/core/config.py @@ -18,8 +18,12 @@ class GlobalConfig(BaseSettings): TITLE: str = "Traction" DESCRIPTION: str = "A digital wallet solution for organizations" - TENANT_TITLE: str = "Traction" - TENANT_DESCRIPTION: str = "A digital wallet solution for organizations" + # sub-app titles/descriptions + TENANT_TITLE: str = "Traction Tenant" + TENANT_DESCRIPTION: str = "Endpoints for Tenants of Traction" + + INNKEEPER_TITLE: str = "Traction Innkeeper" + INNKEEPER_DESCRIPTION: str = "Endpoints for Innkeeper of Traction" ENVIRONMENT: EnvironmentEnum DEBUG: bool = False @@ -54,7 +58,11 @@ class GlobalConfig(BaseSettings): "ACAPY_ADMIN_URL_API_KEY", "change-me" ) + TRACTION_API_ADMIN_USER: str = os.environ.get( + "TRACTION_API_ADMIN_USER", "innkeeper" + ) TRACTION_API_ADMIN_KEY: str = os.environ.get("TRACTION_API_ADMIN_KEY", "change-me") + TRACTION_WEBHOOK_URL: str = os.environ.get( "TRACTION_WEBHOOK_URL", "http://traction-api:5000/webhook" ) diff --git a/services/traction/api/endpoints/dependencies/jwt_security.py b/services/traction/api/endpoints/dependencies/jwt_security.py new file mode 100644 index 000000000..7beace336 --- /dev/null +++ b/services/traction/api/endpoints/dependencies/jwt_security.py @@ -0,0 +1,22 @@ +from datetime import datetime, timedelta + +from jose import jwt +from pydantic import BaseModel + +from api.core.config import settings + + +class AccessToken(BaseModel): + access_token: str + token_type: str + + +def create_access_token(data: dict): + expires_delta = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = data.copy() + expire = datetime.utcnow() + expires_delta + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM + ) + return AccessToken(access_token=encoded_jwt, token_type="bearer") diff --git a/services/traction/api/tenant_security.py b/services/traction/api/endpoints/dependencies/tenant_security.py similarity index 68% rename from services/traction/api/tenant_security.py rename to services/traction/api/endpoints/dependencies/tenant_security.py index 0423cbfd5..6d83c3f52 100644 --- a/services/traction/api/tenant_security.py +++ b/services/traction/api/endpoints/dependencies/tenant_security.py @@ -1,9 +1,4 @@ -from datetime import datetime, timedelta -from typing import Optional - -from fastapi.security import OAuth2PasswordBearer from jose import jwt -from pydantic import BaseModel from starlette_context import context from starlette.middleware.base import ( BaseHTTPMiddleware, @@ -16,19 +11,6 @@ from api.core.config import settings -class TenantToken(BaseModel): - access_token: str - token_type: str - - -class TenantTokenData(BaseModel): - wallet_id: Optional[str] = None - bearer_token: Optional[str] = None - - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - - class JWTTFetchingMiddleware(BaseHTTPMiddleware): """Middleware to inject tenant JWT into context.""" @@ -69,16 +51,3 @@ async def authenticate_tenant(username: str, password: str): return tenant except Exception: return None - - -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): - to_encode = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) - to_encode.update({"exp": expire}) - encoded_jwt = jwt.encode( - to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM - ) - return encoded_jwt diff --git a/services/traction/api/endpoints/routes/api.py b/services/traction/api/endpoints/routes/api.py deleted file mode 100644 index 88995c8bd..000000000 --- a/services/traction/api/endpoints/routes/api.py +++ /dev/null @@ -1,7 +0,0 @@ -from fastapi import APIRouter - -from api.endpoints.routes import innkeeper, tenants - -api_router = APIRouter() -api_router.include_router(innkeeper.router, prefix="/innkeeper", tags=["innkeeper"]) -api_router.include_router(tenants.router, prefix="/tenants", tags=["tenants"]) diff --git a/services/traction/api/endpoints/routes/connections.py b/services/traction/api/endpoints/routes/connections.py index 3ae4d2726..4c6c5cb9e 100644 --- a/services/traction/api/endpoints/routes/connections.py +++ b/services/traction/api/endpoints/routes/connections.py @@ -1,14 +1,10 @@ from enum import Enum from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter from pydantic import BaseModel from api import acapy_utils as au -from api.tenant_security import ( - oauth2_scheme, -) - router = APIRouter() @@ -80,8 +76,6 @@ async def get_connections( connection_state: Optional[ConnectionStateType] = None, their_did: Optional[str] = None, their_role: Optional[ConnectionRoleType] = None, - # note we don't need the token here but we need to make sure it gets set - _token: str = Depends(oauth2_scheme), ): params = { "alias": alias, @@ -99,8 +93,6 @@ async def get_connections( @router.post("/create-invitation", response_model=Invitation) async def create_invitation( alias: str | None = None, - # note we don't need the token here but we need to make sure it gets set - _token: str = Depends(oauth2_scheme), ): params = {"alias": alias} invitation = await au.acapy_POST( @@ -113,8 +105,6 @@ async def create_invitation( async def receive_invitation( payload: dict, alias: str | None = None, - # note we don't need the token here but we need to make sure it gets set - _token: str = Depends(oauth2_scheme), ): params = {"alias": alias} connection = await au.acapy_POST( @@ -128,8 +118,6 @@ async def send_message( payload: BasicMessage, connection_id: str | None = None, alias: str | None = None, - # note we don't need the token here but we need to make sure it gets set - _token: str = Depends(oauth2_scheme), ): if not connection_id: lookup_params = {"alias": alias} diff --git a/services/traction/api/endpoints/routes/ledger.py b/services/traction/api/endpoints/routes/ledger.py index c6cb88b5a..22d0a09cb 100644 --- a/services/traction/api/endpoints/routes/ledger.py +++ b/services/traction/api/endpoints/routes/ledger.py @@ -1,14 +1,10 @@ from enum import Enum from typing import Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter from pydantic import BaseModel from api import acapy_utils as au -from api.tenant_security import ( - oauth2_scheme, -) - router = APIRouter() @@ -27,8 +23,6 @@ class DIDEndpointType(str, Enum): async def get_did_endpoint( did: str, endpoint_type: Optional[DIDEndpointType] = None, - # note we don't need the token here but we need to make sure it gets set - _token: str = Depends(oauth2_scheme), ): params = {"did": did} if endpoint_type: diff --git a/services/traction/api/endpoints/routes/tenants.py b/services/traction/api/endpoints/routes/tenants.py deleted file mode 100644 index fc031626e..000000000 --- a/services/traction/api/endpoints/routes/tenants.py +++ /dev/null @@ -1,21 +0,0 @@ -import logging -from uuid import UUID - -from fastapi import APIRouter, Depends -from sqlalchemy.ext.asyncio import AsyncSession -from starlette import status - -from api.db.models.tenant import TenantRead -from api.endpoints.dependencies.db import get_db -from api.db.repositories.tenants import TenantsRepository - -router = APIRouter() -logger = logging.getLogger(__name__) - - -@router.get("/{tenant_id}", status_code=status.HTTP_200_OK, response_model=TenantRead) -async def get_tenant(tenant_id: UUID, db: AsyncSession = Depends(get_db)) -> TenantRead: - # this should take some query params, sorting and paging params... - repo = TenantsRepository(db_session=db) - item = await repo.get_by_id(tenant_id) - return item diff --git a/services/traction/api/innkeeper_main.py b/services/traction/api/innkeeper_main.py new file mode 100644 index 000000000..1c1aaf6c0 --- /dev/null +++ b/services/traction/api/innkeeper_main.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, FastAPI, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer +from starlette.middleware import Middleware +from starlette_context import plugins +from starlette_context.middleware import RawContextMiddleware + +from api.endpoints.routes.innkeeper import router as innkeeper_router +from api.endpoints.dependencies.jwt_security import AccessToken, create_access_token +from api.core.config import settings as s + + +middleware = [ + Middleware( + RawContextMiddleware, + plugins=(plugins.RequestIdPlugin(), plugins.CorrelationIdPlugin()), + ), +] + +router = APIRouter() + + +def get_innkeeperapp() -> FastAPI: + application = FastAPI( + title=s.INNKEEPER_TITLE, + description=s.INNKEEPER_DESCRIPTION, + debug=s.DEBUG, + middleware=middleware, + ) + # mount the token endpoint + application.include_router(router, prefix="") + # mount other endpoints, these will be secured by the above token endpoint + application.include_router( + innkeeper_router, + prefix=s.API_V1_STR, + dependencies=[Depends(OAuth2PasswordBearer(tokenUrl="token"))], + tags=["innkeeper"], + ) + return application + + +@router.post("/token", response_model=AccessToken) +async def login_for_traction_api_admin( + form_data: OAuth2PasswordRequestForm = Depends(), +): + authenticated = await authenticate_innkeeper(form_data.username, form_data.password) + if not authenticated: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect Traction Api Admin User or Traction Api Admin Key", + headers={"WWW-Authenticate": "Bearer"}, + ) + return create_access_token(data={"sub": form_data.username}) + + +async def authenticate_innkeeper(username: str, password: str): + if s.TRACTION_API_ADMIN_USER == username and s.TRACTION_API_ADMIN_KEY == password: + return True + return False diff --git a/services/traction/api/main.py b/services/traction/api/main.py index c8cafc02e..947647827 100644 --- a/services/traction/api/main.py +++ b/services/traction/api/main.py @@ -6,9 +6,9 @@ from starlette.responses import JSONResponse from api.db.errors import DoesNotExist, AlreadyExists -from api.endpoints.routes.api import api_router from api.endpoints.routes.webhooks import get_webhookapp from api.core.config import settings +from api.innkeeper_main import get_innkeeperapp from api.tenant_main import get_tenantapp @@ -23,16 +23,19 @@ def get_application() -> FastAPI: debug=settings.DEBUG, middleware=None, ) - application.include_router(api_router, prefix=settings.API_V1_STR) return application app = get_application() webhook_app = get_webhookapp() app.mount("/webhook", webhook_app) + tenant_app = get_tenantapp() app.mount("/tenant", tenant_app) +innkeeper_app = get_innkeeperapp() +app.mount("/innkeeper", innkeeper_app) + @app.exception_handler(DoesNotExist) async def does_not_exist_exception_handler(request: Request, exc: DoesNotExist): diff --git a/services/traction/api/tenant_main.py b/services/traction/api/tenant_main.py index f9bf68cf0..44c710b1f 100644 --- a/services/traction/api/tenant_main.py +++ b/services/traction/api/tenant_main.py @@ -1,20 +1,16 @@ -from datetime import timedelta - from fastapi import APIRouter, Depends, FastAPI, HTTPException, status -from fastapi.security import OAuth2PasswordRequestForm +from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from starlette.middleware import Middleware from starlette_context import plugins from starlette_context.middleware import RawContextMiddleware from api.endpoints.routes.tenant_api import tenant_router -from api.tenant_security import ( - TenantToken, - authenticate_tenant, - create_access_token, +from api.endpoints.dependencies.jwt_security import AccessToken, create_access_token +from api.core.config import settings +from api.endpoints.dependencies.tenant_security import ( JWTTFetchingMiddleware, + authenticate_tenant, ) -from api.core.config import settings - middleware = [ Middleware( @@ -34,13 +30,21 @@ def get_tenantapp() -> FastAPI: debug=settings.DEBUG, middleware=middleware, ) - application.include_router(tenant_router, prefix=settings.API_V1_STR) + # mount the token endpoint application.include_router(router, prefix="") + # mount other endpoints, these will be secured by the above token endpoint + application.include_router( + tenant_router, + prefix=settings.API_V1_STR, + dependencies=[Depends(OAuth2PasswordBearer(tokenUrl="token"))], + ) return application -@router.post("/token", response_model=TenantToken) -async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): +@router.post("/token", response_model=AccessToken) +async def login_for_tenant_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), +): tenant = await authenticate_tenant(form_data.username, form_data.password) if not tenant: raise HTTPException( @@ -48,9 +52,6 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends( detail="Incorrect wallet_id or wallet_key", headers={"WWW-Authenticate": "Bearer"}, ) - access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = create_access_token( - data={"sub": tenant["wallet_id"], "key": tenant["wallet_token"]}, - expires_delta=access_token_expires, + return create_access_token( + data={"sub": tenant["wallet_id"], "key": tenant["wallet_token"]} ) - return {"access_token": access_token, "token_type": "bearer"} diff --git a/services/traction/requirements.txt b/services/traction/requirements.txt index 7f5e41196..1e9b7b116 100644 --- a/services/traction/requirements.txt +++ b/services/traction/requirements.txt @@ -14,6 +14,7 @@ httptools==0.3.0 idna==3.3 itsdangerous==2.0.1 Jinja2==3.0.3 +jose==1.0.0 Mako==1.1.6 MarkupSafe==2.0.1 passlib==1.7.4 From cbde52096038e0816f4ddf4a1dbfdd32383fc233 Mon Sep 17 00:00:00 2001 From: Jason Sherman Date: Mon, 31 Jan 2022 13:06:42 -0800 Subject: [PATCH 2/2] forgot env example env var Signed-off-by: Jason Sherman --- scripts/.env-example | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/.env-example b/scripts/.env-example index e589e6d37..5d51c57aa 100644 --- a/scripts/.env-example +++ b/scripts/.env-example @@ -89,6 +89,7 @@ TRACTION_PSQL_USER=tractionuser TRACTION_PSQL_USER_PWD=tractionPass +TRACTION_API_ADMIN_USER=innkeeper TRACTION_API_ADMIN_KEY=change-me # ------------------------------------------------------------