diff --git a/.gitignore b/.gitignore index a1915836..90ad19cb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ /pkg +# http://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/ +/Gemfile.lock + # Dummy application crap /spec/dummy/log/*.log /spec/dummy/tmp diff --git a/Gemfile.lock b/Gemfile.lock index 944904a7..bb05aad8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,21 +1,25 @@ PATH remote: . specs: - casino (1.3.1) - casino_core (~> 1.4.0) + casino (1.3.2) + addressable (~> 2.3) + faraday (~> 0.8) http_accept_language (~> 2.0.0.pre) jquery-rails (~> 2.1) rails (~> 3.2.9) + rotp (~> 1.4) + terminal-table (~> 1.4) + useragent (~> 0.4) GEM remote: https://rubygems.org/ specs: - actionmailer (3.2.13) - actionpack (= 3.2.13) - mail (~> 2.5.3) - actionpack (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) + actionmailer (3.2.14) + actionpack (= 3.2.14) + mail (~> 2.5.4) + actionpack (3.2.14) + activemodel (= 3.2.14) + activesupport (= 3.2.14) builder (~> 3.0.0) erubis (~> 2.7.0) journey (~> 1.0.4) @@ -23,21 +27,21 @@ GEM rack-cache (~> 1.2) rack-test (~> 0.6.1) sprockets (~> 2.2.1) - activemodel (3.2.13) - activesupport (= 3.2.13) + activemodel (3.2.14) + activesupport (= 3.2.14) builder (~> 3.0.0) - activerecord (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) + activerecord (3.2.14) + activemodel (= 3.2.14) + activesupport (= 3.2.14) arel (~> 3.0.2) tzinfo (~> 0.3.29) - activeresource (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) - activesupport (3.2.13) - i18n (= 0.6.1) + activeresource (3.2.14) + activemodel (= 3.2.14) + activesupport (= 3.2.14) + activesupport (3.2.14) + i18n (~> 0.6, >= 0.6.4) multi_json (~> 1.0) - addressable (2.3.4) + addressable (2.3.5) arel (3.0.2) builder (3.0.4) capybara (2.1.0) @@ -46,33 +50,31 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) - casino_core (1.4.3) - activerecord (~> 3.2.9) - addressable (~> 2.3) - faraday (~> 0.8) - rotp (~> 1.4) - terminal-table (~> 1.4) - useragent (~> 0.4) + crack (0.4.1) + safe_yaml (~> 0.9.0) diff-lcs (1.2.4) erubis (2.7.0) - faraday (0.8.7) - multipart-post (~> 1.1) - hike (1.2.2) + factory_girl (4.2.0) + activesupport (>= 3.0.0) + faraday (0.8.8) + multipart-post (~> 1.2.0) + hike (1.2.3) http_accept_language (2.0.0.pre) - i18n (0.6.1) + i18n (0.6.5) journey (1.0.4) - jquery-rails (2.2.1) + jquery-rails (2.3.0) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) - json (1.7.7) - mail (2.5.3) - i18n (>= 0.4.0) + json (1.8.0) + mail (2.5.4) mime-types (~> 1.16) treetop (~> 1.4.8) mime-types (1.23) - multi_json (1.7.2) + mini_portile (0.5.1) + multi_json (1.7.9) multipart-post (1.2.0) - nokogiri (1.5.9) + nokogiri (1.6.0) + mini_portile (~> 0.5.0) polyglot (0.3.3) rack (1.4.5) rack-cache (1.2) @@ -81,40 +83,41 @@ GEM rack rack-test (0.6.2) rack (>= 1.0) - rails (3.2.13) - actionmailer (= 3.2.13) - actionpack (= 3.2.13) - activerecord (= 3.2.13) - activeresource (= 3.2.13) - activesupport (= 3.2.13) + rails (3.2.14) + actionmailer (= 3.2.14) + actionpack (= 3.2.14) + activerecord (= 3.2.14) + activeresource (= 3.2.14) + activesupport (= 3.2.14) bundler (~> 1.0) - railties (= 3.2.13) - railties (3.2.13) - actionpack (= 3.2.13) - activesupport (= 3.2.13) + railties (= 3.2.14) + railties (3.2.14) + actionpack (= 3.2.14) + activesupport (= 3.2.14) rack-ssl (~> 1.3.2) rake (>= 0.8.7) rdoc (~> 3.4) thor (>= 0.14.6, < 2.0) - rake (10.0.4) + rake (10.1.0) rdoc (3.12.2) json (~> 1.4) rotp (1.4.1) - rspec (2.13.0) - rspec-core (~> 2.13.0) - rspec-expectations (~> 2.13.0) - rspec-mocks (~> 2.13.0) - rspec-core (2.13.1) - rspec-expectations (2.13.0) + rspec (2.14.1) + rspec-core (~> 2.14.0) + rspec-expectations (~> 2.14.0) + rspec-mocks (~> 2.14.0) + rspec-core (2.14.5) + rspec-expectations (2.14.1) diff-lcs (>= 1.1.3, < 2.0) - rspec-mocks (2.13.1) - rspec-rails (2.13.0) + rspec-mocks (2.14.3) + rspec-rails (2.14.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 2.13.0) - rspec-expectations (~> 2.13.0) - rspec-mocks (~> 2.13.0) + rspec-core (~> 2.14.0) + rspec-expectations (~> 2.14.0) + rspec-mocks (~> 2.14.0) + safe_yaml (0.9.5) simplecov (0.7.1) multi_json (~> 1.0) simplecov-html (~> 0.7.1) @@ -127,12 +130,15 @@ GEM sqlite3 (1.3.7) terminal-table (1.4.5) thor (0.18.1) - tilt (1.3.7) - treetop (1.4.12) + tilt (1.4.1) + treetop (1.4.14) polyglot polyglot (>= 0.3.1) tzinfo (0.3.37) - useragent (0.5.0) + useragent (0.6.0) + webmock (1.13.0) + addressable (>= 2.2.7) + crack (>= 0.3.2) xpath (2.0.0) nokogiri (~> 1.3) @@ -142,8 +148,10 @@ PLATFORMS DEPENDENCIES capybara (~> 2.1) casino! + factory_girl (~> 4.1) rake (~> 10.0) rspec (~> 2.12) rspec-rails (~> 2.0) simplecov (~> 0.7) sqlite3 (~> 1.3) + webmock (~> 1.9) diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..2405991b --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,41 @@ +# Upgrade CASinoCore + +Here is a list of backward-incompatible changes that were introduced. + +## 1.4.0 + +This release changed some database structure. Be sure to advise users to migrate the database using `bundle exec rake casino_core:db:migrate`. + +API changes: + +* `LoginCredentialAcceptor`: `user_logged_in` may receive a third argument (`Time`, optional, default = `nil`) which represents the expiry date of the cookie. If it is `nil`, the cookie should be a session cookie. +* `Logout`: `user_logged_out` may receive a second argument (`boolean`, optional, default = `false`). When it is `true`, the user should be redirected immediately. + +## 1.3.0 + +This release adds support for two-factor authentication using a [TOTP](http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm) (time-based one-time password) which can be generated with applications like [Google Authenticator](http://support.google.com/a/bin/answer.py?hl=en&answer=1037451) (iPhone, Android, BlackBerry) or gadgets such as the [YubiKey](http://www.yubico.com/products/yubikey-hardware/yubikey/). + +If you would like to support two-factor authentication in your web application, please have a look at the corresponding processors: `SecondFactorAuthenticationAcceptor`, `TwoFactorAuthenticatorActivator`, `TwoFactorAuthenticatorDestroyer`, `TwoFactorAuthenticatorOverview`, `TwoFactorAuthenticatorRegistrator` + +New callbacks: + +* `LoginCredentialAcceptor`: calls `#two_factor_authentication_pending` on the listener, when two-factor authentication is enabled for this user. + +If you don't want to support two-factor authentication, nothing has to be changed. + +## 1.2.0 + +API changes: + +* We extracted user data into an entity. Because of this, attributes such as `username` are no longer accessible directly on a `ticket_granting_ticket`. Use `ticket_granting_ticket.user.username` instead. + +## 1.1.0 + +API changes: + +* `LoginCredentialAcceptor`: The parameters of `#process` changed from `params, cookies, user_agent` to just `params, user_agent` + +New callbacks: + +* `LoginCredentialRequestor` and `LoginCredentialAcceptor` call `#service_not_allowed` on the listener, when a service is not in the service whitelist. +* `API::ServiceTicketProvider` calls `#service_not_allowed_via_api` on the listener, when a service is not in the service whitelist. diff --git a/casino.gemspec b/casino.gemspec index 8ac080e0..d0237423 100644 --- a/casino.gemspec +++ b/casino.gemspec @@ -29,9 +29,15 @@ Gem::Specification.new do |s| s.add_development_dependency 'rspec-rails', '~> 2.0' s.add_development_dependency 'simplecov', '~> 0.7' s.add_development_dependency 'sqlite3', '~> 1.3' + s.add_development_dependency 'factory_girl', '~> 4.1' + s.add_development_dependency 'webmock', '~> 1.9' s.add_runtime_dependency 'rails', '~> 3.2.9' s.add_runtime_dependency 'jquery-rails', '~> 2.1' s.add_runtime_dependency 'http_accept_language', '~> 2.0.0.pre' - s.add_runtime_dependency 'casino_core', '~> 1.4.0' + s.add_runtime_dependency 'addressable', '~> 2.3' + s.add_runtime_dependency 'terminal-table', '~> 1.4' + s.add_runtime_dependency 'useragent', '~> 0.4' + s.add_runtime_dependency 'faraday', '~> 0.8' + s.add_runtime_dependency 'rotp', '~> 1.4' end diff --git a/config/cas.yml b/config/cas.yml new file mode 100644 index 00000000..f18ca4b2 --- /dev/null +++ b/config/cas.yml @@ -0,0 +1,24 @@ +defaults: &defaults + service_ticket: + lifetime_unconsumed: 299 + authenticators: + static_1: + class: "CASinoCore::Authenticator::Static" + options: + users: + testuser: + password: "foobar123" + name: "Test User" + static_2: + class: "CASinoCore::Authenticator::Static" + options: + users: + example: + password: "dito123" + name: "Test User" + +development: + <<: *defaults + +test: + <<: *defaults diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000..6f94a7ba --- /dev/null +++ b/config/database.yml @@ -0,0 +1,19 @@ +# this configuration is only needed to setup the database for the tests + +# SQLite version 3.x +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem 'sqlite3' +development: + adapter: sqlite3 + database: db/development.sqlite3 + pool: 5 + timeout: 5000 + +test: + adapter: sqlite3 + database: ':memory:' + pool: 5 + timeout: 5000 + verbosity: quiet diff --git a/db/migrate/20121112154930_create_ticket_granting_tickets.rb b/db/migrate/20121112154930_create_ticket_granting_tickets.rb new file mode 100644 index 00000000..8371a6df --- /dev/null +++ b/db/migrate/20121112154930_create_ticket_granting_tickets.rb @@ -0,0 +1,11 @@ +class CreateTicketGrantingTickets < ActiveRecord::Migration + def change + create_table :ticket_granting_tickets do |t| + t.string :ticket, null: false, unique: true + t.string :username, null: false + t.text :extra_attributes + + t.timestamps + end + end +end diff --git a/db/migrate/20121112160009_create_login_tickets.rb b/db/migrate/20121112160009_create_login_tickets.rb new file mode 100644 index 00000000..54ac5e84 --- /dev/null +++ b/db/migrate/20121112160009_create_login_tickets.rb @@ -0,0 +1,9 @@ +class CreateLoginTickets < ActiveRecord::Migration + def change + create_table :login_tickets do |t| + t.string :ticket + + t.timestamps + end + end +end diff --git a/db/migrate/20121112165804_ticket_should_not_be_null.rb b/db/migrate/20121112165804_ticket_should_not_be_null.rb new file mode 100644 index 00000000..d0d56b5b --- /dev/null +++ b/db/migrate/20121112165804_ticket_should_not_be_null.rb @@ -0,0 +1,5 @@ +class TicketShouldNotBeNull < ActiveRecord::Migration + def change + change_column :login_tickets, :ticket, :string, null: false, unique: true + end +end diff --git a/db/migrate/20121122180310_add_user_agent_to_ticket_granting_tickets.rb b/db/migrate/20121122180310_add_user_agent_to_ticket_granting_tickets.rb new file mode 100644 index 00000000..b8d1ec56 --- /dev/null +++ b/db/migrate/20121122180310_add_user_agent_to_ticket_granting_tickets.rb @@ -0,0 +1,5 @@ +class AddUserAgentToTicketGrantingTickets < ActiveRecord::Migration + def change + add_column :ticket_granting_tickets, :user_agent, :string + end +end diff --git a/db/migrate/20121124170004_add_index_for_username_to_ticket_granting_tickets.rb b/db/migrate/20121124170004_add_index_for_username_to_ticket_granting_tickets.rb new file mode 100644 index 00000000..0ca84b95 --- /dev/null +++ b/db/migrate/20121124170004_add_index_for_username_to_ticket_granting_tickets.rb @@ -0,0 +1,5 @@ +class AddIndexForUsernameToTicketGrantingTickets < ActiveRecord::Migration + def change + add_index :ticket_granting_tickets, :username + end +end diff --git a/db/migrate/20121124183542_create_service_tickets.rb b/db/migrate/20121124183542_create_service_tickets.rb new file mode 100644 index 00000000..0f67049d --- /dev/null +++ b/db/migrate/20121124183542_create_service_tickets.rb @@ -0,0 +1,13 @@ +class CreateServiceTickets < ActiveRecord::Migration + def change + create_table :service_tickets do |t| + t.string :ticket, null: false, unique: true + t.string :service, null: false + t.integer :ticket_granting_ticket_id, null: false + + t.timestamps + end + add_index :service_tickets, :ticket + add_index :service_tickets, :ticket_granting_ticket_id + end +end diff --git a/db/migrate/20121124183732_add_ticket_indexes.rb b/db/migrate/20121124183732_add_ticket_indexes.rb new file mode 100644 index 00000000..f82730a2 --- /dev/null +++ b/db/migrate/20121124183732_add_ticket_indexes.rb @@ -0,0 +1,6 @@ +class AddTicketIndexes < ActiveRecord::Migration + def change + add_index :ticket_granting_tickets, :ticket + add_index :login_tickets, :ticket + end +end diff --git a/db/migrate/20121124195013_add_consumed_to_service_tickets.rb b/db/migrate/20121124195013_add_consumed_to_service_tickets.rb new file mode 100644 index 00000000..fb066d54 --- /dev/null +++ b/db/migrate/20121124195013_add_consumed_to_service_tickets.rb @@ -0,0 +1,5 @@ +class AddConsumedToServiceTickets < ActiveRecord::Migration + def change + add_column :service_tickets, :consumed, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20121125091934_add_issued_from_credentials_to_service_tickets.rb b/db/migrate/20121125091934_add_issued_from_credentials_to_service_tickets.rb new file mode 100644 index 00000000..92b945df --- /dev/null +++ b/db/migrate/20121125091934_add_issued_from_credentials_to_service_tickets.rb @@ -0,0 +1,5 @@ +class AddIssuedFromCredentialsToServiceTickets < ActiveRecord::Migration + def change + add_column :service_tickets, :issued_from_credentials, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20121125185415_create_proxy_granting_tickets.rb b/db/migrate/20121125185415_create_proxy_granting_tickets.rb new file mode 100644 index 00000000..b1e707e4 --- /dev/null +++ b/db/migrate/20121125185415_create_proxy_granting_tickets.rb @@ -0,0 +1,14 @@ +class CreateProxyGrantingTickets < ActiveRecord::Migration + def change + create_table :proxy_granting_tickets do |t| + t.string :ticket, null: false + t.string :iou, null: false + t.integer :ticket_granting_ticket_id, null: false + + t.timestamps + end + add_index :proxy_granting_tickets, :ticket, unique: true + add_index :proxy_granting_tickets, :iou, unique: true + add_index :proxy_granting_tickets, :ticket_granting_ticket_id + end +end diff --git a/db/migrate/20121125190013_tickets_should_be_unique.rb b/db/migrate/20121125190013_tickets_should_be_unique.rb new file mode 100644 index 00000000..3d2da066 --- /dev/null +++ b/db/migrate/20121125190013_tickets_should_be_unique.rb @@ -0,0 +1,8 @@ +class TicketsShouldBeUnique < ActiveRecord::Migration + def change + [:login_tickets, :service_tickets, :ticket_granting_tickets].each do |table| + remove_index table, :ticket + add_index table, :ticket, unique: true + end + end +end diff --git a/db/migrate/20121223135227_proxy_granting_tickets_belongs_to_service_ticket.rb b/db/migrate/20121223135227_proxy_granting_tickets_belongs_to_service_ticket.rb new file mode 100644 index 00000000..f510a214 --- /dev/null +++ b/db/migrate/20121223135227_proxy_granting_tickets_belongs_to_service_ticket.rb @@ -0,0 +1,14 @@ +require 'casino_core/model' + +class ProxyGrantingTicketsBelongsToServiceTicket < ActiveRecord::Migration + def change + CASinoCore::Model::ProxyGrantingTicket.delete_all + + remove_index :proxy_granting_tickets, :ticket_granting_ticket_id + remove_column :proxy_granting_tickets, :ticket_granting_ticket_id + + add_column :proxy_granting_tickets, :service_ticket_id, :integer + change_column :proxy_granting_tickets, :service_ticket_id, :integer, null: false + add_index :proxy_granting_tickets, :service_ticket_id + end +end diff --git a/db/migrate/20121224113737_create_proxy_tickets.rb b/db/migrate/20121224113737_create_proxy_tickets.rb new file mode 100644 index 00000000..6cdc43cc --- /dev/null +++ b/db/migrate/20121224113737_create_proxy_tickets.rb @@ -0,0 +1,15 @@ +class CreateProxyTickets < ActiveRecord::Migration + def change + create_table :proxy_tickets do |t| + t.string :ticket, null: false + t.string :service, null: false + t.boolean :consumed, null: false, default: false + t.integer :proxy_granting_ticket_id, null: false + + t.timestamps + end + + add_index :proxy_tickets, :ticket, unique: true + add_index :proxy_tickets, :proxy_granting_ticket_id + end +end diff --git a/db/migrate/20121225153637_add_pgt_url_to_proxy_granting_tickets.rb b/db/migrate/20121225153637_add_pgt_url_to_proxy_granting_tickets.rb new file mode 100644 index 00000000..ab524a5f --- /dev/null +++ b/db/migrate/20121225153637_add_pgt_url_to_proxy_granting_tickets.rb @@ -0,0 +1,11 @@ +class AddPgtUrlToProxyGrantingTickets < ActiveRecord::Migration + def up + add_column :proxy_granting_tickets, :pgt_url, :string, null: true + CASinoCore::Model::ProxyGrantingTicket.delete_all + change_column :proxy_granting_tickets, :pgt_url, :string, null: false + end + + def down + remove_column :proxy_granting_tickets, :pgt_url + end +end diff --git a/db/migrate/20121225231301_proxy_granting_ticket_can_be_granted_by_proxy_ticket.rb b/db/migrate/20121225231301_proxy_granting_ticket_can_be_granted_by_proxy_ticket.rb new file mode 100644 index 00000000..84ec5778 --- /dev/null +++ b/db/migrate/20121225231301_proxy_granting_ticket_can_be_granted_by_proxy_ticket.rb @@ -0,0 +1,6 @@ +class ProxyGrantingTicketCanBeGrantedByProxyTicket < ActiveRecord::Migration + def up + add_column :proxy_granting_tickets, :granter_type, :string, null: false, default: 'ServiceTicket' + rename_column :proxy_granting_tickets, :service_ticket_id, :granter_id + end +end diff --git a/db/migrate/20121225231713_no_default_granter_type.rb b/db/migrate/20121225231713_no_default_granter_type.rb new file mode 100644 index 00000000..796b2258 --- /dev/null +++ b/db/migrate/20121225231713_no_default_granter_type.rb @@ -0,0 +1,5 @@ +class NoDefaultGranterType < ActiveRecord::Migration + def up + change_column_default :proxy_granting_tickets, :granter_type, nil + end +end diff --git a/db/migrate/20121226192211_fix_index_for_granter_on_proxy_granting_ticket.rb b/db/migrate/20121226192211_fix_index_for_granter_on_proxy_granting_ticket.rb new file mode 100644 index 00000000..f54ef42a --- /dev/null +++ b/db/migrate/20121226192211_fix_index_for_granter_on_proxy_granting_ticket.rb @@ -0,0 +1,6 @@ +class FixIndexForGranterOnProxyGrantingTicket < ActiveRecord::Migration + def change + remove_index :proxy_granting_tickets, :service_ticket_id + add_index :proxy_granting_tickets, [:granter_type, :granter_id], unique: true + end +end diff --git a/db/migrate/20121226211511_allow_service_tickets_without_ticket_granting_ticket.rb b/db/migrate/20121226211511_allow_service_tickets_without_ticket_granting_ticket.rb new file mode 100644 index 00000000..869e0993 --- /dev/null +++ b/db/migrate/20121226211511_allow_service_tickets_without_ticket_granting_ticket.rb @@ -0,0 +1,5 @@ +class AllowServiceTicketsWithoutTicketGrantingTicket < ActiveRecord::Migration + def change + change_column :service_tickets, :ticket_granting_ticket_id, :integer, null: true + end +end diff --git a/db/migrate/20121231114141_add_authenticator_to_ticket_granting_tickets.rb b/db/migrate/20121231114141_add_authenticator_to_ticket_granting_tickets.rb new file mode 100644 index 00000000..42aa12c0 --- /dev/null +++ b/db/migrate/20121231114141_add_authenticator_to_ticket_granting_tickets.rb @@ -0,0 +1,15 @@ +class AddAuthenticatorToTicketGrantingTickets < ActiveRecord::Migration + def up + add_column :ticket_granting_tickets, :authenticator, :string, null: true + CASinoCore::Model::TicketGrantingTicket.delete_all + change_column :ticket_granting_tickets, :authenticator, :string, null: false + add_index :ticket_granting_tickets, [:authenticator, :username] + remove_index :ticket_granting_tickets, :username + end + + def down + remove_index :ticket_granting_tickets, [:authenticator, :username] + remove_column :ticket_granting_tickets, :authenticator + add_index :ticket_granting_tickets, :username + end +end diff --git a/db/migrate/20130105152327_create_service_rules.rb b/db/migrate/20130105152327_create_service_rules.rb new file mode 100644 index 00000000..97978f1e --- /dev/null +++ b/db/migrate/20130105152327_create_service_rules.rb @@ -0,0 +1,15 @@ +class CreateServiceRules < ActiveRecord::Migration + def change + create_table :service_rules do |t| + t.boolean :enabled, null: false, default: true + t.integer :order, null: false, default: 10 + t.string :name, null: false + t.string :url, null: false + t.boolean :regex, null: false, default: false + + t.timestamps + end + + add_index :service_rules, :url, unique: true + end +end diff --git a/db/migrate/20130202210100_create_users.rb b/db/migrate/20130202210100_create_users.rb new file mode 100644 index 00000000..65b1422d --- /dev/null +++ b/db/migrate/20130202210100_create_users.rb @@ -0,0 +1,28 @@ +class CreateUsers < ActiveRecord::Migration + def up + create_table :users do |t| + t.string :authenticator, null: false + t.string :username, null: false + t.text :extra_attributes + + t.timestamps + end + + add_index :users, [:authenticator, :username], unique: true + + remove_index :ticket_granting_tickets, [:authenticator, :username] + add_column :ticket_granting_tickets, :user_id, :integer, null: true + CASinoCore::Model::TicketGrantingTicket.reset_column_information + CASinoCore::Model::TicketGrantingTicket.all.each do |ticket| + user = CASinoCore::Model::User.where( + authenticator: ticket.authenticator, + username: ticket.username).first_or_initialize + user.extra_attributes = ticket.extra_attributes + user.save! + ticket.user_id = user.id + ticket.save! + end + change_column :ticket_granting_tickets, :user_id, :integer, null: false + remove_columns :ticket_granting_tickets, :authenticator, :username, :extra_attributes + end +end diff --git a/db/migrate/20130203100015_create_two_factor_authenticators.rb b/db/migrate/20130203100015_create_two_factor_authenticators.rb new file mode 100644 index 00000000..feb4ec41 --- /dev/null +++ b/db/migrate/20130203100015_create_two_factor_authenticators.rb @@ -0,0 +1,12 @@ +class CreateTwoFactorAuthenticators < ActiveRecord::Migration + def change + create_table :two_factor_authenticators do |t| + t.integer :user_id, null: false + t.string :secret, null: false + + t.timestamps + end + + add_index :two_factor_authenticators, :user_id + end +end diff --git a/db/migrate/20130203101351_add_active_to_two_factor_authenticators.rb b/db/migrate/20130203101351_add_active_to_two_factor_authenticators.rb new file mode 100644 index 00000000..fbb3e277 --- /dev/null +++ b/db/migrate/20130203101351_add_active_to_two_factor_authenticators.rb @@ -0,0 +1,5 @@ +class AddActiveToTwoFactorAuthenticators < ActiveRecord::Migration + def change + add_column :two_factor_authenticators, :active, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20130203155008_add_awaiting_two_factor_authentication_to_ticket_granting_tickets.rb b/db/migrate/20130203155008_add_awaiting_two_factor_authentication_to_ticket_granting_tickets.rb new file mode 100644 index 00000000..ebec4a55 --- /dev/null +++ b/db/migrate/20130203155008_add_awaiting_two_factor_authentication_to_ticket_granting_tickets.rb @@ -0,0 +1,5 @@ +class AddAwaitingTwoFactorAuthenticationToTicketGrantingTickets < ActiveRecord::Migration + def change + add_column :ticket_granting_tickets, :awaiting_two_factor_authentication, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20130323111208_add_long_term_to_ticket_granting_tickets.rb b/db/migrate/20130323111208_add_long_term_to_ticket_granting_tickets.rb new file mode 100644 index 00000000..b64e5a90 --- /dev/null +++ b/db/migrate/20130323111208_add_long_term_to_ticket_granting_tickets.rb @@ -0,0 +1,5 @@ +class AddLongTermToTicketGrantingTickets < ActiveRecord::Migration + def change + add_column :ticket_granting_tickets, :long_term, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 00000000..3d55a6c3 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,107 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended to check this file into your version control system. + +ActiveRecord::Schema.define(:version => 20130323111208) do + + create_table "login_tickets", :force => true do |t| + t.string "ticket", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "login_tickets", ["ticket"], :name => "index_login_tickets_on_ticket", :unique => true + + create_table "proxy_granting_tickets", :force => true do |t| + t.string "ticket", :null => false + t.string "iou", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "granter_id", :null => false + t.string "pgt_url", :null => false + t.string "granter_type", :null => false + end + + add_index "proxy_granting_tickets", ["granter_type", "granter_id"], :name => "index_proxy_granting_tickets_on_granter_type_and_granter_id", :unique => true + add_index "proxy_granting_tickets", ["iou"], :name => "index_proxy_granting_tickets_on_iou", :unique => true + add_index "proxy_granting_tickets", ["ticket"], :name => "index_proxy_granting_tickets_on_ticket", :unique => true + + create_table "proxy_tickets", :force => true do |t| + t.string "ticket", :null => false + t.string "service", :null => false + t.boolean "consumed", :default => false, :null => false + t.integer "proxy_granting_ticket_id", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "proxy_tickets", ["proxy_granting_ticket_id"], :name => "index_proxy_tickets_on_proxy_granting_ticket_id" + add_index "proxy_tickets", ["ticket"], :name => "index_proxy_tickets_on_ticket", :unique => true + + create_table "service_rules", :force => true do |t| + t.boolean "enabled", :default => true, :null => false + t.integer "order", :default => 10, :null => false + t.string "name", :null => false + t.string "url", :null => false + t.boolean "regex", :default => false, :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "service_rules", ["url"], :name => "index_service_rules_on_url", :unique => true + + create_table "service_tickets", :force => true do |t| + t.string "ticket", :null => false + t.string "service", :null => false + t.integer "ticket_granting_ticket_id" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.boolean "consumed", :default => false, :null => false + t.boolean "issued_from_credentials", :default => false, :null => false + end + + add_index "service_tickets", ["ticket"], :name => "index_service_tickets_on_ticket", :unique => true + add_index "service_tickets", ["ticket_granting_ticket_id"], :name => "index_service_tickets_on_ticket_granting_ticket_id" + + create_table "ticket_granting_tickets", :force => true do |t| + t.string "ticket", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.string "user_agent" + t.integer "user_id", :null => false + t.boolean "awaiting_two_factor_authentication", :default => false, :null => false + t.boolean "long_term", :default => false, :null => false + end + + add_index "ticket_granting_tickets", ["ticket"], :name => "index_ticket_granting_tickets_on_ticket", :unique => true + + create_table "two_factor_authenticators", :force => true do |t| + t.integer "user_id", :null => false + t.string "secret", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.boolean "active", :default => false, :null => false + end + + add_index "two_factor_authenticators", ["user_id"], :name => "index_two_factor_authenticators_on_user_id" + + create_table "users", :force => true do |t| + t.string "authenticator", :null => false + t.string "username", :null => false + t.text "extra_attributes" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "users", ["authenticator", "username"], :name => "index_users_on_authenticator_and_username", :unique => true + +end diff --git a/lib/casino_core.rb b/lib/casino_core.rb new file mode 100644 index 00000000..b566906c --- /dev/null +++ b/lib/casino_core.rb @@ -0,0 +1,53 @@ +require 'active_support/inflector' +require 'active_record' + +module CASinoCore + autoload :Authenticator, 'casino_core/authenticator.rb' + autoload :Helper, 'casino_core/helper.rb' + autoload :Model, 'casino_core/model.rb' + autoload :Processor, 'casino_core/processor.rb' + autoload :RakeTasks, 'casino_core/rake_tasks.rb' + autoload :Settings, 'casino_core/settings.rb' + + require 'casino_core/railtie' if defined?(Rails) + + class << self + def setup(environment = nil, options = {}) + @environment = environment || 'development' + root_path = options[:application_root] || '.' + + establish_connection(@environment, root_path) unless active_record_connected? + + config = YAML.load_file(File.join(root_path, 'config/cas.yml'))[@environment].symbolize_keys + recursive_symbolize_keys!(config) + CASinoCore::Settings.init config + end + + private + def recursive_symbolize_keys! hash + # ugly, ugly, ugly + # TODO refactor! + hash.symbolize_keys! + hash.values.select{|v| v.is_a? Hash}.each{|h| recursive_symbolize_keys!(h)} + hash.values.select{|v| v.is_a? Array}.each{|a| a.select{|v| v.is_a? Hash}.each{|h| recursive_symbolize_keys!(h)}} + end + + def active_record_connected? + ActiveRecord::Base.connection + rescue ActiveRecord::ConnectionNotEstablished + false + end + + def establish_connection(env, root_path) + require 'yaml' + + db_cfg = YAML::load(ERB.new(IO.read(File.join(root_path, 'config/database.yml'))).result)[env] + ActiveRecord::Base.establish_connection db_cfg + end + end +end + +ActiveSupport::Inflector.inflections do |inflect| + inflect.acronym 'CAS' + inflect.acronym 'CASino' +end diff --git a/lib/casino_core/authenticator.rb b/lib/casino_core/authenticator.rb new file mode 100644 index 00000000..efddcc44 --- /dev/null +++ b/lib/casino_core/authenticator.rb @@ -0,0 +1,11 @@ +module CASinoCore + class Authenticator + autoload :Static, 'casino_core/authenticator/static.rb' + + class AuthenticatorError < StandardError; end + + def validate(username, password) + raise NotImplementedError, "This method must be implemented by a class extending #{self.class}" + end + end +end diff --git a/lib/casino_core/authenticator/static.rb b/lib/casino_core/authenticator/static.rb new file mode 100644 index 00000000..391a2717 --- /dev/null +++ b/lib/casino_core/authenticator/static.rb @@ -0,0 +1,23 @@ +require 'casino_core/authenticator' + +# The static authenticator is just a simple example. +# Never ever us this authenticator in a productive environment! +class CASinoCore::Authenticator::Static < CASinoCore::Authenticator + + # @param [Hash] options + def initialize(options) + @users = options[:users] || {} + end + + def validate(username, password) + username = :"#{username}" + if @users.include?(username) && @users[username][:password] == password + { + username: "#{username}", + extra_attributes: @users[username].except(:password) + } + else + false + end + end +end diff --git a/lib/casino_core/builder.rb b/lib/casino_core/builder.rb new file mode 100644 index 00000000..276b74af --- /dev/null +++ b/lib/casino_core/builder.rb @@ -0,0 +1,7 @@ +require 'active_record' + +module CASinoCore + class Builder + autoload :TicketValidationResponse, 'casino_core/builder/ticket_validation_response.rb' + end +end diff --git a/lib/casino_core/builder/ticket_validation_response.rb b/lib/casino_core/builder/ticket_validation_response.rb new file mode 100644 index 00000000..aaea30b0 --- /dev/null +++ b/lib/casino_core/builder/ticket_validation_response.rb @@ -0,0 +1,85 @@ +require 'builder' +require 'casino_core/builder' + +class CASinoCore::Builder::TicketValidationResponse < CASinoCore::Builder + def initialize(success, options) + @success = success + @options = options + end + + def build + xml = Builder::XmlMarkup.new(indent: 2) + xml.cas :serviceResponse, 'xmlns:cas' => 'http://www.yale.edu/tp/cas' do |service_response| + if @success + ticket = @options[:ticket] + if ticket.is_a?(CASinoCore::Model::ProxyTicket) + proxies = [] + service_ticket = ticket + while service_ticket.is_a?(CASinoCore::Model::ProxyTicket) + proxy_granting_ticket = ticket.proxy_granting_ticket + proxies << proxy_granting_ticket.pgt_url + service_ticket = proxy_granting_ticket.granter + end + ticket_granting_ticket = service_ticket.ticket_granting_ticket + else + service_ticket = ticket + ticket_granting_ticket = ticket.ticket_granting_ticket + end + + build_success_xml(service_response, ticket, service_ticket, ticket_granting_ticket, proxies) + else + build_failure_xml(service_response) + end + end + xml.target! + end + + private + def serialize_extra_attribute(builder, key, value) + if value.kind_of?(String) || value.kind_of?(Numeric) || value.kind_of?(Symbol) + builder.cas key, "#{value}" + else + builder.cas key do |container| + container.cdata! value.to_yaml + end + end + end + + def build_success_xml(service_response, ticket, service_ticket, ticket_granting_ticket, proxies) + user = ticket_granting_ticket.user + service_response.cas :authenticationSuccess do |authentication_success| + authentication_success.cas :user, user.username + unless user.extra_attributes.blank? + authentication_success.cas :attributes do |attributes| + attributes.cas :authenticationDate, ticket_granting_ticket.created_at.iso8601 + attributes.cas :longTermAuthenticationRequestTokenUsed, ticket_granting_ticket.long_term? + attributes.cas :isFromNewLogin, service_ticket.issued_from_credentials? + # This would probably be the correct way, but current clients do not support this: + # attributes.cas :userAttributes do |user_attributes| + # user.extra_attributes.each do |key, value| + # serialize_extra_attribute(user_attributes, key, value) + # end + # end + user.extra_attributes.each do |key, value| + serialize_extra_attribute(attributes, key, value) + end + end + end + if @options[:proxy_granting_ticket] + proxy_granting_ticket = @options[:proxy_granting_ticket] + authentication_success.cas :proxyGrantingTicket, proxy_granting_ticket.iou + end + if ticket.is_a?(CASinoCore::Model::ProxyTicket) + authentication_success.cas :proxies do |proxies_container| + proxies.each do |proxy| + proxies_container.cas :proxy, proxy + end + end + end + end + end + + def build_failure_xml(service_response) + service_response.cas :authenticationFailure, @options[:error_message], code: @options[:error_code] + end +end diff --git a/lib/casino_core/helper.rb b/lib/casino_core/helper.rb new file mode 100644 index 00000000..30d5495f --- /dev/null +++ b/lib/casino_core/helper.rb @@ -0,0 +1,17 @@ +require 'logger' +require 'useragent' + +module CASinoCore + module Helper + autoload :Authentication, 'casino_core/helper/authentication.rb' + autoload :Browser, 'casino_core/helper/browser.rb' + autoload :Logger, 'casino_core/helper/logger.rb' + autoload :LoginTickets, 'casino_core/helper/login_tickets.rb' + autoload :ProxyGrantingTickets, 'casino_core/helper/proxy_granting_tickets.rb' + autoload :ProxyTickets, 'casino_core/helper/proxy_tickets.rb' + autoload :ServiceTickets, 'casino_core/helper/service_tickets.rb' + autoload :Tickets, 'casino_core/helper/tickets.rb' + autoload :TicketGrantingTickets, 'casino_core/helper/ticket_granting_tickets.rb' + autoload :TwoFactorAuthenticators, 'casino_core/helper/two_factor_authenticators.rb' + end +end diff --git a/lib/casino_core/helper/authentication.rb b/lib/casino_core/helper/authentication.rb new file mode 100644 index 00000000..66e0cf3d --- /dev/null +++ b/lib/casino_core/helper/authentication.rb @@ -0,0 +1,24 @@ +module CASinoCore + module Helper + module Authentication + + def validate_login_credentials(username, password) + authentication_result = nil + CASinoCore::Settings.authenticators.each do |authenticator_name, authenticator| + begin + data = authenticator.validate(username, password) + rescue CASinoCore::Authenticator::AuthenticatorError => e + logger.error "Authenticator '#{authenticator_name}' (#{authenticator.class}) raised an error: #{e}" + end + if data + authentication_result = { authenticator: authenticator_name, user_data: data } + logger.info("Credentials for username '#{data[:username]}' successfully validated using authenticator '#{authenticator_name}' (#{authenticator.class})") + break + end + end + authentication_result + end + + end + end +end diff --git a/lib/casino_core/helper/browser.rb b/lib/casino_core/helper/browser.rb new file mode 100644 index 00000000..f7854c5a --- /dev/null +++ b/lib/casino_core/helper/browser.rb @@ -0,0 +1,14 @@ +module CASinoCore + module Helper + module Browser + def browser_info(user_agent) + user_agent = UserAgent.parse(user_agent) + "#{user_agent.browser} (#{user_agent.platform})" + end + + def same_browser?(user_agent, other_user_agent) + user_agent == other_user_agent || browser_info(user_agent) == browser_info(other_user_agent) + end + end + end +end diff --git a/lib/casino_core/helper/logger.rb b/lib/casino_core/helper/logger.rb new file mode 100644 index 00000000..4453959b --- /dev/null +++ b/lib/casino_core/helper/logger.rb @@ -0,0 +1,11 @@ +require 'casino_core/settings' + +module CASinoCore + module Helper + module Logger + def logger + CASinoCore::Settings.logger + end + end + end +end diff --git a/lib/casino_core/helper/login_tickets.rb b/lib/casino_core/helper/login_tickets.rb new file mode 100644 index 00000000..44a37a21 --- /dev/null +++ b/lib/casino_core/helper/login_tickets.rb @@ -0,0 +1,29 @@ +module CASinoCore + module Helper + module LoginTickets + include CASinoCore::Helper::Logger + include CASinoCore::Helper::Tickets + + def acquire_login_ticket + ticket = CASinoCore::Model::LoginTicket.create ticket: random_ticket_string('LT') + logger.debug "Created login ticket '#{ticket.ticket}'" + ticket + end + + def login_ticket_valid?(lt) + ticket = CASinoCore::Model::LoginTicket.find_by_ticket lt + if ticket.nil? + logger.info "Login ticket '#{lt}' not found" + false + elsif ticket.created_at < CASinoCore::Settings.login_ticket[:lifetime].seconds.ago + logger.info "Login ticket '#{ticket.ticket}' expired" + false + else + logger.debug "Login ticket '#{ticket.ticket}' successfully validated" + ticket.delete + true + end + end + end + end +end diff --git a/lib/casino_core/helper/proxy_granting_tickets.rb b/lib/casino_core/helper/proxy_granting_tickets.rb new file mode 100644 index 00000000..351683ca --- /dev/null +++ b/lib/casino_core/helper/proxy_granting_tickets.rb @@ -0,0 +1,47 @@ +require 'addressable/uri' +require 'faraday' + +require 'casino_core/helper/logger' +require 'casino_core/helper/tickets' + +module CASinoCore + module Helper + module ProxyGrantingTickets + include CASinoCore::Helper::Logger + include CASinoCore::Helper::Tickets + + def acquire_proxy_granting_ticket(pgt_url, service_ticket) + callback_uri = Addressable::URI.parse(pgt_url) + if callback_uri.scheme != 'https' + logger.warn "Proxy tickets can only be granted to callback servers using HTTPS." + nil + else + contact_callback_server(callback_uri, service_ticket) + end + end + + private + def contact_callback_server(callback_uri, service_ticket) + pgt = service_ticket.proxy_granting_tickets.new({ + ticket: random_ticket_string('PGT'), + iou: random_ticket_string('PGTIOU'), + pgt_url: "#{callback_uri}" + }) + callback_uri.query_values = (callback_uri.query_values || {}).merge(pgtId: pgt.ticket, pgtIou: pgt.iou) + response = Faraday.get "#{callback_uri}" + # TODO: does this follow redirects? CAS specification says that redirects MAY be followed (2.5.4) + if response.success? + pgt.save! + logger.debug "Proxy-granting ticket generated for service '#{service_ticket.service}': #{pgt.inspect}" + pgt + else + logger.warn "Proxy-granting ticket callback server responded with a bad result code '#{response.status}'. PGT will not be stored." + nil + end + rescue Faraday::Error::ClientError => error + logger.warn "Exception while communicating with proxy-granting ticket callback server: #{error.message}" + nil + end + end + end +end diff --git a/lib/casino_core/helper/proxy_tickets.rb b/lib/casino_core/helper/proxy_tickets.rb new file mode 100644 index 00000000..e0f2a1b6 --- /dev/null +++ b/lib/casino_core/helper/proxy_tickets.rb @@ -0,0 +1,57 @@ +module CASinoCore + module Helper + module ProxyTickets + + class ValidationResult < CASinoCore::Model::ValidationResult; end + + include CASinoCore::Helper::Logger + include CASinoCore::Helper::Tickets + + def acquire_proxy_ticket(proxy_granting_ticket, service) + proxy_granting_ticket.proxy_tickets.create!({ + ticket: random_ticket_string('PT'), + service: service, + }) + end + + def validate_ticket_for_service(ticket, service, renew = false) + if ticket.nil? + result = ValidationResult.new 'INVALID_TICKET', 'Invalid validate request: Ticket does not exist', :warn + else + result = validate_existing_ticket_for_service(ticket, service, renew) + ticket.consumed = true + ticket.save! + logger.debug "Consumed ticket '#{ticket.ticket}'" + end + if result.success? + logger.info "Ticket '#{ticket.ticket}' for service '#{service}' successfully validated" + else + logger.send(result.error_severity, result.error_message) + end + result + end + + def ticket_valid_for_service?(ticket, service, renew = false) + validate_ticket_for_service(ticket, service, renew).success? + end + + private + def validate_existing_ticket_for_service(ticket, service, renew = false) + if ticket.is_a?(CASinoCore::Model::ServiceTicket) + service = clean_service_url(service) + end + if ticket.consumed? + ValidationResult.new 'INVALID_TICKET', "Ticket '#{ticket.ticket}' already consumed", :warn + elsif ticket.expired? + ValidationResult.new 'INVALID_TICKET', "Ticket '#{ticket.ticket}' has expired", :warn + elsif service != ticket.service + ValidationResult.new 'INVALID_SERVICE', "Ticket '#{ticket.ticket}' is not valid for service '#{service}'", :warn + elsif renew && !ticket.issued_from_credentials? + ValidationResult.new 'INVALID_TICKET', "Ticket '#{ticket.ticket}' was not issued from credentials but service '#{service}' will only accept a renewed ticket", :info + else + ValidationResult.new + end + end + end + end +end diff --git a/lib/casino_core/helper/service_tickets.rb b/lib/casino_core/helper/service_tickets.rb new file mode 100644 index 00000000..2779e9b4 --- /dev/null +++ b/lib/casino_core/helper/service_tickets.rb @@ -0,0 +1,49 @@ +require 'addressable/uri' + +module CASinoCore + module Helper + module ServiceTickets + include CASinoCore::Helper::Logger + include CASinoCore::Helper::Tickets + include CASinoCore::Helper::ProxyTickets + + class ServiceNotAllowedError < StandardError; end + + RESERVED_CAS_PARAMETER_KEYS = ['service', 'ticket', 'gateway', 'renew'] + + def acquire_service_ticket(ticket_granting_ticket, service, credentials_supplied = nil) + service_url = clean_service_url(service) + unless CASinoCore::Model::ServiceRule.allowed?(service_url) + message = "#{service_url} is not in the list of allowed URLs" + logger.error message + raise ServiceNotAllowedError, message + end + ticket_granting_ticket.service_tickets.create!({ + ticket: random_ticket_string('ST'), + service: service_url, + issued_from_credentials: !!credentials_supplied + }) + end + + def clean_service_url(dirty_service) + return dirty_service if dirty_service.blank? + service_uri = Addressable::URI.parse dirty_service + unless service_uri.query_values.nil? + service_uri.query_values = service_uri.query_values(Array).select { |k,v| !RESERVED_CAS_PARAMETER_KEYS.include?(k) } + end + if service_uri.query_values.blank? + service_uri.query_values = nil + end + + service_uri.path = (service_uri.path || '').gsub(/\/+\z/, '') + service_uri.path = '/' if service_uri.path.blank? + + clean_service = service_uri.to_s + + logger.debug("Cleaned dirty service URL '#{dirty_service}' to '#{clean_service}'") if dirty_service != clean_service + + clean_service + end + end + end +end diff --git a/lib/casino_core/helper/ticket_granting_tickets.rb b/lib/casino_core/helper/ticket_granting_tickets.rb new file mode 100644 index 00000000..549f6513 --- /dev/null +++ b/lib/casino_core/helper/ticket_granting_tickets.rb @@ -0,0 +1,66 @@ +require 'addressable/uri' + +module CASinoCore + module Helper + module TicketGrantingTickets + + include CASinoCore::Helper::Browser + include CASinoCore::Helper::Logger + + def find_valid_ticket_granting_ticket(tgt, user_agent, ignore_two_factor = false) + ticket_granting_ticket = CASinoCore::Model::TicketGrantingTicket.where(ticket: tgt).first + unless ticket_granting_ticket.nil? + if ticket_granting_ticket.expired? + logger.info "Ticket-granting ticket expired (Created: #{ticket_granting_ticket.created_at})" + ticket_granting_ticket.destroy + nil + elsif !ignore_two_factor && ticket_granting_ticket.awaiting_two_factor_authentication? + logger.info 'Ticket-granting ticket is valid, but two-factor authentication is pending' + nil + elsif same_browser?(ticket_granting_ticket.user_agent, user_agent) + ticket_granting_ticket.user_agent = user_agent + ticket_granting_ticket.touch + ticket_granting_ticket.save! + ticket_granting_ticket + else + logger.info 'User-Agent changed: ticket-granting ticket not valid for this browser' + nil + end + end + end + + def acquire_ticket_granting_ticket(authentication_result, user_agent = nil, long_term = nil) + user_data = authentication_result[:user_data] + user = load_or_initialize_user(authentication_result[:authenticator], user_data[:username], user_data[:extra_attributes]) + cleanup_expired_ticket_granting_tickets(user) + user.ticket_granting_tickets.create!({ + ticket: random_ticket_string('TGC'), + awaiting_two_factor_authentication: !user.active_two_factor_authenticator.nil?, + user_agent: user_agent, + long_term: !!long_term + }) + end + + def load_or_initialize_user(authenticator, username, extra_attributes) + user = CASinoCore::Model::User.where( + authenticator: authenticator, + username: username).first_or_initialize + user.extra_attributes = extra_attributes + user.save! + return user + end + + def remove_ticket_granting_ticket(ticket_granting_ticket, user_agent = nil) + tgt = find_valid_ticket_granting_ticket(ticket_granting_ticket, user_agent) + unless tgt.nil? + tgt.destroy + end + end + + def cleanup_expired_ticket_granting_tickets(user) + CASinoCore::Model::TicketGrantingTicket.cleanup(user) + end + + end + end +end diff --git a/lib/casino_core/helper/tickets.rb b/lib/casino_core/helper/tickets.rb new file mode 100644 index 00000000..6944baff --- /dev/null +++ b/lib/casino_core/helper/tickets.rb @@ -0,0 +1,17 @@ +require 'securerandom' + +module CASinoCore + module Helper + module Tickets + + ALLOWED_TICKET_STRING_CHARACTERS = ('A'..'Z').to_a + ('a'..'z').to_a + ('0'..'9').to_a + + def random_ticket_string(prefix, length = 40) + random_string = SecureRandom.random_bytes(length).each_char.map do |char| + ALLOWED_TICKET_STRING_CHARACTERS[(char.ord % ALLOWED_TICKET_STRING_CHARACTERS.length)] + end.join + "#{prefix}-#{'%d' % (Time.now.to_f * 10000)}-#{random_string}" + end + end + end +end diff --git a/lib/casino_core/helper/two_factor_authenticators.rb b/lib/casino_core/helper/two_factor_authenticators.rb new file mode 100644 index 00000000..8874f12d --- /dev/null +++ b/lib/casino_core/helper/two_factor_authenticators.rb @@ -0,0 +1,22 @@ +require 'addressable/uri' + +module CASinoCore + module Helper + module TwoFactorAuthenticators + class ValidationResult < CASinoCore::Model::ValidationResult; end + + def validate_one_time_password(otp, authenticator) + if authenticator.nil? || authenticator.expired? + ValidationResult.new 'INVALID_AUTHENTICATOR', 'Authenticator does not exist or expired', :warn + else + totp = ROTP::TOTP.new(authenticator.secret) + if totp.verify_with_drift(otp, CASinoCore::Settings.two_factor_authenticator[:drift]) + ValidationResult.new + else + ValidationResult.new 'INVALID_OTP', 'One-time password not valid', :warn + end + end + end + end + end +end diff --git a/lib/casino_core/model.rb b/lib/casino_core/model.rb new file mode 100644 index 00000000..5fc10c2f --- /dev/null +++ b/lib/casino_core/model.rb @@ -0,0 +1,15 @@ +require 'active_record' + +module CASinoCore + module Model + autoload :LoginTicket, 'casino_core/model/login_ticket.rb' + autoload :ServiceRule, 'casino_core/model/service_rule.rb' + autoload :ServiceTicket, 'casino_core/model/service_ticket.rb' + autoload :ProxyGrantingTicket, 'casino_core/model/proxy_granting_ticket.rb' + autoload :ProxyTicket, 'casino_core/model/proxy_ticket.rb' + autoload :TicketGrantingTicket, 'casino_core/model/ticket_granting_ticket.rb' + autoload :TwoFactorAuthenticator, 'casino_core/model/two_factor_authenticator.rb' + autoload :User, 'casino_core/model/user.rb' + autoload :ValidationResult, 'casino_core/model/validation_result.rb' + end +end diff --git a/lib/casino_core/model/login_ticket.rb b/lib/casino_core/model/login_ticket.rb new file mode 100644 index 00000000..7f6824ec --- /dev/null +++ b/lib/casino_core/model/login_ticket.rb @@ -0,0 +1,15 @@ +require 'casino_core/model' +require 'casino_core/settings' + +class CASinoCore::Model::LoginTicket < ActiveRecord::Base + attr_accessible :ticket + validates :ticket, uniqueness: true + + def self.cleanup + self.delete_all(['created_at < ?', CASinoCore::Settings.login_ticket[:lifetime].seconds.ago]) + end + + def to_s + self.ticket + end +end diff --git a/lib/casino_core/model/proxy_granting_ticket.rb b/lib/casino_core/model/proxy_granting_ticket.rb new file mode 100644 index 00000000..6a3d9a4e --- /dev/null +++ b/lib/casino_core/model/proxy_granting_ticket.rb @@ -0,0 +1,9 @@ +require 'casino_core/model' + +class CASinoCore::Model::ProxyGrantingTicket < ActiveRecord::Base + attr_accessible :iou, :ticket, :pgt_url + validates :ticket, uniqueness: true + validates :iou, uniqueness: true + belongs_to :granter, polymorphic: true + has_many :proxy_tickets, dependent: :destroy +end diff --git a/lib/casino_core/model/proxy_ticket.rb b/lib/casino_core/model/proxy_ticket.rb new file mode 100644 index 00000000..e270ac82 --- /dev/null +++ b/lib/casino_core/model/proxy_ticket.rb @@ -0,0 +1,27 @@ +require 'casino_core/model' +require 'casino_core/settings' +require 'addressable/uri' + +class CASinoCore::Model::ProxyTicket < ActiveRecord::Base + attr_accessible :ticket, :service + validates :ticket, uniqueness: true + belongs_to :proxy_granting_ticket + has_many :proxy_granting_tickets, as: :granter, dependent: :destroy + + def self.cleanup_unconsumed + self.destroy_all(['created_at < ? AND consumed = ?', CASinoCore::Settings.proxy_ticket[:lifetime_unconsumed].seconds.ago, false]) + end + + def self.cleanup_consumed + self.destroy_all(['created_at < ? AND consumed = ?', CASinoCore::Settings.proxy_ticket[:lifetime_consumed].seconds.ago, true]) + end + + def expired? + lifetime = if consumed? + CASinoCore::Settings.proxy_ticket[:lifetime_consumed] + else + CASinoCore::Settings.proxy_ticket[:lifetime_unconsumed] + end + (Time.now - (self.created_at || Time.now)) > lifetime + end +end diff --git a/lib/casino_core/model/service_rule.rb b/lib/casino_core/model/service_rule.rb new file mode 100644 index 00000000..646d88e7 --- /dev/null +++ b/lib/casino_core/model/service_rule.rb @@ -0,0 +1,28 @@ +require 'casino_core/model' + +class CASinoCore::Model::ServiceRule < ActiveRecord::Base + attr_accessible :enabled, :order, :name, :url, :regex + validates :name, presence: true + validates :url, uniqueness: true, presence: true + + def self.allowed?(service_url) + rules = self.where(enabled: true) + if rules.empty? + true + else + rules.any? { |rule| rule.allows?(service_url) } + end + end + + def allows?(service_url) + if self.regex? + regex = Regexp.new self.url, true + if regex =~ service_url + return true + end + elsif self.url == service_url + return true + end + false + end +end diff --git a/lib/casino_core/model/service_ticket.rb b/lib/casino_core/model/service_ticket.rb new file mode 100644 index 00000000..95fdc400 --- /dev/null +++ b/lib/casino_core/model/service_ticket.rb @@ -0,0 +1,47 @@ +require 'casino_core/model' +require 'casino_core/settings' +require 'addressable/uri' + +class CASinoCore::Model::ServiceTicket < ActiveRecord::Base + autoload :SingleSignOutNotifier, 'casino_core/model/service_ticket/single_sign_out_notifier.rb' + + attr_accessible :ticket, :service, :issued_from_credentials + validates :ticket, uniqueness: true + belongs_to :ticket_granting_ticket + before_destroy :send_single_sing_out_notification, if: :consumed? + has_many :proxy_granting_tickets, as: :granter, dependent: :destroy + + def self.cleanup_unconsumed + self.destroy_all(['created_at < ? AND consumed = ?', CASinoCore::Settings.service_ticket[:lifetime_unconsumed].seconds.ago, false]) + end + + def self.cleanup_consumed + self.destroy_all(['(ticket_granting_ticket_id IS NULL OR created_at < ?) AND consumed = ?', CASinoCore::Settings.service_ticket[:lifetime_consumed].seconds.ago, true]) + end + + def self.cleanup_consumed_hard + self.delete_all(['created_at < ? AND consumed = ?', (CASinoCore::Settings.service_ticket[:lifetime_consumed].seconds * 2).ago, true]) + end + + def service_with_ticket_url + service_uri = Addressable::URI.parse(self.service) + service_uri.query_values = (service_uri.query_values(Array) || []) << ['ticket', self.ticket] + service_uri.to_s + end + + def expired? + lifetime = if consumed? + CASinoCore::Settings.service_ticket[:lifetime_consumed] + else + CASinoCore::Settings.service_ticket[:lifetime_unconsumed] + end + (Time.now - (self.created_at || Time.now)) > lifetime + end + + private + def send_single_sing_out_notification + notifier = SingleSignOutNotifier.new(self) + notifier.notify + true + end +end diff --git a/lib/casino_core/model/service_ticket/single_sign_out_notifier.rb b/lib/casino_core/model/service_ticket/single_sign_out_notifier.rb new file mode 100644 index 00000000..c9463c71 --- /dev/null +++ b/lib/casino_core/model/service_ticket/single_sign_out_notifier.rb @@ -0,0 +1,47 @@ +require 'builder' +require 'faraday' +require 'casino_core/model/service_ticket' + +class CASinoCore::Model::ServiceTicket::SingleSignOutNotifier + include CASinoCore::Helper::Logger + + def initialize(service_ticket) + @service_ticket = service_ticket + end + + def notify + send_notification @service_ticket.service, build_xml + end + + private + def build_xml + xml = Builder::XmlMarkup.new(indent: 2) + xml.samlp :LogoutRequest, + 'xmlns:samlp' => 'urn:oasis:names:tc:SAML:2.0:protocol', + 'xmlns:saml' => 'urn:oasis:names:tc:SAML:2.0:assertion', + ID: SecureRandom.uuid, + Version: '2.0', + IssueInstant: Time.now do |logout_request| + logout_request.saml :NameID, '@NOT_USED@' + logout_request.samlp :SessionIndex, @service_ticket.ticket + end + xml.target! + end + + def send_notification(url, xml) + logger.info "Sending Single Sign Out notification for ticket '#{@service_ticket.ticket}'" + result = Faraday.post(url, logoutRequest: xml) do |request| + request.options[:timeout] = CASinoCore::Settings.service_ticket[:single_sign_out_notification][:timeout] + end + if result.success? + logger.info "Logout notification successfully posted to #{url}." + true + else + logger.warn "Service #{url} responed to logout notification with code '#{result.status}'!" + false + end + rescue Faraday::Error::ClientError => error + logger.warn "Failed to send logout notification to service #{url} due to #{error}" + false + end +end diff --git a/lib/casino_core/model/ticket_granting_ticket.rb b/lib/casino_core/model/ticket_granting_ticket.rb new file mode 100644 index 00000000..485828aa --- /dev/null +++ b/lib/casino_core/model/ticket_granting_ticket.rb @@ -0,0 +1,55 @@ +require 'casino_core/model' + +class CASinoCore::Model::TicketGrantingTicket < ActiveRecord::Base + attr_accessible :ticket, :user_agent, :awaiting_two_factor_authentication, :long_term + validates :ticket, uniqueness: true + + belongs_to :user + has_many :service_tickets, dependent: :destroy + + def self.cleanup(user = nil) + if user.nil? + base = self + else + base = user.ticket_granting_tickets + end + base.where([ + '(created_at < ? AND awaiting_two_factor_authentication = ?) OR (created_at < ? AND long_term = ?) OR created_at < ?', + CASinoCore::Settings.two_factor_authenticator[:timeout].seconds.ago, + true, + CASinoCore::Settings.ticket_granting_ticket[:lifetime].seconds.ago, + false, + CASinoCore::Settings.ticket_granting_ticket[:lifetime_long_term].seconds.ago + ]).destroy_all + end + + def browser_info + unless self.user_agent.blank? + user_agent = UserAgent.parse(self.user_agent) + if user_agent.platform.nil? + "#{user_agent.browser}" + else + "#{user_agent.browser} (#{user_agent.platform})" + end + end + end + + def same_user?(other_ticket) + if other_ticket.nil? + false + else + other_ticket.user_id == self.user_id + end + end + + def expired? + if awaiting_two_factor_authentication? + lifetime = CASinoCore::Settings.two_factor_authenticator[:timeout] + elsif long_term? + lifetime = CASinoCore::Settings.ticket_granting_ticket[:lifetime_long_term] + else + lifetime = CASinoCore::Settings.ticket_granting_ticket[:lifetime] + end + (Time.now - (self.created_at || Time.now)) > lifetime + end +end diff --git a/lib/casino_core/model/two_factor_authenticator.rb b/lib/casino_core/model/two_factor_authenticator.rb new file mode 100644 index 00000000..89225d4b --- /dev/null +++ b/lib/casino_core/model/two_factor_authenticator.rb @@ -0,0 +1,19 @@ +require 'casino_core/model' + +class CASinoCore::Model::TwoFactorAuthenticator < ActiveRecord::Base + attr_accessible :secret + + belongs_to :user + + def self.cleanup + self.delete_all(['(created_at < ?) AND active = ?', self.lifetime.ago, false]) + end + + def self.lifetime + CASinoCore::Settings.two_factor_authenticator[:lifetime_inactive].seconds + end + + def expired? + !self.active? && (Time.now - (self.created_at || Time.now)) > self.class.lifetime + end +end diff --git a/lib/casino_core/model/user.rb b/lib/casino_core/model/user.rb new file mode 100644 index 00000000..d71049ef --- /dev/null +++ b/lib/casino_core/model/user.rb @@ -0,0 +1,13 @@ +require 'casino_core/model' + +class CASinoCore::Model::User < ActiveRecord::Base + attr_accessible :authenticator, :username, :extra_attributes + serialize :extra_attributes, Hash + + has_many :ticket_granting_tickets + has_many :two_factor_authenticators + + def active_two_factor_authenticator + self.two_factor_authenticators.where(active: true).first + end +end diff --git a/lib/casino_core/model/validation_result.rb b/lib/casino_core/model/validation_result.rb new file mode 100644 index 00000000..478631ce --- /dev/null +++ b/lib/casino_core/model/validation_result.rb @@ -0,0 +1,7 @@ +require 'casino_core/model' + +class CASinoCore::Model::ValidationResult < Struct.new(:error_code, :error_message, :error_severity) + def success? + self.error_code.nil? + end +end diff --git a/lib/casino_core/processor.rb b/lib/casino_core/processor.rb new file mode 100644 index 00000000..2754a31e --- /dev/null +++ b/lib/casino_core/processor.rb @@ -0,0 +1,27 @@ +require 'active_record' + +module CASinoCore + class Processor + autoload :LegacyValidator, 'casino_core/processor/legacy_validator.rb' + autoload :LoginCredentialAcceptor, 'casino_core/processor/login_credential_acceptor.rb' + autoload :LoginCredentialRequestor, 'casino_core/processor/login_credential_requestor.rb' + autoload :Logout, 'casino_core/processor/logout.rb' + autoload :OtherSessionsDestroyer, 'casino_core/processor/other_sessions_destroyer.rb' + autoload :ProxyTicketProvider, 'casino_core/processor/proxy_ticket_provider.rb' + autoload :ProxyTicketValidator, 'casino_core/processor/proxy_ticket_validator.rb' + autoload :SecondFactorAuthenticationAcceptor, 'casino_core/processor/second_factor_authentication_acceptor.rb' + autoload :ServiceTicketValidator, 'casino_core/processor/service_ticket_validator.rb' + autoload :SessionDestroyer, 'casino_core/processor/session_destroyer.rb' + autoload :SessionOverview, 'casino_core/processor/session_overview.rb' + autoload :TwoFactorAuthenticatorActivator, 'casino_core/processor/two_factor_authenticator_activator.rb' + autoload :TwoFactorAuthenticatorDestroyer, 'casino_core/processor/two_factor_authenticator_destroyer.rb' + autoload :TwoFactorAuthenticatorOverview, 'casino_core/processor/two_factor_authenticator_overview.rb' + autoload :TwoFactorAuthenticatorRegistrator, 'casino_core/processor/two_factor_authenticator_registrator.rb' + + autoload :API, 'casino_core/processor/api.rb' + + def initialize(listener) + @listener = listener + end + end +end diff --git a/lib/casino_core/processor/api.rb b/lib/casino_core/processor/api.rb new file mode 100644 index 00000000..6765bd64 --- /dev/null +++ b/lib/casino_core/processor/api.rb @@ -0,0 +1,9 @@ +module CASinoCore + class Processor + module API + autoload :LoginCredentialAcceptor, 'casino_core/processor/api/login_credential_acceptor.rb' + autoload :ServiceTicketProvider, 'casino_core/processor/api/service_ticket_provider.rb' + autoload :Logout, 'casino_core/processor/api/logout.rb' + end + end +end diff --git a/lib/casino_core/processor/api/login_credential_acceptor.rb b/lib/casino_core/processor/api/login_credential_acceptor.rb new file mode 100644 index 00000000..6b717a74 --- /dev/null +++ b/lib/casino_core/processor/api/login_credential_acceptor.rb @@ -0,0 +1,50 @@ +require 'casino_core/processor/api' +require 'casino_core/helper' + +# This processor should be used for API calls: POST /cas/v1/tickets +class CASinoCore::Processor::API::LoginCredentialAcceptor < CASinoCore::Processor + include CASinoCore::Helper::Logger + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::Authentication + include CASinoCore::Helper::TicketGrantingTickets + + # Use this method to process the request. It expects the username in the parameter "username" and the password + # in "password". + # + # The method will call one of the following methods on the listener: + # * `#user_logged_in_via_api`: First and only argument is a String with the TGT-id + # * `#invalid_login_credentials_via_api`: No argument + # + # @param [Hash] login_data parameters supplied by user (username and password) + def process(login_data, user_agent = nil) + @login_data = login_data + @user_agent = user_agent + + validate_login_data + + unless @authentication_result.nil? + generate_ticket_granting_ticket + callback_user_logged_in + else + callback_invalid_login_credentials + end + end + + private + def validate_login_data + @authentication_result = validate_login_credentials(@login_data[:username], @login_data[:password]) + end + + def callback_user_logged_in + @listener.user_logged_in_via_api @ticket_granting_ticket.ticket + end + + def generate_ticket_granting_ticket + @ticket_granting_ticket = acquire_ticket_granting_ticket(@authentication_result, @user_agent) + end + + def callback_invalid_login_credentials + @listener.invalid_login_credentials_via_api + end + +end diff --git a/lib/casino_core/processor/api/logout.rb b/lib/casino_core/processor/api/logout.rb new file mode 100644 index 00000000..a23181c2 --- /dev/null +++ b/lib/casino_core/processor/api/logout.rb @@ -0,0 +1,21 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The Logout processor should be used to process API DELETE requests to /cas/v1/tickets/ +class CASinoCore::Processor::API::Logout < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#user_logged_out_via_api` on the listener. + # + # @param [String] ticket_granting_ticket Ticket-granting ticket to logout + def process(ticket_granting_ticket, user_agent = nil) + remove_ticket_granting_ticket(ticket_granting_ticket, user_agent) + callback_user_logged_out + end + + def callback_user_logged_out + @listener.user_logged_out_via_api + end + +end diff --git a/lib/casino_core/processor/api/service_ticket_provider.rb b/lib/casino_core/processor/api/service_ticket_provider.rb new file mode 100644 index 00000000..29432de9 --- /dev/null +++ b/lib/casino_core/processor/api/service_ticket_provider.rb @@ -0,0 +1,74 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' +require 'casino_core/builder' + +# The ServiceTicketProvider processor should be used to handle API calls: POST requests to /cas/v1/tickets/ +class CASinoCore::Processor::API::ServiceTicketProvider < CASinoCore::Processor + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::TicketGrantingTickets + + # Use this method to process the request. + # + # The method will call one of the following methods on the listener: + # * `#granted_service_ticket_via_api`: First and only argument is a String with the service ticket. + # The service ticket (and nothing else) should be displayed. + # * `#invalid_ticket_granting_ticket_via_api`: No argument. The application should respond with status "400 Bad Request" + # * `#no_service_provided_via_api`: No argument. The application should respond with status "400 Bad Request" + # * `#service_not_allowed_via_api`: The user tried to access a service that this CAS server is not allowed to serve. + # + # @param [String] ticket_granting_ticket ticket_granting_ticket supplied by the user in the URL + # @param [Hash] parameters parameters supplied by user (`service` in particular) + # @param [String] user_agent user-agent delivered by the client + def process(ticket_granting_ticket, parameters = nil, user_agent = nil) + parameters ||= {} + @client_ticket_granting_ticket = ticket_granting_ticket + @service_url = parameters[:service] + @user_agent = user_agent + + fetch_valid_ticket_granting_ticket + handle_ticket_granting_ticket + end + + private + def fetch_valid_ticket_granting_ticket + @ticket_granting_ticket = find_valid_ticket_granting_ticket(@client_ticket_granting_ticket, @user_agent) + end + + def handle_ticket_granting_ticket + case + when (@service_url and @ticket_granting_ticket) + begin + create_service_ticket + callback_granted_service_ticket + rescue ServiceNotAllowedError + callback_service_not_allowed + end + when (@service_url and not @ticket_granting_ticket) + callback_invalid_tgt + when (not @service_url and @ticket_granting_ticket) + callback_empty_service + end + end + + def create_service_ticket + @service_ticket = acquire_service_ticket(@ticket_granting_ticket, @service_url) + end + + def callback_granted_service_ticket + @listener.granted_service_ticket_via_api @service_ticket.ticket + end + + def callback_invalid_tgt + @listener.invalid_ticket_granting_ticket_via_api + end + + def callback_empty_service + @listener.no_service_provided_via_api + end + + def callback_service_not_allowed + @listener.service_not_allowed_via_api(clean_service_url @service_url) + end + +end diff --git a/lib/casino_core/processor/legacy_validator.rb b/lib/casino_core/processor/legacy_validator.rb new file mode 100644 index 00000000..7d118d96 --- /dev/null +++ b/lib/casino_core/processor/legacy_validator.rb @@ -0,0 +1,24 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The LegacyValidator processor should be used for GET requests to /validate +class CASinoCore::Processor::LegacyValidator < CASinoCore::Processor + include CASinoCore::Helper::Logger + include CASinoCore::Helper::ServiceTickets + + # This method will call `#validation_succeeded` or `#validation_failed`. In both cases, it supplies + # a string as argument. The web application should present that string (and nothing else) to the + # requestor. + # + # @param [Hash] params parameters supplied by requestor (a service) + def process(params = nil) + params ||= {} + ticket = CASinoCore::Model::ServiceTicket.where(ticket: params[:ticket]).first + if !params[:service].nil? && ticket_valid_for_service?(ticket, params[:service], !!params[:renew]) + @listener.validation_succeeded("yes\n#{ticket.ticket_granting_ticket.user.username}\n") + else + @listener.validation_failed("no\n\n") + end + end +end diff --git a/lib/casino_core/processor/login_credential_acceptor.rb b/lib/casino_core/processor/login_credential_acceptor.rb new file mode 100644 index 00000000..2af3724e --- /dev/null +++ b/lib/casino_core/processor/login_credential_acceptor.rb @@ -0,0 +1,67 @@ +require 'casino_core/processor' +require 'casino_core/helper' + +# This processor should be used for POST requests to /login +class CASinoCore::Processor::LoginCredentialAcceptor < CASinoCore::Processor + include CASinoCore::Helper::Logger + include CASinoCore::Helper::LoginTickets + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::Authentication + include CASinoCore::Helper::TicketGrantingTickets + + # Use this method to process the request. It expects the username in the parameter "username" and the password + # in "password". + # + # The method will call one of the following methods on the listener: + # * `#user_logged_in`: The first argument (String) is the URL (if any), the user should be redirected to. + # The second argument (String) is the ticket-granting ticket. It should be stored in a cookie named "tgt". + # The third argument (Time, optional, default = nil) is for "Remember Me" functionality. + # This is the cookies expiration date. If it is `nil`, the cookie should be a session cookie. + # * `#invalid_login_ticket` and `#invalid_login_credentials`: The first argument is a LoginTicket. + # See {CASinoCore::Processor::LoginCredentialRequestor} for details. + # * `#service_not_allowed`: The user tried to access a service that this CAS server is not allowed to serve. + # * `#two_factor_authentication_pending`: The user should be asked to enter his OTP. The first argument (String) is the ticket-granting ticket. The ticket-granting ticket is not active yet. Use SecondFactorAuthenticatonAcceptor to activate it. + # + # @param [Hash] params parameters supplied by user + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, user_agent = nil) + @params = params || {} + @user_agent = user_agent + if login_ticket_valid?(@params[:lt]) + authenticate_user + else + @listener.invalid_login_ticket(acquire_login_ticket) + end + end + + private + def authenticate_user + authentication_result = validate_login_credentials(@params[:username], @params[:password]) + if !authentication_result.nil? + user_logged_in(authentication_result) + else + @listener.invalid_login_credentials(acquire_login_ticket) + end + end + + def user_logged_in(authentication_result) + long_term = @params[:rememberMe] + ticket_granting_ticket = acquire_ticket_granting_ticket(authentication_result, @user_agent, long_term) + if ticket_granting_ticket.awaiting_two_factor_authentication? + @listener.two_factor_authentication_pending(ticket_granting_ticket.ticket) + else + begin + url = unless @params[:service].blank? + acquire_service_ticket(ticket_granting_ticket, @params[:service], true).service_with_ticket_url + end + if long_term + @listener.user_logged_in(url, ticket_granting_ticket.ticket, CASinoCore::Settings.ticket_granting_ticket[:lifetime_long_term].seconds.from_now) + else + @listener.user_logged_in(url, ticket_granting_ticket.ticket) + end + rescue ServiceNotAllowedError => e + @listener.service_not_allowed(clean_service_url @params[:service]) + end + end + end +end diff --git a/lib/casino_core/processor/login_credential_requestor.rb b/lib/casino_core/processor/login_credential_requestor.rb new file mode 100644 index 00000000..a53d1906 --- /dev/null +++ b/lib/casino_core/processor/login_credential_requestor.rb @@ -0,0 +1,70 @@ +require 'casino_core/processor' +require 'casino_core/helper' + +# This processor should be used for GET requests to /login +class CASinoCore::Processor::LoginCredentialRequestor < CASinoCore::Processor + include CASinoCore::Helper::Browser + include CASinoCore::Helper::Logger + include CASinoCore::Helper::LoginTickets + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::TicketGrantingTickets + + # Use this method to process the request. + # + # The method will call one of the following methods on the listener: + # * `#user_logged_in`: The first argument (String) is the URL (if any), the user should be redirected to. + # * `#user_not_logged_in`: The first argument is a LoginTicket. It should be stored in a hidden field with name "lt". + # * `#service_not_allowed`: The user tried to access a service that this CAS server is not allowed to serve. + # + # @param [Hash] params parameters supplied by user + # @param [Hash] cookies cookies supplied by user + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + @params = params || {} + @cookies = cookies || {} + @user_agent = user_agent || {} + if check_service_allowed + handle_allowed_service + end + end + + private + def handle_allowed_service + if !@params[:renew] && (@ticket_granting_ticket = find_valid_ticket_granting_ticket(@cookies[:tgt], @user_agent)) + handle_logged_in + else + handle_not_logged_in + end + end + + def handle_logged_in + service_url_with_ticket = unless @params[:service].nil? + acquire_service_ticket(@ticket_granting_ticket, @params[:service], true).service_with_ticket_url + end + @listener.user_logged_in(service_url_with_ticket) + end + + def handle_not_logged_in + if gateway_request? + # we actually lie to the listener to simplify things + @listener.user_logged_in(@params[:service]) + else + login_ticket = acquire_login_ticket + @listener.user_not_logged_in(login_ticket) + end + end + + def check_service_allowed + service_url = clean_service_url(@params[:service]) unless @params[:service].nil? + if service_url.nil? || CASinoCore::Model::ServiceRule.allowed?(service_url) + true + else + @listener.service_not_allowed(service_url) + false + end + end + + def gateway_request? + @params[:gateway] == 'true' && @params[:service] + end +end diff --git a/lib/casino_core/processor/logout.rb b/lib/casino_core/processor/logout.rb new file mode 100644 index 00000000..ab8646c3 --- /dev/null +++ b/lib/casino_core/processor/logout.rb @@ -0,0 +1,27 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The Logout processor should be used to process GET requests to /logout. +class CASinoCore::Processor::Logout < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#user_logged_out` and may supply an URL that should be presented to the user. + # As per specification, the URL specified by "url" SHOULD be on the logout page with descriptive text. + # For example, "The application you just logged out of has provided a link it would like you to follow. + # Please click here to access http://www.go-back.edu/." + # + # @param [Hash] params parameters supplied by user + # @param [Hash] cookies cookies supplied by user + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + params ||= {} + cookies ||= {} + remove_ticket_granting_ticket(cookies[:tgt], user_agent) + if params[:service] && CASinoCore::Model::ServiceRule.allowed?(params[:service]) + @listener.user_logged_out(params[:service], true) + else + @listener.user_logged_out(params[:url]) + end + end +end diff --git a/lib/casino_core/processor/other_sessions_destroyer.rb b/lib/casino_core/processor/other_sessions_destroyer.rb new file mode 100644 index 00000000..321f50dd --- /dev/null +++ b/lib/casino_core/processor/other_sessions_destroyer.rb @@ -0,0 +1,30 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The OtherSessionsDestroyer processor should be used to process GET requests to /destroy-other-sessions. +# +# It is usefule to redirect users to this action after a password change. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::OtherSessionsDestroyer < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#other_sessions_destroyed` and may supply an URL that should be presented to the user. + # The user should be redirected to this URL immediately. + # + # @param [Hash] params parameters supplied by user + # @param [Hash] cookies cookies supplied by user + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + params ||= {} + cookies ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + unless tgt.nil? + other_ticket_granting_tickets = tgt.user.ticket_granting_tickets.where('id != ?', tgt.id) + other_ticket_granting_tickets.destroy_all + end + @listener.other_sessions_destroyed(params[:service]) + end +end diff --git a/lib/casino_core/processor/proxy_ticket_provider.rb b/lib/casino_core/processor/proxy_ticket_provider.rb new file mode 100644 index 00000000..30dd943d --- /dev/null +++ b/lib/casino_core/processor/proxy_ticket_provider.rb @@ -0,0 +1,44 @@ +require 'builder' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The ProxyTicketProvider processor should be used to handle GET requests to /proxy +class CASinoCore::Processor::ProxyTicketProvider < CASinoCore::Processor + include CASinoCore::Helper::ProxyGrantingTickets + include CASinoCore::Helper::ProxyTickets + + # This method will call `#request_succeeded` or `#request_failed`. In both cases, it supplies + # a string as argument. The web application should present that string (and nothing else) to the + # requestor. The Content-Type should be set to 'text/xml; charset=utf-8' + # + # @param [Hash] params parameters delivered by the client + def process(params = nil) + if params[:pgt].nil? || params[:targetService].nil? + @listener.request_failed build_xml false, error_code: 'INVALID_REQUEST', error_message: '"pgt" and "targetService" parameters are both required' + else + proxy_granting_ticket = CASinoCore::Model::ProxyGrantingTicket.where(ticket: params[:pgt]).first + if proxy_granting_ticket.nil? + @listener.request_failed build_xml false, error_code: 'BAD_PGT', error_message: 'PGT not found' + else + proxy_ticket = acquire_proxy_ticket(proxy_granting_ticket, params[:targetService]) + @listener.request_succeeded build_xml true, proxy_ticket: proxy_ticket + end + end + end + + private + def build_xml(success, options = {}) + xml = Builder::XmlMarkup.new(indent: 2) + xml.cas :serviceResponse, 'xmlns:cas' => 'http://www.yale.edu/tp/cas' do |service_response| + if success + service_response.cas :proxySuccess do |proxy_success| + proxy_success.cas :proxyTicket, options[:proxy_ticket].ticket + end + else + service_response.cas :proxyFailure, options[:error_message], code: options[:error_code] + end + end + xml.target! + end +end diff --git a/lib/casino_core/processor/proxy_ticket_validator.rb b/lib/casino_core/processor/proxy_ticket_validator.rb new file mode 100644 index 00000000..5565aea9 --- /dev/null +++ b/lib/casino_core/processor/proxy_ticket_validator.rb @@ -0,0 +1,27 @@ +require 'builder' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The ProxyTicketValidator processor should be used to handle GET requests to /proxyValidate +class CASinoCore::Processor::ProxyTicketValidator < CASinoCore::Processor::ServiceTicketValidator + + # This method will call `#validation_succeeded` or `#validation_failed`. In both cases, it supplies + # a string as argument. The web application should present that string (and nothing else) to the + # requestor. The Content-Type should be set to 'text/xml; charset=utf-8' + # + # @param [Hash] params parameters delivered by the client + def process(params = nil) + params ||= {} + if request_valid?(params) + ticket = if params[:ticket].start_with?('PT-') + CASinoCore::Model::ProxyTicket.where(ticket: params[:ticket]).first + elsif params[:ticket].start_with?('ST-') + CASinoCore::Model::ServiceTicket.where(ticket: params[:ticket]).first + else + nil + end + validate_ticket!(ticket, params) + end + end +end diff --git a/lib/casino_core/processor/second_factor_authentication_acceptor.rb b/lib/casino_core/processor/second_factor_authentication_acceptor.rb new file mode 100644 index 00000000..e088d4ce --- /dev/null +++ b/lib/casino_core/processor/second_factor_authentication_acceptor.rb @@ -0,0 +1,50 @@ +require 'rotp' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The SecondFactorAuthenticationAcceptor processor can be used to activate a previously generated ticket-granting ticket with pending two-factor authentication. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::SecondFactorAuthenticationAcceptor < CASinoCore::Processor + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::TicketGrantingTickets + include CASinoCore::Helper::TwoFactorAuthenticators + + # The method will call one of the following methods on the listener: + # * `#user_not_logged_in`: The user should be redirected to /login. + # * `#user_logged_in`: The first argument (String) is the URL (if any), the user should be redirected to. + # The second argument (String) is the ticket-granting ticket. It should be stored in a cookie named "tgt". + # * `#invalid_one_time_password`: The user should be asked for a new OTP. + # + # @param [Hash] params parameters supplied by user. The processor will look for keys :otp and :service. + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, user_agent = nil) + cookies ||= {} + tgt = find_valid_ticket_granting_ticket(params[:tgt], user_agent, true) + if tgt.nil? + @listener.user_not_logged_in + else + validation_result = validate_one_time_password(params[:otp], tgt.user.active_two_factor_authenticator) + if validation_result.success? + tgt.awaiting_two_factor_authentication = false + tgt.save! + begin + url = unless params[:service].blank? + acquire_service_ticket(tgt, params[:service], true).service_with_ticket_url + end + if tgt.long_term? + @listener.user_logged_in(url, tgt.ticket, CASinoCore::Settings.ticket_granting_ticket[:lifetime_long_term].seconds.from_now) + else + @listener.user_logged_in(url, tgt.ticket) + end + rescue ServiceNotAllowedError => e + @listener.service_not_allowed(clean_service_url params[:service]) + end + else + @listener.invalid_one_time_password + end + end + end +end diff --git a/lib/casino_core/processor/service_ticket_validator.rb b/lib/casino_core/processor/service_ticket_validator.rb new file mode 100644 index 00000000..ff3a8206 --- /dev/null +++ b/lib/casino_core/processor/service_ticket_validator.rb @@ -0,0 +1,51 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' +require 'casino_core/builder' + +# The ServiceTicketValidator processor should be used to handle GET requests to /serviceValidate +class CASinoCore::Processor::ServiceTicketValidator < CASinoCore::Processor + include CASinoCore::Helper::ServiceTickets + include CASinoCore::Helper::ProxyGrantingTickets + + # This method will call `#validation_succeeded` or `#validation_failed`. In both cases, it supplies + # a string as argument. The web application should present that string (and nothing else) to the + # requestor. The Content-Type should be set to 'text/xml; charset=utf-8' + # + # @param [Hash] params parameters delivered by the client + def process(params = nil) + params ||= {} + if request_valid?(params) + ticket = CASinoCore::Model::ServiceTicket.where(ticket: params[:ticket]).first + validate_ticket!(ticket, params) + end + end + + protected + def build_service_response(success, options = {}) + builder = CASinoCore::Builder::TicketValidationResponse.new(success, options) + builder.build + end + + def request_valid?(params) + if params[:ticket].nil? || params[:service].nil? + @listener.validation_failed build_service_response(false, error_code: 'INVALID_REQUEST', error_message: '"ticket" and "service" parameters are both required') + false + else + true + end + end + + def validate_ticket!(ticket, params) + validation_result = validate_ticket_for_service(ticket, params[:service], !!params[:renew]) + if validation_result.success? + options = { ticket: ticket } + unless params[:pgtUrl].nil? + options[:proxy_granting_ticket] = acquire_proxy_granting_ticket(params[:pgtUrl], ticket) + end + @listener.validation_succeeded(build_service_response(true, options)) + else + @listener.validation_failed(build_service_response(false, error_code: validation_result.error_code, error_message: validation_result.error_message)) + end + end +end diff --git a/lib/casino_core/processor/session_destroyer.rb b/lib/casino_core/processor/session_destroyer.rb new file mode 100644 index 00000000..ad2d9bb0 --- /dev/null +++ b/lib/casino_core/processor/session_destroyer.rb @@ -0,0 +1,31 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The SessionDestroyer processor is used to destroy a ticket-granting ticket. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. It is especially useful in +# combination with the {CASinoCore::Processor::SessionOverview} processor. +class CASinoCore::Processor::SessionDestroyer < CASinoCore::Processor + + include CASinoCore::Helper::Logger + + # This method will call `#ticket_not_found` or `#ticket_deleted` on the listener. + # @param [Hash] params parameters supplied by user (ID of ticket-granting ticket to delete should by in params[:id]) + # @param [Hash] cookies cookies supplied by user + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + params ||= {} + cookies ||= {} + ticket = CASinoCore::Model::TicketGrantingTicket.where(id: params[:id]).first + owner_ticket = CASinoCore::Model::TicketGrantingTicket.where(ticket: cookies[:tgt]).first + if ticket.nil? || !ticket.same_user?(owner_ticket) + @listener.ticket_not_found + else + logger.info "Destroying ticket-granting ticket '#{ticket.ticket}'" + ticket.destroy + @listener.ticket_deleted + end + end +end diff --git a/lib/casino_core/processor/session_overview.rb b/lib/casino_core/processor/session_overview.rb new file mode 100644 index 00000000..bc2da531 --- /dev/null +++ b/lib/casino_core/processor/session_overview.rb @@ -0,0 +1,25 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The SessionOverview processor to list all open session for the currently signed in user. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::SessionOverview < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#user_not_logged_in` or `#ticket_granting_tickets_found(Enumerable)` on the listener. + # @param [Hash] cookies cookies delivered by the client + # @param [String] user_agent user-agent delivered by the client + def process(cookies = nil, user_agent = nil) + cookies ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + if tgt.nil? + @listener.user_not_logged_in + else + ticket_granting_tickets = tgt.user.ticket_granting_tickets.where(awaiting_two_factor_authentication: false).order('updated_at DESC') + @listener.ticket_granting_tickets_found(ticket_granting_tickets) + end + end +end diff --git a/lib/casino_core/processor/two_factor_authenticator_activator.rb b/lib/casino_core/processor/two_factor_authenticator_activator.rb new file mode 100644 index 00000000..17c3df76 --- /dev/null +++ b/lib/casino_core/processor/two_factor_authenticator_activator.rb @@ -0,0 +1,46 @@ +require 'rotp' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The TwoFactorAuthenticatorActivator processor can be used to activate a previously generated two-factor authenticator. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::TwoFactorAuthenticatorActivator < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + include CASinoCore::Helper::TwoFactorAuthenticators + + # The method will call one of the following methods on the listener: + # * `#user_not_logged_in`: The user is not logged in and should be redirected to /login. + # * `#two_factor_authenticator_activated`: The two-factor authenticator was successfully activated. + # * `#invalid_two_factor_authenticator`: The two-factor authenticator is not valid. + # * `#invalid_one_time_password`: The user should be asked for a new OTP. + # + # @param [Hash] params parameters supplied by user. The processor will look for keys :otp and :id. + # @param [Hash] cookies cookies delivered by the client + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + cookies ||= {} + params ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + if tgt.nil? + @listener.user_not_logged_in + else + authenticator = tgt.user.two_factor_authenticators.where(id: params[:id]).first + validation_result = validate_one_time_password(params[:otp], authenticator) + if validation_result.success? + tgt.user.two_factor_authenticators.where(active: true).delete_all + authenticator.active = true + authenticator.save! + @listener.two_factor_authenticator_activated + else + if validation_result.error_code == 'INVALID_OTP' + @listener.invalid_one_time_password(authenticator) + else + @listener.invalid_two_factor_authenticator + end + end + end + end +end diff --git a/lib/casino_core/processor/two_factor_authenticator_destroyer.rb b/lib/casino_core/processor/two_factor_authenticator_destroyer.rb new file mode 100644 index 00000000..40421933 --- /dev/null +++ b/lib/casino_core/processor/two_factor_authenticator_destroyer.rb @@ -0,0 +1,38 @@ +require 'rotp' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The TwoFactorAuthenticatorDestroyer processor can be used to deactivate a previously activated two-factor authenticator. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::TwoFactorAuthenticatorDestroyer < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + include CASinoCore::Helper::TwoFactorAuthenticators + + # The method will call one of the following methods on the listener: + # * `#user_not_logged_in`: The user is not logged in and should be redirected to /login. + # * `#two_factor_authenticator_destroyed`: The two-factor authenticator was successfully destroyed. + # * `#invalid_two_factor_authenticator`: The two-factor authenticator is not valid. + # + # @param [Hash] params parameters supplied by user. The processor will look for key :id. + # @param [Hash] cookies cookies delivered by the client + # @param [String] user_agent user-agent delivered by the client + def process(params = nil, cookies = nil, user_agent = nil) + cookies ||= {} + params ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + if tgt.nil? + @listener.user_not_logged_in + else + authenticator = tgt.user.two_factor_authenticators.where(id: params[:id]).first + if authenticator + authenticator.destroy + @listener.two_factor_authenticator_destroyed + else + @listener.invalid_two_factor_authenticator + end + end + end +end diff --git a/lib/casino_core/processor/two_factor_authenticator_overview.rb b/lib/casino_core/processor/two_factor_authenticator_overview.rb new file mode 100644 index 00000000..03361295 --- /dev/null +++ b/lib/casino_core/processor/two_factor_authenticator_overview.rb @@ -0,0 +1,24 @@ +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The TwoFactorAuthenticatorOverview processor lists registered two factor devices for the currently signed in user. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::TwoFactorAuthenticatorOverview < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#user_not_logged_in` or `#two_factor_authenticators_found(Enumerable)` on the listener. + # @param [Hash] cookies cookies delivered by the client + # @param [String] user_agent user-agent delivered by the client + def process(cookies = nil, user_agent = nil) + cookies ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + if tgt.nil? + @listener.user_not_logged_in + else + @listener.two_factor_authenticators_found(tgt.user.two_factor_authenticators.where(active: true)) + end + end +end diff --git a/lib/casino_core/processor/two_factor_authenticator_registrator.rb b/lib/casino_core/processor/two_factor_authenticator_registrator.rb new file mode 100644 index 00000000..ee2514db --- /dev/null +++ b/lib/casino_core/processor/two_factor_authenticator_registrator.rb @@ -0,0 +1,27 @@ +require 'rotp' +require 'casino_core/processor' +require 'casino_core/helper' +require 'casino_core/model' + +# The TwoFactorAuthenticatorRegistrator processor can be used as the first step to register a new two-factor authenticator. +# It is inactive until activated using TwoFactorAuthenticatorActivator. +# +# This feature is not described in the CAS specification so it's completly optional +# to implement this on the web application side. +class CASinoCore::Processor::TwoFactorAuthenticatorRegistrator < CASinoCore::Processor + include CASinoCore::Helper::TicketGrantingTickets + + # This method will call `#user_not_logged_in` or `#two_factor_authenticator_registered(two_factor_authenticator)` on the listener. + # @param [Hash] cookies cookies delivered by the client + # @param [String] user_agent user-agent delivered by the client + def process(cookies = nil, user_agent = nil) + cookies ||= {} + tgt = find_valid_ticket_granting_ticket(cookies[:tgt], user_agent) + if tgt.nil? + @listener.user_not_logged_in + else + two_factor_authenticator = tgt.user.two_factor_authenticators.create! secret: ROTP::Base32.random_base32 + @listener.two_factor_authenticator_registered(two_factor_authenticator) + end + end +end diff --git a/lib/casino_core/railtie.rb b/lib/casino_core/railtie.rb new file mode 100644 index 00000000..d2d5cef6 --- /dev/null +++ b/lib/casino_core/railtie.rb @@ -0,0 +1,14 @@ +require 'casino_core' +require 'rails' + +module CASinoCore + class Railtie < Rails::Railtie + rake_tasks do + CASinoCore::RakeTasks.load_tasks + end + + initializer 'casino_core.setup_logger' do + CASinoCore::Settings.logger = Rails.logger + end + end +end diff --git a/lib/casino_core/rake_tasks.rb b/lib/casino_core/rake_tasks.rb new file mode 100644 index 00000000..e95e2be6 --- /dev/null +++ b/lib/casino_core/rake_tasks.rb @@ -0,0 +1,15 @@ +module CASinoCore + class RakeTasks + class << self + def load_tasks + %w( + database + cleanup + service_rule + ).each do |task| + load "casino_core/tasks/#{task}.rake" + end + end + end + end +end diff --git a/lib/casino_core/settings.rb b/lib/casino_core/settings.rb new file mode 100644 index 00000000..73d869c6 --- /dev/null +++ b/lib/casino_core/settings.rb @@ -0,0 +1,79 @@ +require 'casino_core/authenticator' + +module CASinoCore + class Settings + class << self + attr_accessor :login_ticket, :ticket_granting_ticket, :service_ticket, :proxy_ticket, :two_factor_authenticator, :authenticators, :logger, :frontend + DEFAULT_SETTINGS = { + login_ticket: { + lifetime: 600 + }, + ticket_granting_ticket: { + lifetime: 86400, + lifetime_long_term: 864000 + }, + service_ticket: { + lifetime_unconsumed: 300, + lifetime_consumed: 86400, + single_sign_out_notification: { + timeout: 5 + } + }, + proxy_ticket: { + lifetime_unconsumed: 300, + lifetime_consumed: 86400 + }, + two_factor_authenticator: { + timeout: 180, + lifetime_inactive: 300, + drift: 30 + } + } + + def init(config = {}) + DEFAULT_SETTINGS.deep_merge(config).each do |key,value| + if respond_to?("#{key}=") + send("#{key}=", value) + end + end + end + + def logger + @logger ||= ::Logger.new(STDOUT) + end + + def authenticators=(authenticators) + @authenticators = {} + authenticators.each do |index, authenticator| + unless authenticator.is_a?(CASinoCore::Authenticator) + if authenticator[:class] + authenticator = authenticator[:class].constantize.new(authenticator[:options]) + else + authenticator = load_and_instantiate_authenticator(authenticator[:authenticator], authenticator[:options]) + end + end + @authenticators[index] = authenticator + end + end + + def add_defaults(name, config = {}) + DEFAULT_SETTINGS[name] = config + end + + private + def load_and_instantiate_authenticator(name, options) + gemname = "casino_core-authenticator-#{name.underscore}" + classname = name.camelize + begin + require gemname + CASinoCore::Authenticator.const_get(classname).new(options) + rescue LoadError => error + puts "Failed to load authenticator '#{name}'. Maybe you have to include \"gem '#{gemname}'\" in your Gemfile?" + puts " Error: #{error.message}" + puts '' + raise error + end + end + end + end +end diff --git a/lib/casino_core/tasks/cleanup.rake b/lib/casino_core/tasks/cleanup.rake new file mode 100644 index 00000000..bbd12ea5 --- /dev/null +++ b/lib/casino_core/tasks/cleanup.rake @@ -0,0 +1,48 @@ +require 'yaml' +require 'logger' +require 'active_record' +require 'casino_core/model' + +namespace :casino_core do + namespace :cleanup do + desc 'Remove expired service tickets.' + task service_tickets: 'casino_core:db:configure_connection' do + [:consumed, :unconsumed].each do |type| + rows_affected = CASinoCore::Model::ServiceTicket.send("cleanup_#{type}").length + puts "Deleted #{rows_affected} #{type} service tickets." + end + rows_affected = CASinoCore::Model::ServiceTicket.cleanup_consumed_hard + puts "Force deleted #{rows_affected} consumed service tickets." + end + + desc 'Remove expired proxy tickets.' + task proxy_tickets: 'casino_core:db:configure_connection' do + [:consumed, :unconsumed].each do |type| + rows_affected = CASinoCore::Model::ProxyTicket.send("cleanup_#{type}").length + puts "Deleted #{rows_affected} #{type} proxy tickets." + end + end + + desc 'Remove expired login tickets.' + task login_tickets: 'casino_core:db:configure_connection' do + rows_affected = CASinoCore::Model::LoginTicket.cleanup + puts "Deleted #{rows_affected} login tickets." + end + + desc 'Remove expired inactive two-factor authenticators.' + task two_factor_authenticators: 'casino_core:db:configure_connection' do + rows_affected = CASinoCore::Model::TwoFactorAuthenticator.cleanup + puts "Deleted #{rows_affected} inactive two-factor authenticators." + end + + desc 'Remove expired ticket-granting tickets.' + task ticket_granting_tickets: 'casino_core:db:configure_connection' do + rows_affected = CASinoCore::Model::TicketGrantingTicket.cleanup.length + puts "Deleted #{rows_affected} ticket-granting tickets." + end + + desc 'Perform all cleanup tasks.' + task all: [:ticket_granting_tickets, :service_tickets, :proxy_tickets, :login_tickets, :two_factor_authenticators] do + end + end +end diff --git a/lib/casino_core/tasks/database.rake b/lib/casino_core/tasks/database.rake new file mode 100644 index 00000000..b5ca4c4b --- /dev/null +++ b/lib/casino_core/tasks/database.rake @@ -0,0 +1,57 @@ +require 'erb' +require 'yaml' +require 'logger' +require 'active_record' + +namespace :casino_core do + namespace :db do + task :environment do + BASE_DIR = if Gem.loaded_specs['casino_core'].nil? + Dir.pwd + else + Gem.loaded_specs['casino_core'].full_gem_path + end + DATABASE_ENV = ENV['DATABASE_ENV'] || ENV['RAILS_ENV'] || 'development' + ActiveRecord::Migrator.migrations_paths = File.join(BASE_DIR, 'db', 'migrate') + SCHEMA_PATH = ENV['SCHEMA'] || File.join(BASE_DIR, 'db', 'schema.rb') + end + + task :configuration => :environment do + CASinoCore.setup DATABASE_ENV + ActiveRecord::Base.logger = CASinoCore::Settings.logger + end + + desc 'Migrate the database (options: VERSION=x, VERBOSE=false)' + task :migrate => :configuration do + ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true + ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, ENV["VERSION"] ? ENV["VERSION"].to_i : nil) do |migration| + ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope) + end + end + + desc 'Rolls the schema back to the previous version (specify steps w/ STEP=n).' + task :rollback => :configuration do + step = ENV['STEP'] ? ENV['STEP'].to_i : 1 + ActiveRecord::Migrator.rollback(ActiveRecord::Migrator.migrations_paths, step) + end + + namespace :schema do + desc 'Create a db/schema.rb file that can be portably used against any DB supported by AR' + task :dump => :configuration do + require 'active_record/schema_dumper' + File.open(SCHEMA_PATH, "w:utf-8") do |file| + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) + end + end + + desc 'Load a schema.rb file into the database' + task :load => :configuration do + if File.exists?(SCHEMA_PATH) + load(SCHEMA_PATH) + else + abort %{#{SCHEMA_PATH} doesn't exist yet.} + end + end + end + end +end diff --git a/lib/casino_core/tasks/service_rule.rake b/lib/casino_core/tasks/service_rule.rake new file mode 100644 index 00000000..2cf0c4ef --- /dev/null +++ b/lib/casino_core/tasks/service_rule.rake @@ -0,0 +1,52 @@ +require 'terminal-table' +require 'casino_core/model' +require 'casino_core/helper/service_tickets' + +namespace :casino_core do + namespace :service_rule do + include CASinoCore::Helper::ServiceTickets + + desc 'Add a service rule (prefix the url parameter with "regex:" to add a regular expression)' + task :add, [:name, :url] => 'casino_core:db:configure_connection' do |task, args| + service_rule = CASinoCore::Model::ServiceRule.new name: args[:name] + match = /^regex:(.*)/.match(args[:url]) + if match.nil? + service_rule.url = clean_service_url(args[:url]) + else + service_rule.url = match[1] + service_rule.regex = true + end + if !service_rule.save + fail service_rule.errors.full_messages.join("\n") + elsif service_rule.regex && service_rule.url[0] != '^' + puts 'Warning: Potentially unsafe regex! Use ^ to match the beginning of the URL. Example: ^https://' + end + end + + desc 'Remove a servcice rule.' + task :delete, [:id] => 'casino_core:db:configure_connection' do |task, args| + CASinoCore::Model::ServiceRule.find(args[:id]).delete + puts "Successfully deleted service rule ##{args[:id]}." + end + + desc 'Delete all servcice rules.' + task :flush => 'casino_core:db:configure_connection' do |task, args| + CASinoCore::Model::ServiceRule.delete_all + puts 'Successfully deleted all service rules.' + end + + desc 'List all service rules.' + task list: 'casino_core:db:configure_connection' do + table = Terminal::Table.new :headings => ['Enabled', 'ID', 'Name', 'URL'] do |t| + CASinoCore::Model::ServiceRule.all.each do |service_rule| + url = service_rule.url + if service_rule.regex? + url += " (Regex)" + end + t.add_row [service_rule.enabled, service_rule.id, service_rule.name, url] + end + end + puts table + end + end +end diff --git a/lib/casino_core/version.rb b/lib/casino_core/version.rb new file mode 100644 index 00000000..69242d68 --- /dev/null +++ b/lib/casino_core/version.rb @@ -0,0 +1,3 @@ +module CASinoCore + VERSION = '1.4.4' +end diff --git a/spec/authenticator/base_spec.rb b/spec/authenticator/base_spec.rb new file mode 100644 index 00000000..866f4dcb --- /dev/null +++ b/spec/authenticator/base_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe CASinoCore::Authenticator do + subject { + CASinoCore::Authenticator.new + } + + context '#validate' do + it 'raises an error' do + expect { subject.validate(nil, nil) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/authenticator/static_spec.rb b/spec/authenticator/static_spec.rb new file mode 100644 index 00000000..7b08f335 --- /dev/null +++ b/spec/authenticator/static_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe CASinoCore::Authenticator::Static do + subject { + CASinoCore::Authenticator::Static.new({ + users: { + user: { + password: 'testing123', + fullname: 'Example User' + } + } + }) + } + + context '#validate' do + context 'with invalid credentials' do + it 'returns false for an unknown username' do + subject.validate('foobar', 'test').should == false + end + + it 'returns false for a known username with wrong password' do + subject.validate('user', 'test').should == false + end + end + + context 'with valid credentials' do + let(:result) { subject.validate('user', 'testing123') } + + it 'does not return false' do + result.should_not == false + end + + it 'returns the username' do + result[:username].should == 'user' + end + + it 'returns extra attributes' do + result[:extra_attributes][:fullname].should == 'Example User' + end + end + end +end diff --git a/spec/model/login_ticket_spec.rb b/spec/model/login_ticket_spec.rb new file mode 100644 index 00000000..dc0e6253 --- /dev/null +++ b/spec/model/login_ticket_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe CASinoCore::Model::LoginTicket do + describe '.cleanup' do + it 'deletes expired login tickets' do + ticket = described_class.new ticket: 'LT-12345' + ticket.save! + ticket.created_at = 10.hours.ago + ticket.save! + lambda do + described_class.cleanup + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('LT-12345').should be_false + end + end + + describe '#to_s' do + it 'returns the ticket identifier' do + ticket = described_class.new ticket: 'LT-12345' + "#{ticket}".should == ticket.ticket + end + end +end diff --git a/spec/model/proxy_ticket_spec.rb b/spec/model/proxy_ticket_spec.rb new file mode 100644 index 00000000..792951c4 --- /dev/null +++ b/spec/model/proxy_ticket_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe CASinoCore::Model::ProxyTicket do + let(:unconsumed_ticket) { + ticket = described_class.new ticket: 'PT-12345', service: 'any_string_is_valid' + ticket.proxy_granting_ticket_id = 1 + ticket + } + let(:consumed_ticket) { + ticket = described_class.new ticket: 'PT-54321', service: 'any_string_is_valid' + ticket.proxy_granting_ticket_id = 1 + ticket.consumed = true + ticket.save! + ticket + } + + describe '#expired?' do + [:unconsumed, :consumed].each do |state| + context "with an #{state} ticket" do + let(:ticket) { send("#{state}_ticket") } + + context 'with an expired ticket' do + before(:each) do + ticket.created_at = (CASinoCore::Settings.service_ticket[:"lifetime_#{state}"].seconds + 1).ago + ticket.save! + end + + it 'returns true' do + ticket.expired?.should == true + end + end + + context 'with an unexpired ticket' do + it 'returns false' do + ticket.expired?.should == false + end + end + end + end + end + + describe '.cleanup_unconsumed' do + it 'deletes expired unconsumed service tickets' do + unconsumed_ticket.created_at = 10.hours.ago + unconsumed_ticket.save! + lambda do + described_class.cleanup_unconsumed + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('PT-12345').should be_false + end + end + + describe '.cleanup_consumed' do + it 'deletes expired consumed service tickets' do + consumed_ticket.created_at = 10.days.ago + consumed_ticket.save! + lambda do + described_class.cleanup_consumed + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('PT-12345').should be_false + end + end +end diff --git a/spec/model/service_rule_spec.rb b/spec/model/service_rule_spec.rb new file mode 100644 index 00000000..bf4a24d2 --- /dev/null +++ b/spec/model/service_rule_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe CASinoCore::Model::ServiceRule do + describe '.allowed?' do + context 'with an empty table' do + ['https://www.example.org/', 'http://www.google.com/'].each do |service_url| + it "allows access to #{service_url}" do + described_class.allowed?(service_url).should == true + end + end + end + + context 'with a regex rule' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + + ['https://www.example.org/', 'https://www.google.com/'].each do |service_url| + it "allows access to #{service_url}" do + described_class.allowed?(service_url).should == true + end + end + + ['http://www.example.org/', 'http://www.google.com/'].each do |service_url| + it "does not allow access to #{service_url}" do + described_class.allowed?(service_url).should == false + end + end + end + + context 'with many regex rules' do + before(:each) do + 100.times do |counter| + FactoryGirl.create :service_rule, :regex, url: "^https://www#{counter}.example.com" + end + end + + let(:service_url) { 'https://www111.example.com/bla' } + + it 'does not take too long to check a denied service' do + start = Time.now + described_class.allowed?(service_url).should == false + (Time.now - start).should < 0.1 + end + end + + context 'with a non-regex rule' do + before(:each) do + FactoryGirl.create :service_rule, url: 'https://www.google.com/foo' + end + + ['https://www.google.com/foo'].each do |service_url| + it "allows access to #{service_url}" do + described_class.allowed?(service_url).should == true + end + end + + ['https://www.example.org/', 'http://www.example.org/', 'https://www.google.com/test'].each do |service_url| + it "does not allow access to #{service_url}" do + described_class.allowed?(service_url).should == false + end + end + end + end +end diff --git a/spec/model/service_ticket/single_sign_out_notifier_spec.rb b/spec/model/service_ticket/single_sign_out_notifier_spec.rb new file mode 100644 index 00000000..24467301 --- /dev/null +++ b/spec/model/service_ticket/single_sign_out_notifier_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' +require 'nokogiri' + +describe CASinoCore::Model::ServiceTicket::SingleSignOutNotifier do + let(:service_ticket) { FactoryGirl.create :service_ticket } + let(:service) { service_ticket.service } + let(:notifier) { described_class.new service_ticket } + + describe '#notify' do + before(:each) do + stub_request(:post, service) + end + + it 'sends a valid Single Sign Out XML to the service URL' do + notifier.notify + WebMock.should have_requested(:post, service).with { |request| + post_params = CGI.parse(request.body) + post_params.should_not be_nil + xml = Nokogiri::XML post_params['logoutRequest'].first + xml.at_xpath('/samlp:LogoutRequest/samlp:SessionIndex').text.should == service_ticket.ticket + } + end + + it 'sets the timeout values' do + [:read_timeout=, :open_timeout=].each do |timeout| + Net::HTTP.any_instance.should_receive(timeout).with(CASinoCore::Settings.service_ticket[:single_sign_out_notification][:timeout]) + end + notifier.notify + end + + context 'when it is a success' do + it 'returns true' do + notifier.notify.should == true + end + end + + context 'with server error' do + [404, 500].each do |status_code| + context "#{status_code}" do + before(:each) do + stub_request(:post, service).to_return status: status_code + end + + it 'returns false' do + notifier.notify.should == false + end + end + end + + context 'connection timeout' do + before(:each) do + stub_request(:post, service).to_raise Timeout::Error + end + + it 'returns false' do + notifier.notify.should == false + end + end + end + end +end \ No newline at end of file diff --git a/spec/model/service_ticket_spec.rb b/spec/model/service_ticket_spec.rb new file mode 100644 index 00000000..6222eb4d --- /dev/null +++ b/spec/model/service_ticket_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +describe CASinoCore::Model::ServiceTicket do + let(:unconsumed_ticket) { + ticket = described_class.new ticket: 'ST-12345', service: 'https://example.com/cas-service' + ticket.ticket_granting_ticket_id = 1 + ticket + } + let(:consumed_ticket) { + ticket = described_class.new ticket: 'ST-54321', service: 'https://example.com/cas-service' + ticket.ticket_granting_ticket_id = 1 + ticket.consumed = true + ticket.save! + ticket + } + + describe '#expired?' do + [:unconsumed, :consumed].each do |state| + context "with an #{state} ticket" do + let(:ticket) { send("#{state}_ticket") } + + context 'with an expired ticket' do + before(:each) do + ticket.created_at = (CASinoCore::Settings.service_ticket[:"lifetime_#{state}"].seconds + 1).ago + ticket.save! + end + + it 'returns true' do + ticket.expired?.should == true + end + end + + context 'with an unexpired ticket' do + it 'returns false' do + ticket.expired?.should == false + end + end + end + end + end + + describe '.cleanup_unconsumed' do + it 'deletes expired unconsumed service tickets' do + unconsumed_ticket.created_at = 10.hours.ago + unconsumed_ticket.save! + lambda do + described_class.cleanup_unconsumed + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('ST-12345').should be_false + end + end + + describe '.cleanup_consumed_hard' do + before(:each) do + described_class::SingleSignOutNotifier.any_instance.stub(:notify).and_return(false) + end + + it 'deletes consumed service tickets with an unreachable Single Sign Out callback server' do + consumed_ticket.created_at = 10.days.ago + consumed_ticket.save! + lambda do + described_class.cleanup_consumed_hard + end.should change(described_class, :count).by(-1) + end + end + + describe '.cleanup_consumed' do + before(:each) do + described_class::SingleSignOutNotifier.any_instance.stub(:notify).and_return(true) + end + + it 'deletes expired consumed service tickets' do + consumed_ticket.created_at = 10.days.ago + consumed_ticket.save! + lambda do + described_class.cleanup_consumed + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('ST-12345').should be_false + end + + it 'deletes consumed service tickets without ticket_granting_ticket' do + consumed_ticket.ticket_granting_ticket_id = nil + consumed_ticket.save! + lambda do + described_class.cleanup_consumed + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket('ST-12345').should be_false + end + + it 'does not delete unexpired service tickets' do + consumed_ticket # create the ticket + lambda do + described_class.cleanup_consumed + end.should_not change(described_class, :count) + end + end + + describe '#destroy' do + it 'sends out a single sign out notification' do + described_class::SingleSignOutNotifier.any_instance.should_receive(:notify).and_return(true) + consumed_ticket.destroy + end + + context 'when notification fails' do + before(:each) do + described_class::SingleSignOutNotifier.any_instance.stub(:notify).and_return(false) + end + + it 'does delete the service ticket anyway' do + consumed_ticket + lambda { + consumed_ticket.destroy + }.should change(CASinoCore::Model::ServiceTicket, :count).by(-1) + end + end + end + + describe '#service_with_ticket_url' do + it 'does not escape the url from the database' do + unconsumed_ticket.service = 'https://host.example.org/test.php?t=other&other=testing' + unconsumed_ticket.service_with_ticket_url.should eq('https://host.example.org/test.php?t=other&other=testing&ticket=ST-12345') + end + end +end diff --git a/spec/model/ticket_granting_ticket_spec.rb b/spec/model/ticket_granting_ticket_spec.rb new file mode 100644 index 00000000..aec699fc --- /dev/null +++ b/spec/model/ticket_granting_ticket_spec.rb @@ -0,0 +1,204 @@ +require 'spec_helper' +require 'useragent' + +describe CASinoCore::Model::TicketGrantingTicket do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:service_ticket) { FactoryGirl.create :service_ticket, ticket_granting_ticket: ticket_granting_ticket } + + describe '#destroy' do + let!(:consumed_service_ticket) { FactoryGirl.create :service_ticket, :consumed, ticket_granting_ticket: ticket_granting_ticket } + + context 'when notification for a service ticket fails' do + before(:each) do + CASinoCore::Model::ServiceTicket::SingleSignOutNotifier.any_instance.stub(:notify).and_return(false) + end + + it 'deletes depending proxy-granting tickets' do + consumed_service_ticket.proxy_granting_tickets.create! ticket: 'PGT-12345', iou: 'PGTIOU-12345', pgt_url: 'bla' + lambda { + ticket_granting_ticket.destroy + }.should change(CASinoCore::Model::ProxyGrantingTicket, :count).by(-1) + end + + it 'deletes depending service tickets' do + lambda { + ticket_granting_ticket.destroy + }.should change(CASinoCore::Model::ServiceTicket, :count).by(-1) + end + end + end + + describe '#browser_info' do + let(:user_agent) { Object.new } + before(:each) do + user_agent.stub(:browser).and_return('TestBrowser') + UserAgent.stub(:parse).and_return(user_agent) + end + + context 'without platform' do + before(:each) do + user_agent.stub(:platform).and_return(nil) + end + + it 'returns the browser name' do + ticket_granting_ticket.browser_info.should == 'TestBrowser' + end + end + + context 'with a platform' do + before(:each) do + user_agent.stub(:platform).and_return('Linux') + end + + it 'returns the browser name' do + ticket_granting_ticket.browser_info.should == 'TestBrowser (Linux)' + end + end + end + + describe '#same_user?' do + context 'with a nil value' do + let(:other_ticket_granting_ticket) { nil } + + it 'should return false' do + ticket_granting_ticket.same_user?(other_ticket_granting_ticket).should == false + end + end + + context 'with a ticket from another user' do + let(:other_ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + + it 'should return false' do + ticket_granting_ticket.same_user?(other_ticket_granting_ticket).should == false + end + end + + context 'with a ticket from the same user' do + let(:other_ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, user: ticket_granting_ticket.user } + + it 'should return true' do + ticket_granting_ticket.same_user?(other_ticket_granting_ticket).should == true + end + end + end + + describe '#expired?' do + context 'with a long-term ticket' do + context 'when almost expired' do + before(:each) do + ticket_granting_ticket.created_at = 9.days.ago + ticket_granting_ticket.long_term = true + ticket_granting_ticket.save! + end + + it 'returns false' do + ticket_granting_ticket.expired?.should == false + end + end + + context 'when expired' do + before(:each) do + ticket_granting_ticket.created_at = 30.days.ago + ticket_granting_ticket.long_term = true + ticket_granting_ticket.save! + end + + it 'returns true' do + ticket_granting_ticket.expired?.should == true + end + end + end + + context 'with an expired ticket' do + before(:each) do + ticket_granting_ticket.created_at = 25.hours.ago + ticket_granting_ticket.save! + end + + it 'returns true' do + ticket_granting_ticket.expired?.should == true + end + end + + context 'with an unexpired ticket' do + it 'returns false' do + ticket_granting_ticket.expired?.should == false + end + end + + context 'with pending two-factor authentication' do + before(:each) do + ticket_granting_ticket.awaiting_two_factor_authentication = true + ticket_granting_ticket.save! + end + + context 'with an expired ticket' do + before(:each) do + ticket_granting_ticket.created_at = 10.minutes.ago + ticket_granting_ticket.save! + end + + it 'returns true' do + ticket_granting_ticket.expired?.should == true + end + end + + context 'with an unexpired ticket' do + it 'returns false' do + ticket_granting_ticket.expired?.should == false + end + end + end + end + + describe '.cleanup' do + let!(:other_ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + + it 'deletes expired ticket-granting tickets' do + ticket_granting_ticket.created_at = 25.hours.ago + ticket_granting_ticket.save! + lambda do + described_class.cleanup + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket(ticket_granting_ticket.ticket).should be_false + end + + it 'does not delete almost expired long-term ticket-granting tickets' do + ticket_granting_ticket.created_at = 9.days.ago + ticket_granting_ticket.long_term = true + ticket_granting_ticket.save! + lambda do + described_class.cleanup + end.should_not change(described_class, :count) + end + + it 'does delete expired long-term ticket-granting tickets' do + ticket_granting_ticket.created_at = 30.days.ago + ticket_granting_ticket.long_term = true + ticket_granting_ticket.save! + lambda do + described_class.cleanup + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket(ticket_granting_ticket.ticket).should be_false + end + + it 'does not delete almost expired ticket-granting tickets with pending two-factor authentication' do + ticket_granting_ticket.created_at = 2.minutes.ago + ticket_granting_ticket.awaiting_two_factor_authentication = true + ticket_granting_ticket.save! + lambda do + described_class.cleanup + end.should_not change(described_class, :count) + end + + it 'does delete expired ticket-granting tickets with pending two-factor authentication' do + ticket_granting_ticket.created_at = 20.minutes.ago + ticket_granting_ticket.awaiting_two_factor_authentication = true + ticket_granting_ticket.save! + lambda do + described_class.cleanup + end.should change(described_class, :count).by(-1) + described_class.find_by_ticket(ticket_granting_ticket.ticket).should be_false + end + end +end diff --git a/spec/model/two_factor_authenticator_spec.rb b/spec/model/two_factor_authenticator_spec.rb new file mode 100644 index 00000000..3ccb045e --- /dev/null +++ b/spec/model/two_factor_authenticator_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe CASinoCore::Model::TwoFactorAuthenticator do + describe '.cleanup' do + it 'deletes expired inactive two-factor authenticators' do + authenticator = FactoryGirl.create :two_factor_authenticator, :inactive + authenticator.created_at = 10.hours.ago + authenticator.save! + lambda do + described_class.cleanup + end.should change(described_class, :count).by(-1) + end + + it 'does not delete not expired inactive two-factor authenticators' do + authenticator = FactoryGirl.create :two_factor_authenticator, :inactive + authenticator.created_at = (CASinoCore::Settings.two_factor_authenticator[:lifetime_inactive].seconds - 5).ago + lambda do + described_class.cleanup + end.should_not change(described_class, :count) + end + + it 'does not delete active two-factor authenticators' do + authenticator = FactoryGirl.create :two_factor_authenticator + authenticator.created_at = 10.hours.ago + authenticator.save! + lambda do + described_class.cleanup + end.should_not change(described_class, :count) + end + end +end diff --git a/spec/processor/api/login_credential_acceptor_spec.rb b/spec/processor/api/login_credential_acceptor_spec.rb new file mode 100644 index 00000000..e3952283 --- /dev/null +++ b/spec/processor/api/login_credential_acceptor_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe CASinoCore::Processor::API::LoginCredentialAcceptor do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:user_agent) { 'ThisIsATestBrwoser 1.0' } + + context 'with invalid credentials' do + let(:login_data) { { username: 'testuser', password: 'wrong' } } + + before(:each) do + listener.stub(:invalid_login_credentials_via_api) + end + + it 'calls the #invalid_login_credentials_via_api method on the listener' do + listener.should_receive(:invalid_login_credentials_via_api) + processor.process(login_data, user_agent).should be_false + end + + it 'does not generate a ticket-granting ticket' do + expect { + processor.process(login_data, user_agent) + }.to_not change(CASinoCore::Model::TicketGrantingTicket, :count) + end + end + + context 'with valid credentials' do + let(:login_data) { { username: 'testuser', password: 'foobar123' } } + + before(:each) do + listener.stub(:user_logged_in_via_api) + end + + it 'calls the #user_logged_in_via_api method on the listener' do + listener.should_receive(:user_logged_in_via_api).with(/^TGC\-/) + processor.process(login_data, user_agent) + end + + it 'generates a ticket-granting ticket' do + expect { + processor.process(login_data, user_agent) + }.to change(CASinoCore::Model::TicketGrantingTicket, :count).by(1) + end + + it 'sets the user-agent in the ticket-granting ticket' do + processor.process(login_data, user_agent) + CASinoCore::Model::TicketGrantingTicket.last.user_agent.should == user_agent + end + end + end +end diff --git a/spec/processor/api/logout_spec.rb b/spec/processor/api/logout_spec.rb new file mode 100644 index 00000000..91d36b32 --- /dev/null +++ b/spec/processor/api/logout_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe CASinoCore::Processor::API::Logout do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create(:ticket_granting_ticket) } + let(:user_agent) { ticket_granting_ticket.user_agent } + + it 'deletes the ticket-granting ticket' do + listener.should_receive(:user_logged_out_via_api) + processor.process(ticket_granting_ticket.ticket, user_agent) + CASinoCore::Model::TicketGrantingTicket.where(id: ticket_granting_ticket.id).first.should == nil + end + + it 'calls the #user_logged_out_via_api method on the listener' do + listener.should_receive(:user_logged_out_via_api) + processor.process(ticket_granting_ticket, user_agent) + end + + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + + it 'calls the #user_logged_out method on the listener' do + listener.should_receive(:user_logged_out_via_api) + processor.process(tgt) + end + end + end +end diff --git a/spec/processor/api/service_ticket_provider_spec.rb b/spec/processor/api/service_ticket_provider_spec.rb new file mode 100644 index 00000000..3180ff32 --- /dev/null +++ b/spec/processor/api/service_ticket_provider_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe CASinoCore::Processor::API::ServiceTicketProvider do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + let(:service) { 'http://example.org/' } + let(:parameters) { { service: service } } + + context 'with an invalid ticket-granting ticket' do + let(:ticket_granting_ticket) { 'TGT-INVALID' } + + it 'calls the #invalid_tgt_via_api method on the listener' do + listener.should_receive(:invalid_ticket_granting_ticket_via_api) + processor.process(ticket_granting_ticket, parameters).should be_false + end + end + + context 'with a valid ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create(:ticket_granting_ticket) } + let(:ticket) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + + context 'with a not allowed service' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + let(:service) { 'http://www.example.org/' } + + it 'calls the #service_not_allowed method on the listener' do + listener.should_receive(:service_not_allowed_via_api).with(service) + processor.process(ticket, parameters, user_agent) + end + end + + it 'calls the #granted_service_ticket_via_api method on the listener' do + listener.should_receive(:granted_service_ticket_via_api).with(/^ST\-/) + processor.process(ticket, parameters, user_agent) + end + + it 'generates a ticket-granting ticket' do + listener.should_receive(:granted_service_ticket_via_api).with(/^ST\-/) + expect { + processor.process(ticket, parameters, user_agent) + }.to change(CASinoCore::Model::ServiceTicket, :count).by(1) + end + + context 'without a service' do + let(:parameters) { { } } + + it 'calls the #no_service_provided_via_api method on the listener' do + listener.should_receive(:no_service_provided_via_api) + processor.process(ticket, parameters, user_agent) + end + end + + end + end +end + diff --git a/spec/processor/legacy_validator_spec.rb b/spec/processor/legacy_validator_spec.rb new file mode 100644 index 00000000..84fbb0fb --- /dev/null +++ b/spec/processor/legacy_validator_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' + +describe CASinoCore::Processor::LegacyValidator do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:service_ticket) { FactoryGirl.create :service_ticket } + let(:parameters) { { service: service_ticket.service, ticket: service_ticket.ticket }} + let(:username) { service_ticket.ticket_granting_ticket.user.username } + + before(:each) do + listener.stub(:validation_failed) + listener.stub(:validation_succeeded) + end + + context 'with an unconsumed service ticket' do + context 'without renew flag' do + it 'consumes the service ticket' do + processor.process(parameters) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with("yes\n#{username}\n") + processor.process(parameters) + end + end + + context 'with renew flag' do + let(:parameters_with_renew) { parameters.merge renew: 'true' } + + context 'with a service ticket without issued_from_credentials flag' do + it 'consumes the service ticket' do + processor.process(parameters_with_renew) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed).with("no\n\n") + processor.process(parameters_with_renew) + end + end + + context 'with a service ticket with issued_from_credentials flag' do + before(:each) do + service_ticket.issued_from_credentials = true + service_ticket.save! + end + + it 'consumes the service ticket' do + processor.process(parameters_with_renew) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with("yes\n#{username}\n") + processor.process(parameters_with_renew) + end + end + end + end + + context 'with a consumed service ticket' do + before(:each) do + service_ticket.consumed = true + service_ticket.save! + end + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed).with("no\n\n") + processor.process(parameters) + end + end + end +end diff --git a/spec/processor/login_credential_acceptor_spec.rb b/spec/processor/login_credential_acceptor_spec.rb new file mode 100644 index 00000000..3310a652 --- /dev/null +++ b/spec/processor/login_credential_acceptor_spec.rb @@ -0,0 +1,164 @@ +require 'spec_helper' + +describe CASinoCore::Processor::LoginCredentialAcceptor do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + context 'without a valid login ticket' do + it 'calls the #invalid_login_ticket method on the listener' do + listener.should_receive(:invalid_login_ticket).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process + end + end + + context 'with an expired login ticket' do + let(:expired_login_ticket) { FactoryGirl.create :login_ticket, :expired } + + it 'calls the #invalid_login_ticket method on the listener' do + listener.should_receive(:invalid_login_ticket).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(lt: expired_login_ticket.ticket) + end + end + + context 'with a valid login ticket' do + let(:login_ticket) { FactoryGirl.create :login_ticket } + + context 'with invalid credentials' do + it 'calls the #invalid_login_credentials method on the listener' do + listener.should_receive(:invalid_login_credentials).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(lt: login_ticket.ticket) + end + end + + context 'with valid credentials' do + let(:service) { 'https://www.example.org' } + let(:username) { 'testuser' } + let(:authenticator) { 'static_1' } + let(:login_data) { { lt: login_ticket.ticket, username: username, password: 'foobar123', service: service } } + + before(:each) do + listener.stub(:user_logged_in) + end + + context 'with rememberMe set' do + let(:login_data_with_remember_me) { login_data.merge(rememberMe: true) } + + it 'calls the #user_logged_in method on the listener with an expiration date set' do + listener.should_receive(:user_logged_in).with(/^#{service}\/\?ticket=ST\-/, /^TGC\-/, kind_of(Time)) + processor.process(login_data_with_remember_me) + end + + it 'creates a long-term ticket-granting ticket' do + processor.process(login_data_with_remember_me) + tgt = CASinoCore::Model::TicketGrantingTicket.last + tgt.long_term.should == true + end + end + + context 'with two-factor authentication enabled' do + let(:user) { CASinoCore::Model::User.create! username: username, authenticator: authenticator } + let!(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, user: user } + + it 'calls the `#two_factor_authentication_pending` method on the listener' do + listener.should_receive(:two_factor_authentication_pending).with(/^TGC\-/) + processor.process(login_data) + end + end + + context 'with a not allowed service' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + let(:service) { 'http://www.example.org/' } + + it 'calls the #service_not_allowed method on the listener' do + listener.should_receive(:service_not_allowed).with(service) + processor.process(login_data) + end + end + + context 'when all authenticators raise an error' do + before(:each) do + CASinoCore::Authenticator::Static.any_instance.stub(:validate) do + raise CASinoCore::Authenticator::AuthenticatorError, 'error123' + end + end + + it 'calls the #invalid_login_credentials method on the listener' do + listener.should_receive(:invalid_login_credentials).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(login_data) + end + end + + context 'without a service' do + let(:service) { nil } + + it 'calls the #user_logged_in method on the listener' do + listener.should_receive(:user_logged_in).with(nil, /^TGC\-/) + processor.process(login_data) + end + + it 'generates a ticket-granting ticket' do + lambda do + processor.process(login_data) + end.should change(CASinoCore::Model::TicketGrantingTicket, :count).by(1) + end + + context 'when the user does not exist yet' do + it 'generates exactly one user' do + lambda do + processor.process(login_data) + end.should change(CASinoCore::Model::User, :count).by(1) + end + + it 'sets the users attributes' do + processor.process(login_data) + user = CASinoCore::Model::User.last + user.username.should == username + user.authenticator.should == 'static_1' + end + end + + context 'when the user already exists' do + it 'does not regenerate the user' do + CASinoCore::Model::User.create! username: username, authenticator: authenticator + lambda do + processor.process(login_data) + end.should_not change(CASinoCore::Model::User, :count) + end + + it 'updates the extra attributes' do + user = CASinoCore::Model::User.create! username: username, authenticator: authenticator + lambda do + processor.process(login_data) + user.reload + end.should change(user, :extra_attributes) + end + end + end + + context 'with a service' do + let(:service) { 'https://www.example.com' } + + it 'calls the #user_logged_in method on the listener' do + listener.should_receive(:user_logged_in).with(/^#{service}\/\?ticket=ST\-/, /^TGC\-/) + processor.process(login_data) + end + + it 'generates a service ticket' do + lambda do + processor.process(login_data) + end.should change(CASinoCore::Model::ServiceTicket, :count).by(1) + end + + it 'generates a ticket-granting ticket' do + lambda do + processor.process(login_data) + end.should change(CASinoCore::Model::TicketGrantingTicket, :count).by(1) + end + end + end + end + end +end diff --git a/spec/processor/login_credential_requestor_spec.rb b/spec/processor/login_credential_requestor_spec.rb new file mode 100644 index 00000000..5a155532 --- /dev/null +++ b/spec/processor/login_credential_requestor_spec.rb @@ -0,0 +1,135 @@ +require 'spec_helper' + +describe CASinoCore::Processor::LoginCredentialRequestor do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + context 'with a not allowed service' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + let(:service) { 'http://www.example.org/' } + let(:params) { { service: service } } + + it 'calls the #service_not_allowed method on the listener' do + listener.should_receive(:service_not_allowed).with(service) + processor.process(params) + end + end + + context 'when logged out' do + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process + end + + context 'with gateway parameter' do + context 'with a service' do + let(:service) { 'http://example.com/' } + let(:params) { { service: service, gateway: 'true' } } + + it 'calls the #user_logged_in method on the listener' do + listener.should_receive(:user_logged_in).with(service) + processor.process(params) + end + end + + context 'without a service' do + let(:params) { { gateway: 'true' } } + + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process + end + end + end + end + + context 'when logged in' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + let(:cookies) { { tgt: ticket_granting_ticket.ticket } } + + before(:each) do + listener.stub(:user_logged_in) + end + + context 'when two-factor authentication is pending' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, :awaiting_two_factor_authentication } + + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(nil, cookies, user_agent) + end + end + + context 'when ticket-granting ticket expired' do + before(:each) do + ticket_granting_ticket.created_at = 25.hours.ago + ticket_granting_ticket.save! + end + + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(nil, cookies, user_agent) + end + end + + context 'with a service' do + let(:service) { 'http://example.com/' } + let(:params) { { service: service } } + + it 'calls the #user_logged_in method on the listener' do + listener.should_receive(:user_logged_in).with(/^#{service}\?ticket=ST\-/) + processor.process(params, cookies, user_agent) + end + + it 'generates a service ticket' do + lambda do + processor.process(params, cookies, user_agent) + end.should change(CASinoCore::Model::ServiceTicket, :count).by(1) + end + + context 'with renew parameter' do + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(params.merge({ renew: 'true' }), cookies) + end + end + end + + context 'with a service with nested attributes' do + let(:service) { 'http://example.com/?a%5B%5D=test&a%5B%5D=example' } + let(:params) { { service: service } } + + it 'does not remove the attributes' do + listener.should_receive(:user_logged_in).with(/\?a%5B%5D=test&a%5B%5D=example&ticket=ST\-[^&]+$/) + processor.process(params, cookies, user_agent) + end + end + + context 'without a service' do + it 'calls the #user_logged_in method on the listener' do + listener.should_receive(:user_logged_in).with(nil) + processor.process(nil, cookies, user_agent) + end + + it 'does not generate a service ticket' do + lambda do + processor.process(nil, cookies, user_agent) + end.should change(CASinoCore::Model::ServiceTicket, :count).by(0) + end + + context 'with a changed browser' do + let(:user_agent) { 'FooBar 1.0' } + + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(kind_of(CASinoCore::Model::LoginTicket)) + processor.process(nil, cookies, user_agent) + end + end + end + end + end +end diff --git a/spec/processor/logout_other_sessions_spec.rb b/spec/processor/logout_other_sessions_spec.rb new file mode 100644 index 00000000..9ad195e6 --- /dev/null +++ b/spec/processor/logout_other_sessions_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe CASinoCore::Processor::OtherSessionsDestroyer do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + let(:url) { nil } + let(:params) { { :service => url } unless url.nil? } + + before(:each) do + listener.stub(:other_sessions_destroyed) + end + + context 'with an existing ticket-granting ticket' do + let(:user) { FactoryGirl.create :user } + let!(:other_users_ticket_granting_tickets) { FactoryGirl.create_list :ticket_granting_ticket, 3 } + let!(:other_ticket_granting_tickets) { FactoryGirl.create_list :ticket_granting_ticket, 3, user: user } + let!(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, user: user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + + it 'deletes all other ticket-granting tickets' do + lambda do + processor.process(params, cookies, user_agent) + end.should change(CASinoCore::Model::TicketGrantingTicket, :count).by(-3) + end + + it 'calls the #user_logged_out method on the listener' do + listener.should_receive(:other_sessions_destroyed).with(nil) + processor.process(params, cookies, user_agent) + end + + context 'with an URL' do + let(:url) { 'http://www.example.com' } + + it 'calls the #user_logged_out method on the listener and passes the URL' do + listener.should_receive(:other_sessions_destroyed).with(url) + processor.process(params, cookies, user_agent) + end + end + end + + context 'with an invlaid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + + it 'calls the #other_sessions_destroyed method on the listener' do + listener.should_receive(:other_sessions_destroyed).with(nil) + processor.process(params, cookies) + end + end + end +end diff --git a/spec/processor/logout_spec.rb b/spec/processor/logout_spec.rb new file mode 100644 index 00000000..94778857 --- /dev/null +++ b/spec/processor/logout_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe CASinoCore::Processor::Logout do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + let(:url) { nil } + let(:params) { { :url => url } unless url.nil? } + + before(:each) do + listener.stub(:user_logged_out) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + + it 'deletes the ticket-granting ticket' do + processor.process(params, cookies, user_agent) + CASinoCore::Model::TicketGrantingTicket.where(id: ticket_granting_ticket.id).first.should == nil + end + + it 'calls the #user_logged_out method on the listener' do + listener.should_receive(:user_logged_out).with(nil) + processor.process(params, cookies, user_agent) + end + + context 'with an URL' do + let(:url) { 'http://www.example.com' } + + it 'calls the #user_logged_out method on the listener and passes the URL' do + listener.should_receive(:user_logged_out).with(url) + processor.process(params, cookies, user_agent) + end + end + + context 'with a service' do + let(:params) { { :service => url } } + let(:url) { 'http://www.example.org' } + + context '(whitelisted)' do + it 'calls the #user_logged_out method on the listener and passes the URL and the redirect_immediate flag' do + listener.should_receive(:user_logged_out).with(url, true) + processor.process(params, cookies, user_agent) + end + end + + context '(not whitelisted)' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + + it 'calls the #user_logged_out method on the listener and passes no URL' do + listener.should_receive(:user_logged_out).with(nil) + processor.process(params, cookies, user_agent) + end + end + end + end + + context 'with an invlaid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + + it 'calls the #user_logged_out method on the listener' do + listener.should_receive(:user_logged_out).with(nil) + processor.process(params, cookies) + end + end + end +end diff --git a/spec/processor/proxy_ticket_provider_spec.rb b/spec/processor/proxy_ticket_provider_spec.rb new file mode 100644 index 00000000..523ca1e7 --- /dev/null +++ b/spec/processor/proxy_ticket_provider_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe CASinoCore::Processor::ProxyTicketProvider do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:params) { { targetService: 'this_does_not_have_to_be_a_url' } } + + before(:each) do + listener.stub(:request_failed) + listener.stub(:request_succeeded) + end + + context 'without proxy-granting ticket' do + it 'calls the #request_failed method on the listener' do + listener.should_receive(:request_failed) + processor.process(params) + end + + it 'does not create a proxy ticket' do + lambda do + processor.process(params) + end.should_not change(CASinoCore::Model::ProxyTicket, :count) + end + end + + context 'with a not-existing proxy-granting ticket' do + let(:params_with_deleted_pgt) { params.merge(pgt: 'PGT-123453789') } + + it 'calls the #request_failed method on the listener' do + listener.should_receive(:request_failed) + processor.process(params_with_deleted_pgt) + end + + it 'does not create a proxy ticket' do + lambda do + processor.process(params_with_deleted_pgt) + end.should_not change(CASinoCore::Model::ProxyTicket, :count) + end + end + + context 'with a proxy-granting ticket' do + let(:proxy_granting_ticket) { FactoryGirl.create :proxy_granting_ticket } + let(:params_with_valid_pgt) { params.merge(pgt: proxy_granting_ticket.ticket) } + + it 'calls the #request_succeeded method on the listener' do + listener.should_receive(:request_succeeded) + processor.process(params_with_valid_pgt) + end + + it 'does not create a proxy ticket' do + lambda do + processor.process(params_with_valid_pgt) + end.should change(proxy_granting_ticket.proxy_tickets, :count).by(1) + end + + it 'includes the proxy ticket in the response' do + listener.should_receive(:request_succeeded) do |response| + proxy_ticket = CASinoCore::Model::ProxyTicket.last + response.should =~ /#{proxy_ticket.ticket}<\/cas:proxyTicket>/ + end + processor.process(params_with_valid_pgt) + end + end + end +end diff --git a/spec/processor/proxy_ticket_validator_spec.rb b/spec/processor/proxy_ticket_validator_spec.rb new file mode 100644 index 00000000..12c4c088 --- /dev/null +++ b/spec/processor/proxy_ticket_validator_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe CASinoCore::Processor::ProxyTicketValidator do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + describe '#process' do + let(:regex_success) { /\A\s*#{proxy_ticket.proxy_granting_ticket.pgt_url}<\/cas:proxy>\s*<\/cas:proxies>/ } + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters) + end + + it 'includes the proxy in the response' do + listener.should_receive(:validation_succeeded).with(regex_proxy) + processor.process(parameters) + end + + context 'with an expired proxy ticket' do + before(:each) do + CASinoCore::Model::ProxyTicket.any_instance.stub(:expired?).and_return(true) + end + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed) + processor.process(parameters) + end + end + + context 'with an other service' do + let(:parameters_with_other_service) { parameters.merge(service: 'this_is_another_service') } + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed) + processor.process(parameters_with_other_service) + end + end + + context 'without an existing ticket' do + let(:parameters_without_existing_ticket) { { ticket: 'PT-1234', service: 'https://www.example.com/' } } + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed) + processor.process(parameters_without_existing_ticket) + end + end + end + end +end diff --git a/spec/processor/second_factor_authenticaton_acceptor_spec.rb b/spec/processor/second_factor_authenticaton_acceptor_spec.rb new file mode 100644 index 00000000..38248411 --- /dev/null +++ b/spec/processor/second_factor_authenticaton_acceptor_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe CASinoCore::Processor::SecondFactorAuthenticationAcceptor do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:invalid_one_time_password) + listener.stub(:user_logged_in) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, :awaiting_two_factor_authentication } + let(:user) { ticket_granting_ticket.user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + let(:otp) { '123456' } + let(:service) { 'http://www.example.com/testing' } + let(:params) { { tgt: tgt, otp: otp, service: service }} + + context 'with an active authenticator' do + let!(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, user: user } + + context 'with a valid OTP' do + before(:each) do + ROTP::TOTP.any_instance.should_receive(:verify_with_drift).with(otp, 30).and_return(true) + end + + it 'calls the `#user_logged_in` method an the listener' do + listener.should_receive(:user_logged_in).with(/^#{service}\?ticket=ST\-/, /^TGC\-/) + processor.process(params, user_agent) + end + + it 'does activate the ticket-granting ticket' do + processor.process(params, user_agent) + ticket_granting_ticket.reload + ticket_granting_ticket.should_not be_awaiting_two_factor_authentication + end + + context 'with a long-term ticket-granting ticket' do + before(:each) do + ticket_granting_ticket.update_attributes! long_term: true + end + + it 'calls the #user_logged_in method on the listener with an expiration date set' do + listener.should_receive(:user_logged_in).with(/^#{service}\?ticket=ST\-/, /^TGC\-/, kind_of(Time)) + processor.process(params, user_agent) + end + end + + context 'with a not allowed service' do + before(:each) do + FactoryGirl.create :service_rule, :regex, url: '^https://.*' + end + let(:service) { 'http://www.example.org/' } + + it 'calls the #service_not_allowed method on the listener' do + listener.should_receive(:service_not_allowed).with(service) + processor.process(params.merge(service: service), user_agent) + end + end + end + + context 'with an invalid OTP' do + before(:each) do + ROTP::TOTP.any_instance.should_receive(:verify_with_drift).with(otp, 30).and_return(false) + end + + it 'calls the `#invalid_one_time_password` method an the listener' do + listener.should_receive(:invalid_one_time_password).with(no_args) + processor.process(params, user_agent) + end + + it 'does not activate the ticket-granting ticket' do + processor.process(params, user_agent) + ticket_granting_ticket.reload + ticket_granting_ticket.should be_awaiting_two_factor_authentication + end + end + end + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + let(:user_agent) { 'TestBrowser 1.0' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process({tgt: tgt}, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/session_destroyer_spec.rb b/spec/processor/session_destroyer_spec.rb new file mode 100644 index 00000000..a448a66a --- /dev/null +++ b/spec/processor/session_destroyer_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe CASinoCore::Processor::SessionDestroyer do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:owner_ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { owner_ticket_granting_ticket.user } + let(:user_agent) { owner_ticket_granting_ticket.user_agent } + let(:cookies) { { tgt: owner_ticket_granting_ticket.ticket } } + + before(:each) do + listener.stub(:ticket_deleted) + listener.stub(:ticket_not_found) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, user: user } + let(:service_ticket) { FactoryGirl.create :service_ticket, ticket_granting_ticket: ticket_granting_ticket } + let(:consumed_service_ticket) { FactoryGirl.create :service_ticket, :consumed, ticket_granting_ticket: ticket_granting_ticket } + let(:params) { { id: ticket_granting_ticket.id } } + + it 'deletes exactly one ticket-granting ticket' do + ticket_granting_ticket + owner_ticket_granting_ticket + lambda do + processor.process(params, cookies, user_agent) + end.should change(CASinoCore::Model::TicketGrantingTicket, :count).by(-1) + end + + it 'deletes the ticket-granting ticket' do + processor.process(params, cookies, user_agent) + CASinoCore::Model::TicketGrantingTicket.where(id: params[:id]).length.should == 0 + end + + it 'calls the #ticket_deleted method on the listener' do + listener.should_receive(:ticket_deleted).with(no_args) + processor.process(params, cookies, user_agent) + end + end + + context 'with an invalid ticket-granting ticket' do + let(:params) { { id: 99999 } } + it 'does not delete a ticket-granting ticket' do + owner_ticket_granting_ticket + lambda do + processor.process(params, cookies, user_agent) + end.should_not change(CASinoCore::Model::TicketGrantingTicket, :count) + end + + it 'calls the #ticket_not_found method on the listener' do + listener.should_receive(:ticket_not_found).with(no_args) + processor.process(params, cookies, user_agent) + end + end + + context 'when trying to delete ticket-granting ticket of another user' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:params) { { id: ticket_granting_ticket.id } } + + it 'does not delete a ticket-granting ticket' do + owner_ticket_granting_ticket + ticket_granting_ticket + lambda do + processor.process(params, cookies, user_agent) + end.should change(CASinoCore::Model::TicketGrantingTicket, :count).by(0) + end + + it 'calls the #ticket_not_found method on the listener' do + listener.should_receive(:ticket_not_found).with(no_args) + processor.process(params, cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/session_overview_spec.rb b/spec/processor/session_overview_spec.rb new file mode 100644 index 00000000..4b5ab3f6 --- /dev/null +++ b/spec/processor/session_overview_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe CASinoCore::Processor::SessionOverview do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:other_ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { other_ticket_granting_ticket.user } + let(:user_agent) { other_ticket_granting_ticket.user_agent } + let(:cookies) { { tgt: tgt } } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:ticket_granting_tickets_found) + other_ticket_granting_ticket + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket, user: user } + let(:tgt) { ticket_granting_ticket.ticket } + it 'calls the #ticket_granting_tickets_found method on the listener' do + listener.should_receive(:ticket_granting_tickets_found) do |tickets| + tickets.length.should == 2 + end + processor.process(cookies, user_agent) + end + end + + context 'with a ticket-granting ticket with same username but different authenticator' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:tgt) { ticket_granting_ticket.ticket } + + it 'calls the #ticket_granting_tickets_found method on the listener' do + listener.should_receive(:ticket_granting_tickets_found) do |tickets| + tickets.length.should == 1 + end + processor.process(cookies, user_agent) + end + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process(cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/ticket_validator_spec.rb b/spec/processor/ticket_validator_spec.rb new file mode 100644 index 00000000..a22c09bd --- /dev/null +++ b/spec/processor/ticket_validator_spec.rb @@ -0,0 +1,199 @@ +require 'spec_helper' + +[CASinoCore::Processor::ServiceTicketValidator, CASinoCore::Processor::ProxyTicketValidator].each do |class_under_test| + describe class_under_test do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:service_ticket) { FactoryGirl.create :service_ticket } + let(:parameters) { { service: service_ticket.service, ticket: service_ticket.ticket }} + let(:regex_failure) { /\A\true<\/cas\:longTermAuthenticationRequestTokenUsed>/ + ) + processor.process(parameters) + end + end + + context 'without renew flag' do + it 'consumes the service ticket' do + processor.process(parameters) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters) + end + end + + context 'with empty query values' do + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters.merge(service: "#{service_ticket.service}/?")) + end + end + + context 'with renew flag' do + let(:parameters_with_renew) { parameters.merge renew: 'true' } + + context 'with a service ticket without issued_from_credentials flag' do + it 'consumes the service ticket' do + processor.process(parameters_with_renew) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed).with(regex_failure) + processor.process(parameters_with_renew) + end + end + + context 'with a service ticket with issued_from_credentials flag' do + before(:each) do + service_ticket.issued_from_credentials = true + service_ticket.save! + end + + it 'consumes the service ticket' do + processor.process(parameters_with_renew) + service_ticket.reload + service_ticket.consumed.should == true + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters_with_renew) + end + end + end + + context 'with proxy-granting ticket callback server' do + let(:pgt_url) { 'https://www.example.org' } + let(:parameters_with_pgt_url) { parameters.merge pgtUrl: pgt_url } + + before(:each) do + stub_request(:get, /#{pgt_url}\/\?pgtId=[^&]+&pgtIou=[^&]+/) + end + + context 'not using https' do + let(:pgt_url) { 'http://www.example.org' } + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters_with_pgt_url) + end + + it 'does not create a proxy-granting ticket' do + lambda do + processor.process(parameters_with_pgt_url) + end.should_not change(service_ticket.proxy_granting_tickets, :count) + end + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters_with_pgt_url) + end + + it 'includes the PGTIOU in the response' do + listener.should_receive(:validation_succeeded).with(/\\n?\s*PGTIOU-.+/) + processor.process(parameters_with_pgt_url) + end + + it 'creates a proxy-granting ticket' do + lambda do + processor.process(parameters_with_pgt_url) + end.should change(service_ticket.proxy_granting_tickets, :count).by(1) + end + + it 'contacts the callback server' do + processor.process(parameters_with_pgt_url) + proxy_granting_ticket = CASinoCore::Model::ProxyGrantingTicket.last + WebMock.should have_requested(:get, 'https://www.example.org').with(query: { + pgtId: proxy_granting_ticket.ticket, + pgtIou: proxy_granting_ticket.iou + }) + end + + context 'when callback server gives an error' do + before(:each) do + stub_request(:get, /#{pgt_url}.*/).to_return status: 404 + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters_with_pgt_url) + end + + it 'does not create a proxy-granting ticket' do + lambda do + processor.process(parameters_with_pgt_url) + end.should_not change(service_ticket.proxy_granting_tickets, :count) + end + end + + context 'when callback server is unreachable' do + before(:each) do + stub_request(:get, /#{pgt_url}.*/).to_raise(Timeout::Error) + end + + it 'calls the #validation_succeeded method on the listener' do + listener.should_receive(:validation_succeeded).with(regex_success) + processor.process(parameters_with_pgt_url) + end + + it 'does not create a proxy-granting ticket' do + lambda do + processor.process(parameters_with_pgt_url) + end.should_not change(service_ticket.proxy_granting_tickets, :count) + end + end + end + end + + context 'with a consumed service ticket' do + before(:each) do + service_ticket.consumed = true + service_ticket.save! + end + + it 'calls the #validation_failed method on the listener' do + listener.should_receive(:validation_failed).with(regex_failure) + processor.process(parameters) + end + end + end + end +end diff --git a/spec/processor/two_factor_authenticator_activator_spec.rb b/spec/processor/two_factor_authenticator_activator_spec.rb new file mode 100644 index 00000000..95225507 --- /dev/null +++ b/spec/processor/two_factor_authenticator_activator_spec.rb @@ -0,0 +1,122 @@ +require 'spec_helper' + +describe CASinoCore::Processor::TwoFactorAuthenticatorActivator do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:two_factor_authenticator_activated) + listener.stub(:invalid_two_factor_authenticator) + listener.stub(:invalid_one_time_password) + listener.stub(:two_factor_authenticator_activated) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { ticket_granting_ticket.user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + let(:id) { two_factor_authenticator.id } + let(:otp) { '123456' } + let(:params) { { otp: otp, id: id } } + + context 'with an invalid authenticator' do + context 'with an expired authenticator' do + let(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, :inactive, user: user } + + before(:each) do + two_factor_authenticator.created_at = 10.hours.ago + two_factor_authenticator.save! + end + + it 'calls the `#invalid_two_factor_authenticator` method an the listener' do + listener.should_receive(:invalid_two_factor_authenticator).with(no_args) + processor.process(params, cookies, user_agent) + end + end + + context 'with a authenticator of another user' do + let(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, :inactive } + + before(:each) do + two_factor_authenticator.created_at = 10.hours.ago + two_factor_authenticator.save! + end + + it 'calls the `#invalid_two_factor_authenticator` method an the listener' do + listener.should_receive(:invalid_two_factor_authenticator).with(no_args) + processor.process(params, cookies, user_agent) + end + end + end + + context 'with a valid authenticator' do + let(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, :inactive, user: user } + + context 'with a valid OTP' do + before(:each) do + ROTP::TOTP.any_instance.should_receive(:verify_with_drift).with(otp, 30).and_return(true) + end + + it 'calls the `#two_factor_authenticator_activated` method an the listener' do + listener.should_receive(:two_factor_authenticator_activated).with(no_args) + processor.process(params, cookies, user_agent) + end + + it 'does activate the authenticator' do + processor.process(params, cookies, user_agent) + two_factor_authenticator.reload + two_factor_authenticator.should be_active + end + + context 'when another two-factor authenticator was active' do + let!(:other_two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, user: user } + + it 'does activate the authenticator' do + processor.process(params, cookies, user_agent) + two_factor_authenticator.reload + two_factor_authenticator.should be_active + end + + it 'does delete the other authenticator' do + processor.process(params, cookies, user_agent) + lambda do + other_two_factor_authenticator.reload + end.should raise_error(ActiveRecord::RecordNotFound) + end + end + + end + + context 'with an invalid OTP' do + before(:each) do + ROTP::TOTP.any_instance.should_receive(:verify_with_drift).with(otp, 30).and_return(false) + end + + it 'calls the `#invalid_one_time_password` method an the listener' do + listener.should_receive(:invalid_one_time_password).with(two_factor_authenticator) + processor.process(params, cookies, user_agent) + end + + it 'does not activate the authenticator' do + processor.process(params, cookies, user_agent) + two_factor_authenticator.reload + two_factor_authenticator.should_not be_active + end + end + end + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + let(:user_agent) { 'TestBrowser 1.0' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process(nil, cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/two_factor_authenticator_destroyer_spec.rb b/spec/processor/two_factor_authenticator_destroyer_spec.rb new file mode 100644 index 00000000..c9e3a15f --- /dev/null +++ b/spec/processor/two_factor_authenticator_destroyer_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe CASinoCore::Processor::TwoFactorAuthenticatorDestroyer do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:two_factor_authenticator_destroyed) + listener.stub(:invalid_two_factor_authenticator) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { ticket_granting_ticket.user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + let(:params) { { id: two_factor_authenticator.id } } + + context 'with a valid two-factor authenticator' do + let!(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, user: user } + + it 'calls the #two_factor_authenticator_destroyed method on the listener' do + listener.should_receive(:two_factor_authenticator_destroyed).with(no_args) + processor.process(params, cookies, user_agent) + end + + it 'deletes the two-factor authenticator' do + processor.process(params, cookies, user_agent) + lambda do + two_factor_authenticator.reload + end.should raise_error(ActiveRecord::RecordNotFound) + end + + it 'does not delete other two-factor authenticators' do + other = FactoryGirl.create :two_factor_authenticator + lambda do + processor.process(params, cookies, user_agent) + end.should change(CASinoCore::Model::TwoFactorAuthenticator, :count).by(-1) + end + end + + context 'with a two-factor authenticator of another user' do + let!(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator } + + it 'calls the #invalid_two_factor_authenticator method on the listener' do + listener.should_receive(:invalid_two_factor_authenticator).with(no_args) + processor.process(params, cookies, user_agent) + end + + it 'does not delete two-factor authenticators' do + lambda do + processor.process(params, cookies, user_agent) + end.should_not change(CASinoCore::Model::TwoFactorAuthenticator, :count) + end + end + end + + context 'with an invalid ticket-granting ticket' do + let(:params) { {} } + let(:tgt) { 'TGT-lalala' } + let(:user_agent) { 'TestBrowser 1.0' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process(params, cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/two_factor_authenticator_overview_spec.rb b/spec/processor/two_factor_authenticator_overview_spec.rb new file mode 100644 index 00000000..6c05ace7 --- /dev/null +++ b/spec/processor/two_factor_authenticator_overview_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe CASinoCore::Processor::TwoFactorAuthenticatorOverview do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:two_factor_authenticators_found) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { ticket_granting_ticket.user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + + context 'without a two-factor authenticator registered' do + it 'calls the #two_factor_authenticators_found method on the listener' do + listener.should_receive(:two_factor_authenticators_found).with([]) + processor.process(cookies, user_agent) + end + end + + context 'with an inactive two-factor authenticator' do + let!(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, :inactive, user: user } + + it 'does not include the inactive authenticator' do + listener.should_receive(:two_factor_authenticators_found).with([]) + processor.process(cookies, user_agent) + end + end + + context 'with a two-factor authenticator registered' do + let(:two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator, user: user } + let!(:other_two_factor_authenticator) { FactoryGirl.create :two_factor_authenticator } + + it 'calls the #two_factor_authenticators_found method on the listener' do + listener.should_receive(:two_factor_authenticators_found).with([two_factor_authenticator]) + processor.process(cookies, user_agent) + end + end + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + let(:user_agent) { 'TestBrowser 1.0' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process(cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/processor/two_factor_authenticator_registrator_spec.rb b/spec/processor/two_factor_authenticator_registrator_spec.rb new file mode 100644 index 00000000..6b0f4582 --- /dev/null +++ b/spec/processor/two_factor_authenticator_registrator_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe CASinoCore::Processor::TwoFactorAuthenticatorRegistrator do + describe '#process' do + let(:listener) { Object.new } + let(:processor) { described_class.new(listener) } + let(:cookies) { { tgt: tgt } } + + before(:each) do + listener.stub(:user_not_logged_in) + listener.stub(:two_factor_authenticator_registered) + end + + context 'with an existing ticket-granting ticket' do + let(:ticket_granting_ticket) { FactoryGirl.create :ticket_granting_ticket } + let(:user) { ticket_granting_ticket.user } + let(:tgt) { ticket_granting_ticket.ticket } + let(:user_agent) { ticket_granting_ticket.user_agent } + + it 'creates exactly one authenticator' do + lambda do + processor.process(cookies, user_agent) + end.should change(CASinoCore::Model::TwoFactorAuthenticator, :count).by(1) + end + + it 'calls #two_factor_authenticator_created on the listener' do + listener.should_receive(:two_factor_authenticator_registered) do |authenticator| + authenticator.should == CASinoCore::Model::TwoFactorAuthenticator.last + end + processor.process(cookies, user_agent) + end + + it 'creates an inactive two-factor authenticator' do + processor.process(cookies, user_agent) + CASinoCore::Model::TwoFactorAuthenticator.last.should_not be_active + end + end + + context 'with an invalid ticket-granting ticket' do + let(:tgt) { 'TGT-lalala' } + let(:user_agent) { 'TestBrowser 1.0' } + it 'calls the #user_not_logged_in method on the listener' do + listener.should_receive(:user_not_logged_in).with(no_args) + processor.process(cookies, user_agent) + end + end + end +end \ No newline at end of file diff --git a/spec/settings_spec.rb b/spec/settings_spec.rb new file mode 100644 index 00000000..c236cfb7 --- /dev/null +++ b/spec/settings_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe CASinoCore::Settings do + describe 'initializer' do + it 'loads default settings' do + described_class.service_ticket[:lifetime_consumed].should == 86400 + end + it 'overwrites specific settings' do + described_class.service_ticket[:lifetime_unconsumed].should == 299 + end + end + + describe '.add_defaults' do + it 'allows to set a overwritable default' do + CASinoCore::Settings.add_defaults :frontend, { foo: 'bar', example: 'test' } + CASinoCore::Settings.init frontend: { foo: 'test', test: 'example' } + CASinoCore::Settings.frontend.should == { foo: 'test', example: 'test', test: 'example' } + end + end + + describe '#authenticators=' do + context 'with an authenticator name' do + let(:authenticator_name) { 'testing' } + let(:gem_name) { "casino_core-authenticator-#{authenticator_name}" } + let(:options) { { } } + let(:authenticators) { + { + test_1: { + authenticator: authenticator_name, + options: options + } + } + } + + context 'when the authenticator exists' do + let(:class_name) { 'Testing' } + let(:authenticator) { CASinoCore::Authenticator::Static } + + before(:each) do + CASinoCore::Settings.stub(:require) + CASinoCore::Authenticator.stub(:const_get).and_return(authenticator) + end + + it 'loads the required file' do + CASinoCore::Settings.should_receive(:require).with(gem_name) + described_class.authenticators = authenticators + end + + it 'instantiates the authenticator' do + CASinoCore::Authenticator.should_receive(:const_get).with(class_name).and_return(authenticator) + described_class.authenticators = authenticators + end + end + + context 'when the authenticator does not exist' do + before(:each) do + CASinoCore::Settings.stub(:require) do + raise LoadError, 'cannot load such file' + end + CASinoCore::Settings.stub(:puts) + end + + it 'raises an error' do + lambda { + described_class.authenticators = authenticators + }.should raise_error(LoadError) + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4900990c..e9ee7021 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,7 @@ require File.expand_path('../dummy/config/environment.rb', __FILE__) require 'rspec/rails' require 'rspec/autorun' +require 'webmock/rspec' require 'capybara/rails' @@ -15,20 +16,3 @@ # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[File.join(ENGINE_RAILS_ROOT, 'spec/support/**/*.rb')].each {|f| require f } - -RSpec.configure do |config| - config.use_transactional_fixtures = true - - # If true, the base class of anonymous controllers will be inferred - # automatically. This will be the default behavior in future versions of - # rspec-rails. - config.infer_base_class_for_anonymous_controllers = false - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = 'random' - - config.before(:each, type: :controller) { @routes = CASino::Engine.routes } -end diff --git a/spec/support/casino.rb b/spec/support/casino.rb new file mode 100644 index 00000000..d145961d --- /dev/null +++ b/spec/support/casino.rb @@ -0,0 +1,7 @@ +require 'active_support/core_ext/hash/deep_dup' + +RSpec.configure do |config| + config.around(type: :controller) do + self.routes = CASino::Engine.routes + end +end diff --git a/spec/support/casino_core.rb b/spec/support/casino_core.rb new file mode 100644 index 00000000..dbddf75d --- /dev/null +++ b/spec/support/casino_core.rb @@ -0,0 +1,6 @@ +RSpec.configure do |config| + config.before(:each) do + CASinoCore.setup ENV['RAILS_ENV'] + CASinoCore::Settings.logger.level = ::Logger::Severity::UNKNOWN + end +end diff --git a/spec/support/factories/login_ticket_factory.rb b/spec/support/factories/login_ticket_factory.rb new file mode 100644 index 00000000..afad87b0 --- /dev/null +++ b/spec/support/factories/login_ticket_factory.rb @@ -0,0 +1,16 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :login_ticket, class: CASinoCore::Model::LoginTicket do + sequence :ticket do |n| + "LT-ticket#{n}" + end + + trait :consumed do + consumed true + end + trait :expired do + created_at 601.seconds.ago + end + end +end diff --git a/spec/support/factories/proxy_granting_ticket_factory.rb b/spec/support/factories/proxy_granting_ticket_factory.rb new file mode 100644 index 00000000..36044695 --- /dev/null +++ b/spec/support/factories/proxy_granting_ticket_factory.rb @@ -0,0 +1,16 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :proxy_granting_ticket, class: CASinoCore::Model::ProxyGrantingTicket do + association :granter, factory: :service_ticket + sequence :ticket do |n| + "PGT-ticket#{n}" + end + sequence :iou do |n| + "PGTIOU-ticket#{n}" + end + sequence :pgt_url do |n| + "https://www#{n}.example.org/pgtUrl" + end + end +end diff --git a/spec/support/factories/proxy_ticket_factory.rb b/spec/support/factories/proxy_ticket_factory.rb new file mode 100644 index 00000000..6d3d8e74 --- /dev/null +++ b/spec/support/factories/proxy_ticket_factory.rb @@ -0,0 +1,17 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :proxy_ticket, class: CASinoCore::Model::ProxyTicket do + proxy_granting_ticket + sequence :ticket do |n| + "PT-ticket#{n}" + end + sequence :service do |n| + "imaps://mail#{n}.example.org/" + end + + trait :consumed do + consumed true + end + end +end diff --git a/spec/support/factories/service_rule_factory.rb b/spec/support/factories/service_rule_factory.rb new file mode 100644 index 00000000..37cf6be2 --- /dev/null +++ b/spec/support/factories/service_rule_factory.rb @@ -0,0 +1,16 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :service_rule, class: CASinoCore::Model::ServiceRule do + sequence :order do |n| + n + end + sequence :name do |n| + "Rule #{n}" + end + + trait :regex do + regex true + end + end +end diff --git a/spec/support/factories/service_ticket_factory.rb b/spec/support/factories/service_ticket_factory.rb new file mode 100644 index 00000000..f2766203 --- /dev/null +++ b/spec/support/factories/service_ticket_factory.rb @@ -0,0 +1,17 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :service_ticket, class: CASinoCore::Model::ServiceTicket do + ticket_granting_ticket + sequence :ticket do |n| + "ST-ticket#{n}" + end + sequence :service do |n| + "http://www#{n}.example.org/" + end + + trait :consumed do + consumed true + end + end +end diff --git a/spec/support/factories/ticket_granting_ticket_factory.rb b/spec/support/factories/ticket_granting_ticket_factory.rb new file mode 100644 index 00000000..5270b5ec --- /dev/null +++ b/spec/support/factories/ticket_granting_ticket_factory.rb @@ -0,0 +1,15 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :ticket_granting_ticket, class: CASinoCore::Model::TicketGrantingTicket do + user + sequence :ticket do |n| + "TGC-ticket#{n}" + end + user_agent 'TestBrowser 1.0' + + trait :awaiting_two_factor_authentication do + awaiting_two_factor_authentication true + end + end +end diff --git a/spec/support/factories/two_factor_authenticator_factory.rb b/spec/support/factories/two_factor_authenticator_factory.rb new file mode 100644 index 00000000..e874a191 --- /dev/null +++ b/spec/support/factories/two_factor_authenticator_factory.rb @@ -0,0 +1,16 @@ +require 'factory_girl' +require 'rotp' + +FactoryGirl.define do + factory :two_factor_authenticator, class: CASinoCore::Model::TwoFactorAuthenticator do + user + secret do + ROTP::Base32.random_base32 + end + active true + + trait :inactive do + active false + end + end +end diff --git a/spec/support/factories/user_factory.rb b/spec/support/factories/user_factory.rb new file mode 100644 index 00000000..b3003e62 --- /dev/null +++ b/spec/support/factories/user_factory.rb @@ -0,0 +1,11 @@ +require 'factory_girl' + +FactoryGirl.define do + factory :user, class: CASinoCore::Model::User do + authenticator 'test' + sequence(:username) do |n| + "test#{n}" + end + extra_attributes({ fullname: "Test User", age: 15, roles: [:user] }) + end +end diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb new file mode 100644 index 00000000..72682ae3 --- /dev/null +++ b/spec/support/rspec.rb @@ -0,0 +1,8 @@ +RSpec.configure do |config| + config.use_transactional_fixtures = true + config.infer_base_class_for_anonymous_controllers = false + config.treat_symbols_as_metadata_keys_with_true_values = true + config.run_all_when_everything_filtered = true + config.filter_run focus: true + config.order = 'random' +end diff --git a/spec/support/sqlite3.rb b/spec/support/sqlite3.rb new file mode 100644 index 00000000..4a049aaf --- /dev/null +++ b/spec/support/sqlite3.rb @@ -0,0 +1,5 @@ +root_path = File.join(File.dirname(__FILE__),'..','..') +schema_path = File.join(root_path, 'db') + +CASinoCore.send(:establish_connection, ENV['RAILS_ENV'], root_path) +load File.join(schema_path, 'schema.rb')