From 317c79cee281b22cdc503022cb2dee0f7300ead3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Tue, 25 Mar 2025 11:45:09 -0300 Subject: [PATCH] add better auth --- http-api/Makefile | 4 + http-api/app.py | 22 +- http-api/auth.py | 60 +- http-api/middlewares.py | 29 + http-api/models.py | 1 - http-api/openapi.py | 32 ++ http-api/routes/courses/__init__.py | 11 +- http-api/swagger/index.html | 760 ++++++++++++++++++++++++++ http-api/tests/routes/test_courses.py | 12 +- http-api/tests/seeds.jsonl | 1 + http-api/tests/test_auth.py | 26 +- 11 files changed, 912 insertions(+), 46 deletions(-) create mode 100644 http-api/middlewares.py create mode 100644 http-api/openapi.py create mode 100644 http-api/swagger/index.html diff --git a/http-api/Makefile b/http-api/Makefile index 7043532..0a08262 100644 --- a/http-api/Makefile +++ b/http-api/Makefile @@ -6,3 +6,7 @@ deploy: build start-api: build sam local start-api + +openapi: + uv run openapi.py && \ + uv run python -m http.server 80 -d swagger diff --git a/http-api/app.py b/http-api/app.py index 1caea06..82e7131 100644 --- a/http-api/app.py +++ b/http-api/app.py @@ -5,28 +5,16 @@ from aws_lambda_powertools.event_handler.api_gateway import ( content_types, ) from aws_lambda_powertools.event_handler.exceptions import ServiceError -from aws_lambda_powertools.event_handler.openapi.models import ( - HTTPBearer, - Server, -) from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.utilities.typing import LambdaContext +from middlewares import CorrelationIdMiddleware from routes import courses, enrollments, orders, users, webhooks tracer = Tracer() logger = Logger(__name__) app = APIGatewayHttpResolver(enable_validation=True) -app.enable_swagger( - servers=[Server(url='https://api.saladeaula.digital/v2')], - title='EDUSEG® Public API', - path='/_swagger', - compress=True, - security_schemes={'bearerAuth': HTTPBearer()}, - security=[{'bearerAuth': []}], - persist_authorization=True, -) - +app.use(middlewares=[CorrelationIdMiddleware('workspace')]) app.include_router(users.router, prefix='/users') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(orders.router, prefix='/orders') @@ -50,9 +38,3 @@ def exc_error(exc: ServiceError): @tracer.capture_lambda_handler def lambda_handler(event: dict, context: LambdaContext) -> dict: return app.resolve(event, context) - - -# if __name__ == '__main__': -# print( -# app.get_openapi_json_schema(), -# ) diff --git a/http-api/auth.py b/http-api/auth.py index 014feaa..60c0f27 100644 --- a/http-api/auth.py +++ b/http-api/auth.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + import boto3 from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.utilities.data_classes import event_source @@ -6,9 +8,12 @@ from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event i APIGatewayAuthorizerResponseV2, ) from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.endpoint_provider import Enum from cognito import get_user +APIKEY_PREFIX = 'edxg' + tracer = Tracer() logger = Logger(__name__) idp_client = boto3.client('cognito-idp') @@ -18,18 +23,49 @@ idp_client = boto3.client('cognito-idp') @logger.inject_lambda_context @event_source(data_class=APIGatewayAuthorizerEventV2) def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext): - auth_header = event.get_header_value('authorization', default_value='') + bearer = _parse_bearer_token(event.headers.get('authorization', '')) + if not bearer: + return APIGatewayAuthorizerResponseV2(authorize=False).asdict() + + if bearer.auth_type == TokenType.USER_TOKEN: + user = get_user(bearer.token, idp_client=idp_client) + + if user: + return APIGatewayAuthorizerResponseV2( + authorize=True, + context=dict(user=user), + ).asdict() + + return APIGatewayAuthorizerResponseV2(authorize=False).asdict() + + +class TokenType(str, Enum): + API_KEY = 'API_KEY' + USER_TOKEN = 'USER_TOKEN' + + +@dataclass +class BearerToken: + auth_type: TokenType + token: str + + +def _parse_bearer_token( + s: str, + *, + apikey_prefix: str = APIKEY_PREFIX, +) -> BearerToken | None: + """Parses and identifies a bearer token as either an API key or a user token.""" try: - _, bearer_token = auth_header.split(' ') - user = get_user(bearer_token, idp_client=idp_client) + _, bearer_token = s.split(' ') + + if bearer_token.startswith(f'{apikey_prefix}-'): + return BearerToken( + TokenType.API_KEY, + bearer_token.removeprefix(f'{apikey_prefix}-'), + ) except ValueError: - return APIGatewayAuthorizerResponseV2(authorize=False).asdict() - - if not user: - return APIGatewayAuthorizerResponseV2(authorize=False).asdict() - - return APIGatewayAuthorizerResponseV2( - authorize=True, - context=dict(user=user), - ).asdict() + return None + else: + return BearerToken(TokenType.USER_TOKEN, bearer_token) diff --git a/http-api/middlewares.py b/http-api/middlewares.py new file mode 100644 index 0000000..594898f --- /dev/null +++ b/http-api/middlewares.py @@ -0,0 +1,29 @@ +from aws_lambda_powertools.event_handler.api_gateway import ( + APIGatewayHttpResolver, + Response, +) +from aws_lambda_powertools.event_handler.middlewares import ( + BaseMiddlewareHandler, + NextMiddleware, +) + + +class CorrelationIdMiddleware(BaseMiddlewareHandler): + def __init__(self, header: str): + super().__init__() + self.header = header + + def handler( + self, app: APIGatewayHttpResolver, next_middleware: NextMiddleware + ) -> Response: + # BEFORE logic + request_id = app.current_event.request_context.request_id + correlation_id = app.current_event.headers.get(self.header, request_id) + + # Call next middleware or route handler ('/todos') + response = next_middleware(app) + + # AFTER logic + response.headers[self.header] = correlation_id + + return response diff --git a/http-api/models.py b/http-api/models.py index 4663e17..6d3e753 100644 --- a/http-api/models.py +++ b/http-api/models.py @@ -29,7 +29,6 @@ class User(BaseModel): class Cert(BaseModel): - id: UUID4 | str = Field(default_factory=uuid4) exp_interval: int diff --git a/http-api/openapi.py b/http-api/openapi.py new file mode 100644 index 0000000..e20d993 --- /dev/null +++ b/http-api/openapi.py @@ -0,0 +1,32 @@ +from aws_lambda_powertools.event_handler.openapi.models import ( + HTTPBearer, + Server, +) +from aws_lambda_powertools.event_handler.openapi.swagger_ui.html import ( + generate_swagger_html, +) + +from app import app + +title = 'EDUSEG® Public API' +swagger_base_url = 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.20.1' + +if __name__ == '__main__': + spec = app.get_openapi_json_schema( + servers=[Server(url='https://api.saladeaula.digital/v2')], + title=title, + security_schemes={'bearerAuth': HTTPBearer()}, + security=[{'bearerAuth': []}], + ) + + body = generate_swagger_html( + spec=spec, + swagger_base_url=swagger_base_url, + swagger_js=f'{swagger_base_url}/swagger-ui-bundle.js', + swagger_css=f'{swagger_base_url}/swagger-ui.css', + oauth2_config=None, + persist_authorization=True, + ) + + with open('swagger/index.html', 'w') as fp: + fp.write(body) diff --git a/http-api/routes/courses/__init__.py b/http-api/routes/courses/__init__.py index 81157f6..0ce483a 100644 --- a/http-api/routes/courses/__init__.py +++ b/http-api/routes/courses/__init__.py @@ -6,7 +6,7 @@ from aws_lambda_powertools.event_handler import Response, content_types from aws_lambda_powertools.event_handler.api_gateway import Router from elasticsearch import Elasticsearch from layercake.dynamodb import DynamoDBPersistenceLayer -from pydantic.main import BaseModel +from pydantic import BaseModel import elastic from course import create_course @@ -34,16 +34,17 @@ def get_courses(): ) -class Payload(BaseModel): +class CoursePayload(BaseModel): course: Course - org: Org @router.post('/', compress=True, tags=['Course']) -def post_course(payload: Payload): +def post_course(payload: CoursePayload): + org = Org(id='*', name='EDUSEG') + create_course( course=payload.course, - org=payload.org, + org=org, persistence_layer=course_layer, ) diff --git a/http-api/swagger/index.html b/http-api/swagger/index.html new file mode 100644 index 0000000..daa2560 --- /dev/null +++ b/http-api/swagger/index.html @@ -0,0 +1,760 @@ + + + + + Swagger UI + + + + + +
+ Loading... +
+ + + + + + \ No newline at end of file diff --git a/http-api/tests/routes/test_courses.py b/http-api/tests/routes/test_courses.py index 66a714c..21a206a 100644 --- a/http-api/tests/routes/test_courses.py +++ b/http-api/tests/routes/test_courses.py @@ -18,19 +18,21 @@ def test_post_course( http_api_proxy( raw_path='/courses', method=HTTPMethod.POST, + headers={'Tenant': '*'}, body={ 'course': { 'name': 'pytest', 'access_period': 365, - }, - 'org': { - 'id': '6RQuJ7koa9Gz4ZXTA4NeGR', - 'name': 'EDUSEG', - }, + 'cert': { + 'exp_interval': 730, # 2 years + }, + } }, ), lambda_context, ) + print(r) + assert 'id' in json.loads(r['body']) assert r['statusCode'] == HTTPStatus.CREATED diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index 4b76320..d742d4a 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -1,4 +1,5 @@ {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}, {"S": "edp8njvgQuzNkLx2ySNfAD"}, {"S": "8TVSi5oACLxTiT8ycKPmaQ"}]}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#sergio@somosbeta.com.br"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": true}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}} +{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#*"}, "create_date": {"S": "2022-06-13T15:00:24.309410-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}} {"id": {"S": "logs#5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}} {"id": {"S": "logs#5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}} diff --git a/http-api/tests/test_auth.py b/http-api/tests/test_auth.py index 52e6102..9c65e66 100644 --- a/http-api/tests/test_auth.py +++ b/http-api/tests/test_auth.py @@ -1,17 +1,19 @@ import auth as app +from auth import _parse_bearer_token from .conftest import LambdaContext -def test_bearer_jwt(lambda_context: LambdaContext): +def test_bearer_jwt(lambda_context: LambdaContext, dynamodb_seeds): + # You should mock the Cognito user to pass the test app.get_user = lambda *args, **kwargs: { 'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e', 'name': 'pytest', + 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', } - bearer_token = 'eyJraWQiOiJiSkZaSlNkMjhIeUtJNEQ0bG84SlkxSzk5NEdSUGhYU3YwV1BNczZ3aGVzPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI1OGVmZWQ4ZC1kMjc2LTQxYTgtODUwMi00YWI4YjVhNjQxNWUiLCJldmVudF9pZCI6IjJhNjlmOWE5LWQ2N2MtNDU0Ny04YzJlLWU5N2U2YzI5MzY4YSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoiYXdzLmNvZ25pdG8uc2lnbmluLnVzZXIuYWRtaW4iLCJhdXRoX3RpbWUiOjE3NDIzOTMxNjMsImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5zYS1lYXN0LTEuYW1hem9uYXdzLmNvbVwvc2EtZWFzdC0xX3M2WW1WU2ZYaiIsImV4cCI6MTc0MjQzNzA4NywiaWF0IjoxNzQyNDMzNDg3LCJqdGkiOiJmNjQ2MTdhMy04MzY2LTQxZWUtOGU2MC04YTA3YzA2N2UzOTMiLCJjbGllbnRfaWQiOiJsZGZ2ZHZrdDZjbDIybjdwMzN2cXRzZjRqIiwidXNlcm5hbWUiOiI1OGVmZWQ4ZC1kMjc2LTQxYTgtODUwMi00YWI4YjVhNjQxNWUifQ.dRhCaEItKEBbzrl7b5Ndh2xI8YGCK8trfKRs6YsW0cdZ_lU59oLhfd1bXEUe-dPyUb3zzGM41bSVUKHZTTlaMx8QNq2U4HbtrgQuQ77yXkN_i8Ft0DpLJiOFtBJzdx-LDUU8CwfjgLNN9fSUyUfkPkCnssBug0fIVcUJpixadk19-7_LJ3_gCPxlpcWT3vCb3yQtY8DzpW4iFcbqBUt1i6XWMTQHfTNamqzaWQ7m6QarefWK1gfDxGmfRg5qQJCRYzsQXcCe3JXRy0BgErpKrVHeIx0Dz8DyOWy1s0hSmv6n9ZPrHOFj13LprS7XihEK9DFwq4usolBungPLRIs_Og' event = { 'headers': { - 'authorization': f'Bearer {bearer_token}', + 'authorization': 'Bearer 3c51cdfd-d23e-47f9-8d7c-e3e31a432921', }, } @@ -21,6 +23,24 @@ def test_bearer_jwt(lambda_context: LambdaContext): 'user': { 'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e', 'name': 'pytest', + 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', } }, } + + +def test_parse_bearer_token_api_key(): + bearer = _parse_bearer_token( + 'Bearer pptx-35433970-6857-4062-bb43-f71683b2f68e', + apikey_prefix='pptx', + ) + + assert bearer.token == '35433970-6857-4062-bb43-f71683b2f68e' # type: ignore + assert bearer.auth_type == 'API_KEY' # type: ignore + + +def test_parse_bearer_token_user_token(): + bearer = _parse_bearer_token('Bearer d977f5a2-0302-4dd2-87c7-57414264d27a') + + assert bearer.token == 'd977f5a2-0302-4dd2-87c7-57414264d27a' # type: ignore + assert bearer.auth_type == 'USER_TOKEN' # type: ignore