haveapi 0.12.1 → 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/doc/protocol.md +27 -10
  3. data/haveapi.gemspec +1 -2
  4. data/lib/haveapi/action.rb +23 -7
  5. data/lib/haveapi/actions/default.rb +3 -3
  6. data/lib/haveapi/authentication/base.rb +19 -1
  7. data/lib/haveapi/authentication/basic/provider.rb +7 -1
  8. data/lib/haveapi/authentication/chain.rb +10 -12
  9. data/lib/haveapi/authentication/token/action_config.rb +53 -0
  10. data/lib/haveapi/authentication/token/action_request.rb +23 -0
  11. data/lib/haveapi/authentication/token/action_result.rb +42 -0
  12. data/lib/haveapi/authentication/token/config.rb +115 -0
  13. data/lib/haveapi/authentication/token/provider.rb +259 -81
  14. data/lib/haveapi/authentication/token.rb +9 -0
  15. data/lib/haveapi/client_examples/curl.rb +3 -3
  16. data/lib/haveapi/client_examples/fs_client.rb +3 -3
  17. data/lib/haveapi/client_examples/http.rb +7 -7
  18. data/lib/haveapi/client_examples/js_client.rb +1 -1
  19. data/lib/haveapi/client_examples/php_client.rb +1 -1
  20. data/lib/haveapi/client_examples/ruby_cli.rb +1 -1
  21. data/lib/haveapi/client_examples/ruby_client.rb +1 -1
  22. data/lib/haveapi/context.rb +13 -13
  23. data/lib/haveapi/example.rb +3 -3
  24. data/lib/haveapi/exceptions.rb +2 -0
  25. data/lib/haveapi/extensions/exception_mailer.rb +8 -1
  26. data/lib/haveapi/model_adapters/active_record.rb +6 -6
  27. data/lib/haveapi/parameters/resource.rb +10 -10
  28. data/lib/haveapi/params.rb +1 -1
  29. data/lib/haveapi/resource.rb +18 -10
  30. data/lib/haveapi/resources/action_state.rb +2 -2
  31. data/lib/haveapi/route.rb +4 -3
  32. data/lib/haveapi/server.rb +7 -7
  33. data/lib/haveapi/spec/mock_action.rb +3 -3
  34. data/lib/haveapi/spec/spec_methods.rb +8 -8
  35. data/lib/haveapi/version.rb +2 -2
  36. data/lib/haveapi/views/version_page.erb +2 -2
  37. data/shell.nix +1 -1
  38. metadata +9 -5
  39. data/lib/haveapi/authentication/token/resources.rb +0 -110
@@ -1,4 +1,6 @@
1
1
  require 'haveapi/authentication/base'
2
+ require 'haveapi/resource'
3
+ require 'haveapi/action'
2
4
 
3
5
  module HaveAPI::Authentication
4
6
  module Token
@@ -8,15 +10,18 @@ module HaveAPI::Authentication
8
10
 
9
11
  end
10
12
 
11
- # Provider for token authentication. This class has to be subclassed
12
- # and implemented.
13
+ # Provider for token authentication.
13
14
  #
14
- # Token auth contains resource token. User can request a token by calling
15
- # action Resources::Token::Request. Returned token is then used for
16
- # authenticating the user. Client sends the token with each request
17
- # in configured #http_header or #query_parameter.
15
+ # This provider has to be configured using
16
+ # {HaveAPI::Authentication::Token::Config}.
18
17
  #
19
- # Token can be revoked by calling Resources::Token::Revoke.
18
+ # Token auth contains API resource `token`. User can request a token by
19
+ # calling action `Request`. The returned token is then used for
20
+ # authenticating the user. Client sends the token with each request in
21
+ # configured {HaveAPI::Authentication::Token::Config#http_header} or
22
+ # {HaveAPI::Authentication::Token::Config#query_parameter}.
23
+ #
24
+ # Token can be revoked by calling action `Revoke` and renewed with `Renew`.
20
25
  #
21
26
  # === \Example usage:
22
27
  #
@@ -34,31 +39,60 @@ module HaveAPI::Authentication
34
39
  # end
35
40
  # end
36
41
  #
