FIX: Show a nicer error if name/code missing for TOTP/Security Keys (#9124)

Meta: https://meta.discourse.org/t/improve-error-message-when-not-including-name-setting-up-totp/143339

* when the user creates a TOTP second factor method we want
to show them a nicer error if they forget to add a name
or the code from the app, instead of the param missing error
* also add a client-side check for this and for security key name,
no need to bother the server if we can help it
This commit is contained in:
Martin Brennan
2020-03-06 14:37:40 +10:00
committed by GitHub
parent 494379201d
commit 29ccdf5d35
8 changed files with 120 additions and 11 deletions

View File

@ -53,6 +53,13 @@ export default Controller.extend(ModalFunctionality, {
actions: { actions: {
registerSecurityKey() { registerSecurityKey() {
if (!this.securityKeyName) {
this.set(
"errorMessage",
I18n.t("user.second_factor.security_key.name_required_error")
);
return;
}
const publicKeyCredentialCreationOptions = { const publicKeyCredentialCreationOptions = {
challenge: Uint8Array.from(this.challenge, c => c.charCodeAt(0)), challenge: Uint8Array.from(this.challenge, c => c.charCodeAt(0)),
rp: { rp: {

View File

@ -45,7 +45,13 @@ export default Controller.extend(ModalFunctionality, {
}, },
enableSecondFactor() { enableSecondFactor() {
if (!this.secondFactorToken) return; if (!this.secondFactorToken || !this.secondFactorName) {
this.set(
"errorMessage",
I18n.t("user.second_factor.totp.name_and_code_required_error")
);
return;
}
this.set("loading", true); this.set("loading", true);
this.model this.model

View File

@ -16,7 +16,7 @@
<div class="control-group"> <div class="control-group">
<div class="controls"> <div class="controls">
{{input value=securityKeyName id='test' placeholder='security key name'}} {{input value=securityKeyName id='security-key-name' placeholder='security key name'}}
</div> </div>
</div> </div>
@ -24,7 +24,7 @@
<div class="controls"> <div class="controls">
{{#unless webauthnUnsupported}} {{#unless webauthnUnsupported}}
{{d-button {{d-button
class="btn-primary add-totp" class="btn-primary add-security-key"
action=(action "registerSecurityKey") action=(action "registerSecurityKey")
label="user.second_factor.security_key.register"}} label="user.second_factor.security_key.register"}}
{{/unless}} {{/unless}}

View File

@ -1211,8 +1211,12 @@ class UsersController < ApplicationController
end end
def enable_second_factor_totp def enable_second_factor_totp
params.require(:second_factor_token) if params[:second_factor_token].blank?
params.require(:name) return render json: failed_json.merge(error: I18n.t("login.missing_second_factor_code"))
end
if params[:name].blank?
return render json: failed_json.merge(error: I18n.t("login.missing_second_factor_name"))
end
auth_token = params[:second_factor_token] auth_token = params[:second_factor_token]
totp_data = secure_session["staged-totp-#{current_user.id}"] totp_data = secure_session["staged-totp-#{current_user.id}"]

View File

@ -1023,6 +1023,7 @@ en:
title: "Token-Based Authenticators" title: "Token-Based Authenticators"
add: "New Authenticator" add: "New Authenticator"
default_name: "My Authenticator" default_name: "My Authenticator"
name_and_code_required_error: "You must provide a name and the code from your authenticator app."
security_key: security_key:
register: "Register" register: "Register"
title: "Security Keys" title: "Security Keys"
@ -1033,6 +1034,7 @@ en:
edit: "Edit Security Key" edit: "Edit Security Key"
edit_description: "Security Key Name" edit_description: "Security Key Name"
delete: "Delete" delete: "Delete"
name_required_error: "You must provide a name for your security key."
change_about: change_about:
title: "Change About Me" title: "Change About Me"

View File

@ -2381,6 +2381,8 @@ en:
second_factor_backup_title: "Two Factor Backup Code" second_factor_backup_title: "Two Factor Backup Code"
invalid_second_factor_code: "Invalid authentication code. Each code can only be used once." invalid_second_factor_code: "Invalid authentication code. Each code can only be used once."
invalid_security_key: "Invalid security key." invalid_security_key: "Invalid security key."
missing_second_factor_name: "Please provide a name."
missing_second_factor_code: "Please provide a code."
second_factor_toggle: second_factor_toggle:
totp: "Use an authenticator app or security key instead" totp: "Use an authenticator app or security key instead"
backup_code: "Use a backup code instead" backup_code: "Use a backup code instead"

View File

@ -3522,6 +3522,58 @@ describe UsersController do
end end
end end
describe "#enable_second_factor_totp" do
before do
sign_in(user)
end
def create_totp
stub_secure_session_confirmed
post "/users/create_second_factor_totp.json"
end
it "creates a totp for the user successfully" do
create_totp
staged_totp_key = read_secure_session["staged-totp-#{user.id}"]
token = ROTP::TOTP.new(staged_totp_key).now
post "/users/enable_second_factor_totp.json", params: { name: "test", second_factor_token: token }
expect(response.status).to eq(200)
expect(user.user_second_factors.count).to eq(1)
end
context "when an incorrect token is provided" do
before do
create_totp
post "/users/enable_second_factor_totp.json", params: { name: "test", second_factor_token: "123456" }
end
it "shows a helpful error message to the user" do
expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.invalid_second_factor_code"))
end
end
context "when a name is not provided" do
before do
create_totp
post "/users/enable_second_factor_totp.json", params: { second_factor_token: "123456" }
end
it "shows a helpful error message to the user" do
expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.missing_second_factor_name"))
end
end
context "when a token is not provided" do
before do
create_totp
post "/users/enable_second_factor_totp.json", params: { name: "test" }
end
it "shows a helpful error message to the user" do
expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.missing_second_factor_code"))
end
end
end
describe '#update_second_factor' do describe '#update_second_factor' do
let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) } let(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
@ -3554,7 +3606,7 @@ describe UsersController do
context 'when token is valid' do context 'when token is valid' do
before do before do
ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true") stub_secure_session_confirmed
end end
it 'should allow second factor for the user to be renamed' do it 'should allow second factor for the user to be renamed' do
put "/users/second_factor.json", params: { put "/users/second_factor.json", params: {
@ -4062,7 +4114,11 @@ describe UsersController do
def create_second_factor_security_key def create_second_factor_security_key
sign_in(user) sign_in(user)
UsersController.any_instance.stubs(:secure_session_confirmed?).returns(true) stub_secure_session_confirmed
post "/u/create_second_factor_security_key.json" post "/u/create_second_factor_security_key.json"
end end
def stub_secure_session_confirmed
UsersController.any_instance.stubs(:secure_session_confirmed?).returns(true)
end
end end

View File

@ -20,6 +20,16 @@ acceptance("User Preferences", {
}); });
}); });
server.post("/u/create_second_factor_security_key.json", () => {
return helper.response({
challenge:
"a6d393d12654c130b2273e68ca25ca232d1d7f4c2464c2610fb8710a89d4",
rp_id: "localhost",
rp_name: "Discourse",
supported_algoriths: [-7, -257]
});
});
server.post("/u/enable_second_factor_totp.json", () => { server.post("/u/enable_second_factor_totp.json", () => {
return helper.response({ error: "invalid token" }); return helper.response({ error: "invalid token" });
}); });
@ -211,7 +221,7 @@ QUnit.test("connected accounts", async assert => {
.indexOf("Connect") > -1; .indexOf("Connect") > -1;
}); });
QUnit.test("second factor", async assert => { QUnit.test("second factor totp", async assert => {
await visit("/u/eviltrout/preferences/second-factor"); await visit("/u/eviltrout/preferences/second-factor");
assert.ok(exists("#password"), "it has a password input"); assert.ok(exists("#password"), "it has a password input");
@ -223,14 +233,36 @@ QUnit.test("second factor", async assert => {
await click(".new-totp"); await click(".new-totp");
assert.ok(exists("#test-qr"), "shows qr code"); assert.ok(exists("#test-qr"), "shows qr code");
await fillIn("#second-factor-token", "111111");
await click(".add-totp"); await click(".add-totp");
assert.ok( assert.ok(
find(".alert-error") find(".alert-error")
.html() .html()
.indexOf("invalid token") > -1, .indexOf("provide a name and the code") > -1,
"shows server validation error message" "shows name/token missing error message"
);
});
QUnit.test("second factor security keys", async assert => {
await visit("/u/eviltrout/preferences/second-factor");
assert.ok(exists("#password"), "it has a password input");
await fillIn("#password", "secrets");
await click(".user-preferences .btn-primary");
assert.notOk(exists("#password"), "it hides the password input");
await click(".new-security-key");
assert.ok(exists("#security-key-name"), "shows security key name input");
fillIn("#security-key-name", "");
await click(".add-security-key");
assert.ok(
find(".alert-error")
.html()
.indexOf("provide a name") > -1,
"shows name missing error message"
); );
}); });