Skip to content
This repository
Browse code

Implement HTTP Digest authentication. [#1230 state:resolved] [Gregg K…

…ellogg, Pratik Naik]

Signed-off-by: Pratik Naik <pratiknaik@gmail.com>
  • Loading branch information...
commit 306cc2b920203cfa51cee82d2fc452484efc72f8 1 parent e6493eb
authored January 29, 2009 lifo committed January 29, 2009
21  actionpack/CHANGELOG
... ...
@@ -1,5 +1,26 @@
1 1
 *2.3.0 [Edge]*
2 2
 
  3
+* Implement HTTP Digest authentication. #1230 [Gregg Kellogg, Pratik Naik] Example :
  4
+
  5
+  class DummyDigestController < ActionController::Base
  6
+    USERS = { "lifo" => 'world' }
  7
+
  8
+    before_filter :authenticate
  9
+
  10
+    def index
  11
+      render :text => "Hello Secret"
  12
+    end
  13
+
  14
+    private
  15
+
  16
+    def authenticate
  17
+      authenticate_or_request_with_http_digest("Super Secret") do |username|
  18
+        # Return the user's password
  19
+        USERS[username]
  20
+      end
  21
+    end
  22
+  end
  23
+
3 24
 * Improved i18n support for the number_to_human_size helper. Changes the storage_units translation data; update your translations accordingly.  #1634 [Yaroslav Markin]
4 25
     storage_units:
5 26
       # %u is the storage unit, %n is the number (default: 2 MB)
4  actionpack/lib/action_controller/base.rb
@@ -1344,8 +1344,8 @@ def process_cleanup
1344 1344
   Base.class_eval do
1345 1345
     [ Filters, Layout, Benchmarking, Rescue, Flash, MimeResponds, Helpers,
1346 1346
       Cookies, Caching, Verification, Streaming, SessionManagement,
1347  
-      HttpAuthentication::Basic::ControllerMethods, RecordIdentifier,
1348  
-      RequestForgeryProtection, Translation
  1347
+      HttpAuthentication::Basic::ControllerMethods, HttpAuthentication::Digest::ControllerMethods,
  1348
+      RecordIdentifier, RequestForgeryProtection, Translation
1349 1349
     ].each do |mod|
1350 1350
       include mod
1351 1351
     end
196  actionpack/lib/action_controller/http_authentication.rb
... ...
@@ -1,42 +1,42 @@
1 1
 module ActionController
2 2
   module HttpAuthentication
3 3
     # Makes it dead easy to do HTTP Basic authentication.
4  
-    # 
  4
+    #
5 5
     # Simple Basic example:
6  
-    # 
  6
+    #
7 7
     #   class PostsController < ApplicationController
8 8
     #     USER_NAME, PASSWORD = "dhh", "secret"
9  
-    #   
  9
+    #
10 10
     #     before_filter :authenticate, :except => [ :index ]
11  
-    #   
  11
+    #
12 12
     #     def index
13 13
     #       render :text => "Everyone can see me!"
14 14
     #     end
15  
-    #   
  15
+    #
16 16
     #     def edit
17 17
     #       render :text => "I'm only accessible if you know the password"
18 18
     #     end
19  
-    #   
  19
+    #
20 20
     #     private
21 21
     #       def authenticate
22  
-    #         authenticate_or_request_with_http_basic do |user_name, password| 
  22
+    #         authenticate_or_request_with_http_basic do |user_name, password|
23 23
     #           user_name == USER_NAME && password == PASSWORD
24 24
     #         end
25 25
     #       end
26 26
     #   end
27  
-    # 
28  
-    # 
29  
-    # Here is a more advanced Basic example where only Atom feeds and the XML API is protected by HTTP authentication, 
  27
+    #
  28
+    #
  29
+    # Here is a more advanced Basic example where only Atom feeds and the XML API is protected by HTTP authentication,
30 30
     # the regular HTML interface is protected by a session approach:
31  
-    # 
  31
+    #
32 32
     #   class ApplicationController < ActionController::Base
33 33
     #     before_filter :set_account, :authenticate
34  
-    #   
  34
+    #
35 35
     #     protected
36 36
     #       def set_account
