Register TOTP secrets per user

pull/7/head
Clement Michaud 2017-01-28 18:27:54 +01:00
parent b205ba6a0d
commit 90494407a9
17 changed files with 520 additions and 58 deletions

View File

@ -32,6 +32,7 @@
"nedb": "^1.8.0",
"nodemailer": "^2.7.0",
"object-path": "^0.11.3",
"qrcode": "^0.5.0",
"randomstring": "^1.1.5",
"speakeasy": "^2.0.0",
"winston": "^2.3.1",

View File

@ -5,6 +5,7 @@ module.exports = {
IdentityError: IdentityError,
AccessDeniedError: AccessDeniedError,
AuthenticationRegulationError: AuthenticationRegulationError,
InvalidTOTPError: InvalidTOTPError,
}
function LdapSearchError(message) {
@ -36,3 +37,9 @@ function AuthenticationRegulationError(message) {
this.message = (message || "");
}
AuthenticationRegulationError.prototype = Object.create(Error.prototype);
function InvalidTOTPError(message) {
this.name = "InvalidTOTPError";
this.message = (message || "");
}
InvalidTOTPError.prototype = Object.create(Error.prototype);

View File

@ -4,6 +4,7 @@ var second_factor = require('./routes/second_factor');
var reset_password = require('./routes/reset_password');
var verify = require('./routes/verify');
var u2f_register_handler = require('./routes/u2f_register_handler');
var totp_register = require('./routes/totp_register');
var objectPath = require('object-path');
module.exports = {
@ -13,7 +14,8 @@ module.exports = {
first_factor: first_factor,
second_factor: second_factor,
reset_password: reset_password,
u2f_register: u2f_register_handler
u2f_register: u2f_register_handler,
totp_register: totp_register,
}
function serveLogin(req, res) {

View File

@ -3,31 +3,48 @@ module.exports = totp;
var totp = require('../totp');
var objectPath = require('object-path');
var exceptions = require('../../../src/lib/exceptions');
var UNAUTHORIZED_MESSAGE = 'Unauthorized access';
function replyWithUnauthorized(res) {
res.status(401);
res.send();
}
function totp(req, res) {
if(!objectPath.has(req, 'session.auth_session.second_factor')) {
replyWithUnauthorized(res);
}
var token = req.body.token;
var totp_engine = req.app.get('totp engine');
var config = req.app.get('config');
var logger = req.app.get('logger');
var userid = objectPath.get(req, 'session.auth_session.userid');
logger.info('POST 2ndfactor totp: Initiate TOTP validation for user %s', userid);
totp.validate(totp_engine, token, config.totp_secret)
if(!userid) {
logger.error('POST 2ndfactor totp: No user id in the session');
res.status(403);
res.send();
return;
}
var token = req.body.token;
var totp_engine = req.app.get('totp engine');
var data_store = req.app.get('user data store');
logger.debug('POST 2ndfactor totp: Fetching secret for user %s', userid);
data_store.get_totp_secret(userid)
.then(function(doc) {
logger.debug('POST 2ndfactor totp: TOTP secret is %s', JSON.stringify(doc));
return totp.validate(totp_engine, token, doc.secret.base32)
})
.then(function() {
req.session.auth_session.second_factor = true;
logger.debug('POST 2ndfactor totp: TOTP validation succeeded');
objectPath.set(req, 'session.auth_session.second_factor', true);
res.status(204);
res.send();
}, function(err) {
throw new exceptions.InvalidTOTPError();
})
.catch(exceptions.InvalidTOTPError, function(err) {
logger.error('POST 2ndfactor totp: Invalid TOTP token %s', err);
res.status(401);
res.send('Invalid TOTP token');
})
.catch(function(err) {
console.error(err);
replyWithUnauthorized(res);
logger.error('POST 2ndfactor totp: Internal error %s', err);
res.status(500);
res.send('Internal error');
});
}

View File

@ -0,0 +1,92 @@
var objectPath = require('object-path');
var Promise = require('bluebird');
var QRCode = require('qrcode');
var CHALLENGE = 'totp-register';
var icheck_interface = {
challenge: CHALLENGE,
render_template: 'totp-register',
pre_check_callback: pre_check,
email_subject: 'Register your TOTP secret key',
}
module.exports = {
icheck_interface: icheck_interface,
post: post,
}
function pre_check(req) {
var first_factor_passed = objectPath.get(req, 'session.auth_session.first_factor');
if(!first_factor_passed) {
return Promise.reject('Authentication required before registering TOTP secret key');
}
var userid = objectPath.get(req, 'session.auth_session.userid');
var email = objectPath.get(req, 'session.auth_session.email');
if(!(userid && email)) {
return Promise.reject('User ID or email is missing');
}
var identity = {};
identity.email = email;
identity.userid = userid;
return Promise.resolve(identity);
}
function secretToDataURLAsync(secret) {
return new Promise(function(resolve, reject) {
QRCode.toDataURL(secret.otpauth_url, function(err, url_data) {
if(err) {
reject(err);
return;
}
resolve(url_data);
});
});
}
// Generate a secret and send it to the user
function post(req, res) {
var logger = req.app.get('logger');
var userid = objectPath.get(req, 'session.auth_session.identity_check.userid');
var challenge = objectPath.get(req, 'session.auth_session.identity_check.challenge');
if(challenge != CHALLENGE || !userid) {
res.status(403);
res.send();
return;
}
var user_data_store = req.app.get('user data store');
var totp = req.app.get('totp engine');
var secret = totp.generateSecret();
var qrcode_data;
secretToDataURLAsync(secret)
.then(function(data) {
qrcode_data = data;
logger.debug('POST new-totp-secret: save the TOTP secret in DB');
return user_data_store.set_totp_secret(userid, secret);
})
.then(function() {
var doc = {};
doc.qrcode = qrcode_data;
doc.base32 = secret.base32;
doc.ascii = secret.ascii;
objectPath.set(req, 'session', undefined);
res.status(200);
res.json(doc);
})
.catch(function(err) {
logger.error('POST new-totp-secret: Internal error %s', err);
res.status(500);
res.send();
});
}

View File

@ -69,6 +69,7 @@ function run(config, ldap_client, deps, fn) {
app.get (base_endpoint + '/login', routes.login);
app.get (base_endpoint + '/logout', routes.logout);
identity_check(app, base_endpoint + '/totp-register', routes.totp_register.icheck_interface);
identity_check(app, base_endpoint + '/u2f-register', routes.u2f_register.icheck_interface);
identity_check(app, base_endpoint + '/reset-password', routes.reset_password.icheck_interface);
@ -77,6 +78,9 @@ function run(config, ldap_client, deps, fn) {
// Reset the password
app.post (base_endpoint + '/new-password', routes.reset_password.post);
// Generate a new TOTP secret
app.post (base_endpoint + '/new-totp-secret', routes.totp_register.post);
// verify authentication
app.get (base_endpoint + '/verify', routes.verify);

View File

@ -10,6 +10,8 @@ function UserDataStore(DataStore, options) {
create_collection('identity_check_tokens', options, DataStore);
this._authentication_traces_collection =
create_collection('authentication_traces', options, DataStore);
this._totp_secret_collection =
create_collection('totp_secrets', options, DataStore);
}
function create_collection(name, options, DataStore) {
@ -104,3 +106,19 @@ UserDataStore.prototype.consume_identity_check_token = function(token) {
return Promise.resolve(doc_content);
})
}
UserDataStore.prototype.set_totp_secret = function(userid, secret) {
var doc = {}
doc.userid = userid;
doc.secret = secret;
var query = {};
query.userid = userid;
return this._totp_secret_collection.updateAsync(query, doc, { upsert: true });
}
UserDataStore.prototype.get_totp_secret = function(userid) {
var query = {};
query.userid = userid;
return this._totp_secret_collection.findOneAsync(query);
}

View File

@ -39,11 +39,27 @@ body {
width:300px;
height:300px;
}
.login h1 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; }
.login h2 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; font-size: 1em; }
.totp {
position: absolute;
top: 50%;
left: 50%;
margin: -150px 0 0 -150px;
width:400px;
height:300px;
}
.login p { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; }
h1 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; }
h2 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; font-size: 1em; }
p { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; }
a { color: #fff; text-align: center; }
#qrcode { text-align: center; }
#secret { font-size: 0.7em; }
input {
width: 100%;

View File

@ -39,13 +39,26 @@ function onTotpSignButtonClicked() {
var token = $('#totp-token').val();
validateSecondFactorTotp(token, function(err) {
if(err) {
onSecondFactorTotpFailure();
onSecondFactorTotpFailure(err.responseText);
return;
}
onSecondFactorTotpSuccess();
});
}
function onTotpRegisterButtonClicked() {
$.ajax({
type: 'POST',
url: '/authentication/totp-register'
})
.done(function(data) {
$.notify('An email has been sent to your email address', 'info');
})
.fail(function(xhr, status) {
$.notify('Unable to send you an email', 'error');
});
}
function onU2fSignButtonClicked() {
startU2fAuthentication(function(err) {
if(err) {
@ -174,8 +187,8 @@ function onSecondFactorTotpSuccess() {
onAuthenticationSuccess();
}
function onSecondFactorTotpFailure() {
$.notify('Wrong TOTP token', 'error');
function onSecondFactorTotpFailure(err) {
$.notify('Error while validating TOTP token. Cause: ' + err, 'error');
}
function onU2fAuthenticationSuccess() {
@ -216,6 +229,10 @@ function setupTotpSignButton() {
setupEnterKeypressListener('#totp', onTotpSignButtonClicked);
}
function setupTotpRegisterButton() {
$('#second-factor #totp-register-button').on('click', onTotpRegisterButtonClicked);
}
function setupU2fSignButton() {
$('#second-factor #u2f-sign-button').on('click', onU2fSignButtonClicked);
setupEnterKeypressListener('#u2f', onU2fSignButtonClicked);
@ -241,6 +258,7 @@ function enterSecondFactor() {
showSecondFactorLayout();
cleanupFirstFactorLoginButton();
setupTotpSignButton();
setupTotpRegisterButton();
setupU2fSignButton();
setupU2fRegistrationButton();
}

View File

@ -0,0 +1,28 @@
(function() {
function generateSecret(fn) {
$.ajax({
type: 'POST',
url: '/authentication/new-totp-secret',
contentType: 'application/json',
dataType: 'json',
})
.done(function(data) {
fn(undefined, data);
})
.fail(function(xhr, status) {
$.notify('Error when generating TOTP secret');
});
}
function onSecretGenerated(err, secret) {
// console.log('secret generated successfully', secret);
var img = $('<img src="' + secret.qrcode + '" alt="secret-qrcode"/>');
$('#qrcode').append(img);
$("#secret").text(secret.base32);
}
$(document).ready(function() {
generateSecret(onSecretGenerated);
});
})();

View File

@ -20,6 +20,7 @@
<h2>Time-Based One-Time Password</h2>
<input type="text" name="totp-token" id="totp-token" placeholder="Validation token" />
<button type="button" id="totp-sign-button" class="btn btn-primary btn-block btn-large">Sign</button>
<button type="button" id="totp-register-button" class="btn btn-primary btn-block btn-large">Register</button>
</div>
<div id="u2f">
<h2>FIDO Universal 2nd Factor</h2>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<head>
<title>TOTP Registration</title>
<% include head %>
</head>
<body>
<div class="totp">
<h1>TOTP Secret</h1>
<p>Insert your secret in Google Authenticator</p>
<p id="secret"></p>
<div id="qrcode"></div>
<p><a href="/authentication/login">Login</a></p>
</div>
</body>
<% include scripts %>
<script src="js/totp-register.js"></script>
</html>

View File

@ -36,6 +36,37 @@ module.exports = function(port) {
});
}
function execute_register_totp(jar, transporter) {
return request.postAsync({
url: BASE_URL + '/authentication/totp-register',
jar: jar
})
.then(function(res) {
assert.equal(res.statusCode, 204);
var html_content = transporter.sendMail.getCall(0).args[0].html;
var regexp = /identity_token=([a-zA-Z0-9]+)/;
var token = regexp.exec(html_content)[1];
// console.log(html_content, token);
return request.getAsync({
url: BASE_URL + '/authentication/totp-register?identity_token=' + token,
jar: jar
})
})
.then(function(res) {
assert.equal(res.statusCode, 200);
return request.postAsync({
url : BASE_URL + '/authentication/new-totp-secret',
jar: jar,
})
})
.then(function(res) {
console.log(res.statusCode);
console.log(res.body);
assert.equal(res.statusCode, 200);
return Promise.resolve(res.body);
});
}
function execute_totp(jar, token) {
return request.postAsync({
url: BASE_URL + '/authentication/2ndfactor/totp',
@ -136,6 +167,7 @@ module.exports = function(port) {
first_factor: execute_first_factor,
failing_first_factor: execute_failing_first_factor,
totp: execute_totp,
register_totp: execute_register_totp,
}
}

View File

@ -3,10 +3,12 @@ var totp = require('../../../src/lib/routes/totp');
var Promise = require('bluebird');
var sinon = require('sinon');
var assert = require('assert');
var winston = require('winston');
describe('test totp route', function() {
var req, res;
var totp_engine;
var user_data_store;
beforeEach(function() {
var app_get = sinon.stub();
@ -19,6 +21,7 @@ describe('test totp route', function() {
},
session: {
auth_session: {
userid: 'user',
first_factor: false,
second_factor: false
}
@ -33,46 +36,52 @@ describe('test totp route', function() {
totp_engine = {
totp: sinon.stub()
}
user_data_store = {};
user_data_store.get_totp_secret = sinon.stub();
var doc = {};
doc.userid = 'user';
doc.secret = {};
doc.secret.base32 = 'ABCDEF';
user_data_store.get_totp_secret.returns(Promise.resolve(doc));
app_get.withArgs('logger').returns(winston);
app_get.withArgs('totp engine').returns(totp_engine);
app_get.withArgs('config').returns(config);
app_get.withArgs('user data store').returns(user_data_store);
});
it('should send status code 204 when totp is valid', function() {
return new Promise(function(resolve, reject) {
totp_engine.totp.returns('abc');
res.send = sinon.spy(function() {
// Second factor passed
assert.equal(true, req.session.auth_session.second_factor)
assert.equal(204, res.status.getCall(0).args[0]);
resolve();
});
totp(req, res);
})
it('should send status code 204 when totp is valid', function(done) {
totp_engine.totp.returns('abc');
res.send = sinon.spy(function() {
// Second factor passed
assert.equal(true, req.session.auth_session.second_factor)
assert.equal(204, res.status.getCall(0).args[0]);
done();
});
totp(req, res);
});
it('should send status code 401 when totp is not valid', function() {
return new Promise(function(resolve, reject) {
totp_engine.totp.returns('bad_token');
res.send = sinon.spy(function() {
assert.equal(false, req.session.auth_session.second_factor)
assert.equal(401, res.status.getCall(0).args[0]);
resolve();
});
totp(req, res);
})
it('should send status code 401 when totp is not valid', function(done) {
totp_engine.totp.returns('bad_token');
res.send = sinon.spy(function() {
assert.equal(false, req.session.auth_session.second_factor)
assert.equal(401, res.status.getCall(0).args[0]);
done();
});
totp(req, res);
});
it('should send status code 401 when session has not been initiated', function() {
return new Promise(function(resolve, reject) {
totp_engine.totp.returns('abc');
res.send = sinon.spy(function() {
assert.equal(401, res.status.getCall(0).args[0]);
resolve();
});
req.session = {};
totp(req, res);
})
it('should send status code 401 when session has not been initiated', function(done) {
totp_engine.totp.returns('abc');
res.send = sinon.spy(function() {
assert.equal(403, res.status.getCall(0).args[0]);
done();
});
req.session = {};
totp(req, res);
});
});

View File

@ -0,0 +1,130 @@
var sinon = require('sinon');
var winston = require('winston');
var totp_register = require('../../../src/lib/routes/totp_register');
var assert = require('assert');
var Promise = require('bluebird');
describe('test totp register', function() {
var req, res;
var user_data_store;
beforeEach(function() {
req = {}
req.app = {};
req.app.get = sinon.stub();
req.app.get.withArgs('logger').returns(winston);
req.session = {};
req.session.auth_session = {};
req.session.auth_session.userid = 'user';
req.session.auth_session.email = 'user@example.com';
req.session.auth_session.first_factor = true;
req.session.auth_session.second_factor = false;
req.headers = {};
req.headers.host = 'localhost';
var options = {};
options.inMemoryOnly = true;
user_data_store = {};
user_data_store.set_u2f_meta = sinon.stub().returns(Promise.resolve({}));
user_data_store.get_u2f_meta = sinon.stub().returns(Promise.resolve({}));
user_data_store.issue_identity_check_token = sinon.stub().returns(Promise.resolve({}));
user_data_store.consume_identity_check_token = sinon.stub().returns(Promise.resolve({}));
user_data_store.set_totp_secret = sinon.stub().returns(Promise.resolve({}));
req.app.get.withArgs('user data store').returns(user_data_store);
res = {};
res.send = sinon.spy();
res.json = sinon.spy();
res.status = sinon.spy();
});
describe('test totp registration check', test_registration_check);
describe('test totp post secret', test_post_secret);
function test_registration_check() {
it('should fail if first_factor has not been passed', function(done) {
req.session.auth_session.first_factor = false;
totp_register.icheck_interface.pre_check_callback(req)
.catch(function(err) {
done();
});
});
it('should fail if userid is missing', function(done) {
req.session.auth_session.first_factor = false;
req.session.auth_session.userid = undefined;
totp_register.icheck_interface.pre_check_callback(req)
.catch(function(err) {
done();
});
});
it('should fail if email is missing', function(done) {
req.session.auth_session.first_factor = false;
req.session.auth_session.email = undefined;
totp_register.icheck_interface.pre_check_callback(req)
.catch(function(err) {
done();
});
});
it('should succeed if first factor passed, userid and email are provided', function(done) {
totp_register.icheck_interface.pre_check_callback(req)
.then(function(err) {
done();
});
});
}
function test_post_secret() {
it('should send the secret in json format', function(done) {
req.app.get.withArgs('totp engine').returns(require('speakeasy'));
req.session.auth_session.identity_check = {};
req.session.auth_session.identity_check.userid = 'user';
req.session.auth_session.identity_check.challenge = 'totp-register';
res.json = sinon.spy(function() {
done();
});
totp_register.post(req, res);
});
it('should clear the session for reauthentication', function(done) {
req.app.get.withArgs('totp engine').returns(require('speakeasy'));
req.session.auth_session.identity_check = {};
req.session.auth_session.identity_check.userid = 'user';
req.session.auth_session.identity_check.challenge = 'totp-register';
res.json = sinon.spy(function() {
assert.equal(req.session, undefined);
done();
});
totp_register.post(req, res);
});
it('should return 403 if the identity check challenge is not set', function(done) {
req.session.auth_session.identity_check = {};
req.session.auth_session.identity_check.challenge = undefined;
res.send = sinon.spy(function() {
assert.equal(res.status.getCall(0).args[0], 403);
done();
});
totp_register.post(req, res);
});
it('should return 500 if db throws', function(done) {
req.app.get.withArgs('totp engine').returns(require('speakeasy'));
req.session.auth_session.identity_check = {};
req.session.auth_session.identity_check.userid = 'user';
req.session.auth_session.identity_check.challenge = 'totp-register';
user_data_store.set_totp_secret.throws('internal error');
res.send = sinon.spy(function() {
assert.equal(res.status.getCall(0).args[0], 500);
done();
});
totp_register.post(req, res);
});
  }
});

View File

@ -219,10 +219,6 @@ describe('test the server', function() {
});
it('should return status code 204 when user is authenticated using totp', function() {
var real_token = speakeasy.totp({
secret: 'totp_secret',
encoding: 'base32'
});
var j = request.jar();
return requests.login(j)
.then(function(res) {
@ -231,6 +227,14 @@ describe('test the server', function() {
})
.then(function(res) {
assert.equal(res.statusCode, 204, 'first factor failed');
return requests.register_totp(j, transporter);
})
.then(function(secret) {
var sec = JSON.parse(secret);
var real_token = speakeasy.totp({
secret: sec.base32,
encoding: 'base32'
});
return requests.totp(j, real_token);
})
.then(function(res) {

View File

@ -0,0 +1,65 @@
var assert = require('assert');
var Promise = require('bluebird');
var sinon = require('sinon');
var MockDate = require('mockdate');
var UserDataStore = require('../../../src/lib/user_data_store');
var DataStore = require('nedb');
describe('test user data store', function() {
describe('test totp secrets store', test_totp_secrets);
});
function test_totp_secrets() {
it('should save and reload a totp secret', function() {
var options = {};
options.inMemoryOnly = true;
var data_store = new UserDataStore(DataStore, options);
var userid = 'user';
var secret = {};
secret.ascii = 'abc';
secret.base32 = 'ABCDKZLEFZGREJK';
return data_store.set_totp_secret(userid, secret)
.then(function() {
return data_store.get_totp_secret(userid);
})
.then(function(doc) {
assert('_id' in doc);
assert.equal(doc.userid, 'user');
assert.equal(doc.secret.ascii, 'abc');
assert.equal(doc.secret.base32, 'ABCDKZLEFZGREJK');
return Promise.resolve();
});
});
it('should only remember last secret', function() {
var options = {};
options.inMemoryOnly = true;
var data_store = new UserDataStore(DataStore, options);
var userid = 'user';
var secret1 = {};
secret1.ascii = 'abc';
secret1.base32 = 'ABCDKZLEFZGREJK';
var secret2 = {};
secret2.ascii = 'def';
secret2.base32 = 'XYZABC';
return data_store.set_totp_secret(userid, secret1)
.then(function() {
return data_store.set_totp_secret(userid, secret2)
})
.then(function() {
return data_store.get_totp_secret(userid);
})
.then(function(doc) {
assert('_id' in doc);
assert.equal(doc.userid, 'user');
assert.equal(doc.secret.ascii, 'def');
assert.equal(doc.secret.base32, 'XYZABC');
return Promise.resolve();
});
});
}