bterlson-httpclient 2.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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