FEATURE: Implement 2factor login TOTP

implemented review items.

Blocking previous codes - valid 2-factor auth tokens can only be authenticated once/30 seconds.
I played with updating the “last used” any time the token was attempted but that seemed to be overkill, and frustrating as to why a token would fail.
Translatable texts.
Move second factor logic to a helper class.
Move second factor specific controller endpoints to its own controller.
Move serialization logic for 2-factor details in admin user views.
Add a login ember component for de-duplication
Fix up code formatting
Change verbiage of google authenticator

add controller tests:
second factor controller tests
change email tests
change password tests
admin login tests

add qunit tests - password reset, preferences

fix: check for 2factor on change email controller
fix: email controller - only show second factor errors on attempt
fix: check against 'true' to enable second factor.

Add modal for explaining what 2fa with links to Google Authenticator/FreeOTP

add two factor to email signin link

rate limit if second factor token present

add rate limiter test for second factor attempts
This commit is contained in:
Jeff Wong
2017-12-21 17:18:12 -08:00
committed by Guo Xiang Tan
parent b6e82815bd
commit f4f8a293e7
52 changed files with 1005 additions and 45 deletions

View File

@ -24,6 +24,20 @@ acceptance("Password Reset", {
return response({success: "OK", message: I18n.t('password_reset.success')});
}
});
server.get('/u/confirm-email-token/requiretwofactor.json', () => { //eslint-disable-line
return response({success: "OK"});
});
server.put('/u/password-reset/requiretwofactor.json', request => { //eslint-disable-line
const body = parsePostData(request.requestBody);
if (body.password === "perf3ctly5ecur3" && body.second_factor_token === "123123") {
return response({success: "OK", message: I18n.t('password_reset.success')});
} else if (body.second_factor_token === "123123") {
return response({success: false, errors: {password: ["invalid"]}});
} else {
return response({success: false, message: "invalid token", errors: {second_factor: ["invalid token"]}});
}
});
}
});
@ -58,4 +72,35 @@ QUnit.test("Password Reset Page", assert => {
andThen(() => {
assert.ok(!exists(".password-reset form"), "form is gone");
});
});
});
QUnit.test("Password Reset Page With Second Factor", assert => {
PreloadStore.store('password_reset', {is_developer: false, second_factor_required: true});
visit("/u/password-reset/requiretwofactor");
andThen(() => {
assert.notOk(exists("#new-account-password"), "does not show the input");
assert.ok(exists("#second-factor"), "shows the second factor prompt");
});
fillIn('#second-factor', '0000');
click('.password-reset form button');
andThen(() => {
assert.ok(exists(".alert-error"), "shows 2 factor error");
assert.ok(find(".alert-error").html().indexOf("invalid token") > -1, "server validation error message shows");
});
fillIn('#second-factor', '123123');
click('.password-reset form button');
andThen(() => {
assert.notOk(exists(".alert-error"), "hides error");
assert.ok(exists("#new-account-password"), "shows the input");
});
fillIn('.password-reset input', 'perf3ctly5ecur3');
click('.password-reset form button');
andThen(() => {
assert.ok(!exists(".password-reset form"), "form is gone");
});
});

View File

@ -1,5 +1,20 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("User Preferences", { loggedIn: true });
acceptance("User Preferences", {
loggedIn: true,
beforeEach() {
const response = (object) => {
return [
200,
{"Content-Type": "application/json"},
object
];
};
server.post('/second_factor/create', () => { //eslint-disable-line
return response({key: "rcyryaqage3jexfj", qr: '<div id="test-qr">qr-code</div>'});
});
}
});
QUnit.test("update some fields", assert => {
visit("/u/eviltrout/preferences");
@ -73,3 +88,16 @@ QUnit.test("email", assert => {
assert.equal(find('.tip.bad').text().trim(), I18n.t('user.email.invalid'), 'it should display invalid email tip');
});
});
QUnit.test("second factor", assert => {
visit("/u/eviltrout/preferences/second-factor");
andThen(() => {
assert.ok(exists("#password"), "it has a password input");
});
fillIn('#password', 'secrets');
click(".user-content .btn-primary");
andThen(() => {
assert.ok(exists("#test-qr"), "shows qr code");
assert.notOk(exists("#password"), "it hides the password input");
});
});

View File

@ -76,6 +76,32 @@ QUnit.test("sign in - not activated - edit email", assert => {
});
});
QUnit.test("second factor", assert => {
visit("/");
click("header .login-button");
andThen(() => {
assert.ok(exists('.login-modal'), "it shows the login modal");
});
// Login with username and password only
fillIn('#login-account-name', 'eviltrout');
fillIn('#login-account-password', 'need-second-factor');
click('.modal-footer .btn-primary');
andThen(() => {
assert.not(exists('#modal-alert:visible'), 'it hides the login error');
assert.not(exists('#credentials:visible'), 'it hides the username and password prompt');
assert.ok(exists('#second-factor:visible'), 'it displays the second factor prompt');
assert.not(exists('.modal-footer .btn-primary:disabled'), "enables the login button");
});
// Login with username, password, and token
fillIn('#login-second-factor', '123456');
click('.modal-footer .btn-primary');
andThen(() => {
assert.ok(exists('.modal-footer .btn-primary:disabled'), "disables the login button");
});
});
QUnit.test("create account", assert => {
visit("/");
click("header .sign-up-button");
@ -106,4 +132,4 @@ QUnit.test("create account", assert => {
andThen(() => {
assert.ok(exists('.modal-footer .btn-primary:disabled'), "create account is disabled");
});
});
});

View File

@ -227,6 +227,16 @@ export default function() {
current_email: 'current@example.com' });
}
if (data.password === 'need-second-factor') {
if (data.second_factor_token) {
return response({username: 'eviltrout'});
}
return response({ error: "Invalid Second Factor",
reason: "invalid_second_factor",
sent_to_email: 'eviltrout@example.com',
current_email: 'current@example.com' });
}
return response(400, {error: 'invalid login'});
});