cloudkit 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. data/CHANGES +2 -0
  2. data/COPYING +20 -0
  3. data/README +55 -0
  4. data/Rakefile +35 -0
  5. data/TODO +22 -0
  6. data/cloudkit.gemspec +82 -0
  7. data/doc/curl.html +329 -0
  8. data/doc/images/example-code.gif +0 -0
  9. data/doc/images/json-title.gif +0 -0
  10. data/doc/images/oauth-discovery-logo.gif +0 -0
  11. data/doc/images/openid-logo.gif +0 -0
  12. data/doc/index.html +87 -0
  13. data/doc/main.css +151 -0
  14. data/doc/rest-api.html +358 -0
  15. data/examples/1.ru +3 -0
  16. data/examples/2.ru +3 -0
  17. data/examples/3.ru +6 -0
  18. data/examples/4.ru +5 -0
  19. data/examples/5.ru +10 -0
  20. data/examples/6.ru +10 -0
  21. data/examples/TOC +17 -0
  22. data/lib/cloudkit.rb +74 -0
  23. data/lib/cloudkit/flash_session.rb +22 -0
  24. data/lib/cloudkit/oauth_filter.rb +273 -0
  25. data/lib/cloudkit/oauth_store.rb +56 -0
  26. data/lib/cloudkit/openid_filter.rb +198 -0
  27. data/lib/cloudkit/openid_store.rb +101 -0
  28. data/lib/cloudkit/rack/builder.rb +120 -0
  29. data/lib/cloudkit/rack/router.rb +20 -0
  30. data/lib/cloudkit/request.rb +159 -0
  31. data/lib/cloudkit/service.rb +135 -0
  32. data/lib/cloudkit/store.rb +459 -0
  33. data/lib/cloudkit/store/adapter.rb +9 -0
  34. data/lib/cloudkit/store/extraction_view.rb +57 -0
  35. data/lib/cloudkit/store/response.rb +51 -0
  36. data/lib/cloudkit/store/response_helpers.rb +72 -0
  37. data/lib/cloudkit/store/sql_adapter.rb +36 -0
  38. data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
  39. data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
  40. data/lib/cloudkit/templates/oauth_meta.erb +8 -0
  41. data/lib/cloudkit/templates/openid_login.erb +31 -0
  42. data/lib/cloudkit/templates/request_authorization.erb +23 -0
  43. data/lib/cloudkit/templates/request_token_denied.erb +18 -0
  44. data/lib/cloudkit/user_store.rb +44 -0
  45. data/lib/cloudkit/util.rb +60 -0
  46. data/test/ext_test.rb +57 -0
  47. data/test/flash_session_test.rb +22 -0
  48. data/test/helper.rb +50 -0
  49. data/test/oauth_filter_test.rb +331 -0
  50. data/test/oauth_store_test.rb +12 -0
  51. data/test/openid_filter_test.rb +54 -0
  52. data/test/openid_store_test.rb +12 -0
  53. data/test/rack_builder_test.rb +41 -0
  54. data/test/request_test.rb +197 -0
  55. data/test/service_test.rb +718 -0
  56. data/test/store_test.rb +99 -0
  57. data/test/user_store_test.rb +12 -0
  58. data/test/util_test.rb +13 -0
  59. metadata +190 -0