37
- # Authentication provider:
38
- # class MyTokenAuth < HaveAPI::Authentication::Token::Provider
39
- # protected
40
- # def save_token(request, user, token, lifetime, interval)
41
- # user.tokens << ::Token.new(token: token, lifetime: lifetime,
42
- # valid_to: (lifetime != 'permanent' ? Time.now + interval : nil),
43
- # interval: interval, label: request.user_agent)
44
- # end
42
+ # Authentication provider configuration:
43
+ # class MyTokenAuthConfig < HaveAPI::Authentication::Token::Config
44
+ # request do
45
+ # handle do |req, res|
46
+ # user = ::User.find_by(login: input[:user], password: input[:password])
47
+ #
48
+ # if user.nil?
49
+ # res.error = 'invalid user or password'
50
+ # next res
51
+ # end
52
+ #
53
+ # token = SecureRandom.hex(50)
54
+ # valid_to =
55
+ # if req.input[:lifetime] == 'permanent'
56
+ # nil
57
+ # else
58
+ # Time.now + req.input[:interval]
59
+ #
60
+ # user.tokens << ::Token.new(
61
+ # token: token,
62
+ # lifetime: req.input[:lifetime],
63
+ # valid_to: valid_to,
64
+ # interval: req.input[:interval],
65
+ # label: req.request.user_agent,
66
+ # )
45
67
  #
46
- # def revoke_token(request, user, token)
47
- # user.tokens.delete(token: token)
68
+ # res.token = token
69
+ # res.valid_to = valid_to
70
+ # res.complete = true
71
+ # res.ok
72
+ # end
48
73
  # end
49
74
  #
50
- # def renew_token(request, user, token)
51
- # t = ::Token.find_by(user: user, token: token)
75
+ # renew do
76
+ # handle do |req, res|
77
+ # t = ::Token.find_by(user: req.user, token: req.token)
52
78
  #
53
- # if t.lifetime.start_with('renewable')
54
- # t.renew
55
- # t.save
56
- # t.valid_to
79
+ # if t && t.lifetime.start_with('renewable')
80
+ # t.renew
81
+ # t.save
82
+ # res.valid_to = t.valid_to
83
+ # res.ok
84
+ # else
85
+ # res.error = 'unable to renew token'
86
+ # res
87
+ # end
57
88
  # end
58
89
  # end
59
90
  #
60
- # def find_user_by_credentials(request, username, password)
61
- # ::User.find_by(login: username, password: password)
91
+ # revoke do
92
+ # handle do |req, res|
93
+ # req.user.tokens.delete(token: req.token)
94
+ # res.ok
95
+ # end
62
96
  # end
63
97
  #
64
98
  # def find_user_by_token(request, token)
@@ -79,94 +113,238 @@ module HaveAPI::Authentication
79
113
  # Finally put the provider in the authentication chain:
80
114
  # api = HaveAPI.new(...)
81
115
  # ...
82
- # api.auth_chain << MyTokenAuth
116
+ # api.auth_chain << HaveAPI::Authentication::Token.with_config(MyTokenAuthConfig)
83
117
  class Provider < Base
118
+ auth_method :token
119
+
120
+ # Configure the token provider
121
+ # @param cfg [Config]
122
+ def self.with_config(cfg)
123
+ Module.new do
124
+ define_singleton_method(:new) do |*args|
125
+ Provider.new(*args, cfg)
126
+ end
127
+ end
128
+ end
129
+
130
+ attr_reader :config
131
+
132
+ def initialize(server, v, cfg)
133
+ @config = cfg.new(server, v)
134
+ super(server, v)
135
+ end
136
+
84
137
  def setup
85
- Resources::Token.token_instance ||= {}
86
- Resources::Token.token_instance[@version] = self
138
+ @server.allow_header(config.class.http_header)
139
+ end
87
140
 
88
- @server.allow_header(http_header)
141
+ def resource_module
142
+ return @module if @module
143
+ provider = self
144
+
145
+ @module = Module.new do
146
+ const_set(:Token, provider.send(:token_resource))
147
+ end
89
148
  end
90
149
 
150
+ # Authenticate request
151
+ # @param request [Sinatra::Request]
91
152
  def authenticate(request)
92
153
  t = token(request)
93
154
 
94
- t && find_user_by_token(request, t)
155
+ t && config.find_user_by_token(request, t)
95
156
  end
