maquina-generators 0.8.1 → 0.9.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/Rakefile +1 -1
  4. data/lib/generators/maquina/app/USAGE +2 -0
  5. data/lib/generators/maquina/app/app_generator.rb +31 -0
  6. data/lib/generators/maquina/app/templates/README.md.erb +6 -0
  7. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +3 -0
  8. data/lib/maquina_generators/version.rb +1 -1
  9. data/test/generators/maquina/app_generator_test.rb +41 -0
  10. data/test/tmp/Gemfile +1 -2
  11. data/test/tmp/app/controllers/application_controller.rb +3 -0
  12. data/test/tmp/app/controllers/concerns/authentication.rb +61 -0
  13. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
  14. data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
  15. data/test/tmp/app/controllers/registrations_controller.rb +63 -0
  16. data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
  17. data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
  18. data/test/tmp/app/controllers/sessions_controller.rb +56 -0
  19. data/test/tmp/app/helpers/authentication_helper.rb +20 -0
  20. data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
  21. data/test/tmp/app/mailers/verification_mailer.rb +15 -0
  22. data/test/tmp/app/models/account.rb +5 -0
  23. data/test/tmp/app/models/current.rb +5 -0
  24. data/test/tmp/app/models/email_verification.rb +56 -0
  25. data/test/tmp/app/models/session.rb +18 -0
  26. data/test/tmp/app/models/user.rb +0 -10
  27. data/test/tmp/app/views/registration/verifications/new.html.erb +43 -0
  28. data/test/tmp/app/views/registrations/new.html.erb +39 -0
  29. data/test/tmp/app/views/session/verifications/new.html.erb +43 -0
  30. data/test/tmp/app/views/sessions/new.html.erb +5 -2
  31. data/test/tmp/app/views/verification_mailer/verification_code.html.erb +37 -0
  32. data/test/tmp/app/views/verification_mailer/verification_code.text.erb +11 -0
  33. data/test/tmp/config/locales/clave.en.yml +102 -0
  34. data/test/tmp/config/locales/clave.es.yml +102 -0
  35. data/test/tmp/config/routes.rb +9 -6
  36. data/test/tmp/db/migrate/20261019191386_create_accounts.rb +9 -0
  37. data/test/tmp/db/migrate/20261019191387_create_users.rb +17 -0
  38. data/test/tmp/db/migrate/20261019191388_create_sessions.rb +12 -0
  39. data/test/tmp/db/migrate/20261019191389_create_email_verifications.rb +17 -0
  40. data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
  41. metadata +26 -21
  42. data/test/tmp/app/controllers/concerns/passkey_challenge.rb +0 -42
  43. data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +0 -26
  44. data/test/tmp/app/controllers/passkey_challenges_controller.rb +0 -24
  45. data/test/tmp/app/controllers/passkeys_controller.rb +0 -68
  46. data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +0 -18
  47. data/test/tmp/app/controllers/session/passkeys_controller.rb +0 -55
  48. data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +0 -109
  49. data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +0 -113
  50. data/test/tmp/app/models/passkey.rb +0 -36
  51. data/test/tmp/app/views/passkeys/index.html.erb +0 -77
  52. data/test/tmp/app/views/sessions/_passkey.html.erb +0 -23
  53. data/test/tmp/config/initializers/rack_attack.rb +0 -9
  54. data/test/tmp/config/initializers/webauthn.rb +0 -23
  55. data/test/tmp/config/locales/passkeys.en.yml +0 -46
  56. data/test/tmp/config/locales/passkeys.es.yml +0 -46
  57. data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +0 -6
  58. data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +0 -20
  59. data/test/tmp/test/integration/passkeys_test.rb +0 -99
  60. data/test/tmp/test/test_helper.rb +0 -4
  61. data/test/tmp/test/test_helpers/webauthn_test_helper.rb +0 -38
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e7ab2221409345c84231d6908e18f5f14b265d32a10229f288f23d4c5a03006
4
- data.tar.gz: b4966e9de4099d0664da18fd6ba2818b560770cdb2dab121a9f06265e38b4769
3
+ metadata.gz: 76d3dc4690cd7ff89b935e2289d7032084e426bda2e2f2998ed0aa81c0bb5fef
4
+ data.tar.gz: d983ec5b7183219b998a4447215808edf1d492c01cd344c957e9e9674036a3c7
5
5
  SHA512:
