maquina-generators 0.7.0 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -0
  3. data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
  4. data/lib/generators/maquina/passkeys/USAGE +18 -0
  5. data/lib/generators/maquina/passkeys/passkeys_generator.rb +259 -0
  6. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
  7. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  8. data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
  9. data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
  10. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
  11. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
  12. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
  13. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  14. data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
  15. data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
  16. data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
  17. data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
  18. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
  19. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
  20. data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
  21. data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
  22. data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
  23. data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
  24. data/lib/maquina_generators/version.rb +1 -1
  25. data/test/generators/maquina/clave_generator_test.rb +13 -2
  26. data/test/generators/maquina/passkeys_generator_test.rb +253 -0
  27. data/test/tmp/Gemfile +1 -14
  28. data/test/tmp/app/controllers/application_controller.rb +3 -0
  29. data/test/tmp/app/controllers/concerns/authentication.rb +63 -0
  30. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
  31. data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
  32. data/test/tmp/app/controllers/registrations_controller.rb +63 -0
  33. data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
  34. data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
  35. data/test/tmp/app/controllers/sessions_controller.rb +56 -0
  36. data/test/tmp/app/helpers/authentication_helper.rb +20 -0
  37. data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
  38. data/test/tmp/app/mailers/verification_mailer.rb +15 -0
  39. data/test/tmp/app/models/account.rb +5 -0
  40. data/test/tmp/app/models/current.rb +5 -0
  41. data/test/tmp/app/models/email_verification.rb +58 -0
  42. data/test/tmp/app/models/session.rb +18 -0
  43. data/test/tmp/app/models/user.rb +37 -0
  44. data/test/tmp/config/locales/clave.en.yml +102 -0
  45. data/test/tmp/config/locales/clave.es.yml +102 -0
  46. data/test/tmp/config/routes.rb +10 -1
  47. data/test/tmp/db/migrate/20261014212613_create_accounts.rb +9 -0
  48. data/test/tmp/db/migrate/20261014212614_create_users.rb +17 -0
  49. data/test/tmp/db/migrate/20261014212615_create_sessions.rb +12 -0
  50. data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +17 -0
  51. data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
  52. metadata +48 -19
  53. data/test/tmp/.gitignore +0 -5
  54. data/test/tmp/.rubocop.yml +0 -11
  55. data/test/tmp/.standard.yml +0 -1
  56. data/test/tmp/Procfile.dev +0 -3
  57. data/test/tmp/README.md +0 -89
  58. data/test/tmp/app/controllers/home_controller.rb +0 -6
  59. data/test/tmp/app/javascript/application.js +0 -1
  60. data/test/tmp/app/views/home/index.html.erb +0 -204
  61. data/test/tmp/app/views/layouts/application.html.erb +0 -10
  62. data/test/tmp/config/application.rb +0 -15
  63. data/test/tmp/config/database.yml +0 -90
  64. data/test/tmp/config/database.yml.example +0 -90
  65. data/test/tmp/config/environments/development.rb +0 -8
  66. data/test/tmp/config/environments/production.rb +0 -6
  67. data/test/tmp/config/initializers/generators.rb +0 -4
@@ -130,6 +130,13 @@ class Maquina::Generators::ClaveGeneratorTest < Rails::Generators::TestCase
130
130
  assert_file "app/models/user.rb"
131
131
  end
132
132
 
133
+ test "sign-in page links to sign-up" do
134
+ run_generator
135
+
136
+ assert_file "app/views/sessions/new.html.erb",
137
+ /<% end %>\n\n <p class="mt-6 text-center text-sm text-muted-foreground">\n <%= t\("\.no_account"\) %>\n <%= link_to t\("\.sign_up"\), new_registration_path/
138
+ end
139
+
133
140
  test "skips registration with --skip-registration" do
134
141
  run_generator %w[--skip-registration]
135
142
 
@@ -139,9 +146,13 @@ class Maquina::Generators::ClaveGeneratorTest < Rails::Generators::TestCase
139
146
  assert_no_file "app/views/registrations/new.html.erb"
140
147
  assert_no_file "app/views/registration/verifications/new.html.erb"
141
148
 
142
- # Session should still be generated
149
+ # Session should still be generated, without a link to the missing sign-up
143
150
  assert_file "app/controllers/sessions_controller.rb"
144
- assert_file "app/views/sessions/new.html.erb"
151
+ assert_file "app/views/sessions/new.html.erb" do |content|
152
+ assert_no_match(/registration_path/, content)
153
+ assert_no_match(/t\("\.sign_up"\)/, content)
154
+ assert_match(/<% end %>\n <\/div>\n <\/div>\n<\/div>\n\z/, content)
155
+ end
145
156
 
146
157
  # Routes should not include registration
147
158
  assert_file "config/routes.rb" do |routes|
