rubycas-server 0.6.0 → 0.7.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.
Files changed (50) hide show
  1. data/CHANGELOG.txt +1 -186
  2. data/History.txt +247 -0
  3. data/Manifest.txt +27 -2
  4. data/PostInstall.txt +3 -0
  5. data/Rakefile +4 -60
  6. data/bin/rubycas-server +2 -2
  7. data/bin/rubycas-server-ctl +0 -0
  8. data/casserver.db +0 -0
  9. data/casserver.log +792 -0
  10. data/casserver_db.log +88 -0
  11. data/config/hoe.rb +76 -0
  12. data/config/requirements.rb +15 -0
  13. data/config.example.yml +130 -6
  14. data/lib/casserver/authenticators/base.rb +20 -0
  15. data/lib/casserver/authenticators/client_certificate.rb +46 -0
  16. data/lib/casserver/authenticators/google.rb +54 -0
  17. data/lib/casserver/authenticators/ldap.rb +70 -40
  18. data/lib/casserver/authenticators/ntlm.rb +88 -0
  19. data/lib/casserver/authenticators/open_id.rb +22 -0
  20. data/lib/casserver/authenticators/sql.rb +66 -1
  21. data/lib/casserver/authenticators/sql_md5.rb +19 -0
  22. data/lib/casserver/authenticators/test.rb +5 -1
  23. data/lib/casserver/cas.rb +97 -22
  24. data/lib/casserver/controllers.rb +95 -34
  25. data/lib/casserver/environment.rb +16 -9
  26. data/lib/casserver/models.rb +38 -10
  27. data/lib/casserver/version.rb +1 -1
  28. data/lib/casserver/views.rb +38 -22
  29. data/lib/casserver.rb +13 -9
  30. data/lib/rubycas-server/version.rb +1 -0
  31. data/lib/rubycas-server.rb +1 -1
  32. data/lib/themes/notice.png +0 -0
  33. data/lib/themes/simple/logo.png +0 -0
  34. data/misc/basic_cas_single_signon_mechanism_diagram.png +0 -0
  35. data/misc/basic_cas_single_signon_mechanism_diagram.svg +652 -0
  36. data/script/console +10 -0
  37. data/script/destroy +14 -0
  38. data/script/generate +14 -0
  39. data/script/txt2html +82 -0
  40. data/tasks/deployment.rake +34 -0
  41. data/tasks/environment.rake +7 -0
  42. data/tasks/website.rake +17 -0
  43. data/website/index.html +40 -0
  44. data/website/index.txt +3 -0
  45. data/website/javascripts/rounded_corners_lite.inc.js +285 -0
  46. data/website/stylesheets/screen.css +138 -0
  47. data/website/template.html.erb +40 -0
  48. metadata +45 -33
  49. data/test/test_cas.rb +0 -33
  50. data/test/test_casserver.rb +0 -125
@@ -28,23 +28,34 @@ class CASServer::Authenticators::LDAP < CASServer::Authenticators::Base
28
28
  return false if @password.blank?
29
29
 
30
30
  raise CASServer::AuthenticatorError, "Cannot validate credentials because the authenticator hasn't yet been configured" unless @options
31
- raise CASServer::AuthenticatorError, "Invalid authenticator configuration!" unless @options[:ldap]
32
- raise CASServer::AuthenticatorError, "You must specify an ldap server in the configuration!" unless @options[:ldap][:server]
33
-
31
+ raise CASServer::AuthenticatorError, "Invalid LDAP authenticator configuration!" unless @options[:ldap]
32
+ raise CASServer::AuthenticatorError, "You must specify a server host in the LDAP configuration!" unless @options[:ldap][:host] || @options[:ldap][:server]
33
+
34
34
  raise CASServer::AuthenticatorError, "The username '#{@username}' contains invalid characters." if (@username =~ /[*\(\)\0\/]/)
35
35
 
36
36
  preprocess_username
37
37
 
38
38
  @ldap = Net::LDAP.new