6
- metadata.gz: f7b0d031778c2aa4b8cc03079e08f049a79d499500dfe0bb8b44b5e94e7743afdfc0ea6d0625d97b0128c937628980c9eb5317eef5bfe9ea62d68cd25f3e0487
7
- data.tar.gz: 2ccfde4751e49534fb7c36149c623ea8fd93849a8e26e860fae0566556f2832173f8f488b708dca6e23f8890e8a6b4243571e61c6811723a325c1535f2a5dde5
6
+ metadata.gz: ea8c573e01d55f1504c0d1b647e805847f67c48042b13580cfec8e301b61151f6ebecb8f3c645a57d937168c48b33f72b1e4e8f35ced0c0bd3a5c36e79d105cd
7
+ data.tar.gz: 329c7a172d226fc0bca18c5cc807c869dd1a3937bb9e6d2f66d315c9ac0b41ef082e44159ae7e2c378cf97f8cfeada7d8cadd9a90afb8465aad0e087101a03b7
data/README.md CHANGED
@@ -423,7 +423,7 @@ Watch the Security page while the policy is report-only, then set `content_secur
423
423
  9. Adds turbo morphing, `yield :head`, and simplifies `<main>` tag in layout
424
424
  10. Optionally installs authentication (`maquina:clave` or `maquina:registration`)
425
425
  11. Invokes sub-generators: `maquina:mission_control_jobs`, `maquina:solid_errors`
426
- 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`, then `maquina:security` (which installs the `maquina:rack_attack` rules)
426
+ 12. Runs external installers: `solid_queue:install`, `solid_errors:install`, `solid_cache:install`, `solid_cable:install`, `maquina_components:install`, then `maquina:security` (which installs the `maquina:rack_attack` rules), and `maquina:passkeys` with `--passkeys`
427
427
  13. Restores custom layouts overwritten by gem installers
428
428
  14. Configures multi-database `database.yml` (primary, queue, cache, cable, errors, security)
429
429
  15. Creates a HomeController with root route
@@ -436,6 +436,7 @@ Watch the Security page while the policy is report-only, then set `content_secur
436
436
  rails g maquina:app
437
437
  rails g maquina:app --prefix /backstage --port 3100
438
438
  rails g maquina:app --auth registration
439
+ rails g maquina:app --auth clave --passkeys
439
440
  ```
440
441
 
441
442
  #### Options
@@ -443,6 +444,7 @@ rails g maquina:app --auth registration
443
444
  - `--prefix` (default: `/admin`) -- Base path prefix for backstage tools (Solid Errors, Mission Control Jobs, Security)
444
445
  - `--port` (default: `3000`) -- Default port for the development server
445
446
  - `--auth` (default: `none`) -- Authentication type: `none`, `clave`, or `registration`
447
+ - `--passkeys` (default: `false`) -- Adds passkey sign-in with `maquina:passkeys`. It needs `--auth clave` and refuses to run without it. The passkey origin follows `--port`, and the home page links to `/passkeys`.
446
448
 
447
449
  #### After running
448
450
 
data/Rakefile CHANGED
@@ -4,7 +4,7 @@ require "rake/testtask"
4
4
  Rake::TestTask.new(:test) do |t|
5
5
  t.libs << "test"
6
6
  t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
7
+ t.test_files = FileList["test/**/*_test.rb"].exclude("test/tmp/**/*")
8
8
  end
9
9
 
10
10
  task default: :test
@@ -9,3 +9,5 @@ Examples:
9
9
  rails g maquina:app
10
10
 
11
11
  rails g maquina:app --prefix /backstage --port 3100
