httpclient 2.1.4 → 2.1.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -61,21 +61,24 @@ class HTTPClient
61
61
  # It traps HTTP response header and maintains authentication state, and
62
62
  # traps HTTP request header for inserting necessary authentication header.
63
63
  #
64
- # WWWAuth has sub filters (BasicAuth, DigestAuth, and NegotiateAuth) and
65
- # delegates some operations to it.
64
+ # WWWAuth has sub filters (BasicAuth, DigestAuth, NegotiateAuth and
65
+ # SSPINegotiateAuth) and delegates some operations to it.
66
66
  # NegotiateAuth requires 'ruby/ntlm' module.
67
+ # SSPINegotiateAuth requires 'win32/sspi' module.
67
68
  class WWWAuth < AuthFilterBase
68
69
  attr_reader :basic_auth
69
70
  attr_reader :digest_auth
70
71
  attr_reader :negotiate_auth
72
+ attr_reader :sspi_negotiate_auth
71
73
 
72
74
  # Creates new WWWAuth.
73
75
  def initialize
74
76
  @basic_auth = BasicAuth.new
75
77
  @digest_auth = DigestAuth.new
76
78
  @negotiate_auth = NegotiateAuth.new
79
+ @sspi_negotiate_auth = SSPINegotiateAuth.new
77
80
  # sort authenticators by priority
78
- @authenticator = [@negotiate_auth, @digest_auth, @basic_auth]
81
+ @authenticator = [@negotiate_auth, @sspi_negotiate_auth, @digest_auth, @basic_auth]
79
82
  end
80
83
 
81
84
  # Resets challenge state. See sub filters for more details.
@@ -317,20 +320,22 @@ class HTTPClient
317
320
  def calc_cred(method, uri, user, passwd, param)
318
321
  a_1 = "#{user}:#{param['realm']}:#{passwd}"
319
322
  a_2 = "#{method}:#{uri.path}"
323
+ nonce = param['nonce']
324
+ cnonce = generate_cnonce()
320
325
  @nonce_count += 1
321
326
  message_digest = []
322
327
  message_digest << Digest::MD5.hexdigest(a_1)
323
- message_digest << param['nonce']
328
+ message_digest << nonce
324
329
  message_digest << ('%08x' % @nonce_count)
325
- message_digest << param['nonce']
330
+ message_digest << cnonce
326
331
  message_digest << param['qop']
327
332
  message_digest << Digest::MD5.hexdigest(a_2)
328
333
  header = []
329
334
  header << "username=\"#{user}\""
330
335
  header << "realm=\"#{param['realm']}\""
331
- header << "nonce=\"#{param['nonce']}\""
336
+ header << "nonce=\"#{nonce}\""
332
337
  header << "uri=\"#{uri.path}\""
333
- header << "cnonce=\"#{param['nonce']}\""
338
+ header << "cnonce=\"#{cnonce}\""
334
339
  header << "nc=#{'%08x' % @nonce_count}"
335
340
  header << "qop=\"#{param['qop']}\""
336
341
  header << "response=\"#{Digest::MD5.hexdigest(message_digest.join(":"))}\""
@@ -339,6 +344,13 @@ class HTTPClient
339
344
  header.join(", ")
340
345
  end
341
346
 
347
+ # cf. WEBrick::HTTPAuth::DigestAuth#generate_next_nonce(aTime)
348
+ def generate_cnonce
349
+ now = "%012d" % Time.now.to_i
350
+ pk = Digest::MD5.hexdigest([now, self.__id__, Process.pid, rand(65535)].join)[0, 32]
351
+ [now + ':' + pk].pack('m*').chop
352
+ end
353
+
342
354
  def parse_challenge_param(param_str)
343
355
  param = {}
344
356
  param_str.scan(/\s*([^\,]+(?:\\.[^\,]*)*)/).each do |str|
@@ -481,7 +493,7 @@ class HTTPClient
481
493
  case state
482
494
  when :init
483
495
  authenticator = param[:authenticator] = Win32::SSPI::NegotiateAuth.new