39
- @ldap.host = @options[:ldap][:server]
39
+
40
+
41
+ @options[:ldap][:host] ||= @options[:ldap][:server]
42
+ @ldap.host = @options[:ldap][:host]
40
43
  @ldap.port = @options[:ldap][:port] if @options[:ldap][:port]
44
+ @ldap.encryption(@options[:ldap][:encryption].intern) if @options[:ldap][:encryption]
41
45
 
42
46
  begin
43
47
  if @options[:ldap][:auth_user]
44
- bind_with_preauthentication
48
+ bind_success = bind_by_username_with_preauthentication
45
49
  else
46
- bind_directly
50
+ bind_success = bind_by_username
47
51
  end
52
+
53
+ return false unless bind_success
54
+
55
+ entry = find_user
56
+ extract_extra_attributes(entry)
57
+
58
+ return true
48
59
  rescue Net::LDAP::LdapError => e
49
60
  raise CASServer::AuthenticatorError,
50
61
  "LDAP authentication failed with '#{e}'. Check your authenticator configuration."
@@ -53,56 +64,75 @@ class CASServer::Authenticators::LDAP < CASServer::Authenticators::Base
53
64
 
54
65
  protected
55
66
  def default_username_attribute
56
- "uid"
67
+ "cn"
57
68
  end
58
69
 
59
70
  private
71
+ # Add prefix to username, if :username_prefix was specified in the :ldap config.
60
72
  def preprocess_username
61
- # add prefix to username, if prefix was specified in the config
62
73
  @username = @options[:ldap][:username_prefix] + @username if @options[:ldap][:username_prefix]
63
74
  end
64
-
65
- def bind_with_preauthentication
66
- # If an auth_user is specified, we will connect ("pre-authenticate") to the
67
- # LDAP server using the authenticator account, and then attempt to bind as the
68
- # user who is actually trying to authenticate. Note that you need to set up
69
- # the special authenticator account first. Also, auth_user must be the authenticator
70
- # user's full CN, which is probably not the same as their username.
71
- #
72
- # This pre-authentication process is necessary because binding can only be done
73
- # using the CN, so having just the username is not enough. We connect as auth_user,
74
- # and then try to find the target user's CN based on the given username. Then we bind
75
- # as the target user to validate their credentials.
75
+
76
+ # Attempt to bind with the LDAP server using the username and password entered by
77
+ # the user. If a :filter was specified in the :ldap config, the filter will be
78
+ # added to the LDAP query for the username.
79
+ def bind_by_username
80
+ username_attribute = options[:ldap][:username_attribute] || default_username_attribute
76
81
 
82
+ @ldap.bind_as(:base => @options[:ldap][:base], :password => @password, :filter => user_filter)
83
+ end
84
+
85
+ # If an auth_user is specified, we will connect ("pre-authenticate") with the
86
+ # LDAP server using the authenticator account, and then attempt to bind as the
87
+ # user who is actually trying to authenticate. Note that you need to set up
88
+ # the special authenticator account first. Also, auth_user must be the authenticator
89
+ # user's full CN, which is probably not the same as their username.
90
+ #
91
+ # This pre-authentication process is necessary because binding can only be done
92
+ # using the CN, so having just the username is not enough. We connect as auth_user,
93
+ # and then try to find the target user's CN based on the given username. Then we bind
94
+ # as the target user to validate their credentials.
95
+ def bind_by_username_with_preauthentication
77
96
  raise CASServer::AuthenticatorError, "A password must be specified in the configuration for the authenticator user!" unless
78
97
  @options[:ldap][:auth_password]
79
98
 
80
99
  @ldap.authenticate(@options[:ldap][:auth_user], @options[:ldap][:auth_password])
81
100
 
101
+ @ldap.bind_as(:base => @options[:ldap][:base], :password => @password, :filter => user_filter)
102
+ end
103
+
104
+ # Combine the filter for finding the user with the optional extra filter specified in the config
105
+ # (if any).
106
+ def user_filter
82
107
  username_attribute = options[:ldap][:username_attribute] || default_username_attribute
83
108
 