12
+
13
+ rails g maquina:app --auth clave --passkeys
@@ -11,6 +11,15 @@ module Maquina
11
11
  desc: "Default port for development server"
12
12
  class_option :auth, type: :string, default: "none",
13
13
  desc: "Authentication type: none, clave, or registration"
14
+ class_option :passkeys, type: :boolean, default: false,
15
+ desc: "Add passkey sign-in on top of clave (requires --auth clave)"
16
+
17
+ # 0. Check options before touching anything
18
+ def check_options
19
+ return unless options[:passkeys] && options[:auth] != "clave"
20
+
21
+ raise Thor::Error, "--passkeys builds on clave. Run with `--auth clave --passkeys`."
22
+ end
14
23
 
15
24
  # 1. Add gems
16
25
  def add_gems
@@ -215,6 +224,24 @@ module Maquina
215
224
  generate "maquina:security", "--prefix", options[:prefix], "--quiet"
216
225
  end
217
226
 
227
+ # 12b. Install passkeys (after security, so its Rack::Attack throttle lands)
228
+ def install_passkeys
229
+ return unless options[:passkeys] && rails_app?
230
+
231
+ generate "maquina:passkeys", "--quiet"
232
+ end
233
+
234
+ # 12c. Point the passkey origin at the development port
235
+ def configure_passkeys_origin
236
+ return unless options[:passkeys] && options[:port] != "3000"
237
+
238
+ initializer = File.join(destination_root, "config/initializers/webauthn.rb")
239
+ return unless File.exist?(initializer)
240
+
241
+ gsub_file "config/initializers/webauthn.rb",
242
+ '"http://localhost:3000"', %("http://localhost:#{options[:port]}")
243
+ end
244
+
218
245
  # 13. Restore custom layouts overwritten by gem installers
219
246
  def restore_custom_layouts
220
247
  return unless rails_app?
@@ -339,6 +366,10 @@ module Maquina
339
366
  say " - Visit /session/new to sign in"
340
367
  say " - Visit /registrations/new to sign up" if options[:auth] == "registration"
341
368
  say " - Visit /registration/new to sign up" if options[:auth] == "clave"
369
+ if options[:passkeys]
370
+ say " - Visit /passkeys to add a passkey"
371
+ say " - Production: set WEBAUTHN_RP_ID and WEBAUTHN_ORIGINS before the first passkey"
372
+ end
342
373
  say " - Customize: app/controllers/concerns/authentication.rb"
343
374
  say " - Locales: config/locales/"
344
375
  end
@@ -26,6 +26,9 @@ Your app will be running at **http://localhost:<%= options[:port] %>**.
26
26
  <% if options[:auth] != "none" -%>
27
27
  | **Authentication** | <%= options[:auth] == "clave" ? "Clave passwordless email-code authentication" : "Registration-based email/password authentication" %> |
28
28
  <% end -%>
29
+ <% if options[:passkeys] -%>
30
+ | **Passkeys** | Passkey sign-in with Face ID, a fingerprint or a device PIN; the email code stays as the way in without one |
31
+ <% end -%>
29
32
  | **Background Jobs** | Solid Queue with Mission Control Jobs dashboard |
30
33
  | **Error Tracking** | Solid Errors with custom UI |
31
34
  | **Rate Limiting** | Rack::Attack with sensible defaults |
@@ -52,6 +55,9 @@ Your app will be running at **http://localhost:<%= options[:port] %>**.
52
55
  <% elsif options[:auth] == "clave" -%>
53
56
  | http://localhost:<%= options[:port] %>/registration/new | Sign up |
54
57
  <% end -%>
58
+ <% if options[:passkeys] -%>
59
+ | http://localhost:<%= options[:port] %>/passkeys | Passkeys |
60
+ <% end -%>
55
61
  <% end -%>
56
62
 
57
63
  ## Scripts
@@ -15,6 +15,9 @@
15
15
  <p class="text-sm text-muted-foreground">
16
16
  Signed in as <span class="font-medium text-foreground"><%%= Current.user.email_address %></span>
