1#
2# httpauth/digestauth.rb -- HTTP digest access authentication
3#
4# Author: IPR -- Internet Programming with Ruby -- writers
5# Copyright (c) 2003 Internet Programming with Ruby writers.
6# Copyright (c) 2003 H.M.
7#
8# The original implementation is provided by H.M.
9#   URL: http://rwiki.jin.gr.jp/cgi-bin/rw-cgi.rb?cmd=view;name=
10#        %C7%A7%BE%DA%B5%A1%C7%BD%A4%F2%B2%FE%C2%A4%A4%B7%A4%C6%A4%DF%A4%EB
11#
12# $IPR: digestauth.rb,v 1.5 2003/02/20 07:15:47 gotoyuzo Exp $
13
14require 'webrick/config'
15require 'webrick/httpstatus'
16require 'webrick/httpauth/authenticator'
17require 'digest/md5'
18require 'digest/sha1'
19
20module WEBrick
21  module HTTPAuth
22
23    ##
24    # RFC 2617 Digest Access Authentication for WEBrick
25    #
26    # Use this class to add digest authentication to a WEBrick servlet.
27    #
28    # Here is an example of how to set up DigestAuth:
29    #
30    #   config = { :Realm => 'DigestAuth example realm' }
31    #
32    #   htdigest = WEBrick::HTTPAuth::Htdigest.new 'my_password_file'
33    #   htdigest.set_passwd config[:Realm], 'username', 'password'
34    #   htdigest.flush
35    #
36    #   config[:UserDB] = htdigest
37    #
38    #   digest_auth = WEBrick::HTTPAuth::DigestAuth.new config
39    #
40    # When using this as with a servlet be sure not to create a new DigestAuth
41    # object in the servlet's #initialize.  By default WEBrick creates a new
42    # servlet instance for every request and the DigestAuth object must be
43    # used across requests.
44
45    class DigestAuth
46      include Authenticator
47
48      AuthScheme = "Digest" # :nodoc:
49
50      ##
51      # Struct containing the opaque portion of the digest authentication
52
53      OpaqueInfo = Struct.new(:time, :nonce, :nc) # :nodoc:
54
55      ##
56      # Digest authentication algorithm
57
58      attr_reader :algorithm
59
60      ##
61      # Quality of protection.  RFC 2617 defines "auth" and "auth-int"
62
63      attr_reader :qop
64
65      ##
66      # Used by UserDB to create a digest password entry
67
68      def self.make_passwd(realm, user, pass)
69        pass ||= ""
70        Digest::MD5::hexdigest([user, realm, pass].join(":"))
71      end
72
73      ##
74      # Creates a new DigestAuth instance.  Be sure to use the same DigestAuth
75      # instance for multiple requests as it saves state between requests in
76      # order to perform authentication.
77      #
78      # See WEBrick::Config::DigestAuth for default configuration entries
79      #
80      # You must supply the following configuration entries:
81      #
82      # :Realm:: The name of the realm being protected.
83      # :UserDB:: A database of usernames and passwords.
84      #           A WEBrick::HTTPAuth::Htdigest instance should be used.
85
86      def initialize(config, default=Config::DigestAuth)
87        check_init(config)
88        @config                 = default.dup.update(config)
89        @algorithm              = @config[:Algorithm]
90        @domain                 = @config[:Domain]
91        @qop                    = @config[:Qop]
92        @use_opaque             = @config[:UseOpaque]
93        @use_next_nonce         = @config[:UseNextNonce]
94        @check_nc               = @config[:CheckNc]
95        @use_auth_info_header   = @config[:UseAuthenticationInfoHeader]
96        @nonce_expire_period    = @config[:NonceExpirePeriod]
97        @nonce_expire_delta     = @config[:NonceExpireDelta]
98        @internet_explorer_hack = @config[:InternetExplorerHack]
99
100        case @algorithm
101        when 'MD5','MD5-sess'
102          @h = Digest::MD5
103        when 'SHA1','SHA1-sess'  # it is a bonus feature :-)
104          @h = Digest::SHA1
105        else
106          msg = format('Algorithm "%s" is not supported.', @algorithm)
107          raise ArgumentError.new(msg)
108        end
109
110        @instance_key = hexdigest(self.__id__, Time.now.to_i, Process.pid)
111        @opaques = {}
112        @last_nonce_expire = Time.now
113        @mutex = Mutex.new
114      end
115
116      ##
117      # Authenticates a +req+ and returns a 401 Unauthorized using +res+ if
118      # the authentication was not correct.
119
120      def authenticate(req, res)
121        unless result = @mutex.synchronize{ _authenticate(req, res) }
122          challenge(req, res)
123        end
124        if result == :nonce_is_stale
125          challenge(req, res, true)
126        end
127        return true
128      end
129
130      ##
131      # Returns a challenge response which asks for for authentication
132      # information
133
134      def challenge(req, res, stale=false)
135        nonce = generate_next_nonce(req)
136        if @use_opaque
137          opaque = generate_opaque(req)
138          @opaques[opaque].nonce = nonce
139        end
140
141        param = Hash.new
142        param["realm"]  = HTTPUtils::quote(@realm)
143        param["domain"] = HTTPUtils::quote(@domain.to_a.join(" ")) if @domain
144        param["nonce"]  = HTTPUtils::quote(nonce)
145        param["opaque"] = HTTPUtils::quote(opaque) if opaque
146        param["stale"]  = stale.to_s
147        param["algorithm"] = @algorithm
148        param["qop"]    = HTTPUtils::quote(@qop.to_a.join(",")) if @qop
149
150        res[@response_field] =
151          "#{@auth_scheme} " + param.map{|k,v| "#{k}=#{v}" }.join(", ")
152        info("%s: %s", @response_field, res[@response_field]) if $DEBUG
153        raise @auth_exception
154      end
155
156      private
157
158      # :stopdoc:
159
160      MustParams = ['username','realm','nonce','uri','response']
161      MustParamsAuth = ['cnonce','nc']
162
163      def _authenticate(req, res)
164        unless digest_credentials = check_scheme(req)
165          return false
166        end
167
168        auth_req = split_param_value(digest_credentials)
169        if auth_req['qop'] == "auth" || auth_req['qop'] == "auth-int"
170          req_params = MustParams + MustParamsAuth
171        else
172          req_params = MustParams
173        end
174        req_params.each{|key|
175          unless auth_req.has_key?(key)
176            error('%s: parameter missing. "%s"', auth_req['username'], key)
177            raise HTTPStatus::BadRequest
178          end
179        }
180
181        if !check_uri(req, auth_req)
182          raise HTTPStatus::BadRequest
183        end
184
185        if auth_req['realm'] != @realm
186          error('%s: realm unmatch. "%s" for "%s"',
187                auth_req['username'], auth_req['realm'], @realm)
188          return false
189        end
190
191        auth_req['algorithm'] ||= 'MD5'
192        if auth_req['algorithm'].upcase != @algorithm.upcase
193          error('%s: algorithm unmatch. "%s" for "%s"',
194                auth_req['username'], auth_req['algorithm'], @algorithm)
195          return false
196        end
197
198        if (@qop.nil? && auth_req.has_key?('qop')) ||
199           (@qop && (! @qop.member?(auth_req['qop'])))
200          error('%s: the qop is not allowed. "%s"',
201                auth_req['username'], auth_req['qop'])
202          return false
203        end
204
205        password = @userdb.get_passwd(@realm, auth_req['username'], @reload_db)
206        unless password
207          error('%s: the user is not allowd.', auth_req['username'])
208          return false
209        end
210
211        nonce_is_invalid = false
212        if @use_opaque
213          info("@opaque = %s", @opaque.inspect) if $DEBUG
214          if !(opaque = auth_req['opaque'])
215            error('%s: opaque is not given.', auth_req['username'])
216            nonce_is_invalid = true
217          elsif !(opaque_struct = @opaques[opaque])
218            error('%s: invalid opaque is given.', auth_req['username'])
219            nonce_is_invalid = true
220          elsif !check_opaque(opaque_struct, req, auth_req)
221            @opaques.delete(auth_req['opaque'])
222            nonce_is_invalid = true
223          end
224        elsif !check_nonce(req, auth_req)
225          nonce_is_invalid = true
226        end
227
228        if /-sess$/i =~ auth_req['algorithm']
229          ha1 = hexdigest(password, auth_req['nonce'], auth_req['cnonce'])
230        else
231          ha1 = password
232        end
233
234        if auth_req['qop'] == "auth" || auth_req['qop'] == nil
235          ha2 = hexdigest(req.request_method, auth_req['uri'])
236          ha2_res = hexdigest("", auth_req['uri'])
237        elsif auth_req['qop'] == "auth-int"
238          ha2 = hexdigest(req.request_method, auth_req['uri'],
239                          hexdigest(req.body))
240          ha2_res = hexdigest("", auth_req['uri'], hexdigest(res.body))
241        end
242
243        if auth_req['qop'] == "auth" || auth_req['qop'] == "auth-int"
244          param2 = ['nonce', 'nc', 'cnonce', 'qop'].map{|key|
245            auth_req[key]
246          }.join(':')
247          digest     = hexdigest(ha1, param2, ha2)
248          digest_res = hexdigest(ha1, param2, ha2_res)
249        else
250          digest     = hexdigest(ha1, auth_req['nonce'], ha2)
251          digest_res = hexdigest(ha1, auth_req['nonce'], ha2_res)
252        end
253
254        if digest != auth_req['response']
255          error("%s: digest unmatch.", auth_req['username'])
256          return false
257        elsif nonce_is_invalid
258          error('%s: digest is valid, but nonce is not valid.',
259                auth_req['username'])
260          return :nonce_is_stale
261        elsif @use_auth_info_header
262          auth_info = {
263            'nextnonce' => generate_next_nonce(req),
264            'rspauth'   => digest_res
265          }
266          if @use_opaque
267            opaque_struct.time  = req.request_time
268            opaque_struct.nonce = auth_info['nextnonce']
269            opaque_struct.nc    = "%08x" % (auth_req['nc'].hex + 1)
270          end
271          if auth_req['qop'] == "auth" || auth_req['qop'] == "auth-int"
272            ['qop','cnonce','nc'].each{|key|
273              auth_info[key] = auth_req[key]
274            }
275          end
276          res[@resp_info_field] = auth_info.keys.map{|key|
277            if key == 'nc'
278              key + '=' + auth_info[key]
279            else
280              key + "=" + HTTPUtils::quote(auth_info[key])
281            end
282          }.join(', ')
283        end
284        info('%s: authentication succeeded.', auth_req['username'])
285        req.user = auth_req['username']
286        return true
287      end
288
289      def split_param_value(string)
290        ret = {}
291        while string.bytesize != 0
292          case string
293          when /^\s*([\w\-\.\*\%\!]+)=\s*\"((\\.|[^\"])*)\"\s*,?/
294            key = $1
295            matched = $2
296            string = $'
297            ret[key] = matched.gsub(/\\(.)/, "\\1")
298          when /^\s*([\w\-\.\*\%\!]+)=\s*([^,\"]*),?/
299            key = $1
300            matched = $2
301            string = $'
302            ret[key] = matched.clone
303          when /^s*^,/
304            string = $'
305          else
306            break
307          end
308        end
309        ret
310      end
311
312      def generate_next_nonce(req)
313        now = "%012d" % req.request_time.to_i
314        pk  = hexdigest(now, @instance_key)[0,32]
315        nonce = [now + ":" + pk].pack("m*").chop # it has 60 length of chars.
316        nonce
317      end
318
319      def check_nonce(req, auth_req)
320        username = auth_req['username']
321        nonce = auth_req['nonce']
322
323        pub_time, pk = nonce.unpack("m*")[0].split(":", 2)
324        if (!pub_time || !pk)
325          error("%s: empty nonce is given", username)
326          return false
327        elsif (hexdigest(pub_time, @instance_key)[0,32] != pk)
328          error("%s: invalid private-key: %s for %s",
329                username, hexdigest(pub_time, @instance_key)[0,32], pk)
330          return false
331        end
332
333        diff_time = req.request_time.to_i - pub_time.to_i
334        if (diff_time < 0)
335          error("%s: difference of time-stamp is negative.", username)
336          return false
337        elsif diff_time > @nonce_expire_period
338          error("%s: nonce is expired.", username)
339          return false
340        end
341
342        return true
343      end
344
345      def generate_opaque(req)
346        @mutex.synchronize{
347          now = req.request_time
348          if now - @last_nonce_expire > @nonce_expire_delta
349            @opaques.delete_if{|key,val|
350              (now - val.time) > @nonce_expire_period
351            }
352            @last_nonce_expire = now
353          end
354          begin
355            opaque = Utils::random_string(16)
356          end while @opaques[opaque]
357          @opaques[opaque] = OpaqueInfo.new(now, nil, '00000001')
358          opaque
359        }
360      end
361
362      def check_opaque(opaque_struct, req, auth_req)
363        if (@use_next_nonce && auth_req['nonce'] != opaque_struct.nonce)
364          error('%s: nonce unmatched. "%s" for "%s"',
365                auth_req['username'], auth_req['nonce'], opaque_struct.nonce)
366          return false
367        elsif !check_nonce(req, auth_req)
368          return false
369        end
370        if (@check_nc && auth_req['nc'] != opaque_struct.nc)
371          error('%s: nc unmatched."%s" for "%s"',
372                auth_req['username'], auth_req['nc'], opaque_struct.nc)
373          return false
374        end
375        true
376      end
377
378      def check_uri(req, auth_req)
379        uri = auth_req['uri']
380        if uri != req.request_uri.to_s && uri != req.unparsed_uri &&
381           (@internet_explorer_hack && uri != req.path)
382          error('%s: uri unmatch. "%s" for "%s"', auth_req['username'],
383                auth_req['uri'], req.request_uri.to_s)
384          return false
385        end
386        true
387      end
388
389      def hexdigest(*args)
390        @h.hexdigest(args.join(":"))
391      end
392
393      # :startdoc:
394    end
395
396    ##
397    # Digest authentication for proxy servers.  See DigestAuth for details.
398
399    class ProxyDigestAuth < DigestAuth
400      include ProxyAuthenticator
401
402      private
403      def check_uri(req, auth_req) # :nodoc:
404        return true
405      end
406    end
407  end
408end
409