Skip to content
Browse files

Adding Rack::Auth::Digest::MD5, and refactoring Auth::Basic accordingly

darcs-hash:20070326212732-5d7f7-7878374ff363bdf14952ebb169619b111cad3fb7.gz
  • Loading branch information...
1 parent 2800d60 commit 0186ca16903589c60f6d9c6a50a1a9e8f830c288 @tim tim committed Mar 26, 2007
View
9 lib/rack.rb
@@ -41,7 +41,14 @@ def self.version
module Auth
autoload :Basic, "rack/auth/basic"
- autoload :Request, "rack/auth/request"
+ autoload :AbstractRequest, "rack/auth/abstract/request"
+ autoload :AbstractHandler, "rack/auth/abstract/handler"
+ module Digest
+ autoload :MD5, "rack/auth/digest/md5"
+ autoload :Nonce, "rack/auth/digest/nonce"
+ autoload :Params, "rack/auth/digest/params"
+ autoload :Request, "rack/auth/digest/request"
+ end
end
module Session
View
25 lib/rack/auth/abstract/handler.rb
@@ -0,0 +1,25 @@
+module Rack
+ module Auth
+ class AbstractHandler
+
+ attr_accessor :realm
+
+ def initialize(app, &authenticator)
+ @app, @authenticator = app, authenticator
+ end
+
+ def unauthorized(www_authenticate = challenge)
+ headers = {
+ 'Content-Type' => 'text/html',
+ 'WWW-Authenticate' => www_authenticate.to_s
+ }
+ return [ 401, headers, ['<h1>401 Unauthorized</h1>'] ]
+ end
+
+ def bad_request
+ [ 400, { 'Content-Type' => 'text/html' }, ['<h1>400 Bad Request</h1>'] ]
+ end
+
+ end
+ end
+end
View
39 lib/rack/auth/abstract/request.rb
@@ -0,0 +1,39 @@
+require 'base64'
+
+module Rack
+ module Auth
+ class AbstractRequest
+
+ def initialize(env)
+ @env = env
+ end
+
+ def provided?
+ !authorization_key.nil?
+ end
+
+ def parts
+ @parts ||= @env[authorization_key].split(' ', 2)
+ end
+
+ def scheme
+ @scheme ||= parts.first.downcase.to_sym
+ end
+
+ def params
+ @params ||= parts.last
+ end
+
+
+ private
+
+ AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION']
+
+ def authorization_key
+ @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) }
+ end
+
+ end
+
+ end
+end
View
60 lib/rack/auth/basic.rb
@@ -1,45 +1,51 @@
-require 'base64'
-require 'rack/auth/request'
+require 'rack/auth/abstract/handler'
+require 'rack/auth/abstract/request'
module Rack
module Auth
- class Basic
-
- def initialize(app, options = {}, &authenticator)
- unless options.has_key?(:realm)
- raise ArgumentError, 'no realm specified'
- end
-
- @app, @options, @authenticator = app, options, authenticator
- end
+ class Basic < AbstractHandler
def call(env)
- auth = Auth::Request.new(env)
+ auth = Basic::Request.new(env)
- if auth.provided? && auth.is?(:Basic) && valid?(auth.credentials)
+ return unauthorized unless auth.provided?
+
+ return bad_request unless auth.basic?
+
+ if valid?(auth)
env['REMOTE_USER'] = auth.username
return @app.call(env)
end
- return challenge_response
+ unauthorized
end
-
-
+
+
private
-
- def challenge_response
- headers = {
- 'Content-Type' => 'text/html',
- 'WWW-Authenticate' => 'Basic realm="%s"' % @options[:realm]
- }
- return [ 401, headers, ['<h1>401 Unauthorized</h1>'] ]
+
+ def challenge
+ 'Basic realm="%s"' % realm
end
-
- def valid?(credentials)
- @authenticator.call(*credentials)
+
+ def valid?(auth)
+ @authenticator.call *auth.credentials
end
-
+
+ class Request < Auth::AbstractRequest
+ def basic?
+ :basic == scheme
+ end
+
+ def credentials
+ @credentials ||= Base64.decode64(params).split(/:/, 2)
+ end
+
+ def username
+ credentials.first
+ end
+ end
+
end
end
end
View
110 lib/rack/auth/digest/md5.rb
@@ -0,0 +1,110 @@
+require 'rack/auth/abstract/handler'
+require 'rack/auth/digest/request'
+require 'rack/auth/digest/params'
+require 'rack/auth/digest/nonce'
+require 'digest/md5'
+
+module Rack
+ module Auth
+ module Digest
+ class MD5 < AbstractHandler
+
+ attr_accessor :opaque
+
+ attr_writer :passwords_hashed
+
+ def passwords_hashed?
+ !!@passwords_hashed
+ end
+
+ def call(env)
+ auth = Request.new(env)
+
+ unless auth.provided?
+ return unauthorized
+ end
+
+ if !auth.digest? || !auth.correct_uri? || !valid_qop?(auth)
+ return bad_request
+ end
+
+ if valid?(auth)
+ if auth.nonce.stale?
+ return unauthorized(challenge(:stale => true))
+ else
+ env['REMOTE_USER'] = auth.username
+
+ return @app.call(env)
+ end
+ end
+
+ unauthorized
+ end
+
+
+ private
+
+ QOP = 'auth'.freeze
+
+ def params(hash = {})
+ Params.new do |params|
+ params['realm'] = realm
+ params['nonce'] = Nonce.new.to_s
+ params['opaque'] = H(opaque)
+ params['qop'] = QOP
+
+ hash.each { |k, v| params[k] = v }
+ end
+ end
+
+ def challenge(hash = {})
+ "Digest #{params(hash)}"
+ end
+
+ def valid?(auth)
+ valid_opaque?(auth) && valid_nonce?(auth) && valid_digest?(auth)
+ end
+
+ def valid_qop?(auth)
+ QOP == auth.qop
+ end
+
+ def valid_opaque?(auth)
+ H(opaque) == auth.opaque
+ end
+
+ def valid_nonce?(auth)
+ auth.nonce.valid?
+ end
+
+ def valid_digest?(auth)
+ digest(auth, @authenticator.call(auth.username)) == auth.response
+ end
+
+ def md5(data)
+ ::Digest::MD5.hexdigest(data)
+ end
+
+ alias :H :md5
+
+ def KD(secret, data)
+ H([secret, data] * ':')
+ end
+
+ def A1(auth, password)
+ [ auth.username, auth.realm, password ] * ':'
+ end
+
+ def A2(auth)
+ [ auth.method, auth.uri ] * ':'
+ end
+
+ def digest(auth, password)
+ KD passwords_hashed? ? password : H(A1(auth, password)),
+ [ auth.nonce, auth.nc, auth.cnonce, QOP, H(A2(auth)) ] * ':'
+ end
+
+ end
+ end
+ end
+end
View
44 lib/rack/auth/digest/nonce.rb
@@ -0,0 +1,44 @@
+require 'base64'
+require 'digest/md5'
+
+module Rack
+ module Auth
+ module Digest
+ class Nonce
+
+ class << self
+ attr_accessor :private_key, :time_limit
+ end
+
+ def self.parse(string)
+ new *Base64.decode64(string).split(' ', 2)
+ end
+
+ def initialize(timestamp = Time.now, given_digest = nil)
+ @timestamp, @given_digest = timestamp.to_i, given_digest
+ end
+
+ def to_s
+ Base64.encode64([ @timestamp, digest ] * ' ').strip
+ end
+
+ def digest
+ ::Digest::MD5.hexdigest([ @timestamp, self.class.private_key ] * ':')
+ end
+
+ def valid?
+ digest == @given_digest
+ end
+
+ def stale?
+ !self.class.time_limit.nil? && (@timestamp - Time.now.to_i) < self.class.time_limit
+ end
+
+ def fresh?
+ !stale?
+ end
+
+ end
+ end
+ end
+end
View
55 lib/rack/auth/digest/params.rb
@@ -0,0 +1,55 @@
+module Rack
+ module Auth
+ module Digest
+ class Params < Hash
+
+ def self.parse(str)
+ split_header_value(str).inject(new) do |header, param|
+ k, v = param.split('=', 2)
+ header[k] = dequote(v)
+ header
+ end
+ end
+
+ def self.dequote(str) # From WEBrick::HTTPUtils
+ ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup
+ ret.gsub!(/\\(.)/, "\\1")
+ ret
+ end
+
+ def self.split_header_value(str) # From WEBrick::HTTPUtils
+ str.scan(/((?:"(?:\\.|[^"])+?"|[^",]+)+)(?:,\s*|\Z)/n).collect{ |v| v[0] }
+ end
+
+ def initialize
+ super
+
+ yield self if block_given?
+ end
+
+ def [](k)
+ super k.to_s
+ end
+
+ def []=(k, v)
+ super k.to_s, v.to_s
+ end
+
+ UNQUOTED = ['qop', 'nc', 'stale']
+
+ def to_s
+ inject([]) do |parts, (k, v)|
+ parts << "#{k}=" + (UNQUOTED.include?(k) ? v.to_s : quote(v))
+ parts
+ end.join(', ')
+ end
+
+ def quote(str) # From WEBrick::HTTPUtils
+ '"' << str.gsub(/[\\\"]/o, "\\\1") << '"'
+ end
+
+ end
+ end
+ end
+end
+
View
40 lib/rack/auth/digest/request.rb
@@ -0,0 +1,40 @@
+require 'rack/auth/abstract/request'
+require 'rack/auth/digest/params'
+require 'rack/auth/digest/nonce'
+
+module Rack
+ module Auth
+ module Digest
+ class Request < Auth::AbstractRequest
+
+ def method
+ @env['REQUEST_METHOD']
+ end
+
+ def digest?
+ :digest == scheme
+ end
+
+ def correct_uri?
+ @env['PATH_INFO'] == uri
+ end
+
+ def nonce
+ @nonce ||= Nonce.parse(params['nonce'])
+ end
+
+ def params
+ @params ||= Params.parse(parts.last)
+ end
+
+ def method_missing(sym)
+ if params.has_key? key = sym.to_s
+ return params[key]
+ end
+ super
+ end
+
+ end
+ end
+ end
+end
View
48 lib/rack/auth/request.rb
@@ -1,48 +0,0 @@
-module Rack
- module Auth
- class Request
-
- def initialize(env)
- @env = env
- end
-
- def provided?
- !authorization_key.nil?
- end
-
- def authorization
- @authorization ||= @env[authorization_key].split
- end
-
- def is?(scheme)
- scheme == authorization.first.to_sym
- end
-
- def credentials
- @credentials ||= Base64.decode64(encoded_credentials).split(/:/, 2)
- end
-
- def username
- credentials.first
- end
-
- def password
- credentials.last
- end
-
-
- private
-
- def encoded_credentials
- authorization.last
- end
-
- AUTHORIZATION_KEYS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION']
-
- def authorization_key
- @authorization_key ||= AUTHORIZATION_KEYS.detect { |key| @env.has_key?(key) }
- end
-
- end
- end
-end
View
49 test/spec_rack_auth_basic.rb
@@ -1,60 +1,67 @@
require 'test/spec'
-require 'rack/mock'
-require 'rack/auth/basic'
require 'base64'
context 'Rack::Auth::Basic' do
- REALM = 'WallysWorld'
+ def realm
+ 'WallysWorld'
+ end
- ORIGINAL_APP = lambda do |env|
- [ 200, {'Content-Type' => 'text/plain'}, ["Hi #{env['REMOTE_USER']}"] ]
+ def unprotected_app
+ lambda { |env| [ 200, {'Content-Type' => 'text/plain'}, ["Hi #{env['REMOTE_USER']}"] ] }
end
+ def protected_app
+ app = Rack::Auth::Basic.new(unprotected_app) { |username, password| 'Boss' == username }
+ app.realm = realm
+ app
+ end
+
setup do
- @request = Rack::MockRequest.new(
- Rack::Auth::Basic.new(ORIGINAL_APP, :realm => REALM) { |user, pass| 'Boss' == user }
- )
+ @request = Rack::MockRequest.new(protected_app)
end
- def request_with_basic_auth(path, username, password, &block)
- request path, 'HTTP_AUTHORIZATION' => 'Basic ' + Base64.encode64("#{username}:#{password}"), &block
+ def request_with_basic_auth(username, password, &block)
+ request 'HTTP_AUTHORIZATION' => 'Basic ' + Base64.encode64("#{username}:#{password}"), &block
end
- def request(path, headers = {})
- yield @request.get(path, headers)
+ def request(headers = {})
+ yield @request.get('/', headers)
end
def assert_basic_auth_challenge(response)
response.should.be.a.client_error
response.status.should.equal 401
response.should.include 'WWW-Authenticate'
- response.headers['WWW-Authenticate'].should.equal 'Basic realm="%s"' % REALM
+ response.headers['WWW-Authenticate'].should =~ /Basic realm="/
response.should =~ /401 Unauthorized/
end
- specify 'should fail on initialization if no realm is provided' do
- initialization_without_realm = lambda { Rack::Auth::Basic.new(ORIGINAL_APP) { } }
- initialization_without_realm.should.raise ArgumentError
- end
-
specify 'should challenge correctly when no credentials are specified' do
- request '/' do |response|
+ request do |response|
assert_basic_auth_challenge response
end
end
specify 'should rechallenge if incorrect credentials are specified' do
- request_with_basic_auth '/', 'joe', 'password' do |response|
+ request_with_basic_auth 'joe', 'password' do |response|
assert_basic_auth_challenge response
end
end
specify 'should return application output if correct credentials are specified' do
- request_with_basic_auth '/', 'Boss', 'password' do |response|
+ request_with_basic_auth 'Boss', 'password' do |response|
response.status.should.equal 200
response.body.to_s.should.equal 'Hi Boss'
end
end
+
+ specify 'should return 400 Bad Request if different auth scheme used' do
+ request 'HTTP_AUTHORIZATION' => 'Digest params' do |response|
+ response.should.be.a.client_error
+ response.status.should.equal 400
+ response.should.not.include 'WWW-Authenticate'
+ end
+ end
end
View
167 test/spec_rack_auth_digest.rb
@@ -0,0 +1,167 @@
+require 'test/spec'
+require 'rack'
+
+context 'Rack::Auth::Digest::MD5' do
+
+ def realm
+ 'WallysWorld'
+ end
+
+ def unprotected_app
+ lambda do |env|
+ [ 200, {'Content-Type' => 'text/plain'}, ["Hi #{env['REMOTE_USER']}"] ]
+ end
+ end
+
+ def protected_app
+ app = Rack::Auth::Digest::MD5.new(unprotected_app) do |username|
+ { 'Alice' => 'correct-password' }[username]
+ end
+ app.realm = realm
+ app.opaque = 'this-should-be-secret'
+ app
+ end
+
+ def protected_app_with_hashed_passwords
+ app = Rack::Auth::Digest::MD5.new(unprotected_app) do |username|
+ username == 'Alice' ? Digest::MD5.hexdigest("Alice:#{realm}:correct-password") : nil
+ end
+ app.realm = realm
+ app.opaque = 'this-should-be-secret'
+ app.passwords_hashed = true
+ app
+ end
+
+ setup do
+ @request = Rack::MockRequest.new(protected_app)
+ end
+
+ def request(path, headers = {}, &block)
+ response = @request.get(path, headers)
+ block.call(response) if block
+ return response
+ end
+
+ class MockDigestRequest
+ def initialize(params)
+ @params = params
+ end
+ def method_missing(sym)
+ if @params.has_key? k = sym.to_s
+ return @params[k]
+ end
+ super
+ end
+ def method
+ 'GET'
+ end
+ def response(password)
+ Rack::Auth::Digest::MD5.new(nil).send :digest, self, password
+ end
+ end
+
+ def request_with_digest_auth(path, username, password, options = {}, &block)
+ response = request('/')
+
+ return response unless response.status == 401
+
+ if wait = options.delete(:wait)
+ sleep wait
+ end
+
+ challenge = response['WWW-Authenticate'].split(' ', 2).last
+
+ params = Rack::Auth::Digest::Params.parse(challenge)
+
+ params['username'] = username
+ params['nc'] = '00000001'
+ params['cnonce'] = 'nonsensenonce'
+ params['uri'] = path
+
+ params.update options
+
+ params['response'] = MockDigestRequest.new(params).response(password)
+
+ request(path, { 'HTTP_AUTHORIZATION' => "Digest #{params}" }, &block)
+ end
+
+ def assert_digest_auth_challenge(response)
+ response.should.be.a.client_error
+ response.status.should.equal 401
+ response.should.include 'WWW-Authenticate'
+ response.headers['WWW-Authenticate'].should =~ /^Digest /
+ response.should =~ /401 Unauthorized/
+ end
+
+ def assert_bad_request(response)
+ response.should.be.a.client_error
+ response.status.should.equal 400
+ response.should.not.include 'WWW-Authenticate'
+ end
+
+ specify 'should challenge when no credentials are specified' do
+ request '/' do |response|
+ assert_digest_auth_challenge response
+ end
+ end
+
+ specify 'should return application output if correct credentials given' do
+ request_with_digest_auth '/', 'Alice', 'correct-password' do |response|
+ response.status.should.equal 200
+ response.body.to_s.should.equal 'Hi Alice'
+ end
+ end
+
+ specify 'should return application output if correct credentials given (hashed passwords)' do
+ @request = Rack::MockRequest.new(protected_app_with_hashed_passwords)
+
+ request_with_digest_auth '/', 'Alice', 'correct-password' do |response|
+ response.status.should.equal 200
+ response.body.to_s.should.equal 'Hi Alice'
+ end
+ end
+
+ specify 'should rechallenge if incorrect username given' do
+ request_with_digest_auth '/', 'Bob', 'correct-password' do |response|
+ assert_digest_auth_challenge response
+ end
+ end
+
+ specify 'should rechallenge if incorrect password given' do
+ request_with_digest_auth '/', 'Alice', 'wrong-password' do |response|
+ assert_digest_auth_challenge response
+ end
+ end
+
+ specify 'should rechallenge with stale parameter if nonce is stale' do
+ begin
+ Rack::Auth::Digest::Nonce.time_limit = 1
+
+ request_with_digest_auth '/', 'Alice', 'correct-password', :wait => 2 do |response|
+ assert_digest_auth_challenge response
+ response.headers['WWW-Authenticate'].should =~ /\bstale=true\b/
+ end
+ ensure
+ Rack::Auth::Digest::Nonce.time_limit = nil
+ end
+ end
+
+ specify 'should return 400 Bad Request if incorrect qop given' do
+ request_with_digest_auth '/', 'Alice', 'correct-password', 'qop' => 'auth-int' do |response|
+ assert_bad_request response
+ end
+ end
+
+ specify 'should return 400 Bad Request if incorrect uri given' do
+ request_with_digest_auth '/', 'Alice', 'correct-password', 'uri' => '/foo' do |response|
+ assert_bad_request response
+ end
+ end
+
+ specify 'should return 400 Bad Request if different auth scheme used' do
+ request '/', 'HTTP_AUTHORIZATION' => 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==' do |response|
+ assert_bad_request response
+ end
+ end
+
+end

0 comments on commit 0186ca1

Please sign in to comment.
Something went wrong with that request. Please try again.