84
- filter = Net::LDAP::Filter.construct(@options[:ldap][:filter]) if
85
- @options[:ldap][:filter] && !@options[:ldap][:filter].blank?
86
- username_filter = Net::LDAP::Filter.eq(username_attribute, @username)
87
- if filter
88
- filter &= username_filter
89
- else
90
- filter = username_filter
109
+ filter = Net::LDAP::Filter.eq(username_attribute, @username)
110
+ unless @options[:ldap][:filter].blank?
111
+ filter &= Net::LDAP::Filter.construct(@options[:ldap][:filter])
91
112
  end
92
-
93
- @ldap.bind_as(:base => @options[:ldap][:base], :password => @password, :filter => filter)
94
113
  end
95
-
96
- def bind_directly
97
- # When no auth_user is specified, we will try to connect directly as the user
98
- # who is trying to authenticate. Note that for this to work, the username must
99
- # be equivalent to the user's CN, and this is often not the case (for example,
100
- # in Active Directory, the username is the 'sAMAccountName' attribute, while the
101
- # user's CN is generally their full name.)
102
-
103
- cn = @username
114
+
115
+ # Finds the user based on the user_filter (this is called after authentication).
116
+ # We do this to make it possible to extract extra_attributes.
117
+ def find_user
118
+ results = @ldap.search( :base => options[:ldap][:base], :filter => user_filter)
119
+ return results.first
120
+ end
121
+
122
+ def extract_extra_attributes(ldap_entry)
123
+ @extra_attributes = {}
124
+ extra_attributes_to_extract.each do |attr|
125
+ v = !ldap_entry[attr].blank? && ldap_entry[attr].first
126
+ if v
127
+ @extra_attributes[attr] = v.to_s
128
+ end
129
+ end
104
130
 
105
- @ldap.authenticate(cn, @password)
106
- @ldap.bind
131
+ if @extra_attributes.empty?
132
+ $LOG.warn("#{self.class}: Did not read any extra_attributes for user #{@username.inspect} even though an :extra_attributes option was provided.")
133
+ else
134
+ $LOG.debug("#{self.class}: Read the following extra_attributes for user #{@username.inspect}: #{@extra_attributes.inspect}")
135
+ end
136
+ ldap_entry
107
137
  end
108
138
  end
