diff options
author | Christian Neukirchen <chneukirchen@gmail.com> | 2008-06-24 13:55:25 +0200 |
---|---|---|
committer | Christian Neukirchen <chneukirchen@gmail.com> | 2008-06-24 13:55:25 +0200 |
commit | 2e3c3adc0df92fcd41b32feb4b64bfaae9664385 (patch) | |
tree | 58fb1173b20476649e3d4baf9cdd7f719a7d98fd | |
parent | fe22d0fddfe13b4fbcac44e8d804c93e23eb6380 (diff) | |
parent | 86d4f36b5e252c76d1ca09fa104cd2aac7f9f46a (diff) | |
download | rack-2e3c3adc0df92fcd41b32feb4b64bfaae9664385.tar.gz |
Merge commit 'scytrin/master'
-rw-r--r-- | lib/rack/auth/openid.rb | 368 | ||||
-rw-r--r-- | test/spec_rack_auth_openid.rb | 53 | ||||
-rw-r--r-- | test/spec_rack_utils.rb | 1 |
3 files changed, 334 insertions, 88 deletions
diff --git a/lib/rack/auth/openid.rb b/lib/rack/auth/openid.rb index 9267a98e..f093e8f4 100644 --- a/lib/rack/auth/openid.rb +++ b/lib/rack/auth/openid.rb @@ -1,115 +1,309 @@ # AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net -gem_require 'ruby-openid', '~> 1.0.0' if defined? Gem -require 'rack/auth/abstract/handler' -require 'openid' +gem 'ruby-openid', '~> 2' if defined? Gem +require 'rack/auth/abstract/handler' #rack +require 'uri' #std +require 'pp' #std +require 'openid' #gem +require 'openid/store/memory' #gem module Rack module Auth - # Rack::Auth::OpenID provides a simple method for permitting openid - # based logins. It requires the ruby-openid lib from janrain to operate, - # as well as some method of session management of a Hash type. + # Rack::Auth::OpenID provides a simple method for permitting + # openid based logins. It requires the ruby-openid library from + # janrain to operate, as well as a rack method of session management. # - # After a transaction, the response status object is stored in the - # environment at rack.auth.openid.status, which can be used in the - # followup block or in a wrapping application to accomplish - # additional data maniipulation. + # The ruby-openid home page is at http://openidenabled.com/ruby-openid/. # - # NOTE: Due to the amount of data that ruby-openid stores in the session, - # Rack::Session::Cookie may fault. + # The OpenID specifications can be found at + # http://openid.net/specs/openid-authentication-1_1.html + # and + # http://openid.net/specs/openid-authentication-2_0.html. Documentation + # for published OpenID extensions and related topics can be found at + # http://openid.net/developers/specs/. # - # A hash of data is stored in the session hash at the key of :openid. - # The fully canonicalized identity url is stored within at 'identity'. - # Extension data from 'openid.sreg.nickname' would be stored as - # { 'nickname' => value }. + # It is recommended to read through the OpenID spec, as well as + # ruby-openid's documentation, to understand what exactly goes on. However + # a setup as simple as the presented examples is enough to provide + # functionality. # - # NOTE: To my knowledge there is no collision at this point from storage - # of this manner, if there is please let me know so I may adjust this app - # to cope. - # NOTE: This rack application is only compatible with the 1.x.x versions - # of the ruby-openid library. If rubygems is loaded at require time of - # this app, the specification will be made. If it is not then the 'openid' - # library will be required, and will fail if it is not compatible. + # This library strongly intends to utilize the OpenID 2.0 features of the + # ruby-openid library, while maintaining OpenID 1.0 compatiblity. + # + # All responses from this rack application will be 303 redirects unless an + # error occurs, with the exception of an authentication request requiring + # an HTML form submission. + # + # NOTE: Extensions are not currently supported by this implimentation of + # the OpenID rack application due to the complexity of the current + # ruby-openid extension handling. + # + # NOTE: Due to the amount of data that this library stores in the + # session, Rack::Session::Cookie may fault. class OpenID < AbstractHandler + class NoSession < RuntimeError; end # Required for ruby-openid - OIDStore = ::OpenID::MemoryStore.new + OIDStore = ::OpenID::Store::Memory.new + HTML = '<html><head><title>%s</title></head><body>%s</body></html>' # A Hash of options is taken as it's single initializing - # argument. String keys are taken to be openid protocol - # extension namespaces. - # - # For example: 'sreg' => { 'required' => # 'nickname' } - # - # Other keys are taken as options for Rack::Auth::OpenID, normally Symbols. - # Only :return is required. :trust is highly recommended to be set. - # - # * :return defines the url to return to after the client authenticates - # with the openid service provider. Should point to where this app is - # mounted. (ex: 'http://mysite.com/openid') - # * :trust defines the url identifying the site they are actually logging - # into. (ex: 'http://mysite.com/') - # * :session_key defines the key to the session hash in the env. - # (by default it uses 'rack.session') - def initialize(options={}) - raise ArgumentError, 'No return url provided.' unless options[:return] - warn 'No trust url provided.' unless options[:trust] - options[:trust] ||= options[:return] - - @options = { - :session_key => 'rack.session' + # argument. For example: + # + # simple_oid = OpenID.new('http://mysite.com/') + # + # return_oid = OpenID.new('http://mysite.com/', { + # :return_to => 'http://mysite.com/openid' + # }) + # + # page_oid = OpenID.new('http://mysite.com/', + # :login_good => 'http://mysite.com/auth_good' + # ) + # + # = Arguments + # + # The first argument is the realm, identifying the site they are trusting + # with their identity. This is required. + # + # NOTE: In OpenID 1.x, the realm or trust_root is optional and the + # return_to url is required. As this library strives tward ruby-openid + # 2.0, and OpenID 2.0 compatibiliy, the realm is required and return_to + # is optional. However, this implimentation is still backwards compatible + # with OpenID 1.0 servers. + # + # The optional second argument is a hash of options. + # + # == Options + # + # <tt>:return_to</tt> defines the url to return to after the client authenticates with the openid service provider. This url should point to where Rack::Auth::OpenID is mounted. If <tt>:return_to</tt> is not provided, the url will be derived within the ruby-openid implementation. + # + # <tt>:session_key</tt> defines the key to the session hash in the env. It defaults to 'rack.session'. + # + # <tt>:openid_param</tt> defines at what key in the request parameters to find the identifier to resolve. As per the 2.0 spec, the default is 'openid_identifier'. + # + # <tt>:extensions</tt> will specify what extensions are to used with OpenID, of which the format and support of which is yet to be completed. + # + # <tt>:immediate</tt> as true will make immediate type of requests the default. See the specification documentation. + # + # === URL options + # + # <tt>:login_good</tt> is the url to go to after the authentication process has completed. + # + # <tt>:login_fail</tt> is the url to go to after the authentication process has failed. + # + # <tt>:login_quit</tt> is the url to go to after the authentication process + # has been cancelled. + # + # === Response options + # + # <tt>:no_session</tt> should be a rack response to be returned if no or an incompatible session is found. + # + # <tt>:auth_fail</tt> should be a rack response to be returned if an OpenID::DiscoveryFailure occurs. This is typically due to being unable to access the identity url or identity server. + # + # <tt>:error</tt> should be a rack response to return if any other generic error would occur and <tt>options[:catch_errors]</tt> is true. + def initialize(realm, options={}) + @realm = realm + realm = URI(realm) + if realm.path.empty? + raise ArgumentError, "Invalid realm path: '#{realm.path}'" + elsif not realm.absolute? + raise ArgumentError, "Realm '#{@realm}' not absolute" + end + + [:return_to, :login_good, :login_fail, :login_quit].each do |key| + if options.key? key and luri = URI(options[key]) + if !luri.absolute? + raise ArgumentError, ":#{key} is not an absolute uri: '#{luri}'" + end + end + end + + if options[:return_to] and ruri = URI(options[:return_to]) + if ruri.path.empty? + raise ArgumentError, "Invalid return_to path: '#{ruri.path}'" + elsif realm.path != ruri.path[0, realm.path.size] + raise ArgumentError, 'return_to not within realm.' \ + end + end + + # TODO: extension support + if options.has_key? :extensions + warn "Extensions are not currently supported by Rack::Auth::OpenID" + end + + @options = { + :session_key => 'rack.session', + :openid_param => 'openid_identifier', + #:return_to, :login_good, :login_fail, :login_quit + #:no_session, :auth_fail, :error + :store => OIDStore, + :immediate => false, + :anonymous => false, + :catch_errors => false }.merge(options) end + attr_reader :options + + # It sets up and uses session data at <tt>:openid</tt> within the session. It sets up the ::OpenID::Consumer using the store specified by <tt>options[:store]</tt>. + # + # If the parameter specified by <tt>options[:openid_param]</tt> is present, processing is passed to #check and the result is returned. + # + # If the parameter 'openid.mode' is set, implying a followup from the openid server, processing is passed to #finish and the result is returned. + # + # If neither of these conditions are met, a 400 error is returned. + # + # If an error is thrown and <tt>options[:catch_errors]</tt> is false, the exception will be reraised. Otherwise a 500 error is returned. def call(env) + env['rack.auth.openid'] = self + session = env[@options[:session_key]] + unless session and session.is_a? Hash + raise(NoSession, 'No compatible session') + end + # let us work in our own namespace... + session = (session[:openid] ||= {}) + unless session and session.is_a? Hash + raise(NoSession, 'Incompatible session') + end + request = Rack::Request.new env - return no_session unless session = request.env[@options[:session_key]] - resp = if request.GET['openid.mode'] - finish session, request.GET, env - elsif request.GET['openid_url'] - check session, request.GET['openid_url'], env - else - bad_request - end - end + consumer = ::OpenID::Consumer.new session, @options[:store] - def check(session, oid_url, env) - consumer = ::OpenID::Consumer.new session, OIDStore - oid = consumer.begin oid_url - return auth_fail unless oid.status == ::OpenID::SUCCESS - @options.each do |ns,s| - next unless ns.is_a? String - s.each {|k,v| oid.add_extension_arg(ns, k, v) } + if request.params[@options[:openid_param]] + check consumer, session, request + elsif request.params['openid.mode'] + finish consumer, session, request + else + env['rack.errors'].puts "No valid params provided." + bad_request end - r_url = @options.fetch :return do |k| request.url end - t_url = @options.fetch :trust - env['rack.auth.openid.status'] = oid - return 303, {'Location'=>oid.redirect_url( t_url, r_url )}, [] - end + rescue NoSession + env['rack.errors'].puts($!.message, *$@) - def finish(session, params, env) - consumer = ::OpenID::Consumer.new session, OIDStore - oid = consumer.complete params - return bad_login unless oid.status == ::OpenID::SUCCESS - session[:openid] = {'identity' => oid.identity_url} - @options.each do |ns,s| - next unless ns.is_a? String - oid.extension_response(ns).each{|k,v| session[k]=v } - end - env['rack.auth.openid.status'] = oid - return 303, {'Location'=>@options[:trust]}, [] - end + @options. ### Missing or incompatible session + fetch :no_session, [ 500, + {'Content-Type'=>'text/plain'}, + $!.message ] + rescue + env['rack.errors'].puts($!.message, *$@) - def no_session + if not @options[:catch_error] + raise($!) + end @options. - fetch :no_session, [500,{'Content-Type'=>'text/plain'},'No session available.'] + fetch :error, [ 500, + {'Content-Type'=>'text/plain'}, + 'OpenID has encountered an error.' ] end - def auth_fail - @options. - fetch :auth_fail, [500, {'Content-Type'=>'text/plain'},'Foreign server failure.'] + + # As the first part of OpenID consumer action, #check retrieves the data required for completion. + # + # * <tt>session[:openid][:openid_param]</tt> is the request parameter requested to be authenticated. + # * <tt>session[:openid][:site_return]</tt> is set as the request's HTTP_REFERER if previously unset. + # * <tt>env['rack.auth.openid.request']</tt> is the openid checkid request. + def check(consumer, session, req) + session[:openid_param] = req.params[@options[:openid_param]] + oid = consumer.begin(session[:openid_param], @options[:anonymous]) + pp oid if $DEBUG + req.env['rack.auth.openid.request'] = oid + + session[:site_return] ||= req.env['HTTP_REFERER'] + + # SETUP_NEEDED check! + # see OpenID::Consumer::CheckIDRequest docs + query_args = [@realm, *@options.values_at(:return_to, :immediate)] + query_args[2] = false if session.key? :setup_needed + pp query_args if $DEBUG + + if oid.send_redirect?(*query_args) + redirect = oid.redirect_url(*query_args) + [ 303, {'Location'=>redirect}, [] ] + else + # check on 'action' option. + formbody = oid.form_markup(*query_args) + body = HTML % ['Confirm...', formbody] + [ 200, {'Content-Type'=>'text/html'}, body.to_a ] + end + rescue ::OpenID::DiscoveryFailure => e + # thrown from inside OpenID::Consumer#begin by yadis stuff + req.env['rack.errors'].puts($!.message, *$@) + + @options. ### Foreign server failed + fetch :auth_fail, [ 503, + {'Content-Type'=>'text/plain'}, + 'Foreign server failure.' ] end - def bad_login - @options. - fetch :bad_login, [401, {'Content-Type'=>'text/plain'},'Identification has failed.'] + + # This is the final portion of authentication. Unless any errors outside + # of specification occur, a 303 redirect will be returned with Location + # determined by the OpenID response type. If none of the response type + # :login_* urls are set, the redirect will be set to + # <tt>session[:openid][:site_return]</tt>. If <tt>session[:openid][:site_return]</tt> is + # unset, the realm will be used. + # + # Any messages from OpenID's response are appended to the 303 response + # body. + # + # * <tt>env['rack.auth.openid.response']</tt> is the openid response. + # + # The four valid possible outcomes are: + # * failure: <tt>options[:login_fail]</tt> or <tt>session[:site_return]</tt> or the realm + # * <tt>session[:openid]</tt> is cleared and any messages are send to rack.errors + # * <tt>session[:openid]['authenticated']</tt> is <tt>false</tt> + # * success: <tt>options[:login_good]</tt> or <tt>session[:site_return]</tt> or the realm + # * <tt>session[:openid]</tt> is cleared + # * <tt>session[:openid]['authenticated']</tt> is <tt>true</tt> + # * <tt>session[:openid]['identity']</tt> is the actual identifier + # * <tt>session[:openid]['identifier']</tt> is the pretty identifier + # * cancel: <tt>options[:login_good]</tt> or <tt>session[:site_return]</tt> or the realm + # * <tt>session[:openid]</tt> is cleared + # * <tt>session[:openid]['authenticated']</tt> is <tt>false</tt> + # * setup_needed: resubmits the authentication request. A flag is set for non-immediate handling. + # * <tt>session[:openid][:setup_needed]</tt> is set to <tt>true</tt>, which will prevent immediate style openid authentication. + def finish(consumer, session, req) + oid = consumer.complete(req.params, req.url) + pp oid if $DEBUG + req.env['rack.auth.openid.response'] = oid + + goto = session.fetch :site_return, @realm + body = [] + + case oid.status + when ::OpenID::Consumer::FAILURE + session.clear + session['authenticated'] = false + req.env['rack.errors'].puts oid.message + + goto = @options[:login_fail] if @option.key? :login_fail + body << "Authentication unsuccessful.\n" + when ::OpenID::Consumer::SUCCESS + session.clear + session['authenticated'] = true + # Value for unique identification and such + session['identity'] = oid.identity_url + # Value for display and UI labels + session['identifier'] = oid.display_identifier + + goto = @options[:login_good] if @options.key? :login_good + body << "Authentication successful.\n" + when ::OpenID::Consumer::CANCEL + session.clear + session['authenticated'] = false + + goto = @options[:login_fail] if @option.key? :login_fail + body << "Authentication cancelled.\n" + when ::OpenID::Consumer::SETUP_NEEDED + session[:setup_needed] = true + unless o_id = session[:openid_param] + raise('Required values missing.') + end + + goto = req.script_name+ + '?'+@options[:openid_param]+ + '='+o_id + body << "Reauthentication required.\n" + end + body << oid.message if oid.message + [ 303, {'Location'=>goto}, body] end end end diff --git a/test/spec_rack_auth_openid.rb b/test/spec_rack_auth_openid.rb new file mode 100644 index 00000000..37d97e19 --- /dev/null +++ b/test/spec_rack_auth_openid.rb @@ -0,0 +1,53 @@ +require 'test/spec' + +# requires the ruby-openid gem +require 'rack/auth/openid' + +context "Rack::Auth::OpenID" do + OID = Rack::Auth::OpenID + realm = 'http://path/arf' + ruri = %w{arf arf/blargh} + auri = ruri.map{|u|'/'+u} + furi = auri.map{|u|'http://path'+u} + + specify 'realm uri should be absolute and have a path' do + lambda{OID.new('/path')}. + should.raise ArgumentError + lambda{OID.new('http://path')}. + should.raise ArgumentError + lambda{OID.new('http://path/')}. + should.not.raise + lambda{OID.new('http://path/arf')}. + should.not.raise + end + + specify 'uri options should be absolute' do + [:login_good, :login_fail, :login_quit, :return_to].each do |param| + ruri.each do |uri| + lambda{OID.new(realm, {param=>uri})}. + should.raise ArgumentError + end + auri.each do |uri| + lambda{OID.new(realm, {param=>uri})}. + should.raise ArgumentError + end + furi.each do |uri| + lambda{OID.new(realm, {param=>uri})}. + should.not.raise + end + end + end + + specify 'return_to should be absolute and be under the realm' do + lambda{OID.new(realm, {:return_to => 'http://path'})}. + should.raise ArgumentError + lambda{OID.new(realm, {:return_to => 'http://path/'})}. + should.raise ArgumentError + lambda{OID.new(realm, {:return_to => 'http://path/arf'})}. + should.not.raise + lambda{OID.new(realm, {:return_to => 'http://path/arf/'})}. + should.not.raise + lambda{OID.new(realm, {:return_to => 'http://path/arf/blargh'})}. + should.not.raise + end +end diff --git a/test/spec_rack_utils.rb b/test/spec_rack_utils.rb index 1bb2e7f7..8256e12f 100644 --- a/test/spec_rack_utils.rb +++ b/test/spec_rack_utils.rb @@ -76,7 +76,6 @@ context "Rack::Utils::Context" do test_app1 = Object.new def test_app1.context app Rack::Utils::Context.new self, app do |env| - p app app.call env end end |