37 37
     #         @account = Account.find_by_url_name(request.subdomains.first)
38 38
     #       end
39  
-    #   
  39
+    #
40 40
     #       def authenticate
41 41
     #         case request.format
42 42
     #         when Mime::XML, Mime::ATOM
@@ -54,24 +54,48 @@ module HttpAuthentication
54 54
     #         end
55 55
     #       end
56 56
     #   end
57  
-    # 
58  
-    # 
  57
+    #
59 58
     # In your integration tests, you can do something like this:
60  
-    # 
  59
+    #
61 60
     #   def test_access_granted_from_xml
62 61
     #     get(
63  
-    #       "/notes/1.xml", nil, 
  62
+    #       "/notes/1.xml", nil,
64 63
     #       :authorization => ActionController::HttpAuthentication::Basic.encode_credentials(users(:dhh).name, users(:dhh).password)
65 64
     #     )
66  
-    # 
  65
+    #
67 66
     #     assert_equal 200, status
68 67
     #   end
69  
-    #  
70  
-    #  
  68
+    #
  69
+    # Simple Digest example:
  70
+    #
  71
+    #   class PostsController < ApplicationController
  72
+    #     USERS = {"dhh" => "secret"}
  73
+    #
  74
+    #     before_filter :authenticate, :except => [:index]
  75
+    #
  76
+    #     def index
  77
+    #       render :text => "Everyone can see me!"
  78
+    #     end
  79
+    #
  80
+    #     def edit
  81
+    #       render :text => "I'm only accessible if you know the password"
  82
+    #     end
  83
+    #
  84
+    #     private
  85
+    #       def authenticate
  86
+    #         authenticate_or_request_with_http_digest(realm) do |username|
  87
+    #           USERS[username]
  88
+    #         end
  89
+    #       end
  90
+    #   end
  91
+    #
  92
+    # NOTE: The +authenticate_or_request_with_http_digest+ block must return the user's password so the framework can appropriately
  93
+    #       hash it to check the user's credentials. Returning +nil+ will cause authentication to fail.
  94
+    #
71 95
     # On shared hosts, Apache sometimes doesn't pass authentication headers to
72 96
     # FCGI instances. If your environment matches this description and you cannot
73 97
     # authenticate, try this rule in your Apache setup:
74  
-    # 
  98
+    #
75 99
     #   RewriteRule ^(.*)$ dispatch.fcgi [E=X-HTTP_AUTHORIZATION:%{HTTP:Authorization},QSA,L]
76 100
     module Basic
77 101
       extend self
@@ -99,14 +123,14 @@ def authenticate(controller, &login_procedure)
99 123
       def user_name_and_password(request)
100 124
         decode_credentials(request).split(/:/, 2)
101 125
       end
102  
-  
  126
+
103 127
       def authorization(request)
104 128
         request.env['HTTP_AUTHORIZATION']   ||
105 129
         request.env['X-HTTP_AUTHORIZATION'] ||
106 130
         request.env['X_HTTP_AUTHORIZATION'] ||
107 131
         request.env['REDIRECT_X_HTTP_AUTHORIZATION']
108 132
       end
109  
-    
  133
+
110 134
       def decode_credentials(request)
111 135
         ActiveSupport::Base64.decode64(authorization(request).split.last || '')
112 136
       end
@@ -120,5 +144,131 @@ def authentication_request(controller, realm)
120 144
         controller.__send__ :render, :text => "HTTP Basic: Access denied.\n", :status => :unauthorized
121 145
       end
122 146
     end
  147
+
  148
+    module Digest
  149
+      extend self
  150
+
  151
+      module ControllerMethods
  152
+        def authenticate_or_request_with_http_digest(realm = "Application", &password_procedure)
  153
+          authenticate_with_http_digest(realm, &password_procedure) || request_http_digest_authentication(realm)
  154
+        end
  155
+
  156
+        # Authenticate with HTTP Digest, returns true or false
  157
+        def authenticate_with_http_digest(realm = "Application", &password_procedure)
  158
+          HttpAuthentication::Digest.authenticate(self, realm, &password_procedure)
  159
+        end
  160
+
  161
+        # Render output including the HTTP Digest authentication header
  162
+        def request_http_digest_authentication(realm = "Application", message = nil)
  163
