bterlson-httpclient 2.1.4

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