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,75 +1,114 @@
|
|
|
1
|
-
import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
2
|
-
|
|
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
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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":
|
|
31
|
+
"X-CSRF-Token": self.csrfToken()
|
|
24
32
|
},
|
|
25
|
-
credentials:
|
|
26
|
-
}).then(function (response) {
|
|
33
|
+
credentials: "same-origin"
|
|
34
|
+
}).then(async function (response) {
|
|
27
35
|
if (response.ok) {
|
|
28
|
-
|
|
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
|
|
39
|
-
WebAuthnJSON.create({ "publicKey": credentialOptions })
|
|
40
|
-
self.callback(callbackUrl, credential,
|
|
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
|
|
56
|
+
const urlTag = document.querySelector('meta[name="passkey_auth_url"]') ||
|
|
47
57
|
document.querySelector('meta[name="webauthn_auth_url"]');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
self.
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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 <
|
|
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.
|
|
8
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
39
|
+
def redirect_after_update
|
|
28
40
|
if @user.email_previously_changed?
|
|
29
41
|
resend_email_verification
|
|
30
|
-
redirect_to
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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,
|
|
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
|
|
43
|
-
|
|
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:
|
|
15
|
+
user = User.find_or_initialize_by(email: string_param(:email))
|
|
16
|
+
|
|
8
17
|
if user.new_record?
|
|
9
|
-
password = generate_compliant_password
|
|
10
|
-
|
|
11
|
-
user.
|
|
12
|
-
|
|
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 =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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:
|
|
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
|