+          HttpAuthentication::Digest.authentication_request(self, realm, message)
  164
+        end
  165
+      end
  166
+
  167
+      # Returns false on a valid response, true otherwise
  168
+      def authenticate(controller, realm, &password_procedure)
  169
+        authorization(controller.request) && validate_digest_response(controller, realm, &password_procedure)
  170
+      end
  171
+
  172
+      def authorization(request)
  173
+        request.env['HTTP_AUTHORIZATION']   ||
  174
+        request.env['X-HTTP_AUTHORIZATION'] ||
  175
+        request.env['X_HTTP_AUTHORIZATION'] ||
  176
+        request.env['REDIRECT_X_HTTP_AUTHORIZATION']
  177
+      end
  178
+
  179
+      # Raises error unless the request credentials response value matches the expected value.
  180
+      def validate_digest_response(controller, realm, &password_procedure)
  181
+        credentials = decode_credentials_header(controller.request)
  182
+        valid_nonce = validate_nonce(controller.request, credentials[:nonce])
  183
+
  184
+        if valid_nonce && realm == credentials[:realm] && opaque(controller.request.session.session_id) == credentials[:opaque]
  185
+          password = password_procedure.call(credentials[:username])
  186
+          expected = expected_response(controller.request.env['REQUEST_METHOD'], controller.request.url, credentials, password)
  187
+          expected == credentials[:response]
  188
+        end
  189
+      end
  190
+
  191
+      # Returns the expected response for a request of +http_method+ to +uri+ with the decoded +credentials+ and the expected +password+
  192
+      def expected_response(http_method, uri, credentials, password)
  193
+        ha1 = ::Digest::MD5.hexdigest([credentials[:username], credentials[:realm], password].join(':'))
  194
+        ha2 = ::Digest::MD5.hexdigest([http_method.to_s.upcase, uri].join(':'))
  195
+        ::Digest::MD5.hexdigest([ha1, credentials[:nonce], credentials[:nc], credentials[:cnonce], credentials[:qop], ha2].join(':'))
  196
+      end
  197
+
  198
+      def encode_credentials(http_method, credentials, password)
  199
+        credentials[:response] = expected_response(http_method, credentials[:uri], credentials, password)
  200
+        "Digest " + credentials.sort_by {|x| x[0].to_s }.inject([]) {|a, v| a << "#{v[0]}='#{v[1]}'" }.join(', ')
  201
+      end
  202
+
  203
+      def decode_credentials_header(request)
  204
+        decode_credentials(authorization(request))
  205
+      end
  206
+
  207
+      def decode_credentials(header)
  208
+        header.to_s.gsub(/^Digest\s+/,'').split(',').inject({}) do |hash, pair|
  209
+          key, value = pair.split('=', 2)
  210
