Skip to content

Commit 5586435

Browse files
author
Ben Lavender
authored
Merge pull request #15 from github/v3-auth
Use public key authentication as per v3
2 parents b74b44d + 531bd0e commit 5586435

12 files changed

Lines changed: 245 additions & 50 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Rails helpers for easy, JSON-RPC based chatops.
55
A minimal controller example:
66

77
```ruby
8-
class ChatOpsController < ApplicationController
9-
include ::ChatOps::Controller
8+
class ChatopsController < ApplicationController
9+
include ::Chatops::Controller
1010

1111
chatops_namespace :echo
1212

@@ -79,7 +79,7 @@ You can return `jsonrpc_success` with a string to return text to chat. If you
7979
have an input validation or other handle-able error, you can use
8080
`jsonrpc_failure` to send a helpful error message.
8181

82-
ChatOps are regular old rails controller actions, and you can use niceties like
82+
Chatops are regular old rails controller actions, and you can use niceties like
8383
`before_action` and friends. `before_action :echo, :load_user` for the above
8484
case would call `load_user` before running `echo`.
8585

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
$:.push File.expand_path("../lib", __FILE__)
22

33
# Maintain your gem's version:
4-
require "chatops_controller/version"
4+
require "chatops/controller/version"
55

66
# Describe your gem and declare its dependencies:
77
Gem::Specification.new do |s|
8-
s.name = "chatops_controller"
8+
s.name = "chatops-controller"
99
s.version = ChatopsController::VERSION
1010
s.authors = ["Ben Lavender"]
11-
s.homepage = "https://github.com/github/chatops_controller"
11+
s.homepage = "https://github.com/github/chatops-controller"
1212
s.email = ["bhuga@github.com"]
1313
s.license = "unknown - maybe we'll open source this?"
1414
s.summary = %q{Rails helpers to create JSON-RPC chatops}

lib/chatops-controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require 'chatops/controller'

lib/chatops.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Chatops
2+
def self.public_key
3+
ENV[public_key_env_var_name]
4+
end
5+
6+
def self.public_key_env_var_name
7+
"CHATOPS_AUTH_PUBLIC_KEY"
8+
end
9+
10+
def self.alt_public_key
11+
ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"]
12+
end
13+
14+
def self.auth_base_url
15+
ENV[auth_base_url_env_var_name]
16+
end
17+
18+
def self.auth_base_url_env_var_name
19+
"CHATOPS_AUTH_BASE_URL"
20+
end
21+
end

lib/chatops/controller.rb

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
module ChatOps
1+
require "chatops"
2+
3+
module Chatops
24
module Controller
5+
class ConfigurationError < StandardError ; end
36
extend ActiveSupport::Concern
47

58
included do
6-
before_action :ensure_chatops_authenticated
9+
with_options if: :should_authenticate_chatops? do |controller|
10+
controller.before_action :ensure_valid_chatops_url
11+
controller.before_action :ensure_valid_chatops_timestamp
12+
controller.before_action :ensure_valid_chatops_signature
13+
controller.before_action :ensure_valid_chatops_nonce
14+
controller.before_action :ensure_chatops_authenticated
15+
end
716
before_action :ensure_user_given
817
before_action :ensure_method_exists
918
end
@@ -16,7 +25,7 @@ def list
1625
help: self.class.chatops_help,
1726
error_response: self.class.chatops_error_response,
1827
methods: chatops,
19-
version: "2" }
28+
version: "3" }
2029
end
2130

2231
def process(*args)
@@ -90,21 +99,78 @@ def ensure_user_given
9099
end
91100

92101
def ensure_chatops_authenticated
93-
return true unless (chatop_names + [:list]).include?(params[:action].to_sym)
94-
authenticated = authenticate_with_http_basic do |u, p|
95-
if ENV["CHATOPS_AUTH_TOKEN"].nil?
96-
raise StandardError, "Attempting to authenticate chatops with nil token"
97-
end
98-
if ENV["CHATOPS_ALT_AUTH_TOKEN"].nil?
99-
raise StandardError, "Attempting to authenticate chatops with nil alternate token"
100-
end
102+
body = request.raw_post || ""
103+
signature_string = [@chatops_url, @chatops_nonce, @chatops_timestamp, body].join("\n")
104+
# We return this just to aid client debugging.
105+
response.headers["Chatops-Signature-String"] = signature_string
106+
raise ConfigurationError.new("You need to add a client's public key in .pem format via #{Chatops.public_key_env_var_name}") unless Chatops.public_key.present?
107+
if signature_valid?(Chatops.public_key, @chatops_signature, signature_string) ||
108+
signature_valid?(Chatops.alt_public_key, @chatops_signature, signature_string)
109+
return true
110+
end
111+
return render :status => :forbidden, :plain => "Not authorized"
112+
end
101113

102-
Rack::Utils.secure_compare(ENV["CHATOPS_AUTH_TOKEN"], p) ||
103-
Rack::Utils.secure_compare(ENV["CHATOPS_ALT_AUTH_TOKEN"], p)
114+
def ensure_valid_chatops_url
115+
unless Chatops.auth_base_url.present?
116+
raise ConfigurationError.new("You need to set the server's base URL to authenticate chatops RPC via #{Chatops.auth_base_url_env_var_name}")
104117
end
105-
unless authenticated
106-
render :status => :forbidden, :plain => "Not authorized"
118+
if Chatops.auth_base_url[-1] == "/"
119+
raise ConfigurationError.new("Don't include a trailing slash in #{Chatops.auth_base_url_env_var_name}; the rails path will be appended and it must match exactly.")
107120
end
121+
@chatops_url = Chatops.auth_base_url + request.path
122+
end
123+
124+
def ensure_valid_chatops_nonce
125+
@chatops_nonce = request.headers["Chatops-Nonce"]
126+
return render :status => :forbidden, :plain => "A Chatops-Nonce header is required" unless @chatops_nonce.present?
127+
end
128+
129+
def ensure_valid_chatops_signature
130+
signature_header = request.headers["Chatops-Signature"]
131+
132+
begin
133+
# "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
134+
signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
135+
@chatops_signature = signature_items["signature"]
136+
rescue NoMethodError
137+
# The signature header munging, if something's amiss, can produce a `nil` that raises a
138+
# no method error. We'll just carry on; the nil signature will raise below
139+
end
140+
141+
unless @chatops_signature.present?
142+
return render :status => :forbidden, :plain => "Failed to parse signature header"
143+
end
144+
end
145+
146+
def ensure_valid_chatops_timestamp
147+
@chatops_timestamp = request.headers["Chatops-Timestamp"]
148+
time = Time.iso8601(@chatops_timestamp)
149+
if !(time > 1.minute.ago && time < 1.minute.from_now)
150+
return render :status => :forbidden, :plain => "Chatops timestamp not within 1 minute of server time: #{@chatops_timestamp} vs #{Time.now.utc.iso8601}"
151+
end
152+
rescue ArgumentError, TypeError
153+
# time parsing or missing can raise these
154+
return render :status => :forbidden, :plain => "Invalid Chatops-Timestamp: #{@chatops_timestamp}"
155+
end
156+
157+
def request_is_chatop?
158+
(chatop_names + [:list]).include?(params[:action].to_sym)
159+
end
160+
161+
def chatops_test_auth?
162+
Rails.env.test? && request.env["CHATOPS_TESTING_AUTH"]
163+
end
164+
165+
def should_authenticate_chatops?
166+
request_is_chatop? && !chatops_test_auth?
167+
end
168+
169+
def signature_valid?(key_string, signature, signature_string)
170+
digest = OpenSSL::Digest::SHA256.new
171+
decoded_signature = Base64.decode64(signature)
172+
public_key = OpenSSL::PKey::RSA.new(key_string)
173+
public_key.verify(digest, decoded_signature, signature_string)
108174
end
109175

110176
def ensure_method_exists

lib/chatops/controller/rspec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
require "chatops/controller/test_case"
22

33
RSpec.configure do |config|
4-
config.include ChatOps::Controller::TestCaseHelpers
4+
config.include Chatops::Controller::TestCaseHelpers
55
end
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
require "chatops/controller/test_case_helpers"
22

3-
class ChatOps::Controller::TestCase < ActionController::TestCase
4-
include ChatOps::Controller::TestCaseHelpers
3+
class Chatops::Controller::TestCase < ActionController::TestCase
4+
include Chatops::Controller::TestCaseHelpers
55
end

lib/chatops/controller/test_case_helpers.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
module ChatOps::Controller::TestCaseHelpers
1+
module Chatops::Controller::TestCaseHelpers
22

33
class NoMatchingCommandRegex < StandardError ; end
44

5-
def chatops_auth!(user = "_", pass = ENV["CHATOPS_AUTH_TOKEN"])
6-
request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(user, pass)
5+
def chatops_auth!
6+
request.env["CHATOPS_TESTING_AUTH"] = true
77
end
88

99
def chatop(method, params = {})
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module ChatopsController
2-
VERSION = "2.0.0"
2+
VERSION = "3.0.0"
33
end

lib/chatops_controller.rb

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)