Skip to content

Commit

Permalink
Merge pull request #12798 from /issues/12278
Browse files Browse the repository at this point in the history
feat(third-party auth): Add UI with /create_password route for passwordless accounts
  • Loading branch information
LZoog authored May 18, 2022
2 parents 161c9fc + 9192ae8 commit 9cb8888
Show file tree
Hide file tree
Showing 34 changed files with 1,089 additions and 400 deletions.
13 changes: 5 additions & 8 deletions packages/fxa-auth-client/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,14 +820,11 @@ export default class AuthClient {
}

async createPassword(
sessionToken: string,
email: string,
newPassword: string
): Promise<any> {
const newCredentials = await crypto.getCredentials(
email,
newPassword
);
sessionToken: string,
email: string,
newPassword: string
): Promise<number> {
const newCredentials = await crypto.getCredentials(email, newPassword);

const payload = {
authPW: newCredentials.authPW,
Expand Down
84 changes: 43 additions & 41 deletions packages/fxa-auth-server/lib/routes/password.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const error = require('../error');
const isA = require('@hapi/joi');
const random = require('../crypto/random');
const requestHelper = require('../routes/utils/request_helper');
const errors = require('../error');
const { emailsMatch } = require('fxa-shared').email.helpers;

const PASSWORD_DOCS = require('../../docs/swagger/password-api').default;
Expand Down Expand Up @@ -71,21 +70,21 @@ module.exports = function (
const password = new Password(
oldAuthPW,
emailRecord.authSalt,
emailRecord.verifierVersion,
emailRecord.verifierVersion
);
return signinUtils
.checkPassword(emailRecord, password, request.app.clientAddress)
.then((match) => {
if (!match) {
throw error.incorrectPassword(
emailRecord.email,
form.email,
form.email
);
}
const password = new Password(
oldAuthPW,
emailRecord.authSalt,
emailRecord.verifierVersion,
emailRecord.verifierVersion
);
return password.unwrap(emailRecord.wrapWrapKb);
})
Expand Down Expand Up @@ -119,7 +118,7 @@ module.exports = function (
});
}
throw err;
},
}
)
.then((tokens) => {
return {
Expand Down Expand Up @@ -158,7 +157,7 @@ module.exports = function (
.label('Password.changeFinish_payload'),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.changeFinish', request);
const passwordChangeToken = request.auth.credentials;
const authPW = request.payload.authPW;
Expand Down Expand Up @@ -230,7 +229,7 @@ module.exports = function (
// get informed about the change via WebChannel message.
if (originatingDeviceId) {
devicesToNotify = devicesToNotify.filter(
(d) => d.id !== originatingDeviceId,
(d) => d.id !== originatingDeviceId
);
}
});
Expand Down Expand Up @@ -278,7 +277,7 @@ module.exports = function (
// Notify the devices that the account has changed.
push.notifyPasswordChanged(
passwordChangeToken.uid,
devicesToNotify,
devicesToNotify
);
}

Expand All @@ -292,7 +291,7 @@ module.exports = function (
generation: account.verifierSetAt,
});
return oauth.removePublicAndCanGrantTokens(
passwordChangeToken.uid,
passwordChangeToken.uid
);
})
.then(() => {
Expand Down Expand Up @@ -328,7 +327,7 @@ module.exports = function (
'Password.changeFinish.sendPasswordChangedNotification.error',
{
error: e,
},
}
);
});
});
Expand Down Expand Up @@ -383,7 +382,7 @@ module.exports = function (
) {
return db.verifyTokensWithMethod(
sessionToken.id,
previousSessionToken.verificationMethodValue,
previousSessionToken.verificationMethodValue
);
}
}
Expand Down Expand Up @@ -468,7 +467,7 @@ module.exports = function (
.label('Password.forgotSend_response'),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.forgotSend', request);
const email = request.payload.email;
const service = request.payload.service || request.query.service;
Expand Down Expand Up @@ -545,7 +544,7 @@ module.exports = function (
});
})
.then(() =>
request.emitMetricsEvent('password.forgot.send_code.completed'),
request.emitMetricsEvent('password.forgot.send_code.completed')
)
.then(() => ({
passwordForgotToken: passwordForgotToken.data,
Expand Down Expand Up @@ -598,7 +597,7 @@ module.exports = function (
.label('Password.forgotResend_response'),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.forgotResend', request);
const passwordForgotToken = request.auth.credentials;
const service = request.payload.service || request.query.service;
Expand All @@ -612,7 +611,7 @@ module.exports = function (
customs.check(
request,
passwordForgotToken.email,
'passwordForgotResendCode',
'passwordForgotResendCode'
),
])
.then(() => {
Expand Down Expand Up @@ -651,7 +650,7 @@ module.exports = function (
})
.then(() => {
return request.emitMetricsEvent(
'password.forgot.resend_code.completed',
'password.forgot.resend_code.completed'
);
})
.then(() => {
Expand Down Expand Up @@ -695,7 +694,7 @@ module.exports = function (
.label('Password.forgotVerify_response'),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.forgotVerify', request);
const passwordForgotToken = request.auth.credentials;
const code = request.payload.code;
Expand All @@ -712,7 +711,7 @@ module.exports = function (
customs.check(
request,
passwordForgotToken.email,
'passwordForgotVerifyCode',
'passwordForgotVerifyCode'
),
])
.then(() => {
Expand All @@ -735,7 +734,7 @@ module.exports = function (
return Promise.all([
request.propagateMetricsContext(
passwordForgotToken,
accountResetToken,
accountResetToken
),
db.accountEmails(passwordForgotToken.uid),
]);
Expand All @@ -758,7 +757,7 @@ module.exports = function (
});
})
.then(() =>
request.emitMetricsEvent('password.forgot.verify_code.completed'),
request.emitMetricsEvent('password.forgot.verify_code.completed')
)
.then(() => ({
accountResetToken: accountResetToken.data,
Expand All @@ -773,51 +772,54 @@ module.exports = function (
auth: {
strategy: 'sessionToken',
},
response: {
schema: isA
.object({
authPW: isA.string(),
}),
validate: {
payload: isA.object({
authPW: isA.string(),
}),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.create', request);
const sessionToken = request.auth.credentials;
const { uid } = sessionToken;

const { authPW } = request.payload;

const account = await db.account(uid);
// We don't allow users that have a password set already to create a new password
// because this process would destroy their original encryption keys and might
// leave the account in an invalid state.
if (account.verifierSetAt > 0) {
throw error.cannotCreatePassword();
}

// Users that have enabled 2FA must be in a 2FA verified session to create a password.
const hasTotpToken = await otpUtils.hasTotpToken(account);
if (hasTotpToken &&
(sessionToken.tokenVerificationId || sessionToken.authenticatorAssuranceLevel <= 1)
) {
if (
hasTotpToken &&
(sessionToken.tokenVerificationId ||
sessionToken.authenticatorAssuranceLevel <= 1)
) {
throw error.unverifiedSession();
}

const authSalt = await random.hex(32);
const password = new Password(
authPW,
authSalt,
config.verifierVersion,
);
const password = new Password(authPW, authSalt, config.verifierVersion);
const verifyHash = await password.verifyHash();

// Accounts that don't have a password set, also do not have encryption keys therefore
// we generate one for them.
const wrapWrapKb = await random.hex(32);

await db.createPassword(uid, authSalt, verifyHash, wrapWrapKb, verifierVersion);

return {};
const passwordCreated = await db.createPassword(
uid,
authSalt,
verifyHash,
wrapWrapKb,
verifierVersion
);

return passwordCreated;
},
},
{
Expand All @@ -837,7 +839,7 @@ module.exports = function (
.label('Password.forgotStatus_response'),
},
},
handler: async function(request) {
handler: async function (request) {
log.begin('Password.forgotStatus', request);
const passwordForgotToken = request.auth.credentials;
return {
Expand Down
46 changes: 27 additions & 19 deletions packages/fxa-auth-server/test/local/routes/password.js
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ describe('/password', () => {
mockDB = mocks.mockDB({
uid,
email: TEST_EMAIL,
verifierSetAt: 0
verifierSetAt: 0,
});
mockMailer = mocks.mockMailer();
const mockLog = mocks.mockLog();
Expand All @@ -666,7 +666,7 @@ describe('/password', () => {
log: mockLog,
credentials: {
email: TEST_EMAIL,
uid
uid,
},
payload: {
authPW,
Expand All @@ -675,67 +675,75 @@ describe('/password', () => {
});

it('should create password', async () => {
const res = await runRoute(passwordRoutes, '/password/create', mockRequest);
const res = await runRoute(
passwordRoutes,
'/password/create',
mockRequest
);
assert.equal(mockDB.account.callCount, 1);
assert.equal(mockDB.createPassword.callCount, 1);
assert.deepEqual(res, {});
assert.deepEqual(res, 1584397692000);
});

it('should fail if password already created', async () => {
mockDB = mocks.mockDB({
uid,
email: TEST_EMAIL,
verifierSetAt: Date.now()
verifierSetAt: Date.now(),
});
passwordRoutes = makeRoutes({
db: mockDB,
mailer: mockMailer,
});

try {
await runRoute(passwordRoutes, '/password/create', mockRequest);
assert.fail('should not set password');
} catch(err) {
assert.equal(err.errno, 206, 'can not create password error')
} catch (err) {
assert.equal(err.errno, 206, 'can not create password error');
}
});

it('should fail if not in totp verified session', async () => {
mockDB.totpToken = sinon.spy(() => {
return {
verified: true,
enabled: true
}
})
enabled: true,
};
});
passwordRoutes = makeRoutes({
db: mockDB,
mailer: mockMailer,
});
mockRequest.auth.credentials.authenticatorAssuranceLevel = 1
mockRequest.auth.credentials.authenticatorAssuranceLevel = 1;
try {
await runRoute(passwordRoutes, '/password/create', mockRequest);
assert.fail('should not set password');
} catch(err) {
assert.equal(err.errno, 138, 'unverified session error')
} catch (err) {
assert.equal(err.errno, 138, 'unverified session error');
}
});

it('should succeed if in totp verified session', async () => {
mockDB.totpToken = sinon.spy(() => {
return {
verified: true,
enabled: true
}
})
enabled: true,
};
});
passwordRoutes = makeRoutes({
db: mockDB,
mailer: mockMailer,
});
mockRequest.auth.credentials.authenticatorAssuranceLevel = 2;
const res = await runRoute(passwordRoutes, '/password/create', mockRequest);
const res = await runRoute(
passwordRoutes,
'/password/create',
mockRequest
);
assert.equal(mockDB.account.callCount, 1);
assert.equal(mockDB.createPassword.callCount, 1);
assert.deepEqual(res, {});
assert.deepEqual(res, 1584397692000);
});
});
});
Loading

0 comments on commit 9cb8888

Please sign in to comment.