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 +53 -2
- data/lib/rack/cas.rb +37 -32
- data/lib/rack/fake_cas.rb +61 -0
- data/lib/rack-cas/cas_request.rb +35 -0
- data/lib/rack-cas/session_store/mongo.rb +45 -0
- data/lib/rack-cas/session_store/mongoid.rb +57 -0
- data/lib/rack-cas/session_store/rack/mongo.rb +10 -0
- data/lib/rack-cas/session_store/rack/mongoid.rb +10 -0
- data/lib/rack-cas/session_store/rails/mongo.rb +10 -0
- data/lib/rack-cas/session_store/rails/mongoid.rb +10 -0
- data/lib/rack-cas/version.rb +2 -2
- metadata +10 -2
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
|
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
|
-
|
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
|
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
|
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
|
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
|
47
|
-
|
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
|
59
|
-
|
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
|
data/lib/rack-cas/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
1
|
module RackCAS
|
2
|
-
VERSION = '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.
|
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-
|
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: []
|