96
157
 
158
+ # Extract token from HTTP request
159
+ # @param request [Sinatra::Request]
160
+ # @return [String]
97
161
  def token(request)
98
- request[query_parameter] || request.env[header_to_env]
162
+ request[config.class.query_parameter] || request.env[header_to_env]
99
163
  end
100
164
 
101
165
  def describe
102
166
  {
103
- http_header: http_header,
104
- query_parameter: query_parameter,
105
- description: "The client authenticates with username and password and gets "+
106
- "a token. From this point, the password can be forgotten and "+
107
- "the token is used instead. Tokens can have different lifetimes, "+
108
- "can be renewed and revoked. The token is passed either via HTTP "+
109
- "header or query parameter."
167
+ http_header: config.class.http_header,
168
+ query_parameter: config.class.query_parameter,
169
+ description: "The client authenticates with credentials, usually "+
170
+ "username and password, and gets a token. "+
171
+ "From this point, the credentials can be forgotten and "+
172
+ "the token is used instead. Tokens can have different lifetimes, "+
173
+ "can be renewed and revoked. The token is passed either via HTTP "+
174
+ "header or query parameter."
110
175
  }
111
176
  end
112
177
 
113
- protected
114
- # HTTP header that is searched for token.
115
- def http_header
116
- 'X-HaveAPI-Auth-Token'
178
+ private
179
+ def header_to_env
180
+ "HTTP_#{config.class.http_header.upcase.gsub(/\-/, '_')}"
117
181
  end
118
182
 
119
- # Query parameter searched for token.
120
- def query_parameter
121
- :_auth_token
122
- end
183
+ def token_resource
184
+ provider = self
123
185
 
124
- # Generate token. Implicit implementation returns token of 100 chars.
125
- def generate_token
126
- SecureRandom.hex(50)
127
- end
186
+ HaveAPI::Resource.define_resource(:Token) do
187
+ define_singleton_method(:token_instance) { provider }
128
188
 
129
- # Save generated +token+ for +user+. Token has given +lifetime+
130
- # and when not permanent, also a +interval+ of validity.
131
- # Returns a date time which is token expiration.
132
- # It is up to the implementation of this method to remember
133
- # token lifetime and interval.
134
- # Must be implemented.
135
- def save_token(request, user, token, lifetime, interval)
189
+ auth false
190
+ version :all
136
191
 
137
- end
192
+ define_action(:Request) do
193
+ route ''
194
+ http_method :post
138
195
 
139
- # Revoke existing +token+ for +user+.
140
- # Must be implemented.
141
- def revoke_token(request, user, token)
196
+ input(:hash) do
197
+ if block = provider.config.class.request.input
198
+ instance_exec(&block)
199
+ end
142
200
 
143
- end
201
+ string :lifetime, label: 'Lifetime', required: true,
202
+ choices: %i(fixed renewable_manual renewable_auto permanent),
203
+ desc: <<END
204
+ fixed - the token has a fixed validity period, it cannot be renewed
205
+ renewable_manual - the token can be renewed, but it must be done manually via renew action
206
+ renewable_auto - the token is renewed automatically to now+interval every time it is used
207
+ permanent - the token will be valid forever, unless deleted
208
+ END
209
+ integer :interval, label: 'Interval',
210
+ desc: 'How long will requested token be valid, in seconds.',
211
+ default: 60*5, fill: true
212
+ end
144
213
 
145
- # Renew existing +token+ for +user+.
146
- # Returns a date time which is token expiration.
147
- # Must be implemented.
148
- def renew_token(request, user, token)
214
+ output(:hash) do
215
+ string :token
216
+ datetime :valid_to
217
+ bool :complete
218
+ string :next_action
219
+ end
149
220
 
150
- end
221
+ authorize do
222
+ allow
223
+ end
151
224
 
152
- # Used by action Resources::Token::Request when the user is requesting
153
- # a token. This method returns user object or nil.
154
- # Must be implemented.
155
- def find_user_by_credentials(request, username, password)
225
+ def exec
226
+ config = self.class.resource.token_instance.config
156
227
 
