softwear 2.0.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 (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