diff --git a/Rakefile b/Rakefile index 75e2873..fb8ae86 100644 --- a/Rakefile +++ b/Rakefile @@ -14,6 +14,7 @@ begin gem.add_dependency "json" gem.add_dependency "crack" gem.add_dependency "ruby-hmac" + gem.add_dependency 'signature' gem.add_development_dependency "rspec", ">= 1.2.9" gem.add_development_dependency "webmock" # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings diff --git a/lib/pusher.rb b/lib/pusher.rb index ced373f..f508164 100644 --- a/lib/pusher.rb +++ b/lib/pusher.rb @@ -18,7 +18,7 @@ def logger end def authentication_token - Authentication::Token.new(@key, @secret) + Signature::Token.new(@key, @secret) end end @@ -36,4 +36,3 @@ def self.[](channel_name) end require 'pusher/channel' -require 'pusher/authentication' diff --git a/lib/pusher/authentication.rb b/lib/pusher/authentication.rb deleted file mode 100644 index 3111c2a..0000000 --- a/lib/pusher/authentication.rb +++ /dev/null @@ -1,142 +0,0 @@ -require 'hmac-sha2' -require 'base64' - -module Authentication - class AuthenticationError < RuntimeError; end - - class Token - attr_reader :key, :secret - - def initialize(key, secret) - @key, @secret = key, secret - end - - def sign(request) - request.sign(self) - end - end - - class Request - attr_accessor :path, :query_hash - - # http://www.w3.org/TR/NOTE-datetime - ISO8601 = "%Y-%m-%dT%H:%M:%SZ" - - def initialize(method, path, query) - raise ArgumentError, "Expected string" unless path.kind_of?(String) - raise ArgumentError, "Expected hash" unless query.kind_of?(Hash) - - query_hash = {} - auth_hash = {} - query.each do |key, v| - k = key.to_s.downcase - k[0..4] == 'auth_' ? auth_hash[k] = v : query_hash[k] = v - end - - @method = method.upcase - @path, @query_hash, @auth_hash = path, query_hash, auth_hash - end - - def sign(token) - @auth_hash = { - :auth_version => "1.0", - :auth_key => token.key, - :auth_timestamp => Time.now.to_i - } - - @auth_hash[:auth_signature] = signature(token) - - return @auth_hash - end - - # Authenticates the request with a token - # - # Timestamp check: Unless timestamp_grace is set to nil (which will skip - # the timestamp check), an exception will be raised if timestamp is not - # supplied or if the timestamp provided is not within timestamp_grace of - # the real time (defaults to 10 minutes) - # - # Signature check: Raises an exception if the signature does not match the - # computed value - # - def authenticate_by_token!(token, timestamp_grace = 600) - validate_version! - validate_timestamp!(timestamp_grace) - validate_signature!(token) - true - end - - def authenticate_by_token(token, timestamp_grace = 600) - authenticate_by_token!(token, timestamp_grace) - rescue AuthenticationError - false - end - - def authenticate(timestamp_grace = 600, &block) - key = @auth_hash['auth_key'] - raise AuthenticationError, "Authentication key required" unless key - token = yield key - unless token && token.secret - raise AuthenticationError, "Invalid authentication key" - end - authenticate_by_token!(token, timestamp_grace) - return token - end - - def auth_hash - raise "Request not signed" unless @auth_hash && @auth_hash[:auth_signature] - @auth_hash - end - - private - - def signature(token) - HMAC::SHA256.hexdigest(token.secret, string_to_sign) - end - - def string_to_sign - [@method, @path, parameter_string].join("\n") - end - - def parameter_string - param_hash = @query_hash.merge(@auth_hash || {}) - - # Convert keys to lowercase strings - hash = {}; param_hash.each { |k,v| hash[k.to_s.downcase] = v } - - # Exclude signature from signature generation! - hash.delete("auth_signature") - - hash.keys.sort.map { |k| "#{k}=#{hash[k]}" }.join("&") - end - - def validate_version! - version = @auth_hash["auth_version"] - raise AuthenticationError, "Version required" unless version - raise AuthenticationError, "Version not supported" unless version == '1.0' - end - - def validate_timestamp!(grace) - return true if grace.nil? - - timestamp = @auth_hash["auth_timestamp"] - error = (timestamp.to_i - Time.now.to_i).abs - raise AuthenticationError, "Timestamp required" unless timestamp - if error >= grace - raise AuthenticationError, "Timestamp expired: Given timestamp "\ - "(#{Time.at(timestamp.to_i).utc.strftime(ISO8601)}) "\ - "not within #{grace}s of server time "\ - "(#{Time.now.utc.strftime(ISO8601)})" - end - return true - end - - def validate_signature!(token) - unless @auth_hash["auth_signature"] == signature(token) - raise AuthenticationError, "Invalid signature: you should have "\ - "sent HmacSHA256Hex(#{string_to_sign.inspect}, your_secret_key)" - end - return true - end - end -end diff --git a/lib/pusher/channel.rb b/lib/pusher/channel.rb index 66052d1..068e268 100644 --- a/lib/pusher/channel.rb +++ b/lib/pusher/channel.rb @@ -1,4 +1,5 @@ require 'crack/core_extensions' # Used for Hash#to_params +require 'signature' require 'digest/md5' require 'json' @@ -99,7 +100,7 @@ def construct_request(event_name, data, socket_id) end params[:body_md5] = Digest::MD5.hexdigest(body) - request = Authentication::Request.new('POST', @uri.path, params) + request = Signature::Request.new('POST', @uri.path, params) auth_hash = request.sign(Pusher.authentication_token) query_params = params.merge(auth_hash) diff --git a/spec/authentication_spec.rb b/spec/authentication_spec.rb deleted file mode 100644 index 47e40d5..0000000 --- a/spec/authentication_spec.rb +++ /dev/null @@ -1,176 +0,0 @@ -require File.expand_path('../spec_helper', __FILE__) - -describe Authentication do - before :each do - Time.stub!(:now).and_return(Time.at(1234)) - - @token = Authentication::Token.new('key', 'secret') - - @request = Authentication::Request.new('POST', '/some/path', { - "query" => "params", - "go" => "here" - }) - @signature = @request.sign(@token)[:auth_signature] - end - - it "should generate base64 encoded signature from correct key" do - @request.send(:string_to_sign).should == "POST\n/some/path\nauth_key=key&auth_timestamp=1234&auth_version=1.0&go=here&query=params" - @signature.should == '3b237953a5ba6619875cbb2a2d43e8da9ef5824e8a2c689f6284ac85bc1ea0db' - end - - it "should make auth_hash available after request is signed" do - request = Authentication::Request.new('POST', '/some/path', { - "query" => "params" - }) - lambda { - request.auth_hash - }.should raise_error('Request not signed') - - request.sign(@token) - request.auth_hash.should == { - :auth_signature => "da078fcedd72941b6c873caa40d0d6b2000ebfc700cee802b128dd20f72e74e9", - :auth_version => "1.0", - :auth_key => "key", - :auth_timestamp => 1234 - } - end - - it "should cope with symbol keys" do - @request.query_hash = { - :query => "params", - :go => "here" - } - @request.sign(@token)[:auth_signature].should == @signature - end - - it "should cope with upcase keys (keys are lowercased before signing)" do - @request.query_hash = { - "Query" => "params", - "GO" => "here" - } - @request.sign(@token)[:auth_signature].should == @signature - end - - it "should use the path to generate signature" do - @request.path = '/some/other/path' - @request.sign(@token)[:auth_signature].should_not == @signature - end - - it "should use the query string keys to generate signature" do - @request.query_hash = { - "other" => "query" - } - @request.sign(@token)[:auth_signature].should_not == @signature - end - - it "should use the query string values to generate signature" do - @request.query_hash = { - "key" => "notfoo", - "other" => 'bar' - } - @request.sign(@token)[:signature].should_not == @signature - end - - describe "verification" do - before :each do - Time.stub!(:now).and_return(Time.at(1234)) - @request.sign(@token) - @params = @request.query_hash.merge(@request.auth_hash) - end - - it "should verify requests" do - request = Authentication::Request.new('POST', '/some/path', @params) - request.authenticate_by_token(@token).should == true - end - - it "should raise error if signature is not correct" do - @params[:auth_signature] = 'asdf' - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error('Invalid signature: you should have sent HmacSHA256Hex("POST\n/some/path\nauth_key=key&auth_timestamp=1234&auth_version=1.0&go=here&query=params", your_secret_key)') - end - - it "should raise error if timestamp not available" do - @params.delete(:auth_timestamp) - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error('Timestamp required') - end - - it "should raise error if timestamp has expired (default of 600s)" do - request = Authentication::Request.new('POST', '/some/path', @params) - Time.stub!(:now).and_return(Time.at(1234 + 599)) - request.authenticate_by_token!(@token).should == true - Time.stub!(:now).and_return(Time.at(1234 - 599)) - request.authenticate_by_token!(@token).should == true - Time.stub!(:now).and_return(Time.at(1234 + 600)) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 600s of server time (1970-01-01T00:30:34Z)") - Time.stub!(:now).and_return(Time.at(1234 - 600)) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 600s of server time (1970-01-01T00:10:34Z)") - end - - it "should be possible to customize the timeout grace period" do - grace = 10 - request = Authentication::Request.new('POST', '/some/path', @params) - Time.stub!(:now).and_return(Time.at(1234 + grace - 1)) - request.authenticate_by_token!(@token, grace).should == true - Time.stub!(:now).and_return(Time.at(1234 + grace)) - lambda { - request.authenticate_by_token!(@token, grace) - }.should raise_error("Timestamp expired: Given timestamp (1970-01-01T00:20:34Z) not within 10s of server time (1970-01-01T00:20:44Z)") - end - - it "should be possible to skip timestamp check by passing nil" do - request = Authentication::Request.new('POST', '/some/path', @params) - Time.stub!(:now).and_return(Time.at(1234 + 1000)) - request.authenticate_by_token!(@token, nil).should == true - end - - it "should check that auth_version is supplied" do - @params.delete(:auth_version) - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error('Version required') - end - - it "should check that auth_version equals 1.0" do - @params[:auth_version] = '1.1' - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate_by_token!(@token) - }.should raise_error('Version not supported') - end - - describe "when used with optional block" do - it "should optionally take a block which yields the signature" do - request = Authentication::Request.new('POST', '/some/path', @params) - request.authenticate do |key| - key.should == @token.key - @token - end.should == @token - end - - it "should raise error if no auth_key supplied to request" do - @params.delete(:auth_key) - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate { |key| nil } - }.should raise_error('Authentication key required') - end - - it "should raise error if block returns nil (i.e. key doesn't exist)" do - request = Authentication::Request.new('POST', '/some/path', @params) - lambda { - request.authenticate { |key| nil } - }.should raise_error('Invalid authentication key') - end - end - end -end