17
17
  </p>
18
+ <% if options[:passkeys] -%>
19
+ <%%= link_to "Passkeys", passkeys_path, data: { component: "button", variant: "outline", size: "sm" } %>
20
+ <% end -%>
18
21
  <%%= button_to t(".sign_out", default: "Sign Out"), session_path, method: :delete, data: { component: "button", variant: "outline", size: "sm" } %>
19
22
  </div>
20
23
  <%% else %>
@@ -1,3 +1,3 @@
1
1
  module MaquinaGenerators
2
- VERSION = "0.8.1"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -364,6 +364,47 @@ class Maquina::Generators::AppGeneratorTest < Rails::Generators::TestCase
364
364
  assert_match(/registrations\/new/, output)
365
365
  end
366
366
 
367
+ test "passkeys need clave" do
368
+ %w[none registration].each do |auth|
369
+ error = assert_raises(Thor::Error) { run_generator ["--auth", auth, "--passkeys"], debug: true }
370
+ assert_match(/--auth clave --passkeys/, error.message)
371
+ end
372
+ assert_no_file "Procfile.dev"
373
+ end
374
+
375
+ test "passkeys show up in the home page, README and post-install" do
376
+ output = run_generator %w[--auth clave --passkeys]
377
+
378
+ assert_file "app/views/home/index.html.erb", /link_to "Passkeys", passkeys_path/
379
+ assert_file "README.md" do |content|
380
+ assert_match(/\*\*Passkeys\*\*/, content)
381
+ assert_match(%r{localhost:3000/passkeys \| Passkeys}, content)
382
+ end
383
+ assert_match(%r{Visit /passkeys to add a passkey}, output)
384
+ assert_match(/WEBAUTHN_RP_ID and WEBAUTHN_ORIGINS/, output)
385
+ end
386
+
387
+ test "without passkeys nothing mentions them" do
388
+ output = run_generator %w[--auth clave]
389
+
390
+ assert_file "app/views/home/index.html.erb" do |content|
391
+ assert_no_match(/passkeys_path/, content)
392
+ end
393
+ assert_file "README.md" do |content|
394
+ assert_no_match(/Passkeys/, content)
395
+ end
396
+ assert_no_match(/passkey/, output)
397
+ end
398
+
399
+ test "points the passkey origin at a custom development port" do
400
+ initializer = File.join(destination_root, "config/initializers/webauthn.rb")
401
+ File.write(initializer, %(config.allowed_origins = Array(ENV["WEBAUTHN_ORIGINS"]&.split(",") || "http://localhost:3000")\n))
402
+
403
+ run_generator %w[--auth clave --passkeys --port 3100]
404
+
405
+ assert_match(/"http:\/\/localhost:3100"\)\n\z/, File.read(initializer))
406
+ end
407
+
367
408
  test "injects database.yml restore into bin/setup" do
368
409
  write_bin_setup
369
410
 
data/test/tmp/Gemfile CHANGED
@@ -1,3 +1,2 @@
1
1
  source "https://rubygems.org"
