From 1dac38c03e84347a3ed77ae74086c7c02a789364 Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Wed, 30 Dec 2020 12:15:20 -0500 Subject: [PATCH] feat: dependency to handle slash commands request (#4) --- README.md | 22 ++++- fastapi_slack.py | 86 +++++++++++++++++++- tests/conftest.py | 7 ++ tests/functionals/conftest.py | 24 ++++++ tests/functionals/demo.py | 11 +++ tests/functionals/test_slash_command.py | 28 +++++++ tests/test_settings.py | 18 ---- tests/unittests/test_check_signature.py | 43 ++++++++++ tests/unittests/test_settings.py | 38 +++++++++ tests/{ => unittests}/test_version.py | 0 tests/unittests/test_with_body.py | 15 ++++ tests/unittests/test_with_form_data.py | 7 ++ tests/unittests/test_with_slash_command.py | 30 +++++++ tests/unittests/test_with_valid_signature.py | 29 +++++++ tests/unittests/test_with_valid_timestamp.py | 16 ++++ 15 files changed, 352 insertions(+), 22 deletions(-) create mode 100644 tests/functionals/conftest.py create mode 100644 tests/functionals/demo.py create mode 100644 tests/functionals/test_slash_command.py delete mode 100644 tests/test_settings.py create mode 100644 tests/unittests/test_check_signature.py create mode 100644 tests/unittests/test_settings.py rename tests/{ => unittests}/test_version.py (100%) create mode 100644 tests/unittests/test_with_body.py create mode 100644 tests/unittests/test_with_form_data.py create mode 100644 tests/unittests/test_with_slash_command.py create mode 100644 tests/unittests/test_with_valid_signature.py create mode 100644 tests/unittests/test_with_valid_timestamp.py diff --git a/README.md b/README.md index 7893a56..62228c9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,26 @@ from fastapi import FastAPI app = FastAPI() - app.include_router(fastapi_slack.router) ``` + +## [Slash Commands] + +* Dependency `fastapi_slack.with_slash_command` validates request signature and extract + the slash command info needed to process it: + +```python +from fastapi import Depends, FastAPI +from fastapi_slack import SlashCommand, router, with_slash_command + +app = FastAPI() +app.include_router(router) + + +@app.post("/slash-commands") +def process_commands(slash_command: SlashCommand = Depends(with_slash_command)): + pass +``` + + +[Slash Commands]: https://api.slack.com/interactivity/slash-commands diff --git a/fastapi_slack.py b/fastapi_slack.py index ceb32ee..a2bac31 100644 --- a/fastapi_slack.py +++ b/fastapi_slack.py @@ -1,9 +1,15 @@ -from fastapi import APIRouter +from hashlib import sha256 +from hmac import HMAC +from time import time +from urllib.parse import parse_qsl + +from fastapi import APIRouter, Depends, Header, HTTPException, Request from pkg_resources import get_distribution -from pydantic import BaseSettings, SecretStr, ValidationError +from pydantic import BaseModel, BaseSettings, SecretStr, ValidationError -__all__ = ["router"] +__all__ = ["SlashCommand", "router", "with_slash_command"] __version__ = get_distribution("fastapi-slack").version + router = APIRouter() @@ -15,9 +21,83 @@ class Config: env_prefix = "slack_" +class SlashCommand(BaseModel): + token: str + team_id: str + team_domain: str + enterprise_id: str + enterprise_name: str + channel_id: str + channel_name: str + user_id: str + user_name: str + command: str + text: str + response_url: str + trigger_id: str + api_app_id: str + + @router.on_event("startup") async def on_startup(): try: Settings() except ValidationError as error: raise Exception("Missing environment variable(s)") from error + + +def with_valid_timestamp(x_slack_request_timestamp: int = Header(...)) -> int: + now = time() + more_than_thirty_seconds = now - x_slack_request_timestamp > 30 + if more_than_thirty_seconds: + raise HTTPException(400, "Invalid timestamp") + + return x_slack_request_timestamp + + +async def with_body(request: Request) -> bytes: + """Return request body. + + Per design, a route cannot depend on a form field and consume body because + dependencies resolution consumes body. + Therefore, this is not using `fastapi.Form` to extract form data parameters. + """ + return await request.body() + + +def with_form_data(body: bytes = Depends(with_body)) -> dict: + return dict(parse_qsl(body.decode())) + + +def check_signature(secret: str, timestamp: int, signature: str, body: bytes) -> bool: + """Return True if signature is valid and False if not.""" + signature_message = f"v0:{timestamp}:{body.decode()}" + local_hash = HMAC(secret.encode(), signature_message.encode(), sha256).hexdigest() + local_signature = f"v0={local_hash}" + return local_signature == signature + + +def with_settings() -> Settings: + try: + return Settings() + except ValidationError as error: + raise HTTPException(500) from error + + +def with_valid_signature( + body: bytes = Depends(with_body), + settings: Settings = Depends(with_settings), + timestamp: int = Depends(with_valid_timestamp), + signature: str = Header(..., alias="X-Slack-Signature"), +) -> str: + secret = settings.signing_secret.get_secret_value() + if check_signature(secret, timestamp, signature, body) is False: + raise HTTPException(403, "invalid signature") + return signature + + +def with_slash_command( + form_data: dict = Depends(with_form_data), + signature=Depends(with_valid_signature), +) -> SlashCommand: + return SlashCommand(**form_data) diff --git a/tests/conftest.py b/tests/conftest.py index ddd4584..321e373 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,3 +11,10 @@ def environ(): } with patch.dict("os.environ", environ, clear=True): yield environ + + +@fixture +def settings(environ): + from fastapi_slack import Settings + + return Settings() diff --git a/tests/functionals/conftest.py b/tests/functionals/conftest.py new file mode 100644 index 0000000..a15fdf6 --- /dev/null +++ b/tests/functionals/conftest.py @@ -0,0 +1,24 @@ +from asgi_lifespan import LifespanManager +from httpx import ASGITransport, AsyncClient +from pytest import fixture + + +@fixture +async def app(): + from demo import app + + from fastapi_slack import with_valid_signature + + async with LifespanManager(app): + app.dependency_overrides[with_valid_signature] = lambda: "signature" + yield app + + +@fixture +async def client(app): + async with LifespanManager(app): + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://example.local" + ) as client: + yield client diff --git a/tests/functionals/demo.py b/tests/functionals/demo.py new file mode 100644 index 0000000..c801bda --- /dev/null +++ b/tests/functionals/demo.py @@ -0,0 +1,11 @@ +from fastapi import Depends, FastAPI + +from fastapi_slack import SlashCommand, router, with_slash_command + +app = FastAPI(title="demo") +app.include_router(router) + + +@app.post("/slack-commands") +def commands(command: SlashCommand = Depends(with_slash_command)): + pass diff --git a/tests/functionals/test_slash_command.py b/tests/functionals/test_slash_command.py new file mode 100644 index 0000000..598dc9f --- /dev/null +++ b/tests/functionals/test_slash_command.py @@ -0,0 +1,28 @@ +from pytest import fixture, mark + +pytestmark = mark.asyncio + + +@fixture +def payload() -> str: + return ( + "token=gIkuvaNzQIHg97ATvDxqgjtO" + "&team_id=T0001" + "&team_domain=example" + "&enterprise_id=E0001" + "&enterprise_name=Globular%20Construct%20Inc" + "&channel_id=C2147483705" + "&channel_name=test" + "&user_id=U2147483697" + "&user_name=Steve" + "&command=/weather" + "&text=94070" + "&response_url=https://hooks.slack.com/commands/1234/5678" + "&trigger_id=13345224609.738474920.8088930838d88f008e0" + "&api_app_id=A123456" + ) + + +async def test_slack_command(client, payload): + res = await client.post("/slack-commands", content=payload) + assert res.status_code == 200, res.content diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index aa9a863..0000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,18 +0,0 @@ -from pytest import mark, raises - -pytestmark = mark.asyncio - - -async def test_on_startup_fails_with_invalid_settings(monkeypatch): - from fastapi_slack import on_startup - - monkeypatch.delenv("slack_access_token") - monkeypatch.delenv("slack_signing_secret") - with raises(Exception): - await on_startup() - - -async def test_on_startup(): - from fastapi_slack import on_startup - - await on_startup() diff --git a/tests/unittests/test_check_signature.py b/tests/unittests/test_check_signature.py new file mode 100644 index 0000000..6393459 --- /dev/null +++ b/tests/unittests/test_check_signature.py @@ -0,0 +1,43 @@ +from pytest import fixture + + +@fixture +def secret(): + """Valid slack signing secret.""" + return "e33f1fb286333652295f920c461de67d" + + +@fixture +def timestamp(): + """Valid timestamp.""" + return 1602970682 + + +@fixture +def signature(): + """Valid signature.""" + return "v0=9f214e30377799cf44e680e22991f04147a32bf2434954247728d60304180dc2" + + +@fixture +def body() -> bytes: + """Valid slack command body.""" + return ( + b"token=zLKyq9QNyqgGQ3weXwtSov90&team_id=T129F61FC&team_domain=dialoguemd&channe" + b"l_id=D1V7CLUH3&channel_name=directmessage&user_id=U1V5RS49Z&user_name=hadrien&" + b"command=%2Fdev-ohs&text=&api_app_id=A01D64BPMTK&response_url=https%3A%2F%2Fhoo" + b"ks.slack.com%2Fcommands%2FT129F61FC%2F1435644863042%2FgV9qwUtCPGS3AQry7kNLiVsX" + b"&trigger_id=1428914092678.36321205522.18053fc39d1f89c8bab43fd18ee47ed8" + ) + + +def test_check_signature(secret, timestamp, signature, body): + from fastapi_slack import check_signature + + assert check_signature(secret, timestamp, signature, body) is True + + +def test_check_signature_return_false_on_invalid_signature(): + from fastapi_slack import check_signature + + assert check_signature("secret", 12345, "signature", b"body") is False diff --git a/tests/unittests/test_settings.py b/tests/unittests/test_settings.py new file mode 100644 index 0000000..868a6c7 --- /dev/null +++ b/tests/unittests/test_settings.py @@ -0,0 +1,38 @@ +from fastapi import HTTPException +from pytest import fixture, mark, raises + +pytestmark = mark.asyncio + + +@fixture +def missing_variables(monkeypatch): + monkeypatch.delenv("slack_access_token") + monkeypatch.delenv("slack_signing_secret") + + +async def test_on_startup_fails_with_invalid_settings(missing_variables): + from fastapi_slack import on_startup + + with raises(Exception): + await on_startup() + + +async def test_on_startup(): + from fastapi_slack import on_startup + + await on_startup() + + +async def test_with_settings(settings): + from fastapi_slack import with_settings + + assert with_settings() == settings + + +async def test_with_settings_raises_500_with_missing_variables(missing_variables): + from fastapi_slack import with_settings + + with raises(HTTPException) as raise_info: + with_settings() + + assert raise_info.value.status_code == 500 diff --git a/tests/test_version.py b/tests/unittests/test_version.py similarity index 100% rename from tests/test_version.py rename to tests/unittests/test_version.py diff --git a/tests/unittests/test_with_body.py b/tests/unittests/test_with_body.py new file mode 100644 index 0000000..f305fc7 --- /dev/null +++ b/tests/unittests/test_with_body.py @@ -0,0 +1,15 @@ +from unittest.mock import AsyncMock + +from fastapi import Request +from pytest import mark + +pytestmark = mark.asyncio + + +async def test_with_body(): + from fastapi_slack import with_body + + request = AsyncMock(spec=Request) + request.body.return_value = b"b o d y" + + assert await with_body(request) == b"b o d y" diff --git a/tests/unittests/test_with_form_data.py b/tests/unittests/test_with_form_data.py new file mode 100644 index 0000000..85a78e8 --- /dev/null +++ b/tests/unittests/test_with_form_data.py @@ -0,0 +1,7 @@ +def test_with_form_data(): + from fastapi_slack import with_form_data + + assert with_form_data(b"param1=1¶m2=abc") == { + "param1": "1", + "param2": "abc", + } diff --git a/tests/unittests/test_with_slash_command.py b/tests/unittests/test_with_slash_command.py new file mode 100644 index 0000000..e6fc3b5 --- /dev/null +++ b/tests/unittests/test_with_slash_command.py @@ -0,0 +1,30 @@ +from pytest import fixture + + +@fixture +def form_data() -> dict: + # https://api.slack.com/interactivity/slash-commands#app_command_handling + return { + "token": "gIkuvaNzQIHg97ATvDxqgjtO", + "team_id": "T0001", + "team_domain": "example", + "enterprise_id": "E0001", + "enterprise_name": "Globular Construct Inc", + "channel_id": "C2147483705", + "channel_name": "test", + "user_id": "U2147483697", + "user_name": "Steve", + "command": "/weather", + "text": "94070", + "response_url": "https://hooks.slack.com/commands/1234/5678", + "trigger_id": "13345224609.738474920.8088930838d88f008e0", + "api_app_id": "A123456", + } + + +def test_slash_commands(settings, form_data): + from fastapi_slack import with_slash_command + + slash_command = with_slash_command(form_data, "signature") + + assert slash_command.dict() == form_data diff --git a/tests/unittests/test_with_valid_signature.py b/tests/unittests/test_with_valid_signature.py new file mode 100644 index 0000000..618ee31 --- /dev/null +++ b/tests/unittests/test_with_valid_signature.py @@ -0,0 +1,29 @@ +from unittest.mock import patch + +from fastapi import HTTPException +from pytest import fixture, raises + + +@fixture +def check_signature(): + with patch("fastapi_slack.check_signature") as check_signature: + check_signature.return_value = True + yield check_signature + + +def test_with_valid_signature(check_signature, settings): + from fastapi_slack import with_valid_signature + + with_valid_signature(b"b o d y", settings, 12345, "signature") + + +def test_with_valid_signature_raises_403_when_signature_is_invalid( + check_signature, settings +): + from fastapi_slack import with_valid_signature + + check_signature.return_value = False + with raises(HTTPException) as raise_info: + with_valid_signature(b"b o d y", settings, 12345, "signature") + + assert raise_info.value.status_code == 403 diff --git a/tests/unittests/test_with_valid_timestamp.py b/tests/unittests/test_with_valid_timestamp.py new file mode 100644 index 0000000..859e540 --- /dev/null +++ b/tests/unittests/test_with_valid_timestamp.py @@ -0,0 +1,16 @@ +from time import time + +from pytest import raises + + +def test_with_valid_timestamp(): + from fastapi_slack import with_valid_timestamp + + with_valid_timestamp(int(time())) + + +def test_with_valid_timestamp_raises_400_when_more_than_30_seconds(): + from fastapi_slack import with_valid_timestamp + + with raises(Exception): + with_valid_timestamp(int(time() - 40))