ditty 0.7.2 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
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