action_auth 1.8.7 → 2.0.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/README.md +485 -36
  4. data/Rakefile +0 -2
  5. data/app/assets/javascripts/action_auth/application.js +72 -71
  6. data/app/assets/stylesheets/action_auth/application.css +38 -1
  7. data/app/controllers/action_auth/application_controller.rb +132 -1
  8. data/app/controllers/action_auth/connected_accounts_controller.rb +16 -0
  9. data/app/controllers/action_auth/identity/email_verifications_controller.rb +36 -5
  10. data/app/controllers/action_auth/identity/emails_controller.rb +16 -4
  11. data/app/controllers/action_auth/identity/password_resets_controller.rb +20 -22
  12. data/app/controllers/action_auth/magics/requests_controller.rb +27 -6
  13. data/app/controllers/action_auth/magics/sign_ins_controller.rb +22 -7
  14. data/app/controllers/action_auth/passwords_controller.rb +7 -18
  15. data/app/controllers/action_auth/provider_callbacks_controller.rb +147 -0
  16. data/app/controllers/action_auth/registrations_controller.rb +7 -25
  17. data/app/controllers/action_auth/sessions/passkeys_controller.rb +17 -9
  18. data/app/controllers/action_auth/sessions_controller.rb +27 -25
  19. data/app/controllers/action_auth/sms_auths/requests_controller.rb +40 -14
  20. data/app/controllers/action_auth/sms_auths/sign_ins_controller.rb +40 -15
  21. data/app/controllers/action_auth/users_controller.rb +35 -1
  22. data/app/controllers/action_auth/webauthn_credential_authentications_controller.rb +36 -51
  23. data/app/controllers/action_auth/webauthn_credentials_controller.rb +63 -51
  24. data/app/helpers/action_auth/application_helper.rb +83 -0
  25. data/app/mailers/action_auth/application_mailer.rb +1 -1
  26. data/app/mailers/action_auth/user_mailer.rb +7 -0
  27. data/app/models/action_auth/connected_account.rb +18 -0
  28. data/app/models/action_auth/session.rb +9 -1
  29. data/app/models/action_auth/user.rb +197 -4
  30. data/app/models/action_auth/webauthn_credential.rb +2 -0
  31. data/app/views/action_auth/identity/email_verifications/show.html.erb +23 -0
  32. data/app/views/action_auth/identity/emails/edit.html.erb +41 -42
  33. data/app/views/action_auth/identity/password_resets/edit.html.erb +10 -11
  34. data/app/views/action_auth/identity/password_resets/new.html.erb +4 -9
  35. data/app/views/action_auth/magics/requests/new.html.erb +4 -14
  36. data/app/views/action_auth/magics/sign_ins/show.html.erb +18 -0
  37. data/app/views/action_auth/passwords/edit.html.erb +9 -21
  38. data/app/views/action_auth/registrations/new.html.erb +5 -27
  39. data/app/views/action_auth/sessions/_account.html.erb +30 -0
  40. data/app/views/action_auth/sessions/_connected_accounts.html.erb +32 -0
  41. data/app/views/action_auth/sessions/_security_keys.html.erb +32 -0
  42. data/app/views/action_auth/sessions/_sessions.html.erb +28 -0
  43. data/app/views/action_auth/sessions/index.html.erb +10 -62
  44. data/app/views/action_auth/sessions/new.html.erb +3 -21
  45. data/app/views/action_auth/sessions/passkeys/new.html.erb +10 -4
  46. data/app/views/action_auth/shared/_errors.html.erb +10 -0
  47. data/app/views/action_auth/shared/_flash.html.erb +11 -0
  48. data/app/views/action_auth/shared/_links.html.erb +3 -0
  49. data/app/views/action_auth/shared/_oauth_buttons.html.erb +5 -0
  50. data/app/views/action_auth/shared/_webauthn_error.html.erb +1 -0
  51. data/app/views/action_auth/sms_auths/requests/new.html.erb +4 -14
  52. data/app/views/action_auth/sms_auths/sign_ins/show.html.erb +6 -2
  53. data/app/views/action_auth/user_mailer/account_locked.html.erb +17 -0
  54. data/app/views/action_auth/user_mailer/account_locked.text.erb +12 -0
  55. data/app/views/action_auth/user_mailer/email_verification.html.erb +5 -0
  56. data/app/views/action_auth/user_mailer/email_verification.text.erb +3 -0
  57. data/app/views/action_auth/user_mailer/magic_link.html.erb +4 -0
  58. data/app/views/action_auth/user_mailer/magic_link.text.erb +5 -0
  59. data/app/views/action_auth/user_mailer/password_reset.text.erb +1 -1
  60. data/app/views/action_auth/webauthn_credential_authentications/new.html.erb +9 -3
  61. data/app/views/action_auth/webauthn_credentials/new.html.erb +10 -3
  62. data/app/views/layouts/action_auth/application.html.erb +2 -7
  63. data/config/routes.rb +13 -3
  64. data/db/migrate/20261105120000_add_magic_link_used_at_to_users.rb +5 -0
  65. data/db/migrate/20261105130000_add_account_lockout_and_suspension_to_users.rb +7 -0
  66. data/db/migrate/20261105130100_create_action_auth_connected_accounts.rb +19 -0
  67. data/lib/action_auth/configuration.rb +96 -0
  68. data/lib/action_auth/controllers/helpers.rb +44 -11
  69. data/lib/action_auth/engine.rb +27 -1
  70. data/lib/action_auth/routing/helpers.rb +8 -0
  71. data/lib/action_auth/version.rb +1 -1
  72. data/lib/generators/action_auth/views/USAGE +8 -0
  73. data/lib/generators/action_auth/views/views_generator.rb +59 -0
  74. data/lib/tasks/action_auth_tasks.rake +19 -1
  75. metadata +28 -5
