summaryrefslogtreecommitdiff
path: root/lib/mixlib/authentication/signatureverification.rb
blob: 841e77af7ce09e92f95021f829fefb0c71370faf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
#
# Author:: Christopher Brown (<cb@opscode.com>)
# Copyright:: Copyright (c) 2009 Opscode, Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# 
#     http://www.apache.org/licenses/LICENSE-2.0
# 
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require 'ostruct'
require 'net/http'
require 'mixlib/authentication'
require 'mixlib/authentication/signedheaderauth'

module Mixlib
  module Authentication
    class SignatureVerification

      include Mixlib::Authentication::SignedHeaderAuth
      
      attr_reader :hashed_body, :timestamp, :http_method, :user_id

      # Takes the request, boils down the pieces we are interested in,
      # looks up the user, generates a signature, and compares to
      # the signature in the request
      # ====Headers
      #
      # X-Ops-Sign: algorithm=sha256;version=1.0;
      # X-Ops-UserId: <user_id>
      # X-Ops-Timestamp:
      # X-Ops-Content-Hash: 
      # X-Ops-Authorization-#{line_number}
      def authenticate_user_request(request, user_lookup, time_skew=(15*60))
        Mixlib::Authentication::Log.debug "Initializing header auth : #{request.inspect}"
        
        headers ||= request.env.inject({ }) { |memo, kv| memo[$2.gsub(/\-/,"_").downcase.to_sym] = kv[1] if kv[0] =~ /^(HTTP_)(.*)/; memo }
        digester = Mixlib::Authentication::Digester.new        

        begin
          @allowed_time_skew   = time_skew # in seconds
          @http_method         = request.method.to_s
          @signing_description = headers[:x_ops_sign].chomp
          @user_id             = headers[:x_ops_userid].chomp
          @timestamp           = headers[:x_ops_timestamp].chomp
          @host                = headers[:host].chomp
          @content_hash        = headers[:x_ops_content_hash].chomp
          @user_secret         = user_lookup

          # The authorization header is a Base64-encoded version of an RSA signature.
          # The client sent it on multiple header lines, starting at index 1 - 
          # X-Ops-Authorization-1, X-Ops-Authorization-2, etc. Pull them out and
          # concatenate.

          # if there are 11 headers, the sort breaks - it becomes lexicographic sort rather than numeric [cb]
          @request_signature = headers.find_all { |h| h[0].to_s =~ /^x_ops_authorization_/ }.sort { |x,y| x.to_s <=> y.to_s}.map { |i| i[1] }.join("\n")
          Mixlib::Authentication::Log.debug "Reconstituted request signature: #{@request_signature}"
          
          # The request signature is based on any file attached, if any. Otherwise
          # it's based on the body of the request.
          # TODO: tim: 2009-12-28: It'd be nice to remove this special case, and
          # always hash the entire request body. In the file case it would just be
          # expanded multipart text - the entire body of the POST.
          #
          # Pull out any file that was attached to this request, using multipart
          # form uploads.
          # Depending on the server we're running in, multipart form uploads are
          # handed to us differently. 
          # - In Passenger (Cookbooks Community Site), the File is handed to us 
          #   directly in the params hash. The name is whatever the client used, 
          #   its value is therefore a File or Tempfile. 
          #   e.g. request['file_param'] = File
          #   
          # - In Merb (Chef server), the File is wrapped. The original parameter 
          #   name used for the file is used, but its value is a Hash. Within
          #   the hash is a name/value pair named 'file' which actually 
          #   contains the Tempfile instance.
          #   e.g. request['file_param'] = { :file => Tempfile }
          file_param = request.params.values.find { |value| value.respond_to?(:read) }

          # No file_param; we're running in Merb, or it's just not there..
          if file_param.nil?
            hash_param = request.params.values.find { |value| value.respond_to?(:has_key?) }  # Hash responds to :has_key? .
            if !hash_param.nil?
              file_param = hash_param.values.find { |value| value.respond_to?(:read) } # File/Tempfile responds to :read.
            end
          end

          # Any file that's included in the request is hashed if it's there. Otherwise,
          # we hash the body.
          if file_param
            Mixlib::Authentication::Log.debug "Digesting file_param: '#{file_param.inspect}'"
            @hashed_body = digester.hash_file(file_param)
          else
            body = request.raw_post
            Mixlib::Authentication::Log.debug "Digesting body: '#{body}'"
            @hashed_body = digester.hash_body(body)
          end
          
          Mixlib::Authentication::Log.debug "Authenticating user : #{user_id}, User secret is : #{@user_secret}, Request signature is :\n#{@request_signature}, Hashed Body is : #{@hashed_body}"
          
          #BUGBUG Not doing anything with the signing description yet [cb]          
          parse_signing_description
          candidate_block = canonicalize_request
          request_decrypted_block = @user_secret.public_decrypt(Base64.decode64(@request_signature))
          signatures_match = (request_decrypted_block == candidate_block)
          timeskew_is_acceptable = timestamp_within_bounds?(Time.parse(timestamp), Time.now)
          hashes_match = @content_hash == hashed_body
        rescue StandardError=>se
          raise StandardError,"Failed to authenticate user request.  Most likely missing a necessary header: #{se.message}", se.backtrace
        end
        
        Mixlib::Authentication::Log.debug "Candidate Block is: '#{candidate_block}'\nRequest decrypted block is: '#{request_decrypted_block}'\nCandidate content hash is: #{hashed_body}\nRequest Content Hash is: '#{@content_hash}'\nSignatures match: #{signatures_match}, Allowed Time Skew: #{timeskew_is_acceptable}, Hashes match?: #{hashes_match}\n"
        
        if signatures_match and timeskew_is_acceptable and hashes_match
          OpenStruct.new(:name=>user_id)
        else
          nil
        end
      end
      
      private
      
      # Compare the request timestamp with boundary time
      # 
      # 
      # ====Parameters
      # time1<Time>:: minuend
      # time2<Time>:: subtrahend
      #
      def timestamp_within_bounds?(time1, time2)
        time_diff = (time2-time1).abs
        is_allowed = (time_diff < @allowed_time_skew)
        Mixlib::Authentication::Log.debug "Request time difference: #{time_diff}, within #{@allowed_time_skew} seconds? : #{!!is_allowed}"
        is_allowed      
      end
    end


  end
end