sp-job 0.1.17

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,342 @@
1
+ #
2
+ # Copyright (c) 2011-2016 Cloudware S.A. All rights reserved.
3
+ #
4
+ # This file is part of sp-job.
5
+ #
6
+ # sp-job is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Affero General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # sp-job is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with sp-job. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # encoding: utf-8
20
+ #
21
+
22
+ module SP
23
+ module Job
24
+
25
+ class BrokerHTTPClient
26
+
27
+ ### INNER CLASS(ES) ###
28
+
29
+ public
30
+
31
+ class Response
32
+
33
+ attr_accessor :code
34
+ attr_accessor :headers
35
+ attr_accessor :body
36
+
37
+ ### INSTANCE METHOD(S) ###
38
+
39
+ def initialize (a_curb_request)
40
+ http_response, *http_headers = a_curb_request.header_str.split(/[\r\n]+/).map(&:strip)
41
+ @code = a_curb_request.response_code
42
+ @headers = Response.symbolize_keys(Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }])
43
+ @body = a_curb_request.body
44
+ self
45
+ end
46
+
47
+ ### CLASS METHOD(S) ###
48
+
49
+ public
50
+
51
+ def self.symbolize_keys(a_hash)
52
+ a_hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v }
53
+ end
54
+
55
+ end
56
+
57
+ class WWWAuthenticateParser
58
+ class SchemeParsingError < StandardError
59
+ end
60
+ class SchemeParser
61
+ def parse(string)
62
+ scheme, attributes_string = split(string)
63
+ raise SchemeParsingError,
64
+ 'No attributes provided' if attributes_string.nil?
65
+ raise SchemeParsingError,
66
+ %(Unsupported scheme "#{scheme}") unless scheme == 'Bearer'
67
+ parse_attributes(attributes_string)
68
+ end
69
+
70
+ def split(string)
71
+ string.split(' ', 2)
72
+ end
73
+
74
+ def parse_attributes(string)
75
+ attributes = {}
76
+ string.scan(/(\w+)="([^"]*)"/).each do |group|
77
+ attributes[group[0].to_sym] = group[1]
78
+ end
79
+ attributes
80
+ end
81
+ end
82
+ end
83
+
84
+ #
85
+ # Current session data.
86
+ #
87
+ class Session
88
+
89
+ attr_accessor :is_new
90
+ attr_accessor :access_token
91
+ attr_accessor :expires_in
92
+ attr_accessor :refresh_token
93
+ attr_accessor :scope
94
+
95
+ #
96
+ # Initializer
97
+ #
98
+ # @param a_access_token
99
+ # @param a_refresh_token
100
+ # @param a_scope
101
+ # @param a_expires_in
102
+ #
103
+ def initialize(a_access_token, a_refresh_token, a_scope, a_expires_in = -1)
104
+ @is_new = ( nil == a_access_token )
105
+ @access_token = a_access_token
106
+ @expires_in = a_expires_in
107
+ @refresh_token = a_refresh_token
108
+ @scope = a_scope
109
+ self
110
+ end
111
+
112
+ end # class Session
113
+
114
+ ### METHOD(S) ###
115
+
116
+ public
117
+
118
+ def session
119
+ # Avoid exposing the original session
120
+ Session.new(
121
+ @session.access_token,
122
+ @session.refresh_token,
123
+ @session.scope,
124
+ @session.expires_in
125
+ )
126
+ end
127
+
128
+ #
129
+ # Initializer
130
+ #
131
+ # @param a_session
132
+ # @param a_config
133
+ # @param a_refreshed_callback
134
+ # @param a_auto_renew_refresh_token
135
+ #
136
+ def initialize(a_session, a_oauth2_client, a_refreshed_callback, a_auto_renew_refresh_token)
137
+ @session = a_session
138
+ @oauth2_client = a_oauth2_client
139
+ @refreshed_callback = a_refreshed_callback
140
+ @auto_renew_refresh_token = a_auto_renew_refresh_token
141
+ end
142
+
143
+ #
144
+ # Perfom an HTTP GET request and, if required, renew access token.
145
+ #
146
+ # @param a_uri
147
+ # @param a_content_type
148
+ #
149
+ def get(a_uri, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
150
+ if true == a_auto_renew_token || nil == @session.access_token
151
+ response = call_and_try_to_recover do
152
+ do_http_get(a_uri, a_content_type)
153
+ end
154
+ else
155
+ do_http_get(a_uri, a_content_type)
156
+ end
157
+ end
158
+
159
+ #
160
+ # Perfom an HTTP POST request and, if required, renew access token.
161
+ #
162
+ # @param a_uri
163
+ # @param a_body
164
+ # @param a_content_type
165
+ #
166
+ def post(a_uri, a_body, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
167
+ if true == a_auto_renew_token || nil == @session.access_token
168
+ response = call_and_try_to_recover do
169
+ do_http_post(a_uri, a_body, a_content_type)
170
+ end
171
+ else
172
+ do_http_post(a_uri, a_body, a_content_type)
173
+ end
174
+ end
175
+
176
+ #
177
+ # Perfom a HTTP PATCH request.
178
+ #
179
+ # @param a_uri
180
+ # @param a_body
181
+ # @param a_content_type
182
+ #
183
+ def patch(a_uri, a_body, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
184
+ if true == a_auto_renew_token || nil == @session.access_token
185
+ response = call_and_try_to_recover do
186
+ do_http_patch(a_uri, a_body, a_content_type)
187
+ end
188
+ else
189
+ do_http_patch(a_uri, a_body, a_content_type)
190
+ end
191
+ end
192
+
193
+ #
194
+ # Perfom a HTTP DELETE request.
195
+ #
196
+ # @param a_uri
197
+ # @param a_body
198
+ # @param a_content_type
199
+ #
200
+ def delete(a_uri, a_body = nil, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
201
+ if true == a_auto_renew_token || nil == @session.access_token
202
+ response = call_and_try_to_recover do
203
+ do_http_delete(a_uri, a_body, a_content_type)
204
+ end
205
+ else
206
+ do_http_delete(a_uri, a_body, a_content_type)
207
+ end
208
+ end
209
+
210
+ ### METHOD(S) ###
211
+
212
+ private
213
+
214
+ #
215
+ # Perfom a HTTP GET request.
216
+ #
217
+ # @param a_uri
218
+ # @param a_content_type
219
+ #
220
+ def do_http_get (a_uri, a_content_type = 'application/vnd.api+json')
221
+ http_request = Curl::Easy.http_get(a_uri) do |curl|
222
+ curl.headers['Content-Type'] = a_content_type;
223
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
224
+ end
225
+ Response.new(http_request)
226
+ end
227
+
228
+ #
229
+ # Perfom a HTTP POST request.
230
+ #
231
+ # @param a_uri
232
+ # @param a_body
233
+ # @param a_content_type
234
+ #
235
+ def do_http_post (a_uri, a_body, a_content_type = 'application/vnd.api+json')
236
+ http_request = Curl::Easy.http_post(a_uri, a_body) do |curl|
237
+ curl.headers['Content-Type'] = a_content_type;
238
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
239
+ end
240
+ Response.new(http_request)
241
+ end
242
+
243
+ #
244
+ # Perfom a HTTP PATCH request.
245
+ #
246
+ # @param a_uri
247
+ # @param a_body
248
+ # @param a_content_type
249
+ #
250
+ def do_http_patch (a_uri, a_body, a_content_type = 'application/vnd.api+json')
251
+ http_request = Curl.http(:PATCH, a_uri, a_body) do |curl|
252
+ curl.headers['Content-Type'] = a_content_type;
253
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
254
+ end
255
+ Response.new(http_request)
256
+ end
257
+
258
+ #
259
+ # Perfom a HTTP DELETE request.
260
+ #
261
+ # @param a_uri
262
+ # @param a_body
263
+ # @param a_content_type
264
+ #
265
+ def do_http_delete (a_uri, a_body = nil, a_content_type = 'application/vnd.api+json')
266
+ http_request = Curl::Easy.http_delete(a_uri) do |curl|
267
+ curl.headers['Content-Type'] = a_content_type;
268
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
269
+ end
270
+ Response.new(http_request)
271
+ end
272
+
273
+ #
274
+ # Perform an HTTP request an if 'invalid_token' is returned try to renew
275
+ # access_token and retry request.
276
+ #
277
+ # @param callback
278
+ #
279
+ def call_and_try_to_recover(*callback)
280
+ # pre-request check
281
+ if nil == @session.access_token
282
+ fetch_new_tokens()
283
+ end
284
+ # call http request
285
+ response = yield
286
+ if 401 == response.code && response.headers.has_key?(:'WWW-Authenticate')
287
+ # try to refresh access_token
288
+ tokens_response = @oauth2_client.refresh_access_token(@session.refresh_token, @session.scope)
289
+ if 200 == tokens_response[:http][:status_code] && tokens_response[:oauth2] && ! tokens_response[:oauth2][:error]
290
+ # success: keep track of new data
291
+ @session.is_new = false
292
+ @session.access_token = tokens_response[:oauth2][:access_token]
293
+ @session.refresh_token = tokens_response[:oauth2][:refresh_token]
294
+ @session.scope = tokens_response[:oauth2][:scope] || @session.scope
295
+ @session.expires_in = tokens_response[:oauth2][:expires_in] || -1
296
+ # notify owner
297
+ if nil != @refreshed_callback
298
+ @refreshed_callback.call(@session)
299
+ end
300
+ else
301
+ fetch_new_tokens()
302
+ end
303
+ # retry http request
304
+ response = yield
305
+ end
306
+ response
307
+ end
308
+
309
+ def fetch_new_tokens()
310
+ # this is only allower for server 2 server usage
311
+ # and when the client configuration has company data already set
312
+ if false == @auto_renew_refresh_token
313
+ raise ::SP::Job::BrokerOAuth2Client::UnauthorizedUser.new(nil)
314
+ end
315
+ # failure: request a new 'authorization code'
316
+ auth_code_response = @oauth2_client.get_authorization_code(
317
+ a_redirect_uri = nil,
318
+ a_scope = @session.scope
319
+ )
320
+ # success ?
321
+ if 302 == auth_code_response[:http][:status_code] && auth_code_response[:oauth2] && ! auth_code_response[:oauth2][:error]
322
+ # request new access and refresh tokens
323
+ tokens_response = @oauth2_client.exchange_auth_code_for_token(auth_code_response[:oauth2][:code])
324
+ if 200 == tokens_response[:http][:status_code] && tokens_response[:oauth2] && ! tokens_response[:oauth2][:error]
325
+ # success: keep track of new data
326
+ @session.is_new = true
327
+ @session.access_token = tokens_response[:oauth2][:access_token]
328
+ @session.refresh_token = tokens_response[:oauth2][:refresh_token]
329
+ @session.scope = tokens_response[:oauth2][:scope] || @session.scope
330
+ @session.expires_in = tokens_response[:oauth2][:expires_in] || -1
331
+ # notify owner
332
+ if nil != @refreshed_callback
333
+ @refreshed_callback.call(@session)
334
+ end
335
+ end
336
+ end
337
+ end
338
+
339
+ end
340
+
341
+ end # module Job
342
+ end #module SP
@@ -0,0 +1,378 @@
1
+ #
2
+ # Copyright (c) 2011-2016 Cloudware S.A. All rights reserved.
3
+ #
4
+ # This file is part of sp-job.
5
+ #
6
+ # sp-job is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Affero General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # sp-job is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with sp-job. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # encoding: utf-8
20
+ #
21
+
22
+ # https://github.com/tiabas/oauth2-client
23
+
24
+ require 'oauth2'
25
+ require 'oauth2-client'
26
+ require 'curb'
27
+
28
+ module SP
29
+ module Job
30
+
31
+ class BrokerOAuth2Client
32
+
33
+ public
34
+
35
+ #
36
+ # Configuration
37
+ #
38
+ # {
39
+ # "protocol": "",
40
+ # "host": "",
41
+ # "port": 0,
42
+ # "endpoints": {
43
+ # "authorization" : "",
44
+ # "token" : ""
45
+ # }
46
+ # }
47
+ class Config
48
+
49
+ @protocol = nil
50
+ @host = nil
51
+ @port = nil
52
+ @endpoints = nil
53
+ @path = nil
54
+ @base_url = nil
55
+ @client_id = nil
56
+ @client_secret = nil
57
+ @redirect_uri = nil
58
+ @scope = nil
59
+
60
+ attr_accessor :protocol
61
+ attr_accessor :host
62
+ attr_accessor :port
63
+ attr_accessor :endpoints
64
+ attr_accessor :path
65
+ attr_accessor :base_url
66
+
67
+ attr_accessor :client_id
68
+ attr_accessor :client_secret
69
+ attr_accessor :redirect_uri
70
+ attr_accessor :scope
71
+
72
+ def initialize(a_hash)
73
+ @protocol = a_hash[:protocol]
74
+ @host = a_hash[:host]
75
+ @port = a_hash[:port]
76
+ @path = a_hash[:path]
77
+ @endpoints = {
78
+ :authorization => a_hash[:endpoints][:authorization],
79
+ :token => a_hash[:endpoints][:token]
80
+ }
81
+ @path = nil
82
+ @base_url = "#{@protocol}://#{@host}"
83
+ if @port && 80 != @port
84
+ @base_url += ":#{@port}"
85
+ end
86
+ if @path
87
+ @base_url += "#{@path}"
88
+ end
89
+ @client_id = a_hash[:client_id]
90
+ @client_secret = a_hash[:client_secret]
91
+ @redirect_uri = a_hash[:redirect_uri]
92
+ @scope = a_hash[:scope]
93
+ end
94
+
95
+ end
96
+
97
+ private
98
+
99
+ #
100
+ # Generic error.
101
+ #
102
+ class Error < StandardError
103
+
104
+ @code = nil
105
+ @description = nil
106
+
107
+ attr_accessor :code
108
+ attr_accessor :description
109
+
110
+ def initialize(a_code, a_description)
111
+ @code = a_code
112
+ @description = a_description
113
+ end
114
+
115
+ def as_hash
116
+ { :oauth2 => { :error => @code, :error_description => @description } }
117
+ end
118
+
119
+ end
120
+
121
+ public
122
+
123
+ #
124
+ # Access denied error.
125
+ #
126
+ class AccessDenied < Error
127
+ def initialize(a_description)
128
+ super "access_denied", a_description
129
+ end
130
+ end
131
+
132
+ #
133
+ # Invalid e-mail or password error.
134
+ #
135
+ class InvalidEmailOrPassword < AccessDenied
136
+ def initialize(a_description="Invalid email or password!")
137
+ super a_description
138
+ end
139
+ end
140
+
141
+ #
142
+ # Unauthorized User
143
+ #
144
+ class UnauthorizedUser< Error
145
+ def initialize(a_description)
146
+ super "unauthorized_user", a_description
147
+ end
148
+ end
149
+
150
+
151
+ #
152
+ # Internal error.
153
+ #
154
+ class InternalError < Error
155
+ def initialize(a_description)
156
+ super "internal_error", a_description
157
+ end
158
+ end
159
+
160
+ #
161
+ #
162
+ #
163
+ class CurbConnectionClient
164
+
165
+ class Response
166
+
167
+ attr_accessor :code
168
+ attr_accessor :body
169
+
170
+ @code = nil
171
+ @body = nil
172
+
173
+ def initialize(code:, body:)
174
+ @code = code
175
+ @body = body
176
+ end
177
+
178
+ end
179
+
180
+ def initialize(site_url, connection_options={})
181
+ # set url and connection options
182
+ @site_url = site_url
183
+ @connection_options = connection_options
184
+ end
185
+
186
+ def base_url(path)
187
+ @site_url + path
188
+ end
189
+
190
+ def send_request(http_method, request_path, options={})
191
+
192
+ # options may contain optional arguments like http headers, request parameters etc
193
+ # send http request over the inter-webs
194
+
195
+ params = options[:params] || {}
196
+ headers = options[:headers]|| {}
197
+ url = base_url(request_path)
198
+ handle = Curl::Easy.new(url)
199
+ headers.each do |key, value|
200
+ handle.headers[key] = value
201
+ end
202
+
203
+ case http_method
204
+ when :get
205
+ handle.http_get()
206
+ return Response.new(code: handle.response_code.to_s, headers: nil)
207
+ when :post
208
+ args = []
209
+ params.each do |key, value|
210
+ args << Curl::PostField.content(key, value)
211
+ end
212
+ handle.http_post(args)
213
+ return Response.new(code: handle.response_code.to_s, body: handle.body_str)
214
+ when :delete
215
+ when :put
216
+ raise UnhandledHTTPMethodError.new("Unsupported HTTP method, #{http_method}")
217
+ else
218
+ raise UnhandledHTTPMethodError.new("Unsupported HTTP method, #{http_method}")
219
+ end
220
+ end
221
+ end
222
+
223
+
224
+ private
225
+
226
+ @client = nil
227
+ @redirect_uri = nil
228
+ @scope = nil
229
+
230
+ public
231
+
232
+ #
233
+ # Initializer
234
+ #
235
+ def initialize(protocol:, host:, port:, client_id:, client_secret:, redirect_uri:, scope:, options: {})
236
+ host = "#{protocol}://#{host}"
237
+ if ( 'https' == protocol && 443 != port ) || ( 'http' == protocol && 80 != port )
238
+ host += ":#{port}"
239
+ end
240
+ options.merge!({
241
+ :connection_client => CurbConnectionClient
242
+ })
243
+ @client = ::OAuth2Client::Client.new(host, client_id, client_secret, options)
244
+ @redirect_uri = redirect_uri
245
+ @scope = scope
246
+ @client.token_path = '/oauth/token'
247
+ @client.authorize_path = '/oauth/auth'
248
+ end
249
+
250
+
251
+ #
252
+ # Returns the authorization url, ready to be called.
253
+ #
254
+ def get_authorization_url(a_redirect_uri, a_scope = nil)
255
+ a_scope = @client.normalize_scope(a_scope, ',') if a_scope
256
+ @client.authorization_code.authorization_url({
257
+ redirect_uri: a_redirect_uri,
258
+ scope: a_scope
259
+ })
260
+ end
261
+
262
+ #
263
+ # Build and call the authorization url
264
+ #
265
+ # Returns CURL response object.
266
+ #
267
+ def call_authorization_url(a_redirect_uri, a_scope = nil)
268
+ url = get_authorization_url(a_redirect_uri, a_scope)
269
+ c = Curl::Easy.http_get(url) do |curl|
270
+ curl.headers['Content-Type'] = "application/json";
271
+ end
272
+ http_response, *http_headers = c.header_str.split(/[\r\n]+/).map(&:strip)
273
+ http_headers = Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }]
274
+ if 302 == c.response_code
275
+ if not http_headers.has_key?('Location')
276
+ raise InternalError.new("Response is missing 'Location' header!")
277
+ end
278
+ end
279
+ Curl::Easy.http_get(http_headers['Location'])
280
+ end
281
+
282
+ #
283
+ # Build and call the authorization url.
284
+ #
285
+ # Returns an hash with http data and oauth2 authorization code.
286
+ #
287
+ def get_authorization_code(a_redirect_uri, a_scope = nil)
288
+ url = get_authorization_url(a_redirect_uri || @redirect_uri, a_scope)
289
+ c = Curl::Easy.http_get(url) do |curl|
290
+ curl.headers['Content-Type'] = "application/json";
291
+ end
292
+ http_response, *http_headers = c.header_str.split(/[\r\n]+/).map(&:strip)
293
+ http_headers = Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }]
294
+ if 302 == c.response_code
295
+ if not http_headers.has_key?('Location')
296
+ raise InternalError.new("Response is missing 'Location' header!")
297
+ end
298
+ if false == http_headers['Location'].start_with?("#{a_redirect_uri}")
299
+ raise InternalError.new("Unable to parse 'Location'")
300
+ end
301
+ h = {
302
+ :http => {
303
+ :status_code => c.response_code,
304
+ :location => http_headers['Location'],
305
+ :params => Hash[ URI::decode_www_form(URI(http_headers['Location']).query).to_h.map { |k, v| [k.to_sym, v] }]
306
+ },
307
+ }
308
+ if not h[:http][:params][:code]
309
+ if not h[:http][:params][:error]
310
+ raise InternalError.new("Unable to retrieve an authorization code or error!")
311
+ else
312
+ h[:oauth2] = {
313
+ :error => h[:http][:params][:error]
314
+ }
315
+ if h[:http][:params][:error_description]
316
+ h[:oauth2][:error_description] = h[:http][:params][:error_description]
317
+ end
318
+ end
319
+ else
320
+ h[:oauth2] = {
321
+ :code => h[:http][:params][:code]
322
+ }
323
+ end
324
+ h
325
+ else
326
+ raise InternalError.new("Unable to retrieve an authorization code - unexpected HTTP status code #{c.response_code}!")
327
+ end
328
+ end
329
+
330
+ #
331
+ # Exchange an 'authorization code' for access ( and refresh ) token(s).
332
+ #
333
+ # @param a_code
334
+ # @param a_scope
335
+ #
336
+ def exchange_auth_code_for_token(a_code, a_scope = nil)
337
+ unless a_code
338
+ raise InternalError.new("Authorization code expected but was nil!")
339
+ end
340
+ opts = { authenticate: :headers }
341
+ if nil != a_scope
342
+ opts[:params] = { :scope => a_scope }
343
+ end
344
+ response = @client.authorization_code.get_token(a_code, opts)
345
+ h = {
346
+ :http => {
347
+ :status_code => response.code.to_i,
348
+ }
349
+ }
350
+ h[:oauth2] = Hash[ JSON.parse(response.body).to_h.map { |k, v| [k.to_sym, v] }]
351
+ h
352
+ end
353
+
354
+ #
355
+ # Refresh an 'access token'.
356
+ #
357
+ # @param a_refresh_token
358
+ # @param a_scope
359
+ #
360
+ def refresh_access_token (a_refresh_token, a_scope = nil)
361
+ unless a_refresh_token
362
+ raise InternalError.new("'refresh token' is expected but is nil!")
363
+ end
364
+ opts = nil != a_scope ? { :params => { :scope => a_scope } } : {}
365
+ response = @client.refresh_token.get_token(a_refresh_token, opts)
366
+ h = {
367
+ :http => {
368
+ :status_code => response.code.to_i,
369
+ }
370
+ }
371
+ h[:oauth2] = Hash[ JSON.parse(response.body).to_h.map { |k, v| [k.to_sym, v] }]
372
+ h
373
+ end
374
+
375
+ end # BrokerOAuth2Client
376
+
377
+ end # module 'Job'
378
+ end # module 'SP'