+          hash[key.strip.to_sym] = value.to_s.gsub(/^"|"$/,'').gsub(/'/, '')
  211
+          hash
  212
+        end
  213
+      end
  214
+
  215
+      def authentication_header(controller, realm)
  216
+        session_id = controller.request.session.session_id
  217
+        controller.headers["WWW-Authenticate"] = %(Digest realm="#{realm}", qop="auth", algorithm=MD5, nonce="#{nonce(session_id)}", opaque="#{opaque(session_id)}")
  218
+      end
  219
+
  220
+      def authentication_request(controller, realm, message = nil)
  221
+        message ||= "HTTP Digest: Access denied.\n"
  222
+        authentication_header(controller, realm)
  223
+        controller.__send__ :render, :text => message, :status => :unauthorized
  224
+      end
  225
+
  226
+      # Uses an MD5 digest based on time to generate a value to be used only once.
  227
+      #
  228
+      # A server-specified data string which should be uniquely generated each time a 401 response is made.
  229
+      # It is recommended that this string be base64 or hexadecimal data.
  230
+      # Specifically, since the string is passed in the header lines as a quoted string, the double-quote character is not allowed.
  231
+      #
  232
+      # The contents of the nonce are implementation dependent.
  233
+      # The quality of the implementation depends on a good choice.
  234
+      # A nonce might, for example, be constructed as the base 64 encoding of
  235
+      #
  236
+      # => time-stamp H(time-stamp ":" ETag ":" private-key)
  237
+      #
  238
+      # where time-stamp is a server-generated time or other non-repeating value,
  239
+      # ETag is the value of the HTTP ETag header associated with the requested entity,
  240
+      # and private-key is data known only to the server.
  241
+      # With a nonce of this form a server would recalculate the hash portion after receiving the client authentication header and
  242
+      # reject the request if it did not match the nonce from that header or
  243
+      # if the time-stamp value is not recent enough. In this way the server can limit the time of the nonce's validity.
  244
+      # The inclusion of the ETag prevents a replay request for an updated version of the resource.
  245
+      # (Note: including the IP address of the client in the nonce would appear to offer the server the ability
  246
+      # to limit the reuse of the nonce to the same client that originally got it.
  247
+      # However, that would break proxy farms, where requests from a single user often go through different proxies in the farm.
  248
+      # Also, IP address spoofing is not that hard.)
  249
+      #
  250
+      # An implementation might choose not to accept a previously used nonce or a previously used digest, in order to
  251
+      # protect against a replay attack. Or, an implementation might choose to use one-time nonces or digests for
  252
+      # POST or PUT requests and a time-stamp for GET requests. For more details on the issues involved see Section 4
  253
+      # of this document.
  254
+      #
  255
+      # The nonce is opaque to the client.
  256
+      def nonce(session_id, time = Time.now)
  257
+        t = time.to_i
  258
+        hashed = [t, session_id]
  259
+        digest = ::Digest::MD5.hexdigest(hashed.join(":"))
  260
+        Base64.encode64("#{t}:#{digest}").gsub("\n", '')
  261
+      end
  262
+
  263
+      def validate_nonce(request, value)
  264
+        t = Base64.decode64(value).split(":").first.to_i
  265
+        nonce(request.session.session_id, t) == value && (t - Time.now.to_i).abs <= 10 * 60
  266
+      end
  267
+
  268
+      # Opaque based on digest of session_id
  269
+      def opaque(session_id)
  270
+        Base64.encode64(::Digest::MD5::hexdigest(session_id)).gsub("\n", '')
  271
+      end
  272
+    end
123 273
   end
124 274
 end
130  actionpack/test/controller/http_digest_authentication_test.rb
... ...
@@ -0,0 +1,130 @@
  1
+require 'abstract_unit'
  2
+
  3
+class HttpDigestAuthenticationTest < ActionController::TestCase
  4
+  class DummyDigestController < ActionController::Base
  5
+    before_filter :authenticate, :only => :index
  6
+    before_filter :authenticate_with_request, :only => :display
  7
+
  8
+    USERS = { 'lifo' => 'world', 'pretty' => 'please' }
  9
+
  10
+    def index
  11
+      render :text => "Hello Secret"
  12
+    end
  13
+
  14
+    def display
  15
+      render :text => 'Definitely Maybe'
  16
+    end
  17
+
  18
+    private
  19
+
  20
+    def authenticate
  21
+      authenticate_or_request_with_http_digest("SuperSecret") do |username|
  22
+        # Return the password
  23
+        USERS[username]
  24
+      end
  25
+    end
  26
+
  27
+    def authenticate_with_request
  28
+      if authenticate_with_http_digest("SuperSecret")  { |username| USERS[username] }
  29
+        @logged_in = true
  30
+      else
  31
+        request_http_digest_authentication("SuperSecret", "Authentication Failed")
  32
+      end
  33
+    end
  34
+  end
  35
+
  36
+  AUTH_HEADERS = ['HTTP_AUTHORIZATION', 'X-HTTP_AUTHORIZATION', 'X_HTTP_AUTHORIZATION', 'REDIRECT_X_HTTP_AUTHORIZATION']
  37
+
  38
+  tests DummyDigestController
  39
+
  40
+  AUTH_HEADERS.each do |header|
  41
+    test "successful authentication with #{header.downcase}" do
  42
+      @request.env[header] = encode_credentials(:username => 'lifo', :password => 'world')
  43
+      get :index
  44
+
  45
+      assert_response :success
  46
+      assert_equal 'Hello Secret', @response.body, "Authentication failed for request header #{header}"
  47
+    end
  48
+  end
  49
+
  50
+  AUTH_HEADERS.each do |header|
  51
+    test "unsuccessful authentication with #{header.downcase}" do
  52
+      @request.env[header] = encode_credentials(:username => 'h4x0r', :password => 'world')
  53
+      get :index
  54
+
  55
+      assert_response :unauthorized
  56
+      assert_equal "HTTP Digest: Access denied.\n", @response.body, "Authentication didn't fail for request header #{header}"
  57
+    end
  58
+  end
  59
+
  60
+  test "authentication request without credential" do
  61
+    get :display
  62
+
  63
+    assert_response :unauthorized
  64
+    assert_equal "Authentication Failed", @response.body
  65
+    credentials = decode_credentials(@response.headers['WWW-Authenticate'])
  66
+    assert_equal 'SuperSecret', credentials[:realm]
  67
+  end
  68
+
  69
+  test "authentication request with invalid password" do
  70
+    @request.env['HTTP_AUTHORIZATION'] = encode_credentials(:username => 'pretty', :password => 'foo')
  71
+    get :display
  72
+
  73
+    assert_response :unauthorized
  74
+    assert_equal "Authentication Failed", @response.body
  75
+  end
  76
+
  77
+  test "authentication request with invalid nonce" do
  78
+    @request.env['HTTP_AUTHORIZATION'] = encode_credentials(:username => 'pretty', :password => 'please', :nonce => "xxyyzz")
  79
+    get :display
  80
+
  81
+    assert_response :unauthorized
  82
+    assert_equal "Authentication Failed", @response.body
  83
+  end
  84
+
  85
+  test "authentication request with invalid opaque" do
  86
+    @request.env['HTTP_AUTHORIZATION'] = encode_credentials(:username => 'pretty', :password => 'foo', :opaque => "xxyyzz")
  87
+    get :display
  88
+
  89
+    assert_response :unauthorized
  90
+    assert_equal "Authentication Failed", @response.body
  91
+  end
  92
+
  93
+  test "authentication request with invalid realm" do
  94
+    @request.env['HTTP_AUTHORIZATION'] = encode_credentials(:username => 'pretty', :password => 'foo', :realm => "NotSecret")
  95
+    get :display
  96
+
  97
+    assert_response :unauthorized
  98
+    assert_equal "Authentication Failed", @response.body
  99
+  end
  100
+
  101
+  test "authentication request with valid credential" do
  102
+    @request.env['HTTP_AUTHORIZATION'] = encode_credentials(:username => 'pretty', :password => 'please')
  103
+    get :display
  104
+
  105
+    assert_response :success
  106
+    assert assigns(:logged_in)
  107
+    assert_equal 'Definitely Maybe', @response.body
  108
+  end
  109
+
  110
+  private
  111
+
  112
+  def encode_credentials(options)
  113
+    options.reverse_merge!(:nc => "00000001", :cnonce => "0a4f113b")
  114
+    password = options.delete(:password)
  115
+
  116
+    # Perform unautheticated get to retrieve digest parameters to use on subsequent request
  117
+    get :index
  118
+
  119
+    assert_response :unauthorized
  120
+
  121
+    credentials = decode_credentials(@response.headers['WWW-Authenticate'])
  122
+    credentials.merge!(options)
  123
+    credentials.merge!(:uri => "http://#{@request.host}#{@request.env['REQUEST_URI']}")
  124
+    ActionController::HttpAuthentication::Digest.encode_credentials("GET", credentials, password)
  125
+  end
  126
+
  127
+  def decode_credentials(header)
  128
+    ActionController::HttpAuthentication::Digest.decode_credentials(@response.headers['WWW-Authenticate'])
  129
+  end
  130
+end

3 notes on commit 306cc2b

Radoslav Stankov

Looks great, 10x :) But I’m missing something but in HttpAuthentication::Digest.validate_digest_response you can just pass the controller.request, instead of the whole controller object, because you only use the request there. And if I’m then in HttpAuthentication::Digest.authenticate you will also need only the request object

Pratik
Owner

You’re right. Changed that in b3bc4fa5e02e71a992f8a432757548c762f0aad8

Thanks !

Radoslav Stankov

Glad to help :)

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