157
- end
228
+ begin
229
+ result = config.class.request.handle.call(ActionRequest.new(
230
+ request: request,
231
+ input: input,
232
+ ), ActionResult.new)
233
+ rescue HaveAPI::AuthenticationError => e
234
+ error(e.message)
235
+ end
158
236
 
159
- # Authenticate user by +token+. Return user object or nil.
160
- # If the token was created as auto-renewable, this method
161
- # is responsible for its renewal.
162
- # Must be implemented.
163
- def find_user_by_token(request, token)
237
+ unless result.ok?
238
+ error(result.error || 'invalid authentication credentials')
239
+ end
164
240
 
165
- end
241
+ {
242
+ token: result.token,
243
+ valid_to: result.valid_to,
244
+ complete: result.complete?,
245
+ next_action: result.next_action,
246
+ }
247
+ end
248
+ end
166
249
 
167
- private
168
- def header_to_env
169
- "HTTP_#{http_header.upcase.gsub(/\-/, '_')}"
250
+ define_action(:Revoke) do
251
+ http_method :post
252
+ auth true
253
+
254
+ authorize do
255
+ allow
256
+ end
257
+
258
+ def exec
259
+ provider = self.class.resource.token_instance
260
+ result = provider.config.class.revoke.handle.call(ActionRequest.new(
261
+ request: request,
262
+ user: current_user,
263
+ token: provider.token(request),
264
+ ), ActionResult.new)
265
+
266
+ if result.ok?
267
+ ok
268
+ else
269
+ error(result.error || 'revoke failed')
270
+ end
271
+ end
272
+ end
273
+
274
+ define_action(:Renew) do
275
+ http_method :post
276
+ auth true
277
+
278
+ output(:hash) do
279
+ datetime :valid_to
280
+ end
281
+
282
+ authorize do
283
+ allow
284
+ end
285
+
286
+ def exec
287
+ provider = self.class.resource.token_instance
288
+ result = provider.config.renew_token(ActionRequest.new(
289
+ request: request,
290
+ user: current_user,
291
+ token: provider.token(request),
292
+ ), ActionResult.new)
293
+
294
+ if result.ok?
295
+ {valid_to: result.valid_to}
296
+ else
297
+ error(result.error || 'renew failed')
298
+ end
299
+ end
300
+ end
301
+
302
+ provider.config.class.actions.each do |name, config|
303
+ define_action(:"#{name.to_s.capitalize}") do
304
+ http_method :post
305
+ auth false
306
+
307
+ input(:hash) do
308
+ string :token, required: true
309
+ instance_exec(&config.input) if config.input
310
+ end
311
+
312
+ output(:hash) do
313
+ string :token
314
+ datetime :valid_to
315
+ bool :complete
316
+ string :next_action
317
+ end
318
+
319
+ authorize do
320
+ allow
321
+ end
322
+
323
+ define_method(:exec) do
324
+ begin
325
+ result = config.handle.call(ActionRequest.new(
326
+ request: request,
327
+ input: input,
328
+ token: input[:token],
329
+ ), ActionResult.new)
330
+ rescue HaveAPI::AuthenticationError => e
331
+ error(e.message)
332
+ end
333
+
334
+ unless result.ok?
335
+ error(result.error || 'authentication failed')
336
+ end
337
+
338
+ {
339
+ token: result.token,
340
+ valid_to: result.valid_to,
341
+ complete: result.complete?,
342
+ next_action: result.next_action,
343
+ }
344
+ end
345
+ end
346
+ end
347
+ end
170
348
  end
171
349
  end
172
350
  end
@@ -0,0 +1,9 @@
1
+ module HaveAPI::Authentication
2
+ module Token
3
+ # Configure the token provider
4
+ # @param cfg [Config]
5
+ def self.with_config(cfg)
6
+ Provider.with_config(cfg)
7
+ end
8
+ end
9
+ end
@@ -12,7 +12,7 @@ module HaveAPI::ClientExamples
12
12
  end
13
13
 
14
14
  def auth(method, desc)
15
- login = {login: 'user', password: 'password', lifetime: 'fixed'}
15
+ login = {user: 'user', password: 'password', lifetime: 'fixed'}
16
16
 
17
17
  case method
18
18
  when :basic
@@ -50,8 +50,8 @@ END
50
50
  base_url,