@@ -0,0 +1,253 @@
1
+ require "test_helper"
2
+ require "generators/maquina/passkeys/passkeys_generator"
3
+
4
+ class Maquina::Generators::PasskeysGeneratorTest < Rails::Generators::TestCase
5
+ tests Maquina::Generators::PasskeysGenerator
6
+ destination File.expand_path("../../tmp", __dir__)
7
+
8
+ CLAVE_TEMPLATES = File.expand_path("../../../lib/generators/maquina/clave/templates", __dir__)
9
+
10
+ setup do
11
+ prepare_destination
12
+ write "config/routes.rb", "Rails.application.routes.draw do\nend\n"
13
+ write "Gemfile", "source \"https://rubygems.org\"\n"
14
+ write "app/models/email_verification.rb", "class EmailVerification < ApplicationRecord\nend\n"
15
+ write "app/controllers/concerns/authentication.rb", "module Authentication\nend\n"
16
+ # The files clave generates, as clave generates them.
17
+ write "app/models/user.rb", File.read(File.join(CLAVE_TEMPLATES, "app/models/user.rb.tt"))
18
+ write "app/views/sessions/new.html.erb", clave_sign_in_page
19
+ write "config/initializers/rack_attack.rb", "class Rack::Attack\nend\n"
20
+ write "test/test_helper.rb", TEST_HELPER
21
+ end
22
+
23
+ TEST_HELPER = <<~RUBY
24
+ ENV["RAILS_ENV"] ||= "test"
25
+ require_relative "../config/environment"
26
+ require "rails/test_help"
27
+ RUBY
28
+
29
+ test "refuses to run without clave" do
30
+ File.delete(File.join(destination_root, "app/models/email_verification.rb"))
31
+
32
+ output = capture(:stderr) { run_generator }
33
+
34
+ assert_match(/Run `rails g maquina:clave` first/, output)
35
+ assert_no_file "app/models/passkey.rb"
36
+ end
37
+
38
+ test "adds the webauthn gem" do
39
+ run_generator
40
+
41
+ assert_file "Gemfile", /^gem "webauthn", "~> 3\.4"$/
42
+ end
43
+
44
+ test "creates the relying party initializer" do
45
+ run_generator
46
+
47
+ assert_file "config/initializers/webauthn.rb" do |content|
48
+ assert_match(/config\.rp_id = ENV\["WEBAUTHN_RP_ID"\]/, content)
49
+ assert_match(/config\.allowed_origins = /, content)
50
+ assert_match(/raise "Passkeys: set WEBAUTHN_RP_ID/, content)
51
+ end
52
+ end
53
+
54
+ test "creates the passkey model and wires it into the user" do
55
+ run_generator
56
+
57
+ assert_file "app/models/passkey.rb", /class Passkey < ApplicationRecord/
58
+ assert_file "app/models/user.rb" do |content|
59
+ assert_match(/has_many :sessions, dependent: :destroy\n has_many :passkeys, dependent: :destroy\n/, content)
60
+ assert_match(/def webauthn_id\n super \|\| begin\n update_column\(:webauthn_id, WebAuthn\.generate_user_id\)/, content)
61
+ assert_operator content.index("def webauthn_id"), :<, content.index(" private")
62
+ end
63
+ end
64
+
65
+ test "creates the migrations" do
66
+ run_generator
67
+
68
+ assert_migration "db/migrate/add_webauthn_id_to_users.rb", /add_index :users, :webauthn_id, unique: true/
69
+ assert_migration "db/migrate/create_passkeys.rb" do |content|
70
+ assert_match(/t\.string :credential_id, null: false/, content)
71
+ assert_match(/t\.text :public_key, null: false/, content)
72
+ assert_match(/t\.bigint :sign_count, null: false, default: 0/, content)
73
+ assert_match(/add_index :passkeys, :credential_id, unique: true/, content)
74
+ end
75
+ end
76
+
77
+ test "creates the controllers and routes" do
78
+ run_generator
79
+
80
+ assert_file "app/controllers/session/passkey_challenges_controller.rb", /class Session::PasskeyChallengesController/
81
+ assert_file "app/controllers/session/passkeys_controller.rb", /class Session::PasskeysController/
82
+ assert_file "app/controllers/passkey_challenges_controller.rb", /class PasskeyChallengesController/
83
+ assert_file "app/controllers/passkeys_controller.rb", /class PasskeysController/
84
+ assert_file "app/controllers/concerns/passkey_challenge.rb", /module PasskeyChallenge/
85
+ assert_file "app/controllers/concerns/passkey_reauthentication.rb", /module PasskeyReauthentication/
86
+ assert_file "config/routes.rb" do |content|
87
+ assert_match(/resource :passkey_challenge, only: :create, controller: "session\/passkey_challenges"/, content)
88
+ assert_match(/resource :passkey, only: :create, controller: "session\/passkeys"/, content)
89
+ assert_match(/resources :passkeys, only: \[:index, :create, :update, :destroy\]/, content)
90
+ end
91
+ end
92
+
93
+ test "sign-in offers every passkey and requires user verification" do
94
+ run_generator
95
+
96
+ assert_file "app/controllers/session/passkey_challenges_controller.rb" do |content|
97
+ assert_match(/options_for_get\(user_verification: "required"\)/, content)
98
+ assert_no_match(/allow:/, content)
99
+ end
100
+ assert_file "app/controllers/session/passkeys_controller.rb" do |content|
101
+ assert_match(/allow_unauthenticated_access/, content)
102
+ assert_match(/rate_limit to:/, content)
103
+ assert_match(/sign_count: passkey\.sign_count, user_verification: true\)/, content)
104
+ assert_match(/credential\.user_handle != user\.webauthn_id/, content)
105
+ assert_match(/start_new_session_for\(user\)/, content)
106
+ # The challenge is gone from the session before anything is verified.
107
+ assert_operator content.index("take_passkey_challenge"), :<, content.index("credential.verify")
108
+ end
109
+ assert_file "app/controllers/concerns/passkey_challenge.rb", /stored = session\.delete\(passkey_challenge_key\(purpose\)\)/
110
+ end
111
+
112
+ test "registration makes discoverable passkeys behind a recent sign-in" do
113
+ run_generator
114
+
115
+ assert_file "app/controllers/passkey_challenges_controller.rb" do |content|
116
+ assert_match(/include PasskeyReauthentication/, content)
117
+ assert_match(/resident_key: "required", user_verification: "required"/, content)
118
+ assert_match(/exclude: user\.passkeys\.pluck\(:credential_id\)/, content)
119
+ end
120
+ assert_file "app/controllers/passkeys_controller.rb" do |content|
121
+ assert_match(/include PasskeyReauthentication/, content)
122
+ assert_match(/credential\.verify\(challenge, user_verification: true\)/, content)
123
+ assert_match(/Current\.user\.passkeys\.find\(params\[:id\]\)/, content)
124
+ end
125
+ end
126
+
127
+ test "adds the passkey button and autofill to the sign-in page" do
128
+ run_generator
129
+
130
+ # Visible as rendered, refreshed after a Turbo morph (which doesn't reconnect).
131
+ assert_file "app/views/sessions/_passkey.html.erb" do |content|
132
+ assert_match(/data-action="turbo:morph@document->passkey-sign-in#refresh"/, content)
133
+ assert_match(/class="mt-6 space-y-4">/, content)
134
+ end
135
+ assert_file "app/views/passkeys/index.html.erb" do |content|
136
+ assert_match(/data-action="turbo:morph@document->passkey-registration#refresh"/, content)
137
+ assert_no_match(/disabled:opacity-50"\s+hidden>/, content)
138
+ end
139
+ assert_file "app/javascript/controllers/passkey_sign_in_controller.js", /mediation: "conditional"|\{ mediation \}/
140
+ assert_file "app/javascript/controllers/passkey_registration_controller.js", /parseCreationOptionsFromJSON/
141
+ assert_file "app/views/sessions/new.html.erb" do |content|
142
+ assert_match(/autocomplete: "username webauthn"/, content)
143
+ assert_match(/<% end %>\n\n <%= render "sessions\/passkey" %>\n/, content)
144
+ end
145
+ end
146
+
147
+ test "the JavaScript needs no WebAuthn library" do
148
+ run_generator
149
+
150
+ %w[passkey_sign_in_controller.js passkey_registration_controller.js].each do |file|
151
+ assert_file "app/javascript/controllers/#{file}" do |content|
152
+ assert_equal ['import { Controller } from "@hotwired/stimulus"'], content.scan(/^import .*$/)
153
+ end
154
+ end
155
+ end
156
+
157
+ test "adds the passkey button to a sign-in page without sign-up" do
158
+ write "app/views/sessions/new.html.erb", clave_sign_in_page(skip_registration: true)
159
+
160
+ run_generator
161
+
162
+ assert_file "app/views/sessions/new.html.erb" do |content|
163
+ assert_match(/<% end %>\n\n <%= render "sessions\/passkey" %>\n <\/div>/, content)
164
+ assert_match(/autocomplete: "username webauthn"/, content)
165
+ end
166
+ end
167
+
168
+ test "skip-views leaves the pages and the sign-in form alone" do
169
+ run_generator ["--skip-views"]
170
+
171
+ assert_no_file "app/views/passkeys/index.html.erb"
172
+ assert_no_file "app/javascript/controllers/passkey_sign_in_controller.js"
173
+ assert_file "app/views/sessions/new.html.erb" do |content|
174
+ assert_no_match(/passkey/, content)
175
+ end
176
+ end
177
+
178
+ test "creates locales and the test helper" do
179
+ run_generator
180
+
181
+ assert_file "config/locales/passkeys.en.yml", /sign_in: "Sign in with a passkey"/
182
+ assert_file "config/locales/passkeys.es.yml", /sign_in: "Iniciar sesión con llave de acceso"/
183
+ assert_file "test/test_helpers/webauthn_test_helper.rb", /require "webauthn\/fake_client"/
184
+ assert_file "test/integration/passkeys_test.rb", /sign_in_with_passkey\(@user\)/
185
+ assert_file "test/test_helper.rb", /require "rails\/test_help"\nrequire_relative "test_helpers\/webauthn_test_helper"\n/
186
+ end
187
+
188
+ test "locales share the same keys" do
189
+ run_generator
190
+
191
+ en = YAML.load_file(File.join(destination_root, "config/locales/passkeys.en.yml"))["en"]
192
+ es = YAML.load_file(File.join(destination_root, "config/locales/passkeys.es.yml"))["es"]
193
+ assert_equal keys(en), keys(es)
194
+ end
195
+
196
+ test "throttles passkey sign-in in rack attack" do
197
+ run_generator
198
+
199
+ assert_file "config/initializers/rack_attack.rb", /Rack::Attack\.throttle\("passkey\/ip", limit: 20, period: 1\.minute\)/
200
+ end
201
+
202
+ test "running twice changes nothing" do
203
+ run_generator
204
+ files = %w[Gemfile config/routes.rb app/models/user.rb app/views/sessions/new.html.erb config/initializers/rack_attack.rb
205
+ test/test_helper.rb]
206
+ before = files.to_h { |f| [f, File.read(File.join(destination_root, f))] }
207
+
208
+ run_generator
209
+
210
+ files.each { |f| assert_equal before[f], File.read(File.join(destination_root, f)), f }
211
+ end
212
+
213
+ test "lists what it could not place" do
214
+ write "app/models/user.rb", "class User < ApplicationRecord\nend\n"
215
+ write "app/views/sessions/new.html.erb", "<h1>Sign in</h1>\n"
216
+ File.delete(File.join(destination_root, "config/initializers/rack_attack.rb"))
217
+ write "test/test_helper.rb", "# custom\n"
218
+
219
+ output = run_generator
220
+
221
+ assert_match(/Add to app\/models\/user\.rb:.*has_many :passkeys/m, output)
222
+ assert_match(/Offer passkeys on the sign-in page/, output)
223
+ assert_match(/Throttle passkey sign-in in config\/initializers\/rack_attack\.rb/, output)
224
+ assert_match(/Load the passkey test helper in test\/test_helper\.rb/, output)
225
+ assert_file "app/models/user.rb", "class User < ApplicationRecord\nend\n"
226
+ assert_file "app/views/sessions/new.html.erb", "<h1>Sign in</h1>\n"
227
+ end
228
+
229
+ test "quiet hides the post-install message" do
230
+ output = run_generator ["--quiet"]
231
+
232
+ assert_no_match(/Passkeys have been installed/, output)
233
+ end
234
+
235
+ private
236
+
237
+ # Clave's sign-in page as `rails g maquina:clave` renders it.
238
+ def clave_sign_in_page(skip_registration: false)
239
+ template = File.read(File.join(CLAVE_TEMPLATES, "app/views/sessions/new.html.erb.tt"))
240
+ options = {skip_registration: skip_registration}
241
+ ERB.new(template, trim_mode: "-").result(binding)
242
+ end
243
+
244
+ def write(path, content)
245
+ full_path = File.join(destination_root, path)
246
+ FileUtils.mkdir_p(File.dirname(full_path))
247
+ File.write(full_path, content)
248
+ end
249
+
250
+ def keys(hash, prefix = nil)
251
+ hash.flat_map { |k, v| v.is_a?(Hash) ? keys(v, "#{prefix}#{k}.") : ["#{prefix}#{k}"] }.sort
252
+ end
253
+ end
data/test/tmp/Gemfile CHANGED
@@ -1,15 +1,2 @@
1
1
  source "https://rubygems.org"
2
-
3
- gem "brakeman", group: :development
4
-
5
- gem "bundle-audit", group: :development
6
-
7
- gem "letter_opener", group: :development
8
-
9
- gem "standard", ">= 1.54", group: :development
10
-
11
- gem "rails-i18n"
12
-
13
- gem "maquina-components"
14
-
15
- gem "aws-sdk-s3", group: :production
2
+ # gem "bcrypt"
@@ -0,0 +1,3 @@
1
+ class ApplicationController < ActionController::Base
2
+ include Authentication
3
+ end
@@ -0,0 +1,63 @@
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
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