@@ -0,0 +1,198 @@
1
+ module CloudKit
2
+
3
+ # An OpenIDFilter provides OpenID authentication, listening for upstream
4
+ # OAuth authentication and bypassing if already authorized.
5
+ #
6
+ # Responds to the following URIs:
7
+ # /login
8
+ # /logout
9
+ # /openid_complete
10
+ #
11
+ class OpenIDFilter
12
+ include Util
13
+
14
+ @@lock = Mutex.new
15
+ @@store = nil
16
+
17
+ def initialize(app, options={})
18
+ @app = app; @options = options
19
+ end
20
+
21
+ def call(env)
22
+ @@lock.synchronize do
23
+ @@store = OpenIDStore.new(env[storage_uri_key])
24
+ @users = UserStore.new(env[storage_uri_key])
25
+ @@store.get_association('x') rescue nil # refresh sqlite3
26
+ end unless @@store
27
+
28
+ request = Request.new(env)
29
+ request.announce_auth(openid_filter_key)
30
+
31
+ case request
32
+ when r(:get, request.login_url); request_login(request)
33
+ when r(:post, request.login_url); begin_openid_login(request)
34
+ when r(:get, '/openid_complete'); complete_openid_login(request)
35
+ when r(:post, request.logout_url); logout(request)
36
+ else
37
+ if (root_request?(request) || valid_auth_key?(request) || logged_in?(request))
38
+ @app.call(env)
39
+ else
40
+ if request.env[challenge_key]
41
+ store_location(request)
42
+ erb(request, :openid_login, request.env[challenge_key], 401)
43
+ elsif !request.via.include?(oauth_filter_key)
44
+ store_location(request)
45
+ login_redirect(request)
46
+ else
47
+ [500, {}, ['server misconfigured']]
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def logout(request)
54
+ user_uri = request.session.delete('user_uri')
55
+ result = @users.get(user_uri)
56
+ user = result.parsed_content
57
+ user.delete('remember_me_token')
58
+ user.delete('remember_me_expiration')
59
+ json = JSON.generate(user)
60
+ @users.put(user_uri, :etag => result.etag, :json => json)
61
+
62
+ request.env[auth_key] = nil
63
+ request.flash['info'] = 'You have been logged out.'
64
+ response = Rack::Response.new([], 302, {'Location' => request.login_url})
65
+ response.delete_cookie('remember_me')
66
+ response.finish
67
+ end
68
+
69
+ def request_login(request)
70
+ erb(request, :openid_login)
71
+ end
72
+
73
+ def begin_openid_login(request)
74
+ begin
75
+ response = openid_consumer(request).begin request[:openid_url]
76
+ rescue => e
77
+ request.flash[:error] = e
78
+ return login_redirect(request)
79
+ end
80
+
81
+ redirect_url = response.redirect_url(base_url(request), full_url(request))
82
+ [302, {'Location' => redirect_url}, []]
83
+ end
84
+
85
+ def complete_openid_login(request)
86
+ begin
87
+ idp_response = openid_consumer(request).complete(request.params, full_url(request))
88
+ rescue => e
89
+ request.flash[:error] = e
90
+ return login_redirect(request)
91
+ end
92
+
93
+ if idp_response.is_a?(OpenID::Consumer::FailureResponse)
94
+ request.flash[:error] = idp_response.message
95
+ return login_redirect(request)
96
+ end
97
+
98
+ result = @users.get(
99
+ '/cloudkit_login_view',
100
+ :identity_url => idp_response.endpoint.claimed_id)
101
+ user_uris = result.parsed_content['uris']
102
+
103
+ if user_uris.empty?
104
+ json = JSON.generate(:identity_url => idp_response.endpoint.claimed_id)
105
+ result = @users.post('/cloudkit_users', :json => json)
106
+ user_uri = result.parsed_content['uri']
107
+ else
108
+ user_uri = user_uris.first
109
+ end
110
+ user_result = @users.resolve_uris([user_uri]).first
111
+ user = user_result.parsed_content
112
+
113
+ if request.session['user_uri'] = user_uri
114
+ request.current_user = user_uri
115
+ user['remember_me_expiration'] = two_weeks_from_now
116
+ user['remember_me_token'] = Base64.encode64(
117
+ OpenSSL::Random.random_bytes(32)).gsub(/\W/,'')
118
+ url = request.session.delete('return_to')
119
+ response = Rack::Response.new([], 302, {'Location' => (url || '/')})
120
+ response.set_cookie(
121
+ 'remember_me', {
122
+ :value => user['remember_me_token'],
123
+ :expires => Time.at(user['remember_me_expiration']).utc})
124
+ json = JSON.generate(user)
125
+ @users.put(user_uri, :etag => user_result.etag, :json => json)
126
+ request.flash[:notice] = 'You have been logged in.'
127
+ response.finish
128
+ else
129
+ request.flash[:error] = 'Could not log on with your OpenID.'
130
+ login_redirect(request)
131
+ end
132
+ end
133
+
134
+ def login_redirect(request)
135
+ [302, {'Location' => request.login_url}, []]
136
+ end
137
+
138
+ def base_url(request)
139
+ "#{request.scheme}://#{request.env['HTTP_HOST']}/"
140
+ end
141
+
142
+ def full_url(request)
143
+ base_url(request) + 'openid_complete'
144
+ end
145
+
146
+ def logged_in?(request)
147
+ logged_in = user_in_session?(request) || valid_remember_me_token?(request)
148
+ request.current_user = request.session['user_uri'] if logged_in
149
+ logged_in
150
+ end
151
+
152
+ def user_in_session?(request)
153
+ request.session['user_uri'] != nil
154
+ end
155
+
156
+ def store_location(request)
157
+ request.session['return_to'] = request.url
158
+ end
159
+
160
+ def root_request?(request)
161
+ request.path_info == '/' || request.path_info == '/favicon.ico'
162
+ end
163
+
164
+ def valid_auth_key?(request)
165
+ request.env[auth_key] && request.env[auth_key] != ''
166
+ end
167
+
168
+ def openid_consumer(request)
169
+ @openid_consumer ||= OpenID::Consumer.new(
170
+ request.session, OpenIDStore.new)
171
+ end
172
+
173
+ def valid_remember_me_token?(request)
174
+ return false unless token = request.cookies['remember_me']
175
+
176
+ result = @users.get('/cloudkit_login_view', :remember_me_token => token)
177
+ return false unless result.status == 200
178
+
179
+ user_uris = result.parsed_content['uris']
180
+ return false unless user_uris.try(:size) == 1
181
+
182
+ user_uri = user_uris.first
183
+ user_result = @users.resolve_uris([user_uri]).first
184
+ user = user_result.parsed_content
185
+ return false unless Time.now.to_i < user['remember_me_expiration']
186
+
187
+ user['remember_me_expiration'] = two_weeks_from_now
188
+ json = JSON.generate(user)
189
+ @users.put(user_uri, :etag => user_result.etag, :json => json)
190
+ request.session['user_uri'] = user_uri
191
+ true
192
+ end
193
+
194
+ def two_weeks_from_now
195
+ Time.now.to_i+1209600
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,101 @@
1
+ require 'openid/store/interface'
2
+ module CloudKit
3
+
4
+ # An OpenIDStore provides the interface expected by the ruby-openid gem,
5
+ # mapping it to a CloudKit::Store instance.
6
+ class OpenIDStore < OpenID::Store::Interface
7
+ @@store = nil
8
+
9
+ # Initialize an OpenIDStore and its required views.
10
+ def initialize(uri=nil)
11
+ unless @@store
12
+ association_view = ExtractionView.new(
13
+ :cloudkit_openid_server_handles,
14
+ :observe => :cloudkit_openid_associations,
15
+ :extract => [:server_url, :handle])
16
+ @@store = Store.new(
17
+ :collections => [:cloudkit_openid_associations, :cloudkit_openid_nonces],
18
+ :views => [association_view],
19
+ :adapter => SQLAdapter.new(uri))
20
+ end
21
+ end
22
+
23
+ def get_association(server_url, handle=nil) #:nodoc:
24
+ options = {:server_url => server_url}
25
+ options.merge!(:handle => Base64.encode64(handle)) if (handle && handle != '')
26
+ result = @@store.get('/cloudkit_openid_server_handles', options)
27
+ return nil unless result.status == 200
28
+ return nil if result.parsed_content['total'] == 0
29
+
30
+ ignore, associations = resolve_associations(result.parsed_content)
31
+ return nil if associations.empty?
32
+
33
+ associations.sort_by{|a| a['issued']}
34
+ a = associations[-1]
35
+ OpenID::Association.new(
36
+ Base64.decode64(a['handle']),
37
+ Base64.decode64(a['secret']),
38
+ Time.at(a['issued']),
39
+ a['lifetime'],
40
+ a['assoc_type'])
41
+ end
42
+
43
+ def remove_association(server_url, handle) #:nodoc:
44
+ result = @@store.get(
45
+ '/cloudkit_openid_server_handles',
46
+ :server_url => server_url,
47
+ :handle => Base64.encode64(handle))
48
+ return nil unless result.status == 200
49
+
50
+ responses, associations = resolve_associations(result.parsed_content)
51
+ return nil if associations.empty?
52
+
53
+ uris = result.parsed_content['uris']
54
+ responses.each_with_index do |r, index|
55
+ @@store.delete(uris[index], :etag => r.etag)
56
+ end
57
+ end
58
+
59
+ def store_association(server_url, association) #:nodoc:
60
+ remove_association(server_url, association.handle)
61
+ json = JSON.generate(
62
+ :server_url => server_url,
63
+ :handle => Base64.encode64(association.handle),
64
+ :secret => Base64.encode64(association.secret),
65
+ :issued => association.issued.to_i,
66
+ :lifetime => association.lifetime,
67
+ :assoc_type => association.assoc_type)
68
+ result = @@store.post('/cloudkit_openid_associations', :json => json)
69
+ return (result.status == 201)
70
+ end
71
+
72
+ def use_nonce(server_url, timestamp, salt) #:nodoc:
73
+ return false if (timestamp - Time.now.to_i).abs > OpenID::Nonce.skew
74
+
75
+ fragment = URI.escape([server_url, timestamp, salt].join('-'))
76
+ uri = "/cloudkit_openid_nonces/#{fragment}"
77
+ result = @@store.put(uri, :json => '{}')
78
+ return (result.status == 201)
79
+ end
80
+
81
+ def self.cleanup #:nodoc:
82
+ # TODO
83
+ end
84
+
85
+ def self.cleanup_associations #:nodoc:
86
+ # TODO
87
+ end
88
+
89
+ def self.cleanup_nonces #:nodoc:
90
+ # TODO
91
+ end
92
+
93
+ protected
94
+
95
+ def resolve_associations(parsed_content) #:nodoc:
96
+ uri_list = parsed_content['uris']
97
+ association_responses = @@store.resolve_uris(uri_list)
98
+ return association_responses, association_responses.map{|a| a.parsed_content}
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,120 @@
1
+ module Rack #:nodoc:
2
+ class Builder
3
+ alias_method :cloudkit_to_app, :to_app
4
+
5
+ # Extends Rack::Builder's to_app method to detect if the last piece of
6
+ # middleware in the stack is a CloudKit shortcut (contain or expose), adding
7
+ # adding a default developer page at the root and a 404 everywhere else.
8
+ def to_app
9
+ default_app = lambda do |env|
10
+ if (env['PATH_INFO'] == '/')
11
+ [200, {'Content-Type' => 'text/html'}, [welcome]]
12
+ else
13
+ [404, {}, []]
14
+ end
15
+ end
16
+ @ins << default_app if @last_cloudkit_id == @ins.last.object_id
17
+ cloudkit_to_app
18
+ end
19
+
20
+ # Setup resource collections hosted behind OAuth and OpenID auth filters.
21
+ #
22
+ # ===Example
23
+ # contain :notes, :projects
24
+ #
25
+ def contain(*args)
26
+ @ins << lambda do |app|
27
+ Rack::Session::Pool.new(
28
+ CloudKit::OAuthFilter.new(
29
+ CloudKit::OpenIDFilter.new(
30
+ CloudKit::Service.new(app, :collections => args.to_a))))
31
+ end
32
+ @last_cloudkit_id = @ins.last.object_id
33
+ end
34
+
35
+ # Setup resource collections without authentication.
36
+ #
37
+ # ===Example
38
+ # expose :notes, :projects
39
+ #
40
+ def expose(*args)
41
+ @ins << lambda do |app|
42
+ CloudKit::Service.new(app, :collections => args.to_a)
43
+ end
44
+ @last_cloudkit_id = @ins.last.object_id
45
+ end
46
+
47
+ def welcome #:nodoc:
48
+ doc = <<HTML
49
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
50
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
51
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
52
+ <head>
53
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
54
+ <title>CloudKit via cURL</title>
55
+ <style type="text/css">
56
+ body {
57
+ font-family: 'Helvetica', 'Arial', san-serif;
58
+ font-size: 15px;
59
+ margin: 0;
60
+ padding: 0;
61
+ color: #222222;
62
+ }
63
+ h1 {
64
+ font-family: 'Helvetica Neue', 'Helvetica', 'Arial', san-serif;
65
+ font-size: 73px;
66
+ font-weight: bold;
67
+ line-height: 28px;
68
+ margin: 20px 0px 20px 0px;
69
+ }
70
+ .wrapper {
71
+ width: 500px;
72
+ margin: 0 auto;
73
+ clear: both;
74
+ }
75
+ p {
76
+ margin-top: 0px;
77
+ line-height: 1.5em;
78
+ }
79
+ #header {
80
+ background-color: #ffffcc;
81
+ display: block;
82
+ padding: 2px 0;
83
+ margin: 35px 0px 10px 0px;
84
+ border-top: 1px solid #ffcc66;
85
+ border-bottom: 1px solid #ffcc66;
86
+ }
87
+ a {
88
+ color: #6b8df2;
89
+ text-decoration: none;
90
+ }
91
+ .meta {
92
+ padding: 7px 7px 7px 7px;
93
+ background-color: #ffccff;
94
+ border-top: 1px solid #cc99ff;
95
+ border-bottom: 1px solid #cc99ff;
96
+ font-size: 14px;
97
+ display: block;
98
+ margin: 10px 0px 10px 0px;
99
+ }
100
+ </style>
101
+ </head>
102
+ <body>
103
+ <div id="header">
104
+ <div class="wrapper">
105
+ <h1>CloudKit</h1>
106
+ </div>
107
+ </div>
108
+ <div class="meta">
109
+ <p class="wrapper">
110
+ This page is appearing because you have not set up a default app in your
111
+ rackup file. To learn more about CloudKit, check out
112
+ <a href="http://getcloudkit.com">the site</a>.
113
+ </p>
114
+ </div>
115
+ </body>
116
+ </html>
117
+ HTML
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,20 @@
1
+ module Rack #:nodoc:
2
+
3
+ # A minimal router providing just what is needed for the OAuth and OpenID
4
+ # filters.
5
+ class Router
6
+
7
+ # Create an instance of Router to match on method, path and params.
8
+ def initialize(method, path, params=[])
9
+ @method = method.to_s.upcase; @path = path; @params = params
10
+ end
11
+
12
+ # By overriding the case comparison operator, we can match routes in a case
13
+ # statement.
14
+ #
15
+ # See also: CloudKit::Util#r, CloudKit::Request#match?
16
+ def ===(request)
17
+ request.match?(@method, @path, @params)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,159 @@
1
+ module CloudKit
2
+
3
+ # A subclass of Rack::Request providing CloudKit-specific features.
4
+ class Request < Rack::Request
5
+ include CloudKit::Util
6
+ alias_method :cloudkit_params, :params
7
+
8
+ def initialize(env)
9
+ super(env)
10
+ end
11
+
12
+ # Return a merged set of both standard params and OAuth header params.
13
+ def params
14
+ @cloudkit_params ||= cloudkit_params.merge(oauth_header_params)
15
+ end
16
+
17
+ # Return true if method, path, and required_params match.
18
+ def match?(method, path, required_params=[])
19
+ (request_method == method) &&
20
+ path_info.match(path.gsub(':id', '*')) && # just enough to work for now
21
+ param_match?(required_params)
22
+ end
23
+
24
+ # Return true of the array of required params match the request params. If
25
+ # a hash in passed in for a param, its value is also used in the match.
26
+ def param_match?(required_params)
27
+ required_params.all? do |required_param|
28
+ case required_param
29
+ when Hash
30
+ key = required_param.keys.first
31
+ return false unless params.has_key? key
32
+ return false unless params[key] == required_param[key]
33
+ when String
34
+ return false unless params.has_key? required_param
35
+ else
36
+ false
37
+ end
38
+ true
39
+ end
40
+ end
41
+
42
+ # Return OAuth header params in a hash.
43
+ def oauth_header_params
44
+ # This is a copy of the same method from the OAuth gem.
45
+ # TODO: Refactor the OAuth gem so that this method is available via a
46
+ # mixin, outside of the request proxy context.
47
+ %w( X-HTTP_AUTHORIZATION Authorization HTTP_AUTHORIZATION ).each do |header|
48
+ next unless @env.include?(header)
49
+ header = @env[header]
50
+ next unless header[0,6] == 'OAuth '
51
+ oauth_param_string = header[6,header.length].split(/[,=]/)
52
+ oauth_param_string.map!{|v| unescape(v.strip)}
53
+ oauth_param_string.map!{|v| v =~ /^\".*\"$/ ? v[1..-2] : v}
54
+ oauth_params = Hash[*oauth_param_string.flatten]
55
+ oauth_params.reject!{|k,v| k !~ /^oauth_/}
56
+ return oauth_params
57
+ end
58
+ return {}
59
+ end
60
+
61
+ # Unescape a value according to the OAuth spec.
62
+ def unescape(value)
63
+ URI.unescape(value.gsub('+', '%2B'))
64
+ end
65
+
66
+ # Return the last path element in the request URI.
67
+ def last_path_element
68
+ path_element(-1)
69
+ end
70
+
71
+ # Return a specific path element
72
+ def path_element(index)
73
+ path_info.split('/')[index] rescue nil
74
+ end
75
+
76
+ # Return an array containing one entry for each piece of upstream
77
+ # middleware. This is in the same spirit as Via headers in HTTP, but does
78
+ # not use the header because the transition from one piece of middleware to
79
+ # the next does not use HTTP.
80
+ def via
81
+ @env[via_key].split(', ') rescue []
82
+ end
83
+
84
+ # Return parsed contents of an If-Match header.
85
+ #
86
+ # Note: Only a single ETag is useful in the context of CloudKit, so a list
87
+ # is treated as one ETag; the result of using the wrong ETag or a list of
88
+ # ETags is the same in the context of PUT and DELETE where If-Match
89
+ # headers are required.
90
+ def if_match
91
+ etag = @env['HTTP_IF_MATCH']
92
+ return nil unless etag
93
+ etag.strip!
94
+ etag = unquote(etag)
95
+ return nil if etag == '*'
96
+ etag
97
+ end
98
+
99
+ # Add a via entry to the Rack environment.
100
+ def inject_via(key)
101
+ items = via << key
102
+ @env[via_key] = items.join(', ')
103
+ end
104
+
105
+ # Return the current user URI.
106
+ def current_user
107
+ return nil unless @env[auth_key] && @env[auth_key] != ''
108
+ @env[auth_key]
109
+ end
110
+
111
+ # Set the current user URI.
112
+ def current_user=(user)
113
+ @env[auth_key] = user
114
+ end
115
+
116
+ # Return true if authentication is being used.
117
+ def using_auth?
118
+ @env[auth_presence_key] != nil
119
+ end
120
+
121
+ # Report to downstream middleware that authentication is in use.
122
+ def announce_auth(via)
123
+ inject_via(via)
124
+ @env[auth_presence_key] = 1
125
+ end
126
+
127
+ # Return the session associated with this request.
128
+ def session
129
+ @env['rack.session']
130
+ end
131
+
132
+ # Return the login URL for this request. This is stashed in the Rack
133
+ # environment so the OpenID and OAuth middleware can cooperate during the
134
+ # token authorization step in the OAuth flow.
135
+ def login_url
136
+ @env[login_url_key] || '/login'
137
+ end
138
+
139
+ # Set the login url for this request.
140
+ def login_url=(url)
141
+ @env[login_url_key] = url
142
+ end
143
+
144
+ # Return the logout URL for this request.
145
+ def logout_url
146
+ @env[logout_url_key] || '/logout'
147
+ end
148
+
149
+ # Set the logout URL for this request.
150
+ def logout_url=(url)
151
+ @env[logout_url_key] = url
152
+ end
153
+
154
+ # Return the flash session for this request.
155
+ def flash
156
+ session[flash_key] ||= CloudKit::FlashSession.new
157
+ end
158
+ end
159
+ end