2
-
3
- gem "webauthn", "~> 3.4"
2
+ # gem "bcrypt"
@@ -0,0 +1,3 @@
1
+ class ApplicationController < ActionController::Base
2
+ include Authentication
3
+ end
@@ -1,2 +1,63 @@
1
1
  module Authentication
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_action :require_authentication
6
+ helper_method :authenticated?, :session_expires_at
7
+ end
8
+
9
+ class_methods do
10
+ def allow_unauthenticated_access(**options)
11
+ skip_before_action :require_authentication, **options
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def authenticated?
18
+ resume_session
19
+ end
20
+
21
+ def require_authentication
22
+ resume_session || request_authentication
23
+ end
24
+
25
+ def resume_session
26
+ Current.session ||= find_session_by_cookie
27
+ end
28
+
29
+ def find_session_by_cookie
30
+ return nil unless cookies.signed[:session_id]
31
+
32
+ Session.active.find_by(id: cookies.signed[:session_id])
33
+ end
34
+
35
+ def request_authentication
36
+ session[:return_to_after_authenticating] = request.url
37
+ redirect_to new_session_path
38
+ end
39
+
40
+ def after_authentication_url
41
+ session.delete(:return_to_after_authenticating) || root_url
42
+ end
43
+
44
+ def start_new_session_for(user)
45
+ user.sessions.create!(
46
+ user_agent: request.user_agent,
47
+ ip_address: request.remote_ip,
48
+ expires_at: 30.days.from_now
49
+ ).tap do |session|
50
+ Current.session = session
51
+ cookies.signed.permanent[:session_id] = {value: session.id, httponly: true, same_site: :lax}
52
+ end
53
+ end
54
+
55
+ def terminate_session
56
+ Current.session.destroy
57
+ cookies.delete(:session_id)
58
+ end
59
+
60
+ def session_expires_at
61
+ Current.session&.expires_at
62
+ end
2
63
  end
