kennedy 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. data/.document +5 -0
  2. data/.gitignore +21 -0
  3. data/.yardoc +0 -0
  4. data/LICENSE +20 -0
  5. data/MAIN.rdoc +23 -0
  6. data/README.markdown +17 -0
  7. data/Rakefile +52 -0
  8. data/VERSION +1 -0
  9. data/bin/kennedy-gen +13 -0
  10. data/doc/Kennedy.html +98 -0
  11. data/doc/Kennedy/Backends.html +94 -0
  12. data/doc/Kennedy/Backends/LDAP.html +471 -0
  13. data/doc/Kennedy/BadTicketException.html +92 -0
  14. data/doc/Kennedy/Granter.html +570 -0
  15. data/doc/Kennedy/Server.html +258 -0
  16. data/doc/Kennedy/Ticket.html +875 -0
  17. data/doc/_index.html +170 -0
  18. data/doc/class_list.html +97 -0
  19. data/doc/css/common.css +1 -0
  20. data/doc/css/full_list.css +23 -0
  21. data/doc/css/style.css +261 -0
  22. data/doc/file.README.html +69 -0
  23. data/doc/file_list.html +29 -0
  24. data/doc/index.html +69 -0
  25. data/doc/js/app.js +91 -0
  26. data/doc/js/full_list.js +39 -0
  27. data/doc/js/jquery.js +19 -0
  28. data/doc/method_list.html +152 -0
  29. data/doc/top-level-namespace.html +80 -0
  30. data/kennedy.gemspec +114 -0
  31. data/lib/kennedy.rb +2 -0
  32. data/lib/kennedy/backends/ldap.rb +35 -0
  33. data/lib/kennedy/generator.rb +69 -0
  34. data/lib/kennedy/granter.rb +52 -0
  35. data/lib/kennedy/instance_configuration.rb +58 -0
  36. data/lib/kennedy/server.rb +164 -0
  37. data/lib/kennedy/ticket.rb +97 -0
  38. data/logo.png +0 -0
  39. data/template/config.ru.erb +22 -0
  40. data/template/config/api_keys.yml.erb +1 -0
  41. data/template/config/backend.rb +0 -0
  42. data/template/config/encryption.yml.erb +2 -0
  43. data/template/config/sessions.yml.erb +1 -0
  44. data/test/granter_test.rb +93 -0
  45. data/test/ldap_backend_test.rb +66 -0
  46. data/test/server_test.rb +285 -0
  47. data/test/teststrap.rb +34 -0
  48. data/test/ticket_test.rb +84 -0
  49. metadata +177 -0
