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,75 +1,114 @@
1
- import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
2
- window.Stimulus = Application.start();
1
+ import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus@3.2.2/dist/stimulus.js";
2
+ import * as WebAuthnJSON from "https://unpkg.com/@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js";
3
+
4
+ const application = Application.start();
5
+
6
+ const GENERIC_ERROR = "Something went wrong. Please try again.";
3
7
 
4
- import * as WebAuthnJSON from 'https://unpkg.com/@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js';
5
8
  const Credential = {
6
- getCRFSToken: function () {
7
- const CSRFSelector = document.querySelector('meta[name="csrf-token"]');
8
- if (CSRFSelector) {
9
- return CSRFSelector.getAttribute("content");
10
- } else {
11
- return null;
9
+ csrfToken: function () {
10
+ const selector = document.querySelector('meta[name="csrf-token"]');
11
+ return selector ? selector.getAttribute("content") : null;
12
+ },
13
+
14
+ showError: function (message) {
15
+ const element = document.getElementById("webauthn_error");
16
+ if (element) {
17
+ element.textContent = message;
18
+ element.hidden = false;
12
19
  }
20
+ console.error("[ActionAuth]", message);
13
21
  },
14
22
 
15
23
  callback: function (url, body, redirectUrl) {
16
- const token = this.getCRFSToken();
17
- fetch(url, {
24
+ const self = this;
25
+ return fetch(url, {
18
26
  method: "POST",
19
27
  body: JSON.stringify(body),
20
28
  headers: {
21
29
  "Content-Type": "application/json",
22
30
  "Accept": "application/json",
23
- "X-CSRF-Token": token
31
+ "X-CSRF-Token": self.csrfToken()
24
32
  },
25
- credentials: 'same-origin'
26
- }).then(function (response) {
33
+ credentials: "same-origin"
34
+ }).then(async function (response) {
27
35
  if (response.ok) {
28
- console.log("Credential created", response);
29
- window.location.replace(redirectUrl);
36
+ const data = await response.json().catch(() => ({}));
37
+ window.location.replace(data.redirect_url || redirectUrl);
30
38
  } else if (response.status < 500) {
31
- response.text();
39
+ self.showError((await response.text()) || GENERIC_ERROR);
40
+ } else {
41
+ self.showError(GENERIC_ERROR);
32
42
  }
33
43
  });
34
44
  },
35
45
 
36
46
  create: function (callbackUrl, credentialOptions) {
37
47
  const self = this;
38
- const webauthnRedirectUrl = document.querySelector('meta[name="webauthn_redirect_url"]').getAttribute("content");
39
- WebAuthnJSON.create({ "publicKey": credentialOptions }).then(function (credential) {
40
- self.callback(callbackUrl, credential, webauthnRedirectUrl);
41
- });
48
+ const redirectUrl = document.querySelector('meta[name="webauthn_redirect_url"]').getAttribute("content");
49
+ return WebAuthnJSON.create({ "publicKey": credentialOptions })
50
+ .then((credential) => self.callback(callbackUrl, credential, redirectUrl))
51
+ .catch((error) => self.showError(error.message || GENERIC_ERROR));
42
52
  },
43
53
 
44
54
  get: function (credentialOptions) {
45
55
  const self = this;
46
- const webauthnUrlTag = document.querySelector('meta[name="passkey_auth_url"]') ||
56
+ const urlTag = document.querySelector('meta[name="passkey_auth_url"]') ||
47
57
  document.querySelector('meta[name="webauthn_auth_url"]');
48
- const webauthnUrl = webauthnUrlTag.getAttribute("content");
49
- WebAuthnJSON.get({ "publicKey": credentialOptions }).then(function (credential) {
50
- self.callback(webauthnUrl, credential, "/");
51
- });
58
+ return WebAuthnJSON.get({ "publicKey": credentialOptions })
59
+ .then((credential) => self.callback(urlTag.getAttribute("content"), credential, "/"))
60
+ .catch((error) => self.showError(error.message || GENERIC_ERROR));
52
61
  }
53
62
  };
54
63
 
55
-
56
- Stimulus.register(
64
+ application.register(
57
65
  "add-credential",
58
66
  class extends Controller {
67
+ // Attached here rather than on page load so it also works when Turbo brings
68
+ // the form in, and so it wins over Turbo's own form handling.
69
+ connect() {
70
+ this.submit = this.submit.bind(this);
71
+ this.element.addEventListener("submit", this.submit);
72
+ }
73
+
74
+ disconnect() {
75
+ this.element.removeEventListener("submit", this.submit);
76
+ }
77
+
78
+ submit(event) {
79
+ event.preventDefault();
80
+ const form = this.element;
81
+
82
+ fetch(form.action, {
83
+ method: "POST",
84
+ body: new FormData(form),
85
+ headers: {
86
+ "Accept": "application/json",
87
+ "X-CSRF-Token": Credential.csrfToken()
88
+ },
89
+ credentials: "same-origin"
90
+ }).then((response) => {
91
+ if (!response.ok) { throw new Error(GENERIC_ERROR); }
92
+ return response.json();
93
+ }).then((data) => {
94
+ form.dispatchEvent(new CustomEvent("ajax:success", { detail: data }));
95
+ }).catch((error) => Credential.showError(error.message || GENERIC_ERROR));
96
+ }
97
+
59
98
  create(event) {
60
99
  const webauthnUrl = document.querySelector('meta[name="webauthn_cred_url"]').getAttribute("content");
61
- const credentialOptions = event.detail;
62
- const credential_nickname = event.target.querySelector("input[name='webauthn_credential[nickname]']").value;
63
- const callback_url = `${webauthnUrl}/?credential_nickname=${credential_nickname}`
64
- Credential.create(encodeURI(callback_url), credentialOptions);
100
+ const nickname = event.target.querySelector("input[name='webauthn_credential[nickname]']").value;
101
+ const callbackUrl = `${webauthnUrl}?credential_nickname=${encodeURIComponent(nickname)}`;
102
+ Credential.create(callbackUrl, event.detail);
65
103
  }
66
104
  }
67
105
  );
68
106
 
69
- Stimulus.register(
107
+ application.register(
70
108
  "credential-authenticator",
71
109
  class extends Controller {
72
110
  static values = { options: Object }
111
+
73
112
  connect() {
74
113
  if (this.hasOptionsValue) {
75
114
  Credential.get(this.optionsValue);
@@ -77,41 +116,3 @@ Stimulus.register(
77
116
  }
78
117
  }
79
118
  );
80
-
81
- function submitFormWithTurbo(form) {
82
- const formData = new FormData(form);
83
- fetch(form.action, {
84
- method: 'POST',
85
- body: formData,
86
- headers: {
87
- 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
88
- },
89
- credentials: 'same-origin'
90
- }).then(response => {
91
- return response.json();
92
- }).then(data => {
93
- form.dispatchEvent(new CustomEvent('ajax:success', { detail: data }));
94
- });
95
- }
96
-
97
- if (!window.Turbo) {
98
- document.addEventListener('DOMContentLoaded', function () {
99
- const form = document.getElementById('webauthn_credential_form');
100
- if (form) {
101
- form.addEventListener('submit', function (event) {
102
- event.preventDefault();
103
- submitFormWithTurbo(form);
104
- });
105
- }
106
- });
107
- } else {
108
- document.addEventListener('turbo:load', function () {
109
- const form = document.getElementById('webauthn_credential_form');
110
- if (form) {
111
- form.addEventListener('submit', function (event) {
112
- event.preventDefault();
113
- submitFormWithTurbo(form);
114
- });
115
- }
116
- });
117
- }
@@ -124,12 +124,34 @@ input[type="password"] {
124
124
  pointer-events: none;
125
125
  }
126
126
 
127
- .btn-primary {
127
+ .btn-primary,
128
+ a.btn-primary:visited {
128
129
  color: #fff;
129
130
  background-color: #007bff;
130
131
  border-color: #007bff;
131
132
  }
132
133
 
134
+ .btn-danger {
135
+ color: #fff;
136
+ background-color: #dc3545;
137
+ border-color: #dc3545;
138
+ }
139
+
140
+ .btn-danger:hover {
141
+ color: #fff;
142
+ background-color: #b02a37;
143
+ border-color: #b02a37;
144
+ }
145
+
146
+ .action-auth--notice {
147
+ color: #146c43;
148
+ }
149
+
150
+ .action-auth--alert,
151
+ .action-auth--errors {
152
+ color: #b02a37;
153
+ }
154
+
133
155
  .action-auth--table {
134
156
  width: 100%;
135
157
  border-collapse: separate;
@@ -217,6 +239,21 @@ input[type="password"] {
217
239
  color: #fbf1c7;
218
240
  }
219
241
 
242
+ a.btn,
243
+ a.btn:visited,
244
+ a.btn:hover {
245
+ color: #fbf1c7;
246
+ }
247
+
248
+ .action-auth--notice {
249
+ color: #b8bb26;
250
+ }
251
+
252
+ .action-auth--alert,
253
+ .action-auth--errors {
254
+ color: #fb4934;
255
+ }
256
+
220
257
  .action-auth--table {
221
258
  background-color: #3c3836;
222
259
  color: #d5c4a1;
@@ -1,7 +1,128 @@
1
1
  module ActionAuth
2
- class ApplicationController < ActionController::Base
2
+ class ApplicationController < ActionAuth.configuration.parent_controller.constantize
3
+ # A host parent controller usually requires sign in for everything, which
4
+ # would make the sign in page unreachable. Each controller here opts back in
5
+ # to authenticate_user! on the actions that need it.
6
+ skip_before_action :authenticate_user!, raise: false
7
+
8
+ protect_from_forgery with: :exception
9
+
10
+ layout -> { ActionAuth.configuration.layout }
11
+
12
+ helper_method :after_sign_in_path, :after_sign_out_path
13
+
3
14
  private
4
15
 
16
+ def action_auth_controller?
17
+ true
18
+ end
19
+
20
+ # Rails turns email[]=x into an Array and email[a]=b into a Hash. Neither is a
21
+ # valid answer to any question these forms ask, so they count as blank.
22
+ def string_param(name)
23
+ value = params[name]
24
+ value.is_a?(String) ? value : ""
25
+ end
26
+
27
+ def after_sign_in_path
28
+ action_auth_redirect_path(ActionAuth.configuration.after_sign_in_path)
29
+ end
30
+
31
+ def after_sign_out_path
32
+ action_auth_redirect_path(ActionAuth.configuration.after_sign_out_path)
33
+ end
34
+
35
+ def action_auth_redirect_path(configured)
36
+ return instance_exec(&configured) if configured.respond_to?(:call)
37
+
38
+ configured || main_app.root_path
39
+ end
40
+
41
+ # Every first factor (password, magic link, SMS, OAuth) ends here, so that a
42
+ # suspended account is always refused, and an account with a security key
43
+ # always gets prompted for it.
44
+ def complete_sign_in(user, notice:)
45
+ if user.account_suspended?
46
+ redirect_to sign_in_path, alert: ACCOUNT_SUSPENDED_MESSAGE, status: :see_other
47
+ elsif user.second_factor_enabled?
48
+ session[:webauthn_user_id] = user.id
49
+ redirect_to new_webauthn_credential_authentications_path
50
+ else
51
+ start_session_for(user)
52
+ redirect_to after_sign_in_path, notice: notice
53
+ end
54
+ end
55
+
56
+ def start_session_for(user)
57
+ user.sessions.create!.tap do |user_session|
58
+ user.unlock_account!
59
+ cookies.signed.permanent[:session_token] = action_auth_session_cookie_options.merge(value: user_session.id)
60
+ end
61
+ end
62
+
63
+ def record_failed_sign_in(user)
64
+ return unless user&.register_failed_sign_in
65
+
66
+ Rails.logger.warn "ActionAuth locked account #{user.id} after too many failed sign ins"
67
+ send_account_locked_email(user)
68
+ end
69
+
70
+ # Lockout is on by default, including for apps that have never had to set up email
71
+ # or a job queue, and this is the answer to someone's sign in. It can't take that
72
+ # down with it. A placeholder address has nobody to tell.
73
+ def send_account_locked_email(user)
74
+ UserMailer.with(user: user).account_locked.deliver_later if user.password_usable?
75
+ rescue StandardError => e
76
+ Rails.logger.error "ActionAuth could not send the account locked email: #{e.class}: #{e.message}"
77
+ end
78
+
79
+ # Checks the assertion the browser posted against one of +credentials+ and
80
+ # the challenge issued to this session. Returns the credential, or nil if it
81
+ # doesn't verify.
82
+ #
83
+ # The challenge is removed first so it can't be replayed. The rest works on
84
+ # client-supplied bytes, which can make the library raise far more than
85
+ # WebAuthn::Error (bad base64, truncated CBOR, missing keys). All of those
86
+ # mean the same thing here: not verified.
87
+ def verify_webauthn_assertion(credentials, **verify_options)
88
+ challenge = session.delete(:current_challenge)
89
+ assertion = WebAuthn::Credential.from_get(params)
90
+ credential = credentials.find_by(external_id: assertion.id)
91
+ return unless credential
92
+
93
+ assertion.verify(challenge, public_key: credential.public_key, sign_count: credential.sign_count, **verify_options)
94
+ credential.update!(sign_count: assertion.sign_count)
95
+ credential
96
+ rescue StandardError => e
97
+ Rails.logger.warn "ActionAuth rejected a WebAuthn assertion: #{e.class}: #{e.message}"
98
+ nil
99
+ end
100
+
101
+ # Before filter body for screens that set a new password. Renders +template+
102
+ # with an error when the submitted password is blank or known to be breached.
103
+ def reject_unusable_password(user, template)
104
+ password = string_param(:password)
105
+
106
+ if password.blank?
107
+ user.errors.add(:password, :blank)
108
+ elsif (count = pwned_count(password)).positive?
109
+ user.errors.add(:password, "has been pwned #{count} times. Please choose a different password.")
110
+ end
111
+
112
+ render template, status: :unprocessable_entity if user.errors.any?
113
+ end
114
+
115
+ # Have I Been Pwned being unreachable shouldn't stop anyone from signing up
116
+ # or resetting a password, so an outage counts as "not pwned".
117
+ def pwned_count(password)
118
+ return 0 unless ActionAuth.configuration.pwned_enabled?
119
+
120
+ Pwned::Password.new(password).pwned_count
121
+ rescue Pwned::Error => e
122
+ Rails.logger.warn "ActionAuth could not check Have I Been Pwned: #{e.message}"
123
+ 0
124
+ end
125
+
5
126
  def generate_compliant_password
6
127
  uppercase = ('A'..'Z').to_a.sample
7
128
  lowercase = ('a'..'z').to_a.sample
@@ -0,0 +1,16 @@
1
+ module ActionAuth
2
+ class ConnectedAccountsController < ApplicationController
3
+ before_action :authenticate_user!
4
+
5
+ def destroy
6
+ connected_account = Current.user.connected_accounts.find(params[:id])
7
+
8
+ if Current.user.can_sign_in_without?(connected_account)
9
+ connected_account.destroy
10
+ redirect_to sessions_path, notice: "#{connected_account.provider_label} has been disconnected.", status: :see_other
11
+ else
12
+ redirect_to sessions_path, alert: "That's the only way to sign in to this account, so it can't be disconnected.", status: :see_other
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,15 +1,42 @@
1
1
  module ActionAuth
2
2
  module Identity
3
3
  class EmailVerificationsController < ApplicationController
4
- before_action :set_user, only: :show
4
+ before_action :set_user, only: [:show, :update]
5
5
 
6
+ rate_limit to: 5,
7
+ within: 1.minute,
8
+ only: :create,
9
+ name: "email-verification-throttle",
10
+ with: -> { redirect_to sign_in_path, alert: "Too many requests. Try again later." }
11
+
12
+ rate_limit to: 5,
13
+ within: 1.minute,
14
+ only: :update,
15
+ name: "email-verification-password-throttle",
16
+ with: -> { redirect_to sign_in_path, alert: "Too many attempts. Try again later.", status: :see_other }
17
+
18
+ # Mail scanners and link previews open the links in an email, so opening one
19
+ # changes nothing. It only asks for the password.
6
20
  def show
7
- @user.update! verified: true
8
- redirect_to sign_in_path, notice: "Thank you for verifying your email address"
21
+ redirect_to sign_in_path, notice: "Your email address is already verified" if @user.verified?
22
+ end
23
+
24
+ # A locked account gets the same answer whether or not the password is right, as
25
+ # it does at sign in, so that this can't be used to carry on guessing.
26
+ def update
27
+ if @user.verify_email_with_password(string_param(:password))
28
+ redirect_to sign_in_path, notice: "Thank you for verifying your email address", status: :see_other
29
+ else
30
+ # An empty form isn't a guess.
31
+ record_failed_sign_in(@user) if string_param(:password).present?
32
+
33
+ flash.now[:alert] = "That password is incorrect"
34
+ render :show, status: :unprocessable_entity
35
+ end
9
36
  end
10
37
 
11
38
  def create
12
- user = ActionAuth::User.find_by(email: params[:email])
39
+ user = ActionAuth::User.find_by(email: string_param(:email))
13
40
  UserMailer.with(user: user).email_verification.deliver_later if user
14
41
  redirect_to sign_in_path, notice: "We sent a verification email to your email address"
15
42
  end
@@ -17,7 +44,11 @@ module ActionAuth
17
44
  private
18
45
 
19
46
  def set_user
20
- @user = ActionAuth::User.find_by_token_for!(:email_verification, params[:sid])
47
+ # The "Verify Email" link has no token. It's a way to reach the form that
48
+ # sends one, not a failed verification.
49
+ return redirect_to(edit_identity_email_path) if params[:sid].blank?
50
+
51
+ @user = ActionAuth::User.find_by_token_for!(:email_verification, string_param(:sid))
21
52
  rescue StandardError
22
53
  redirect_to edit_identity_email_path, alert: "That email verification link is invalid"
23
54
  end
@@ -1,15 +1,27 @@
1
1
  module ActionAuth
2
2
  module Identity
3
3
  class EmailsController < ApplicationController
4
+ # edit stays public: it doubles as the "resend my verification email" form
5
+ # for people who can't sign in yet.
6
+ before_action :authenticate_user!, only: :update
4
7
  before_action :set_user
5
8
 
9
+ rate_limit to: 5,
10
+ within: 1.minute,
11
+ only: :update,
12
+ name: "email-change-throttle",
13
+ with: -> { redirect_to edit_identity_email_path, alert: "Too many attempts. Try again later.", status: :see_other }
14
+
6
15
  def edit
7
16
  end
8
17
 
9
18
  def update
10
19
  if @user.update(user_params)
11
- redirect_to_root
20
+ redirect_after_update
12
21
  else
22
+ # The form shows what was typed from params. Current.user is shared
23
+ # with the rest of the request, so don't leave it holding a rejected email.
24
+ @user.restore_attributes
13
25
  render :edit, status: :unprocessable_entity
14
26
  end
15
27
  end
@@ -24,12 +36,12 @@ module ActionAuth
24
36
  params.permit(:email, :password_challenge).with_defaults(password_challenge: "")
25
37
  end
26
38
 
27
- def redirect_to_root
39
+ def redirect_after_update
28
40
  if @user.email_previously_changed?
29
41
  resend_email_verification
30
- redirect_to sign_in_path, notice: "Your email has been changed. Check your email to verify your email."
42
+ redirect_to sessions_path, notice: "Your email has been changed. Check your email to verify your email.", status: :see_other
31
43
  else
32
- redirect_to sign_in_path
44
+ redirect_to sessions_path, status: :see_other
33
45
  end
34
46
  end
35
47
 
@@ -2,7 +2,14 @@ module ActionAuth
2
2
  module Identity
3
3
  class PasswordResetsController < ApplicationController
4
4
  before_action :set_user, only: %i[ edit update ]
5
- before_action :validate_pwned_password, only: :update
5
+
6
+ rate_limit to: 5,
7
+ within: 1.minute,
8
+ only: :create,
9
+ name: "password-reset-request-throttle",
10
+ with: -> { redirect_to new_identity_password_reset_path, alert: "Too many requests. Try again later." }
11
+
12
+ before_action :validate_new_password, only: :update
6
13
 
7
14
  def new
8
15
  end
@@ -10,18 +17,18 @@ module ActionAuth
10
17
  def edit
11
18
  end
12
19
 
20
+ # The response is the same whether or not the account exists, so this form
21
+ # can't be used to find out who has an account.
13
22
  def create
14
- if @user = ActionAuth::User.find_by(email: params[:email], verified: true)
15
- send_password_reset_email
16
- redirect_to sign_in_path, notice: "Check your email for reset instructions"
17
- else
18
- redirect_to sign_in_path, alert: "You can't reset your password until you verify your email"
19
- end
23
+ user = ActionAuth::User.find_by(email: string_param(:email))
24
+ UserMailer.with(user: user).password_reset.deliver_later if user && !user.account_suspended?
25
+
26
+ redirect_to sign_in_path, notice: "If there's an account with that email address, you'll receive reset instructions shortly."
20
27
  end
21
28
 
22
29
  def update
23
- if @user.update(user_params)
24
- redirect_to sign_in_path, notice: "Your password was reset successfully. Please sign in"
30
+ if @user.reset_password_from_emailed_link(user_params)
31
+ redirect_to sign_in_path, notice: "Your password was reset successfully. Please sign in", status: :see_other
25
32
  else
26
33
  render :edit, status: :unprocessable_entity
27
34
  end
@@ -30,7 +37,8 @@ module ActionAuth
30
37
  private
31
38
 
32
39
  def set_user
33
- @user = ActionAuth::User.find_by_token_for!(:password_reset, params[:sid])
40
+ @user = ActionAuth::User.find_by_token_for!(:password_reset, string_param(:sid))
41
+ redirect_to new_identity_password_reset_path, alert: "That password reset link is invalid" if @user.account_suspended?
34
42
  rescue StandardError
35
43
  redirect_to new_identity_password_reset_path, alert: "That password reset link is invalid"
36
44
  end
@@ -39,18 +47,8 @@ module ActionAuth
39
47
  params.permit(:password, :password_confirmation)
40
48
  end
41
49
 
42
- def send_password_reset_email
43
- UserMailer.with(user: @user).password_reset.deliver_later
44
- end
45
-
46
- def validate_pwned_password
47
- return unless ActionAuth.configuration.pwned_enabled?
48
-
49
- pwned = Pwned::Password.new(params[:password])
50
- if pwned.pwned?
51
- @user.errors.add(:password, "has been pwned #{pwned.pwned_count} times. Please choose a different password.")
52
- render :edit, status: :unprocessable_entity
53
- end
50
+ def validate_new_password
51
+ reject_unusable_password(@user, :edit)
54
52
  end
55
53
  end
56
54
  end
@@ -1,20 +1,41 @@
1
1
  module ActionAuth
2
2
  class Magics::RequestsController < ApplicationController
3
+ rate_limit to: 5,
4
+ within: 1.minute,
5
+ only: :create,
6
+ name: "magic-link-throttle",
7
+ with: -> { redirect_to new_magics_requests_path, alert: "Too many requests. Try again later." }
8
+
9
+ before_action :require_magic_link_migration, only: :create
10
+
3
11
  def new
4
12
  end
5
13
 
6
14
  def create
7
- user = User.find_or_initialize_by(email: params[:email])
15
+ user = User.find_or_initialize_by(email: string_param(:email))
16
+
8
17
  if user.new_record?
9
- password = generate_compliant_password
10
- user.password = password
11
- user.password_confirmation = password
12
- user.save!
18
+ user.password = user.password_confirmation = generate_compliant_password
19
+
20
+ unless user.save
21
+ flash.now[:alert] = user.errors.full_messages.to_sentence
22
+ return render :new, status: :unprocessable_entity
23
+ end
13
24
  end
14
25
 
15
- UserMailer.with(user: user).magic_link.deliver_later
26
+ UserMailer.with(user: user).magic_link.deliver_later unless user.account_suspended?
16
27
 
17
28
  redirect_to sign_in_path, notice: "Check your email for a magic link."
18
29
  end
30
+
31
+ private
32
+
33
+ # Without the column the email would fail in a background job, after this page
34
+ # had already told the person to check their inbox.
35
+ def require_magic_link_migration
36
+ return if User.column_names.include?("magic_link_used_at")
37
+
38
+ raise "ActionAuth magic links need a new migration. Run: bin/rails action_auth:install:migrations db:migrate"
39
+ end
19
40
  end
20
41
  end
@@ -1,15 +1,30 @@
1
1
  module ActionAuth
2
2
  class Magics::SignInsController < ApplicationController
3
+ INVALID_LINK = "That sign in link has expired or has already been used. Please request a new one.".freeze
4
+
5
+ # Mail scanners and link previews open the links in an email, so opening one
6
+ # only asks for confirmation. Otherwise they'd use up the link before the
7
+ # person it was sent to could click it.
3
8
  def show
4
- user = ActionAuth::User.find_by_token_for(:magic_token, params[:token])
5
- if user
6
- @session = user.sessions.create
7
- cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }
8
- user.update(verified: true)
9
- redirect_to main_app.root_path, notice: "Signed In"
9
+ @user = user_from_token
10
+
11
+ redirect_to sign_in_path, alert: INVALID_LINK unless @user
12
+ end
13
+
14
+ def create
15
+ user = user_from_token
16
+
17
+ if user&.claim_magic_link
18
+ complete_sign_in(user, notice: "Signed In")
10
19
  else
11
- redirect_to sign_in_path, alert: "Authentication failed, please try again."
20
+ redirect_to sign_in_path, alert: INVALID_LINK, status: :see_other
12
21
  end
13
22
  end
23
+
24
+ private
25
+
26
+ def user_from_token
27
+ User.find_by_token_for(:magic_token, string_param(:token))
28
+ end
14
29
  end
15
30
  end