diff options
author | Scytrin dai Kinthra <scytrin@gmail.com> | 2008-06-23 04:25:10 -0700 |
---|---|---|
committer | Scytrin dai Kinthra <scytrin@gmail.com> | 2008-06-23 04:25:10 -0700 |
commit | f3c17573a64baaa381cd11795a3dad532a775665 (patch) | |
tree | aa46fbcb1ebce4506b4f0096302f35760cc9549d | |
parent | 5134bcdd5cd603fe61a1b93a9281e2611c2edb71 (diff) | |
parent | 4420448857b538675b85bef5fb623fc5c14699c6 (diff) | |
download | rack-f3c17573a64baaa381cd11795a3dad532a775665.tar.gz |
Merge branch 'openid2'
-rw-r--r-- | lib/rack.rb | 1 | ||||
-rw-r--r-- | lib/rack/auth/openid2.rb | 335 | ||||
-rw-r--r-- | test/spec_rack_auth_openid2.rb | 53 |
3 files changed, 389 insertions, 0 deletions
diff --git a/lib/rack.rb b/lib/rack.rb index 607d0f5c..9c9bef97 100644 --- a/lib/rack.rb +++ b/lib/rack.rb @@ -53,6 +53,7 @@ module Rack autoload :AbstractRequest, "rack/auth/abstract/request" autoload :AbstractHandler, "rack/auth/abstract/handler" autoload :OpenID, "rack/auth/openid" + autoload :OpenID2, "rack/auth/openid2" module Digest autoload :MD5, "rack/auth/digest/md5" autoload :Nonce, "rack/auth/digest/nonce" diff --git a/lib/rack/auth/openid2.rb b/lib/rack/auth/openid2.rb new file mode 100644 index 00000000..c59e93fa --- /dev/null +++ b/lib/rack/auth/openid2.rb @@ -0,0 +1,335 @@ +# AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net + +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 library from + # janrain to operate, as well as a rack method of session management. + # + # The ruby-openid home page is at <http://openidenabled.com/ruby-openid/>. + # + # The OpenID specifications can be found at + # Mhttp://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/>. + # + # 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. + # + # 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 and 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 OpenID2 < AbstractHandler + class NoSession < RuntimeError; end + # Required for ruby-openid + 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. For example: + # + # simple_oid = OpenID2.new('http://mysite.com/') + # + # return_oid = OpenID2.new('http://mysite.com/', { + # :return_to => 'http://mysite.com/openid' + # }) + # + # page_oid = OpenID2.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 + # + # :return_to 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 :return_to is not provided, the url + # will be derived within the ruby-openid implementation. + # + # :session_key defines the key to the session hash in the env. It + # defaults to 'rack.session'. + # + # :openid_param 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'. + # + # :extensions will specify what extensions are to used with OpenID, + # of which the format and support of which is yet to be completed. + # + # :immediate as true will make immediate type of requests the default. + # See the specification documentation. + # + # -- URL options + # + # :login_good is the url to go to after the authentication process + # has completed. + # + # :login_fail is the url to go to after the authentication process + # has failed. + # + # :login_fail is the url to go to after the authentication process + # has been cancelled. + # + # -- Response options + # + # :no_session should be a rack response to be returned if no or an + # incompatible session is found. + # + # :auth_fail 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. + # + # :error should be a rack response to return if any other generic error + # would occur AND options[:catch_errors] 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::OpenID2" + 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 :openid within the session. It + # sets up the ::OpenID::Consumer using the store specified by + # options[:store]. + # + # If the parameter specified by options[:openid_param] 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 options[:catch_errors] 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 + consumer = ::OpenID::Consumer.new session, @options[:store] + + 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 + rescue NoSession + env['rack.errors'].puts($!.message, *$@) + + @options. ### Missing or incompatible session + fetch :no_session, [ 500, + {'Content-Type'=>'text/plain'}, + $!.message ] + rescue + env['rack.errors'].puts($!.message, *$@) + + if not @options[:catch_error] + raise($!) + end + @options. + fetch :error, [ 500, + {'Content-Type'=>'text/plain'}, + 'OpenID has encountered an error.' ] + end + + # As the first part of OpenID consumer action, #check retrieves the + # data required for completion. + # + # * session[:openid][:openid_param] is the request parameter requested to + # be authenticated. + # * session[:openid][:site_return] is set as the request's HTTP_REFERER + # if previously unset. + # * env['rack.auth.openid.request'] is the openid checkidrequest. + 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 + + # 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 + # session[:openid][:site_return]. If session[:openid][:site_return] is + # unset, the realm will be used. + # + # Any messages from OpenID's response are appended to the 303 response + # body. + # + # * env['rack.auth.openid.response'] is the openid response. + # + # The four valid possible outcomes are: + # * failure: options[:login_fail] or session[:site_return] or the realm + # * session[:openid] is cleared and any messages are send to rack.errors + # * session[:openid]['authenticated'] is false + # * success: options[:login_good] or session[:site_return] or the relam + # * session[:openid] is cleared + # * session[:openid]['authenticated'] is true + # * session[:openid]['identity'] is the actual identifier + # * session[:openid]['identifier'] is the pretty identifier + # * cancel: options[:login_quit] or session[:site_return] or the realm + # * session[:openid] is cleared + # * session[:openid]['authenticated'] is false + # * setup_needed: resubmits the authentication request. A flag is set + # for non-immediate handling. + # * session[:openid][:setup_needed] is set to true, 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 +end diff --git a/test/spec_rack_auth_openid2.rb b/test/spec_rack_auth_openid2.rb new file mode 100644 index 00000000..2fd361ac --- /dev/null +++ b/test/spec_rack_auth_openid2.rb @@ -0,0 +1,53 @@ +require 'test/spec' + +# requires the ruby-openid gem +require 'rack/auth/openid2' + +context "Rack::Auth::OpenID2" do + OID2 = Rack::Auth::OpenID2 + 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{OID2.new('/path')}. + should.raise ArgumentError + lambda{OID2.new('http://path')}. + should.raise ArgumentError + lambda{OID2.new('http://path/')}. + should.not.raise + lambda{OID2.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{OID2.new(realm, {param=>uri})}. + should.raise ArgumentError + end + auri.each do |uri| + lambda{OID2.new(realm, {param=>uri})}. + should.raise ArgumentError + end + furi.each do |uri| + lambda{OID2.new(realm, {param=>uri})}. + should.not.raise + end + end + end + + specify 'return_to should be absolute and be under the realm' do + lambda{OID2.new(realm, {:return_to => 'http://path'})}. + should.raise ArgumentError + lambda{OID2.new(realm, {:return_to => 'http://path/'})}. + should.raise ArgumentError + lambda{OID2.new(realm, {:return_to => 'http://path/arf'})}. + should.not.raise + lambda{OID2.new(realm, {:return_to => 'http://path/arf/'})}. + should.not.raise + lambda{OID2.new(realm, {:return_to => 'http://path/arf/blargh'})}. + should.not.raise + end +end |