@@ -0,0 +1,88 @@
1
+ # THIS AUTHENTICATOR DOES NOT WORK (not even close!)
2
+ #
3
+ # I started working on this but run into a wall, so I am commiting what I've got
4
+ # done and leaving it here with hopes of one day finishing it.
5
+ #
6
+ # The main problem is that although I've got the Lan Manager/NTLM password hash,
7
+ # I'm not sure what to do with it. i.e. I need to check it against the AD or SMB
8
+ # server or something... maybe faking an SMB share connection and using the LM
9
+ # response for authentication might do the trick?
10
+
11
+ require 'casserver/authenticators/base'
12
+
13
+ # Ruby/NTLM package from RubyForge
14
+ require 'net/ntlm'
15
+
16
+ module CASServer
17
+ module Authenticators
18
+ class NTLM
19
+ # This will have to be somehow called by the top of the 'get' method
20
+ # in the Login controller (maybe via a hook?)... if this code fails
21
+ # then the controller should fall back to some other method of authentication
22
+ # (probably AD/LDAP or something).
23
+ def filter_for_top_of_login_get_controller_method
24
+ $LOG.debug @env.inspect
25
+ if @env['HTTP_AUTHORIZATION'] =~ /NTLM ([^\s]+)/
26
+ # if we're here, then the client has sent back a Type1 or Type3 message
27
+ # in reply to our NTLM challenge or our Type2 message
28
+ data_raw = Base64.decode64($~[1])
29
+ $LOG.debug "T1 RAW: #{t1_raw}"
30
+ t = Net::NTLM::Message::Message.parse(t1_raw)
31
+ if t.kind_of? Net::NTLM::Type1
32
+ t1 = t
33
+ elsif t.kind_of? Net::NTLM::Type3
34
+ t3 = t
35
+ else
36
+ raise "Invalid NTLM reply from client."
37
+ end
38
+
39
+ if t1
40
+ $LOG.debug "T1: #{t1.inspect}"
41
+
42
+ # now put together a Type2 message asking for the client to send
43
+ # back NTLM credentials (LM hash and such)
44
+ t2 = Net::NTLM::Message::Type2.new
45
+ t2.set_flag :UNICODE
46
+ t2.set_flag :NTLM
47
+ t2.context = 0x0000000000000000 # this can probably just be left unassigned
48
+ t2.challenge = 0x0123456789abcdef # this should be a random 8-byte integer
49
+
50
+ $LOG.debug "T2: #{t2.inspect}"
51
+ $LOG.debug "T2: #{t2.serialize}"
52
+ headers["WWW-Authenticate"] = "NTLM #{t2.encode64}"
53
+
54
+ # the client should respond to this with a Type3 message...
55
+ r('401', '', headers)
56
+ return
57
+ else
58
+ # NOTE: for some reason the server never receives the T3 response, even though monitoring
59
+ # the HTTP traffic I can see that the client does send it back... there's probably
60
+ # another bug hiding somewhere here
61
+
62
+ lm_response = t3.lm_response
63
+ ntlm_response = t3.ntlm_response
64
+ username = t3.user
65
+ # this is where we run up against a wall... we need some way to check the lm and/or ntlm
66
+ # reponse against the authentication server (probably Active Directory)... maybe a samba
67
+ # call would do it?
68
+ $LOG.debug "T3 LM: #{lm_response.inspect}"
69
+ $LOG.debug "T3 NTLM: #{ntlm_response.inspect}"
70
+
71
+ # assuming the authentication was successful, we'll now need to do something in the
72
+ # controller acting as if we'd received correct login credentials (i.e. proceed as if
73
+ # CAS authentication was successful).... if authentication failed, then we should
74
+ # just fall back to old-school web-based authentication, asking the user to enter
75
+ # their username and password the normal CAS way
76
+ end
77
+ else
78
+ # this sends the initial NTLM challenge, asking the browser
79
+ # to send back a Type1 message
80
+ headers['WWW-Authenticate'] = "NTLM"
81
+ headers['Connection'] = "Close"
82
+ r('401', '', headers)
83
+ return
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,22 @@
1
+ require 'casserver/authenticators/base'
2
+
3
+ require 'openid'
4
+ require 'openid/extensions/sreg'
5
+ require 'openid/extensions/pape'
6
+ require 'openid/store/memory'
7
+
8
+
9
+ # CURRENTLY UNIMPLEMENTED
10
+ # This is just starter code.
11
+ class CASServer::Authenticators::OpenID < CASServer::Authenticators::Base
12
+
13
+ def validate(credentials)
14
+ raise NotImplementedError, "The OpenID authenticator is not yet implemented. "+
15
+ "See http://code.google.com/p/rubycas-server/issues/detail?id=36 if you are interested in helping this along."
16
+
17
+ read_standard_credentials(credentials)
18
+
19
+ store = OpenID::Store::Memory.new
20
+ consumer = OpenID::Consumer.new({}, store)
21
+ end
22
+ end
@@ -7,6 +7,51 @@ rescue LoadError
7
7
  require 'active_record'
8
8
  end
9
9
 
10
+ # Authenticates against a plain SQL table.
11
+ #
12
+ # This assumes that all of your users are stored in a table that has a 'username'
13
+ # column and a 'password' column. When the user logs in, CAS conects to the
14
+ # database and looks for a matching username/password in the users table. If a
15
+ # matching username and password is found, authentication is successful.
16
+ #
17
+ # Any database backend supported by ActiveRecord can be used.
18
+ #
19
+ # Config example:
20
+ #
21
+ # authenticator:
22
+ # class: CASServer::Authenticators::SQL
23
+ # database:
24
+ # adapter: mysql
25
+ # database: some_database_with_users_table
26
+ # username: root
27
+ # password:
28
+ # server: localhost
29
+ # user_table: users
30
+ # username_column: username
31
+ # password_column: password
32
+ #
33
+ # When replying to a CAS client's validation request, the server will normally
34
+ # provide the client with the authenticated user's username. However it is now
35
+ # possible for the server to provide the client with additional attributes.
36
+ # You can configure the SQL authenticator to provide data from additional
37
+ # columns in the users table by listing the names of the columns under the
38
+ # 'extra_attributes' option. Note though that this functionality is experimental.
39
+ # It should work with RubyCAS-Client, but may or may not work with other CAS
40
+ # clients.
41
+ #
42
+ # For example, with this configuration, the 'full_name' and 'access_level'
43
+ # columns will be provided to your CAS clients along with the username:
44
+ #
45
+ # authenticator:
46
+ # class: CASServer::Authenticators::SQL
47
+ # database:
48
+ # adapter: mysql
49
+ # database: some_database_with_users_table
50
+ # user_table: users
51
+ # username_column: username
52
+ # password_column: password
53
+ # extra_attributes: full_name, access_level
54
+ #
10
55
  class CASServer::Authenticators::SQL < CASServer::Authenticators::Base
