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.
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 +122 -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,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 sign_in_path, alert: "Too many password reset attempts. Try again later." }
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 sign_in_path, notice: "Your password has been changed"
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 validate_pwned_password
34
- return unless ActionAuth.configuration.pwned_enabled?
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
- session_record = @user.sessions.create!
24
- cookie_options = { value: session_record.id, httponly: true }
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 validate_pwned_password
47
- return unless ActionAuth.configuration.pwned_enabled?
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
- webauthn_credential = WebAuthn::Credential.from_get(params)
12
- credential = WebauthnCredential.find_by(external_id: webauthn_credential.id)
13
- user = User.find_by(id: credential&.user_id)
14
- if credential && user
15
- session = user.sessions.create
16
- cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }
17
- redirect_to main_app.root_path(format: :html), notice: "Signed in successfully"
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
- redirect_to sign_in_path(format: :html), alert: "That passkey is incorrect" and return
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
- if user = User.authenticate_by(email: params[:email], password: params[:password])
22
- if user.second_factor_enabled?
23
- session[:webauthn_user_id] = user.id
24
- redirect_to new_webauthn_credential_authentications_path
25
- else
26
- return if check_if_email_is_verified(user)
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
- redirect_to sign_in_path(email_hint: params[:email]), alert: "That email or password is incorrect"
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
- session = Current.user.sessions.find(params[:id])
42
- session.destroy
43
- cookie_options = {}
44
- cookie_options[:secure] = Rails.env.production? if Rails.env.production?
45
- cookie_options[:same_site] = :lax unless Rails.env.test?
46
- cookies.delete(:session_token, cookie_options)
47
- response.headers["Clear-Site-Data"] = '"cache","storage"'
48
- redirect_to main_app.root_path, notice: "That session has been logged out"
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: params[:email]),
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
- @user = User.find_or_initialize_by(phone_number: params[:phone_number])
8
- if @user.new_record?
9
- password = generate_compliant_password
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
- @user.email = "#{SecureRandom.hex(32)}@smsauth"
12
- @user.password = password
13
- @user.password_confirmation = password
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 = rand(100_000..999_999)
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
- if defined?(ActionAuth.configuration.sms_send_class) &&
24
- ActionAuth.configuration.sms_send_class.respond_to?(:send_code)
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
- redirect_to sms_auths_sign_ins_path, notice: "Check your phone for a SMS Code."
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 = ActionAuth::User.find_by(
9
- phone_number: cookies.signed[:sms_auth_phone_number],
10
- sms_code: params[:sms_code],
11
- sms_code_sent_at: 5.minutes.ago..Time.current
12
- )
13
- if user
14
- @session = user.sessions.create
15
- cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }
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
- redirect_to main_app.root_url, notice: "Your account has been deleted."
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
- class ActionAuth::WebauthnCredentialAuthenticationsController < ApplicationController
2
- before_action :ensure_user_not_authenticated
3
- before_action :ensure_login_initiated
4
- layout "action_auth/application"
5
-
6
- rate_limit to: 5,
7
- within: 20.seconds,
8
- only: :create,
9
- name: "webauthn-throttle",
10
- with: -> { redirect_to sign_in_path, alert: "Too many attempts. Try again later." }
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
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
- credential.update!(sign_count: webauthn_credential.sign_count)
31
- session.delete(:webauthn_user_id)
32
- session = user.sessions.create
33
- cookie_options = { value: session.id, httponly: true }
34
- cookie_options[:secure] = Rails.env.production? if Rails.env.production?
35
- cookie_options[:same_site] = :lax unless Rails.env.test?
36
- cookies.signed.permanent[:session_token] = cookie_options
37
- render json: { status: "ok" }, status: :ok
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
- private
28
+ private
45
29
 
46
- def user
47
- @user ||= ActionAuth::User.find_by(id: session[:webauthn_user_id])
48
- end
30
+ def user
31
+ @user ||= ActionAuth::User.find_by(id: session[:webauthn_user_id])
32
+ end
49
33
 
50
- def ensure_login_initiated
51
- return unless session[:webauthn_user_id].blank?
52
- redirect_to sign_in_path
53
- end
34
+ def ensure_login_initiated
35
+ return if user && !user.account_suspended?
36
+ redirect_to sign_in_path
37
+ end
54
38
 
55
- def ensure_user_not_authenticated
56
- return unless current_user
57
- redirect_to main_app.root_path
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