@@ -1,38 +1,37 @@
1
- class ActionAuth::WebauthnCredentialsController < ApplicationController
2
- before_action :authenticate_user!
3
- layout "action_auth/application"
1
+ module ActionAuth
2
+ class WebauthnCredentialsController < ApplicationController
3
+ before_action :authenticate_user!
4
4
 
5
- def new
6
- end
7
-
8
- def options
9
- if current_user.webauthn_id.blank?
10
- current_user.update!(webauthn_id: WebAuthn.generate_user_id)
5
+ def new
11
6
  end
12
7
 
13
- create_options = WebAuthn::Credential.options_for_create(
14
- user: {
15
- id: current_user.webauthn_id,
16
- name: current_user.email
17
- },
18
- exclude: current_user.webauthn_credentials.pluck(:external_id)
19
- )
8
+ def options
9
+ if current_user.webauthn_id.blank?
10
+ current_user.update!(webauthn_id: WebAuthn.generate_user_id)
11
+ end
20
12
 
21
- session[:current_challenge] = create_options.challenge
13
+ create_options = WebAuthn::Credential.options_for_create(
14
+ user: {
15
+ id: current_user.webauthn_id,
16
+ name: current_user.email
17
+ },
18
+ exclude: current_user.webauthn_credentials.pluck(:external_id),
19
+ authenticator_selection: authenticator_selection
20
+ )
22
21
 
23
- respond_to do |format|
24
- format.json { render json: create_options }
25
- if defined?(Turbo)
26
- format.turbo_stream { render json: create_options }
22
+ session[:current_challenge] = create_options.challenge
23
+
24
+ respond_to do |format|
25
+ format.json { render json: create_options }
26
+ if defined?(Turbo)
27
+ format.turbo_stream { render json: create_options }
28
+ end
27
29
  end
28
30
  end
29
- end
30
-
31
- def create
32
- webauthn_credential = WebAuthn::Credential.from_create(params)
33
31
 
34
- begin
35
- webauthn_credential.verify(session[:current_challenge])
32
+ def create
33
+ webauthn_credential = verified_registration
34
+ return render(json: "Verification failed", status: :unprocessable_entity) unless webauthn_credential
36
35
 