11
56
 
12
57
  def validate(credentials)
@@ -24,7 +69,27 @@ class CASServer::Authenticators::SQL < CASServer::Authenticators::Base
24
69
  results = CASUser.find(:all, :conditions => ["#{username_column} = ? AND #{password_column} = ?", @username, @password])
25
70
 
26
71
  if results.size > 0
27
- $LOG.warn("Multiple matches found for user '#{@username}'") if results.size > 1
72
+ $LOG.warn("#{self.class}: Multiple matches found for user #{@username.inspect}") if results.size > 1
73
+
74
+ unless @options[:extra_attributes].blank?
75
+ if results.size > 1
76
+ $LOG.warn("#{self.class}: Unable to extract extra_attributes because multiple matches were found for #{@username.inspect}")
77
+ else
78
+ user = results.first
79
+
80
+ @extra_attributes = {}
81
+ extra_attributes_to_extract.each do |col|
82
+ @extra_attributes[col] = user.send(col)
83
+ end
84
+
85
+ if @extra_attributes.empty?
86
+ $LOG.warn("#{self.class}: Did not read any extra_attributes for user #{@username.inspect} even though an :extra_attributes option was provided.")
87
+ else
88
+ $LOG.debug("#{self.class}: Read the following extra_attributes for user #{@username.inspect}: #{@extra_attributes.inspect}")
89
+ end
90
+ end
91
+ end
92
+
28
93
  return true
29
94
  else
30
95
  return false
@@ -0,0 +1,19 @@
1
+ require 'casserver/authenticators/sql'
2
+
3
+ require 'digest/md5'
4
+
5
+ # Essentially the same as the standard SQL authenticator, but this version
6
+ # assumes that your password is stored as an MD5 hash.
7
+ #
8
+ # This was contributed by malcomm for Drupal authentication. To work with
9
+ # Drupal, you should use 'name' for the :username_column config option, and
10
+ # 'pass' for the :password_column.
11
+ class CASServer::Authenticators::SQLMd5 < CASServer::Authenticators::SQL
12
+
13
+ protected
14
+ def read_standard_credentials(credentials)
15
+ super
16
+ @password = Digest::MD5.hexdigest(@password)
17
+ end
18
+
19
+ end
@@ -10,6 +10,10 @@ class CASServer::Authenticators::Test < CASServer::Authenticators::Base
10
10
 
11
11
  raise CASServer::AuthenticatorError, "Username is 'do_error'!" if @username == 'do_error'
12
12
 
13
- return @username == "testuser" && @password == "testpassword"
13
+ @extra_attributes[:test_string] = "testing!"
14
+ @extra_attributes[:test_numeric] = 123.45
15
+ @extra_attributes[:test_serialized] = {:foo => 'bar', :alpha => [1,2,3]}
16
+
17
+ return @password == "testpassword"
14
18
  end
15
19
  end
data/lib/casserver/cas.rb CHANGED
@@ -11,32 +11,41 @@ module CASServer::CAS
11
11
  # 3.5 (login ticket)
12
12
  lt = LoginTicket.new
13
13
  lt.ticket = "LT-" + CASServer::Utils.random_string
14
- lt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
14
+ lt.client_hostname = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_HOST'] || env['REMOTE_ADDR']
15
15
  lt.save!
