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.
- checksums.yaml +4 -4
- data/README.md +53 -0
- data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
- data/lib/generators/maquina/passkeys/USAGE +18 -0
- data/lib/generators/maquina/passkeys/passkeys_generator.rb +259 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
- data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
- data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
- data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
- data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
- data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
- data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
- data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
- data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
- data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/clave_generator_test.rb +13 -2
- data/test/generators/maquina/passkeys_generator_test.rb +253 -0
- data/test/tmp/Gemfile +1 -14
- data/test/tmp/app/controllers/application_controller.rb +3 -0
- data/test/tmp/app/controllers/concerns/authentication.rb +63 -0
- data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
- data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
- data/test/tmp/app/controllers/registrations_controller.rb +63 -0
- data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
- data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
- data/test/tmp/app/controllers/sessions_controller.rb +56 -0
- data/test/tmp/app/helpers/authentication_helper.rb +20 -0
- data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
- data/test/tmp/app/mailers/verification_mailer.rb +15 -0
- data/test/tmp/app/models/account.rb +5 -0
- data/test/tmp/app/models/current.rb +5 -0
- data/test/tmp/app/models/email_verification.rb +58 -0
- data/test/tmp/app/models/session.rb +18 -0
- data/test/tmp/app/models/user.rb +37 -0
- data/test/tmp/config/locales/clave.en.yml +102 -0
- data/test/tmp/config/locales/clave.es.yml +102 -0
- data/test/tmp/config/routes.rb +10 -1
- data/test/tmp/db/migrate/20261014212613_create_accounts.rb +9 -0
- data/test/tmp/db/migrate/20261014212614_create_users.rb +17 -0
- data/test/tmp/db/migrate/20261014212615_create_sessions.rb +12 -0
- data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +17 -0
- data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
- metadata +48 -19
- data/test/tmp/.gitignore +0 -5
- data/test/tmp/.rubocop.yml +0 -11
- data/test/tmp/.standard.yml +0 -1
- data/test/tmp/Procfile.dev +0 -3
- data/test/tmp/README.md +0 -89
- data/test/tmp/app/controllers/home_controller.rb +0 -6
- data/test/tmp/app/javascript/application.js +0 -1
- data/test/tmp/app/views/home/index.html.erb +0 -204
- data/test/tmp/app/views/layouts/application.html.erb +0 -10
- data/test/tmp/config/application.rb +0 -15
- data/test/tmp/config/database.yml +0 -90
- data/test/tmp/config/database.yml.example +0 -90
- data/test/tmp/config/environments/development.rb +0 -8
- data/test/tmp/config/environments/production.rb +0 -6
- 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,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
|