lib/atom/http.rb in atom-tools-0.9.0 vs lib/atom/http.rb in atom-tools-0.9.1
- old
+ new
@@ -1,32 +1,119 @@
require "net/http"
require 'uri'
+require "sha1"
+require "md5"
+
module URI # :nodoc: all
class Generic; def to_uri; self; end; end
end
class String # :nodoc:
def to_uri; URI.parse(self); end
end
module Atom
- UA = "atom-tools 0.9.0"
+ UA = "atom-tools 0.9.1"
+
+ module DigestAuth
+ CNONCE = Digest::MD5.new("%x" % (Time.now.to_i + rand(65535))).hexdigest
+
+ @@nonce_count = -1
+
+ # quoted-strings plus a few special cases for Digest
+ def parse_wwwauth_digest param_string
+ params = parse_quoted_wwwauth param_string
+ qop = params[:qop] ? params[:qop].split(",") : nil
+
+ param_string.gsub(/stale=([^,]*)/) do
+ params[:stale] = ($1.downcase == "true")
+ end
+
+ params[:algorithm] = "MD5"
+ param_string.gsub(/algorithm=([^,]*)/) { params[:algorithm] = $1 }
+
+ params
+ end
+
+ def h(data); Digest::MD5.hexdigest(data); end
+ def kd(secret, data); h(secret + ":" + data); end
+
+ # HTTP Digest authentication (RFC 2617)
+ def digest_authenticate(req, url, param_string = "")
+ raise "Digest authentication requires a WWW-Authenticate header" if param_string.empty?
+
+ params = parse_wwwauth_digest(param_string)
+ qop = params[:qop]
+
+ user, pass = username_and_password_for_realm(url, params[:realm])
+
+ if params[:algorithm] == "MD5"
+ a1 = user + ":" + params[:realm] + ":" + pass
+ else
+ # XXX MD5-sess
+ raise "I only support MD5 digest authentication (not #{params[:algorithm].inspect})"
+ end
+
+ if qop.nil? or qop.member? "auth"
+ a2 = req.method + ":" + req.path
+ else
+ # XXX auth-int
+ raise "only 'auth' qop supported (none of: #{qop.inspect})"
+ end
+
+ if qop.nil?
+ response = kd(h(a1), params[:nonce] + ":" + h(a2))
+ else
+ @@nonce_count += 1
+ nc = ('%08x' % @@nonce_count)
+
+ # XXX auth-int
+ data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}"
+
+ response = kd(h(a1), data)
+ end
+
+ header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}">
+
+ if params[:opaque]
+ header += %Q<, opaque="#{params[:opaque]}">
+ end
+
+ if params[:algorithm] != "MD5"
+ header += ", algorithm=#{algo}"
+ end
+
+ if qop
+ # XXX auth-int
+ header += %Q<, nc=#{nc}, cnonce="#{CNONCE}", qop=auth>
+ end
+
+ req["Authorization"] = header
+ end
+ end
+
class Unauthorized < RuntimeError # :nodoc:
end
# An object which handles the details of HTTP - particularly
# authentication and caching (neither of which are fully implemented).
#
- # This object can be used on its own, or passed to an Atom::App,
+ # This object can be used on its own, or passed to an Atom::Service,
# Atom::Collection or Atom::Feed, where it will be used for requests.
#
# All its HTTP methods return a Net::HTTPResponse
class HTTP
+ include DigestAuth
+
# used by the default #when_auth
attr_accessor :user, :pass
+ # XXX doc me
+ # :basic, :wsse, nil
+ attr_accessor :always_auth
+
def initialize # :nodoc:
@get_auth_details = lambda do |abs_url, realm|
if @user and @pass
[@user, @pass]
else
@@ -55,65 +142,97 @@
http_request(url, Net::HTTP::Delete, body, headers)
end
# a block that will be called when a remote server responds with
# 401 Unauthorized, so that your application can prompt for
- # authentication details
+ # authentication details.
#
- # it will be called with the base URL of the requested URL, and the realm used in the WWW-Authenticate header.
+ # the default is to use the values of @user and @pass.
+ #
+ # your block will be called with two parameters
+ # abs_url:: the base URL of the request URL
+ # realm:: the realm used in the WWW-Authenticate header
+ # (will be nil if there is no WWW-Authenticate header)
#
# it should return a value of the form [username, password]
- def when_auth &block
+ def when_auth &block # :yields: abs_url, realm
@get_auth_details = block
end
private
- def parse_wwwauth www_authenticate
- auth_type = www_authenticate.split[0] # "Digest" or "Basic"
- auth_params = {}
-
- www_authenticate =~ /^(\w+) (.*)/
+ # parses plain quoted-strings
+ def parse_quoted_wwwauth param_string
+ params = {}
- $2.gsub(/(\w+)="(.*?)"/) { auth_params[$1] = $2 }
+ param_string.gsub(/(\w+)="(.*?)"/) { params[$1.to_sym] = $2 }
- [ auth_type, auth_params ]
+ params
end
- # performs an authenticated http request
- def authenticated_request(url_string, method, wwwauth, body = nil, init_headers = {})
+ # HTTP Basic authentication (RFC 2617)
+ def basic_authenticate(req, url, param_string = "")
+ params = parse_quoted_wwwauth(param_string)
- auth_type, params = parse_wwwauth(wwwauth)
- req, url = new_request(url_string, method, init_headers)
-
- realm = params["realm"]
- abs_url = (url + "/").to_s
+ user, pass = username_and_password_for_realm(url, params[:realm])
+ req.basic_auth user, pass
+ end
+
+ # WSSE authentication <http://www.xml.com/pub/a/2003/12/17/dive.html>
+ def wsse_authenticate(req, url, params = {})
+ # from <http://www.koders.com/ruby/fidFB0C7F9A0F36CB0F30B2280BDDC4F43FF1FA4589.aspx?s=ruby+cgi>.
+ # (thanks midore!)
+ user, pass = username_and_password_for_realm(url, params["realm"])
+
+ nonce = Array.new(10){ rand(0x100000000) }.pack('I*')
+ nonce_base64 = [nonce].pack("m").chomp
+ now = Time.now.utc.iso8601
+ digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp
+ credentials = sprintf(%Q<UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s">,
+ user, digest, nonce_base64, now)
+ req['X-WSSE'] = credentials
+ req["Authorization"] = 'WSSE profile="UsernameToken"'
+ end
+
+ def username_and_password_for_realm(url, realm)
+ abs_url = (url + "/").to_s
user, pass = @get_auth_details.call(abs_url, realm)
-
- raise Unauthorized unless user and pass
-
- if auth_type == "Basic"
- req.basic_auth user, pass
- else
- # TODO: implement Digest auth
- raise "atom-tools only supports Basic authentication"
+
+ unless user and pass
+ raise Unauthorized, "You must provide a username and password"
end
-
- res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) }
-
- raise Unauthorized if res.kind_of? Net::HTTPUnauthorized
- res
+
+ [ user, pass ]
end
- # performs a regular http request. if it responds 401
- # then it retries using @user and @pass for authentication
- def http_request(url_string, method, body = nil, init_headers = {})
- req, url = new_request(url_string, method, init_headers)
-
+ # performs a generic HTTP request.
+ def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil)
+ req, url = new_request(url_s, method, init_headers)
+
+ # two reasons to authenticate;
+ if @always_auth
+ self.send("#{@always_auth}_authenticate", req, url)
+ elsif www_authenticate
+ # XXX multiple challenges, multiple headers
+ param_string = www_authenticate.sub!(/^(\w+) /, "")
+ auth_type = $~[1]
+ self.send("#{auth_type.downcase}_authenticate", req, url, param_string)
+ end
+
res = Net::HTTP.start(url.host, url.port) { |h| h.request(req, body) }
if res.kind_of? Net::HTTPUnauthorized
- res = authenticated_request(url, method, res["WWW-Authenticate"], body, init_headers)
+ if @always_auth or www_authenticate # XXX and not stale (Digest only)
+ # we've tried the credentials you gave us once and failed
+ raise Unauthorized, "Your username and password were rejected"
+ else
+ # once more, with authentication
+ res = http_request(url_s, method, body, init_headers, res["WWW-Authenticate"])
+
+ if res.kind_of? Net::HTTPUnauthorized
+ raise Unauthorized, "Your username and password were rejected"
+ end
+ end
end
res
end