cloudkit 0.9.0

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