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 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
@@ -0,0 +1,6 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ Bundler.require :default, :development
4
+ require './lib/classy_cas'
5
+
6
+ run ClassyCAS::Server
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
@@ -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