ditty 0.7.2 → 0.8.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -7
  3. data/.travis.yml +5 -5
  4. data/Gemfile.ci +2 -0
  5. data/Rakefile +4 -3
  6. data/Readme.md +24 -2
  7. data/ditty.gemspec +4 -3
  8. data/lib/ditty.rb +24 -0
  9. data/lib/ditty/cli.rb +6 -2
  10. data/lib/ditty/components/app.rb +10 -1
  11. data/lib/ditty/controllers/application.rb +72 -10
  12. data/lib/ditty/controllers/audit_logs.rb +1 -5
  13. data/lib/ditty/controllers/auth.rb +37 -17
  14. data/lib/ditty/controllers/component.rb +15 -5
  15. data/lib/ditty/controllers/main.rb +1 -5
  16. data/lib/ditty/controllers/roles.rb +2 -5
  17. data/lib/ditty/controllers/user_login_traits.rb +18 -0
  18. data/lib/ditty/controllers/users.rb +4 -9
  19. data/lib/ditty/db.rb +3 -1
  20. data/lib/ditty/emails/base.rb +13 -4
  21. data/lib/ditty/helpers/authentication.rb +6 -5
  22. data/lib/ditty/helpers/component.rb +9 -1
  23. data/lib/ditty/helpers/response.rb +24 -3
  24. data/lib/ditty/helpers/views.rb +20 -0
  25. data/lib/ditty/listener.rb +38 -10
  26. data/lib/ditty/middleware/accept_extension.rb +2 -0
  27. data/lib/ditty/middleware/error_catchall.rb +2 -0
  28. data/lib/ditty/models/audit_log.rb +1 -0
  29. data/lib/ditty/models/base.rb +4 -0
  30. data/lib/ditty/models/identity.rb +3 -0
  31. data/lib/ditty/models/role.rb +1 -0
  32. data/lib/ditty/models/user.rb +9 -1
  33. data/lib/ditty/models/user_login_trait.rb +17 -0
  34. data/lib/ditty/policies/audit_log_policy.rb +6 -6
  35. data/lib/ditty/policies/role_policy.rb +2 -2
  36. data/lib/ditty/policies/user_login_trait_policy.rb +45 -0
  37. data/lib/ditty/policies/user_policy.rb +2 -2
  38. data/lib/ditty/rubocop.rb +3 -0
  39. data/lib/ditty/seed.rb +2 -0
  40. data/lib/ditty/services/authentication.rb +7 -2
  41. data/lib/ditty/services/email.rb +8 -2
  42. data/lib/ditty/services/logger.rb +11 -0
  43. data/lib/ditty/services/pagination_wrapper.rb +2 -0
  44. data/lib/ditty/services/settings.rb +14 -3
  45. data/lib/ditty/tasks/ditty.rake +109 -0
  46. data/lib/ditty/tasks/omniauth-ldap.rake +43 -0
  47. data/lib/ditty/version.rb +1 -1
  48. data/lib/rubocop/cop/ditty/call_services_directly.rb +42 -0
  49. data/migrate/20181209_add_user_login_traits.rb +16 -0
  50. data/migrate/20181209_extend_audit_log.rb +12 -0
  51. data/views/403.haml +2 -0
  52. data/views/audit_logs/index.haml +11 -6
  53. data/views/auth/ldap.haml +17 -0
  54. data/views/emails/forgot_password.haml +1 -1
  55. data/views/emails/layouts/action.haml +10 -6
  56. data/views/emails/layouts/alert.haml +2 -1
  57. data/views/emails/layouts/billing.haml +2 -1
  58. data/views/error.haml +8 -3
  59. data/views/partials/form_control.haml +24 -20
  60. data/views/partials/navbar.haml +11 -12
  61. data/views/partials/sidebar.haml +1 -1
  62. data/views/roles/index.haml +2 -0
  63. data/views/user_login_traits/display.haml +32 -0
  64. data/views/user_login_traits/edit.haml +10 -0
  65. data/views/user_login_traits/form.haml +5 -0
  66. data/views/user_login_traits/index.haml +30 -0
  67. data/views/user_login_traits/new.haml +10 -0
  68. data/views/users/display.haml +1 -1
  69. data/views/users/login_traits.haml +27 -0
  70. data/views/users/profile.haml +2 -0
  71. metadata +50 -21
  72. data/lib/ditty/rake_tasks.rb +0 -102
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'ditty/controllers/application'
2
4
  require 'ditty/services/email'
