Merge pull request mozilla/fxa-auth-server#15 from mozilla/reset_protocol

Implement getResetToken and resetPassword of the idp protocol
This commit is contained in:
Danny Coates 2013-05-28 10:08:55 -07:00
Родитель 37c7b18660 a40f11074f
Коммит d44e0a149e
4 изменённых файлов: 413 добавлений и 17 удалений

Просмотреть файл

@ -19,13 +19,24 @@ var notFound = Hapi.Error.notFound;
* verifier: <password verifier>
* kA: <kA key>
* kB: <wrapped kB key>
* accountTokens: {}
* resetTokens: {}
* signTokens: {}
* }
*
* <sessionId>/session = {
* uid: <userId>
* }
*
* <accountToken>/account = {
* <accountToken>/accountToken = {
* uid: <userId>
* }
*
* <resetToken>/resetToken = {
* uid: <userId>
* }
*
* <signToken>/signer = {
* uid: <userId>
* }
*
@ -56,7 +67,10 @@ exports.create = function(data, cb) {
params: data.params,
verifier: data.verifier,
kA: key,
kB: data.kB
kB: data.kB,
accountTokens: {},
resetTokens: {},
signTokens: {}
}, cb);
}
], cb);
@ -74,7 +88,7 @@ exports.startLogin = function(email, cb) {
// eventually will store SRP state
// and expiration time
kv.store.set(sid + '/session', {
kv.cache.set(sid + '/session', {
uid: uid
}, function (err) {
// return sessionID
@ -92,7 +106,7 @@ exports.finishLogin = function(sessionId, verifier, cb) {
async.waterfall([
// get session doc
function(cb) {
kv.store.get(sessKey, function(err, session) {
kv.cache.get(sessKey, function(err, session) {
if (err) return cb(err);
if (!session) return cb(notFound('UnknownSession'));
cb(null, session.value);
@ -101,7 +115,7 @@ exports.finishLogin = function(sessionId, verifier, cb) {
// get user info
function(session, cb) {
uid = session.uid;
exports.getUser(session.uid, cb);
getUser(session.uid, cb);
},
// check password
@ -119,11 +133,11 @@ exports.finishLogin = function(sessionId, verifier, cb) {
// create temporary account token doc
function(token, cb) {
accountToken = token;
kv.store.set(token + '/accountToken', { uid: uid }, cb);
addAccountToken(uid, token, cb);
},
// delete session doc
function(cb) {
kv.store.delete(sessKey, cb);
kv.cache.delete(sessKey, cb);
},
// return info
function(cb) {
@ -145,7 +159,7 @@ exports.getSignToken = function(accountToken, cb) {
// Check that the accountToken exists
// and get the associated user id
function(cb) {
kv.store.get(accountKey, function(err, account) {
kv.cache.get(accountKey, function(err, account) {
if (err) return cb(err);
if (!account) return cb(notFound('UknownAccountToken'));
cb(null, account.value.uid);
@ -158,14 +172,18 @@ exports.getSignToken = function(accountToken, cb) {
},
function(token, cb) {
signToken = token;
kv.store.set(token + '/signer', {
uid: uid,
accessTime: Date.now()
addSignToken(uid, token, cb);
},
// delete account token from user's list
function(cb) {
updateUserData(uid, function(userDoc) {
delete userDoc.value.accountTokens[accountToken];
return userDoc;
}, cb);
},
// delete accountToken
// delete accountToken record
function(cb) {
kv.store.delete(accountToken + '/accountToken', cb);
kv.cache.delete(accountToken + '/accountToken', cb);
},
function(cb) {
cb(null, { signToken: signToken });
@ -173,6 +191,181 @@ exports.getSignToken = function(accountToken, cb) {
], cb);
};
// Takes an accountToken and creates a new resetToken
exports.getResetToken = function(accountToken, cb) {
var accountKey = accountToken + '/accountToken';
var uid, resetToken;
async.waterfall([
// Check that the accountToken exists
// and get the associated user id
function(cb) {
kv.cache.get(accountKey, function(err, account) {
if (err) return cb(err);
if (!account) return cb(notFound('UknownAccountToken'));
cb(null, account.value.uid);
});
},
// get new resetToken
function(id, cb) {
uid = id;
util.getResetToken(cb);
},
function(token, cb) {
resetToken = token;
addResetToken(uid, token, cb);
},
// delete account token from user's list
function(cb) {
updateUserData(uid, function(userDoc) {
delete userDoc.value.accountTokens[accountToken];
return userDoc;
}, cb);
},
// delete accountToken record
function(cb) {
kv.cache.delete(accountToken + '/accountToken', cb);
},
function(cb) {
cb(null, { resetToken: resetToken });
}
], cb);
};
exports.resetPassword = function(resetToken, data, cb) {
var userId;
async.waterfall([
// Check that the resetToken exists
// and get the associated user id
function(cb) {
kv.cache.get(resetToken + '/resetToken', function(err, doc) {
if (err) return cb(err);
if (!doc) return cb(notFound('UknownResetToken'));
userId = doc.value.uid;
cb(null);
});
},
// delete all accountTokens, signTokens, and resetTokens
function(cb) {
deleteAllTokens(userId, cb);
},
// get new class A key
util.getKA,
// create user account
function(key, cb) {
kv.store.set(userId + '/user', {
params: data.params,
verifier: data.verifier,
kA: key,
kB: data.kB,
accountTokens: {},
resetTokens: {},
signTokens: {}
}, cb);
}
], cb);
};
function deleteAllTokens(userId, cb) {
async.waterfall([
getUser.bind(null, userId),
function(user, cb) {
// map each token into a function that deletes that token's record
var funs = Object.keys(user.accountTokens).map(function(token) {
return function(cb) {
kv.cache.delete(token + '/accountToken', cb);
};
})
.concat(
Object.keys(user.signTokens).map(function(token) {
return function(cb) {
kv.store.delete(token + '/signer', cb);
};
}))
.concat(
Object.keys(user.resetTokens).map(function(token) {
return function(cb) {
kv.cache.delete(token + '/resetToken', cb);
};
}));
// run token deletions in parallel
async.parallel(funs, function(err) { cb(err); });
}
], cb);
}
function addTokenFn(tokenType, fn) {
return function (userId, token, cb) {
async.waterfall([
// First, add the signToken to the user's list
function(cb) {
updateUserData(userId, function(userDoc) {
if (token in userDoc.value[tokenType]) {
userDoc.value[tokenType][token] = true;
}
return userDoc;
}, cb);
},
fn.bind(null, token, userId),
], cb);
};
}
var addSignToken = addTokenFn('signTokens',
function(token, userId, cb) {
kv.store.set(token + '/signer', {
uid: userId,
accessTime: Date.now()
}, cb);
});
var addAccountToken = addTokenFn('accountTokens',
function(token, userId, cb) {
kv.cache.set(token + '/accountToken', {
uid: userId
}, cb);
});
var addResetToken = addTokenFn('resetTokens',
function(token, userId, cb) {
kv.cache.set(token + '/resetToken', {
uid: userId,
expirationTime: Date.now() + 60 * 60 * 24
}, cb);
});
function updateUserData(userId, update, cb) {
retryLoop('cas mismatch', 5, function(cb) {
getUserDoc(userId, function(err, doc) {
try {
doc = update(doc);
} catch (e) {
return cb(e);
}
kv.store.cas(userId + '/user', doc.value, doc.casid, cb);
});
}, cb);
}
// Helper function to retry execution in case of errors.
// 'fn' should be the function to execute, with its arguments bound
// except for the callback.
//
function retryLoop(errType, maxAttempts, fn, cb) {
var numRetries = 0;
var attempt = function() {
fn(function(err) {
if (!err) return cb(null);
if (err !== errType) return cb(err);
if (numRetries > maxAttempts) return cb('too many conflicts');
numRetries++;
process.nextTick(attempt);
});
};
attempt();
}
// This method returns the userId currently associated with an email address.
exports.getId = function(email, cb) {
kv.store.get(email + '/uid', function(err, result) {
@ -183,10 +376,19 @@ exports.getId = function(email, cb) {
};
// get meta data associated with a user
exports.getUser = function(userId, cb) {
var getUserDoc = function(userId, cb) {
kv.store.get(userId + '/user', function(err, doc) {
if (err) return cb(internalError(err));
if (!doc) return cb(notFound('UnknownUser'));
doc.id = userId;
cb(null, doc);
});
};
// get meta data associated with a user
var getUser = exports.getUser = function(userId, cb) {
getUserDoc(userId, function(err, doc) {
if (err) return cb(err);
cb(null, doc.value);
});
};

Просмотреть файл

@ -25,6 +25,12 @@ function getSignToken(cb) {
});
}
function getResetToken(cb) {
return crypto.randomBytes(32, function(err, buf) {
cb(null, buf.toString('hex'));
});
}
function getUserId() {
return uuid.v4();
}
@ -39,5 +45,6 @@ module.exports = {
getUserId: getUserId,
getSessionId: getSessionId,
getAccountToken: getAccountToken,
getSignToken: getSignToken
getSignToken: getSignToken,
getResetToken: getResetToken
};

Просмотреть файл

@ -112,7 +112,39 @@ var routes = [
}
}
}
}
},
{
method: 'POST',
path: '/resetToken',
config: {
handler: getResetToken,
validate: {
payload: {
accountToken: Hapi.types.String().required()
},
response: {
schema: {
resetToken: Hapi.types.String().required()
}
}
}
}
},
{
method: 'POST',
path: '/resetPassword',
config: {
handler: resetPassword,
validate: {
payload: {
resetToken: Hapi.types.String().required(),
verifier: Hapi.types.String().required(),
params: Hapi.types.Object(),
kB: Hapi.types.String()
}
}
}
},
];
function wellKnown(request) {
@ -209,6 +241,36 @@ function getSignToken(request) {
);
}
function getResetToken(request) {
account.getResetToken(
request.payload.accountToken,
function (err, result) {
if (err) {
request.reply(err);
}
else {
request.reply(result);
}
}
);
}
function resetPassword(request) {
account.resetPassword(
request.payload.resetToken,
request.payload,
function (err) {
if (err) {
request.reply(err);
}
else {
request.reply('ok');
}
}
);
}
module.exports = {
routes: routes
};

Просмотреть файл

@ -10,10 +10,12 @@ var testClient = new helpers.TestClient();
var TEST_EMAIL = 'foo@example.com';
var TEST_PASSWORD = 'foo';
var TEST_PASSWORD_NEW = 'I like pie.';
var TEST_KB = 'secret!';
var TEST_KB_NEW = 'super secret!';
describe('user', function() {
var sessionId, accountToken, pubkey, signToken;
var sessionId, accountToken, pubkey, signToken, resetToken;
it('should create a new account', function(done) {
testClient.makeRequest('POST', '/create', {
@ -197,5 +199,128 @@ describe('user', function() {
});
});
it('should begin a new login', function(done) {
testClient.makeRequest('POST', '/startLogin', {
payload: { email: TEST_EMAIL }
}, function(res) {
sessionId = res.result.sessionId;
try {
assert.ok(res.result.sessionId);
} catch (e) {
return done(e);
}
done();
});
});
it('should finish login and get a new accountToken', function(done) {
testClient.makeRequest('POST', '/finishLogin', {
payload: {
sessionId: sessionId,
password: TEST_PASSWORD
}
}, function(res) {
try {
accountToken = res.result.accountToken;
assert.ok(res.result.accountToken);
assert.ok(res.result.kA);
assert.equal(res.result.kB, TEST_KB);
} catch (e) {
return done(e);
}
done();
});
});
it('should get resetToken', function(done) {
testClient.makeRequest('POST', '/resetToken', {
payload: {
accountToken: accountToken
}
}, function(res) {
try {
assert.equal(res.statusCode, 200);
resetToken = res.result.resetToken;
assert.ok(res.result.resetToken);
} catch (e) {
return done(e);
}
done();
});
});
it('should reset the account', function(done) {
testClient.makeRequest('POST', '/resetPassword', {
payload: {
resetToken: resetToken,
verifier: TEST_PASSWORD_NEW,
params: { foo: 'bar2' },
kB: TEST_KB_NEW
}
}, function(res) {
try {
assert.equal(res.statusCode, 200);
assert.equal(res.result, 'ok');
} catch (e) {
return done(e);
}
done();
});
});
it('should begin a login with resetted account', function(done) {
testClient.makeRequest('POST', '/startLogin', {
payload: { email: TEST_EMAIL }
}, function(res) {
sessionId = res.result.sessionId;
try {
assert.ok(res.result.sessionId);
} catch (e) {
return done(e);
}
done();
});
});
it('should fail to login with old password', function(done) {
testClient.makeRequest('POST', '/finishLogin', {
payload: {
sessionId: sessionId,
password: TEST_PASSWORD
}
}, function(res) {
try {
assert.equal(res.statusCode, 400);
assert.equal(res.result.message, 'IncorrectPassword');
} catch (e) {
return done(e);
}
done();
});
});
it('should finish login with new password', function(done) {
testClient.makeRequest('POST', '/finishLogin', {
payload: {
sessionId: sessionId,
password: TEST_PASSWORD_NEW
}
}, function(res) {
try {
accountToken = res.result.accountToken;
assert.ok(res.result.accountToken);
assert.ok(res.result.kA);
assert.equal(res.result.kB, TEST_KB_NEW);
} catch (e) {
return done(e);
}
done();
});
});
});