@@ -0,0 +1,38 @@
1
+ class Registration::VerificationResendsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_verification_path, alert: t("flash.general.rate_limited") }
4
+
5
+ # POST /registration/verification_resend
6
+ def create
7
+ old_verification = EmailVerification.find_by(id: session[:pending_verification_id])
8
+
9
+ unless old_verification
10
+ redirect_to new_registration_path
11
+ return
12
+ end
13
+
14
+ email = old_verification.email
15
+
16
+ if EmailVerification.for_email(email).recent.exists?
17
+ recent = EmailVerification.for_email(email).recent.first
18
+ redirect_to new_registration_verification_path,
19
+ alert: t("flash.registrations.cooldown", minutes: recent.minutes_until_resend)
20
+ return
21
+ end
22
+
23
+ verification = EmailVerification.create!(
24
+ email: email,
25
+ code: SecureRandom.hex(3).upcase,
26
+ verification_type: "signup",
27
+ locale: old_verification.locale || I18n.locale.to_s,
28
+ expires_at: 15.minutes.from_now
29
+ )
30
+
31
+ Rails.logger.info "[AUTH] Verification code resent for registration #{email} from #{request.remote_ip}"
32
+ VerificationMailer.verification_code(verification).deliver_later
33
+ session[:pending_verification_id] = verification.id
34
+
35
+ redirect_to new_registration_verification_path,
36
+ notice: t("flash.registrations.code_resent")
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ class Registration::VerificationsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_verification_path, alert: t("flash.general.rate_limited") }
4
+
5
+ # GET /registration/verification/new
6
+ def new
7
+ @verification = EmailVerification.find_by(id: session[:pending_verification_id])
8
+ @email = @verification&.email || session[:pending_signup_email]
9
+ redirect_to new_registration_path unless @email
10
+ end
11
+
12
+ # POST /registration/verification
13
+ def create
14
+ @verification = EmailVerification.find_by(id: session[:pending_verification_id])
15
+ @email = @verification&.email || session[:pending_signup_email]
16
+
17
+ unless @verification
18
+ flash.now[:alert] = t("flash.registrations.invalid_code")
19
+ render :new, status: :unprocessable_entity
20
+ return
21
+ end
22
+
23
+ if @verification.expired?
24
+ flash.now[:alert] = t("flash.registrations.code_expired")
25
+ render :new, status: :unprocessable_entity
26
+ return
27
+ end
28
+
29
+ # Too many wrong guesses: kill the code so a fresh one is required.
30
+ if @verification.locked?
31
+ @verification.update!(expires_at: Time.current)
32
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
33
+ flash.now[:alert] = t("flash.registrations.code_locked")
34
+ render :new, status: :unprocessable_entity
35
+ return
36
+ end
37
+
38
+ unless @verification.code_matches?(params[:code])
39
+ @verification.increment!(:attempts)
40
+ Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
41
+ flash.now[:alert] = t("flash.registrations.invalid_code")
42
+ render :new, status: :unprocessable_entity
43
+ return
44
+ end
45
+
46
+ user = nil
47
+ ActiveRecord::Base.transaction do
48
+ account = Account.create!
49
+ user = account.users.create!(
50
+ email_address: @verification.email,
51
+ locale: @verification.locale || I18n.default_locale.to_s,
52
+ role: :admin
53
+ )
54
+ end
55
+
56
+ @verification.mark_verified!
57
+ session.delete(:pending_verification_id)
58
+ session.delete(:pending_signup_email)
59
+
60
+ Rails.logger.info "[AUTH] Successful registration for #{user.email_address} from #{request.remote_ip}"
61
+ start_new_session_for(user)
62
+ redirect_to root_path, notice: t("flash.registrations.create.success")
63
+ end
64
+ end
@@ -0,0 +1,63 @@
1
+ class RegistrationsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: t("flash.general.rate_limited") }
4
+
5
+ def new
6
+ end
7
+
8
+ def create
9
+ email = registration_params[:email_address]&.downcase&.strip
10
+
11
+ if email&.include?("+")
12
+ Rails.logger.warn "[AUTH] Registration attempted with + in email #{email} from #{request.remote_ip}"
13
+ flash.now[:alert] = t("flash.registrations.email_plus_not_allowed")
14
+ render :new, status: :unprocessable_entity
15
+ return
16
+ end
17
+
18
+ existing_user = User.find_by(email_address: email)
19
+
20
+ if existing_user
21
+ if existing_user.blocked?
22
+ # Fail silently - show same UI but don't send email
23
+ Rails.logger.info "[AUTH] Registration attempted for blocked user #{email} from #{request.remote_ip}"
24
+ session[:pending_signup_email] = email
25
+ redirect_to new_registration_verification_path
26
+ else
27
+ Rails.logger.info "[AUTH] Registration attempted for existing email #{email} from #{request.remote_ip}"
28
+ flash.now[:alert] = t("flash.registrations.email_taken")
29
+ render :new, status: :unprocessable_entity
30
+ end
31
+ return
32
+ end
33
+
34
+ recent_verification = EmailVerification.for_email(email).recent.first
35
+ if recent_verification
36
+ session[:pending_verification_id] = recent_verification.id
37
+ session[:pending_signup_email] = email
38
+ redirect_to new_registration_verification_path,
39
+ notice: t("flash.registrations.code_already_sent", minutes: recent_verification.minutes_until_resend)
40
+ return
41
+ end
42
+
43
+ verification = EmailVerification.create!(
44
+ email: email,
45
+ code: SecureRandom.hex(3).upcase,
46
+ verification_type: "signup",
47
+ locale: I18n.locale.to_s,
48
+ expires_at: 15.minutes.from_now
49
+ )
50
+
51
+ Rails.logger.info "[AUTH] Verification code sent for new registration #{email} from #{request.remote_ip}"
52
+ VerificationMailer.verification_code(verification).deliver_later
53
+ session[:pending_verification_id] = verification.id
54
+
55
+ redirect_to new_registration_verification_path
56
+ end
57
+
58
+ private
59
+
60
+ def registration_params
61
+ params.permit(:email_address)
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ class Session::VerificationResendsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_session_verification_path, alert: t("flash.general.rate_limited") }
4
+
5
+ # POST /session/verification_resend
6
+ def create
7
+ old_verification = EmailVerification.find_by(id: session[:pending_verification_id])
8
+ email = old_verification&.email || session[:pending_login_email]
9
+
10
+ unless email
11
+ redirect_to new_session_path
12
+ return
13
+ end
14
+
15
+ if EmailVerification.for_email(email).recent.exists?
16
+ recent = EmailVerification.for_email(email).recent.first
17
+ redirect_to new_session_verification_path,
18
+ alert: t("flash.sessions.cooldown", minutes: recent.minutes_until_resend)
19
+ return
20
+ end
21
+
22
+ user = User.find_by(email_address: email)
23
+
24
+ if user && !user.blocked?
25
+ verification = EmailVerification.create!(
26
+ email: email,
27
+ code: SecureRandom.hex(3).upcase,
28
+ verification_type: "login",
29
+ locale: user.locale || I18n.default_locale.to_s,
30
+ expires_at: 15.minutes.from_now
31
+ )
32
+
33
+ Rails.logger.info "[AUTH] Verification code resent for login #{email} from #{request.remote_ip}"
34
+ VerificationMailer.verification_code(verification).deliver_later
35
+ session[:pending_verification_id] = verification.id
36
+ else
37
+ Rails.logger.info "[AUTH] Verification resend attempted for non-existent/blocked email #{email} from #{request.remote_ip}"
38
+ end
39
+
40
+ # Always show success (don't reveal if user exists)
41
+ redirect_to new_session_verification_path,
42
+ notice: t("flash.sessions.code_resent")
43
+ end
44
+ end
@@ -0,0 +1,64 @@
1
+ class Session::VerificationsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_verification_path, alert: t("flash.general.rate_limited") }
4
+
5
+ # GET /session/verification/new
6
+ def new
7
+ @verification = EmailVerification.find_by(id: session[:pending_verification_id])
8
+ @email = @verification&.email || session[:pending_login_email]
9
+ redirect_to new_session_path unless @email
10
+ end
11
+
12
+ # POST /session/verification
13
+ def create
14
+ @verification = EmailVerification.find_by(id: session[:pending_verification_id])
15
+ @email = @verification&.email || session[:pending_login_email]
16
+
17
+ # Generic error if no verification (user doesn't exist or blocked)
18
+ unless @verification
19
+ flash.now[:alert] = t("flash.sessions.invalid_code")
20
+ render :new, status: :unprocessable_entity
21
+ return
22
+ end
23
+
24
+ if @verification.expired?
25
+ flash.now[:alert] = t("flash.sessions.code_expired")
26
+ render :new, status: :unprocessable_entity
27
+ return
28
+ end
29
+
30
+ # Too many wrong guesses: kill the code so a fresh one is required.
31
+ if @verification.locked?
32
+ @verification.update!(expires_at: Time.current)
33
+ Rails.logger.warn "[AUTH] Verification locked after #{@verification.attempts} attempts for #{@email} from #{request.remote_ip}"
34
+ flash.now[:alert] = t("flash.sessions.code_locked")
35
+ render :new, status: :unprocessable_entity
36
+ return
37
+ end
38
+
39
+ unless @verification.code_matches?(params[:code])
40
+ @verification.increment!(:attempts)
41
+ Rails.logger.warn "[AUTH] Invalid verification code attempt for #{@email} from #{request.remote_ip} (attempt #{@verification.attempts})"
42
+ flash.now[:alert] = t("flash.sessions.invalid_code")
43
+ render :new, status: :unprocessable_entity
44
+ return
45
+ end
46
+
47
+ user = User.find_by(email_address: @verification.email)
48
+
49
+ # Double-check user exists and not blocked
50
+ if user.nil? || user.blocked?
51
+ flash.now[:alert] = t("flash.sessions.invalid_code")
52
+ render :new, status: :unprocessable_entity
53
+ return
54
+ end
55
+
56
+ @verification.mark_verified!
57
+ session.delete(:pending_verification_id)
58
+ session.delete(:pending_login_email)
59
+
60
+ Rails.logger.info "[AUTH] Successful login for #{user.email_address} from #{request.remote_ip}"
61
+ start_new_session_for(user)
62
+ redirect_to after_authentication_url, notice: t("flash.sessions.create.success")
63
+ end
64
+ end
@@ -0,0 +1,56 @@
1
+ class SessionsController < ApplicationController
2
+ allow_unauthenticated_access only: %i[new create]
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: t("flash.general.rate_limited") }
4
+
5
+ # GET /session/new
6
+ def new
7
+ end
8
+
9
+ # POST /session
10
+ def create
11
+ email = params[:email_address]&.downcase&.strip
12
+ user = User.find_by(email_address: email)
13
+
14
+ # Check cooldown regardless of user existence
15
+ recent_verification = EmailVerification.for_email(email).recent.first
16
+ if recent_verification
17
+ session[:pending_verification_id] = recent_verification.id
18
+ session[:pending_login_email] = email
19
+ redirect_to new_session_verification_path,
20
+ notice: t("flash.sessions.code_already_sent", minutes: recent_verification.minutes_until_resend)
21
+ return
22
+ end
23
+
24
+ if user && !user.blocked?
25
+ # User exists and not blocked - send verification
26
+ verification = EmailVerification.create!(
27
+ email: email,
28
+ code: SecureRandom.hex(3).upcase,
29
+ verification_type: "login",
30
+ locale: user.locale || I18n.default_locale.to_s,
31
+ expires_at: 15.minutes.from_now
32
+ )
33
+
34
+ Rails.logger.info "[AUTH] Verification code sent for login #{email} from #{request.remote_ip}"
35
+ VerificationMailer.verification_code(verification).deliver_later
36
+ session[:pending_verification_id] = verification.id
37
+ else
38
+ # No user OR blocked - don't send email, but show same UI
39
+ if user&.blocked?
40
+ Rails.logger.info "[AUTH] Login attempted for blocked user #{email} from #{request.remote_ip}"
41
+ else
42
+ Rails.logger.info "[AUTH] Login attempted for non-existent email #{email} from #{request.remote_ip}"
43
+ end
44
+ session[:pending_login_email] = email
45
+ end
46
+
47
+ redirect_to new_session_verification_path
48
+ end
49
+
50
+ # DELETE /session
51
+ def destroy
52
+ Rails.logger.info "[AUTH] Logout for user #{Current.session.user.email_address} from #{request.remote_ip}"
53
+ terminate_session
54
+ redirect_to new_session_path, notice: t("flash.sessions.destroy.success"), status: :see_other
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ module AuthenticationHelper
2
+ # Masks an email address for privacy display
3
+ # "mario@example.com" -> "ma***@example.com"
4
+ # "jo@example.com" -> "j*@example.com"
5
+ # "a@example.com" -> "a@example.com"
6
+ def mask_email(email)
7
+ return "" if email.blank?
8
+
9
+ local, domain = email.split("@")
10
+ return email if local.nil? || domain.nil?
11
+
12
+ masked_local = if local.length <= 2
13
+ local[0] + "*" * (local.length - 1)
14
+ else
15
+ local[0..1] + "*" * (local.length - 2)
16
+ end
17
+
18
+ "#{masked_local}@#{domain}"
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ class AuthenticationCleanupJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform
5
+ # Delete email verifications older than 24 hours
6
+ old_verifications_count = EmailVerification.where("created_at < ?", 24.hours.ago).delete_all
7
+ Rails.logger.info "AuthenticationCleanupJob: Deleted #{old_verifications_count} old email verifications"
8
+
9
+ # Delete expired sessions
10
+ expired_sessions_count = Session.cleanup_expired!
11
+ Rails.logger.info "AuthenticationCleanupJob: Deleted #{expired_sessions_count} expired sessions"
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class VerificationMailer < ApplicationMailer
2
+ default from: "App <noreply@example.com>"
3
+
4
+ def verification_code(email_verification)
5
+ @verification = email_verification
6
+ locale = email_verification.locale || I18n.default_locale.to_s
7
+
8
+ I18n.with_locale(locale) do
9
+ mail(
10
+ to: email_verification.email,
11
+ subject: I18n.t("verification_mailer.verification_code.subject")
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ class Account < ApplicationRecord
2
+ has_many :users, dependent: :destroy
3
+
4
+ validates :name, allow_blank: true, length: { maximum: 100 }
5
+ end
@@ -0,0 +1,5 @@
1
+ class Current < ActiveSupport::CurrentAttributes
2
+ attribute :session
3
+ delegate :user, to: :session, allow_nil: true
4
+ delegate :account, to: :user, allow_nil: true
5
+ end