16
16
  $LOG.debug("Generated login ticket '#{lt.ticket}' for client" +
17
17
  " at '#{lt.client_hostname}'")
18
18
  lt
19
19
  end
20
20
 
21
- def generate_ticket_granting_ticket(username)
21
+ # Creates a TicketGrantingTicket for the given username. This is done when the user logs in
22
+ # for the first time to establish their SSO session (after their credentials have been validated).
23
+ #
24
+ # The optional 'extra_attributes' parameter takes a hash of additional attributes
25
+ # that will be sent along with the username in the CAS response to subsequent
26
+ # validation requests from clients.
27
+ def generate_ticket_granting_ticket(username, extra_attributes = {})
22
28
  # 3.6 (ticket granting cookie/ticket)
23
29
  tgt = TicketGrantingTicket.new
24
30
  tgt.ticket = "TGC-" + CASServer::Utils.random_string
25
31
  tgt.username = username
26
- tgt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
32
+ tgt.extra_attributes = extra_attributes
33
+ tgt.client_hostname = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_HOST'] || env['REMOTE_ADDR']
27
34
  tgt.save!
28
35
  $LOG.debug("Generated ticket granting ticket '#{tgt.ticket}' for user" +
29
- " '#{tgt.username}' at '#{tgt.client_hostname}'")
36
+ " '#{tgt.username}' at '#{tgt.client_hostname}'" +
37
+ (extra_attributes.blank? ? "" : " with extra attributes #{extra_attributes.inspect}"))
30
38
  tgt
31
39
  end
32
40
 
33
- def generate_service_ticket(service, username)
41
+ def generate_service_ticket(service, username, tgt)
34
42
  # 3.1 (service ticket)
35
43
  st = ServiceTicket.new
36
44
  st.ticket = "ST-" + CASServer::Utils.random_string
37
45
  st.service = service
38
46
  st.username = username
39
- st.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
47
+ st.ticket_granting_ticket = tgt
48
+ st.client_hostname = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_HOST'] || env['REMOTE_ADDR']
40
49
  st.save!
41
50
  $LOG.debug("Generated service ticket '#{st.ticket}' for service '#{st.service}'" +
42
51
  " for user '#{st.username}' at '#{st.client_hostname}'")
@@ -50,7 +59,8 @@ module CASServer::CAS
50
59
  pt.service = target_service
51
60
  pt.username = pgt.service_ticket.username
52
61
  pt.proxy_granting_ticket_id = pgt.id
53
- pt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
62
+ pt.ticket_granting_ticket = pgt.service_ticket.ticket_granting_ticket
63
+ pt.client_hostname = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_HOST'] || env['REMOTE_ADDR']
54
64
  pt.save!
