From a2bec3277c0f3ff52e0033a4eac26c4fa9daaad4 Mon Sep 17 00:00:00 2001 From: Xavier Francisco Date: Sat, 14 Sep 2019 22:11:25 +0100 Subject: [PATCH] v0.1.0 --- .gitignore | 8 ++ .travis.yml | 28 ++++++ LICENSE | 21 +++++ README.md | 86 ++++++++++++++++++ shard.yml | 11 +++ spec/challenge_handler_spec.cr | 65 ++++++++++++++ spec/spec_helper.cr | 48 ++++++++++ spec/verification_handler_spec.cr | 84 ++++++++++++++++++ src/slack-events-api.cr | 2 + src/slack-events-api/challenge_handler.cr | 47 ++++++++++ src/slack-events-api/verification_handler.cr | 93 ++++++++++++++++++++ 11 files changed, 493 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/challenge_handler_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 spec/verification_handler_spec.cr create mode 100644 src/slack-events-api.cr create mode 100644 src/slack-events-api/challenge_handler.cr create mode 100644 src/slack-events-api/verification_handler.cr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f1ad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..26429d3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,28 @@ +language: crystal +script: + - crystal spec + - crystal tool format --check + - crystal docs + + +before_deploy: + - git config --local user.name "Xavier Francisco" + - git config --local user.email "" + - if [[ "$TRAVIS_BRANCH" = "releases" ]] ; then git tag "$TRAVIS_COMMIT_MESSAGE"; fi + +deploy: + - provider: pages + skip_cleanup: true + github_token: $GITHUB_TOKEN + project_name: crystal-slack-events-api + on: + branch: master + local_dir: docs + + - provider: releases + api_key: + secure: 077LR+xGo8Ott2b+i2FRcSlHSdCuhf3wtW9iUmeJyvTDXvMe21pNjHyDzwPPGe2i81sqvwkddJxEG6r1KmSWVwSiSi4UCiyuDf7C38fUy3kp/lBActy4jymm5Nipk+Egu1aIiR/wDRQ1k1z7cC3sQPCvTLuA5SLie4KyX/d0r44ofG4yIHBsIaRnOtcm6D7ZYMKNiFJ6Cc3hv+aBPRvRFmG+yRtyr+qja0FjyAPvV3R0/beGbwymbWYfX3VuejUsqq42TlK3IkPT6D9o6t1BF8RkAHnG90TNmw6Fd+4p27MJ+KNryxmz9IYoU++oHsxhecKYmSbedKGUZHwAjHIsLJW3fbQIy+nQyzeiMxMmp0k3UrR2EFBVxAg3M1Unw9vhBtskKO39nw23tI2bjqSqfWdVCyOvTw54V5jTwuW8Klv19qEe9m1kjPrLUQvlMPjUclqm1g/dKwbqcFtztlwBu2JyDPutjRwpeJSun0NpEg0qtZU0DJssnNOAShQnoXDKIpyPESTaT0MU09Xj1xlVrzxQy5WK34ZSLz8FzP3KMXC7eKVaJuwjpJRw9kkWPPj6px3DHo3hn2e0OS0fLpkeZ9CWkYGzmS6ADCt33idicRgXT7xPn0YOKn/zjpu8WwXssMwCuy46+iSpeiXj80dYvd5RJ/htf65re45d2lFqWZk= + file_glob: true + file: "*" + on: + branch: releases diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4cfa5a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Xavier Francisco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f4d4be --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Slack Events API adapter for Crystal + +[![Build Status](https://travis-ci.com/Qu4tro/slack-events-api.svg?token=Mqsa3fKeSUryp43kNdBt&branch=master)](https://travis-ci.com/Qu4tro/slack-events-api) + +[![Github release](https://img.shields.io/github/release/qu4tro/slack-events-api.svg)](https://github.com/qu4tro/slack-events-api/releases) + +### The middlewares you need to deal with Slack Events API + +## Overview + +slack-events-api is a [Crystal](https://crystal-lang.org/) package composed of two middlewares: + +`SlackEvents::VerificationHandler` + - Middleware that verifies that requests are correctly signed with `SLACK_SIGNING_SECRET` by Slack. All requests going through this middleware, will be checked. In the event of a request whose signature couldn't be verified, the middleware will early return with a `403 - Forbidden`. + - Receives `SLACK_SIGNING_SECRET` as its sole argument. + + +`SlackEvents::ChallengeHandler` + - Middleware that does the initial challenge handshake between Slack and your API. + +Further documentation can be found in https://qu4tro.github.io/slack-events-api/ + +## Installation + +1. Add this to your application's `shard.yml`: + +```yaml +dependencies: + slack-events-api: + github: qu4tro/slack-events-api +``` +2. Run `shards install` + + +## Usage +This example will suffice to perform the initial setup, but actual events will be 404'd, until you write your application-specific handler. + +```crystal +#!/usr/bin/env crystal + +require "http/server" +require "http/server/handler" + +require "slack-events-api" + +middlewares = [ + HTTP::LogHandler.new.as(HTTP::Handler), + HTTP::ErrorHandler.new, + SlackEvents::VerificationHandler.new(ENV["SLACK_SIGNING_SECRET"]), + SlackEvents::ChallengeHandler.new, +] + +HTTP::Server.new(middlewares).tap do |server| + address = server.bind_tcp "localhost", ENV["PORT"].to_i + puts "Listening on http://#{address}" + server.listen +end +``` +## Further work + +- Make JSON mappings for all event types supported by the Event API +- If a reverse-proxy middleware comes up for Crystal, I think it's worth thinking about creating a docker image, to allow for the verification and challenge-setup to be automated for any ad-hoc server. + + +## Development + +Any restriction to development should be tool-automated. +So, feel free to open PRs. If all the tests pass, it should be good to merge, if it fits the package domain - opening an issue is a good way to clarify. In fact, feel free to open issues for any type of clarification. + + +## Contributing + +1. [Fork it](https://github.com/Qu4tro/slack-events-api/fork) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + + +## Authors + +* **Xavier Francisco** - *Initial work* - [Qu4tro](https://github.com/Qu4tro) + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..4b92fbf --- /dev/null +++ b/shard.yml @@ -0,0 +1,11 @@ +name: slack-events-api +version: 0.1.0 +crystal: 0.30.1 + +authors: + - Xavier Francisco + +description: | + The middlewares you need to deal with Slack Events API + +license: MIT diff --git a/spec/challenge_handler_spec.cr b/spec/challenge_handler_spec.cr new file mode 100644 index 0000000..e1399a1 --- /dev/null +++ b/spec/challenge_handler_spec.cr @@ -0,0 +1,65 @@ +require "spec" + +require "./spec_helper" +require "../src/slack-events-api/challenge_handler" + +def cspec + SpecHandler.new SlackEvents::ChallengeHandler.new +end + +good_challenge = JSON.build do |json| + json.object do + json.field "token", "doesntmatter" + json.field "challenge", "challenge-string" + json.field "type", "url_verification" + end +end + +wrong_type = JSON.build do |json| + json.object do + json.field "token", "doesntmatter" + json.field "challenge", "challenge-string" + json.field "type", "not_url_verification" + end +end + +wrong_schema = JSON.build do |json| + json.object do + json.field "name", "foo" + json.field "values" do + json.array do + json.number 1 + json.number 2 + json.number 3 + end + end + end +end + +describe SlackEvents::ChallengeHandler do + describe "#call" do + it "replies to the challenge with the challenge key" do + request = simple_post(good_challenge) + cspec.with request do |response| + response.status_code.should eq 200 + response.headers["Content-Type"]?.should eq "text/plain" + response.body.should eq "challenge-string" + end + end + + it "does nothing if challenge payload can't be parsed" do + request = simple_post(":o") + cspec.passthrough?(request).should be_true + end + + it "does nothing if type is not a match " do + request = simple_post(wrong_type) + cspec.passthrough?(request).should be_true + end + + it "does nothing if type is not a match " do + request = simple_post(wrong_schema) + cspec.passthrough?(request).should be_true + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..b6eb6a2 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,48 @@ +class SpecHandler + def initialize(@handler : HTTP::Handler) + end + + def handler + @handler + end + + def with(request, &block) + io = IO::Memory.new + response = HTTP::Server::Response.new(io) + context = HTTP::Server::Context.new(request, response) + + @handler.call(context) + response.close + + io.rewind + yield HTTP::Client::Response.from_io(io) + end + + def make_request(request) + self.with request do |r| + end + end + + def passthrough?(request) + not_found? request + end + + def not_found?(request) + self.with request do |response| + not_found = response.status_code == 404 + not_found &&= response.headers["Content-Type"]? == "text/plain" + not_found &&= response.body == "Not Found\n" + not_found + end + end + + def forbidden?(request, message = nil) + self.with request do |response| + return response.status_code == 403 + end + end +end + +def simple_post(body, headers = nil) + HTTP::Request.new("POST", "/", headers, body) +end diff --git a/spec/verification_handler_spec.cr b/spec/verification_handler_spec.cr new file mode 100644 index 0000000..1a39eaf --- /dev/null +++ b/spec/verification_handler_spec.cr @@ -0,0 +1,84 @@ +require "spec" + +require "./spec_helper" +require "../src/slack-events-api/verification_handler" + +def vspec + SpecHandler.new SlackEvents::VerificationHandler.new("secret") +end + +def signed_post(timestamp = Time.utc.to_unix.to_s, signature = nil) + headers = HTTP::Headers{ + "X-Slack-Signature" => signature || "", + "X-Slack-Request-Timestamp" => timestamp, + } + + request = simple_post("doesntmatter", headers: headers) + + if signature == nil + request.headers["X-Slack-Signature"] = + vspec.handler + .as(SlackEvents::VerificationHandler) + .computed_signature(request) + end + + request +end + +describe SlackEvents::VerificationHandler do + describe "passing #call" do + it "allows the request through if the signature is correct" do + vspec.passthrough?(signed_post).should be_true + end + + it "gives the request some leeway if the signature is outdated" do + past = Time.utc_now - Time::Span.new(hours: 0, minutes: 4, seconds: 0) + past_ts = past.to_unix.to_s + vspec.forbidden?(signed_post timestamp: past_ts).should be_false + end + + it "gives the request some leeway if the signature is from the future" do + future = Time.utc_now + Time::Span.new(hours: 0, minutes: 4, seconds: 0) + future_ts = future.to_unix.to_s + vspec.forbidden?(signed_post timestamp: future_ts).should be_false + end + end + + describe "blocing #call" do + it "blocks the request if the signature is outdated" do + past = Time.utc_now - Time::Span.new(hours: 0, minutes: 10, seconds: 0) + past_ts = past.to_unix.to_s + vspec.forbidden?(signed_post timestamp: past_ts).should be_true + end + + it "blocks the request if the signature is from the future" do + future = Time.utc_now + Time::Span.new(hours: 0, minutes: 10, seconds: 0) + future_ts = future.to_unix.to_s + vspec.forbidden?(signed_post timestamp: future_ts).should be_true + end + + it "blocks the request if the signature is wrong" do + request1 = signed_post signature: ":o" + request2 = signed_post signature: "itsme" + request3 = signed_post signature: "letmein" + request4 = signed_post signature: "iforgotthesecretknock" + + vspec.forbidden?(request1).should be_true + vspec.forbidden?(request2).should be_true + vspec.forbidden?(request3).should be_true + vspec.forbidden?(request4).should be_true + end + end + + describe "#computed_signature" do + it "computes a signture correctly" do + signature = "v0=3de8edba6fa2f065b575537fba33bd4c4217aa3d649c7852dd831e2b8caff0ad" + request = signed_post(signature: signature, timestamp: "1234567890") + + vspec.handler + .as(SlackEvents::VerificationHandler) + .computed_signature(request) + .should eq signature + end + end +end diff --git a/src/slack-events-api.cr b/src/slack-events-api.cr new file mode 100644 index 0000000..5c5c968 --- /dev/null +++ b/src/slack-events-api.cr @@ -0,0 +1,2 @@ +require "./slack-events-api/challenge_handler" +require "./slack-events-api/verification_handler" diff --git a/src/slack-events-api/challenge_handler.cr b/src/slack-events-api/challenge_handler.cr new file mode 100644 index 0000000..8f5c3ae --- /dev/null +++ b/src/slack-events-api/challenge_handler.cr @@ -0,0 +1,47 @@ +require "json" +require "http/server" +require "http/server/handler" + +# Verifies ownership of an Events API Request URL +# This event does not require a specific OAuth scope or subscription. +# You'll automatically receive it whenever configuring an Events API URL. +module SlackEvents + # Only used for deserializing the payload sent by Slack + private class Challenge + # JSON mapping for the challenge payload + JSON.mapping( + token: String, + challenge: String, + type: String, + ) + end + + # Middleware that does the initial challenge handshake with Slack. + # It always need to be used with and after `SlackEvents::VerificationHandler`. + class ChallengeHandler + include HTTP::Handler + + # Requests that go through this middleware either: + # Are challenges - and the correct response is returned + # Are some other event - and the middleware does nothing + def call(context) + challenge = get_challenge context.request + return call_next(context) if challenge == nil + + context.response.status_code = 200 + context.response.content_type = "text/plain" + context.response.print challenge + end + + # Parse and validate a challenge payload. + # It's used to identify whetever it's a challenge. + protected def get_challenge(request) + object = Challenge.from_json(request.body.not_nil!) + return nil if object.type != "url_verification" + + object.challenge + rescue JSON::ParseException + nil + end + end +end diff --git a/src/slack-events-api/verification_handler.cr b/src/slack-events-api/verification_handler.cr new file mode 100644 index 0000000..b6afb23 --- /dev/null +++ b/src/slack-events-api/verification_handler.cr @@ -0,0 +1,93 @@ +require "openssl/hmac" +require "http/server" +require "http/server/handler" + +# Slack signs its requests using a secret that's unique to your app. +# With the help of signing secrets, +# your app can more confidently verify whether requests from us are authentic. +module SlackEvents + # Middleware that verifies that requests are correctly signed with `SLACK_SIGNING_SECRET` by Slack. + class VerificationHandler + include HTTP::Handler + + # Initialize with the unique string Slack creates for your app. + # Verify requests from Slack with confidence by verifying signatures + # using your signing secret. + def initialize(@signing_secret : String) + end + + # Requests that go through this middleware need to have a valid signature + # or are a '403 - Forbidden' will be returned to the client. + def call(context) + return forbidden context unless valid? context.request + + call_next(context) + end + + # Mutate the response status to '403 - Forbidden'. + protected def forbidden(context) + context.response.status_code = 403 + end + + # Check for all necessary conditions for a request to be a valid event. + protected def valid?(request) + request.method == "POST" && + (valid_age? request) && + (valid_signature? request) + end + + # Compare this computed signature to the X-Slack-Signature header on the request. + def valid_signature?(request) + (computed_signature request) == request.headers["X-Slack-Signature"]? + end + + # The signature depends on the timestamp to protect against replay attacks. + # Check to make sure that the request occurred recently. + # NOTE: The package defaults to accepting timestamps that + # are within 5 minutes of the current time. + # i.e. It can be either from 3 minutes ago or 3 minutes from now. + def valid_age?(request) + req_ts = Time.unix (timestamp request.headers).to_i + now_ts = Time.utc_now + + age = now_ts - req_ts + + age.duration < Time::Span.new(hours: 0, minutes: 5, seconds: 0) + end + + # With the help of HMAC SHA256 - `OpenSSL::HMAC` hash the basestring, + # using the Slack Signing Secret - `@signing_secret` - as the key. + def computed_signature(request) + "v0=" + OpenSSL::HMAC.hexdigest(:sha256, @signing_secret, basestring request) + end + + # Concatenate the version number, the timestamp, + # and the body of the request to form a basestring. + # Use a colon as the delimiter between the three elements. + # For example, v0:123456789:command=/weather&text=94070. + protected def basestring(request) + [ + version_number, + timestamp(request.headers), + body(request), + ].join(":") + end + + # The version number right now is always v0. + protected def version_number + "v0" + end + + # Retrieves the X-Slack-Request-Timestamp header + # If it's missing it defaults to to 0 - i.e. 01Jan, 1970 and therefore is never valid. + protected def timestamp(headers) + headers["X-Slack-Request-Timestamp"] || "0" + end + + # Peek into the body and return it as a string + # We use `IO.peek` so that middlewares down the line can read from it. + protected def body(request) + String.new(request.body.not_nil!.peek.not_nil!) + end + end +end