b4um_generators 0.1.0 → 0.2.7

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.
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module B4um
6
+ module Generators
7
+ class AuthenticationGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Installs session-based authentication for B4UM."
11
+
12
+ argument :authentication_model,
13
+ type: :string,
14
+ required: true,
15
+ banner: "MODEL"
16
+
17
+ class_option :protect,
18
+ type: :string,
19
+ required: false,
20
+ desc: "Comma-separated controllers to protect"
21
+
22
+ def validate_authentication_model
23
+ model_path = File.join(
24
+ destination_root,
25
+ "app/models",
26
+ "#{authentication_model_name}.rb"
27
+ )
28
+
29
+ unless File.exist?(model_path)
30
+ raise Thor::Error,
31
+ "Authentication model #{authentication_class_name} was not found. " \
32
+ "Expected: app/models/#{authentication_model_name}.rb"
33
+ end
34
+
35
+ model = File.read(model_path)
36
+
37
+ unless model.match?(/\bhas_secure_password\b/)
38
+ raise Thor::Error,
39
+ "Authentication model #{authentication_class_name} must use " \
40
+ "has_secure_password."
41
+ end
42
+
43
+ return if password_digest_column_defined?
44
+
45
+ raise Thor::Error,
46
+ "Authentication model #{authentication_class_name} requires a " \
47
+ "password_digest column. " \
48
+ "Create the model with: " \
49
+ "bin/rails generate b4um:scaffold " \
50
+ "#{authentication_class_name} email:string password_digest:string"
51
+ end
52
+
53
+ def validate_protected_controllers
54
+ protected_controller_names.each do |controller_name|
55
+ controller_path = File.join(
56
+ "app/controllers",
57
+ "#{controller_name.underscore}_controller.rb"
58
+ )
59
+
60
+ full_controller_path = File.join(
61
+ destination_root,
62
+ controller_path
63
+ )
64
+
65
+ next if File.exist?(full_controller_path)
66
+
67
+ raise Thor::Error,
68
+ "Protected controller #{controller_name.camelize}Controller " \
69
+ "was not found: #{controller_path}"
70
+ end
71
+ end
72
+
73
+ def validate_bcrypt
74
+ gemfile_path = File.join(
75
+ destination_root,
76
+ "Gemfile"
77
+ )
78
+
79
+ unless File.exist?(gemfile_path)
80
+ raise Thor::Error,
81
+ "Gemfile not found. bcrypt is required for authentication."
82
+ end
83
+
84
+ gemfile = File.read(gemfile_path)
85
+
86
+ return if gemfile.match?(
87
+ /^\s*gem\s+["']bcrypt["'](?:\s*,.*)?$/
88
+ )
89
+
90
+ raise Thor::Error,
91
+ "bcrypt is required for authentication. " \
92
+ "Run bin/rails generate b4um:install and enable bcrypt, " \
93
+ "or add gem \"bcrypt\" to your Gemfile and run bundle install."
94
+ end
95
+
96
+ def create_sessions_controller
97
+ template(
98
+ "sessions_controller.rb.tt",
99
+ "app/controllers/sessions_controller.rb"
100
+ )
101
+ end
102
+
103
+ def create_login_view
104
+ template(
105
+ "new.html.erb.tt",
106
+ "app/views/sessions/new.html.erb"
107
+ )
108
+ end
109
+
110
+ def add_authentication_helpers
111
+ controller_path = "app/controllers/application_controller.rb"
112
+ full_controller_path = File.join(destination_root, controller_path)
113
+
114
+ unless File.exist?(full_controller_path)
115
+ raise Thor::Error,
116
+ "ApplicationController not found: #{controller_path}"
117
+ end
118
+
119
+ controller = File.read(full_controller_path)
120
+
121
+ return if controller.include?(
122
+ "helper_method :#{current_authentication_method}, :logged_in?"
123
+ )
124
+
125
+ inject_into_file(
126
+ controller_path,
127
+ before: /^end\s*$/
128
+ ) do
129
+ <<~RUBY
130
+
131
+ helper_method :#{current_authentication_method}, :logged_in?
132
+
133
+ private
134
+
135
+ def #{current_authentication_method}
136
+ @#{current_authentication_method} ||= #{authentication_class_name}.find_by(id: session[:#{authentication_model_name}_id])
137
+ end
138
+
139
+ def logged_in?
140
+ #{current_authentication_method}.present?
141
+ end
142
+
143
+ def require_login
144
+ return if logged_in?
145
+
146
+ redirect_to login_path, alert: "Please log in first."
147
+ end
148
+ RUBY
149
+ end
150
+ end
151
+
152
+ def add_session_routes
153
+ routes_path = "config/routes.rb"
154
+ full_routes_path = File.join(destination_root, routes_path)
155
+
156
+ routes = File.read(full_routes_path)
157
+
158
+ return if routes.include?(
159
+ 'get "login", to: "sessions#new", as: :login'
160
+ )
161
+
162
+ inject_into_file(
163
+ routes_path,
164
+ before: /^end\s*$/
165
+ ) do
166
+ <<~RUBY
167
+ get "login", to: "sessions#new", as: :login
168
+ post "login", to: "sessions#create"
169
+ delete "logout", to: "sessions#destroy", as: :logout
170
+ RUBY
171
+ end
172
+ end
173
+
174
+ def add_navigation_links
175
+ navigation_path = "app/views/shared/_navigation.html.erb"
176
+ full_navigation_path = File.join(
177
+ destination_root,
178
+ navigation_path
179
+ )
180
+
181
+ return unless File.exist?(full_navigation_path)
182
+
183
+ navigation = File.read(full_navigation_path)
184
+
185
+ return if navigation.include?(
186
+ 'navigation_button_to "Logout", logout_path, method: :delete'
187
+ )
188
+
189
+ marker = "<%# B4UM_NAVIGATION_LINKS %>"
190
+
191
+ return unless navigation.include?(marker)
192
+
193
+ authentication_links = <<~ERB
194
+ <% if logged_in? %>
195
+ <%= navigation_button_to "Logout", logout_path, method: :delete %>
196
+ <% else %>
197
+ <%= navigation_link_to "Login",
198
+ login_path,
199
+ controller: :sessions,
200
+ action: :new %>
201
+ <% end %>
202
+
203
+ <%# B4UM_NAVIGATION_LINKS %>
204
+ ERB
205
+
206
+ gsub_file(
207
+ navigation_path,
208
+ marker,
209
+ authentication_links.chomp
210
+ )
211
+ end
212
+
213
+ def protect_controllers
214
+ protected_controller_names.each do |controller_name|
215
+ controller_path = File.join(
216
+ "app/controllers",
217
+ "#{controller_name.underscore}_controller.rb"
218
+ )
219
+
220
+ full_controller_path = File.join(
221
+ destination_root,
222
+ controller_path
223
+ )
224
+
225
+ controller = File.read(full_controller_path)
226
+
227
+ next if controller.include?(
228
+ "before_action :require_login, except: [:index, :show]"
229
+ )
230
+
231
+ inject_into_file(
232
+ controller_path,
233
+ after: /^class .*Controller < ApplicationController\s*$/
234
+ ) do
235
+ "\n before_action :require_login, except: [:index, :show]"
236
+ end
237
+ end
238
+ end
239
+
240
+ def protect_show_actions
241
+ protected_controller_names.each do |controller_name|
242
+ protect_show_actions_for(controller_name)
243
+ end
244
+ end
245
+
246
+ def protect_index_actions
247
+ protected_controller_names.each do |controller_name|
248
+ protect_index_actions_for(controller_name)
249
+ end
250
+ end
251
+
252
+ private
253
+
254
+ def protect_index_actions_for(controller_name)
255
+ index_path = File.join(
256
+ "app/views",
257
+ controller_name.underscore,
258
+ "index.html.erb"
259
+ )
260
+
261
+ full_index_path = File.join(
262
+ destination_root,
263
+ index_path
264
+ )
265
+
266
+ return unless File.exist?(full_index_path)
267
+
268
+ content = File.read(full_index_path)
269
+
270
+ return if content.include?("<% if logged_in? %>")
271
+
272
+ pattern = /
273
+ ^[ \t]*<%=\s*link_to\s+"New\s+[^"]+",.*?%>
274
+ /mx
275
+
276
+ return unless content.match?(pattern)
277
+
278
+ wrap_protected_action(index_path, pattern)
279
+ end
280
+
281
+ def protect_show_actions_for(controller_name)
282
+ show_path = File.join(
283
+ "app/views",
284
+ controller_name.underscore,
285
+ "show.html.erb"
286
+ )
287
+
288
+ full_show_path = File.join(destination_root, show_path)
289
+
290
+ return unless File.exist?(full_show_path)
291
+
292
+ content = File.read(full_show_path)
293
+
294
+ return if content.include?("<% if logged_in? %>")
295
+
296
+ protect_show_edit_action(show_path, content)
297
+ protect_show_destroy_action(show_path, content)
298
+ end
299
+
300
+ def protect_show_edit_action(show_path, content)
301
+ pattern = /
302
+ ^[ \t]*<%=\s*link_to\s+"Edit",.*?%>
303
+ /mx
304
+
305
+ return unless content.match?(pattern)
306
+
307
+ wrap_protected_action(show_path, pattern)
308
+ end
309
+
310
+ def protect_show_destroy_action(show_path, content)
311
+ pattern = /
312
+ ^[ \t]*<%=\s*button_to\s+"Destroy",.*?%>
313
+ /mx
314
+
315
+ return unless content.match?(pattern)
316
+
317
+ wrap_protected_action(show_path, pattern)
318
+ end
319
+
320
+ def wrap_protected_action(view_path, pattern)
321
+ gsub_file(view_path, pattern) do |match|
322
+ lines = match.lines
323
+ indentation = lines.first[/\A\s*/]
324
+
325
+ indented_match = lines.map do |line|
326
+ "#{indentation} #{line.delete_prefix(indentation)}"
327
+ end.join
328
+
329
+ [
330
+ "#{indentation}<% if logged_in? %>",
331
+ indented_match.chomp,
332
+ "#{indentation}<% end %>"
333
+ ].join("\n")
334
+ end
335
+ end
336
+
337
+ def protected_controller_names
338
+ options[:protect]
339
+ .to_s
340
+ .split(",")
341
+ .map(&:strip)
342
+ .reject(&:empty?)
343
+ end
344
+
345
+ def authentication_class_name
346
+ authentication_model.to_s.classify
347
+ end
348
+
349
+ def authentication_model_name
350
+ authentication_model.to_s.underscore
351
+ end
352
+
353
+ def current_authentication_method
354
+ "current_#{authentication_model_name}"
355
+ end
356
+
357
+ def password_digest_column_defined?
358
+ password_digest_defined_in_schema? ||
359
+ password_digest_defined_in_migrations?
360
+ end
361
+
362
+ def password_digest_defined_in_schema?
363
+ schema_path = File.join(
364
+ destination_root,
365
+ "db/schema.rb"
366
+ )
367
+
368
+ return false unless File.exist?(schema_path)
369
+
370
+ schema = File.read(schema_path)
371
+ table_name = authentication_model_name.pluralize
372
+
373
+ table_match = schema.match(
374
+ /create_table\s+["']#{Regexp.escape(table_name)}["'].*?do\s+\|t\|(.*?)^\s*end/m
375
+ )
376
+
377
+ return false unless table_match
378
+
379
+ table_match[1].match?(
380
+ /t\.\w+\s+["']password_digest["']/
381
+ )
382
+ end
383
+
384
+ def password_digest_defined_in_migrations?
385
+ migration_paths = Dir.glob(
386
+ File.join(
387
+ destination_root,
388
+ "db/migrate/*.rb"
389
+ )
390
+ )
391
+
392
+ table_name = authentication_model_name.pluralize
393
+
394
+ migration_paths.any? do |migration_path|
395
+ migration = File.read(migration_path)
396
+
397
+ references_table =
398
+ migration.match?(
399
+ /create_table\s+[:'"]#{Regexp.escape(table_name)}['"]?/
400
+ ) ||
401
+ migration.match?(
402
+ /change_table\s+[:'"]#{Regexp.escape(table_name)}['"]?/
403
+ ) ||
404
+ migration.match?(
405
+ /add_column\s+[:'"]#{Regexp.escape(table_name)}['"]?/
406
+ )
407
+
408
+ references_table &&
409
+ migration.match?(/\bpassword_digest\b/)
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,35 @@
1
+ <%% content_for :title, "Login" %>
2
+
3
+ <div class="page-header">
4
+ <h1>Login</h1>
5
+ </div>
6
+
7
+ <div class="b4um-grid">
8
+ <section class="b4um-card b4um-card--full">
9
+ <h2 class="b4um-card__title">
10
+ Sign in
11
+ </h2>
12
+
13
+ <%%= form_with url: login_path, class: "form" do |form| %>
14
+ <div class="form-field">
15
+ <%%= form.email_field :email,
16
+ class: "form-input",
17
+ placeholder: " ",
18
+ required: true %>
19
+ <%%= form.label :email, "Email", class: "form-label" %>
20
+ </div>
21
+
22
+ <div class="form-field">
23
+ <%%= form.password_field :password,
24
+ class: "form-input",
25
+ placeholder: " ",
26
+ required: true %>
27
+ <%%= form.label :password, "Password", class: "form-label" %>
28
+ </div>
29
+
30
+ <div class="form-actions">
31
+ <%%= form.submit "Login", class: "form-submit" %>
32
+ </div>
33
+ <%% end %>
34
+ </section>
35
+ </div>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SessionsController < ApplicationController
4
+ def new
5
+ end
6
+
7
+ def create
8
+ <%= authentication_model_name %> = <%= authentication_class_name %>.find_by(email: params[:email])
9
+
10
+ if <%= authentication_model_name %>&.authenticate(params[:password])
11
+ session[:<%= authentication_model_name %>_id] = <%= authentication_model_name %>.id
12
+
13
+ redirect_to root_path, notice: "Successfully logged in."
14
+ else
15
+ flash.now[:alert] = "Invalid email or password."
16
+ render :new, status: :unprocessable_content
17
+ end
18
+ end
19
+
20
+ def destroy
21
+ session.delete(:<%= authentication_model_name %>_id)
22
+
23
+ redirect_to root_path, notice: "Successfully logged out."
24
+ end
25
+ end