rack-cas 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.markdown CHANGED
@@ -11,10 +11,12 @@ Works with but doesn't depend on Rails, Sinatra, etc.
11
11
  Current gem dependencies are [rack](http://rubygems.org/gems/rack), [addressable](http://rubygems.org/gems/addressable) and [nokogiri](http://rubygems.org/gems/nokogiri).
12
12
  * __Supports CAS extra attributes__
13
13
  Extra attributes are a mess though. So let me know if your brand of CAS server isn't supported.
14
+ * __Single sign out__
15
+ One of the included session stores must be used.
14
16
 
15
17
  Coming Soon
16
18
  ===========
17
- * __Single sign out__
19
+ * __Single sign out compatible session store for Active Record__
18
20
 
19
21
  Requirements
20
22
  ============
@@ -35,6 +37,28 @@ Then in your `config.ru` file add
35
37
  require 'rack/cas'
36
38
  use Rack::CAS, server_url: 'https://login.example.com/cas'
37
39
 
40
+ Single Sign Out
41
+ ---------------
42
+ Support for [single sign out](https://wiki.jasig.org/display/CASUM/Single+Sign+Out) requires the use of one of the included session stores listed below.
43
+
44
+ * Mongoid
45
+
46
+ To use the session store with Rails add the following to your `config/initializers/session_store.rb` file:
47
+
48
+ require 'rack-cas/session_store/rails/mongoid'
49
+ YourApp::Application.config.session_store :mongoid_store
50
+
51
+ For other Rack-compatible frameworks, add the following to your config.ru file:
52
+
53
+ requre 'rack-cas/sessions_store/rack/mongoid'
54
+ use Rack::Session::MongoidStore
55
+
56
+ Then tell the RackCAS where to find your sessions:
57
+
58
+ require 'rack/cas'
59
+ require 'rack-cas/session_store/mongoid'
60
+ use Rack::CAS server_url: 'http://login.example.com/cas', session_store: RackCAS:MongoidStore
61
+
38
62
  Integration
39
63
  ===========
40
64
  Your app should __return a [401 status](http://httpstatus.es/401)__ whenever a request is made that requires authentication. Rack-CAS will catch these responses and attempt to authenticate via your CAS server.
@@ -44,4 +68,31 @@ Once authentication with the CAS server has completed, Rack-CAS will set the fol
44
68
  request.session['cas']['user'] #=> johndoe
45
69
  request.session['cas']['extra_attributes'] #=> { 'first_name' => 'John', 'last_name' => ... }
46
70
 
47
- __NOTE:__ `extra_attributes` will be an empty hash unless they've been [configured on your CAS server](http://code.google.com/p/rubycas-server/wiki/HowToSendExtraUserAttributes).
71
+ __NOTE:__ `extra_attributes` will be an empty hash unless they've been [configured on your CAS server](http://code.google.com/p/rubycas-server/wiki/HowToSendExtraUserAttributes).
72
+
73
+ Testing
74
+ =======
75
+
76
+ Controller Tests
77
+ ----------------
78
+ Testing your controllers and such should be as simple as setting the session variables manually in a helper.
79
+
80
+ def set_current_user(user)
81
+ session['cas'] = { 'user' => user.username, 'extra_attributes' => {} }
82
+ end
83
+
84
+ Integration Tests
85
+ -----------------
86
+ Integration testing using something like [Capybara](http://jnicklas.github.com/capybara/) is a bit trickier because the session can't be manipulated directly. So for integration tests, I recommend using the provided `Rack::FakeCAS` middleware instead of `Rack::CAS`.
87
+
88
+ require 'rack/fake_cas'
89
+ use Rack::FakeCAS
90
+
91
+ Then you can simply do the following in your integration tests in order to log in.
92
+
93
+ visit '/restricted_path'
94
+ fill_in 'username', with: 'johndoe'
95
+ fill_in 'password', with: 'any password'
96
+ click_button 'Login'
97
+
98
+ __NOTE:__ The FakeCAS middleware will authenticate any username with any password and so should never be used in production.
data/lib/rack/cas.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'rack'
2
2
  require 'addressable/uri'
3
3
  require 'rack-cas/server'
4
+ require 'rack-cas/cas_request'
4
5
 
5
6
  class Rack::CAS
6
7
  attr_accessor :server_url
@@ -8,29 +9,47 @@ class Rack::CAS
8
9
  def initialize(app, config={})
9
10
  @app = app
10
11
  @server_url = config.delete(:server_url)
12
+ @session_store = config.delete(:session_store)
11
13
  @config = config
12
14
 
13
15
  raise ArgumentError, 'server_url is required' if @server_url.nil?
16
+ if @session_store && !@session_store.respond_to?(:destroy_session_by_cas_ticket)
17
+ raise ArgumentError, 'session_store does not support single-sign-out'
18
+ end
14
19
  end
15
20
 
16
21
  def call(env)
17
22
  request = Rack::Request.new(env)
23
+ cas_request = CASRequest.new(request)
24
+
25
+ if cas_request.ticket_validation?
26
+ log env, 'rack-cas: Intercepting ticket validation request.'
18
27
 
19
- if ticket_validation_request?(request)
20
- user, extra_attrs = get_user(request)
28
+ user, extra_attrs = get_user(request.url, cas_request.ticket)
21
29
 
22
- store_session request, user, extra_attrs
23
- return redirect_to ticketless_url(request)
30
+ store_session request, user, cas_request.ticket, extra_attrs
31
+ return redirect_to cas_request.service_url
24
32
  end
25
33
 
26
- if logout_request?(request)
34
+ if cas_request.logout?
35
+ log env, 'rack-cas: Intercepting logout request.'
36
+
27
37
  request.session.clear
28
38
  return redirect_to server.logout_url.to_s
29
39
  end
30
40
 
41
+ if cas_request.single_sign_out? && @session_store
42
+ log env, 'rack-cas: Intercepting single-sign-out request.'
43
+
44
+ @session_store.destroy_session_by_cas_ticket(cas_request.ticket)
45
+ return [200, {'Content-Type' => 'text/plain'}, ['CAS Single-Sign-Out request intercepted.']]
46
+ end
47
+
31
48
  response = @app.call(env)
32
49
 
33
- if access_denied_response?(response)
50
+ if response[0] == 401 # access denied
51
+ log env, 'rack-cas: Intercepting 401 access denied response. Redirecting to CAS login.'
52
+
34
53
  redirect_to server.login_url(request.url).to_s
35
54
  else
36
55
  response
@@ -43,37 +62,23 @@ class Rack::CAS
43
62
  @server ||= RackCAS::Server.new(@server_url)
44
63
  end
45
64
 
46
- def logout_request?(request)
47
- request.path_info == '/logout'
48
- end
49
-
50
- def ticket_validation_request?(request)
51
- !get_ticket(request).nil?
52
- end
53
-
54
- def access_denied_response?(response)
55
- response[0] == 401
65
+ def get_user(service_url, ticket)
66
+ server.validate_service(service_url, ticket)
56
67
  end
57
68
 
58
- def ticketless_url(request)
59
- RackCAS::URL.parse(request.url).remove_param('ticket').to_s
60
- end
61
-
62
- def get_ticket(request)
63
- request.params['ticket']
64
- end
65
-
66
- def get_user(request)
67
- server.validate_service(request.url, get_ticket(request))
68
- end
69
-
70
- def store_session(request, user, extra_attrs = {})
71
- request.session['cas'] = {}
72
- request.session['cas']['user'] = user
73
- request.session['cas']['extra_attributes'] = extra_attrs
69
+ def store_session(request, user, ticket, extra_attrs = {})
70
+ request.session['cas'] = { 'user' => user, 'ticket' => ticket, 'extra_attributes' => extra_attrs }
74
71
  end
75
72
 
76
73
  def redirect_to(url, status=302)
77
74
  [ status, { 'Location' => url, 'Content-Type' => 'text/plain' }, ["Redirecting you to #{url}"] ]
78
75
  end
76
+
77
+ def log(env, message, level = :info)
78
+ if env['rack.logger']
79
+ env['rack-logger'].send(level, message)
80
+ else
81
+ env['rack.errors'].write(message)
82
+ end
83
+ end
79
84
  end
@@ -0,0 +1,61 @@
1
+ require 'rack'
2
+
3
+ class Rack::FakeCAS
4
+ def initialize(app, config={})
5
+ @app = app
6
+ @config = config
7
+ end
8
+
9
+ def call(env)
10
+ @request = Rack::Request.new(env)
11
+
12
+ case @request.path_info
13
+ when '/login'
14
+ @request.session['cas'] = {}
15
+ @request.session['cas']['user'] = @request.params['username']
16
+ @request.session['cas']['extra_attributes'] = {}
17
+ redirect_to @request.params['service']
18
+
19
+ when '/logout'
20
+ @request.session.clear
21
+ redirect_to "#{@request.script_name}/"
22
+
23
+ else
24
+ response = @app.call(env)
25
+
26
+ if response[0] == 401 # access denied
27
+ [ 200, { 'Content-Type' => 'text/html' }, [login_page] ]
28
+ else
29
+ response
30
+ end
31
+ end
32
+ end
33
+
34
+ protected
35
+
36
+ def login_page
37
+ <<-EOS
38
+ <!doctype html>
39
+ <html lang="en">
40
+ <head>
41
+ <meta charset="utf-8"/>
42
+ <title>Fake CAS</title>
43
+ </head>
44
+ <body>
45
+ <form action="#{@request.script_name}/login" method="post">
46
+ <input type="hidden" name="service" value="#{@request.url}"/>
47
+ <label for="username">Username</label>
48
+ <input id="username" name="username" type="text"/>
49
+ <label for="password">Password</label>
50
+ <input id="password" name="password" type="password"/>
51
+ <input type="submit" value="Login"/>
52
+ </form>
53
+ </body>
54
+ </html>
55
+ EOS
56
+ end
57
+
58
+ def redirect_to(url)
59
+ [ 302, { 'Content-Type' => 'text/plain', 'Location' => url }, ['Redirecting you...'] ]
60
+ end
61
+ end
@@ -0,0 +1,35 @@
1
+ require 'nokogiri'
2
+
3
+ class CASRequest
4
+ def initialize(request)
5
+ @request = request
6
+ end
7
+
8
+ def ticket
9
+ @ticket ||= if single_sign_out?
10
+ xml = Nokogiri::XML(@request.params['logoutRequest']).tap do |xml|
11
+ xml.remove_namespaces!
12
+ end
13
+ node = xml.at('/LogoutRequest/SessionIndex')
14
+ node.text unless node.nil?
15
+ else
16
+ @request.params['ticket']
17
+ end
18
+ end
19
+
20
+ def service_url
21
+ RackCAS::URL.parse(@request.url).remove_param('ticket').to_s
22
+ end
23
+
24
+ def logout?
25
+ @request.path_info == '/logout'
26
+ end
27
+
28
+ def single_sign_out?
29
+ !!@request.params['logoutRequest']
30
+ end
31
+
32
+ def ticket_validation?
33
+ !!@request.params['ticket']
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ module RackCAS
2
+ module MongoStore
3
+ def collection
4
+ @collection
5
+ end
6
+
7
+ def initialize(app, options = {})
8
+ require 'mongo'
9
+
10
+ unless options[:collection]
11
+ raise "To avoid creating multiple connections to MongoDB, " +
12
+ "the Mongo Session Store will not create it's own connection " +
13
+ "to MongoDB - you must pass in a collection with the :collection option"
14
+ end
15
+
16
+ @collection = options[:collection].respond_to?(:call) ? options[:collection].call : options[:collection]
17
+
18
+ super
19
+ end
20
+
21
+ private
22
+ def get_session(env, sid)
23
+ sid ||= generate_sid
24
+ session = collection.find(_id: sid).first || {}
25
+ [sid, unpack(session['data'])]
26
+ end
27
+
28
+ def set_session(env, sid, session_data, options = {})
29
+ sid ||= generate_sid
30
+ collection.update({'_id' => sid},
31
+ {'_id' => sid, 'data' => pack(session_data), 'updated_at' => Time.now},
32
+ upsert: true)
33
+ sid # TODO: return boolean, right?
34
+ end
35
+
36
+ def pack(data)
37
+ [Marshal.dump(data)].pack('m*')
38
+ end
39
+
40
+ def unpack(packed)
41
+ return nil unless packed
42
+ Marshal.load(packed.unpack('m*').first)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ module RackCAS
2
+ module MongoidStore
3
+ class Session
4
+ include Mongoid::Document
5
+ include Mongoid::Timestamps
6
+
7
+ field :_id, type: String
8
+ field :data, type: Moped::BSON::Binary, :default => Moped::BSON::Binary.new(:generic,Marshal.dump({}))
9
+ field :cas_ticket, type: String
10
+
11
+ attr_accessible :_id, :data, :cas_ticket
12
+ end
13
+
14
+ def self.destroy_session_by_cas_ticket(cas_ticket)
15
+ affected = Session.where(cas_ticket: cas_ticket).delete
16
+ affected == 1
17
+ end
18
+
19
+ private
20
+
21
+ def get_session(env, sid)
22
+ if sid.nil?
23
+ sid = generate_sid
24
+ data = nil
25
+ else
26
+ session = Session.where(_id: sid).first || {}
27
+ data = unpack(session['data'])
28
+ end
29
+
30
+ [sid, data]
31
+ end
32
+
33
+ def set_session(env, sid, session_data, options)
34
+ cas_ticket = (session_data['cas']['ticket'] unless session_data['cas'].nil?)
35
+
36
+ session = Session.find_or_initialize_by(_id: sid)
37
+ success = session.update_attributes(data: pack(session_data), cas_ticket: cas_ticket)
38
+
39
+ success ? session.id : false
40
+ end
41
+
42
+ def destroy_session(env, sid, options)
43
+ session = Session.where(_id: sid).delete
44
+
45
+ options[:drop] ? nil : generate_sid
46
+ end
47
+
48
+ def pack(data)
49
+ Moped::BSON::Binary.new(:generic,Marshal.dump(data))
50
+ end
51
+
52
+ def unpack(packed)
53
+ return nil unless packed
54
+ Marshal.load(StringIO.new(packed.to_s))
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,10 @@
1
+ require 'rack-cas/session_store/mongo'
2
+ require 'rack/session/abstract/id'
3
+
4
+ module Rack
5
+ module Session
6
+ class MongoStore < Rack::Session::Abstract::ID
7
+ include RackCAS::MongoStore
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'rack-cas/session_store/mongoid'
2
+ require 'rack/session/abstract/id'
3
+
4
+ module Rack
5
+ module Session
6
+ class MongoidStore < Rack::Session::Abstract::ID
7
+ include RackCAS::MongoidStore
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'rack-cas/session_store/mongo'
2
+ require 'action_dispatch/middleware/session/abstract_store'
3
+
4
+ module ActionDispatch
5
+ module Session
6
+ class MongoStore < AbstractStore
7
+ include RackCAS::MongoStore
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require 'rack-cas/session_store/mongoid'
2
+ require 'action_dispatch/middleware/session/abstract_store'
3
+
4
+ module ActionDispatch
5
+ module Session
6
+ class MongoidStore < AbstractStore
7
+ include RackCAS::MongoidStore
8
+ end
9
+ end
10
+ end
@@ -1,3 +1,3 @@
1
1
  module RackCAS
2
- VERSION = '0.1.0'
3
- end
2
+ VERSION = '0.2.0'
3
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-cas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-10-11 00:00:00.000000000 Z
12
+ date: 2012-10-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -70,8 +70,16 @@ files:
70
70
  - lib/rack-cas.rb
71
71
  - lib/rack-cas/url.rb
72
72
  - lib/rack-cas/version.rb
73
+ - lib/rack-cas/session_store/rails/mongo.rb
74
+ - lib/rack-cas/session_store/rails/mongoid.rb
75
+ - lib/rack-cas/session_store/mongo.rb
76
+ - lib/rack-cas/session_store/rack/mongo.rb
77
+ - lib/rack-cas/session_store/rack/mongoid.rb
78
+ - lib/rack-cas/session_store/mongoid.rb
73
79
  - lib/rack-cas/service_validation_response.rb
74
80
  - lib/rack-cas/server.rb
81
+ - lib/rack-cas/cas_request.rb
82
+ - lib/rack/fake_cas.rb
75
83
  - lib/rack/cas.rb
76
84
  homepage: https://github.com/biola/rack-cas
77
85
  licenses: []