UX: Add description to the 2FA page when adding new admins (#16098)

This PR adds an extra description to the 2FA page when granting a user admin access. It also introduces a general system for adding customized descriptions that can be used by future actions.

(Follow-up to dd6ec65061)
This commit is contained in:
Osama Sayegh 2022-03-04 06:43:06 +03:00 committed by GitHub
parent 967946378a
commit 8c71878ff5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 53 additions and 4 deletions

View File

@ -27,6 +27,7 @@ export default Controller.extend({
backupCodesEnabled: readOnly("model.backup_enabled"), backupCodesEnabled: readOnly("model.backup_enabled"),
securityKeysEnabled: readOnly("model.security_keys_enabled"), securityKeysEnabled: readOnly("model.security_keys_enabled"),
allowedMethods: readOnly("model.allowed_methods"), allowedMethods: readOnly("model.allowed_methods"),
customDescription: readOnly("model.description"),
showTotpForm: equal("shownSecondFactorMethod", TOTP), showTotpForm: equal("shownSecondFactorMethod", TOTP),
showSecurityKeyForm: equal("shownSecondFactorMethod", SECURITY_KEY), showSecurityKeyForm: equal("shownSecondFactorMethod", SECURITY_KEY),

View File

@ -3,6 +3,9 @@
{{/if}} {{/if}}
{{#unless loadError}} {{#unless loadError}}
<h3>{{secondFactorTitle}}</h3> <h3>{{secondFactorTitle}}</h3>
{{#if customDescription}}
<p class="action-description">{{customDescription}}</p>
{{/if}}
<p>{{secondFactorDescription}}</p> <p>{{secondFactorDescription}}</p>
{{#if showSecurityKeyForm}} {{#if showSecurityKeyForm}}
<div id="security-key"> <div id="security-key">

View File

@ -46,6 +46,15 @@ const RESPONSES = {
}, },
}; };
Object.keys(RESPONSES).forEach((k) => {
if (k.startsWith("ok")) {
const response = RESPONSES[k];
if (!response.description) {
response.description =
"This is an additional description that can be customized per action";
}
}
});
const WRONG_TOTP = "124323"; const WRONG_TOTP = "124323";
let callbackCount = 0; let callbackCount = 0;
@ -254,6 +263,16 @@ acceptance("Second Factor Auth Page", function (needs) {
); );
}); });
test("2FA action description", async function (assert) {
await visit("/session/2fa?nonce=ok111111");
assert.equal(
query(".action-description").textContent.trim(),
"This is an additional description that can be customized per action",
"action description is rendered on the page"
);
});
test("error when submitting 2FA form", async function (assert) { test("error when submitting 2FA form", async function (assert) {
await visit("/session/2fa?nonce=ok110111"); await visit("/session/2fa?nonce=ok110111");
await fillIn("form.totp-token .second-factor-token-input", WRONG_TOTP); await fillIn("form.totp-token .second-factor-token-input", WRONG_TOTP);

View File

@ -456,6 +456,9 @@ class SessionController < ApplicationController
else else
json[:security_keys_enabled] = false json[:security_keys_enabled] = false
end end
if challenge[:description]
json[:description] = challenge[:description]
end
else else
json[:error] = I18n.t(error_key) json[:error] = I18n.t(error_key)
end end

View File

@ -2583,6 +2583,9 @@ en:
challenge_not_found: "Couldn't find a 2FA challenge in your current session." challenge_not_found: "Couldn't find a 2FA challenge in your current session."
challenge_expired: "Too much time has passed since the 2FA challenge was staged and it's no longer valid. Please try again." challenge_expired: "Too much time has passed since the 2FA challenge was staged and it's no longer valid. Please try again."
challenge_not_completed: "You've not completed the 2FA challenge to perform this action. Please complete the 2FA challenge and try again." challenge_not_completed: "You've not completed the 2FA challenge to perform this action. Please complete the 2FA challenge and try again."
actions:
grant_admin:
description: "For additional security measures, you need to confirm your 2FA before %{username} is granted admin access."
admin: admin:
email: email:
sent_test: "sent!" sent_test: "sent!"

View File

@ -9,9 +9,14 @@ module SecondFactor::Actions
def second_factor_auth_required!(params) def second_factor_auth_required!(params)
user = find_user(params[:user_id]) user = find_user(params[:user_id])
description = I18n.t(
"second_factor_auth.actions.grant_admin.description",
username: "@#{user.username}"
)
{ {
callback_params: { user_id: user.id }, callback_params: { user_id: user.id },
redirect_path: admin_user_show_path(id: user.id, username: user.username) redirect_path: admin_user_show_path(id: user.id, username: user.username),
description: description
} }
end end

View File

@ -52,6 +52,9 @@ the following methods:
redirected to after the action is finished. When this key is omitted, the redirected to after the action is finished. When this key is omitted, the
redirect path is set to the homepage (/). redirect path is set to the homepage (/).
:description => optional action-specific description message that's shown on
the 2FA page.
After this method is called, the auth manager will send a 403 response with a After this method is called, the auth manager will send a 403 response with a
JSON body. It does that by raising an exception that's then rescued by a JSON body. It does that by raising an exception that's then rescued by a
`rescue_from` handler. The JSON response contains a challenge nonce which the `rescue_from` handler. The JSON response contains a challenge nonce which the
@ -169,6 +172,9 @@ class SecondFactor::AuthManager
allowed_methods: allowed_methods.to_a, allowed_methods: allowed_methods.to_a,
generated_at: Time.zone.now.to_i generated_at: Time.zone.now.to_i
} }
if config[:description]
challenge[:description] = config[:description]
end
secure_session["current_second_factor_auth_challenge"] = challenge.to_json secure_session["current_second_factor_auth_challenge"] = challenge.to_json
nonce nonce
end end

View File

@ -40,11 +40,17 @@ describe SecondFactor::Actions::GrantAdmin do
end end
describe "#second_factor_auth_required!" do describe "#second_factor_auth_required!" do
it "returns a hash with callback_params and redirect_path" do it "returns a hash with callback_params, redirect_path and a description" do
instance = create_instance(admin) instance = create_instance(admin)
hash = instance.second_factor_auth_required!(params({ user_id: user.id })) hash = instance.second_factor_auth_required!(params({ user_id: user.id }))
expect(hash[:callback_params]).to eq({ user_id: user.id }) expect(hash[:callback_params]).to eq({ user_id: user.id })
expect(hash[:redirect_path]).to eq("/admin/users/#{user.id}/#{user.username}") expect(hash[:redirect_path]).to eq("/admin/users/#{user.id}/#{user.username}")
expect(hash[:description]).to eq(
I18n.t(
"second_factor_auth.actions.grant_admin.description",
username: "@#{user.username}"
)
)
end end
it "ensures the acting user is admin" do it "ensures the acting user is admin" do

View File

@ -94,7 +94,7 @@ describe SecondFactor::AuthManager do
action action
.expects(:second_factor_auth_required!) .expects(:second_factor_auth_required!)
.with({ expect_me: 131 }) .with({ expect_me: 131 })
.returns({ callback_params: { call_me_back: 4314 }, redirect_path: "/gg" }) .returns({ callback_params: { call_me_back: 4314 }, redirect_path: "/gg", description: "hello world!" })
.once .once
action.expects(:second_factor_auth_completed!).never action.expects(:second_factor_auth_completed!).never
manager = create_manager(action) manager = create_manager(action)
@ -114,6 +114,7 @@ describe SecondFactor::AuthManager do
expect(challenge[:redirect_path]).to eq("/gg") expect(challenge[:redirect_path]).to eq("/gg")
expect(challenge[:allowed_methods]).to eq(manager.allowed_methods.to_a) expect(challenge[:allowed_methods]).to eq(manager.allowed_methods.to_a)
expect(challenge[:callback_params]).to eq({ call_me_back: 4314 }) expect(challenge[:callback_params]).to eq({ call_me_back: 4314 })
expect(challenge[:description]).to eq("hello world!")
end end
it "sets the redirect_path to the root path if second_factor_auth_required! doesn't specify a redirect_path" do it "sets the redirect_path to the root path if second_factor_auth_required! doesn't specify a redirect_path" do

View File

@ -2305,6 +2305,7 @@ describe SessionController do
UserSecondFactor.methods[:totp], UserSecondFactor.methods[:totp],
UserSecondFactor.methods[:security_key], UserSecondFactor.methods[:security_key],
) )
expect(challenge_data["description"]).to eq("this is description for test action")
Fabricate( Fabricate(
:user_security_key_with_random_credential, :user_security_key_with_random_credential,

View File

@ -10,7 +10,8 @@ class TestSecondFactorAction < SecondFactor::Actions::Base
callback_params: { callback_params: {
saved_param_1: params[:saved_param_1], saved_param_1: params[:saved_param_1],
saved_param_2: params[:saved_param_2] saved_param_2: params[:saved_param_2]
} },
description: "this is description for test action"
} }
end end