37
36
  credential = current_user.webauthn_credentials.build(
38
37
  external_id: webauthn_credential.id,
@@ -47,38 +46,51 @@ class ActionAuth::WebauthnCredentialsController < ApplicationController
47
46
  else
48
47
  render json: "Couldn't add your Security Key", status: :unprocessable_entity
49
48
  end
50
- rescue WebAuthn::Error => e
51
- Rails.logger.error "❌ Verification failed: #{e.message}"
52
- render json: "Verification failed: #{e.message}", status: :unprocessable_entity
53
49
  end
54
- end
55
50
 
56
- def destroy
57
- current_user.webauthn_credentials.destroy(params[:id])
51
+ def destroy
52
+ current_user.webauthn_credentials.destroy(params[:id])
58
53
 
59
- redirect_to sessions_path
60
- end
54
+ redirect_to sessions_path, status: :see_other
55
+ end
61
56
 
62
- private
57
+ private
63
58
 
64
- def key_type
65
- transports = params.dig(:response, :transports)
66
- return :unknown unless transports.present?
59
+ # Signing in with only a passkey doesn't name an account up front, so the
60
+ # authenticator has to be able to find the credential by itself. Browsers
61
+ # don't create that kind of credential unless asked.
62
+ def authenticator_selection
63
+ ActionAuth.configuration.passkey_only? ? { resident_key: "preferred" } : {}
64
+ end
67
65
 
68
- transport_types = {
69
- ["internal", "hybrid"] => :passkey,
70
- ["usb", "nfc"] => :hardware,
71
- ["bluetooth", "wireless"] => :wireless,
72
- }.freeze
66
+ # See verify_webauthn_assertion for why this rescues broadly.
67
+ def verified_registration
68
+ challenge = session.delete(:current_challenge)
69
+ WebAuthn::Credential.from_create(params).tap { |credential| credential.verify(challenge) }
70
+ rescue StandardError => e
71
+ Rails.logger.warn "ActionAuth rejected a WebAuthn registration: #{e.class}: #{e.message}"
72
+ nil
73
+ end
73
74
 
74
- transport_types.each do |keys, type|
75
- if transports.is_a?(String)
76
- return type if keys.include?(transports)
77
- elsif transports.is_a?(Array)
78
- return type if (keys & transports).any?
75
+ def key_type
76
+ transports = params.dig(:response, :transports)
77
+ return :unknown unless transports.present?
78
+
79
+ transport_types = {
80
+ ["internal", "hybrid"] => :passkey,
81
+ ["usb", "nfc"] => :hardware,
82
+ ["bluetooth", "wireless"] => :wireless,
83
+ }.freeze
84
+
85
+ transport_types.each do |keys, type|
86
+ if transports.is_a?(String)
87
+ return type if keys.include?(transports)
88
+ elsif transports.is_a?(Array)
89
+ return type if (keys & transports).any?
90
+ end
79
91
  end
80
- end
81
92
 
82
- :unknown
93
+ :unknown
94
+ end
83
95
  end
84
96
  end
@@ -1,4 +1,87 @@
1
1
  module ActionAuth
2
2
  module ApplicationHelper
3
+ WEBAUTHN_SCREENS = %w[
4
+ action_auth/webauthn_credentials
5
+ action_auth/webauthn_credential_authentications
6
+ action_auth/sessions/passkeys
7
+ ].freeze
8
+
9
+ # The other auth screens someone could go to from here, as [label, path]
10
+ # pairs, leaving out the current page and any feature that's turned off.
11
+ # Use this in a custom view to style the links without repeating the checks:
12
+ #
13
+ # <% action_auth_links.each do |label, path| %>
14
+ # <%= link_to label, path, class: "underline" %>
15
+ # <% end %>
16
+ def action_auth_links
17
+ config = ActionAuth.configuration
18
+
19
+ links = [["Sign In", sign_in_path], ["Sign Up", sign_up_path]]
20
+ links << ["Magic Link", new_magics_requests_path] if config.magic_link_enabled?
21
+ links << ["SMS Auth", new_sms_auths_requests_path] if config.sms_auth_enabled?
22
+ links << ["Passkey", new_sessions_passkey_path] if config.passkey_only?
23
+ links << ["Reset Password", new_identity_password_reset_path]
24
+ links << ["Verify Email", edit_identity_email_path] if config.verify_email_on_sign_in
25
+
26
+ links.reject { |_label, path| current_page?(path) }
27
+ end
28
+
29
+ # The configured OAuth providers, as [label, path] pairs. These have to be POSTed
30
+ # by a form that Turbo stays out of, because the response redirects to another site:
31
+ #
32
+ # <% action_auth_oauth_providers.each do |label, path| %>
33
+ # <%= button_to "Continue with #{label}", path, form: { data: { turbo: false } } %>
34
+ # <% end %>
35
+ def action_auth_oauth_providers
36
+ return [] unless ActionAuth.configuration.oauth_enabled?
37
+
38
+ ActionAuth.configuration.oauth_providers.map { |oauth| [oauth.label, oauth_request_path(oauth.key)] }
39
+ end
40
+
41
+ # Everything the browser side of WebAuthn needs. The three screens that use
42
+ # it include this themselves, so they work inside any layout, and the
43
+ # third-party scripts it loads stay off the pages where people type passwords.
44
+ # Only renders once per page, so it's safe to call from a view and a layout.
45
+ def action_auth_webauthn_tags
46
+ return if @action_auth_webauthn_tags_rendered
47
+
48
+ @action_auth_webauthn_tags_rendered = true
49
+
50
+ safe_join([
51
+ csrf_meta_tags,
52
+ tag.meta(name: "webauthn_auth_url", content: webauthn_credential_authentications_url),
53
+ tag.meta(name: "webauthn_cred_url", content: webauthn_credentials_url),
54
+ tag.meta(name: "webauthn_redirect_url", content: sessions_url),
55
+ javascript_include_tag("action_auth/application", type: "module", "data-turbo-track": "reload")
56
+ ].compact, "\n")
57
+ end
58
+
59
+ # For the default layout, so that a WebAuthn view copied from an older version
60
+ # of ActionAuth, which relied on the layout for these tags, keeps working.
61
+ def action_auth_webauthn_screen?
62
+ controller_path.in?(WEBAUTHN_SCREENS)
63
+ end
64
+
65
+ # Engine views only have the engine's routes, so an app's layout would blow
66
+ # up on its first root_path or posts_path. Fall back to the app's routes.
67
+ # The engine's routes still win when both define the same name, so use
68
+ # main_app.session_path for an app route that clashes.
69
+ def method_missing(name, *args, **options, &block)
70
+ if action_auth_main_app_route?(name)
71
+ main_app.public_send(name, *args, **options, &block)
72
+ else
73
+ super
74
+ end
75
+ end
76
+
77
+ def respond_to_missing?(name, include_private = false)
78
+ action_auth_main_app_route?(name) || super
79
+ end
80
+
81
+ private
82
+
83
+ def action_auth_main_app_route?(name)
84
+ name.end_with?("_path", "_url") && main_app.respond_to?(name)
85
+ end
3
86
  end
4
87
  end
@@ -1,6 +1,6 @@
1
1
  module ActionAuth
2
2
  class ApplicationMailer < ActionMailer::Base
3
- default from: ActionAuth.configuration.default_from_email
3
+ default from: -> { ActionAuth.configuration.default_from_email }
4
4
  layout "mailer"
5
5
  end
6
6
  end
@@ -20,5 +20,12 @@ module ActionAuth
20
20
 
21
21
  mail to: @user.email, subject: "Sign in to your account"
22
22
  end
23
+
24
+ def account_locked
25
+ @user = params[:user]
26
+ @duration = ActionAuth.configuration.account_lockout_duration
27
+
28
+ mail to: @user.email, subject: "Your account has been locked"
29
+ end
23
30
  end
24
31
  end
@@ -0,0 +1,18 @@
1
+ module ActionAuth
2
+ # An account on another site that can be used to sign in to this one. Only what
3
+ # identifies it is stored. There are no access tokens here to leak.
4
+ class ConnectedAccount < ApplicationRecord
5
+ self.table_name = "action_auth_connected_accounts"
6
+
7
+ belongs_to :user, class_name: "ActionAuth::User"
8
+
9
+ validates :provider, :uid, presence: true
10
+ validates :uid, uniqueness: { scope: :provider }
11
+
12
+ # The provider may have been removed from the config since this was connected.
13
+ def provider_label
14
+ configured = ActionAuth.configuration.oauth_providers.find { |oauth| oauth.key == provider }
15
+ configured ? configured.label : provider.titleize
16
+ end
17
+ end
18
+ end
@@ -4,8 +4,16 @@ module ActionAuth
4
4
 
5
5
  belongs_to :user, class_name: "ActionAuth::User", foreign_key: "user_id"
6
6
 
7
+ # The controllers refuse these with a message. This is what guarantees that no
8
+ # path, including a new one or an app's own sign in code, can get past that.
9
+ validate on: :create do
10
+ errors.add(:user, "is suspended") if user&.account_suspended?
11
+ end
12
+
7
13
  before_create do
8
- self.user_agent = Current.user_agent
14
+ # Some in-app browsers send very long user agents, and a 255 character
15
+ # column on MySQL would refuse them and fail the sign in.
16
+ self.user_agent = Current.user_agent&.first(255)
9
17
  self.ip_address = Current.ip_address
10
18
  end
11
19
  end
@@ -2,6 +2,9 @@ module ActionAuth
2
2
  class User < ApplicationRecord
3
3
  self.table_name = "users"
4
4
 
5
+ SMS_EMAIL_SUFFIX = "@smsauth".freeze
6
+ OAUTH_EMAIL_SUFFIX = "@oauth".freeze
7
+
5
8
  has_secure_password
6
9
 
7
10
  has_many :sessions, dependent: :destroy,
@@ -12,21 +15,25 @@ module ActionAuth
12
15
  class_name: "ActionAuth::WebauthnCredential", foreign_key: "user_id"
13
16
  end
14
17
 
18
+ # No dependent option, because that would query a table that doesn't exist until
19
+ # the app has run the migration. The foreign key removes these with the user.
20
+ has_many :connected_accounts, class_name: "ActionAuth::ConnectedAccount", foreign_key: "user_id"
21
+
15
22
  generates_token_for :email_verification, expires_in: 2.days do
16
23
  email
17
24
  end
18
25
 
19
26
  generates_token_for :password_reset, expires_in: 20.minutes do
20
- password_salt.last(10)
27
+ [password_salt.last(10), email]
21
28
  end
22
29
 
23
30
  generates_token_for :magic_token, expires_in: 20.minutes do
24
- password_salt.last(10)
31
+ [password_salt.last(10), email, magic_link_used_at]
25
32
  end
26
33
 
27
34
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
28
35
  validates :password, allow_nil: true, length: { minimum: 12 }
29
- validate :password_complexity, if: -> { ActionAuth.configuration.password_complexity_check? && password.present? && !Rails.env.test? }
36
+ validate :password_complexity, if: -> { ActionAuth.configuration.password_complexity_check? && password.present? }
30
37
  validates :phone_number,
31
38
  allow_nil: true,
32
39
  uniqueness: true,
@@ -38,12 +45,28 @@ module ActionAuth
38
45
  normalizes :phone_number, with: -> phone_number { phone_number.gsub(/[^+\d]/, '') }
39
46
  normalizes :email, with: -> email { email.strip.downcase }
40
47
 
48
+ # Always written with the new email, even if this copy already reads false. Otherwise
49
+ # a verification of the old address finishing at the same moment would leave the new
50
+ # address marked as verified.
41
51
  before_validation if: :email_changed?, on: :update do
42
52
  self.verified = false
53
+ verified_will_change!
43
54
  end
44
55
 
45
56
  after_update if: :password_digest_previously_changed? do
46
- sessions.where.not(id: Current.session).delete_all
57
+ sessions.where.not(id: Current.session&.id).delete_all
58
+ unlock_account!
59
+ end
60
+
61
+ # Lockout and suspension are on by default, so they're switched on for apps that
62
+ # have upgraded the gem but not yet run the migration. Until it has run they do
63
+ # nothing, rather than breaking sign in for everyone.
64
+ def self.account_lockout_available?
65
+ ActionAuth.configuration.account_lockout_enabled? && column_names.include?("account_locked_at")
66
+ end
67
+
68
+ def self.account_suspension_available?
69
+ ActionAuth.configuration.account_suspension_enabled? && column_names.include?("account_suspended_at")
47
70
  end
48
71
 
49
72
  def second_factor_enabled?
@@ -51,8 +74,178 @@ module ActionAuth
51
74
  webauthn_credentials.any?
52
75
  end
53
76
 
77
+ # Uses up every magic link sent so far, since their tokens include this value.
78
+ # Returns false to all but one of several simultaneous clicks: the update only
79
+ # matches if nobody has changed these values since this record was loaded. Holding
80
+ # a link also proves the email address is theirs. In a transaction, because a claim
81
+ # that half happened couldn't be tried again: the account would already be verified.
82
+ def claim_magic_link
83
+ address_was_unproven = !verified?
84
+ now = Time.current
85
+
86
+ transaction do
87
+ claimed = self.class.where(id: id, verified: verified, magic_link_used_at: magic_link_used_at)
88
+ .update_all(magic_link_used_at: now, verified: true, updated_at: now)
89
+ next false unless claimed == 1
90
+
91
+ reload
92
+ if address_was_unproven
93
+ self.password = SecureRandom.base58(32)
94
+ save!(validate: false)
95
+ remove_access_from_before_address_was_proven
96
+ end
97
+ true
98
+ end
99
+ end
100
+
101
+ # Verifying takes both the emailed link, which proves the inbox, and the password,
102
+ # which proves it's whoever set up this account. Neither is enough alone. Anyone can
103
+ # sign up with someone else's address and know the password, and that address's owner
104
+ # or their mail scanner can open the link without having signed up at all.
105
+ def verify_email_with_password(password)
106
+ keeping_emailed_address do
107
+ next false if account_locked? || !authenticate_password(password)
108
+
109
+ update!(verified: true)
110
+ end
111
+ end
112
+
113
+ # A reset link proves the inbox too, and it's how an address's owner takes over an
114
+ # account they don't know the password to, so it does what a first magic link does.
115
+ def reset_password_from_emailed_link(attributes)
116
+ keeping_emailed_address do
117
+ next false unless update(attributes)
118
+ next true if verified?
119
+
120
+ update_column(:verified, true)
121
+ remove_access_from_before_address_was_proven
122
+ true
123
+ end
124
+ end
125
+
126
+ # An account made by signing in with SMS, or through a provider that doesn't
127
+ # share an email address, has a random password and a placeholder email address,
128
+ # so nobody can know that password or reset it.
129
+ def password_usable?
130
+ !email.end_with?(SMS_EMAIL_SUFFIX, OAUTH_EMAIL_SUFFIX)
131
+ end
132
+
133
+ # How someone without a usable password proves it's them.
134
+ def fresh_sign_in_method
135
+ email.end_with?(OAUTH_EMAIL_SUFFIX) ? "your connected account" : "a new SMS code"
136
+ end
137
+
138
+ # Whether disconnecting +connected_account+ would leave a way to sign in.
139
+ def can_sign_in_without?(connected_account)
140
+ config = ActionAuth.configuration
141
+
142
+ password_usable? ||
143
+ connected_accounts.where.not(id: connected_account.id).exists? ||
144
+ (config.passkey_only? && webauthn_credentials.exists?) ||
145
+ (config.sms_auth_enabled? && phone_number.present?)
146
+ end
147
+
148
+ # Locks out the guessable factors, passwords and SMS codes. A magic link, passkey
149
+ # or connected account can't be guessed, so they still work. Without that, anyone
150
+ # who knew an email address could keep its owner out by failing on purpose.
151
+ def account_locked?
152
+ return false unless self.class.account_lockout_available? && account_locked_at
153
+
154
+ duration = ActionAuth.configuration.account_lockout_duration
155
+ duration.nil? || account_locked_at > duration.ago
156
+ end
157
+
158
+ # Counts a failed guess, and returns true if it's the one that locked the account.
159
+ # Uses single statements so that simultaneous guesses are all counted and only one
160
+ # of them locks the account, and an attempt during a lock doesn't extend it.
161
+ def register_failed_sign_in
162
+ unless self.class.account_lockout_available?
163
+ warn_about_lockout_migration
164
+ return false
165
+ end
166
+ return false if account_locked?
167
+
168
+ config = ActionAuth.configuration
169
+ account = self.class.where(id: id)
170
+
171
+ # Failures from a lock that has run out don't count towards the next one.
172
+ if config.account_lockout_duration
173
+ account.where(account_locked_at: ..config.account_lockout_duration.ago)
174
+ .update_all(account_locked_at: nil, failed_sign_in_attempts: 0)
175
+ end
176
+
177
+ self.class.update_counters(id, failed_sign_in_attempts: 1)
178
+ locked = account.where(account_locked_at: nil, failed_sign_in_attempts: config.account_lockout_max_attempts..)
179
+ .update_all(account_locked_at: Time.current)
180
+
181
+ reload
182
+ locked == 1
183
+ end
184
+
185
+ # Also runs whenever someone signs in or changes the password, which ends any
186
+ # lock and restarts the count.
187
+ def unlock_account!
188
+ return unless self.class.account_lockout_available?
189
+ return if failed_sign_in_attempts.zero? && account_locked_at.nil?
190
+
191
+ update_columns(failed_sign_in_attempts: 0, account_locked_at: nil)
192
+ end
193
+
194
+ def account_suspended?
195
+ self.class.account_suspension_available? && account_suspended_at.present?
196
+ end
197
+
198
+ # Blocks every way of signing in and signs the account out everywhere, until
199
+ # unsuspend_account!. Skips validations, because an old account that no longer
200
+ # passes them still has to be suspendable. Raises if it can't take effect, since
201
+ # someone suspending an account needs to know it didn't work.
202
+ def suspend_account!
203
+ unless self.class.account_suspension_available?
204
+ raise "ActionAuth can't suspend accounts. Check config.account_suspension_enabled, and run: " \
205
+ "bin/rails action_auth:install:migrations db:migrate"
206
+ end
207
+
208
+ transaction do
209
+ self.account_suspended_at = Time.current
210
+ save!(validate: false)
211
+ sessions.delete_all
212
+ end
213
+ end
214
+
215
+ def unsuspend_account!
216
+ self.account_suspended_at = nil
217
+ save!(validate: false)
218
+ end
219
+
54
220
  private
55
221
 
222
+ # Until now nobody had shown this address is theirs, so whoever set this account
223
+ # up may be a stranger: anyone can sign up with someone else's address, or change
224
+ # their own account to it. The address's owner mustn't end up sharing the account
225
+ # with them, so nothing from before this point can still get in.
226
+ #
227
+ # Security keys stay. They're still asked for after a magic link, so a stranger's
228
+ # key keeps the owner out rather than letting the two share an account, and removing
229
+ # keys would let someone holding only the inbox get past a second factor.
230
+ def remove_access_from_before_address_was_proven
231
+ sessions.delete_all
232
+ connected_accounts.destroy_all if ConnectedAccount.table_exists?
233
+ end
234
+
235
+ # A link only proves the address it was sent to. Hashing a password is slow enough
236
+ # for someone to change the email in the meantime, so this holds the row and checks
237
+ # that the address is still the one the link was checked against.
238
+ def keeping_emailed_address
239
+ address = email
240
+ with_lock { email == address && yield }
241
+ end
242
+
243
+ def warn_about_lockout_migration
244
+ return unless ActionAuth.configuration.account_lockout_enabled?
245
+
246
+ Rails.logger.warn "ActionAuth account lockout isn't active yet. Run: bin/rails action_auth:install:migrations db:migrate"
247
+ end
248
+
56
249
  def password_complexity
57
250
  return if password.blank?
58
251
 
@@ -2,6 +2,8 @@ module ActionAuth
2
2
  class WebauthnCredential < ApplicationRecord
3
3
  self.table_name = "webauthn_credentials"
4
4
 
5
+ belongs_to :user, class_name: "ActionAuth::User", optional: true
6
+
5
7
  validates :external_id, :public_key, :nickname, :sign_count, presence: true
6
8
  validates :external_id, uniqueness: true
7
9
  validates :sign_count,
@@ -0,0 +1,23 @@
1
+ <%= render "action_auth/shared/flash" %>
2
+
3
+ <h1>Verify your email</h1>
4
+
5
+ <p>Enter the password for this account to confirm that <strong><%= @user.email %></strong> is yours.</p>
6
+
7
+ <%= form_with(url: identity_email_verification_path, method: :patch) do |form| %>
8
+ <%= form.hidden_field :sid, value: params[:sid] %>
9
+
10
+ <div class="mb-3">
11
+ <%= form.label :password, style: "display: block" %>
12
+ <%= form.password_field :password, required: true, autofocus: true, autocomplete: "current-password" %>
13
+ </div>
14
+
15
+ <div>
16
+ <%= form.submit "Verify email", class: "btn btn-primary" %>
17
+ </div>
18
+ <% end %>
19
+
20
+ <p>
21
+ Don't know the password? You can <%= link_to "reset it", new_identity_password_reset_path %>, which also verifies this
22
+ address. If you didn't sign up, you can ignore this and nothing will change.
23
+ </p>
@@ -1,50 +1,49 @@
1
+ <%= render "action_auth/shared/flash" %>
1
2
 
2
- <% if user_signed_in? && ActionAuth::Current.user.verified? %>
3
- <% header_text = "Change Your Email" %>
4
- <% label_text = "New email" %>
5
- <% button_text = "Save changes" %>
6
- <% form_url = identity_email_path %>
7
- <% form_method = :patch %>
8
- <% else %>
9
- <% header_text = "Verify Your Email" %>
10
- <% label_text = "Email" %>
11
- <% button_text = "Send verification email" %>
12
- <% form_url = identity_email_verification_path %>
13
- <% form_method = :post %>
14
- <% end %>
15
- <h1><%= header_text %></h1>
16
-
17
- <p style="color: red"><%= alert %></p>
18
-
19
- <%= form_with(url: form_url, method: form_method) do |form| %>
20
- <% if @user&.errors&.any? %>
21
- <div style="color: red">
22
- <h2><%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:</h2>
23
- <ul>
24
- <% @user.errors.each do |error| %>
25
- <li><%= error.full_message %></li>
26
- <% end %>
27
- </ul>
3
+ <% if user_signed_in? %>
4
+ <h1>Change Your Email</h1>
5
+
6
+ <%= form_with(url: identity_email_path, method: :patch) do |form| %>
7
+ <%= render "action_auth/shared/errors", record: @user %>
8
+
9
+ <div class="mb-3">
10
+ <%= form.label :email, "New email", style: "display: block" %>
11
+ <%= form.email_field :email, value: params[:email], required: true, autofocus: true, autocomplete: "email" %>
12
+ </div>
13
+
14
+ <div class="mb-3">
15
+ <%= form.label :password_challenge, "Current password", style: "display: block" %>
16
+ <%= form.password_field :password_challenge, required: true, autocomplete: "current-password" %>
17
+ </div>
18
+
19
+ <div class="mb-3">
20
+ <%= form.submit "Save changes", class: "btn btn-primary" %>
21
+ </div>
22
+ <% end %>
23
+
24
+ <% unless current_user.verified? %>
25
+ <div class="mb-3">
26
+ <p><%= current_user.email %> hasn't been verified yet.</p>
27
+ <%= button_to "Resend verification email", identity_email_verification_path, params: { email: current_user.email }, class: "btn btn-primary" %>
28
28
  </div>
29
29
  <% end %>
30
30
 
31
- <div class="mb-3">
32
- <%= form.label :email, label_text, style: "display: block" %>
33
- <%= form.email_field :email, required: true, autofocus: true %>
31
+ <div>
32
+ <%= link_to "Back", sessions_path %>
34
33
  </div>
34
+ <% else %>
35
+ <h1>Verify Your Email</h1>
35
36
 
36
- <div class="mb-3">
37
- <% if user_signed_in? && ActionAuth::Current.user.verified? %>
38
- <%= form.submit button_text, class: "btn btn-primary" %>
39
- <% else %>
40
- <%= button_to button_text, identity_email_verification_path, class: "btn btn-primary" %>
41
- <% end %>
37
+ <%= form_with(url: identity_email_verification_path) do |form| %>
38
+ <div class="mb-3">
39
+ <%= form.label :email, style: "display: block" %>
40
+ <%= form.email_field :email, required: true, autofocus: true, autocomplete: "email" %>
41
+ </div>
42
42
 
43
- </div>
44
- <% end %>
43
+ <div class="mb-3">
44
+ <%= form.submit "Send verification email", class: "btn btn-primary" %>
45
+ </div>
46
+ <% end %>
45
47
 
46
- <div class="mb-3">
47
- <%= link_to "Sign In", sign_in_path %> |
48
- <%= link_to "Sign Up", sign_up_path %> |
49
- <%= link_to "Reset Password", new_identity_password_reset_path %>
50
- </div>
48
+ <%= render "action_auth/shared/links" %>
49
+ <% end %>