b4um_generators 0.1.1 → 0.2.8

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,769 @@
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
+ if navigation.include?(
186
+ 'navigation_button_to "Logout", logout_path, method: :delete'
187
+ )
188
+ add_register_navigation_link(
189
+ navigation_path,
190
+ navigation
191
+ )
192
+ return
193
+ end
194
+
195
+ marker = "<%# B4UM_NAVIGATION_LINKS %>"
196
+
197
+ return unless navigation.include?(marker)
198
+
199
+ controller_name = authentication_model_name.pluralize
200
+ new_resource_path =
201
+ "new_#{authentication_model_name}_path"
202
+
203
+ authentication_links = <<~ERB
204
+ <% if logged_in? %>
205
+ <%= navigation_button_to "Logout", logout_path, method: :delete %>
206
+ <% else %>
207
+ <%= navigation_link_to "Register",
208
+ #{new_resource_path},
209
+ controller: :#{controller_name},
210
+ action: :new %>
211
+ <%= navigation_link_to "Login",
212
+ login_path,
213
+ controller: :sessions,
214
+ action: :new %>
215
+ <% end %>
216
+
217
+ <%# B4UM_NAVIGATION_LINKS %>
218
+ ERB
219
+
220
+ gsub_file(
221
+ navigation_path,
222
+ marker,
223
+ authentication_links.chomp
224
+ )
225
+ end
226
+
227
+ def protect_authentication_resource
228
+ controller_path = authentication_resource_controller_path
229
+ full_controller_path = File.join(
230
+ destination_root,
231
+ controller_path
232
+ )
233
+
234
+ return unless File.exist?(full_controller_path)
235
+
236
+ add_authentication_resource_callbacks(controller_path)
237
+ add_authentication_resource_authorization(controller_path)
238
+ redirect_destroyed_authentication_resource_to_root(controller_path)
239
+ end
240
+
241
+ def sign_in_after_registration
242
+ controller_path = authentication_resource_controller_path
243
+
244
+ full_controller_path = File.join(
245
+ destination_root,
246
+ controller_path
247
+ )
248
+
249
+ return unless File.exist?(full_controller_path)
250
+
251
+ controller = File.read(full_controller_path)
252
+
253
+ session_assignment =
254
+ "session[:#{authentication_model_name}_id] = " \
255
+ "@#{authentication_model_name}.id"
256
+
257
+ return if controller.include?(session_assignment)
258
+
259
+ save_pattern =
260
+ /^(\s*)if @#{Regexp.escape(authentication_model_name)}\.save\s*$/
261
+
262
+ return unless controller.match?(save_pattern)
263
+
264
+ inject_into_file(
265
+ controller_path,
266
+ after: save_pattern
267
+ ) do
268
+ "\n\\1 #{session_assignment}"
269
+ end
270
+ end
271
+
272
+ def add_profile_navigation
273
+ navigation_path = "app/views/shared/_navigation.html.erb"
274
+
275
+ full_navigation_path = File.join(
276
+ destination_root,
277
+ navigation_path
278
+ )
279
+
280
+ return unless File.exist?(full_navigation_path)
281
+
282
+ navigation = File.read(full_navigation_path)
283
+
284
+ resource_name = authentication_class_name.pluralize
285
+ controller_name = authentication_model_name.pluralize
286
+
287
+ resource_pattern = /
288
+ ^[ \t]*<%=\s*navigation_link_to\s+"#{Regexp.escape(resource_name)}",
289
+ .*?
290
+ controller:\s*:#{Regexp.escape(controller_name)}
291
+ \s*%>\s*
292
+ /mx
293
+
294
+ if navigation.match?(resource_pattern)
295
+ gsub_file(
296
+ navigation_path,
297
+ resource_pattern,
298
+ ""
299
+ )
300
+ end
301
+
302
+ navigation = File.read(full_navigation_path)
303
+
304
+ return if navigation.include?(
305
+ 'navigation_link_to "Profile"'
306
+ )
307
+
308
+ login_marker = "<% if logged_in? %>"
309
+
310
+ return unless navigation.include?(login_marker)
311
+
312
+ profile_link = <<~ERB
313
+ <% if logged_in? %>
314
+ <%= navigation_link_to "Profile",
315
+ #{current_authentication_method},
316
+ controller: :#{controller_name},
317
+ action: :show %>
318
+ ERB
319
+
320
+ gsub_file(
321
+ navigation_path,
322
+ login_marker,
323
+ profile_link.chomp
324
+ )
325
+ end
326
+
327
+ def protect_controllers
328
+ protected_controller_names.each do |controller_name|
329
+ controller_path = File.join(
330
+ "app/controllers",
331
+ "#{controller_name.underscore}_controller.rb"
332
+ )
333
+
334
+ full_controller_path = File.join(
335
+ destination_root,
336
+ controller_path
337
+ )
338
+
339
+ controller = File.read(full_controller_path)
340
+
341
+ next if controller.include?(
342
+ "before_action :require_login, except: [:index, :show]"
343
+ )
344
+
345
+ inject_into_file(
346
+ controller_path,
347
+ after: /^class .*Controller < ApplicationController\s*$/
348
+ ) do
349
+ "\n before_action :require_login, except: [:index, :show]"
350
+ end
351
+ end
352
+ end
353
+
354
+ def protect_show_actions
355
+ protected_controller_names.each do |controller_name|
356
+ protect_show_actions_for(controller_name)
357
+ end
358
+ end
359
+
360
+ def protect_index_actions
361
+ protected_controller_names.each do |controller_name|
362
+ protect_index_actions_for(controller_name)
363
+ end
364
+ end
365
+
366
+ private
367
+
368
+ def add_register_navigation_link(navigation_path, navigation)
369
+ return if navigation.include?(
370
+ 'navigation_link_to "Register",'
371
+ )
372
+
373
+ controller_name = authentication_model_name.pluralize
374
+ new_resource_path =
375
+ "new_#{authentication_model_name}_path"
376
+
377
+ login_pattern = /
378
+ ^([ \t]*)<%=\s*navigation_link_to\s+"Login",
379
+ /x
380
+
381
+ return unless navigation.match?(login_pattern)
382
+
383
+ register_link = <<~ERB
384
+ <%= navigation_link_to "Register",
385
+ #{new_resource_path},
386
+ controller: :#{controller_name},
387
+ action: :new %>
388
+ ERB
389
+
390
+ gsub_file(
391
+ navigation_path,
392
+ login_pattern
393
+ ) do |match|
394
+ indentation = match[/\A[ \t]*/]
395
+
396
+ indented_register = register_link.lines.map do |line|
397
+ "#{indentation}#{line}"
398
+ end.join
399
+
400
+ "#{indented_register}#{match}"
401
+ end
402
+ end
403
+
404
+ def authentication_resource_controller_path
405
+ File.join(
406
+ "app/controllers",
407
+ "#{authentication_model_name.pluralize}_controller.rb"
408
+ )
409
+ end
410
+
411
+ def redirect_destroyed_authentication_resource_to_root(controller_path)
412
+ resource_path =
413
+ "#{authentication_model_name.pluralize}_path"
414
+
415
+ redirect =
416
+ "redirect_to #{resource_path}, status: :see_other"
417
+
418
+ return unless controller_contains?(controller_path, redirect)
419
+
420
+ gsub_file(
421
+ controller_path,
422
+ redirect,
423
+ "redirect_to root_path, status: :see_other"
424
+ )
425
+ end
426
+
427
+ def add_authentication_resource_callbacks(controller_path)
428
+ add_require_login_callback(controller_path)
429
+ add_prevent_authentication_index_callback(controller_path)
430
+ add_require_current_authentication_callback(controller_path)
431
+ add_clear_authentication_session_callback(controller_path)
432
+ end
433
+
434
+ def add_require_login_callback(controller_path)
435
+ callback = "before_action :require_login, except: %i[ new create ]"
436
+
437
+ return if controller_contains?(controller_path, callback)
438
+
439
+ inject_into_file(
440
+ controller_path,
441
+ after: /^class .*Controller < ApplicationController\s*$/
442
+ ) do
443
+ "\n #{callback}"
444
+ end
445
+ end
446
+
447
+ def add_prevent_authentication_index_callback(controller_path)
448
+ callback =
449
+ "before_action :prevent_authentication_index, only: :index"
450
+
451
+ return if controller_contains?(controller_path, callback)
452
+
453
+ inject_into_file(
454
+ controller_path,
455
+ after: /^\s*before_action :require_login, except: %i\[ new create \]\s*$/
456
+ ) do
457
+ "\n #{callback}"
458
+ end
459
+ end
460
+
461
+ def add_require_current_authentication_callback(controller_path)
462
+ callback =
463
+ "before_action :require_current_#{authentication_model_name}, " \
464
+ "only: %i[ show edit update destroy ]"
465
+
466
+ return if controller_contains?(controller_path, callback)
467
+
468
+ set_callback =
469
+ /^\s*before_action :set_#{authentication_model_name}, only: %i\[ show edit update destroy \]\s*$/
470
+
471
+ if controller_matches?(controller_path, set_callback)
472
+ inject_into_file(
473
+ controller_path,
474
+ after: set_callback
475
+ ) do
476
+ "\n #{callback}"
477
+ end
478
+ else
479
+ inject_into_file(
480
+ controller_path,
481
+ after: /^class .*Controller < ApplicationController\s*$/
482
+ ) do
483
+ "\n #{callback}"
484
+ end
485
+ end
486
+ end
487
+
488
+ def add_clear_authentication_session_callback(controller_path)
489
+ callback =
490
+ "after_action :clear_authentication_session, only: :destroy"
491
+
492
+ return if controller_contains?(controller_path, callback)
493
+
494
+ current_user_callback =
495
+ /^\s*before_action :require_current_#{authentication_model_name}, only: %i\[ show edit update destroy \]\s*$/
496
+
497
+ inject_into_file(
498
+ controller_path,
499
+ after: current_user_callback
500
+ ) do
501
+ "\n #{callback}"
502
+ end
503
+ end
504
+
505
+ def add_authentication_resource_authorization(controller_path)
506
+ add_prevent_authentication_index_method(controller_path)
507
+ add_require_current_authentication_method(controller_path)
508
+ add_clear_authentication_session_method(controller_path)
509
+ end
510
+
511
+ def add_prevent_authentication_index_method(controller_path)
512
+ method_name = "def prevent_authentication_index"
513
+
514
+ return if controller_contains?(controller_path, method_name)
515
+
516
+ inject_private_method(
517
+ controller_path,
518
+ <<~RUBY
519
+ def prevent_authentication_index
520
+ redirect_to #{current_authentication_method}
521
+ end
522
+ RUBY
523
+ )
524
+ end
525
+
526
+ def add_require_current_authentication_method(controller_path)
527
+ method_name =
528
+ "def require_current_#{authentication_model_name}"
529
+
530
+ return if controller_contains?(controller_path, method_name)
531
+
532
+ inject_private_method(
533
+ controller_path,
534
+ <<~RUBY
535
+ def require_current_#{authentication_model_name}
536
+ return if @#{authentication_model_name} == #{current_authentication_method}
537
+
538
+ redirect_to root_path, alert: "Access denied."
539
+ end
540
+ RUBY
541
+ )
542
+ end
543
+
544
+ def add_clear_authentication_session_method(controller_path)
545
+ method_name = "def clear_authentication_session"
546
+
547
+ return if controller_contains?(controller_path, method_name)
548
+
549
+ inject_private_method(
550
+ controller_path,
551
+ <<~RUBY
552
+ def clear_authentication_session
553
+ session.delete(:#{authentication_model_name}_id)
554
+ end
555
+ RUBY
556
+ )
557
+ end
558
+
559
+ def inject_private_method(controller_path, method_body)
560
+ full_controller_path = File.join(
561
+ destination_root,
562
+ controller_path
563
+ )
564
+
565
+ content = File.read(full_controller_path)
566
+
567
+ class_end_position = content.rindex(/^end\s*$/)
568
+
569
+ unless class_end_position
570
+ raise Thor::Error,
571
+ "Could not find the closing class end in #{controller_path}"
572
+ end
573
+
574
+ indented_body = method_body.lines.map do |line|
575
+ line.strip.empty? ? "\n" : " #{line}"
576
+ end.join
577
+
578
+ updated_content = content.dup
579
+
580
+ updated_content.insert(
581
+ class_end_position,
582
+ "\n#{indented_body}"
583
+ )
584
+
585
+ File.write(
586
+ full_controller_path,
587
+ updated_content
588
+ )
589
+ end
590
+
591
+ def controller_contains?(controller_path, content)
592
+ full_controller_path = File.join(
593
+ destination_root,
594
+ controller_path
595
+ )
596
+
597
+ File.read(full_controller_path).include?(content)
598
+ end
599
+
600
+ def controller_matches?(controller_path, pattern)
601
+ full_controller_path = File.join(
602
+ destination_root,
603
+ controller_path
604
+ )
605
+
606
+ File.read(full_controller_path).match?(pattern)
607
+ end
608
+
609
+ def protect_index_actions_for(controller_name)
610
+ index_path = File.join(
611
+ "app/views",
612
+ controller_name.underscore,
613
+ "index.html.erb"
614
+ )
615
+
616
+ full_index_path = File.join(
617
+ destination_root,
618
+ index_path
619
+ )
620
+
621
+ return unless File.exist?(full_index_path)
622
+
623
+ content = File.read(full_index_path)
624
+
625
+ return if content.include?("<% if logged_in? %>")
626
+
627
+ pattern = /
628
+ ^[ \t]*<%=\s*link_to\s+"New\s+[^"]+",.*?%>
629
+ /mx
630
+
631
+ return unless content.match?(pattern)
632
+
633
+ wrap_protected_action(index_path, pattern)
634
+ end
635
+
636
+ def protect_show_actions_for(controller_name)
637
+ show_path = File.join(
638
+ "app/views",
639
+ controller_name.underscore,
640
+ "show.html.erb"
641
+ )
642
+
643
+ full_show_path = File.join(destination_root, show_path)
644
+
645
+ return unless File.exist?(full_show_path)
646
+
647
+ content = File.read(full_show_path)
648
+
649
+ return if content.include?("<% if logged_in? %>")
650
+
651
+ protect_show_edit_action(show_path, content)
652
+ protect_show_destroy_action(show_path, content)
653
+ end
654
+
655
+ def protect_show_edit_action(show_path, content)
656
+ pattern = /
657
+ ^[ \t]*<%=\s*link_to\s+"Edit",.*?%>
658
+ /mx
659
+
660
+ return unless content.match?(pattern)
661
+
662
+ wrap_protected_action(show_path, pattern)
663
+ end
664
+
665
+ def protect_show_destroy_action(show_path, content)
666
+ pattern = /
667
+ ^[ \t]*<%=\s*button_to\s+"Destroy",.*?%>
668
+ /mx
669
+
670
+ return unless content.match?(pattern)
671
+
672
+ wrap_protected_action(show_path, pattern)
673
+ end
674
+
675
+ def wrap_protected_action(view_path, pattern)
676
+ gsub_file(view_path, pattern) do |match|
677
+ lines = match.lines
678
+ indentation = lines.first[/\A\s*/]
679
+
680
+ indented_match = lines.map do |line|
681
+ "#{indentation} #{line.delete_prefix(indentation)}"
682
+ end.join
683
+
684
+ [
685
+ "#{indentation}<% if logged_in? %>",
686
+ indented_match.chomp,
687
+ "#{indentation}<% end %>"
688
+ ].join("\n")
689
+ end
690
+ end
691
+
692
+ def protected_controller_names
693
+ options[:protect]
694
+ .to_s
695
+ .split(",")
696
+ .map(&:strip)
697
+ .reject(&:empty?)
698
+ end
699
+
700
+ def authentication_class_name
701
+ authentication_model.to_s.classify
702
+ end
703
+
704
+ def authentication_model_name
705
+ authentication_model.to_s.underscore
706
+ end
707
+
708
+ def current_authentication_method
709
+ "current_#{authentication_model_name}"
710
+ end
711
+
712
+ def password_digest_column_defined?
713
+ password_digest_defined_in_schema? ||
714
+ password_digest_defined_in_migrations?
715
+ end
716
+
717
+ def password_digest_defined_in_schema?
718
+ schema_path = File.join(
719
+ destination_root,
720
+ "db/schema.rb"
721
+ )
722
+
723
+ return false unless File.exist?(schema_path)
724
+
725
+ schema = File.read(schema_path)
726
+ table_name = authentication_model_name.pluralize
727
+
728
+ table_match = schema.match(
729
+ /create_table\s+["']#{Regexp.escape(table_name)}["'].*?do\s+\|t\|(.*?)^\s*end/m
730
+ )
731
+
732
+ return false unless table_match
733
+
734
+ table_match[1].match?(
735
+ /t\.\w+\s+["']password_digest["']/
736
+ )
737
+ end
738
+
739
+ def password_digest_defined_in_migrations?
740
+ migration_paths = Dir.glob(
741
+ File.join(
742
+ destination_root,
743
+ "db/migrate/*.rb"
744
+ )
745
+ )
746
+
747
+ table_name = authentication_model_name.pluralize
748
+
749
+ migration_paths.any? do |migration_path|
750
+ migration = File.read(migration_path)
751
+
752
+ references_table =
753
+ migration.match?(
754
+ /create_table\s+[:'"]#{Regexp.escape(table_name)}['"]?/
755
+ ) ||
756
+ migration.match?(
757
+ /change_table\s+[:'"]#{Regexp.escape(table_name)}['"]?/
758
+ ) ||
759
+ migration.match?(
760
+ /add_column\s+[:'"]#{Regexp.escape(table_name)}['"]?/
761
+ )
762
+
763
+ references_table &&
764
+ migration.match?(/\bpassword_digest\b/)
765
+ end
766
+ end
767
+ end
768
+ end
769
+ end