rubycas-server 0.1.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.
@@ -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