kennedy 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+