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,20 +1,22 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class PasswordsController < ApplicationController
|
|
3
|
+
before_action :authenticate_user!
|
|
3
4
|
before_action :set_user
|
|
4
|
-
before_action :validate_pwned_password, only: :update
|
|
5
5
|
|
|
6
6
|
rate_limit to: 3,
|
|
7
7
|
within: 60.seconds,
|
|
8
8
|
only: :update,
|
|
9
9
|
name: "password-reset-throttle",
|
|
10
|
-
with: -> { redirect_to
|
|
10
|
+
with: -> { redirect_to edit_password_path, alert: "Too many password change attempts. Try again later.", status: :see_other }
|
|
11
|
+
|
|
12
|
+
before_action :validate_new_password, only: :update
|
|
11
13
|
|
|
12
14
|
def edit
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def update
|
|
16
18
|
if @user.update(user_params)
|
|
17
|
-
redirect_to
|
|
19
|
+
redirect_to sessions_path, notice: "Your password has been changed", status: :see_other
|
|
18
20
|
else
|
|
19
21
|
render :edit, status: :unprocessable_entity
|
|
20
22
|
end
|
|
@@ -30,21 +32,8 @@ module ActionAuth
|
|
|
30
32
|
params.permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "")
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# Check minimum password requirements
|
|
37
|
-
if params[:password].present? && ActionAuth.configuration.password_complexity_check? && !Rails.env.test? &&
|
|
38
|
-
(params[:password] !~ /[A-Z]/ || params[:password] !~ /[a-z]/ || params[:password] !~ /[0-9]/ || params[:password] !~ /[^A-Za-z0-9]/)
|
|
39
|
-
@user.errors.add(:password, "must include at least one uppercase letter, one lowercase letter, one number, and one special character.")
|
|
40
|
-
render :edit, status: :unprocessable_entity and return
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
pwned = Pwned::Password.new(params[:password])
|
|
44
|
-
if pwned.pwned?
|
|
45
|
-
@user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.")
|
|
46
|
-
render :edit, status: :unprocessable_entity
|
|
47
|
-
end
|
|
35
|
+
def validate_new_password
|
|
36
|
+
reject_unusable_password(@user, :edit)
|
|
48
37
|
end
|
|
49
38
|
end
|
|
50
39
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
module ActionAuth
|
|
2
|
+
# Where someone comes back to after approving a sign in on another site. By the time
|
|
3
|
+
# create runs, the OmniAuth strategy has checked that this browser started the flow.
|
|
4
|
+
class ProviderCallbacksController < ApplicationController
|
|
5
|
+
FAILED = "We couldn't connect to that provider. Please try again.".freeze
|
|
6
|
+
|
|
7
|
+
# Some providers POST the callback from their own site, so it can't carry a token
|
|
8
|
+
# from ours. Nothing happens here without omniauth.auth, which only the strategy can
|
|
9
|
+
# set, and only after it has checked the OAuth state.
|
|
10
|
+
skip_forgery_protection only: :create
|
|
11
|
+
|
|
12
|
+
rate_limit to: 10,
|
|
13
|
+
within: 1.minute,
|
|
14
|
+
only: :create,
|
|
15
|
+
name: "oauth-callback-throttle",
|
|
16
|
+
with: -> { redirect_to sign_in_path, alert: "Too many attempts. Try again later." }
|
|
17
|
+
|
|
18
|
+
before_action :require_connected_accounts_migration, only: :create
|
|
19
|
+
before_action :require_valid_response, only: :create
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
connected_account = ConnectedAccount.find_by(provider: provider_key, uid: uid)
|
|
23
|
+
|
|
24
|
+
if user_signed_in?
|
|
25
|
+
connect(connected_account)
|
|
26
|
+
elsif connected_account
|
|
27
|
+
complete_sign_in(connected_account.user, notice: "Signed in successfully")
|
|
28
|
+
else
|
|
29
|
+
sign_up
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# What's sent in the message parameter can't be trusted, so it's never displayed.
|
|
34
|
+
def failure
|
|
35
|
+
if params[:message] == "authenticity_error"
|
|
36
|
+
Rails.logger.error "ActionAuth OAuth needs the omniauth-rails_csrf_protection gem, or the form was missing its token"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
redirect_to user_signed_in? ? sessions_path : sign_in_path, alert: FAILED, status: :see_other
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def auth
|
|
45
|
+
request.env["omniauth.auth"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def provider_key
|
|
49
|
+
auth.provider.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def uid
|
|
53
|
+
auth.uid.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def provider_label
|
|
57
|
+
ActionAuth.configuration.oauth_providers.find { |oauth| oauth.key == provider_key }.label
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An app can also run OmniAuth for its own purposes, and an identity from one of
|
|
61
|
+
# those providers isn't one that ActionAuth was asked to trust for sign in.
|
|
62
|
+
def require_valid_response
|
|
63
|
+
return if auth && uid.present? && ActionAuth.configuration.oauth_providers.any? { |oauth| oauth.key == provider_key }
|
|
64
|
+
|
|
65
|
+
failure
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def require_connected_accounts_migration
|
|
69
|
+
return if ConnectedAccount.table_exists?
|
|
70
|
+
|
|
71
|
+
raise "ActionAuth OAuth needs a new migration. Run: bin/rails action_auth:install:migrations db:migrate"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connect(connected_account)
|
|
75
|
+
if connected_account&.user_id == Current.user.id
|
|
76
|
+
redirect_to sessions_path, notice: "#{provider_label} is already connected to your account."
|
|
77
|
+
elsif connected_account.nil? && create_connected_account(Current.user)
|
|
78
|
+
redirect_to sessions_path, notice: "#{provider_label} has been connected to your account."
|
|
79
|
+
else
|
|
80
|
+
redirect_to sessions_path, alert: "That #{provider_label} account is connected to a different account."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_connected_account(user)
|
|
85
|
+
user.connected_accounts.create!(provider: provider_key, uid: uid, display_name: display_name)
|
|
86
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Only for showing on the management screen. Cut to fit, because a 255 character
|
|
91
|
+
# column on MySQL would refuse a long one and fail the sign in over nothing.
|
|
92
|
+
def display_name
|
|
93
|
+
info = auth.info
|
|
94
|
+
|
|
95
|
+
(info&.email.presence || info&.nickname.presence || info&.name.presence)&.first(255)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Having the same email address isn't enough to sign in to an existing account.
|
|
99
|
+
# That would rely on every provider having verified it, and on nobody having signed
|
|
100
|
+
# up here with an address they don't own and kept the password. Signing in first
|
|
101
|
+
# proves it's the same person, and takes one step.
|
|
102
|
+
def sign_up
|
|
103
|
+
email = provider_email
|
|
104
|
+
|
|
105
|
+
if email && User.exists?(email: email)
|
|
106
|
+
return redirect_to(sign_in_path, alert: "There's already an account with that email address. " \
|
|
107
|
+
"Sign in, then connect #{provider_label} from your account page.")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
user = create_user(email)
|
|
111
|
+
|
|
112
|
+
if user
|
|
113
|
+
complete_sign_in(user, notice: "Welcome! You have signed up successfully")
|
|
114
|
+
else
|
|
115
|
+
redirect_to sign_in_path, alert: FAILED
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Providers may not share an address, or may flag it as unverified, in which case
|
|
120
|
+
# it says nothing about who this is. Those accounts get a placeholder, as SMS does.
|
|
121
|
+
def provider_email
|
|
122
|
+
info = auth.info
|
|
123
|
+
email = info&.email.to_s.strip
|
|
124
|
+
flagged = ActiveModel::Type::Boolean.new.cast(info&.email_verified) == false
|
|
125
|
+
|
|
126
|
+
email if !flagged && email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create_user(email)
|
|
130
|
+
password = generate_compliant_password
|
|
131
|
+
|
|
132
|
+
User.transaction do
|
|
133
|
+
User.create!(
|
|
134
|
+
email: email || "#{SecureRandom.hex(32)}#{User::OAUTH_EMAIL_SUFFIX}",
|
|
135
|
+
password: password,
|
|
136
|
+
password_confirmation: password,
|
|
137
|
+
verified: true
|
|
138
|
+
).tap do |user|
|
|
139
|
+
user.connected_accounts.create!(provider: provider_key, uid: uid, display_name: display_name)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
# Someone else got there first, such as a double click on the provider's page.
|
|
143
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class RegistrationsController < ApplicationController
|
|
3
|
-
before_action :validate_pwned_password, only: :create
|
|
4
|
-
|
|
5
3
|
rate_limit to: 3,
|
|
6
4
|
within: 60.seconds,
|
|
7
5
|
only: :create,
|
|
8
6
|
name: "registration-throttle",
|
|
9
7
|
with: -> { redirect_to new_user_registration_path, alert: "Too many registration attempts. Try again later." }
|
|
10
8
|
|
|
9
|
+
before_action :validate_new_password, only: :create
|
|
10
|
+
|
|
11
11
|
def new
|
|
12
12
|
@user = User.new
|
|
13
13
|
end
|
|
@@ -20,13 +20,8 @@ module ActionAuth
|
|
|
20
20
|
send_email_verification
|
|
21
21
|
redirect_to sign_in_path, notice: "Welcome! You have signed up successfully. Please check your email to verify your account."
|
|
22
22
|
else
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
cookie_options[:secure] = Rails.env.production? if Rails.env.production?
|
|
26
|
-
cookie_options[:same_site] = :lax unless Rails.env.test?
|
|
27
|
-
cookies.signed.permanent[:session_token] = cookie_options
|
|
28
|
-
|
|
29
|
-
redirect_to sign_in_path, notice: "Welcome! You have signed up successfully"
|
|
23
|
+
start_session_for(@user)
|
|
24
|
+
redirect_to after_sign_in_path, notice: "Welcome! You have signed up successfully"
|
|
30
25
|
end
|
|
31
26
|
else
|
|
32
27
|
render :new, status: :unprocessable_entity
|
|
@@ -43,22 +38,9 @@ module ActionAuth
|
|
|
43
38
|
UserMailer.with(user: @user).email_verification.deliver_later
|
|
44
39
|
end
|
|
45
40
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if params[:password].present? && !Rails.env.test? && (params[:password] !~ /[A-Z]/ || params[:password] !~ /[a-z]/ || params[:password] !~ /[0-9]/ || params[:password] !~ /[^A-Za-z0-9]/)
|
|
50
|
-
@user = User.new(email: params[:email])
|
|
51
|
-
@user.errors.add(:password, "must include at least one uppercase letter, one lowercase letter, one number, and one special character.")
|
|
52
|
-
render :new, status: :unprocessable_entity and return
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
pwned = Pwned::Password.new(params[:password])
|
|
56
|
-
|
|
57
|
-
if pwned.pwned?
|
|
58
|
-
@user = User.new(email: params[:email])
|
|
59
|
-
@user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.")
|
|
60
|
-
render :new, status: :unprocessable_entity
|
|
61
|
-
end
|
|
41
|
+
def validate_new_password
|
|
42
|
+
@user = User.new(email: string_param(:email))
|
|
43
|
+
reject_unusable_password(@user, :new)
|
|
62
44
|
end
|
|
63
45
|
end
|
|
64
46
|
end
|
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
module Sessions
|
|
3
3
|
class PasskeysController < ApplicationController
|
|
4
|
+
rate_limit to: 5,
|
|
5
|
+
within: 20.seconds,
|
|
6
|
+
only: :create,
|
|
7
|
+
name: "passkey-throttle",
|
|
8
|
+
with: -> { render json: "Too many attempts. Try again later.", status: :too_many_requests }
|
|
9
|
+
|
|
4
10
|
def new
|
|
5
|
-
get_options = WebAuthn::Credential.options_for_get
|
|
11
|
+
get_options = WebAuthn::Credential.options_for_get(user_verification: "required")
|
|
6
12
|
session[:current_challenge] = get_options.challenge
|
|
7
13
|
@options = get_options
|
|
8
14
|
end
|
|
9
15
|
|
|
16
|
+
# A passkey is the only factor on this path, so it has to prove the user
|
|
17
|
+
# was verified on the device (biometric or PIN), not just present.
|
|
10
18
|
def create
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
credential = verify_webauthn_assertion(WebauthnCredential, user_verification: true)
|
|
20
|
+
|
|
21
|
+
if credential&.user&.account_suspended?
|
|
22
|
+
render json: ACCOUNT_SUSPENDED_MESSAGE, status: :unprocessable_entity
|
|
23
|
+
elsif credential&.user
|
|
24
|
+
start_session_for(credential.user)
|
|
25
|
+
render json: { status: "ok", redirect_url: after_sign_in_path }
|
|
18
26
|
else
|
|
19
|
-
|
|
27
|
+
render json: "That passkey is incorrect", status: :unprocessable_entity
|
|
20
28
|
end
|
|
21
29
|
end
|
|
22
30
|
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class SessionsController < ApplicationController
|
|
3
|
-
before_action :set_current_request_details
|
|
4
3
|
before_action :authenticate_user!, only: [:index, :destroy]
|
|
5
4
|
|
|
6
5
|
rate_limit to: 5,
|
|
@@ -17,35 +16,38 @@ module ActionAuth
|
|
|
17
16
|
def new
|
|
18
17
|
end
|
|
19
18
|
|
|
19
|
+
# A locked account gets the same answer whether or not the password is right.
|
|
20
|
+
# Saying "locked" only to the right password would let guessing carry on through
|
|
21
|
+
# the lock, and saying it to anyone would reveal who has an account. The account's
|
|
22
|
+
# owner finds out by email instead.
|
|
20
23
|
def create
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@session = user.sessions.create
|
|
28
|
-
session_token_hash = { value: @session.id, httponly: true }
|
|
29
|
-
session_token_hash[:secure] = Rails.env.production? if Rails.env.production?
|
|
30
|
-
session_token_hash[:same_site] = :lax unless Rails.env.test?
|
|
31
|
-
session_token_hash[:domain] = :all if ActionAuth.configuration.insert_cookie_domain
|
|
32
|
-
cookies.signed.permanent[:session_token] = session_token_hash
|
|
33
|
-
redirect_to main_app.root_path, notice: "Signed in successfully"
|
|
34
|
-
end
|
|
24
|
+
user = User.authenticate_by(email: string_param(:email), password: string_param(:password))
|
|
25
|
+
|
|
26
|
+
if user && !user.account_locked?
|
|
27
|
+
return if check_if_email_is_verified(user)
|
|
28
|
+
|
|
29
|
+
complete_sign_in(user, notice: "Signed in successfully")
|
|
35
30
|
else
|
|
36
|
-
|
|
31
|
+
# An empty form isn't a guess.
|
|
32
|
+
record_failed_sign_in(user || User.find_by(email: string_param(:email))) if string_param(:password).present?
|
|
33
|
+
|
|
34
|
+
redirect_to sign_in_path(email_hint: string_param(:email)), alert: "That email or password is incorrect"
|
|
37
35
|
end
|
|
38
36
|
end
|
|
39
37
|
|
|
40
38
|
def destroy
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
user_session = Current.user.sessions.find(params[:id])
|
|
40
|
+
user_session.destroy
|
|
41
|
+
|
|
42
|
+
if user_session == Current.session
|
|
43
|
+
Current.session = nil
|
|
44
|
+
delete_action_auth_session_cookie
|
|
45
|
+
response.headers["Clear-Site-Data"] = '"cache","storage"'
|
|
46
|
+
redirect_to after_sign_out_path, notice: "That session has been logged out", status: :see_other
|
|
47
|
+
else
|
|
48
|
+
# Another device was signed out. This one stays signed in.
|
|
49
|
+
redirect_to sessions_path, notice: "That session has been logged out", status: :see_other
|
|
50
|
+
end
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
private
|
|
@@ -54,7 +56,7 @@ module ActionAuth
|
|
|
54
56
|
return false unless ActionAuth.configuration.verify_email_on_sign_in
|
|
55
57
|
return false if user.verified?
|
|
56
58
|
|
|
57
|
-
redirect_to sign_in_path(email_hint:
|
|
59
|
+
redirect_to sign_in_path(email_hint: string_param(:email)),
|
|
58
60
|
alert: "You must verify your email before you sign in."
|
|
59
61
|
end
|
|
60
62
|
end
|
|
@@ -1,31 +1,57 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class SmsAuths::RequestsController < ApplicationController
|
|
3
|
+
TOO_MANY_REQUESTS = -> { redirect_to new_sms_auths_requests_path, alert: "Too many requests. Try again later." }
|
|
4
|
+
|
|
5
|
+
# Every message sent costs money, so limit both how fast one client can send
|
|
6
|
+
# to many numbers and how often any one number can be texted.
|
|
7
|
+
rate_limit to: 3, within: 1.minute, only: :create, name: "sms-request-throttle", with: TOO_MANY_REQUESTS
|
|
8
|
+
rate_limit to: 3, within: 5.minutes, only: :create, name: "sms-number-throttle",
|
|
9
|
+
by: -> { User.normalize_value_for(:phone_number, string_param(:phone_number)) }, with: TOO_MANY_REQUESTS
|
|
10
|
+
|
|
3
11
|
def new
|
|
4
12
|
end
|
|
5
13
|
|
|
6
14
|
def create
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
# Never nil: searching for a nil phone number would match every account that
|
|
16
|
+
# doesn't have one.
|
|
17
|
+
@user = User.find_or_initialize_by(phone_number: string_param(:phone_number))
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
@user.
|
|
13
|
-
@user.password_confirmation =
|
|
19
|
+
if @user.new_record?
|
|
20
|
+
@user.email = "#{SecureRandom.hex(32)}#{User::SMS_EMAIL_SUFFIX}"
|
|
21
|
+
@user.password = @user.password_confirmation = generate_compliant_password
|
|
14
22
|
@user.verified = false
|
|
15
23
|
|
|
16
|
-
@user.save
|
|
24
|
+
unless @user.save
|
|
25
|
+
flash.now[:alert] = @user.errors.full_messages_for(:phone_number).to_sentence
|
|
26
|
+
return render :new, status: :unprocessable_entity
|
|
27
|
+
end
|
|
17
28
|
end
|
|
18
29
|
|
|
19
|
-
code
|
|
30
|
+
# There's no point paying for a code that couldn't be used. The answer stays the
|
|
31
|
+
# same, so this form doesn't reveal which numbers are locked or suspended.
|
|
32
|
+
send_new_code unless @user.account_locked? || @user.account_suspended?
|
|
33
|
+
cookies.signed[:sms_auth_phone_number] = action_auth_session_cookie_options.merge(value: @user.phone_number)
|
|
34
|
+
|
|
35
|
+
redirect_to sms_auths_sign_ins_path, notice: "Check your phone for a SMS Code."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def send_new_code
|
|
41
|
+
code = SecureRandom.random_number(100_000..999_999)
|
|
20
42
|
@user.update!(sms_code: code, sms_code_sent_at: Time.current)
|
|
21
|
-
cookies.signed[:sms_auth_phone_number] = { value: @user.phone_number, httponly: true }
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
ActionAuth.configuration.sms_send_class.send_code(@user.phone_number, code)
|
|
26
|
-
end
|
|
44
|
+
send_code(@user.phone_number, code)
|
|
45
|
+
end
|
|
27
46
|
|
|
28
|
-
|
|
47
|
+
def send_code(phone_number, code)
|
|
48
|
+
sender = ActionAuth.configuration.sms_send_class
|
|
49
|
+
|
|
50
|
+
if sender.respond_to?(:send_code)
|
|
51
|
+
sender.send_code(phone_number, code)
|
|
52
|
+
else
|
|
53
|
+
Rails.logger.error "ActionAuth could not send an SMS code: config.sms_send_class must respond to send_code"
|
|
54
|
+
end
|
|
29
55
|
end
|
|
30
56
|
end
|
|
31
57
|
end
|
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class SmsAuths::SignInsController < ApplicationController
|
|
3
|
+
CODE_LIFETIME = 5.minutes
|
|
4
|
+
|
|
5
|
+
TOO_MANY_ATTEMPTS = -> { redirect_to sign_in_path, alert: "Too many attempts. Try again later." }
|
|
6
|
+
|
|
7
|
+
# A six digit code is only safe if it can't be guessed at speed, so limit
|
|
8
|
+
# guesses per client and against any one phone number.
|
|
9
|
+
rate_limit to: 5, within: 1.minute, only: :create, name: "sms-sign-in-throttle", with: TOO_MANY_ATTEMPTS
|
|
10
|
+
rate_limit to: 5, within: CODE_LIFETIME, only: :create, name: "sms-code-throttle",
|
|
11
|
+
by: -> { cookies.signed[:sms_auth_phone_number].to_s }, with: TOO_MANY_ATTEMPTS
|
|
12
|
+
|
|
3
13
|
def show
|
|
4
|
-
@user = ActionAuth::User.find_by(phone_number: params[:phone_number])
|
|
5
14
|
end
|
|
6
15
|
|
|
16
|
+
# The rate limits above depend on the app's cache. Lockout is the backstop on
|
|
17
|
+
# guessing a code that doesn't, and it says so whether or not the code is right.
|
|
7
18
|
def create
|
|
8
|
-
user =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
user.update(
|
|
17
|
-
verified: true,
|
|
18
|
-
sms_code: nil,
|
|
19
|
-
sms_code_sent_at: nil
|
|
20
|
-
)
|
|
21
|
-
redirect_to main_app.root_path, notice: "Signed In"
|
|
19
|
+
user = user_for_phone_number
|
|
20
|
+
|
|
21
|
+
if user&.account_locked?
|
|
22
|
+
instance_exec(&TOO_MANY_ATTEMPTS)
|
|
23
|
+
elsif user && submitted_code_matches?(user)
|
|
24
|
+
user.update(verified: true, sms_code: nil, sms_code_sent_at: nil)
|
|
25
|
+
cookies.delete(:sms_auth_phone_number, action_auth_session_cookie_options)
|
|
26
|
+
complete_sign_in(user, notice: "Signed In")
|
|
22
27
|
else
|
|
28
|
+
record_failed_sign_in(user) if string_param(:sms_code).present?
|
|
23
29
|
redirect_to sign_in_path, alert: "Authentication failed, please try again."
|
|
24
30
|
end
|
|
25
31
|
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Never searches for a blank number, which would match every account without one.
|
|
36
|
+
def user_for_phone_number
|
|
37
|
+
phone_number = cookies.signed[:sms_auth_phone_number]
|
|
38
|
+
|
|
39
|
+
User.find_by(phone_number: phone_number) if phone_number.present?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Compares one code in Ruby rather than putting it in the query, where an
|
|
43
|
+
# array parameter would let one request try thousands of codes.
|
|
44
|
+
def submitted_code_matches?(user)
|
|
45
|
+
code = string_param(:sms_code)
|
|
46
|
+
return false unless code.present? && user.sms_code.present?
|
|
47
|
+
return false unless user.sms_code_sent_at&.between?(CODE_LIFETIME.ago, Time.current)
|
|
48
|
+
|
|
49
|
+
ActiveSupport::SecurityUtils.secure_compare(user.sms_code, code)
|
|
50
|
+
end
|
|
26
51
|
end
|
|
27
52
|
end
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
module ActionAuth
|
|
2
2
|
class UsersController < ApplicationController
|
|
3
|
+
RECENT_SIGN_IN = 10.minutes
|
|
4
|
+
|
|
3
5
|
before_action :authenticate_user!
|
|
4
6
|
|
|
7
|
+
# Per session, because this is a way to guess the password of whoever left
|
|
8
|
+
# themselves signed in, and that shouldn't get easier from more IP addresses.
|
|
9
|
+
rate_limit to: 5,
|
|
10
|
+
within: 1.minute,
|
|
11
|
+
only: :destroy,
|
|
12
|
+
name: "account-deletion-throttle",
|
|
13
|
+
by: -> { Current.session.id },
|
|
14
|
+
with: -> { redirect_to sessions_path, alert: "Too many attempts. Try again later.", status: :see_other }
|
|
15
|
+
|
|
5
16
|
def destroy
|
|
17
|
+
if Current.user.password_usable?
|
|
18
|
+
return refuse("Your account wasn't deleted because the password was missing or incorrect.") unless password_confirmed?
|
|
19
|
+
else
|
|
20
|
+
return refuse("Please sign in again with #{Current.user.fresh_sign_in_method}, then delete your account within 10 minutes.") unless recently_signed_in?
|
|
21
|
+
end
|
|
22
|
+
|
|
6
23
|
Current.user.destroy
|
|
7
|
-
|
|
24
|
+
delete_action_auth_session_cookie
|
|
25
|
+
redirect_to after_sign_out_path, notice: "Your account has been deleted.", status: :see_other
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def password_confirmed?
|
|
31
|
+
Current.user.authenticate(string_param(:password_challenge))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A new SMS code or provider sign in proves the same thing a password would. This uses created_at
|
|
35
|
+
# because updated_at keeps moving while the session is being used.
|
|
36
|
+
def recently_signed_in?
|
|
37
|
+
Current.session.created_at > RECENT_SIGN_IN.ago
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def refuse(message)
|
|
41
|
+
redirect_to sessions_path, alert: message, status: :see_other
|
|
8
42
|
end
|
|
9
43
|
end
|
|
10
44
|
end
|
|
@@ -1,59 +1,44 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def create
|
|
19
|
-
webauthn_credential = WebAuthn::Credential.from_get(params)
|
|
20
|
-
|
|
21
|
-
credential = user.webauthn_credentials.find_by(external_id: webauthn_credential.id)
|
|
22
|
-
|
|
23
|
-
begin
|
|
24
|
-
webauthn_credential.verify(
|
|
25
|
-
session[:current_challenge],
|
|
26
|
-
public_key: credential.public_key,
|
|
27
|
-
sign_count: credential.sign_count
|
|
28
|
-
)
|
|
1
|
+
module ActionAuth
|
|
2
|
+
class WebauthnCredentialAuthenticationsController < ApplicationController
|
|
3
|
+
before_action :ensure_user_not_authenticated
|
|
4
|
+
before_action :ensure_login_initiated
|
|
5
|
+
|
|
6
|
+
rate_limit to: 5,
|
|
7
|
+
within: 20.seconds,
|
|
8
|
+
only: :create,
|
|
9
|
+
name: "webauthn-throttle",
|
|
10
|
+
with: -> { render json: "Too many attempts. Try again later.", status: :too_many_requests }
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
get_options = WebAuthn::Credential.options_for_get(allow: user.webauthn_credentials.pluck(:external_id))
|
|
14
|
+
session[:current_challenge] = get_options.challenge
|
|
15
|
+
@options = get_options
|
|
16
|
+
end
|
|
29
17
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
rescue WebAuthn::Error => e
|
|
39
|
-
Rails.logger.error "❌ Verification failed: #{e.message}"
|
|
40
|
-
render json: "Verification failed: #{e.message}", status: :unprocessable_entity
|
|
18
|
+
def create
|
|
19
|
+
if verify_webauthn_assertion(user.webauthn_credentials)
|
|
20
|
+
session.delete(:webauthn_user_id)
|
|
21
|
+
start_session_for(user)
|
|
22
|
+
render json: { status: "ok", redirect_url: after_sign_in_path }
|
|
23
|
+
else
|
|
24
|
+
render json: "Verification failed", status: :unprocessable_entity
|
|
25
|
+
end
|
|
41
26
|
end
|
|
42
|
-
end
|
|
43
27
|
|
|
44
|
-
|
|
28
|
+
private
|
|
45
29
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
def user
|
|
31
|
+
@user ||= ActionAuth::User.find_by(id: session[:webauthn_user_id])
|
|
32
|
+
end
|
|
49
33
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
34
|
+
def ensure_login_initiated
|
|
35
|
+
return if user && !user.account_suspended?
|
|
36
|
+
redirect_to sign_in_path
|
|
37
|
+
end
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
def ensure_user_not_authenticated
|
|
40
|
+
return unless current_user
|
|
41
|
+
redirect_to after_sign_in_path
|
|
42
|
+
end
|
|
58
43
|
end
|
|
59
44
|
end
|