diff --git a/api/src/alembic/versions/2daaedf28c12_extended_request.py b/api/src/alembic/versions/2daaedf28c12_extended_request.py new file mode 100644 index 00000000..f7db51c6 --- /dev/null +++ b/api/src/alembic/versions/2daaedf28c12_extended_request.py @@ -0,0 +1,39 @@ +"""extended_request + +Revision ID: 2daaedf28c12 +Revises: ca282a8389ef +Create Date: 2023-07-01 16:11:01.969832 + +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2daaedf28c12" +down_revision = "ca282a8389ef" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("api_request", sa.Column("base_url", sa.Text(), nullable=True)) + op.add_column( + "api_request", + sa.Column( + "path_params", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + ) + op.add_column( + "api_request", + sa.Column( + "query_params", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + ) + op.add_column("api_request", sa.Column("client_host", sa.Text(), nullable=True)) + op.add_column("api_request", sa.Column("client_port", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + pass diff --git a/api/src/data_inclusion/api/core/auth.py b/api/src/data_inclusion/api/core/auth.py new file mode 100644 index 00000000..5399487f --- /dev/null +++ b/api/src/data_inclusion/api/core/auth.py @@ -0,0 +1,38 @@ +from starlette import authentication, responses +from starlette.middleware.authentication import ( # noqa: F401 + AuthenticationMiddleware as AuthenticationMiddleware, +) +from starlette.requests import HTTPConnection + +import fastapi +from fastapi.security import HTTPBearer + +from data_inclusion.api.core import jwt + + +def on_error(conn: HTTPConnection, exc: Exception) -> responses.Response: + return responses.JSONResponse(content=str(exc), status_code=403) + + +class AuthenticationBackend(authentication.AuthenticationBackend): + async def authenticate(self, conn): + http_bearer_instance = HTTPBearer() + + try: + token = await http_bearer_instance(request=conn) + except fastapi.HTTPException as exc: + raise authentication.AuthenticationError(exc.detail) + + payload = jwt.verify_token(token.credentials) + + if payload is None: + raise authentication.AuthenticationError("Not authenticated") + + scopes = ["authenticated"] + + if payload.get("admin", False): + scopes += ["admin"] + + return authentication.AuthCredentials(scopes=scopes), authentication.SimpleUser( + username=payload["sub"] + ) diff --git a/api/src/data_inclusion/api/core/request/models.py b/api/src/data_inclusion/api/core/request/models.py index b09667ea..a6531cb9 100644 --- a/api/src/data_inclusion/api/core/request/models.py +++ b/api/src/data_inclusion/api/core/request/models.py @@ -1,7 +1,7 @@ import uuid import sqlalchemy as sqla -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from data_inclusion.api.core.db import Base @@ -17,4 +17,9 @@ class Request(Base): status_code = sqla.Column(sqla.SmallInteger) method = sqla.Column(sqla.Text) path = sqla.Column(sqla.Text) + base_url = sqla.Column(sqla.Text) user = sqla.Column(sqla.Text, nullable=True) + path_params = sqla.Column(JSONB) + query_params = sqla.Column(JSONB) + client_host = sqla.Column(sqla.Text) + client_port = sqla.Column(sqla.Integer) diff --git a/api/src/data_inclusion/api/core/request/services.py b/api/src/data_inclusion/api/core/request/services.py index 9e967235..de1605dd 100644 --- a/api/src/data_inclusion/api/core/request/services.py +++ b/api/src/data_inclusion/api/core/request/services.py @@ -10,7 +10,12 @@ def save_request(request: requests.Request, response: responses.Response) -> Non status_code=response.status_code, method=request.method, path=request.url.path, - user=request.scope.get("user"), + base_url=str(request.base_url), + user=request.user.username, + path_params=request.path_params, + query_params=dict(request.query_params), + client_host=request.client.host, + client_port=request.client.port, ) session.add(request_instance) session.commit() diff --git a/api/src/data_inclusion/api/entrypoints/fastapi.py b/api/src/data_inclusion/api/entrypoints/fastapi.py index 89478192..5c672124 100644 --- a/api/src/data_inclusion/api/entrypoints/fastapi.py +++ b/api/src/data_inclusion/api/entrypoints/fastapi.py @@ -7,13 +7,13 @@ import fastapi import fastapi_pagination -from fastapi import middleware, requests +from fastapi import middleware from fastapi.middleware import cors -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from fastapi.security import HTTPBearer from fastapi_pagination.ext.sqlalchemy import paginate from data_inclusion.api import models, schema, settings -from data_inclusion.api.core import db, jwt +from data_inclusion.api.core import auth, db, jwt from data_inclusion.api.core.request.middleware import RequestMiddleware from data_inclusion.api.utils import pagination @@ -64,6 +64,11 @@ def create_app() -> fastapi.FastAPI: allow_methods=["*"], allow_headers=["*"], ), + middleware.Middleware( + auth.AuthenticationMiddleware, + backend=auth.AuthenticationBackend(), + on_error=auth.on_error, + ), middleware.Middleware(RequestMiddleware), ], ) @@ -76,29 +81,9 @@ def create_app() -> fastapi.FastAPI: return app -def authenticated( - request: requests.Request, - token: HTTPAuthorizationCredentials = fastapi.Depends(HTTPBearer()), -) -> Optional[dict]: - payload = jwt.verify_token(token.credentials) - if payload is None: - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_403_FORBIDDEN, - detail="Not authenticated", - ) - - # attach username to the request - # TODO: https://www.starlette.io/authentication/ instead - if "sub" in payload: - request.scope["user"] = payload["sub"] - request.scope["admin"] = payload.get("admin", False) - - return payload - - v0_api_router = fastapi.APIRouter( prefix="/api/v0", - dependencies=[fastapi.Depends(authenticated)] if settings.TOKEN_ENABLED else [], + dependencies=[fastapi.Depends(HTTPBearer())] if settings.TOKEN_ENABLED else [], tags=["Données"], ) @@ -653,7 +638,7 @@ def create_token_endpoint( token_creation_data: schema.TokenCreationData, request: fastapi.Request, ): - if request.scope.get("admin"): + if "admin" in request.auth.scopes: return create_token(email=token_creation_data.email) else: raise fastapi.HTTPException( diff --git a/api/tests/core/test_request.py b/api/tests/core/test_request.py index 58d7b625..8abfb1df 100644 --- a/api/tests/core/test_request.py +++ b/api/tests/core/test_request.py @@ -5,18 +5,20 @@ @pytest.mark.with_token def test_save_api_request_with_token(api_client, db_session): - url = "/api/v0/structures" + url = "/api/v0/structures/foo/bar?baz=1" response = api_client.get(url) - assert response.status_code == 200 + assert response.status_code == 404 assert db_session.query(models.Request).count() == 1 request_instance = db_session.query(models.Request).first() - assert request_instance.status_code == 200 + assert request_instance.status_code == 404 assert request_instance.user == "some_user" - assert request_instance.path == "/api/v0/structures" + assert request_instance.path == "/api/v0/structures/foo/bar" assert request_instance.method == "GET" + assert request_instance.path_params == {"source": "foo", "id": "bar"} + assert request_instance.query_params == {"baz": "1"} def test_save_api_request_without_token(api_client, db_session): @@ -25,10 +27,4 @@ def test_save_api_request_without_token(api_client, db_session): response = api_client.get(url) assert response.status_code == 403 - assert db_session.query(models.Request).count() == 1 - - request_instance = db_session.query(models.Request).first() - assert request_instance.status_code == 403 - assert request_instance.user is None - assert request_instance.path == "/api/v0/structures" - assert request_instance.method == "GET" + assert db_session.query(models.Request).count() == 0