51
51
  resolve_path(
52
52
  action[:method],
53
- action[:url],
54
- sample[:url_params] || [],
53
+ action[:path],
54
+ sample[:path_params] || [],
55
55
  sample[:request]
56
56
  )
57
57
  )
@@ -43,13 +43,13 @@ END
43
43
  path = [mountpoint].concat(resource_path)
44
44
 
45
45
  unless class_action?
46
- if !sample[:url_params] || sample[:url_params].empty?
46
+ if !sample[:path_params] || sample[:path_params].empty?
47
47
  fail "example {#{sample}} of action #{resource_path.join('.')}"+
48
48
  ".#{action_name} is for an instance action but does not include "+
49
49
  "URL parameters"
50
50
  end
51
51
 
52
- path << sample[:url_params].first.to_s
52
+ path << sample[:path_params].first.to_s
53
53
  end
54
54
 
55
55
  path << 'actions' << action_name
@@ -112,7 +112,7 @@ END
112
112
  end
113
113
 
114
114
  def class_action?
115
- action[:url].index(/:[a-zA-Z\-_]+/).nil?
115
+ action[:path].index(/:[a-zA-Z\-_]+/).nil?
116
116
  end
117
117
  end
118
118
  end
@@ -33,7 +33,7 @@ POST /_auth/token/tokens HTTP/1.1
33
33
  Host: #{host}
34
34
  Content-Type: application/json
35
35
 
36
- #{JSON.pretty_generate({token: {login: 'user', password: 'secret', lifetime: 'fixed'}})}
36
+ #{JSON.pretty_generate({token: {user: 'user', password: 'secret', lifetime: 'fixed'}})}
37
37
  END
38
38
  end
39
39
  end
@@ -41,8 +41,8 @@ END
41
41
  def request(sample)
42
42
  path = resolve_path(
43
43
  action[:method],
44
- action[:url],
45
- sample[:url_params] || [],
44
+ action[:path],
45
+ sample[:path_params] || [],
46
46
  sample[:request]
47
47
  )
48
48
 
@@ -74,11 +74,11 @@ END
74
74
  res
75
75
  end
76
76
 
77
- def resolve_path(method, url, url_params, input_params)
78
- ret = url.clone
77
+ def resolve_path(method, path, path_params, input_params)
78
+ ret = path.clone
79
79
 
80
- url_params.each do |v|
81
- ret.sub!(/:[a-zA-Z\-_]+/, v.to_s)
80
+ path_params.each do |v|
81
+ ret.sub!(/\{[a-zA-Z\-_]+\}/, v.to_s)
82
82
  end
83
83
 
84
84
  return ret if method != 'GET' || !input_params || input_params.empty?
@@ -56,7 +56,7 @@ END
56
56
  def example(sample)
57
57
  args = []
58
58
 
59
- args.concat(sample[:url_params]) if sample[:url_params]
59
+ args.concat(sample[:path_params]) if sample[:path_params]
60
60
 
61
61
  if sample[:request] && !sample[:request].empty?
62
62
  args << JSON.pretty_generate(sample[:request])
@@ -39,7 +39,7 @@ END
39
39
  def example(sample)
40
40
  args = []
41
41
 
42
- args.concat(sample[:url_params]) if sample[:url_params]
42
+ args.concat(sample[:path_params]) if sample[:path_params]
43
43
 
44
44
  if sample[:request] && !sample[:request].empty?
45
45
  args << format_parameters(:input, sample[:request])
@@ -47,7 +47,7 @@ END
47
47
  cmd = [init]
48
48
  cmd << resource_path.join('.')
49
49
  cmd << action_name
50
- cmd.concat(sample[:url_params]) if sample[:url_params]
50
+ cmd.concat(sample[:path_params]) if sample[:path_params]
51
51
 
52
52
  if sample[:request] && !sample[:request].empty?
53
53
  cmd << "-- \\\n"
@@ -42,7 +42,7 @@ END
42
42
  def example(sample)
43
43
  args = []
44
44
 
45
- args.concat(sample[:url_params]) if sample[:url_params]
45
+ args.concat(sample[:path_params]) if sample[:path_params]
46
46
 
47
47
  if sample[:request] && !sample[:request].empty?
48
48
  args << PP.pp(sample[:request], '').strip
@@ -1,18 +1,18 @@
1
1
  module HaveAPI
