softwear 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +37 -0
  4. data/app/assets/javascripts/softwear/application.js +13 -0
  5. data/app/assets/javascripts/softwear/error_utils.js.coffee +82 -0
  6. data/app/assets/javascripts/softwear/modals.js.coffee +171 -0
  7. data/app/assets/stylesheets/softwear/application.css +33 -0
  8. data/app/controllers/softwear/application_controller.rb +4 -0
  9. data/app/controllers/softwear/error_reports_controller.rb +37 -0
  10. data/app/helpers/softwear/application_helper.rb +4 -0
  11. data/app/helpers/softwear/emails_helper.rb +9 -0
  12. data/app/mailers/error_report_mailer.rb +53 -0
  13. data/app/views/error_report_mailer/send_report.html.erb +30 -0
  14. data/app/views/layouts/softwear/application.html.erb +14 -0
  15. data/app/views/softwear/errors/_error.html.erb +35 -0
  16. data/app/views/softwear/errors/internal_server_error.html.erb +6 -0
  17. data/app/views/softwear/errors/internal_server_error.js.erb +10 -0
  18. data/bin/rails +12 -0
  19. data/config/routes.rb +3 -0
  20. data/lib/softwear/auth/belongs_to_user.rb +43 -0
  21. data/lib/softwear/auth/controller.rb +43 -0
  22. data/lib/softwear/auth/helper.rb +35 -0
  23. data/lib/softwear/auth/model.rb +16 -0
  24. data/lib/softwear/auth/spec.rb +62 -0
  25. data/lib/softwear/auth/standard_model.rb +498 -0
  26. data/lib/softwear/auth/stubbed_model.rb +130 -0
  27. data/lib/softwear/auth/token_authentication.rb +72 -0
  28. data/lib/softwear/engine.rb +5 -0
  29. data/lib/softwear/error_catcher.rb +65 -0
  30. data/lib/softwear/library/api_controller.rb +169 -0
  31. data/lib/softwear/library/capistrano.rb +94 -0
  32. data/lib/softwear/library/controller_authentication.rb +145 -0
  33. data/lib/softwear/library/enqueue.rb +87 -0
  34. data/lib/softwear/library/spec.rb +127 -0
  35. data/lib/softwear/library/tcp_server.rb +107 -0
  36. data/lib/softwear/version.rb +3 -0
  37. data/lib/softwear.rb +107 -0
  38. metadata +172 -0