@@ -0,0 +1,97 @@
1
+ require 'openssl'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module Kennedy
6
+ class BadTicketException < RuntimeError; end
7
+
8
+ # A ticket represents a time-constrained period in which an authenticated
9
+ # person can access a service
10
+ class Ticket
11
+ DefaultExpiry = 30 # In seconds
12
+ attr_reader :identifier
13
+
14
+ class << self
15
+ private :new
16
+ end
17
+
18
+ # Creates a new ticket with the given arguments
19
+ # @param [Hash] args The arguments to generate the ticket with
20
+ # @option args [String] :identifier An identifier to use in the ticket
21
+ # @option args [String] :iv An iv to use to encrypt and decrypt the ticket
22
+ # @option args [String] :passphrase A passphrase to encrypt and decrypt the ticket
23
+ # @option args [String] :expiry A length of time in seconds for which this ticket is valid
24
+ # after to_encrypted is called
25
+ def self.create(args = {})
26
+ identifier = args[:identifier] || raise(ArgumentError, "Ticket identifier must be given as :identifier")
27
+ ticket = new(:iv => args[:iv], :passphrase => args[:passphrase], :expiry => args[:expiry])
28
+ ticket.identifier = identifier
29
+ ticket
30
+ end
31
+
32
+ # Decrypts a ticket from the given arguments
33
+ # @param [Hash] args The arguments to build the ticket with
34
+ # @option args [String] :data An encrypted ticket
35
+ # @option args [String] :iv An IV to use to decrypt the ticket
36
+ # @option args [String] :passphrase A passphrase to use to decrypt the ticket
37
+ def self.from_encrypted(args = {})
38
+ data = args[:data] || raise(ArgumentError, "Data must be given as :data")
39
+ ticket = new(:iv => args[:iv], :passphrase => args[:passphrase])
40
+ ticket.decrypt(data)
41
+ ticket
42
+ end
43
+
44
+ # @param [Hash] args The arguments to construct the ticket with
45
+ # @option args [String] :iv An iv to use to encrypt and decrypt the ticket
46
+ # @option args [String] :passphrase A passphrase to encrypt and decrypt the ticket
47
+ def initialize(args = {})
48
+ @iv = args[:iv] || raise(ArgumentError, "Ticket encryption IV must be given as :iv")
49
+ @passphrase = args[:passphrase] || raise(ArgumentError, "Ticket encryption passphrase must be given as :passphrase")
50
+ @expiry = args[:expiry] || DefaultExpiry
51
+ end
52
+
53
+ def identifier=(identifier)
54
+ @identifier ||= identifier
55
+ end
56
+
57
+ # Generates an encrypted chunk of JSON with the identifier and expiration time for this
58
+ # ticket encoded in
59
+ # @return [String] An encrypted JSON string
60
+ def to_encrypted
61
+ cipher = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
62
+ cipher.encrypt
63
+ cipher.key = @passphrase
64
+ cipher.iv = @iv
65
+ encrypted = cipher.update(to_expiring_json)
66
+ encrypted << cipher.final
67
+ encrypted
68
+ end
69
+
70
+ # Decrypts the given ticket data
71
+ # @param data [String] The ticket data to decrypt
72
+ def decrypt(data)
73
+ cipher = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
74
+ cipher.decrypt
75
+ cipher.key = @passphrase
76
+ cipher.iv = @iv
77
+ decrypted = cipher.update(data)
78
+ decrypted << cipher.final
79
+ json = JSON.parse(decrypted)
80
+ self.identifier = json['identifier']
81
+ @expiry = Time.parse(json['expiry'])
82
+ rescue OpenSSL::Cipher::CipherError => e
83
+ raise Kennedy::BadTicketException, "Given data was not decryptable"
84
+ end
85
+
86
+ def expired?
87
+ !@expiry.nil? && (@expiry < Time.now)
88
+ end
89
+
90
+ private
91
+
92
+ def to_expiring_json
93
+ {'identifier' => @identifier, 'expiry' => Time.now + @expiry}.to_json
94
+ end
95
+
96
+ end # Ticket
97
+ end # Kennedy
Binary file
@@ -0,0 +1,22 @@
1
+ # Rackup file auto-generated by kennedy-gen at <%= Time.now %>
2
+ here = File.dirname(__FILE__)
3
+ bundler_env = File.join(here, 'vendor/gems/environment.rb')
4
+ config_dir = File.join(here, 'config')
5
+
6
+ if File.exist?(bundler_env)
7
+ load bundler_env
8
+ else
9
+ require 'rubygems'
10
+ end
11
+
12
+ RACK_ENV = ENV["RACK_ENV"] ||= "development" unless defined?(RACK_ENV)
13
+
14
+ require 'kennedy/server'
15
+ require 'kennedy/instance_configuration'
16
+
17
+ config = Kennedy::InstanceConfiguration.load_config(config_dir)
18
+ Kennedy::ServerInstance = Kennedy::Server.create(:encryption => {:iv => config.encryption['iv'], :passphrase => config.encryption['passphrase']},
19
+ :backend => config.backend, :session_secret => config.session_secret,
20
+ :api_keys => config.api_keys, :require_ssl => !(ENV['RACK_ENV'] == 'development'))
21
+
22
+ run Kennedy::ServerInstance
@@ -0,0 +1 @@
1
+ "foo@example.com": <%= Digest::SHA1.hexdigest("#{Time.now}#{Time.now.usec}") %>
File without changes
@@ -0,0 +1,2 @@
1
+ iv: <%= Digest::SHA1.hexdigest("#{Time.now}#{Time.now.usec}") %>
2
+ passphrase: <%= Digest::SHA1.hexdigest("#{Time.now}#{Time.now.usec}") %>
@@ -0,0 +1 @@
1
+ secret: <%= Digest::SHA1.hexdigest("#{Time.now}#{Time.now.usec}") %>
@@ -0,0 +1,93 @@
1
+ require 'teststrap'
2
+ require 'digest/sha1'
3
+
4
+ context "kennedy granter" do
5
+
6
+ should "raise an exception if not given an IV" do
7
+ Kennedy::Granter.new(:passphrase => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
8
+ :backend => Object.new)
9
+ end.raises(ArgumentError, "Encryption IV must be given as :iv")
10
+
11
+ should "raise an exception if not given a passphrase" do
12
+ Kennedy::Granter.new(:iv => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
13
+ :backend => Object.new)
14
+ end.raises(ArgumentError, "Encryption passphrase must be given as :passphrase")
15
+
16
+ should "raise an exception if not given a backend" do
17
+ Kennedy::Granter.new(:iv => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
18
+ :passphrase => Digest::SHA1.hexdigest(Time.now.to_i.to_s))
19
+ end.raises(ArgumentError, "Authentication backend must be given as :backend")
20
+
21
+ context "with valid arguments and a given backend" do
22
+
23
+ setup do
24
+ @granter = Kennedy::Granter.new(:iv => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
25
+ :passphrase => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
26
+ :backend => @backend = StubBackend.new)
27
+ end
28
+
29
+ should "use the given backend to authenticate" do
30
+ @granter.authenticate(:identifier => "foo", :password => "bar")
31
+ @backend.credentials
32
+ end.equals(["foo", "bar"])
33
+
34
+ should "return true with valid credentials" do
35
+ @granter.authenticate(:identifier => "foo", :password => "bar") == true
36
+ end
37
+
38
+ should "return false with invalid credentials" do
39
+ @granter.authenticate(:identifier => "foo", :password => "baz") == false
40
+ end
41
+
42
+ end # with valid arguments and a given backend
43
+
44
+ context "generating a ticket for a service" do
45
+ context "with default expiry time" do
46
+ setup do
47
+ @granter = Kennedy::Granter.new(:iv => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
48
+ :passphrase => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
49
+ :backend => @backend = StubBackend.new)
50
+ end
51
+
52
+ should "require an identifier to generate a ticket" do
53
+ @granter.generate_ticket
54
+ end.raises(ArgumentError, "An identifier must be given as :identifier")
55
+
56
+ should "return a ticket object when granting" do
57
+ @granter.generate_ticket(:identifier => "foo@example.com")
58
+ end.kind_of(Kennedy::Ticket)
59
+
60
+ end # with default expiry time
61
+ end # generating a ticket for a service
62
+
63
+ context "reading an encrypted ticket" do
64
+ setup do
65
+ @granter = Kennedy::Granter.new(:iv => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
66
+ :passphrase => Digest::SHA1.hexdigest(Time.now.to_i.to_s),
67
+ :backend => @backend = StubBackend.new)
68
+ end
69
+
70
+ should "require data to read a ticket" do
71
+ @granter.read_ticket
72
+ end.raises(ArgumentError, "Data must be given as :data")
73
+
74
+ context "with gibberish data" do
75
+ should "raise an exception" do
76
+ @granter.read_ticket(:data => "bzzt")
77
+ end.raises(Kennedy::BadTicketException)
78
+ end
79
+
80
+ context "with valid data" do
81
+ setup do
82
+ encrypted_ticket = @granter.generate_ticket(:identifier => "foo@example.com").to_encrypted
83
+ @granter.read_ticket(:data => encrypted_ticket)
84
+ end
85
+
86
+ should "be a kennedy ticket" do
87
+ topic
88
+ end.kind_of(Kennedy::Ticket)
89
+ end
90
+ end # reading an encrypted ticket
91
+
92
+ end
93
+
@@ -0,0 +1,66 @@
1
+ require 'teststrap'
2
+
3
+ context "kennedy ldap backend" do
4
+
5
+ should "require a :host argument" do
6
+ Kennedy::Backends::LDAP.new(:auth => {}, :base => "cn=foo")
7
+ end.raises(ArgumentError, "Host must be given as :host")
8
+
9
+ should "require an :auth argument" do
10
+ Kennedy::Backends::LDAP.new(:host => "example.com", :base => "cn=foo")
11
+ end.raises(ArgumentError, "Auth must be given as :auth")
12
+
13
+ should "require a :base argument" do
14
+ Kennedy::Backends::LDAP.new(:host => "example.com", :auth => {})
15
+ end.raises(ArgumentError, "Base must be given as :base")
16
+
17
+ should "raise if no filter block is given and authentication is attempted" do
18
+ backend = Kennedy::Backends::LDAP.new(:host => "example.com", :auth => {}, :base => "foo")
19
+ backend.authenticate("foo", "bar")
20
+ end.raises(ArgumentError, "Set a filter block on this object using the 'filter' writer")
21
+
22
+ context "trying to authenticate" do
23
+
24
+ setup do
25
+ @backend = Kennedy::Backends::LDAP.new(:host => "example.com", :auth => {}, :base => "foo")
26
+ @backend.filter = lambda do |identifier|
27
+ "(mail=#{identifier})"
28
+ end
29
+ ldap_conn = nil
30
+ @backend.instance_eval { ldap_conn = @ldap_conn = StubLDAP.new(nil) }
31
+ ldap_conn
32
+ end
33
+
34
+ should "use the given filter block to generate the search filter" do
35
+ @backend.authenticate("foo@example.com", "bar")
36
+ topic.bind_as_arguments[:filter]
37
+ end.equals("(mail=foo@example.com)")
38
+
39
+ should "use the given password to bind as" do
40
+ @backend.authenticate("foo@example.com", "bar")
41
+ topic.bind_as_arguments[:password]
42
+ end.equals("bar")
43
+
44
+ context "succesfully" do
45
+ setup do
46
+ @backend.instance_eval { @ldap_conn = StubLDAP.new(Object.new) }
47
+ end
48
+
49
+ should "return true when the backend returns a non-nil value" do
50
+ @backend.authenticate("foo@example.com", "bar") == true
51
+ end
52
+ end
53
+
54
+ context "unsuccesfully" do
55
+ setup do
56
+ @backend.instance_eval { @ldap_conn = StubLDAP.new(nil) }
57
+ end
58
+
59
+ should "return false when the backend returns nil" do
60
+ @backend.authenticate("foo@example.com", "bar") == false
61
+ end
62
+ end
63
+
64
+ end
65
+
66
+ end
@@ -0,0 +1,285 @@
1
+ require 'teststrap'
2
+ require 'kennedy/server'
3
+ require 'digest/sha1'
4
+ require 'base64'
5
+
6
+ context "kennedy server" do
7
+ iv = Digest::SHA1.hexdigest("what")
8
+ passphrase = Digest::SHA1.hexdigest("qhat")
9
+ session_secret = "foobarbaz"
10
+
11
+ new_backend = lambda do
12
+ StubBackend.new
13
+ end
14
+
15
+ new_server = lambda do
16
+ Kennedy::Server.create(:encryption => {:iv => iv, :passphrase => passphrase},
17
+ :backend => new_backend, :session_secret => "foobarbaz",
18
+ :api_keys => {'foo@example.com' => 'password'})
19
+ end
20
+
21
+ encode_credentials = lambda do |username,password|
22
+ "Basic " + Base64.encode64("#{username}:#{password}")
23
+ end
24
+
25
+ should "use tamper-proof cookies" do
26
+ new_server[].middleware.detect do |mw|
27
+ mw[0] == Rack::Session::Cookie && !mw[1][0][:secret].nil?
28
+ end
29
+ end
30
+
31
+ context "delete to /session" do
32
+ setup do
33
+ @server = Rack::MockRequest.new(new_server[])
34
+ end
35
+
36
+ should "not allow non-ssl connections" do
37
+ @server.delete('/session').status
38
+ end.equals(403)
39
+
40
+ context "via ssl" do
41
+ setup do
42
+ @server = SSLMockRequest.new(new_server[])
43
+ @server.delete('/session', 'CONTENT_TYPE' => 'application/json')
44
+ end
45
+
46
+ should "return a 200" do
47
+ topic.status
48
+ end.equals(200)
49
+
50
+ should "return success" do
51
+ JSON.parse(topic.body)['success']
52
+ end.equals('session_destroyed')
53
+
54
+ end
55
+ end
56
+
57
+ context "post to /session" do
58
+ setup do
59
+ @server = Rack::MockRequest.new(new_server[])
60
+ end
61
+
62
+ should "not allow non-ssl connections" do
63
+ @server.post('/session').status
64
+ end.equals(403)
65
+
66
+ context "via ssl" do
67
+ setup do
68
+ @server = SSLMockRequest.new(new_server[])
69
+ end
70
+
71
+ should "not allow non-JSON requests" do
72
+ @server.post('/session').status
73
+ end.equals(415)
74
+
75
+ context "with invalid credentials" do
76
+ setup do
77
+ @server.post('/session', 'CONTENT_TYPE' => 'application/json', :input => {}.to_json)
78
+ end
79
+
80
+ should "return a 406" do
81
+ topic.status
82
+ end.equals(406)
83
+
84
+ should "return json" do
85
+ topic.content_type
86
+ end.equals("application/json")
87
+
88
+ should "return an error" do
89
+ JSON.parse(topic.body)['error']
90
+ end.equals('bad_credentials')
91
+
92
+ end
93
+
94
+ context "with valid credentials" do
95
+ setup do
96
+ @server.post('/session', 'CONTENT_TYPE' => 'application/json', :input => {'credentials' => {'identifier' => 'foo', 'password' => 'bar'}}.to_json)
97
+ end
98
+
99
+ should "return a 201" do
100
+ topic.status
101
+ end.equals(201)
102
+
103
+ should "return json" do
104
+ topic.content_type
105
+ end.equals("application/json")
106
+
107
+ should "return success" do
108
+ JSON.parse(topic.body)['success']
109
+ end.equals('session_created')
110
+
111
+ should "set a session cookie" do
112
+ topic.headers['Set-Cookie']
113
+ end.matches(/rack\.session=/)
114
+
115
+ end
116
+ end
117
+
118
+ end # post to /session
119
+
120
+ context "get to /session" do
121
+ setup do
122
+ @server = Rack::MockRequest.new(new_server[])
123
+ end
124
+
125
+ should "not allow non-ssl connections" do
126
+ @server.get('/session').status
127
+ end.equals(403)
128
+
129
+ context "via ssl" do
130
+ setup do
131
+ @server = SSLMockRequest.new(new_server[])
132
+ end
133
+
134
+ should "not allow non-JSON requests" do
135
+ @server.get('/session').status
136
+ end.equals(415)
137
+
138
+ context "when already logged in" do
139
+ setup do
140
+ response = @server.post('/session', 'CONTENT_TYPE' => 'application/json', :input => {'credentials' => {'identifier' => 'foo', 'password' => 'bar'}}.to_json)
141
+ cookie = response.headers['Set-Cookie'].split(";").first
142
+ @server.get('/session', 'CONTENT_TYPE' => 'application/json', 'HTTP_COOKIE' => cookie)
143
+ end
144
+
145
+ should "return json" do
146
+ topic.content_type
147
+ end.equals("application/json")
148
+
149
+ should "respond with a ticket" do
150
+ JSON.parse(topic.body)['ticket']
151
+ end.kind_of(String)
152
+
153
+ end # when already logged in
154
+
155
+ context "when not logged in" do
156
+ setup do
157
+ @server.get('/session', 'CONTENT_TYPE' => 'application/json')
158
+ end
159
+
160
+ should "return json" do
161
+ topic.content_type
162
+ end.equals("application/json")
163
+
164
+ should "return a 401" do
165
+ topic.status
166
+ end.equals(401)
167
+
168
+ end # when not logged in
169
+ end # via ssl
170
+ end # get to /session
171
+
172
+ context "post to /validation_request" do
173
+ setup do
174
+ @server = Rack::MockRequest.new(new_server[])
175
+ end
176
+
177
+ should "not allow non-ssl connection" do
178
+ @server.post('/validation_request').status
179
+ end.equals(403)
180
+
181
+ context "via ssl" do
182
+ setup do
183
+ @server = SSLMockRequest.new(new_server[])
184
+ end
185
+
186
+ should "not allow non-JSON requests" do
187
+ @server.post('/validation_request').status
188
+ end.equals(415)
189
+
190
+ context "with no API key" do
191
+ setup do
192
+ @server.post('/validation_request', 'CONTENT_TYPE' => 'application/json', :input => {'ticket' => '123'}.to_json)
193
+ end
194
+
195
+ should "return a 401" do
196
+ topic.status
197
+ end.equals(401)
198
+
199
+ should "return an error" do
200
+ JSON.parse(topic.body)['error']
201
+ end.equals('authentication_required')
202
+ end
203
+
204
+ context "with a bad API key" do
205
+
206
+ setup do
207
+ @server.post('/validation_request', 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION'=> encode_credentials['foo@example.com', 'badpassword'],
208
+ :input => {'ticket' => '123'}.to_json)
209
+ end
210
+
211
+ should "return a 401" do
212
+ topic.status
213
+ end.equals(401)
214
+
215
+ should "return an error" do
216
+ JSON.parse(topic.body)['error']
217
+ end.equals('authentication_required')
218
+
219
+ end
220
+
221
+ context "with a valid ticket" do
222
+ setup do
223
+ granter = Kennedy::Granter.new(:iv => iv, :passphrase => passphrase, :backend => StubBackend.new)
224
+ ticket = granter.generate_ticket(:identifier => "foo@example.com")
225
+ @server.post('/validation_request', 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION'=> encode_credentials['foo@example.com', 'password'],
226
+ :input => {'ticket' => Base64.encode64(ticket.to_encrypted)}.to_json)
227
+ end
228
+
229
+ should "return json" do
230
+ topic.content_type
231
+ end.equals("application/json")
232
+
233
+ should "return a 200" do
234
+ topic.status
235
+ end.equals(200)
236
+
237
+ should "return an identifier" do
238
+ JSON.parse(topic.body)['identifier']
239
+ end.equals('foo@example.com')
240
+
241
+ end
242
+
243
+ context "with gibberish" do
244
+ setup do
245
+ @server.post('/validation_request', 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION'=> encode_credentials['foo@example.com', 'password'],
246
+ :input => {'ticket' => 'bzzt'}.to_json)
247
+ end
248
+
249
+ should "return json" do
250
+ topic.content_type
251
+ end.equals("application/json")
252
+
253
+ should "return a 406" do
254
+ topic.status
255
+ end.equals(406)
256
+
257
+ should "return an error" do
258
+ JSON.parse(topic.body)['error']
259
+ end.equals('bad_ticket')
260
+
261
+ end
262
+
263
+ context "with an expired ticket" do
264
+ setup do
265
+ ticket = Kennedy::Ticket.create(:identifier => "foo@example.com", :iv => iv, :expiry => -30, :passphrase => passphrase)
266
+ @server.post('/validation_request', 'CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION'=> encode_credentials['foo@example.com', 'password'],
267
+ :input => {'ticket' => Base64.encode64(ticket.to_encrypted)}.to_json)
268
+ end
269
+
270
+ should "return json" do
271
+ topic.content_type
272
+ end.equals("application/json")
273
+
274
+ should "return a 406" do
275
+ topic.status
276
+ end.equals(406)
277
+
278
+ should "return an error" do
279
+ JSON.parse(topic.body)['error']
280
+ end.equals('expired_ticket')
281
+ end
282
+ end # via ssl
283
+ end
284
+ end
285
+