classy_cas 0.9.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.textile +160 -0
- data/config.ru +6 -0
- data/lib/classy_cas.rb +231 -0
- data/lib/login_ticket.rb +34 -0
- data/lib/proxy_granting_ticket.rb +31 -0
- data/lib/proxy_ticket.rb +35 -0
- data/lib/service_ticket.rb +46 -0
- data/lib/strategies.rb +3 -0
- data/lib/strategies/base.rb +26 -0
- data/lib/strategies/devise_database.rb +65 -0
- data/lib/strategies/simple.rb +19 -0
- data/lib/ticket_granting_ticket.rb +36 -0
- data/lib/views/layout.erb +66 -0
- data/lib/views/logged_in.erb +1 -0
- data/lib/views/login.erb +55 -0
- data/public/images/application_edit.png +0 -0
- data/public/images/arrow.png +0 -0
- data/public/images/avatar.png +0 -0
- data/public/images/boxbar-background.png +0 -0
- data/public/images/breadcrumb.png +0 -0
- data/public/images/button-background-active.png +0 -0
- data/public/images/button-background.png +0 -0
- data/public/images/cross.png +0 -0
- data/public/images/key.png +0 -0
- data/public/images/logo-new2.jpg +0 -0
- data/public/images/menubar-background.png +0 -0
- data/public/images/search-button.png +0 -0
- data/public/images/tick.png +0 -0
- data/public/images/tipsy.gif +0 -0
- data/public/javascripts/jquery-1.3.min.js +19 -0
- data/public/javascripts/jquery.localscroll.js +104 -0
- data/public/javascripts/jquery.scrollTo.js +150 -0
- data/public/stylesheets/base.css +397 -0
- data/public/stylesheets/drastic-dark.css +462 -0
- metadata +239 -0
data/README.textile
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
h1. Classy CAS
|
2
|
+
|
3
|
+
Single sign-on server based on the "CAS protocol":http://www.jasig.org/cas/protocol and implemented in Sinatra.
|
4
|
+
|
5
|
+
* Single sign-on server
|
6
|
+
* "CAS protocol":http://www.jasig.org/cas/protocol compliant
|
7
|
+
* Implemented with "Sinatra":http://www.sinatrarb.com/
|
8
|
+
* Uses "Redis":http://code.google.com/p/redis/
|
9
|
+
* Pairs great with "OmniAuth":https://github.com/intridea/omniauth "CAS client":https://github.com/intridea/omniauth/tree/master/oa-enterprise
|
10
|
+
* Supports any "CAS protocol":http://www.jasig.org/cas/protocol compliant client from any language, framework, or platform (PHP, .NET, Java)
|
11
|
+
|
12
|
+
|
13
|
+
On the client side, ClassyCAS pairs up nicely with "OmniAuth":https://github.com/intridea/omniauth and it's "CAS client implementation":https://github.com/intridea/omniauth/tree/master/oa-enterprise . However clients are not only limited to either RubyonRails or Sinatra apps, because the server is built on the "CAS protocol":http://www.jasig.org/cas/protocol any compliant client in any language or framework which adheres to the protocol is supported, thus ClassyCAS is also well to suited to environments where Single sign-on is desired but where the ecosystem of applications is heterogeneous in terms of platforms.
|
14
|
+
|
15
|
+
h2. Demo on Heroku
|
16
|
+
|
17
|
+
* "Site 1 Protected Content":http://casclientone.heroku.com/
|
18
|
+
* "Site 2 Protected Content":http://casclienttwo.heroku.com/
|
19
|
+
* "ClassyCAS Server":http://classycas.heroku.com/
|
20
|
+
|
21
|
+
Username is "test", password is "password".
|
22
|
+
|
23
|
+
|
24
|
+
h2. Quick Start Demo
|
25
|
+
|
26
|
+
<ol>
|
27
|
+
<li>Download and install Redis:"http://code.google.com/p/redis/"
|
28
|
+
(Feel free to also use homebrew if you're on a mac)
|
29
|
+
<pre>
|
30
|
+
<code>
|
31
|
+
curl -O http://redis.googlecode.com/files/redis-2.0.4.tar.gz
|
32
|
+
tar xvzf redis-2.0.4.tar.gz
|
33
|
+
cd redis-2.0.4
|
34
|
+
make
|
35
|
+
sudo cp redis-server redis-cli redis-benchmark /usr/local/bin
|
36
|
+
</code>
|
37
|
+
</pre></li>
|
38
|
+
|
39
|
+
<li>Install the first rails sign-on client example
|
40
|
+
<pre>
|
41
|
+
<code>
|
42
|
+
git clone git@github.com:Econify/classy_cas_client_example.git first_client
|
43
|
+
cd first_client
|
44
|
+
bundle install
|
45
|
+
</code>
|
46
|
+
</pre></li>
|
47
|
+
|
48
|
+
<li>Install a second rails sign-on client example
|
49
|
+
<pre>
|
50
|
+
<code>
|
51
|
+
git clone git@github.com:Econify/classy_cas_client_example.git second_client
|
52
|
+
cd second_client
|
53
|
+
bundle install
|
54
|
+
</code>
|
55
|
+
</pre></li>
|
56
|
+
|
57
|
+
<li>Open the second rails sign-on client in a text editor and navigate to config/config.yml, edit the file to look like this:
|
58
|
+
<pre>
|
59
|
+
<code>
|
60
|
+
development:
|
61
|
+
#first or second
|
62
|
+
site_name: second
|
63
|
+
other_site_url: http://0.0.0.0:3000</code>
|
64
|
+
</pre></li>
|
65
|
+
|
66
|
+
<li>Install ClassyCAS
|
67
|
+
<pre>
|
68
|
+
<code>
|
69
|
+
git clone git://github.com/Econify/ClassyCAS.git
|
70
|
+
bundle install
|
71
|
+
</code>
|
72
|
+
</pre></li>
|
73
|
+
|
74
|
+
<li>Run it all together
|
75
|
+
In your current terminal tab, start Redis in the background:
|
76
|
+
<pre>
|
77
|
+
<code>
|
78
|
+
redis-server
|
79
|
+
</code>
|
80
|
+
</pre></li>
|
81
|
+
|
82
|
+
<li>Start the first client: open up a new tab, navigate to the first client app directory and start the app
|
83
|
+
<pre>
|
84
|
+
<code>
|
85
|
+
ruby script/server
|
86
|
+
</code>
|
87
|
+
</pre></li>
|
88
|
+
|
89
|
+
<li>Same thing for the second client but this time assign it to a different port
|
90
|
+
<pre>
|
91
|
+
<code>
|
92
|
+
ruby script/server -p 3001
|
93
|
+
</code>
|
94
|
+
</pre></li>
|
95
|
+
|
96
|
+
<li>Open up yet another terminal tab (you should have four open now) and start ClassyCAS:
|
97
|
+
<pre>
|
98
|
+
<code>
|
99
|
+
shotgun config.ru
|
100
|
+
</code>
|
101
|
+
</pre></li>
|
102
|
+
</ol>
|
103
|
+
|
104
|
+
# Navigate to the first app in your browser
|
105
|
+
# Click on the login link, and you'll be redirected to ClassyCAS
|
106
|
+
# Login successfully with any username and password
|
107
|
+
# You should be redirect back to the first site's protected area and you'll see a picture of a cow.
|
108
|
+
# From the first site you'll see a link to take you to the other site, you won't see it but you are actually redirected by the second site to ClassyCAS for authentication (which does it via a session cookie) and then sent back to the second site where you'll see a picture of an eagle.
|
109
|
+
# Click to logout from either site, you'll be taken back to ClassyCAS, navigate back to both and you should see that you are logged out from both.
|
110
|
+
|
111
|
+
h2. User Authentication
|
112
|
+
|
113
|
+
ClassyCAS is designed to a provide a vehicle for Single sign-on to multiple client apps and doesn't concern itself with the intricacies of authentication. In other words, it's up to you to roll your own authentication. For authentication ClassyCAS makes a call to a class called UserStore which has one method authenticate. It's up to you how you want to implement authenticate, included in the same folder is an example of authenticate implemented to hit a rails app remotely through REST and getting it's authentication from that app (which in this example does it with Devise.) You can do it many ways and here are some ideas:
|
114
|
+
|
115
|
+
1) Remotely via REST to a devise app
|
116
|
+
2) Locally via Datamapper or Activerecord 3.0 class.
|
117
|
+
3) Key value pairs in a config file
|
118
|
+
4) Whatever you can think of.
|
119
|
+
|
120
|
+
h2. Logout
|
121
|
+
|
122
|
+
The CAS protocol doesn't really specify whether logging you out of one client should log you out of all clients but we thought that made the most sense and so we wrote that into ClassyCAS. Upon logging out a user is redirect to ClassyCAS which renders non-visible iframes that log the user out of all the apps. There are two things to be aware of to make this work for you:
|
123
|
+
|
124
|
+
# The iframes are located in the login template, views/login.erb.
|
125
|
+
# You'll need to modify the url's of the iframes in the template to GET the logout method of the client app.
|
126
|
+
# There is a config for the client apps in config/classy_cas.yml that let's tell ClassyCAS which clients need to be logged out.
|
127
|
+
|
128
|
+
h2. Client Callback
|
129
|
+
|
130
|
+
After a user successfully logs into ClassyCAS they are redirected back to the client. The url they are redirected to must be sent to ClassyCAS in the form of a parameter that is part of the initial login redirect to ClassyCAS.
|
131
|
+
|
132
|
+
For example:
|
133
|
+
|
134
|
+
<pre>
|
135
|
+
<code>
|
136
|
+
http://127.0.0.1:9393/login?service=http://0.0.0.0:3000/auth/cas/callback
|
137
|
+
</code>
|
138
|
+
</pre>
|
139
|
+
|
140
|
+
This url is for the "client demo app example":https://github.com/Econify/classy_cas_client_example.git, it could very be the href for a login link tag. A Callback url is the service parameter in this case http://0.0.0.0:3000/auth/cas/callback, this callback url can be anything you want simply by change the service parameter on the initial call to ClassyCAS.
|
141
|
+
|
142
|
+
|
143
|
+
h2. What's There
|
144
|
+
|
145
|
+
* "Sinatra":http://www.sinatrarb.com/ based. Classy.
|
146
|
+
* Uses "Redis":http://code.google.com/p/redis/ to store tickets. Fast!
|
147
|
+
* Lots of tests. The whole protocol isn't there yet, but "this test":http://github.com/AndrewO/ClassyCAS/blob/master/test/protocol/cas_server_test.rb is a good start on an executable spec for CAS 1.0/2.0.
|
148
|
+
|
149
|
+
h2. What's Missing
|
150
|
+
|
151
|
+
* Proxy authentication.
|
152
|
+
|
153
|
+
h2. Resources
|
154
|
+
|
155
|
+
* "ClassyCAS client example app":https://github.com/Econify/classy_cas_client_example
|
156
|
+
* "CAS":http://www.jasig.org/cas
|
157
|
+
* "Sinatra":http://www.sinatrarb.com/
|
158
|
+
* "Redis":http://code.google.com/p/redis/
|
159
|
+
* "OmniAuth":https://github.com/intridea/omniauth
|
160
|
+
* "OmniAuth CAS":https://github.com/intridea/omniauth/tree/master/oa-enterprise
|
data/config.ru
ADDED
data/lib/classy_cas.rb
ADDED
@@ -0,0 +1,231 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler'
|
3
|
+
Bundler.require
|
4
|
+
# Bundler.require doesn't seem to be pulling this in when used as gem...
|
5
|
+
require 'sinatra'
|
6
|
+
require 'redis'
|
7
|
+
require 'nokogiri'
|
8
|
+
require 'rack'
|
9
|
+
require 'rack-flash'
|
10
|
+
require 'warden'
|
11
|
+
|
12
|
+
if RUBY_VERSION < "1.9"
|
13
|
+
require 'backports'
|
14
|
+
require 'system_timer'
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'addressable/uri'
|
18
|
+
|
19
|
+
require_relative 'login_ticket'
|
20
|
+
require_relative 'proxy_ticket'
|
21
|
+
require_relative 'service_ticket'
|
22
|
+
require_relative 'ticket_granting_ticket'
|
23
|
+
require_relative 'strategies'
|
24
|
+
|
25
|
+
module ClassyCAS
|
26
|
+
class Server < Sinatra::Base
|
27
|
+
set :redis, Proc.new { Redis.new } unless settings.respond_to?(:redis)
|
28
|
+
set :client_sites, [ "http://localhost:3001", 'http://localhost:3002'] unless settings.respond_to?(:client_sites)
|
29
|
+
|
30
|
+
set :root, File.dirname(__FILE__)
|
31
|
+
set :public, File.join(root, "/../public")
|
32
|
+
|
33
|
+
set :warden_strategies, [:simple] unless settings.respond_to?(:warden_strategies)
|
34
|
+
|
35
|
+
use Rack::Session::Cookie
|
36
|
+
use Rack::Flash, :accessorize => [:notice, :error]
|
37
|
+
use Warden::Manager do |manager|
|
38
|
+
manager.failure_app = self
|
39
|
+
manager.default_scope = :cas
|
40
|
+
|
41
|
+
manager.scope_defaults(:cas,
|
42
|
+
:strategies => settings.warden_strategies,
|
43
|
+
:action => "login"
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
configure :development do
|
48
|
+
set :dump_errors
|
49
|
+
end
|
50
|
+
|
51
|
+
get "/" do
|
52
|
+
redirect "/login"
|
53
|
+
end
|
54
|
+
|
55
|
+
get "/login" do
|
56
|
+
@service_url = Addressable::URI.parse(params[:service])
|
57
|
+
@renew = [true, "true", "1", 1].include?(params[:renew])
|
58
|
+
@gateway = [true, "true", "1", 1].include?(params[:gateway])
|
59
|
+
|
60
|
+
if @renew
|
61
|
+
@login_ticket = LoginTicket.create!(settings.redis)
|
62
|
+
render_login
|
63
|
+
elsif @gateway
|
64
|
+
if @service_url
|
65
|
+
if sso_session
|
66
|
+
st = ServiceTicket.new(params[:service], sso_session.username)
|
67
|
+
st.save!(settings.redis)
|
68
|
+
redirect_url = @service_url.clone
|
69
|
+
if @service_url.query_values.nil?
|
70
|
+
redirect_url.query_values = @service_url.query_values = {:ticket => st.ticket}
|
71
|
+
else
|
72
|
+
redirect_url.query_values = @service_url.query_values.merge(:ticket => st.ticket)
|
73
|
+
end
|
74
|
+
redirect redirect_url.to_s, 303
|
75
|
+
else
|
76
|
+
redirect @service_url.to_s, 303
|
77
|
+
end
|
78
|
+
else
|
79
|
+
@login_ticket = LoginTicket.create!(settings.redis)
|
80
|
+
render_login
|
81
|
+
end
|
82
|
+
else
|
83
|
+
if sso_session
|
84
|
+
if @service_url
|
85
|
+
st = ServiceTicket.new(params[:service], sso_session.username)
|
86
|
+
st.save!(settings.redis)
|
87
|
+
redirect_url = @service_url.clone
|
88
|
+
if @service_url.query_values.nil?
|
89
|
+
redirect_url.query_values = @service_url.query_values = {:ticket => st.ticket}
|
90
|
+
else
|
91
|
+
redirect_url.query_values = @service_url.query_values.merge(:ticket => st.ticket)
|
92
|
+
end
|
93
|
+
redirect redirect_url.to_s, 303
|
94
|
+
else
|
95
|
+
render_logged_in
|
96
|
+
end
|
97
|
+
else
|
98
|
+
@login_ticket = LoginTicket.create!(settings.redis)
|
99
|
+
render_login
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
post "/login" do
|
105
|
+
username = params[:username]
|
106
|
+
password = params[:password]
|
107
|
+
|
108
|
+
service_url = params[:service]
|
109
|
+
|
110
|
+
warn = [true, "true", "1", 1].include? params[:warn]
|
111
|
+
# Spec is undefined about what to do without these params, so redirecting to credential requestor
|
112
|
+
redirect "/login", 303 unless username && password && login_ticket
|
113
|
+
# Failures will throw back to self, which we've registered with Warden to handle login failures
|
114
|
+
warden.authenticate!(:scope => :cas, :action => 'unauthenticated')
|
115
|
+
|
116
|
+
tgt = TicketGrantingTicket.new(username)
|
117
|
+
tgt.save!(settings.redis)
|
118
|
+
cookie = tgt.to_cookie(request.host)
|
119
|
+
response.set_cookie(*cookie)
|
120
|
+
|
121
|
+
if service_url && !warn
|
122
|
+
st = ServiceTicket.new(service_url, username)
|
123
|
+
st.save!(settings.redis)
|
124
|
+
redirect service_url + "?ticket=#{st.ticket}", 303
|
125
|
+
else
|
126
|
+
render_logged_in
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
get %r{(proxy|service)Validate} do
|
131
|
+
service_url = params[:service]
|
132
|
+
ticket = params[:ticket]
|
133
|
+
# proxy_gateway = params[:pgtUrl]
|
134
|
+
# renew = params[:renew]
|
135
|
+
|
136
|
+
xml = if service_url && ticket
|
137
|
+
if service_ticket
|
138
|
+
if service_ticket.valid_for_service?(service_url)
|
139
|
+
render_validation_success service_ticket.username
|
140
|
+
else
|
141
|
+
render_validation_error(:invalid_service)
|
142
|
+
end
|
143
|
+
else
|
144
|
+
render_validation_error(:invalid_ticket, "ticket #{ticket} not recognized")
|
145
|
+
end
|
146
|
+
else
|
147
|
+
render_validation_error(:invalid_request)
|
148
|
+
end
|
149
|
+
|
150
|
+
content_type :xml
|
151
|
+
xml
|
152
|
+
end
|
153
|
+
|
154
|
+
|
155
|
+
get '/logout' do
|
156
|
+
url = params[:url]
|
157
|
+
|
158
|
+
if sso_session
|
159
|
+
@sso_session.destroy!(settings.redis)
|
160
|
+
response.delete_cookie(*sso_session.to_cookie(request.host))
|
161
|
+
warden.logout(:cas)
|
162
|
+
flash.now[:notice] = "Logout Successful."
|
163
|
+
if url
|
164
|
+
msg = " The application you just logged out of has provided a link it would like you to follow."
|
165
|
+
msg += "Please <a href=\"#{url}\">click here</a> to access <a href=\"#{url}\">#{url}</a>"
|
166
|
+
flash.now[:notice] += msg
|
167
|
+
end
|
168
|
+
end
|
169
|
+
@login_ticket = LoginTicket.create!(settings.redis)
|
170
|
+
@logout = true
|
171
|
+
render_login
|
172
|
+
end
|
173
|
+
|
174
|
+
def render_login
|
175
|
+
erb :login
|
176
|
+
end
|
177
|
+
|
178
|
+
def render_logged_in
|
179
|
+
erb :logged_in
|
180
|
+
end
|
181
|
+
|
182
|
+
# Override to add user info back to client applications
|
183
|
+
def append_user_info(username, xml)
|
184
|
+
end
|
185
|
+
|
186
|
+
private
|
187
|
+
def warden
|
188
|
+
request.env["warden"]
|
189
|
+
end
|
190
|
+
|
191
|
+
def sso_session
|
192
|
+
@sso_session ||= TicketGrantingTicket.validate(request.cookies["tgt"], settings.redis)
|
193
|
+
end
|
194
|
+
|
195
|
+
def ticket_granting_ticket
|
196
|
+
@ticket_granting_ticket ||= sso_session
|
197
|
+
end
|
198
|
+
|
199
|
+
def login_ticket
|
200
|
+
@login_ticket ||= LoginTicket.validate!(params[:lt], settings.redis)
|
201
|
+
end
|
202
|
+
|
203
|
+
def service_ticket
|
204
|
+
@service_ticket ||= ServiceTicket.find!(params[:ticket], settings.redis)
|
205
|
+
end
|
206
|
+
|
207
|
+
def render_validation_error(code, message = nil)
|
208
|
+
xml = Nokogiri::XML::Builder.new do |xml|
|
209
|
+
xml.serviceResponse("xmlns:cas" => "http://www.yale.edu/tp/cas") {
|
210
|
+
xml.parent.namespace = xml.parent.namespace_definitions.first
|
211
|
+
xml['cas'].authenticationFailure(message, :code => code.to_s.upcase){
|
212
|
+
}
|
213
|
+
}
|
214
|
+
end
|
215
|
+
xml.to_xml
|
216
|
+
end
|
217
|
+
|
218
|
+
def render_validation_success(username)
|
219
|
+
xml = Nokogiri::XML::Builder.new do |xml|
|
220
|
+
xml.serviceResponse("xmlns:cas" => "http://www.yale.edu/tp/cas") {
|
221
|
+
xml.parent.namespace = xml.parent.namespace_definitions.first
|
222
|
+
xml['cas'].authenticationSuccess {
|
223
|
+
xml['cas'].user username
|
224
|
+
append_user_info(username, xml)
|
225
|
+
}
|
226
|
+
}
|
227
|
+
end
|
228
|
+
xml.to_xml
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
data/lib/login_ticket.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
class LoginTicket
|
2
|
+
class << self
|
3
|
+
def validate!(ticket, store)
|
4
|
+
if store.exists ticket
|
5
|
+
store.del ticket
|
6
|
+
new
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def create!(store)
|
11
|
+
lt = self.new
|
12
|
+
lt.save!(store)
|
13
|
+
lt
|
14
|
+
end
|
15
|
+
|
16
|
+
def expire_time
|
17
|
+
300
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def ticket
|
22
|
+
@ticket ||= "LT-#{rand(100000000000000000)}".to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def remaining_time(store)
|
26
|
+
store.ttl ticket
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def save!(store)
|
31
|
+
store[ticket] = 1
|
32
|
+
store.expire ticket, self.class.expire_time
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class ProxyGrantingTicket
|
2
|
+
class << self
|
3
|
+
def validate!(ticket, store)
|
4
|
+
if service_name = store[ticket]
|
5
|
+
new(service_name)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def initialize(service_name)
|
11
|
+
@service_name = service_name
|
12
|
+
end
|
13
|
+
|
14
|
+
def valid_for_service?(url)
|
15
|
+
@service_name == url
|
16
|
+
end
|
17
|
+
|
18
|
+
def ticket
|
19
|
+
@ticket ||= "PGT-#{rand(100000000000000000)}".to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
def save!(store)
|
23
|
+
store[ticket] = @service_name
|
24
|
+
end
|
25
|
+
|
26
|
+
def create_proxy_ticket!(store)
|
27
|
+
pt = ProxyTicket.new(@service_name, self)
|
28
|
+
pt.save!(store)
|
29
|
+
pt
|
30
|
+
end
|
31
|
+
end
|