@@ -0,0 +1,43 @@
1
+ module Softwear
2
+ module Auth
3
+ class Controller < ApplicationController
4
+ skip_before_filter :authenticate_user!, only: [:set_session_token, :clear_query_cache]
5
+
6
+ def self.abstract_class?
7
+ true
8
+ end
9
+
10
+ # ====================
11
+ # Comes from an img tag on softwear-hub to let an authorized app know that
12
+ # a user has signed in.
13
+ # ====================
14
+ def set_session_token
15
+ encrypted_token = params[:token]
16
+ redirect_to Figaro.env.softwear_hub_url and return if encrypted_token.blank?
17
+
18
+ Rails.logger.info "RECEIVED ENCRYPTED TOKEN: #{encrypted_token}"
19
+
20
+ decipher = OpenSSL::Cipher::AES.new(256, :CBC)
21
+ decipher.decrypt
22
+ decipher.key = Figaro.env.token_cipher_key
23
+ decipher.iv = Figaro.env.token_cipher_iv
24
+
25
+ session[:user_token] = decipher.update(Base64.urlsafe_decode64(encrypted_token)) + decipher.final
26
+
27
+ render inline: 'Done'
28
+ end
29
+
30
+ # ====================
31
+ # Comes from an img tag on softwear-hub when there has been a change to user
32
+ # attributes or roles and the cache should be cleared.
33
+ # ====================
34
+ def clear_query_cache
35
+ Softwear::Auth::Model.descendants.each do |user|
36
+ user.query_cache.clear
37
+ end
38
+
39
+ render inline: 'Done'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ module Softwear
2
+ module Auth
3
+ module Helper
4
+ def profile_picture_of(user = nil, options = {})
5
+ options[:class] ||= ''
6
+ options[:class] += ' media-object img-rounded profile-pic img img-responsive'
7
+ options[:alt] ||= "#{user.try(:full_name) || '(Unknown)'}'s Avatar"
8
+ options[:title] ||= user.try(:full_name) || 'Someone'
9
+
10
+ image_tag user.try(:profile_picture_url) || 'avatar/masarie.jpg', options
11
+ end
12
+
13
+ def auth_server_error_banner
14
+ return unless Softwear::Auth::Model.auth_server_down?
15
+
16
+ content_tag :div, class: 'alert alert-danger' do
17
+ content_tag :strong do
18
+ (Softwear::Auth::Model.auth_server_went_down_at || Time.now).strftime(
19
+ "WARNING: The authentication server is unreachable as of %I:%M%p. "\
20
+ "Some site features might not function properly."
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ # module EmailsHelper
28
+ # def backtrace_is_from_app?(line)
29
+ # !(line.include?('/gems/') || /^kernel\// =~ line || line.include?('/vendor_ruby/'))
30
+ # end
31
+
32
+ # extend self
33
+ # end
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ require 'softwear/auth/standard_model'
2
+ require 'softwear/auth/stubbed_model'
3
+
4
+ module Softwear
5
+ module Auth
6
+ if Rails.env.development? && ENV['AUTH_SERVER'] != 'true'
7
+ class Model < StubbedModel
8
+ STUBBED = true
9
+ end
10
+ else
11
+ class Model < StandardModel
12
+ STUBBED = false
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,62 @@
1
+ module Softwear
2
+ module Auth
3
+ module Spec
4
+ def spec_users
5
+ User.instance_variable_get(:@_spec_users)
6
+ end
7
+
8
+ def stub_authentication!(config, *a)
9
+ config.before(:each, *a) do
10
+ User.instance_variable_set(:@_spec_users, [])
11
+
12
+ allow(User).to receive(:all) { spec_users }
13
+ allow(User).to receive(:find) { |n| spec_users.find { |u| u.id.to_s == n.to_s } }
14
+ allow(User).to receive(:auth) { @_signed_in_user or false }
15
+ allow(User).to receive(:raw_query) { |q| raise "Unstubbed authentication query \"#{q}\"" }
16
+
17
+ allow(Figaro.env).to receive(:softwear_hub_url).and_return 'http://hub.example.com'
18
+
19
+ allow_any_instance_of(Softwear::Library::ControllerAuthentication)
20
+ .to receive(:user_token)
21
+ .and_return('')
22
+
23
+ if (controller rescue false)
24
+ controller.class_eval { helper Softwear::Auth::Helper }
25
+
26
+ allow(controller).to receive(:current_user) { @_signed_in_user }
27
+ controller.class_eval { helper_method :current_user }
28
+
29
+ allow(controller).to receive(:user_signed_in?) { !!@_signed_in_user }
30
+ controller.class_eval { helper_method :user_signed_in? }
31
+
32
+ allow(controller).to receive(:destroy_user_session_path) { '#' }
33
+ controller.class_eval { helper_method :destroy_user_session_path }
34
+
35
+ allow(controller).to receive(:users_path) { '#' }
36
+ controller.class_eval { helper_method :users_path }
37
+
38
+ allow(controller).to receive(:edit_user_path) { '#' }
39
+ controller.class_eval { protected; helper_method :edit_user_path }
40
+ end
41
+ end
42
+
43
+ config.after(:each, *a) do
44
+ User.instance_variable_set(:@_spec_users, nil)
45
+ end
46
+ end
47
+
48
+ def sign_in_as(user)
49
+ @_signed_in_user = user
50
+
51
+ allow_any_instance_of(Softwear::Library::ControllerAuthentication)
52
+ .to receive(:user_token).and_return 'abc123'
53
+
54
+ if respond_to?(:session) && session.respond_to?(:[]=)
55
+ session[:user_token] = 'abc123'
56
+ end
57
+ end
58
+ alias_method :sign_in, :sign_in_as
59
+ alias_method :login_as, :sign_in_as
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,498 @@
1
+ module Softwear
2
+ module Auth
3
+ class StandardModel
4
+ include ActiveModel::Model
5
+ include ActiveModel::Conversion
6
+
7
+ class AccessDeniedError < StandardError
8
+ end
9
+ class InvalidCommandError < StandardError
10
+ end
11
+ class AuthServerError < StandardError
12
+ end
13
+ class AuthServerDown < StandardError
14
+ end
15
+
16
+ # ============================= CLASS METHODS ======================
17
+ class << self
18
+ def abstract_class?
19
+ true
20
+ end
21
+
22
+ attr_writer :query_cache
23
+ attr_accessor :total_query_cache
24
+ attr_writer :query_cache_expiry
25
+ alias_method :expire_query_cache_every, :query_cache_expiry=
26
+ attr_accessor :auth_server_went_down_at
27
+ attr_accessor :sent_auth_server_down_email
28
+ attr_accessor :time_before_down_email
29
+ alias_method :email_when_down_after, :time_before_down_email=
30
+
31
+ # ====================
32
+ # Returns true if the authentication server was unreachable for the previous query.
33
+ # ====================
34
+ def auth_server_down?
35
+ !!auth_server_went_down_at
36
+ end
37
+
38
+ # ====================
39
+ # The query cache takes message keys (such as "get 12") with response values straight from
40
+ # the server. So yes, this will cache error responses.
41
+ # You can clear this with <User Class>.query_cache.clear or <User Class>.query_cache = nil
42
+ # ====================
43
+ def query_cache
44
+ @query_cache ||= ThreadSafe::Cache.new
45
+ end
46
+
47
+ def query_cache_expiry
48
+ @query_cache_expiry || Figaro.env.query_cache_expiry.try(:to_f) || 1.hour
49
+ end
50
+
51
+ # ===================
52
+ # Override this in your subclasses! The mailer should have auth_server_down(time) and
53
+ # auth_server_up(time)
54
+ # ===================
55
+ def auth_server_down_mailer
56
+ # override me
57
+ end
58
+
59
+ # ======================================
60
+ def primary_key
61
+ :id
62
+ end
63
+
64
+ def base_class
65
+ self
66
+ end
67
+
68
+ def relation_delegate_class(*)
69
+ self
70
+ end
71
+
72
+ def unscoped
73
+ self
74
+ end
75
+
76
+ def new(*args)
77
+ if args.size == 3
78
+ assoc_class = args[2].owner.class.name
79
+ assoc_name = args[2].reflection.name
80
+ raise "Unsupported user association: #{assoc_class}##{assoc_name}. If this is a belongs_to "\
81
+ "association, you may have #{assoc_class} include Softwear::Auth::BelongsToUser and call "\
82
+ "`belongs_to_user_called :#{assoc_name}' instead of the traditional rails method."
83
+ else
84
+ super
85
+ end
86
+ end
87
+ # ======================================
88
+
89
+ # ====================
90
+ # Not a fully featured has_many - must specify foreign_key if the association doesn't match
91
+ # the model name, through is inefficient.
92
+ # ====================
93
+ def has_many(assoc, options = {})
94
+ assoc = assoc.to_s
95
+
96
+ if through = options[:through]
97
+ source = options[:source] || assoc
98
+
99
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
100
+ def #{assoc}
101
+ #{through}.flat_map(&:#{source})
102
+ end
103
+ RUBY
104
+
105
+ else
106
+ class_name = options[:class_name] || assoc.singularize.camelize
107
+ foreign_key = options[:foreign_key] || 'user_id'
108
+
109
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
110
+ def #{assoc}
111
+ #{class_name}.where(#{foreign_key}: id)
112
+ end
113
+ RUBY
114
+ end
115
+ end
116
+
117
+ # ====================
118
+ # Pretty much a map function - for activerecord compatibility.
119
+ # ====================
120
+ def pluck(*attrs)
121
+ if attrs.size == 1
122
+ all.map do |user|
123
+ user.send(attrs.first)
124
+ end
125
+ else
126
+ all.map do |user|
127
+ attrs.map { |a| user.send(a) }
128
+ end
129
+ end
130
+ end
131
+
132
+ def arel_table
133
+ @arel_table ||= Arel::Table.new(model_name.plural, self)
134
+ end
135
+
136
+ # ====================
137
+ # This is only used to record how long it takes to perform queries for development.
138
+ # ====================
139
+ def record(before, after, type, body)
140
+ ms = (after - before) * 1000
141
+ # The garbage in this string gives us the bold and color
142
+ Rails.logger.info " \033[1m\033[33m#{type} (#{'%.1f' % ms}ms)\033[0m #{body}"
143
+ end
144
+
145
+ # ====================
146
+ # Host of the auth server, from 'auth_server_endpoint' env variable.
147
+ # Defaults to localhost.
148
+ # ====================
149
+ def auth_server_host
150
+ endpoint = Figaro.env.auth_server_endpoint
151
+ if endpoint.blank?
152
+ 'localhost'
153
+ elsif endpoint.include?(':')
154
+ endpoint.split(':').first
155
+ else
156
+ endpoint
157
+ end
158
+ end
159
+
160
+ # ====================
161
+ # Port of the auth server, from 'auth_server_endpoint' env variable.
162
+ # Defaults to 2900.
163
+ # ====================
164
+ def auth_server_port
165
+ endpoint = Figaro.env.auth_server_endpoint
166
+ if endpoint.try(:include?, ':')
167
+ endpoint.split(':').last
168
+ else
169
+ 2900
170
+ end
171
+ end
172
+
173
+ def default_socket
174
+ @default_socket ||= TCPSocket.open(auth_server_host, auth_server_port)
175
+ end
176
+
177
+ # ====================
178
+ # Bare minimum query function - sends a message and returns the response, and
179
+ # handles a broken socket. #query and #force_query call this function.
180
+ # ====================
181
+ def raw_query(message)
182
+ begin
183
+ default_socket.puts message
184
+
185
+ rescue Errno::EPIPE => e
186
+ @default_socket = TCPSocket.open(auth_server_host, auth_server_port)
187
+ @default_socket.puts message
188
+ end
189
+
190
+ response = default_socket.gets.try(:chomp)
191
+ if response.nil?
192
+ @default_socket.close rescue nil
193
+ @default_socket = nil
194
+ return raw_query(message)
195
+ end
196
+ response
197
+
198
+ rescue Errno::ECONNREFUSED => e
199
+ raise AuthServerDown, "Unable to connect to the authentication server."
200
+
201
+ rescue Errno::ETIMEDOUT => e
202
+ raise AuthServerDown, "Connection to authentication server timed out."
203
+ end
204
+
205
+ # ====================
206
+ # Expires the query cache, setting a new expiration time as well as merging
207
+ # with the previous query cache, in case of an auth server outage.
208
+ # ====================
209
+ def expire_query_cache
210
+ before = Time.now
211
+ if total_query_cache
212
+ query_cache.each_pair do |key, value|
213
+ total_query_cache[key] = value
214
+ end
215
+ else
216
+ self.total_query_cache = query_cache.clone
217
+ end
218
+
219
+ query_cache.clear
220
+ query_cache['_expire_at'] = (query_cache_expiry || 1.hour).from_now
221
+ after = Time.now
222
+
223
+ record(before, after, "Authentication Expire Cache", "")
224
+ end
225
+
226
+ # ====================
227
+ # Queries the authentication server only if there isn't a cached response.
228
+ # Also keeps track of whether or not the server is reachable, and sends emails
229
+ # when the server goes down and back up.
230
+ # ====================
231
+ def query(message)
232
+ before = Time.now
233
+
234
+ expire_at = query_cache['_expire_at']
235
+ expire_query_cache if expire_at.blank? || Time.now > expire_at
236
+
237
+ if cached_response = query_cache[message]
238
+ response = cached_response
239
+ action = "Authentication Cache"
240
+ else
241
+ begin
242
+ response = raw_query(message)
243
+ action = "Authentication Query"
244
+ query_cache[message] = response
245
+
246
+ if auth_server_went_down_at
247
+ self.auth_server_went_down_at = nil
248
+
249
+ if sent_auth_server_down_email
250
+ self.sent_auth_server_down_email = false
251
+ if (mailer = auth_server_down_mailer) && mailer.respond_to?(:auth_server_up)
252
+ mailer.auth_server_up(Time.now).deliver_now
253
+ end
254
+ end
255
+ end
256
+
257
+ rescue AuthServerError => e
258
+ raise unless total_query_cache
259
+
260
+ old_response = total_query_cache[message]
261
+ if old_response
262
+ response = old_response
263
+ action = "Authentication Cache (due to error)"
264
+ Rails.logger.error "AUTHENTICATION: The authentication server encountered an error. "\
265
+ "You should probably check the auth server's logs. "\
266
+ "A cached response was used."
267
+ else
268
+ raise
269
+ end
270
+
271
+ rescue AuthServerDown => e
272
+ if auth_server_went_down_at.nil?
273
+ self.auth_server_went_down_at = Time.now
274
+ expire_query_cache
275
+
276
+ elsif auth_server_went_down_at > (time_before_down_email || 5.minutes).ago
277
+ unless sent_auth_server_down_email
278
+ self.sent_auth_server_down_email = true
279
+
280
+ if (mailer = auth_server_down_mailer) && mailer.respond_to?(:auth_server_down)
281
+ mailer.auth_server_down(auth_server_went_down_at).deliver_now
282
+ end
283
+ end
284
+ end
285
+
286
+ old_response = total_query_cache[message]
287
+ if old_response
288
+ response = old_response
289
+ action = "Authentication Cache (server down)"
290
+ else
291
+ raise AuthServerDown, "An uncached query was attempted, and the authentication server is down."
292
+ end
293
+ end
294
+ end
295
+ after = Time.now
296
+
297
+ record(before, after, action, message)
298
+ response
299
+ end
300
+
301
+ # ====================
302
+ # Runs a query through the server without error or cache checking.
303
+ # ====================
304
+ def force_query(message)
305
+ before = Time.now
306
+ response = raw_query(message)
307
+ after = Time.now
308
+
309
+ record(before, after, "Authentication Query (forced)", message)
310
+ response
311
+ end
312
+
313
+ # ====================
314
+ # Expects a response string returned from #query and raises an error for the
315
+ # following cases:
316
+ #
317
+ # - Access denied (AccessDeniedError)
318
+ # - Invalid command (bad query message) (InvalidCommandError)
319
+ # - Error on auth server's side (AuthServerError)
320
+ # ====================
321
+ def validate_response(response_string)
322
+ case response_string
323
+ when 'denied' then raise AccessDeniedError, "Denied"
324
+ when 'invalid' then raise InvalidCommandError, "Invalid command"
325
+ when 'sorry'
326
+ expire_query_cache
327
+ raise AuthServerError, "Authentication server encountered an error"
328
+ else
329
+ response_string
330
+ end
331
+ end
332
+
333
+ # ====================
334
+ # Finds a user with the given ID
335
+ # ====================
336
+ def find(target_id)
337
+ json = validate_response query "get #{target_id}"
338
+
339
+ if json == 'nosuchuser'
340
+ nil
341
+ else
342
+ object = new(JSON.parse(json))
343
+ object.instance_variable_set(:@persisted, true)
344
+ object
345
+ end
346
+
347
+ rescue JSON::ParserError => e
348
+ Rails.logger.error "Bad user model JSON: ``` #{json} ```"
349
+ nil
350
+ end
351
+
352
+ def filter_all(method, options)
353
+ all.send(method) do |user|
354
+ options.all? { |field, wanted_value| user.send(field) == wanted_value }
355
+ end
356
+ end
357
+
358
+ # ====================
359
+ # Finds a user with the given attributes (just queries for 'all' and uses ruby filters)
360
+ # ====================
361
+ def find_by(options)
362
+ filter_all(:find, options)
363
+ end
364
+
365
+ # ====================
366
+ # Finds users with the given attributes (just queries for 'all' and uses ruby filters)
367
+ # ====================
368
+ def where(options)
369
+ filter_all(:select, options)
370
+ end
371
+
372
+ # ====================
373
+ # Returns an array of all registered users
374
+ # ====================
375
+ def all
376
+ json = validate_response query "all"
377
+
378
+ objects = JSON.parse(json).map(&method(:new))
379
+ objects.each { |u| u.instance_variable_set(:@persisted, true) }
380
+ end
381
+
382
+ # ====================
383
+ # Returns array of all users with the given roles
384
+ # ====================
385
+ def of_role(*roles)
386
+ roles = roles.flatten.compact
387
+ return [] if roles.empty?
388
+
389
+ json = validate_response query "ofrole #{Figaro.env.hub_app_name} #{roles.split(' ')}"
390
+
391
+ objects = JSON.parse(json).map(&method(:new))
392
+ objects.each { |u| u.instance_variable_set(:@persisted, true) }
393
+ end
394
+
395
+ # ====================
396
+ # Given a valid signin token:
397
+ # Returns the authenticated user for the given token
398
+ # Given an invalid signin token:
399
+ # Returns false
400
+ # ====================
401
+ def auth(token, app_name = nil)
402
+ response = validate_response query "auth #{app_name || Figaro.env.hub_app_name} #{token}"
403
+
404
+ return false unless response =~ /^yes .+$/
405
+
406
+ _yes, json = response.split(' ', 2)
407
+ object = new(JSON.parse(json))
408
+ object.instance_variable_set(:@persisted, true)
409
+ object
410
+ end
411
+
412
+ # ====================
413
+ # Overridable logger method used when recording query benchmarks
414
+ # ====================
415
+ def logger
416
+ Rails.logger
417
+ end
418
+ end
419
+
420
+ # ============================= INSTANCE METHODS ======================
421
+
422
+ REMOTE_ATTRIBUTES = [
423
+ :id, :email, :first_name, :last_name,
424
+ :roles, :profile_picture_url,
425
+ :default_view, :rights
426
+ ]
427
+ REMOTE_ATTRIBUTES.each(&method(:attr_accessor))
428
+
429
+ attr_reader :persisted
430
+ alias_method :persisted?, :persisted
431
+
432
+ # ====================
433
+ # Various class methods accessible on instances
434
+ def query(*a)
435
+ self.class.query(*a)
436
+ end
437
+ def raw_query(*a)
438
+ self.class.raw_query(*a)
439
+ end
440
+ def force_query(*a)
441
+ self.class.force_query(*a)
442
+ end
443
+ def logger
444
+ self.class.logger
445
+ end
446
+ # ====================
447
+
448
+ def initialize(attributes = {})
449
+ update_attributes(attributes)
450
+ end
451
+
452
+ def update_attributes(attributes={})
453
+ return if attributes.blank?
454
+ attributes = attributes.with_indifferent_access
455
+
456
+ REMOTE_ATTRIBUTES.each do |attr|
457
+ instance_variable_set("@#{attr}", attributes[attr])
458
+ end
459
+ end
460
+
461
+ def to_json
462
+ {
463
+ id: @id,
464
+ email: @email,
465
+ first_name: @first_name,
466
+ last_name: @last_name
467
+ }
468
+ .to_json
469
+ end
470
+
471
+ def reload
472
+ json = validate_response query "get #{id}"
473
+
474
+ update_attributes(JSON.parse(json))
475
+ @persisted = true
476
+ self
477
+ end
478
+
479
+ def full_name
480
+ "#{@first_name} #{@last_name}"
481
+ end
482
+
483
+ def valid_password?(pass)
484
+ query("pass #{id} #{pass}") == 'yes'
485
+ end
486
+
487
+ def role?(*wanted_roles)
488
+ return true if wanted_roles.empty?
489
+
490
+ if @roles.nil?
491
+ query("role #{Figaro.env.hub_app_name} #{id} #{wanted_roles.join(' ')}") == 'yes'
492
+ else
493
+ wanted_roles.any? { |r| @roles.include?(r.to_s) }
494
+ end
495
+ end
496
+ end
497
+ end
498
+ end