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.
- checksums.yaml +4 -4
- data/README.md +3 -1
- data/Rakefile +1 -1
- data/lib/generators/maquina/app/USAGE +2 -0
- data/lib/generators/maquina/app/app_generator.rb +31 -0
- data/lib/generators/maquina/app/templates/README.md.erb +6 -0
- data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +3 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/app_generator_test.rb +41 -0
- data/test/tmp/Gemfile +1 -2
- data/test/tmp/app/controllers/application_controller.rb +3 -0
- data/test/tmp/app/controllers/concerns/authentication.rb +61 -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 +56 -0
- data/test/tmp/app/models/session.rb +18 -0
- data/test/tmp/app/models/user.rb +0 -10
- data/test/tmp/app/views/registration/verifications/new.html.erb +43 -0
- data/test/tmp/app/views/registrations/new.html.erb +39 -0
- data/test/tmp/app/views/session/verifications/new.html.erb +43 -0
- data/test/tmp/app/views/sessions/new.html.erb +5 -2
- data/test/tmp/app/views/verification_mailer/verification_code.html.erb +37 -0
- data/test/tmp/app/views/verification_mailer/verification_code.text.erb +11 -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 +9 -6
- data/test/tmp/db/migrate/20261019191386_create_accounts.rb +9 -0
- data/test/tmp/db/migrate/20261019191387_create_users.rb +17 -0
- data/test/tmp/db/migrate/20261019191388_create_sessions.rb +12 -0
- data/test/tmp/db/migrate/20261019191389_create_email_verifications.rb +17 -0
- data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
- metadata +26 -21
- data/test/tmp/app/controllers/concerns/passkey_challenge.rb +0 -42
- data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +0 -26
- data/test/tmp/app/controllers/passkey_challenges_controller.rb +0 -24
- data/test/tmp/app/controllers/passkeys_controller.rb +0 -68
- data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +0 -18
- data/test/tmp/app/controllers/session/passkeys_controller.rb +0 -55
- data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +0 -109
- data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +0 -113
- data/test/tmp/app/models/passkey.rb +0 -36
- data/test/tmp/app/views/passkeys/index.html.erb +0 -77
- data/test/tmp/app/views/sessions/_passkey.html.erb +0 -23
- data/test/tmp/config/initializers/rack_attack.rb +0 -9
- data/test/tmp/config/initializers/webauthn.rb +0 -23
- data/test/tmp/config/locales/passkeys.en.yml +0 -46
- data/test/tmp/config/locales/passkeys.es.yml +0 -46
- data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +0 -6
- data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +0 -20
- data/test/tmp/test/integration/passkeys_test.rb +0 -99
- data/test/tmp/test/test_helper.rb +0 -4
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76d3dc4690cd7ff89b935e2289d7032084e426bda2e2f2998ed0aa81c0bb5fef
|
|
4
|
+
data.tar.gz: d983ec5b7183219b998a4447215808edf1d492c01cd344c957e9e9674036a3c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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 %>
|
|
@@ -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,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
|