2
2
  class Context
3
- attr_accessor :server, :version, :request, :resource, :action, :url, :args,
3
+ attr_accessor :server, :version, :request, :resource, :action, :path, :args,
4
4
  :params, :current_user, :authorization, :endpoint,
5
5
  :action_instance, :action_prepare, :layout
6
6
 
7
7
  def initialize(server, version: nil, request: nil, resource: [], action: nil,
8
- url: nil, args: nil, params: nil, user: nil,
8
+ path: nil, args: nil, params: nil, user: nil,
9
9
  authorization: nil, endpoint: nil)
10
10
  @server = server
11
11
  @version = version
12
12
  @request = request
13
13
  @resource = resource
14
14
  @action = action
15
- @url = url
15
+ @path = path
16
16
  @args = args
17
17
  @params = params
18
18
  @current_user = user
@@ -20,10 +20,10 @@ module HaveAPI
20
20
  @endpoint = endpoint
21
21
  end
22
22
 
23
- def resolved_url
24
- return @url unless @args
23
+ def resolved_path
24
+ return @path unless @args
25
25
 
26
- ret = @url.dup
26
+ ret = @path.dup
27
27
 
28
28
  @args.each do |arg|
29
29
  resolve_arg!(ret, arg)
@@ -32,7 +32,7 @@ module HaveAPI
32
32
  ret
33
33
  end
34
34
 
35
- def url_for(action, args=nil)
35
+ def path_for(action, args=nil)
36
36
  top_module = Kernel
37
37
  top_route = @server.routes[@version]
38
38
 
@@ -60,20 +60,20 @@ module HaveAPI
60
60
  ret
61
61
  end
62
62
 
63
- def call_url_params(action, obj)
64
- ret = params && action.resolve_url_params(obj)
63
+ def call_path_params(action, obj)
64
+ ret = params && action.resolve_path_params(obj)
65
65
 
66
66
  return [ret] if ret && !ret.is_a?(Array)
67
67
  ret
68
68
  end
69
69
 
70
- def url_with_params(action, obj)
71
- url_for(action, call_url_params(action, obj))
70
+ def path_with_params(action, obj)
71
+ path_for(action, call_path_params(action, obj))
72
72
  end
73
73
 
74
74
  private
75
- def resolve_arg!(url, arg)
76
- url.sub!(/:[a-zA-Z\-_]+/, arg.to_s)
75
+ def resolve_arg!(path, arg)
76
+ path.sub!(/\{[a-zA-Z\-_]+\}/, arg.to_s)
77
77
  end
78
78
  end
79
79
  end
@@ -8,8 +8,8 @@ module HaveAPI
8
8
  @authorization = block
9
9
  end
10
10
 
11
- def url_params(*params)
12
- @url_params = params
11
+ def path_params(*params)
12
+ @path_params = params
13
13
  end
14
14
 
15
15
  def request(f)
@@ -60,7 +60,7 @@ module HaveAPI
60
60
  {
61
61
  title: @title,
62
62
  comment: @comment,
63
- url_params: @url_params,
63
+ path_params: @path_params,
64
64
  request: filter_input_params(context, @request),
65
65
  response: filter_output_params(context, @response),
66
66
  status: @status.nil? ? true : @status,
@@ -4,4 +4,6 @@ module HaveAPI
4
4
  class BuildError < StandardError
5
5
  include Nesty::NestedError
6
6
  end
7
+
8
+ class AuthenticationError < StandardError ; end
7
9
  end
@@ -58,6 +58,13 @@ module HaveAPI::Extensions
58
58
 
59
59
  env = context.request.env
60
60
 
61
+ user =
62
+ if context.current_user.respond_to?(:id)
63
+ context.current_user.id
64
+ else
65
+ context.current_user
66
+ end
67
+
61
68
  mail(context, exception, TEMPLATE.result(binding))
62
69
  end
63
70
 
@@ -222,7 +229,7 @@ module HaveAPI::Extensions
222
229
  </tr>
223
230
  <tr>
224
231
  <th>User</th>
225
- <td><%=h context.request.current_user %></td>
232
+ <td><%=h user %></td>
226
233
  </tr>
227
234
  </table>
228
235
  <div class="clear"></div>