3
5
  require 'securerandom'
@@ -6,48 +8,56 @@ module Ditty
6
8
  class Auth < Application
7
9
  set track_actions: true
8
10
 
9
- def find_template(views, name, engine, &block)
10
- super(views, name, engine, &block) # Root
11
- super(::Ditty::App.view_folder, name, engine, &block) # Basic Plugin
11
+ def redirect_path
12
+ return "#{settings.map_path}/" if omniauth_redirect_path.nil?
13
+ return "#{settings.map_path}/" if omniauth_redirect_path =~ %r{/#{settings.map_path}/auth/?}
14
+
15
+ omniauth_redirect_path
12
16
  end
13
17
 
14
- def redirect_path
15
- return "#{settings.map_path}/" unless env['omniauth.origin']
16
- return "#{settings.map_path}/" if env['omniauth.origin'] =~ %r{/#{settings.map_path}/auth/?}
17
- env['omniauth.origin']
18
+ def omniauth_redirect_path
19
+ env['omniauth.origin'] || request.session['omniauth.origin']
18
20
  end
19
21
 
20
22
  def omniauth_callback(provider)
21
23
  return failed_login unless env['omniauth.auth']
24
+
25
+ broadcast("before_#{provider}_login".to_sym, env['omniauth.auth'])
22
26
  user = User.first(email: env['omniauth.auth']['info']['email'])
23
- user = register_user if user.nil? && ['ldap', 'google_oauth2'].include?(provider)
27
+ user = register_user if user.nil? && authorize(current_user, :register?)
24
28
  return failed_login if user.nil?
29
+
30
+ broadcast("#{provider}_login".to_sym, user)
25
31
  successful_login(user)
26
32
  end
27
33
 
28
34
  def failed_login
29
- broadcast(:user_failed_login, target: self, details: "IP: #{request.ip}")
30
- flash[:warning] = 'Invalid credentials. Please try again.'
35
+ details = params[:message] || 'None'
36
+ logger.warn "Invalid Login: #{details}"
37
+ broadcast(:user_failed_login, target: self, details: details)
38
+ flash[:warning] = 'Invalid credentials. Please try again'
39
+ headers 'X-Authentication-Failure' => params[:message] if params[:message]
31
40
  redirect "#{settings.map_path}/auth/login"
32
41
  end
33
42
 
34
43
  def successful_login(user)
35
44
  halt 200 if request.xhr?
36
45
  self.current_user = user
37
- broadcast(:user_login, target: self, details: "IP: #{request.ip}")
46
+ broadcast(:user_login, target: self)
38
47
  flash[:success] = 'Logged In'
39
48
  redirect redirect_path
40
49
  end
41
50
 
42
51
  def register_user
43
52
  user = User.create(email: env['omniauth.auth']['info']['email'])
44
- broadcast(:user_register, target: self, values: { user: user }, details: "IP: #{request.ip}")
53
+ broadcast(:user_register, target: self, values: { user: user })
45
54
  flash[:info] = 'Successfully Registered.'
46
55
  user
47
56
  end
48
57
 
49
58
  before '/login' do
50
59
  return if User.where(roles: Role.find_or_create(name: 'super_admin')).count.positive?
60
+
51
61
  flash[:info] = 'Please register the super admin user.'
52
62
  redirect "#{settings.map_path}/auth/register"
53
63
  end
@@ -55,10 +65,20 @@ module Ditty
55
65
  # TODO: Make this work for both LDAP and Identity
56
66
  get '/login' do
57
67
  authorize ::Ditty::Identity, :login
68
+ redirect settings.map_path if authenticated?
58
69
 
59
70
  haml :'auth/login', locals: { title: 'Log In' }
60
71
  end
61
72
 
73
+ # Custom login form for LDAP to allow CSRF checks. Set the `request_path` for
74
+ # the omniauth-ldap provider to another path so that this gest triggered
75
+ get '/ldap' do
76
+ authorize ::Ditty::Identity, :login
77
+ redirect settings.map_path if authenticated?
78
+
79
+ haml :'auth/ldap', locals: { title: 'Company Log In' }
80
+ end
81
+
62
82
  get '/forgot-password' do
63
83
  authorize ::Ditty::Identity, :forgot_password
64
84
 
@@ -92,7 +112,7 @@ module Ditty
92
112
 
93
113
  param :token, String, required: true
94
114
  identity = Identity[reset_token: params[:token]]
95
- halt 404 unless identity && identity.reset_requested && identity.reset_requested > (Time.now - (24 * 60 * 60))
115
+ halt 404 unless identity&.reset_requested && identity.reset_requested > (Time.now - (24 * 60 * 60))
96
116
 
97
117
  haml :'auth/reset_password', locals: { title: 'Reset your password', identity: identity }
98
118
  end
@@ -107,11 +127,11 @@ module Ditty
107
127
  identity_params = permitted_attributes(Identity, :update)
108
128
  identity.set identity_params.merge(reset_token: nil, reset_requested: nil)
109
129
  if identity.valid? && identity.save
110
- broadcast(:identity_update_password, target: self, details: "IP: #{request.ip}")
130
+ broadcast(:identity_update_password, target: self)
111
131
  flash[:success] = 'Password Updated'
112
132
  redirect "#{settings.map_path}/auth/login"
113
133
  else
114
- broadcast(:identity_update_password_failed, target: self, details: "IP: #{request.ip}")
134
+ broadcast(:identity_update_password_failed, target: self)
115
135
  haml :'auth/reset_password', locals: { title: 'Reset your password', identity: identity }
116
136
  end
117
137
  end
@@ -135,7 +155,7 @@ module Ditty
135
155
  DB.transaction do
136
156
  user.save
137
157
  user.add_identity identity
138
- broadcast(:user_register, target: self, values: { user: user }, details: "IP: #{request.ip}")
158
+ broadcast(:user_register, target: self, values: { user: user })
139
159
  flash[:info] = 'Successfully Registered. Please log in'
140
160
  redirect "#{settings.map_path}/auth/login"
141
161
  end
@@ -147,7 +167,7 @@ module Ditty
147
167
 
148
168
  # Logout Action
149
169
  delete '/' do
150
- broadcast(:user_logout, target: self, details: "IP: #{request.ip}")
170
+ broadcast(:user_logout, target: self)
151
171
  logout
152
172
 
153
173
  halt 200 if request.xhr?
@@ -13,6 +13,7 @@ module Ditty
13
13
  set dehumanized: nil
14
14
  set view_location: nil
15
15
  set track_actions: false
16
+ set heading: nil
16
17
 
17
18
  def read(id)
18
19
  dataset.first(settings.model_class.primary_key => id)
@@ -22,8 +23,15 @@ module Ditty
22
23
  @skip_verify = true
23
24
  end
24
25
 
26
+ def trigger(event, attribs = {})
27
+ attribs[:target] ||= self
28
+ send(event, attribs) if self.respond_to? event
29
+ broadcast(event, attribs)
30
+ end
31
+
25
32
  after do
26
33
  return if settings.environment == 'production'
34
+
27
35
  if (response.successful? || response.redirection?) && @skip_verify == false
28
36
  verify_authorized if settings.environment != 'production'
29
37
  end
@@ -31,6 +39,7 @@ module Ditty
31
39
 
32
40
  after '/' do
33
41
  return if settings.environment == 'production' || request.request_method != 'GET'
42
+
34
43
  verify_policy_scoped if (response.successful? || response.redirection?) && @skip_verify == false
35
44
  end
36
45
 
@@ -40,7 +49,8 @@ module Ditty
40
49
 
41
50
  result = list
42
51
 
43
- broadcast(:component_list, target: self)
52
+ trigger :component_list
53
+
44
54
  list_response(result)
45
55
  end
46
56
 
@@ -62,7 +72,7 @@ module Ditty
62
72
 
63
73
  entity.db.transaction do
64
74
  entity.save # Will trigger a Sequel::ValidationFailed exception if the model is incorrect
65
- broadcast(:component_create, target: self, entity: entity)
75
+ trigger :component_create, entity: entity
66
76
  end
67
77
 
68
78
  create_response(entity)
@@ -74,7 +84,7 @@ module Ditty
74
84
  halt 404 unless entity
75
85
  authorize entity, :read
76
86
 
77
- broadcast(:component_read, target: self, entity: entity)
87
+ trigger :component_read, entity: entity
78
88
  read_response(entity)
79
89
  end
80
90
 
@@ -98,7 +108,7 @@ module Ditty
98
108
  entity.db.transaction do
99
109
  entity.set(permitted_attributes(settings.model_class, :update))
100
110
  entity.save # Will trigger a Sequel::ValidationFailed exception if the model is incorrect
101
- broadcast(:component_update, target: self, entity: entity)
111
+ trigger :component_update, entity: entity
102
112
  end
103
113
 
104
114
  update_response(entity)
@@ -111,7 +121,7 @@ module Ditty
111
121
 
112
122
  entity.db.transaction do
113
123
  entity.destroy
114
- broadcast(:component_delete, target: self, entity: entity)
124
+ trigger :component_delete, entity: entity
115
125
  end
116
126
 
117
127
  delete_response(entity)
@@ -8,13 +8,9 @@ module Ditty
8
8
  class Main < Application
9
9
  set track_actions: true
10
10
 
11
- def find_template(views, name, engine, &block)
12
- super(views, name, engine, &block) # Root
13
- super(::Ditty::App.view_folder, name, engine, &block) # Basic Plugin
14
- end
15
-
16
11
  before '/' do
17
12
  return if User.where(roles: Role.find_or_create(name: 'super_admin')).count.positive?
13
+
18
14
  flash[:info] = 'Please register the super admin user.'
19
15
  redirect "#{settings.map_path}/auth/register"
20
16
  end
@@ -6,11 +6,8 @@ require 'ditty/policies/role_policy'
6
6
 
7
7
  module Ditty
8
8
  class Roles < Ditty::Component
9
- set model_class: Role
9
+ SEARCHABLE = %i[name].freeze
10
10
 
11
- def find_template(views, name, engine, &block)
12
- super(views, name, engine, &block) # Root
13
- super(::Ditty::App.view_folder, name, engine, &block) # Ditty
14
- end
11
+ set model_class: Role
15
12
  end
16
13
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ditty/controllers/component'
4
+ require 'ditty/models/user_login_trait'
5
+ require 'ditty/policies/user_login_trait_policy'
6
+
7
+ module Ditty
8
+ class UserLoginTraits < Ditty::Component
9
+ SEARCHABLE = %i[platform device browser ip_address].freeze
10
+ FILTERS = [
11
+ { name: :user, field: 'user.email' }
12
+ ].freeze
13
+
14
+ set base_path: 'login-traits'
15
+ set model_class: UserLoginTrait
16
+ # set track_actions: true
17
+ end
18
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'ditty/controllers/component'
4
4
  require 'ditty/models/user'
5
+ require 'ditty/models/user_login_trait'
5
6
  require 'ditty/policies/user_policy'
6
7
  require 'ditty/models/identity'
7
8
  require 'ditty/policies/identity_policy'
@@ -13,11 +14,6 @@ module Ditty
13
14
  set model_class: User
14
15
  set track_actions: true
15
16
 
16
- def find_template(views, name, engine, &block)
17
- super(views, name, engine, &block) # Root
18
- super(::Ditty::App.view_folder, name, engine, &block) # Ditty
19
- end
20
-
21
17
  # New
22
18
  get '/new' do
23
19
  authorize settings.model_class, :create
@@ -45,6 +41,7 @@ module Ditty
45
41
  identity.save
46
42
  rescue Sequel::ValidationFailed
47
43
  raise unless request.accept? 'text/html'
44
+
48
45
  status 400
49
46
  locals = { title: heading(:new), entity: user, identity: identity }
50
47
  return haml(:"#{view_location}/new", locals: locals)
@@ -52,10 +49,8 @@ module Ditty
52
49
  user.save
53
50
  user.add_identity identity
54
51
 
55
- if roles
56
- roles.each do |role_id|
57
- user.add_role(role_id) unless user.roles.map(&:id).include? role_id.to_i
58
- end
52
+ roles&.each do |role_id|
53
+ user.add_role(role_id) unless user.roles.map(&:id).include? role_id.to_i
59
54
  end
60
55
  user.check_roles
61
56
  end
@@ -18,8 +18,10 @@ elsif ENV['DATABASE_URL'].blank? == false
18
18
  )
19
19
 
20
20
  DB.sql_log_level = (ENV['SEQUEL_LOGGING_LEVEL'] || :debug).to_sym
21
- DB.loggers << Ditty::Services::Logger.instance
21
+ DB.loggers << Ditty::Services::Logger.instance if ENV['DB_DEBUG'].to_i == 1
22
22
  DB.extension(:pagination)
23
+ DB.extension(:schema_caching)
24
+ DB.load_schema_cache?('./config/schema.dump')
23
25
 
24
26
  Sequel::Model.plugin :validation_helpers
25
27
  Sequel::Model.plugin :update_or_create
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'haml'
2
4
  require 'ditty/components/app'
3
5
 
@@ -15,8 +17,11 @@ module Ditty
15
17
  def deliver!(to = nil, locals = {})
16
18
  options[:to] = to unless to.nil?
17
19
  @locals.merge!(locals)
18
- %i[to from subject].each do |param|
19
- mail.send(param, options[param]) if options[param]
20
+ %i[to from subject content_type].each do |param|
21
+ next unless options[param]
22
+
23
+ @locals[param] ||= options[param]
24
+ mail.send(param, options[param])
20
25
  end
21
26
  mail.body content
22
27
  mail.deliver!
@@ -24,6 +29,7 @@ module Ditty
24
29
 
25
30
  def method_missing(method, *args, &block)
26
31
  return super unless respond_to_missing?(method)
32
+
27
33
  mail.send(method, *args, &block)
28
34
  end
29
35
 
@@ -36,7 +42,8 @@ module Ditty
36
42
  def content
37
43
  result = Haml::Engine.new(content_haml).render(Object.new, locals)
38
44
  return result unless options[:layout]
39
- Haml::Engine.new(layout_haml).render(Object.new, content: result)
45
+
46
+ Haml::Engine.new(layout_haml).render(Object.new, locals.merge(content: result))
40
47
  end
41
48
 
42
49
  def content_haml
@@ -52,14 +59,16 @@ module Ditty
52
59
  end
53
60
 
54
61
  def base_options
55
- { subject: '(No Subject)', from: 'no-reply@ditty.io', view: :base }
62
+ { subject: '(No Subject)', from: 'no-reply@ditty.io', view: :base, content_type: 'text/html; charset=UTF-8' }
56
63
  end
57
64
 
58
65
  def find_template(file)
59
66
  template = File.expand_path("./views/#{file}.haml")
60
67
  return template if File.file? template
68
+
61
69
  template = File.expand_path("./#{file}.haml", App.view_folder)
62
70
  return template if File.file? template
71
+
63
72
  file
64
73
  end
65
74
 
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ditty/models/user'
4
- require 'ditty/models/role'
5
- require 'ditty/models/identity'
6
4
 
7
5
  module Ditty
8
6
  module Helpers
9
7
  module Authentication
10
8
  def current_user
11
9
  return nil if current_user_id.nil?
10
+
12
11
  @current_user ||= User[current_user_id]
13
12
  end
14
13
 
@@ -19,8 +18,9 @@ module Ditty
19
18
  end
20
19
 
21
20
  def current_user_id
22
- return env['rack.session']['user_id'] if env['rack.session']
23
- env['omniauth.auth'].uid if env['omniauth.auth']
21
+ return env['rack.session']['user_id'] if env['rack.session'] && env['rack.session']['user_id']
22
+
23
+ env['omniauth.auth']&.uid
24
24
  end
25
25
 
26
26
  def authenticate
@@ -33,11 +33,12 @@ module Ditty
33
33
 
34
34
  def authenticate!
35
35
  raise NotAuthenticated unless authenticated?
36
+
36
37
  true
37
38
  end
38
39
 
39
40
  def logout
40
- env['rack.session'].delete('user_id') unless env['rack.session'].nil?
41
+ env['rack.session']&.delete('user_id')
41
42
  env.delete('omniauth.auth')
42
43
  end
43
44
  end
@@ -13,8 +13,10 @@ module Ditty
13
13
  # param :count, Integer, min: 1, default: 10 # Can't do this, since count can be `all`
14
14
  def check_count
15
15
  return 10 if params[:count].nil?
16
+
16
17
  count = params[:count].to_i
17
18
  return count if count >= 1
19
+
18
20
  excp = Sinatra::Param::InvalidParameterError.new 'Parameter cannot be less than 1'
19
21
  excp.param = :count
20
22
  raise excp
@@ -37,16 +39,18 @@ module Ditty
37
39
  ds = dataset
38
40
  ds = ds.dataset if ds.respond_to?(:dataset)
39
41
  return ds if params[:count] == 'all'
42
+
40
43
  params[:count] = check_count
41
44
 
42
45
  # Account for difference between sequel paginate and will paginate
43
46
  return ds.paginate(page: params[:page], per_page: params[:count]) if ds.is_a?(Array)
47
+
44
48
  ds.paginate(params[:page], params[:count])
45
49
  end
46
50
 
47
51
  def heading(action = nil)
48
52
  @headings ||= begin
49
- heading = settings.model_class.to_s.demodulize.titleize
53
+ heading = settings.heading || settings.model_class.to_s.demodulize.singularize.titleize
50
54
  h = Hash.new(heading)
51
55
  h[:new] = "New #{heading}"
52
56
  h[:list] = pluralize heading
@@ -78,6 +82,7 @@ module Ditty
78
82
  def filters
79
83
  filter_fields.map do |filter|
80
84
  next if params[filter[:name]].blank?
85
+
81
86
  filter[:field] ||= filter[:name]
82
87
  filter[:modifier] ||= :to_s # TODO: Do this with Sinatra Param?
83
88
  filter
@@ -86,6 +91,7 @@ module Ditty
86
91
 
87
92
  def ordering
88
93
  return if params[:sort].blank?
94
+
89
95
  Sequel.send(params[:order].to_sym, params[:sort].to_sym)
90
96
  end
91
97
 
@@ -112,11 +118,13 @@ module Ditty
112
118
  assoc = filter[:field].to_s.split('.').first.to_sym
113
119
  assoc = settings.model_class.association_reflection(assoc)
114
120
  raise "Unknown association #{assoc}" if assoc.nil?
121
+
115
122
  assoc
116
123
  end
117
124
 
118
125
  def search_filters
119
126
  return [] if params[:q].blank?
127
+
120
128
  searchable_fields.map { |f| Sequel.ilike(f.to_sym, "%#{params[:q]}%") }
121
129
  end
122
130
  end