484
- return authenticator.get_initial_token
496
+ return authenticator.get_initial_token(@scheme)
485
497
  when :response
486
498
  @challenge.delete(domain_uri)
487
499
  return authenticator.complete_authentication(authphrase)
@@ -0,0 +1,513 @@
1
+ # HTTPClient - HTTP client library.
2
+ # Copyright (C) 2000-2009 NAKAMURA, Hiroshi <nahi@ruby-lang.org>.
3
+ #
4
+ # This program is copyrighted free software by NAKAMURA, Hiroshi. You can
5
+ # redistribute it and/or modify it under the same terms of Ruby's license;
6
+ # either the dual license version in 2003, or any later version.
7
+
8
+
9
+ require 'digest/md5'
10
+ require 'httpclient/session'
11
+
12
+
13
+ class HTTPClient
14
+
15
+ begin
16
+ require 'net/ntlm'
17
+ NTLMEnabled = true
18
+ rescue LoadError
19
+ NTLMEnabled = false
20
+ end
21
+
22
+ begin
23
+ require 'win32/sspi'
24
+ SSPIEnabled = true
25
+ rescue LoadError
26
+ SSPIEnabled = false
27
+ end
28
+
29
+
30
+ # Common abstract class for authentication filter.
31
+ #
32
+ # There are 2 authentication filters.
33
+ # WWWAuth:: Authentication filter for handling authentication negotiation
34
+ # between Web server. Parses 'WWW-Authentication' header in
35
+ # response and generates 'Authorization' header in request.
36
+ # ProxyAuth:: Authentication filter for handling authentication negotiation
37
+ # between Proxy server. Parses 'Proxy-Authentication' header in
38
+ # response and generates 'Proxy-Authorization' header in request.
39
+ class AuthFilterBase
40
+ private
41
+
42
+ def parse_authentication_header(res, tag)
43
+ challenge = res.header[tag]
44
+ return nil unless challenge
45
+ challenge.collect { |c| parse_challenge_header(c) }.compact
46
+ end
47
+
48
+ def parse_challenge_header(challenge)
49
+ scheme, param_str = challenge.scan(/\A(\S+)(?:\s+(.*))?\z/)[0]
50
+ return nil if scheme.nil?
51
+ return scheme, param_str
52
+ end
53
+ end
54
+
55
+
56
+ # Authentication filter for handling authentication negotiation between
57
+ # Web server. Parses 'WWW-Authentication' header in response and
58
+ # generates 'Authorization' header in request.
59
+ #
60
+ # Authentication filter is implemented using request filter of HTTPClient.
61
+ # It traps HTTP response header and maintains authentication state, and
62
+ # traps HTTP request header for inserting necessary authentication header.
63
+ #
64
+ # WWWAuth has sub filters (BasicAuth, DigestAuth, NegotiateAuth and
65
+ # SSPINegotiateAuth) and delegates some operations to it.
66
+ # NegotiateAuth requires 'ruby/ntlm' module.
67
+ # SSPINegotiateAuth requires 'win32/sspi' module.
68
+ class WWWAuth < AuthFilterBase
69
+ attr_reader :basic_auth
70
+ attr_reader :digest_auth
71
+ attr_reader :negotiate_auth
72
+ attr_reader :sspi_negotiate_auth
73
+
74
+ # Creates new WWWAuth.
75
+ def initialize
76
+ @basic_auth = BasicAuth.new
77
+ @digest_auth = DigestAuth.new
78
+ @negotiate_auth = NegotiateAuth.new
79
+ @sspi_negotiate_auth = SSPINegotiateAuth.new
80
+ # sort authenticators by priority
81
+ @authenticator = [@negotiate_auth, @sspi_negotiate_auth, @digest_auth, @basic_auth]
82
+ end
83
+
84
+ # Resets challenge state. See sub filters for more details.
85
+ def reset_challenge
86
+ @authenticator.each do |auth|
87
+ auth.reset_challenge
88
+ end
89
+ end
90
+
91
+ # Set authentication credential. See sub filters for more details.
92
+ def set_auth(uri, user, passwd)
93
+ @authenticator.each do |auth|
94
+ auth.set(uri, user, passwd)
95
+ end
96
+ reset_challenge
97
+ end
98
+
99
+ # Filter API implementation. Traps HTTP request and insert
100
+ # 'Authorization' header if needed.
101
+ def filter_request(req)
102
+ @authenticator.each do |auth|
103
+ if cred = auth.get(req)
104
+ req.header.set('Authorization', auth.scheme + " " + cred)
105
+ return
106
+ end
107
+ end
108
+ end
109
+
110
+ # Filter API implementation. Traps HTTP response and parses
111
+ # 'WWW-Authenticate' header.
112
+ def filter_response(req, res)
113
+ command = nil
114
+ if res.status == HTTP::Status::UNAUTHORIZED
115
+ if challenge = parse_authentication_header(res, 'www-authenticate')
116
+ uri = req.header.request_uri
117
+ challenge.each do |scheme, param_str|
118
+ @authenticator.each do |auth|
119
+ if scheme.downcase == auth.scheme.downcase
120
+ challengeable = auth.challenge(uri, param_str)
121
+ command = :retry if challengeable
122
+ end
123
+ end
124
+ end
125
+ # ignore unknown authentication scheme
126
+ end
127
+ end
128
+ command
129
+ end
130
+ end
131
+
132
+
133
+ # Authentication filter for handling authentication negotiation between
134
+ # Proxy server. Parses 'Proxy-Authentication' header in response and
135
+ # generates 'Proxy-Authorization' header in request.
136
+ #
137
+ # Authentication filter is implemented using request filter of HTTPClient.
138
+ # It traps HTTP response header and maintains authentication state, and
139
+ # traps HTTP request header for inserting necessary authentication header.
140
+ #
141
+ # ProxyAuth has sub filters (BasicAuth, NegotiateAuth, and SSPINegotiateAuth)
142
+ # and delegates some operations to it.
143
+ # NegotiateAuth requires 'ruby/ntlm' module.
144
+ # SSPINegotiateAuth requires 'win32/sspi' module.
145
+ class ProxyAuth < AuthFilterBase
146
+ attr_reader :basic_auth
147
+ attr_reader :negotiate_auth
148
+ attr_reader :sspi_negotiate_auth
149
+
150
+ # Creates new ProxyAuth.
151
+ def initialize
152
+ @basic_auth = BasicAuth.new
153
+ @negotiate_auth = NegotiateAuth.new
154
+ @sspi_negotiate_auth = SSPINegotiateAuth.new
155
+ # sort authenticators by priority
156
+ @authenticator = [@negotiate_auth, @sspi_negotiate_auth, @basic_auth]
157
+ end
158
+
159
+ # Resets challenge state. See sub filters for more details.
160
+ def reset_challenge
161
+ @authenticator.each do |auth|
162
+ auth.reset_challenge
163
+ end
164
+ end
165
+
166
+ # Set authentication credential. See sub filters for more details.
167
+ def set_auth(user, passwd)
168
+ @authenticator.each do |auth|
169
+ auth.set(nil, user, passwd)
170
+ end
171
+ reset_challenge
172
+ end
173
+
174
+ # Filter API implementation. Traps HTTP request and insert
175
+ # 'Proxy-Authorization' header if needed.
176
+ def filter_request(req)
177
+ @authenticator.each do |auth|
178
+ if cred = auth.get(req)
179
+ req.header.set('Proxy-Authorization', auth.scheme + " " + cred)
180
+ return
181
+ end
182
+ end
183
+ end
184
+
185
+ # Filter API implementation. Traps HTTP response and parses
186
+ # 'Proxy-Authenticate' header.
187
+ def filter_response(req, res)
188
+ command = nil
189
+ if res.status == HTTP::Status::PROXY_AUTHENTICATE_REQUIRED
190
+ if challenge = parse_authentication_header(res, 'proxy-authenticate')
191
+ uri = req.header.request_uri
192
+ challenge.each do |scheme, param_str|
193
+ @authenticator.each do |auth|
194
+ if scheme.downcase == auth.scheme.downcase
195
+ challengeable = auth.challenge(uri, param_str)
196
+ command = :retry if challengeable
197
+ end
198
+ end
199
+ end
200
+ # ignore unknown authentication scheme
201
+ end
202
+ end
203
+ command
204
+ end
205
+ end
206
+
207
+ # Authentication filter for handling BasicAuth negotiation.
208
+ # Used in WWWAuth and ProxyAuth.
209
+ class BasicAuth
210
+ # Authentication scheme.
211
+ attr_reader :scheme
212
+
213
+ # Creates new BasicAuth filter.
214
+ def initialize
215
+ @cred = nil
216
+ @auth = {}
217
+ @challengeable = {}
218
+ @scheme = "Basic"
219
+ end
220
+
221
+ # Resets challenge state. Do not send '*Authorization' header until the
222
+ # server sends '*Authentication' again.
223
+ def reset_challenge
224
+ @challengeable.clear
225
+ end
226
+
227
+ # Set authentication credential.
228
+ # uri == nil for generic purpose (allow to use user/password for any URL).
229
+ def set(uri, user, passwd)
230
+ if uri.nil?
231
+ @cred = ["#{user}:#{passwd}"].pack('m').tr("\n", '')
232
+ else
233
+ uri = Util.uri_dirname(uri)
234
+ @auth[uri] = ["#{user}:#{passwd}"].pack('m').tr("\n", '')
235
+ end
236
+ end
237
+
238
+ # Response handler: returns credential.
239
+ # It sends cred only when a given uri is;
240
+ # * child page of challengeable(got *Authenticate before) uri and,
241
+ # * child page of defined credential
242
+ def get(req)
243
+ target_uri = req.header.request_uri
244
+ return nil unless @challengeable.find { |uri, ok|
245
+ Util.uri_part_of(target_uri, uri) and ok
246
+ }
247
+ return @cred if @cred
248
+ Util.hash_find_value(@auth) { |uri, cred|
249
+ Util.uri_part_of(target_uri, uri)
250
+ }
251
+ end
252
+
253
+ # Challenge handler: remember URL for response.
254
+ def challenge(uri, param_str)
255
+ @challengeable[uri] = true
256
+ true
257
+ end
258
+ end
259
+
260
+
261
+ # Authentication filter for handling DigestAuth negotiation.
262
+ # Used in WWWAuth.
263
+ class DigestAuth
264
+ # Authentication scheme.
265
+ attr_reader :scheme
266
+
267
+ # Creates new DigestAuth filter.
268
+ def initialize
269
+ @auth = {}
270
+ @challenge = {}
271
+ @nonce_count = 0
272
+ @scheme = "Digest"
273
+ end
274
+
275
+ # Resets challenge state. Do not send '*Authorization' header until the
276
+ # server sends '*Authentication' again.
277
+ def reset_challenge
278
+ @challenge.clear
279
+ end
280
+
281
+ # Set authentication credential.
282
+ # uri == nil is ignored.
283
+ def set(uri, user, passwd)
284
+ if uri
285
+ uri = Util.uri_dirname(uri)
286
+ @auth[uri] = [user, passwd]
287
+ end
288
+ end
289
+
290
+ # Response handler: returns credential.
291
+ # It sends cred only when a given uri is;
292
+ # * child page of challengeable(got *Authenticate before) uri and,
293
+ # * child page of defined credential
294
+ def get(req)
295
+ target_uri = req.header.request_uri
296
+ param = Util.hash_find_value(@challenge) { |uri, v|
297
+ Util.uri_part_of(target_uri, uri)
298
+ }
299
+ return nil unless param
300
+ user, passwd = Util.hash_find_value(@auth) { |uri, auth_data|
301
+ Util.uri_part_of(target_uri, uri)
302
+ }
303
+ return nil unless user
304
+ uri = req.header.request_uri
305
+ calc_cred(req.header.request_method, uri, user, passwd, param)
306
+ end
307
+
308
+ # Challenge handler: remember URL and challenge token for response.
309
+ def challenge(uri, param_str)
310
+ @challenge[uri] = parse_challenge_param(param_str)
311
+ true
312
+ end
313
+
314
+ private
315
+
316
+ # this method is implemented by sromano and posted to
317
+ # http://tools.assembla.com/breakout/wiki/DigestForSoap
318
+ # Thanks!
319
+ # supported algorithm: MD5 only for now
320
+ def calc_cred(method, uri, user, passwd, param)
321
+ a_1 = "#{user}:#{param['realm']}:#{passwd}"
322
+ a_2 = "#{method}:#{uri.path}"
323
+ @nonce_count += 1
324
+ message_digest = []
325
+ message_digest << Digest::MD5.hexdigest(a_1)
326
+ message_digest << param['nonce']
327
+ message_digest << ('%08x' % @nonce_count)
328
+ message_digest << param['nonce']
329
+ message_digest << param['qop']
330
+ message_digest << Digest::MD5.hexdigest(a_2)
331
+ header = []
332
+ header << "username=\"#{user}\""
333
+ header << "realm=\"#{param['realm']}\""
334
+ header << "nonce=\"#{param['nonce']}\""
335
+ header << "uri=\"#{uri.path}\""
336
+ header << "cnonce=\"#{param['nonce']}\""
337
+ header << "nc=#{'%08x' % @nonce_count}"
338
+ header << "qop=\"#{param['qop']}\""
339
+ header << "response=\"#{Digest::MD5.hexdigest(message_digest.join(":"))}\""
340
+ header << "algorithm=\"MD5\""
341
+ header << "opaque=\"#{param['opaque']}\"" if param.key?('opaque')
342
+ header.join(", ")
343
+ end
344
+
345
+ def parse_challenge_param(param_str)
346
+ param = {}
347
+ param_str.scan(/\s*([^\,]+(?:\\.[^\,]*)*)/).each do |str|
348
+ key, value = str[0].scan(/\A([^=]+)=(.*)\z/)[0]
349
+ if /\A"(.*)"\z/ =~ value
350
+ value = $1.gsub(/\\(.)/, '\1')
351
+ end
352
+ param[key] = value
353
+ end
354
+ param
355
+ end
356
+ end
357
+
358
+
359
+ # Authentication filter for handling Negotiate/NTLM negotiation.
360
+ # Used in WWWAuth and ProxyAuth.
361
+ #
362
+ # NegotiateAuth depends on 'ruby/ntlm' module.
363
+ class NegotiateAuth
364
+ # Authentication scheme.
365
+ attr_reader :scheme
366
+ # NTLM opt for ruby/ntlm. {:ntlmv2 => true} by default.
367
+ attr_reader :ntlm_opt
368
+
369
+ # Creates new NegotiateAuth filter.
370
+ def initialize
371
+ @auth = {}
372
+ @auth_default = nil
373
+ @challenge = {}
374
+ @scheme = "Negotiate"
375
+ @ntlm_opt = {
376
+ :ntlmv2 => true
377
+ }
378
+ end
379
+
380
+ # Resets challenge state. Do not send '*Authorization' header until the
381
+ # server sends '*Authentication' again.
382
+ def reset_challenge
383
+ @challenge.clear
384
+ end
385
+
386
+ # Set authentication credential.
387
+ # uri == nil for generic purpose (allow to use user/password for any URL).
388
+ def set(uri, user, passwd)
389
+ if uri
390
+ uri = Util.uri_dirname(uri)
391
+ @auth[uri] = [user, passwd]
392
+ else
393
+ @auth_default = [user, passwd]
394
+ end
395
+ end
396
+
397
+ # Response handler: returns credential.
398
+ # See ruby/ntlm for negotiation state transition.
399
+ def get(req)
400
+ return nil unless NTLMEnabled
401
+ target_uri = req.header.request_uri
402
+ domain_uri, param = @challenge.find { |uri, v|
403
+ Util.uri_part_of(target_uri, uri)
404
+ }
405
+ return nil unless param
406
+ user, passwd = Util.hash_find_value(@auth) { |uri, auth_data|
407
+ Util.uri_part_of(target_uri, uri)
408
+ }
409
+ unless user
410
+ user, passwd = @auth_default
411
+ end
412
+ return nil unless user
413
+ state = param[:state]
414
+ authphrase = param[:authphrase]
415
+ case state
416
+ when :init
417
+ t1 = Net::NTLM::Message::Type1.new
418
+ return t1.encode64
419
+ when :response
420
+ t2 = Net::NTLM::Message.decode64(authphrase)
421
+ t3 = t2.response({:user => user, :password => passwd}, @ntlm_opt.dup)
422
+ @challenge.delete(domain_uri)
423
+ return t3.encode64
424
+ end
425
+ nil
426
+ end
427
+
428
+ # Challenge handler: remember URL and challenge token for response.
429
+ def challenge(uri, param_str)
430
+ return false unless NTLMEnabled
431
+ if param_str.nil? or @challenge[uri].nil?
432
+ c = @challenge[uri] = {}
433
+ c[:state] = :init
434
+ c[:authphrase] = ""
435
+ else
436
+ c = @challenge[uri]
437
+ c[:state] = :response
438
+ c[:authphrase] = param_str
439
+ end
440
+ true
441
+ end
442
+ end
443
+
444
+
445
+ # Authentication filter for handling Negotiate/NTLM negotiation.
446
+ # Used in ProxyAuth.
447
+ #
448
+ # SSPINegotiateAuth depends on 'win32/sspi' module.
449
+ class SSPINegotiateAuth
450
+ # Authentication scheme.
451
+ attr_reader :scheme
452
+
453
+ # Creates new SSPINegotiateAuth filter.
454
+ def initialize
455
+ @challenge = {}
456
+ @scheme = "Negotiate"
457
+ end
458
+
459
+ # Resets challenge state. Do not send '*Authorization' header until the
460
+ # server sends '*Authentication' again.
461
+ def reset_challenge
462
+ @challenge.clear
463
+ end
464
+
465
+ # Set authentication credential.
466
+ # NOT SUPPORTED: username and necessary data is retrieved by win32/sspi.
467
+ # See win32/sspi for more details.
468
+ def set(uri, user, passwd)
469
+ # not supported
470
+ end
471
+
472
+ # Response handler: returns credential.
473
+ # See win32/sspi for negotiation state transition.
474
+ def get(req)
475
+ return nil unless SSPIEnabled
476
+ target_uri = req.header.request_uri
477
+ domain_uri, param = @challenge.find { |uri, v|
478
+ Util.uri_part_of(target_uri, uri)
479
+ }
480
+ return nil unless param
481
+ state = param[:state]
482
+ authenticator = param[:authenticator]
483
+ authphrase = param[:authphrase]
484
+ case state
485
+ when :init
486
+ authenticator = param[:authenticator] = Win32::SSPI::NegotiateAuth.new
487
+ return authenticator.get_initial_token(@scheme)
488
+ when :response
489
+ @challenge.delete(domain_uri)
490
+ return authenticator.complete_authentication(authphrase)
491
+ end
492
+ nil
493
+ end
494
+
495
+ # Challenge handler: remember URL and challenge token for response.
496
+ def challenge(uri, param_str)
497
+ return false unless SSPIEnabled
498
+ if param_str.nil? or @challenge[uri].nil?
499
+ c = @challenge[uri] = {}
500
+ c[:state] = :init
501
+ c[:authenticator] = nil
502
+ c[:authphrase] = ""
503
+ else
504
+ c = @challenge[uri]
505
+ c[:state] = :response
506
+ c[:authphrase] = param_str
507
+ end
508
+ true
509
+ end
510
+ end
511
+
512
+
513
+ end