rubycas-server 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require 'casserver/authenticators/base'
2
+
3
+ class CASServer::Authenticators::Test < CASServer::Authenticators::Base
4
+ def validate(credentials)
5
+ read_standard_credentials(credentials)
6
+ return @username == "testuser" && @password == "testpassword"
7
+ end
8
+ end
@@ -0,0 +1,224 @@
1
+ require 'uri'
2
+ require 'net/https'
3
+
4
+ # Encapsulates CAS functionality. This module is meant to be included in
5
+ # the CASServer::Controllers module.
6
+ module CASServer::CAS
7
+
8
+ include CASServer::Models
9
+
10
+ def generate_login_ticket
11
+ # 3.5 (login ticket)
12
+ lt = LoginTicket.new
13
+ lt.ticket = "LT-" + CASServer::Utils.random_string
14
+ lt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
15
+ lt.save!
16
+ $LOG.debug("Generated login ticket '#{lt.ticket}' for client" +
17
+ " at '#{lt.client_hostname}'")
18
+ lt
19
+ end
20
+
21
+ def generate_ticket_granting_ticket(username)
22
+ # 3.6 (ticket granting cookie/ticket)
23
+ tgt = TicketGrantingTicket.new
24
+ tgt.ticket = "TGC-" + CASServer::Utils.random_string
25
+ tgt.username = username
26
+ tgt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
27
+ tgt.save!
28
+ $LOG.debug("Generated ticket granting ticket '#{tgt.ticket}' for user" +
29
+ " '#{tgt.username}' at '#{tgt.client_hostname}'")
30
+ tgt
31
+ end
32
+
33
+ def generate_service_ticket(service, username)
34
+ # 3.1 (service ticket)
35
+ st = ServiceTicket.new
36
+ st.ticket = "ST-" + CASServer::Utils.random_string
37
+ st.service = service
38
+ st.username = username
39
+ st.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
40
+ st.save!
41
+ $LOG.debug("Generated service ticket '#{st.ticket}' for service '#{st.service}'" +
42
+ " for user '#{st.username}' at '#{st.client_hostname}'")
43
+ st
44
+ end
45
+
46
+ def generate_proxy_ticket(target_service, pgt)
47
+ # 3.2 (proxy ticket)
48
+ pt = ProxyTicket.new
49
+ pt.ticket = "PT-" + CASServer::Utils.random_string
50
+ pt.service = target_service
51
+ pt.username = pgt.service_ticket.username
52
+ pt.proxy_granting_ticket_id = pgt.id
53
+ pt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
54
+ pt.save!
55
+ $LOG.debug("Generated proxy ticket '#{pt.ticket}' for target service '#{pt.service}'" +
56
+ " for user '#{pt.username}' at '#{pt.client_hostname}' using proxy-granting" +
57
+ " ticket '#{pgt.ticket}'")
58
+ pt
59
+ end
60
+
61
+ def generate_proxy_granting_ticket(pgt_url, st)
62
+ uri = URI.parse(pgt_url)
63
+ https = Net::HTTP.new(uri.host,uri.port)
64
+ https.use_ssl = true
65
+
66
+ # Here's what's going on here:
67
+ #
68
+ # 1. We generate a ProxyGrantingTicket (but don't store it in the database just yet)
69
+ # 2. Deposit the PGT and it's associated IOU at the proxy callback URL.
70
+ # 3. If the proxy callback URL responds with HTTP code 200, store the PGT and return it;
71
+ # otherwise don't save it and return nothing.
72
+ #
73
+ https.start do |conn|
74
+ path = uri.path.empty? ? '/' : uri.path
75
+
76
+ pgt = ProxyGrantingTicket.new
77
+ pgt.ticket = "PGT-" + CASServer::Utils.random_string
78
+ pgt.iou = "PGTIOU-" + CASServer::Utils.random_string
79
+ pgt.service_ticket_id = st.id
80
+ pgt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
81
+
82
+ # FIXME: The CAS protocol spec says to use 'pgt' as the parameter, but in practice
83
+ # the JA-SIG and Yale server implementations use pgtId. We'll go with the
84
+ # in-practice standard.
85
+ path += (uri.query.nil? || uri.query.empty? ? '?' : '&') + "pgtId=#{pgt.ticket}&pgtIou=#{pgt.iou}"
86
+
87
+ response = conn.request_get(path)
88
+ # TODO: follow redirects... 2.5.4 says that redirects MAY be followed
89
+
90
+ if response.code.to_i == 200
91
+ # 3.4 (proxy-granting ticket IOU)
92
+ pgt.save!
93
+ $LOG.debug "PGT generated for pgt_url '#{pgt_url}'. PGT is: '#{pgt.ticket}', PGT-IOU is: '#{pgt.iou}'"
94
+ pgt
95
+ else
96
+ $LOG.warn "PGT callback server responded with a bad result code '#{response.code}'. PGT will not be stored."
97
+ end
98
+ end
99
+ end
100
+
101
+ def validate_login_ticket(ticket)
102
+ $LOG.debug("Validating login ticket '#{ticket}'")
103
+
104
+ success = false
105
+ if ticket.nil?
106
+ error = "Your login request did not include a login ticket."
107
+ $LOG.warn("Missing login ticket.")
108
+ elsif lt = LoginTicket.find_by_ticket(ticket)
109
+ if lt.consumed?
110
+ error = "The login ticket you provided has already been used up."
111
+ $LOG.warn("Login ticket '#{ticket}' previously used up")
112
+ elsif Time.now - lt.created_on < CASServer::Conf.login_ticket_expiry
113
+ $LOG.info("Login ticket '#{ticket}' successfully validated")
114
+ else
115
+ error = "Your login ticket has expired."
116
+ $LOG.warn("Expired login ticket '#{ticket}'")
117
+ end
118
+ else
119
+ error = "The login ticket you provided is invalid."
120
+ $LOG.warn("Invalid login ticket '#{ticket}'")
121
+ end
122
+
123
+ lt.consume! if lt
124
+
125
+ error
126
+ end
127
+
128
+ def validate_ticket_granting_ticket(ticket)
129
+ $LOG.debug("Validating ticket granting ticket '#{ticket}'")
130
+
131
+ if ticket.nil?
132
+ error = "No ticket granting ticket given."
133
+ $LOG.debug(error)
134
+ elsif tgt = TicketGrantingTicket.find_by_ticket(ticket)
135
+ $LOG.info("Ticket granting ticket '#{ticket}' for user '#{tgt.username}' successfully validated.")
136
+ else
137
+ error = "Invalid ticket granting ticket '#{ticket}' (no matching ticket found in the database)."
138
+ $LOG.warn(error)
139
+ end
140
+
141
+ [tgt, error]
142
+ end
143
+
144
+ def validate_service_ticket(service, ticket, allow_proxy_tickets = false)
145
+ $LOG.debug("Validating service/proxy ticket '#{ticket}' for service '#{service}'")
146
+
147
+ if service.nil? or ticket.nil?
148
+ error = Error.new("INVALID_REQUEST", "Ticket or service parameter was missing in the request.")
149
+ $LOG.warn("#{error.code} - #{error.message}")
150
+ elsif st = ServiceTicket.find_by_ticket(ticket)
151
+ if st.consumed?
152
+ error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' has already been used up.")
153
+ $LOG.warn("#{error.code} - #{error.message}")
154
+ elsif st.kind_of?(CASServer::Models::ProxyTicket) && !allow_proxy_tickets
155
+ error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' is a proxy ticket, but only service tickets are allowed here.")
156
+ $LOG.warn("#{error.code} - #{error.message}")
157
+ elsif Time.now - st.created_on > CASServer::Conf.service_ticket_expiry
158
+ error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' has expired.")
159
+ $LOG.warn("Ticket '#{ticket}' has expired.")
160
+ elsif st.service == service
161
+ $LOG.info("Ticket '#{ticket}' for service '#{service}' for user '#{st.username}' successfully validated.")
162
+ else
163
+ error = Error.new("INVALID_SERVICE", "The ticket '#{ticket}' belonging to user '#{st.username}' is valid,"+
164
+ " but the requested service '#{service}' does not match the service '#{st.service}' associated with this ticket.")
165
+ $LOG.warn("#{error.code} - #{error.message}")
166
+ end
167
+ else
168
+ error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' not recognized.")
169
+ $LOG.warn("#{error.code} - #{error.message}")
170
+ end
171
+
172
+ if st
173
+ st.consume!
174
+ end
175
+
176
+
177
+ [st, error]
178
+ end
179
+
180
+ def validate_proxy_ticket(service, ticket)
181
+ pt, error = validate_service_ticket(service, ticket, true)
182
+
183
+ if pt.kind_of?(CASServer::Models::ProxyTicket) && !error
184
+ if not pt.proxy_granting_ticket
185
+ error = Error.new("INTERNAL_ERROR", "Proxy ticket '#{pt}' belonging to user '#{pt.username}' is not associated with a proxy granting ticket.")
186
+ elsif not pt.proxy_granting_ticket.service_ticket
187
+ error = Error.new("INTERNAL_ERROR", "Proxy granting ticket '#{pt.proxy_granting_ticket}'"+
188
+ " (associated with proxy ticket '#{pt}' and belonging to user '#{pt.username}' is not associated with a service ticket.")
189
+ end
190
+ end
191
+
192
+ [pt, error]
193
+ end
194
+
195
+ def validate_proxy_granting_ticket(ticket)
196
+ if ticket.nil?
197
+ error = Error.new("INVALID_REQUEST", "pgt parameter was missing in the request.")
198
+ $LOG.warn("#{error.code} - #{error.message}")
199
+ elsif pgt = ProxyGrantingTicket.find_by_ticket(ticket)
200
+ if pgt.service_ticket
201
+ $LOG.info("Proxy granting ticket '#{ticket}' belonging to user '#{pgt.service_ticket.username}' successfully validated.")
202
+ else
203
+ error = Error.new("INTERNAL_ERROR", "Proxy granting ticket '#{ticket}' is not associated with a service ticket.")
204
+ $LOG.error("#{error.code} - #{error.message}")
205
+ end
206
+ else
207
+ error = Error.new("BAD_PGT", "Invalid proxy granting ticket '#{ticket}' (no matching ticket found in the database).")
208
+ $LOG.warn("#{error.code} - #{error.message}")
209
+ end
210
+
211
+ [pgt, error]
212
+ end
213
+
214
+ def service_uri_with_ticket(service, st)
215
+ raise ArgumentError, "Second argument must be a ServiceTicket!" unless st.kind_of? CASServer::Models::ServiceTicket
216
+
217
+ service_uri = URI.parse(service)
218
+ query_separator = service_uri.query ? "&" : "?"
219
+
220
+ service_with_ticket = service + query_separator + "ticket=" + st.ticket
221
+ service_with_ticket
222
+ end
223
+
224
+ end
@@ -0,0 +1,78 @@
1
+ # load configuration
2
+ begin
3
+ conf_file = etc_conf = "/etc/rubycas-server/config.yml"
4
+ unless File.exists? conf_file
5
+ # can use local config.yml file in case we're running non-gem installation
6
+ conf_file = File.dirname(File.expand_path(__FILE__))+"/../../config.yml"
7
+ end
8
+
9
+ unless File.exists? conf_file
10
+ require 'fileutils'
11
+
12
+ example_conf_file = File.expand_path(File.dirname(File.expand_path(__FILE__))+"/../../config.example.yml")
13
+ puts "\nCAS SERVER NOT YET CONFIGURED!!!\n"
14
+ puts "\nAttempting to copy sample configuration from '#{example_conf_file}' to '#{etc_conf}'...\n"
15
+
16
+ begin
17
+ FileUtils.mkdir("/etc/rubycas-server") unless File.exists? "/etc/rubycas-server"
18
+ FileUtils.cp(example_conf_file, etc_conf)
19
+ rescue Errno::EACCES
20
+ puts "\nIt appears that you do not have permissions to create the '#{etc_conf}' file. Try running this command using sudo (as root).\n"
21
+ exit 2
22
+ rescue
23
+ puts "\nFor some reason the '#{etc_conf}' file could not be created. You'll have to copy the file manually." +
24
+ " Use '#{example_conf_file}' as a template.\n"
25
+ exit 2
26
+ end
27
+
28
+ puts "\nA sample configuration has been created for you in '#{etc_conf}'. Please edit this file to" +
29
+ " suit your needs and then run rubycas-server again.\n"
30
+ exit 1
31
+ end
32
+
33
+
34
+ loaded_conf = HashWithIndifferentAccess.new(YAML.load_file(conf_file))
35
+
36
+ if $CONF
37
+ $CONF = loaded_conf.merge $CONF
38
+ else
39
+ $CONF = loaded_conf
40
+ end
41
+
42
+ begin
43
+ # attempt to instantiate the authenticator
44
+ $AUTH = $CONF[:authenticator][:class].constantize.new
45
+ rescue NameError
46
+ # the authenticator class hasn't yet been loaded, so lets try to load it from the casserver/authenticators directory
47
+ auth_rb = $CONF[:authenticator][:class].underscore.gsub('cas_server/', '')
48
+ require 'casserver/'+auth_rb
49
+ $AUTH = $CONF[:authenticator][:class].constantize.new
50
+ end
51
+ rescue
52
+ raise "Your RubyCAS-Server configuration may be invalid."+
53
+ " Please double-check check your config.yml file."+
54
+ " Make sure that you are using spaces instead of tabs for your indentation!!" +
55
+ "\n\nUNDERLYING EXCEPTION:\n#{$!}"
56
+ end
57
+
58
+ module CASServer
59
+ module Conf
60
+ DEFAULTS = {
61
+ :login_ticket_expiry => 5.minutes,
62
+ :service_ticket_expiry => 5.minutes, # CAS Protocol Spec, sec. 3.2.1 (recommended expiry time)
63
+ :proxy_granting_ticket_expiry => 48.hours,
64
+ :ticket_granting_ticket_expiry => 48.hours,
65
+ :log => {:file => 'casserver.log', :level => 'DEBUG'},
66
+ :uri_path => "/"
67
+ }
68
+
69
+ def [](key)
70
+ $CONF[key] || DEFAULTS[key]
71
+ end
72
+ module_function "[]".intern
73
+
74
+ def self.method_missing(method, *args)
75
+ self[method]
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,253 @@
1
+ # The #.#.# comments (e.g. "2.1.3") refer to section numbers in the CAS protocol spec
2
+ # under http://www.ja-sig.org/products/cas/overview/protocol/index.html
3
+
4
+ module CASServer::Controllers
5
+
6
+ # 2.1
7
+ class Login < R '/', '/login'
8
+ include CASServer::CAS
9
+
10
+ # 2.1.1
11
+ def get
12
+ # make sure there's no caching
13
+ headers['Pragma'] = 'no-cache'
14
+ headers['Cache-Control'] = 'no-store'
15
+ headers['Expires'] = (Time.now - 1.year).rfc2822
16
+
17
+ # optional params
18
+ @service = @input['service']
19
+ @renew = @input['renew']
20
+ @gateway = @input['gateway']
21
+
22
+ if @service && !@renew && tgc = @cookies[:tgt]
23
+ tgt, error = validate_ticket_granting_ticket(tgc)
24
+ if tgt && !error
25
+ st = generate_service_ticket(@service, tgt.username)
26
+ service_with_ticket = service_uri_with_ticket(@service, st)
27
+ $LOG.info("User '#{tgt.username}' authenticated based on ticket granting cookie. Redirecting to service '#{@service}'.")
28
+ return redirect(service_with_ticket, :status => 303) # response code 303 means "See Other" (see Appendix B in CAS Protocol spec)
29
+ end
30
+ end
31
+
32
+ lt = generate_login_ticket
33
+
34
+ $LOG.debug("Rendering login form with lt: #{lt}, service: #{@service}, renew: #{@renew}, gateway: #{@gateway}")
35
+
36
+ @lt = lt.ticket
37
+
38
+ render :login
39
+ end
40
+
41
+ # 2.2
42
+ def post
43
+ # 2.2.1 (optional)
44
+ @service = @input['service']
45
+ @warn = @input['warn']
46
+
47
+ # 2.2.2 (required)
48
+ @username = @input['username']
49
+ @password = @input['password']
50
+ @lt = @input['lt']
51
+
52
+ if error = validate_login_ticket(@lt)
53
+ @message = {:type => 'mistake', :message => error}
54
+ return render(:login)
55
+ end
56
+
57
+ # generate another login ticket to allow for re-submitting the form after a post
58
+ @lt = generate_login_ticket.ticket
59
+
60
+ $AUTH.configure(CASServer::Conf.authenticator)
61
+
62
+ $LOG.debug("Logging in with username: #{@username}, lt: #{@lt}, service: #{@service}, auth: #{$AUTH}")
63
+
64
+ if $AUTH.validate(:username => @username, :password => @password)
65
+ $LOG.info("Credentials for username '#{@username}' successfully validated")
66
+
67
+ # 3.6 (ticket-granting cookie)
68
+ tgt = generate_ticket_granting_ticket(@username)
69
+ @cookies[:tgt] = tgt.to_s
70
+ $LOG.debug("Ticket granting cookie '#{@cookies[:tgt]}' granted to '#{@username}'")
71
+
72
+ if @service.blank?
73
+ $LOG.info("Successfully authenticated user '#{@username}' at '#{tgt.client_hostname}'. No service param was given, so we will not redirect.")
74
+ @message = {:type => 'confirmation', :message => "You have successfully logged in."}
75
+ render :login
76
+ else
77
+ @st = generate_service_ticket(@service, @username)
78
+ service_with_ticket = service_uri_with_ticket(@service, @st)
79
+
80
+ $LOG.info("Redirecting authenticated user '#{@username}' at '#{@st.client_hostname}' to service '#{@service}'")
81
+ return redirect(service_with_ticket, :status => 303) # response code 303 means "See Other" (see Appendix B in CAS Protocol spec)
82
+ end
83
+ else
84
+ $LOG.warn("Invalid credentials given for user '#{@username}'")
85
+ @message = {:type => 'mistake', :message => "Incorrect username or password."}
86
+ render :login
87
+ end
88
+ end
89
+ end
90
+
91
+ # 2.3
92
+ class Logout < R '/logout'
93
+ include CASServer::CAS
94
+
95
+ # 2.3.1
96
+ def get
97
+ # The behaviour here is somewhat non-standard. Rather than showing just a blank
98
+ # "logout" page, we take the user back to the login page with a "you have been logged out"
99
+ # message, allowing for an opportunity to immediately log back in. This makes
100
+ # switching users a lot smoother.
101
+ @service = @input['url'] || @input['service']
102
+ # TODO: display service name in view as per 2.3.2
103
+
104
+ tgt = CASServer::Models::TicketGrantingTicket.find_by_ticket(@cookies[:tgt])
105
+
106
+ @cookies.delete :tgt
107
+
108
+ if tgt
109
+ pgts = CASServer::Models::ProxyGrantingTicket.find(:all, ["username = ?", tgt.username])
110
+ pgts.each do |pgt|
111
+ pgt.destroy
112
+ $LOG.debug("Deleting Proxy-Granting Ticket '#{pgt}' for user '#{tgt.username}'")
113
+ end
114
+
115
+ $LOG.debug("Deleting Ticket-Granting Ticket '#{tgt}' for user '#{tgt.username}'")
116
+
117
+ $LOG.info("User '#{tgt.username}' logged out.")
118
+ else
119
+ $LOG.warn("User tried to log out without a valid ticket-granting ticket.")
120
+ end
121
+
122
+ @message = {:type => 'confirmation', :message => "You have successfully logged out."}
123
+
124
+ @lt = generate_login_ticket
125
+
126
+ render :login
127
+ end
128
+ end
129
+
130
+ # 2.4
131
+ class Validate < R '/validate'
132
+ include CASServer::CAS
133
+
134
+ # 2.4.1
135
+ def get
136
+ # required
137
+ @service = @input['service']
138
+ @ticket = @input['ticket']
139
+ # optional
140
+ @renew = @input['renew']
141
+
142
+ st, @error = validate_service_ticket(@service, @ticket)
143
+ @success = st && !@error
144
+
145
+ @username = st.username if @success
146
+
147
+ render :validate
148
+ end
149
+ end
150
+
151
+ # 2.5
152
+ class ServiceValidate < R '/serviceValidate'
153
+ include CASServer::CAS
154
+
155
+ # 2.5.1
156
+ def get
157
+ # required
158
+ @service = @input['service']
159
+ @ticket = @input['ticket']
160
+ # optional
161
+ @pgt_url = @input['pgtUrl']
162
+ @renew = @input['renew']
163
+
164
+ st, @error = validate_service_ticket(@service, @ticket)
165
+ @success = st && !@error
166
+
167
+ if @success
168
+ @username = st.username
169
+ if @pgt_url
170
+ pgt = generate_proxy_granting_ticket(@pgt_url, st)
171
+ @pgtiou = pgt.iou if pgt
172
+ end
173
+ end
174
+
175
+ render :service_validate
176
+ end
177
+ end
178
+
179
+ # 2.6
180
+ class ProxyValidate < R '/proxyValidate'
181
+ include CASServer::CAS
182
+
183
+ # 2.6.1
184
+ def get
185
+ # required
186
+ @service = @input['service']
187
+ @ticket = @input['ticket']
188
+ # optional
189
+ @pgt_url = @input['pgtUrl']
190
+ @renew = @input['renew']
191
+
192
+ @proxies = []
193
+
194
+ t, @error = validate_proxy_ticket(@service, @ticket)
195
+ @success = t && !@error
196
+
197
+ if @success
198
+
199
+ end
200
+
201
+ if @success
202
+ @username = t.username
203
+
204
+ if t.kind_of? CASServer::Models::ProxyTicket
205
+ @proxies << t.proxy_granting_ticket.service_ticket.service
206
+ end
207
+
208
+ if @pgt_url
209
+ pgt = generate_proxy_granting_ticket(@pgt_url, t)
210
+ @pgtiou = pgt.iou if pgt
211
+ end
212
+ end
213
+
214
+ render :proxy_validate
215
+ end
216
+ end
217
+
218
+ class Proxy < R '/proxy'
219
+ include CASServer::CAS
220
+
221
+ # 2.7
222
+ def get
223
+ # required
224
+ @ticket = @input['pgt']
225
+ @target_service = @input['targetService']
226
+
227
+ pgt, @error = validate_proxy_granting_ticket(@ticket)
228
+ @success = pgt && !@error
229
+
230
+ if @success
231
+ @pt = generate_proxy_ticket(@target_service, pgt)
232
+ end
233
+
234
+ render :proxy
235
+ end
236
+ end
237
+
238
+ class Themes < R '/themes/(.+)'
239
+ MIME_TYPES = {'.css' => 'text/css', '.js' => 'text/javascript',
240
+ '.jpg' => 'image/jpeg'}
241
+ PATH = CASServer::Conf.themes_dir || File.expand_path(File.dirname(__FILE__))+'/../themes'
242
+
243
+ def get(path)
244
+ @headers['Content-Type'] = MIME_TYPES[path[/\.\w+$/, 0]] || "text/plain"
245
+ unless path.include? ".." # prevent directory traversal attacks
246
+ @headers['X-Sendfile'] = "#{PATH}/#{path}"
247
+ else
248
+ @status = "403"
249
+ "403 - Invalid path"
250
+ end
251
+ end
252
+ end
253
+ end