55
65
  $LOG.debug("Generated proxy ticket '#{pt.ticket}' for target service '#{pt.service}'" +
56
66
  " for user '#{pt.username}' at '#{pt.client_hostname}' using proxy-granting" +
@@ -77,7 +87,7 @@ module CASServer::CAS
77
87
  pgt.ticket = "PGT-" + CASServer::Utils.random_string(60)
78
88
  pgt.iou = "PGTIOU-" + CASServer::Utils.random_string(57)
79
89
  pgt.service_ticket_id = st.id
80
- pgt.client_hostname = env['REMOTE_HOST'] || env['REMOTE_ADDR']
90
+ pgt.client_hostname = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_HOST'] || env['REMOTE_ADDR']
81
91
 
82
92
  # FIXME: The CAS protocol spec says to use 'pgt' as the parameter, but in practice
83
93
  # the JA-SIG and Yale server implementations use pgtId. We'll go with the
@@ -150,27 +160,27 @@ module CASServer::CAS
150
160
  $LOG.debug("Validating service/proxy ticket '#{ticket}' for service '#{service}'")
151
161
 
152
162
  if service.nil? or ticket.nil?
153
- error = Error.new("INVALID_REQUEST", "Ticket or service parameter was missing in the request.")
163
+ error = Error.new(:INVALID_REQUEST, "Ticket or service parameter was missing in the request.")
154
164
  $LOG.warn("#{error.code} - #{error.message}")
155
165
  elsif st = ServiceTicket.find_by_ticket(ticket)
156
166
  if st.consumed?
157
- error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' has already been used up.")
167
+ error = Error.new(:INVALID_TICKET, "Ticket '#{ticket}' has already been used up.")
158
168
  $LOG.warn("#{error.code} - #{error.message}")
159
169
  elsif st.kind_of?(CASServer::Models::ProxyTicket) && !allow_proxy_tickets
160
- error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' is a proxy ticket, but only service tickets are allowed here.")
170
+ error = Error.new(:INVALID_TICKET, "Ticket '#{ticket}' is a proxy ticket, but only service tickets are allowed here.")
161
171
  $LOG.warn("#{error.code} - #{error.message}")
162
172
  elsif Time.now - st.created_on > CASServer::Conf.service_ticket_expiry
163
- error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' has expired.")
173
+ error = Error.new(:INVALID_TICKET, "Ticket '#{ticket}' has expired.")
164
174
  $LOG.warn("Ticket '#{ticket}' has expired.")
165
- elsif st.matches_service? service
166
- $LOG.info("Ticket '#{ticket}' for service '#{service}' for user '#{st.username}' successfully validated.")
167
- else
168
- error = Error.new("INVALID_SERVICE", "The ticket '#{ticket}' belonging to user '#{st.username}' is valid,"+
175
+ elsif !st.matches_service? service
176
+ error = Error.new(:INVALID_SERVICE, "The ticket '#{ticket}' belonging to user '#{st.username}' is valid,"+
169
177
  " but the requested service '#{service}' does not match the service '#{st.service}' associated with this ticket.")
170
178
  $LOG.warn("#{error.code} - #{error.message}")
179
+ else
180
+ $LOG.info("Ticket '#{ticket}' for service '#{service}' for user '#{st.username}' successfully validated.")
171
181
  end
172
182
  else
173
- error = Error.new("INVALID_TICKET", "Ticket '#{ticket}' not recognized.")
183
+ error = Error.new(:INVALID_TICKET, "Ticket '#{ticket}' not recognized.")
174
184
  $LOG.warn("#{error.code} - #{error.message}")
175
185
  end
176
186
 
@@ -187,9 +197,9 @@ module CASServer::CAS
187
197
 
188
198
  if pt.kind_of?(CASServer::Models::ProxyTicket) && !error
189
199
  if not pt.proxy_granting_ticket
190
- error = Error.new("INTERNAL_ERROR", "Proxy ticket '#{pt}' belonging to user '#{pt.username}' is not associated with a proxy granting ticket.")
200
+ error = Error.new(:INTERNAL_ERROR, "Proxy ticket '#{pt}' belonging to user '#{pt.username}' is not associated with a proxy granting ticket.")
191
201
  elsif not pt.proxy_granting_ticket.service_ticket
192
- error = Error.new("INTERNAL_ERROR", "Proxy granting ticket '#{pt.proxy_granting_ticket}'"+
202
+ error = Error.new(:INTERNAL_ERROR, "Proxy granting ticket '#{pt.proxy_granting_ticket}'"+
193
203
  " (associated with proxy ticket '#{pt}' and belonging to user '#{pt.username}' is not associated with a service ticket.")
194
204
  end
195
205
  end
@@ -199,23 +209,61 @@ module CASServer::CAS
199
209
 
200
210
  def validate_proxy_granting_ticket(ticket)
201
211
  if ticket.nil?
202
- error = Error.new("INVALID_REQUEST", "pgt parameter was missing in the request.")
212
+ error = Error.new(:INVALID_REQUEST, "pgt parameter was missing in the request.")
203
213
  $LOG.warn("#{error.code} - #{error.message}")
204
214
  elsif pgt = ProxyGrantingTicket.find_by_ticket(ticket)
205
215
  if pgt.service_ticket
206
216
  $LOG.info("Proxy granting ticket '#{ticket}' belonging to user '#{pgt.service_ticket.username}' successfully validated.")
207
217
  else
208
- error = Error.new("INTERNAL_ERROR", "Proxy granting ticket '#{ticket}' is not associated with a service ticket.")
218
+ error = Error.new(:INTERNAL_ERROR, "Proxy granting ticket '#{ticket}' is not associated with a service ticket.")
209
219
  $LOG.error("#{error.code} - #{error.message}")
210
220
  end
211
221
  else
212
- error = Error.new("BAD_PGT", "Invalid proxy granting ticket '#{ticket}' (no matching ticket found in the database).")
222
+ error = Error.new(:BAD_PGT, "Invalid proxy granting ticket '#{ticket}' (no matching ticket found in the database).")
213
223
  $LOG.warn("#{error.code} - #{error.message}")
214
224
  end
215
225
 
216
226
  [pgt, error]
217
227
  end
218
228
 
229
+ # Takes an existing ServiceTicket object (presumably pulled from the database)
230
+ # and sends a POST with logout information to the service that the ticket
231
+ # was generated for.
232
+ #
233
+ # This makes possible the "single sign-out" functionality added in CAS 3.1.
234
+ # See http://www.ja-sig.org/wiki/display/CASUM/Single+Sign+Out
235
+ def send_logout_notification_for_service_ticket(st)
236
+ uri = URI.parse(st.service)
237
+ http = Net::HTTP.new(uri.host, uri.port)
238
+ #http.use_ssl = true if uri.scheme = 'https'
239
+
240
+ time = Time.now
241
+ rand = CASServer::Utils.random_string
242
+
243
+ path = uri.path
244
+ path = '/' if path.empty?
245
+
246
+ req = Net::HTTP::Post.new(path)
247
+ req.set_form_data(
248
+ 'logoutRequest' => %{<samlp:LogoutRequest ID="#{rand}" Version="2.0" IssueInstant="#{time.rfc2822}">
249
+ <saml:NameID></saml:NameID>
250
+ <samlp:SessionIndex>#{st.ticket}</samlp:SessionIndex>
251
+ </samlp:LogoutRequest>}
252
+ )
253
+
254
+ http.start do |conn|
255
+ response = conn.request(req)
256
+
257
+ if response.kind_of? Net::HTTPSuccess
258
+ $LOG.info "Logout notification successfully posted to #{st.service.inspect}."
259
+ return true
260
+ else
261
+ $LOG.error "Service #{st.service.inspect} responed to logout notification with code '#{response.code}'!"
262
+ return false
263
+ end
264
+ end
265
+ end
266
+
219
267
  def service_uri_with_ticket(service, st)
220
268
  raise ArgumentError, "Second argument must be a ServiceTicket!" unless st.kind_of? CASServer::Models::ServiceTicket
221
269
 
@@ -237,4 +285,31 @@ module CASServer::CAS
237
285
  service_with_ticket
238
286
  end
239
287
 
288
+ # Strips CAS-related parameters from a service URL and normalizes it,
289
+ # removing trailing / and ?. Also converts any spaces to +.
290
+ #
291
+ # For example, "http://google.com?ticket=12345" will be returned as
292
+ # "http://google.com". Also, "http://google.com/" would be returned as
293
+ # "http://google.com".
294
+ #
295
+ # Note that only the first occurance of each CAS-related parameter is
296
+ # removed, so that "http://google.com?ticket=12345&ticket=abcd" would be
297
+ # returned as "http://google.com?ticket=abcd".
298
+ def clean_service_url(dirty_service)
299
+ return dirty_service if dirty_service.blank?
300
+ clean_service = dirty_service.dup
301
+ ['service', 'ticket', 'gateway', 'renew'].each do |p|
302
+ clean_service.sub!(Regexp.new("#{p}=[^&]*"), '')
303
+ end
304
+
305
+ clean_service.gsub!(/[\/\?]$/, '')
306
+ clean_service.gsub!(' ', '+')
307
+
308
+ $LOG.debug("Cleaned dirty service URL #{dirty_service.inspect} to #{clean_service.inspect}") if
309
+ dirty_service != clean_service
310
+
311
+ return clean_service
312
+ end
313
+ module_function :clean_service_url
314
+
240
315
  end