action_auth 1.8.9 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -0
- data/README.md +485 -36
- data/Rakefile +0 -2
- data/app/assets/javascripts/action_auth/application.js +72 -71
- data/app/assets/stylesheets/action_auth/application.css +38 -1
- data/app/controllers/action_auth/application_controller.rb +122 -1
- data/app/controllers/action_auth/connected_accounts_controller.rb +16 -0
- data/app/controllers/action_auth/identity/email_verifications_controller.rb +36 -5
- data/app/controllers/action_auth/identity/emails_controller.rb +16 -4
- data/app/controllers/action_auth/identity/password_resets_controller.rb +20 -22
- data/app/controllers/action_auth/magics/requests_controller.rb +27 -6
- data/app/controllers/action_auth/magics/sign_ins_controller.rb +22 -7
- data/app/controllers/action_auth/passwords_controller.rb +7 -18
- data/app/controllers/action_auth/provider_callbacks_controller.rb +147 -0
- data/app/controllers/action_auth/registrations_controller.rb +7 -25
- data/app/controllers/action_auth/sessions/passkeys_controller.rb +17 -9
- data/app/controllers/action_auth/sessions_controller.rb +27 -25
- data/app/controllers/action_auth/sms_auths/requests_controller.rb +40 -14
- data/app/controllers/action_auth/sms_auths/sign_ins_controller.rb +40 -15
- data/app/controllers/action_auth/users_controller.rb +35 -1
- data/app/controllers/action_auth/webauthn_credential_authentications_controller.rb +36 -51
- data/app/controllers/action_auth/webauthn_credentials_controller.rb +63 -51
- data/app/helpers/action_auth/application_helper.rb +83 -0
- data/app/mailers/action_auth/application_mailer.rb +1 -1
- data/app/mailers/action_auth/user_mailer.rb +7 -0
- data/app/models/action_auth/connected_account.rb +18 -0
- data/app/models/action_auth/session.rb +9 -1
- data/app/models/action_auth/user.rb +197 -4
- data/app/models/action_auth/webauthn_credential.rb +2 -0
- data/app/views/action_auth/identity/email_verifications/show.html.erb +23 -0
- data/app/views/action_auth/identity/emails/edit.html.erb +41 -42
- data/app/views/action_auth/identity/password_resets/edit.html.erb +10 -11
- data/app/views/action_auth/identity/password_resets/new.html.erb +4 -9
- data/app/views/action_auth/magics/requests/new.html.erb +4 -14
- data/app/views/action_auth/magics/sign_ins/show.html.erb +18 -0
- data/app/views/action_auth/passwords/edit.html.erb +9 -21
- data/app/views/action_auth/registrations/new.html.erb +5 -27
- data/app/views/action_auth/sessions/_account.html.erb +30 -0
- data/app/views/action_auth/sessions/_connected_accounts.html.erb +32 -0
- data/app/views/action_auth/sessions/_security_keys.html.erb +32 -0
- data/app/views/action_auth/sessions/_sessions.html.erb +28 -0
- data/app/views/action_auth/sessions/index.html.erb +10 -62
- data/app/views/action_auth/sessions/new.html.erb +3 -21
- data/app/views/action_auth/sessions/passkeys/new.html.erb +10 -4
- data/app/views/action_auth/shared/_errors.html.erb +10 -0
- data/app/views/action_auth/shared/_flash.html.erb +11 -0
- data/app/views/action_auth/shared/_links.html.erb +3 -0
- data/app/views/action_auth/shared/_oauth_buttons.html.erb +5 -0
- data/app/views/action_auth/shared/_webauthn_error.html.erb +1 -0
- data/app/views/action_auth/sms_auths/requests/new.html.erb +4 -14
- data/app/views/action_auth/sms_auths/sign_ins/show.html.erb +6 -2
- data/app/views/action_auth/user_mailer/account_locked.html.erb +17 -0
- data/app/views/action_auth/user_mailer/account_locked.text.erb +12 -0
- data/app/views/action_auth/user_mailer/email_verification.html.erb +5 -0
- data/app/views/action_auth/user_mailer/email_verification.text.erb +3 -0
- data/app/views/action_auth/user_mailer/magic_link.html.erb +4 -0
- data/app/views/action_auth/user_mailer/magic_link.text.erb +5 -0
- data/app/views/action_auth/user_mailer/password_reset.text.erb +1 -1
- data/app/views/action_auth/webauthn_credential_authentications/new.html.erb +9 -3
- data/app/views/action_auth/webauthn_credentials/new.html.erb +10 -3
- data/app/views/layouts/action_auth/application.html.erb +2 -7
- data/config/routes.rb +13 -3
- data/db/migrate/20261105120000_add_magic_link_used_at_to_users.rb +5 -0
- data/db/migrate/20261105130000_add_account_lockout_and_suspension_to_users.rb +7 -0
- data/db/migrate/20261105130100_create_action_auth_connected_accounts.rb +19 -0
- data/lib/action_auth/configuration.rb +96 -0
- data/lib/action_auth/controllers/helpers.rb +44 -11
- data/lib/action_auth/engine.rb +27 -1
- data/lib/action_auth/routing/helpers.rb +8 -0
- data/lib/action_auth/version.rb +1 -1
- data/lib/generators/action_auth/views/USAGE +8 -0
- data/lib/generators/action_auth/views/views_generator.rb +59 -0
- data/lib/tasks/action_auth_tasks.rake +19 -1
- metadata +28 -5
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
module ActionAuth
|
|
2
|
+
class WebauthnCredentialsController < ApplicationController
|
|
3
|
+
before_action :authenticate_user!
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
format.
|
|
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
|
-
|
|
35
|
-
webauthn_credential
|
|
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
|
-
|
|
57
|
-
|
|
51
|
+
def destroy
|
|
52
|
+
current_user.webauthn_credentials.destroy(params[:id])
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
redirect_to sessions_path, status: :see_other
|
|
55
|
+
end
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
private
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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?
|
|
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?
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<%=
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
32
|
-
<%=
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
<%= form.
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
43
|
+
<div class="mb-3">
|
|
44
|
+
<%= form.submit "Send verification email", class: "btn btn-primary" %>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
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 %>
|