feat(unblock): add Signin Unblock feature

Adds a `unblockCode` parameter to the `/account/login` route, which can
be used to bypass select rate-limits.

Also addes `/account/login/send_unblock_code` and
`/account/login/reject_unblock_code` routes, to facilitate receiving an
unblock code through email, or to reject and report one if someone tried
attacking your account.

Closes #1398
This commit is contained in:
Sean McArthur 2016-10-11 17:26:37 -07:00
Родитель 7fabfa0fe7
Коммит c3a66c217f
16 изменённых файлов: 804 добавлений и 108 удалений

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

@ -39,6 +39,7 @@ function main() {
var error = require('../lib/error')
var Token = require('../lib/tokens')(log, config)
var Password = require('../lib/crypto/password')(log, config)
var UnblockCode = require('../lib/crypto/base36')(config.signinUnblock.codeLength)
var signer = require('../lib/signer')(config.secretKeyFile, config.domain)
var serverPublicKeys = {
@ -89,7 +90,8 @@ function main() {
Token.KeyFetchToken,
Token.AccountResetToken,
Token.PasswordForgotToken,
Token.PasswordChangeToken
Token.PasswordChangeToken,
UnblockCode
)
DB.connect(config[config.db.backend])

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

@ -498,6 +498,57 @@ var conf = convict({
default: /.+@mozilla\.com$/,
env: 'LASTACCESSTIME_UPDATES_EMAIL_ADDRESSES'
}
},
signinUnblock: {
codeLength: {
doc: 'Number of base36 digits to make up an unblockCode',
default: 8,
env: 'SIGNIN_UNBLOCK_CODE_LENGTH'
},
codeLifetime: {
doc: 'How long an unblockCode should be valid for',
format: 'duration',
default: '1 hour',
env: 'SIGNIN_UNBLOCK_CODE_LIFETIME'
},
enabled: {
default: true
},
allowedEmailAddresses: {
doc: 'If feature enabled, allow sign-in unblock for email addresses matching this regex.',
format: RegExp,
default: '.+@mozilla\\.com$',
env: 'SIGNIN_UNBLOCK_ALLOWED_EMAILS'
},
forcedEmailAddresses: {
doc: 'If feature enabled, force sign-in unblock for email addresses matching this regex.',
format: RegExp,
default: '^$', // default is no one
env: 'SIGNIN_UNBLOCK_FORCED_EMAILS'
},
sampleRate: {
doc: 'signin unblock sample rate, between 0.0 and 1.0',
default: 1.0,
env: 'SIGNIN_UNBLOCK_RATE'
},
supportedClients: {
doc: 'support sign-in unblock for only these clients',
format: Array,
default: [
'web',
'oauth',
'iframe',
'fx_firstrun_v1',
'fx_firstrun_v2',
'fx_desktop_v1',
'fx_desktop_v2',
'fx_desktop_v3',
'fx_ios_v1',
'fx_ios_v2',
'fx_fennec_v1'
],
env: 'SIGNIN_UNBLOCK_SUPPORTED_CLIENTS'
}
}
})
@ -518,6 +569,7 @@ conf.set('smtp.passwordResetUrl', conf.get('contentServer.url') + '/v1/complete_
conf.set('smtp.initiatePasswordResetUrl', conf.get('contentServer.url') + '/reset_password')
conf.set('smtp.initiatePasswordChangeUrl', conf.get('contentServer.url') + '/settings/change_password')
conf.set('smtp.verifyLoginUrl', conf.get('contentServer.url') + '/complete_signin')
conf.set('smtp.reportSignInUrl', conf.get('contentServer.url') + '/report_signin')
conf.set('isProduction', conf.get('env') === 'prod')

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

@ -86,6 +86,7 @@ The currently-defined error responses are:
* status code 400, errno 124: session already registered by another device
* status code 400, errno 125: request blocked for security reasons
* status code 400, errno 126: account must be reset
* status code 400, errno 127: invalid unblock code
* status code 503, errno 201: service temporarily unavailable to due high load (see [backoff protocol](#backoff-protocol))
* status code 503, errno 202: feature has been disabled for operational reasons
* any status code, errno 999: unknown error
@ -318,6 +319,7 @@ ___Parameters___
* authPW - the PBKDF2/HKDF stretched password as a hex string
* service - (optional) opaque alphanumeric token to be included in verification links
* reason - (optional) alphanumeric string indicating the reason for establishing a new session; may be "login" (the default) or "reconnect"
* unblockCode - (optional) base36 code used to unblock certain rate-limitings
### Request
@ -363,6 +365,7 @@ Failing requests may be due to the following errors:
* status code 413, errno 113: request body too large
* status code 400, errno 120: incorrect email case
* status code 400, errno 126: account must be reset
* status code 400, errno 127: invalid unblock code
## GET /v1/account/keys

32
lib/crypto/base36.js Normal file
Просмотреть файл

@ -0,0 +1,32 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
const crypto = require('crypto')
function base36(len) {
let out = []
while (out.length < len) {
let rand = crypto.randomBytes(len)
let randLen = rand.length
for (let i = 0; i < randLen; i++) {
let b = rand[i]
// 252-256 skews the base36 distribution, so skip those bytes
if (b < 252) {
out.push((b % 36).toString(36))
if (out.length === len) {
break
}
}
}
}
return out.join('').toUpperCase()
}
module.exports = (len) => {
return () => {
return base36(len)
}
}

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

@ -55,14 +55,16 @@ module.exports = function (log, error) {
// log a flow event that user got blocked.
log.flowEvent('customs.blocked', request)
var unblock = !!result.unblock
if (result.retryAfter) {
// create a localized retryAfterLocalized value from retryAfter, for example '713' becomes '12 minutes'.
var retryAfterLocalized = localizeTimestamp.format(Date.now() + (result.retryAfter * 1000),
request.headers['accept-language'])
throw error.tooManyRequests(result.retryAfter, retryAfterLocalized)
throw error.tooManyRequests(result.retryAfter, retryAfterLocalized, unblock)
} else {
throw error.requestBlocked(unblock)
}
throw error.requestBlocked()
}
if (result.suspect) {
request.app.isSuspiciousRequest = true

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

@ -19,7 +19,8 @@ module.exports = function (
KeyFetchToken,
AccountResetToken,
PasswordForgotToken,
PasswordChangeToken) {
PasswordChangeToken,
UnblockCode) {
const features = require('./features')(config)
@ -803,6 +804,49 @@ module.exports = function (
return this.pool.get('/securityEvents/' + params.uid.toString('hex') + '/ip/' + params.ipAddr)
}
DB.prototype.createUnblockCode = function (uid) {
log.trace({
op: 'DB.createUnblockCode',
uid: uid
})
var unblock = UnblockCode()
return this.pool.put('/account/' + uid.toString('hex') + '/unblock/' + unblock)
.then(
() => {
return unblock
},
(err) => {
// duplicates should be super rare, but it's feasible that a
// uid already has an existing unblockCode. Just try again.
if (isRecordAlreadyExistsError(err)) {
log.error({
op: 'DB.createUnblockCode.duplicate',
err: err,
uid: uid
})
return this.createUnblockCode(uid)
}
throw err
}
)
}
DB.prototype.consumeUnblockCode = function (uid, code) {
log.trace({
op: 'DB.consumeUnblockCode',
uid: uid
})
return this.pool.del('/account/' + uid.toString('hex') + '/unblock/' + code)
.catch(
function (err) {
if (isNotFoundError(err)) {
throw error.invalidUnblockCode()
}
throw err
}
)
}
return DB
}

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

@ -24,6 +24,7 @@ var ERRNO = {
INVALID_REQUEST_SIGNATURE: 109,
INVALID_TIMESTAMP: 111,
INVALID_TOKEN: 110,
INVALID_UNBLOCK_CODE: 127,
INVALID_VERIFICATION_CODE: 105,
MISSING_CONTENT_LENGTH_HEADER: 112,
MISSING_PARAMETER: 108,
@ -327,7 +328,7 @@ AppError.requestBodyTooLarge = function () {
})
}
AppError.tooManyRequests = function (retryAfter, retryAfterLocalized) {
AppError.tooManyRequests = function (retryAfter, retryAfterLocalized, canUnblock) {
if (!retryAfter) {
retryAfter = 30
}
@ -340,6 +341,11 @@ AppError.tooManyRequests = function (retryAfter, retryAfterLocalized) {
extraData.retryAfterLocalized = retryAfterLocalized
}
if (canUnblock) {
extraData.verificationMethod = 'email-captcha'
extraData.verificationReason = 'login'
}
return new AppError(
{
code: 429,
@ -354,13 +360,20 @@ AppError.tooManyRequests = function (retryAfter, retryAfterLocalized) {
)
}
AppError.requestBlocked = function () {
AppError.requestBlocked = function (canUnblock) {
var extra
if (canUnblock) {
extra = {
verificationMethod: 'email-captcha',
verificationReason: 'login'
}
}
return new AppError({
code: 400,
error: 'Request blocked',
errno: ERRNO.REQUEST_BLOCKED,
message: 'The request was blocked for security reasons'
})
}, extra)
}
AppError.serviceUnavailable = function (retryAfter) {
@ -448,5 +461,14 @@ AppError.deviceSessionConflict = function () {
)
}
AppError.invalidUnblockCode = function () {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.INVALID_UNBLOCK_CODE,
message: 'Invalid unblock code'
})
}
module.exports = AppError
module.exports.ERRNO = ERRNO

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

@ -9,6 +9,7 @@ const crypto = require('crypto')
module.exports = config => {
const lastAccessTimeUpdates = config.lastAccessTimeUpdates
const signinConfirmation = config.signinConfirmation
const signinUnblock = config.signinUnblock
return {
/**
@ -52,7 +53,6 @@ module.exports = config => {
// edge-cases in device login flows that haven't been fully tested.
// Temporarily avoid them for regular users by checking the `context` flag,
// and create pre-verified sessions for unsupported clients.
// This check will go away in the final version of this feature.
const context = request.payload &&
request.payload.metricsContext &&
request.payload.metricsContext.context
@ -64,6 +64,43 @@ module.exports = config => {
return isSampledUser(signinConfirmation.sample_rate, uid, 'signinConfirmation')
},
/**
* Returns whether or not to use signin unblock feature on a request.
*
* @param account
* @param config
* @param request
* @returns {boolean}
*/
isSigninUnblockEnabledForUser(uid, email, request) {
if (! signinUnblock.enabled) {
return false
}
if (signinUnblock.forcedEmailAddresses && signinUnblock.forcedEmailAddresses.test(email)) {
return true
}
if (signinUnblock.allowedEmailAddresses.test(email)) {
return true
}
// While we're testing this feature, there may be some funky
// edge-cases in device login flows that haven't been fully tested.
// Temporarily avoid them for regular users by checking the `context` flag,
const context = request.payload &&
request.payload.metricsContext &&
request.payload.metricsContext.context
if (signinUnblock.supportedClients.indexOf(context) === -1) {
return false
}
// Check to see if user in roll-out cohort.
return isSampledUser(signinUnblock.sampleRate, uid, 'signinUnblock')
},
/**
* Predicate that indicates whether a user belongs to the sampled cohort,
* based on a sample rate, their uid and a key that identifies the feature.

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

@ -25,7 +25,10 @@ var ACTIVITY_FLOW_EVENTS = Object.keys(ALWAYS_ACTIVITY_FLOW_EVENTS)
}, {
// These activity events are flow events when there is a flowId
'account.keyfetch': true,
'account.signed': true
'account.login.sentUnblockCode': true,
'account.login.confirmedUnblockCode': true,
'account.signed': true,
'device.created': true
})
function unbuffer(object) {

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

@ -103,6 +103,23 @@ module.exports = function (config, log) {
}
))
}
mailer.sendUnblockCode = function (account, unblockCode, opts) {
return P.resolve(mailer.unblockCodeEmail(
{
acceptLanguage: opts.acceptLanguage || defaultLanguage,
email: account.email,
ip: opts.ip,
location: opts.location,
timeZone: opts.timeZone,
uaBrowser: opts.uaBrowser,
uaBrowserVersion: opts.uaBrowserVersion,
uaOS: opts.uaOS,
uaOSVersion: opts.uaOSVersion,
uid: account.uid.toString('hex'),
unblockCode: unblockCode
}
))
}
return mailer
}
)

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

@ -2,11 +2,14 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
var validators = require('./validators')
var HEX_STRING = validators.HEX_STRING
var BASE64_JWT = validators.BASE64_JWT
var DISPLAY_SAFE_UNICODE = validators.DISPLAY_SAFE_UNICODE
var URLSAFEBASE64 = validators.URLSAFEBASE64
var BASE_36 = validators.BASE_36
var PUSH_PAYLOADS_SCHEMA_PATH = '../../docs/pushpayloads.schema.json'
// An arbitrary, but very generous, limit on the number of active sessions.
@ -56,7 +59,9 @@ module.exports = function (
})
const features = require('../features')(config)
var securityHistoryEnabled = config.securityHistory && config.securityHistory.enabled
const securityHistoryEnabled = config.securityHistory && config.securityHistory.enabled
const unblockCodeLifetime = config.signinUnblock && config.signinUnblock.codeLifetime || 0
const unblockCodeLen = config.signinUnblock && config.signinUnblock.codeLength || 0
var routes = [
{
@ -354,6 +359,7 @@ module.exports = function (
redirectTo: isA.string().uri().optional(),
resume: isA.string().optional(),
reason: isA.string().max(16).optional(),
unblockCode: isA.string().regex(BASE_36).length(unblockCodeLen).optional(),
metricsContext: metricsContext.schema
}
},
@ -380,7 +386,7 @@ module.exports = function (
var redirectTo = request.payload.redirectTo
var resume = request.payload.resume
var tokenVerificationId = crypto.randomBytes(16)
var emailRecord, sessions, sessionToken, keyFetchToken, mustVerifySession, doSigninConfirmation, emailSent
var emailRecord, sessions, sessionToken, keyFetchToken, mustVerifySession, doSigninConfirmation, emailSent, unblockCode, customsErr, allowSigninUnblock, didSigninUnblock
var ip = request.app.clientAddress
metricsContext.validate(request)
@ -393,8 +399,11 @@ module.exports = function (
})
}
customs.check(request, email, 'accountLogin')
checkIsBlockForced()
.then(() => customs.check(request, email, 'accountLogin'))
.catch(checkUnblockCode)
.then(readEmailRecord)
.then(checkEmailAndPassword)
.then(checkSecurityHistory)
.then(checkNumberOfActiveSessions)
.then(createSessionToken)
@ -405,52 +414,72 @@ module.exports = function (
.then(sendVerifyLoginEmail)
.then(recordSecurityEvent)
.then(createResponse)
.catch(gateSigninUnblock)
.done(reply, reply)
function checkIsBlockForced () {
let forced = config.signinUnblock && config.signinUnblock.enabled && config.signinUnblock.forcedEmailAddresses
if (forced && forced.test(email)) {
return P.reject(error.requestBlocked(true))
}
return P.resolve()
}
function checkUnblockCode (e) {
var method = e.output.payload.verificationMethod
if (method === 'email-captcha') {
// only set `unblockCode` if it is required from customs
unblockCode = request.payload.unblockCode
if (unblockCode) {
unblockCode = unblockCode.toUpperCase()
}
customsErr = e
return
}
throw e
}
function readEmailRecord () {
return db.emailRecord(email)
.then(
function (result) {
emailRecord = result
// Session token verification is only enabled for certain users during phased rollout.
// Even when it is enabled, we only do the email challenge if:
// * the request wants keys, since unverified sessions are fine to use for e.g. oauth login.
// * the email is verified, since content-server triggers a resend of the verification
// email on unverified accounts, which doubles as sign-in confirmation.
if (! features.isSigninConfirmationEnabledForUser(emailRecord.uid, emailRecord.email, request)) {
tokenVerificationId = undefined
mustVerifySession = false
doSigninConfirmation = false
} else {
// The user doesn't *have* to verify their session if they're not requesting keys,
// but we still create it with a non-null tokenVerificationId, so it will still
// be considered unverified. This prevents the session from being used for sync
// unless the user explicitly requests us to resend the confirmation email, and completes it.
mustVerifySession = requestHelper.wantsKeys(request)
doSigninConfirmation = mustVerifySession && emailRecord.emailVerified
}
if(email !== emailRecord.email) {
customs.flag(request.app.clientAddress, {
email: email,
errno: error.ERRNO.INCORRECT_PASSWORD
})
throw error.incorrectPassword(emailRecord.email, email)
}
return checkPassword(emailRecord, authPW, request.app.clientAddress)
.then(
function (match) {
if (! match) {
throw error.incorrectPassword(emailRecord.email, email)
allowSigninUnblock = features.isSigninUnblockEnabledForUser(emailRecord.uid, email, request)
if (allowSigninUnblock && unblockCode) {
return db.consumeUnblockCode(emailRecord.uid, unblockCode)
.then(
(code) => {
if (Date.now() - code.createdAt > unblockCodeLifetime) {
log.info({
op: 'Account.login.unblockCode.expired',
uid: emailRecord.uid.toString('hex')
})
throw error.invalidUnblockCode()
}
didSigninUnblock = true
return log.activityEvent('account.login.confirmedUnblockCode', request, {
uid: emailRecord.uid.toString('hex')
})
}
return log.activityEvent('account.login', request, {
uid: emailRecord.uid.toString('hex')
})
}
)
)
.catch(
(err) => {
if (err.errno === error.ERRNO.UNBLOCK_CODE_INVALID) {
customs.flag(request.app.clientAddress, {
email: email,
errno: err.errno
})
}
throw err
}
)
}
if (!didSigninUnblock && customsErr) {
throw customsErr
}
},
function (err) {
if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) {
@ -530,6 +559,51 @@ module.exports = function (
)
}
function checkEmailAndPassword () {
// Session token verification is only enabled for certain users during phased rollout.
//
// If the user went through the sigin-unblock flow, they have already verified their email.
// No need to also require confirmation afterwards.
//
// Even when it is enabled, we only do the email challenge if:
// * the request wants keys, since unverified sessions are fine to use for e.g. oauth login.
// * the email is verified, since content-server triggers a resend of the verification
// email on unverified accounts, which doubles as sign-in confirmation.
if (didSigninUnblock || !features.isSigninConfirmationEnabledForUser(emailRecord.uid, emailRecord.email, request)) {
tokenVerificationId = undefined
mustVerifySession = false
doSigninConfirmation = false
} else {
// The user doesn't *have* to verify their session if they're not requesting keys,
// but we still create it with a non-null tokenVerificationId, so it will still
// be considered unverified. This prevents the session from being used for sync
// unless the user explicitly requests us to resend the confirmation email, and completes it.
mustVerifySession = requestHelper.wantsKeys(request)
doSigninConfirmation = mustVerifySession && emailRecord.emailVerified
}
if(email !== emailRecord.email) {
customs.flag(request.app.clientAddress, {
email: email,
errno: error.ERRNO.INCORRECT_PASSWORD
})
throw error.incorrectPassword(emailRecord.email, email)
}
return checkPassword(emailRecord, authPW, request.app.clientAddress)
.then(
function (match) {
if (! match) {
throw error.incorrectPassword(emailRecord.email, email)
}
return log.activityEvent('account.login', request, {
uid: emailRecord.uid.toString('hex')
})
}
)
}
function checkNumberOfActiveSessions () {
return db.sessions(emailRecord.uid)
.then(
@ -741,6 +815,18 @@ module.exports = function (
}
return P.resolve(response)
}
function gateSigninUnblock (err) {
// customs.check will always add these properties if the
// customs server has not rate-limited unblock. Nonetheless,
// we shouldn't signal to the content-server that it is
// possible to unblock the user if the feature is not allowed.
if (!allowSigninUnblock && err.output && err.output.payload) {
delete err.output.payload.verificationMethod
delete err.output.payload.verificationReason
}
throw err
}
}
},
{
@ -1537,6 +1623,94 @@ module.exports = function (
reply(error.gone())
}
},
{
method: 'POST',
path: '/account/login/send_unblock_code',
config: {
validate: {
payload: {
email: validators.email().required(),
metricsContext: metricsContext.schema
}
}
},
handler: function (request, reply) {
log.begin('Account.SendUnblockCode', request)
var email = request.payload.email
var ip = request.app.clientAddress
var emailRecord
return customs.check(request, email, 'sendUnblockCode')
.then(lookupAccount)
.then(createUnblockCode)
.then(mailUnblockCode)
.then(() => log.activityEvent('account.login.sentUnblockCode', request, {
uid: emailRecord.uid.toString('hex')
}))
.done(() => {
reply({})
}, reply)
function lookupAccount() {
return db.emailRecord(email)
.then((record) => {
emailRecord = record
return record.uid
})
}
function createUnblockCode(uid) {
if (features.isSigninUnblockEnabledForUser(uid, email, request)) {
return db.createUnblockCode(uid)
} else {
throw error.featureNotEnabled()
}
}
function mailUnblockCode(code) {
return getGeoData(ip)
.then((geoData) => {
return mailer.sendUnblockCode(emailRecord, code, userAgent.call({
acceptLanguage: request.app.acceptLanguage,
ip: ip,
location: geoData.location,
timeZone: geoData.timeZone
}, request.headers['user-agent'], log))
})
}
}
},
{
method: 'POST',
path: '/account/login/reject_unblock_code',
config: {
validate: {
payload: {
uid: isA.string().max(32).regex(HEX_STRING).required(),
unblockCode: isA.string().regex(BASE_36).length(unblockCodeLen).required()
}
}
},
handler: function (request, reply) {
var uid = Buffer(request.payload.uid, 'hex')
var code = request.payload.unblockCode.toUpperCase()
log.begin('Account.RejectUnblockCode', request)
db.consumeUnblockCode(uid, code)
.then(
() => {
log.info({
op: 'account.login.rejectedUnblockCode',
uid: request.payload.uid,
unblockCode: code
})
return {}
}
).done(reply, reply)
}
},
{
method: 'POST',
path: '/account/reset',

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

@ -14,6 +14,8 @@ module.exports.BASE64_JWT = /^(?:[a-zA-Z0-9-_]+[=]{0,2}\.){2}[a-zA-Z0-9-_]+[=]{0
module.exports.URLSAFEBASE64 = /^[a-zA-Z0-9-_]*$/
module.exports.BASE_36 = /^[a-zA-Z0-9]*$/
// Match display-safe unicode characters.
// We're pretty liberal with what's allowed in a unicode string,
// but we exclude the following classes of characters:

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

@ -2,6 +2,8 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict'
var sinon = require('sinon')
var test = require('tap').test
@ -36,6 +38,7 @@ var makeRoutes = function (options, requireMocks) {
}
config.lastAccessTimeUpdates = {}
config.signinConfirmation = config.signinConfirmation || {}
config.signinUnblock = config.signinUnblock || {}
var log = options.log || mocks.mockLog()
var Password = options.Password || require('../../lib/crypto/password')(log, config)
@ -698,13 +701,18 @@ test('/account/create', function (t) {
})
test('/account/login', function (t) {
t.plan(5)
t.plan(6)
var config = {
newLoginNotificationEnabled: true,
securityHistory: {
enabled: true
},
signinConfirmation: {}
signinConfirmation: {},
signinUnblock: {
allowedEmailAddresses: /^.*$/,
codeLifetime: 1000,
enabled: true
}
}
var mockRequest = mocks.mockRequest({
query: {
@ -738,6 +746,21 @@ test('/account/login', function (t) {
}
}
})
var mockRequestWithUnblockCode = mocks.mockRequest({
query: {},
payload: {
authPW: crypto.randomBytes(32).toString('hex'),
email: 'test@mozilla.com',
unblockCode: 'ABCD1234',
service: 'dcdb5ae7add825d2',
reason: 'signin',
metricsContext: {
flowBeginTime: Date.now(),
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
service: 'dcdb5ae7add825d2'
}
}
})
var keyFetchTokenId = crypto.randomBytes(16)
var sessionTokenId = crypto.randomBytes(16)
var uid = uuid.v4('binary')
@ -770,16 +793,15 @@ test('/account/login', function (t) {
})
var mockMailer = mocks.mockMailer()
var mockPush = mocks.mockPush()
var mockCustoms = {
check: () => P.resolve()
}
var accountRoutes = makeRoutes({
checkPassword: function () {
return P.resolve(true)
},
config: config,
customs: {
check: function () {
return P.resolve()
}
},
customs: mockCustoms,
db: mockDB,
log: mockLog,
mailer: mockMailer,
@ -1400,7 +1422,6 @@ test('/account/login', function (t) {
t.equal(record, undefined, 'log.info was not called for Account.history.verified')
})
})
})
t.test('records security event', function (t) {
@ -1417,6 +1438,93 @@ test('/account/login', function (t) {
t.equal(securityQuery.name, 'account.login')
})
})
t.test('blocked by customs', (t) => {
t.plan(2)
t.test('can unblock', (t) => {
t.plan(2)
mockCustoms.check = () => P.reject(error.requestBlocked(true))
t.test('signin unblock disabled', (t) => {
t.plan(4)
config.signinUnblock.enabled = false
return runTest(route, mockRequest, (err) => {
t.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
t.equal(err.output.payload.verificationMethod, undefined, 'no verificationMethod')
t.equal(err.output.payload.verificationReason, undefined, 'no verificationReason')
})
})
t.test('signin unblock enabled', (t) => {
t.plan(2)
config.signinUnblock.enabled = true
t.test('without unblock code', (t) => {
t.plan(4)
return runTest(route, mockRequest, (err) => {
t.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
t.equal(err.output.payload.verificationMethod, 'email-captcha', 'with verificationMethod')
t.equal(err.output.payload.verificationReason, 'login', 'with verificationReason')
})
})
t.test('with unblock code', (t) => {
t.plan(3)
t.test('invalid code', (t) => {
t.plan(2)
mockDB.consumeUnblockCode = () => P.reject(error.invalidUnblockCode())
return runTest(route, mockRequestWithUnblockCode, (err) => {
t.equal(err.errno, error.ERRNO.INVALID_UNBLOCK_CODE, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
})
})
t.test('expired code', (t) => {
mockDB.consumeUnblockCode = () => P.resolve({ createdAt: Date.now() - config.signinUnblock.codeLifetime - 1 })
return runTest(route, mockRequestWithUnblockCode, (err) => {
t.equal(err.errno, error.ERRNO.INVALID_UNBLOCK_CODE, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
})
})
t.test('valid code', (t) => {
t.plan(1)
mockDB.consumeUnblockCode = () => P.resolve({ createdAt: Date.now() })
return runTest(route, mockRequestWithUnblockCode, (res) => {
t.ok(!(res instanceof Error), 'successful login')
})
})
})
})
})
t.test('cannot unblock', (t) => {
t.plan(2)
mockCustoms.check = () => P.reject(error.requestBlocked(false))
config.signinUnblock.enabled = true
t.test('without an unblock code', (t) => {
t.plan(4)
return runTest(route, mockRequest, (err) => {
t.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
t.equal(err.output.payload.verificationMethod, undefined, 'no verificationMethod')
t.equal(err.output.payload.verificationReason, undefined, 'no verificationReason')
})
})
t.test('with unblock code', (t) => {
t.plan(4)
return runTest(route, mockRequestWithUnblockCode, (err) => {
t.equal(err.errno, error.ERRNO.REQUEST_BLOCKED, 'correct errno is returned')
t.equal(err.output.statusCode, 400, 'correct status code is returned')
t.equal(err.output.payload.verificationMethod, undefined, 'no verificationMethod')
t.equal(err.output.payload.verificationReason, undefined, 'no verificationReason')
})
})
})
})
})
test('/recovery_email/verify_code', function (t) {
@ -1759,3 +1867,95 @@ test('/account/devices', function (t) {
})
})
test('/account/login/send_unblock_code', function (t) {
t.plan(2)
var uid = uuid.v4('binary').toString('hex')
var mockRequest = mocks.mockRequest({
payload: {
email: TEST_EMAIL,
metricsContext: {
context: 'fx_desktop_v3',
flowBeginTime: Date.now(),
flowId: 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103',
entrypoint: 'preferences',
utmContent: 'some-content-string'
}
}
})
var mockMailer = mocks.mockMailer()
var mockDb = mocks.mockDB({
uid: uid,
email: TEST_EMAIL
})
var config = {
signinUnblock: {
allowedEmailAddresses: /^.*$/
}
}
var accountRoutes = makeRoutes({
config: config,
db: mockDb,
mailer: mockMailer
})
var route = getRoute(accountRoutes, '/account/login/send_unblock_code')
t.test('signin unblock enabled', function (t) {
t.plan(9)
config.signinUnblock.enabled = true
return runTest(route, mockRequest, function (response) {
t.ok(!(response instanceof Error), response.stack)
t.deepEqual(response, {}, 'response has no keys')
t.equal(mockDb.emailRecord.callCount, 1, 'db.emailRecord called')
t.equal(mockDb.emailRecord.args[0][0], TEST_EMAIL)
t.equal(mockDb.createUnblockCode.callCount, 1, 'db.createUnblockCode called')
var dbArgs = mockDb.createUnblockCode.args[0]
t.equal(dbArgs.length, 1)
t.equal(dbArgs[0], uid)
t.equal(mockMailer.sendUnblockCode.callCount, 1, 'called mailer.sendUnblockCode')
var args = mockMailer.sendUnblockCode.args[0]
t.equal(args.length, 3, 'mailer.sendUnblockCode called with 3 args')
})
})
t.test('signin unblock disabled', function (t) {
t.plan(2)
config.signinUnblock.enabled = false
return runTest(route, mockRequest, function (err) {
t.equal(err.output.statusCode, 503, 'correct status code is returned')
t.equal(err.errno, error.ERRNO.FEATURE_NOT_ENABLED, 'correct errno is returned')
})
})
})
test('/account/login/reject_unblock_code', function (t) {
t.plan(6)
var uid = uuid.v4('binary').toString('hex')
var unblockCode = 'A1B2C3D4'
var mockRequest = mocks.mockRequest({
payload: {
uid: uid,
unblockCode: unblockCode
}
})
var mockDb = mocks.mockDB()
var accountRoutes = makeRoutes({
db: mockDb
})
var route = getRoute(accountRoutes, '/account/login/reject_unblock_code')
return runTest(route, mockRequest, function (response) {
t.ok(!(response instanceof Error), response.stack)
t.deepEqual(response, {}, 'response has no keys')
t.equal(mockDb.consumeUnblockCode.callCount, 1, 'consumeUnblockCode is called')
var args = mockDb.consumeUnblockCode.args[0]
t.equal(args.length, 2)
t.equal(args[0].toString('hex'), uid)
t.equal(args[1], unblockCode)
})
})

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

@ -19,7 +19,8 @@ const crypto = {
const config = {
lastAccessTimeUpdates: {},
signinConfirmation: {}
signinConfirmation: {},
signinUnblock: {}
}
const features = proxyquire('../../lib/features', {
@ -30,10 +31,11 @@ test(
'interface is correct',
t => {
t.equal(typeof features, 'object', 'object type should be exported')
t.equal(Object.keys(features).length, 3, 'object should have two properties')
t.equal(Object.keys(features).length, 4, 'object should have four properties')
t.equal(typeof features.isSampledUser, 'function', 'isSampledUser should be function')
t.equal(typeof features.isLastAccessTimeEnabledForUser, 'function', 'isLastAccessTimeEnabledForUser should be function')
t.equal(typeof features.isSigninConfirmationEnabledForUser, 'function', 'isSigninConfirmationEnabledForUser should be function')
t.equal(typeof features.isSigninUnblockEnabledForUser, 'function', 'isSigninUnblockEnabledForUser should be function')
t.equal(crypto.createHash.callCount, 1, 'crypto.createHash should have been called once on require')
let args = crypto.createHash.args[0]
@ -230,3 +232,50 @@ test(
}
)
test(
'isSigninUnblockEnabledForUser',
t => {
const uid = 'wibble'
const email = 'blee@mozilla.com'
const request = {
payload: {
metricsContext: {
context: 'iframe'
}
}
}
// First 27 characters are ignored, last 13 are 0.02 * 0xfffffffffffff
hashResult = '000000000000000000000000000051eb851eb852'
const unblock = config.signinUnblock
unblock.enabled = true
unblock.sampleRate = 0.02
unblock.allowedEmailAddresses = /.+@notmozilla.com$/
unblock.supportedClients = [ 'wibble', 'iframe' ]
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), false, 'should return false when email is not allowed and uid is not sampled')
unblock.forcedEmailAddresses = /.+/
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), true, 'should return true when forced on')
unblock.forcedEmailAddresses = /^$/
unblock.allowedEmailAddresses = /.+@mozilla.com$/
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), true, 'should return true when email is allowed')
unblock.allowedEmailAddresses = /.+@notmozilla.com$/
unblock.sampleRate = 0.03
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), true, 'should return when uid is sampled')
request.payload.metricsContext.context = ''
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), false, 'should return false when context is not supported')
request.payload.metricsContext.context = 'iframe'
unblock.enabled = false
t.equal(features.isSigninUnblockEnabledForUser(uid, email, request), false, 'should return false when feature is disabled')
t.end()
}
)

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

@ -24,6 +24,8 @@ require('simplesmtp').createSimpleServer(
function (mail) {
var link = mail.headers['x-link']
var rc = mail.headers['x-recovery-code']
var rul = mail.headers['x-report-signin-link']
var uc = mail.headers['x-unblock-code']
var vc = mail.headers['x-verify-code']
var name = emailName(mail.headers.to)
if (vc) {
@ -32,6 +34,11 @@ require('simplesmtp').createSimpleServer(
else if (rc) {
console.log('\x1B[34m', link, '\x1B[39m')
}
else if (uc) {
console.log('\x1B[36mUnblock code:', uc, '\x1B[39m')
console.log('\x1B[36mReport link:', rul, '\x1B[39m')
}
else {
console.error('\x1B[31mNo verify code match\x1B[39m')
console.error(mail)

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

@ -6,33 +6,85 @@
* Shared helpers for mocking things out in the tests.
*/
var sinon = require('sinon')
var extend = require('util')._extend
var P = require('../lib/promise')
var crypto = require('crypto')
const sinon = require('sinon')
const extend = require('util')._extend
const P = require('../lib/promise')
const crypto = require('crypto')
var CUSTOMS_METHOD_NAMES = ['check', 'checkAuthenticated', 'flag', 'reset']
const CUSTOMS_METHOD_NAMES = [
'check',
'checkAuthenticated',
'flag',
'reset'
]
var DB_METHOD_NAMES = ['account', 'createAccount', 'createDevice', 'createKeyFetchToken',
'createPasswordForgotToken', 'createSessionToken', 'deleteAccount',
'deleteDevice', 'deleteKeyFetchToken', 'deletePasswordChangeToken',
'deleteVerificationReminder', 'devices', 'emailRecord', 'forgotPasswordVerified',
'resetAccount', 'securityEvent', 'securityEvents', 'sessions',
'sessionTokenWithVerificationStatus', 'updateDevice', 'updateLocale',
'updateSessionToken', 'verifyEmail', 'verifyTokens',]
const DB_METHOD_NAMES = [
'account',
'consumeUnblockCode',
'createAccount',
'createDevice',
'createKeyFetchToken',
'createPasswordForgotToken',
'createSessionToken',
'createUnblockCode',
'deleteAccount',
'deleteDevice',
'deleteKeyFetchToken',
'deletePasswordChangeToken',
'deleteVerificationReminder',
'devices',
'emailRecord',
'forgotPasswordVerified',
'resetAccount',
'securityEvent',
'securityEvents',
'sessions',
'sessionTokenWithVerificationStatus',
'updateDevice',
'updateLocale',
'updateSessionToken',
'verifyEmail',
'verifyTokens'
]
var LOG_METHOD_NAMES = ['trace', 'increment', 'info', 'error', 'begin', 'warn', 'timing',
'activityEvent', 'flowEvent', 'notifyAttachedServices']
const LOG_METHOD_NAMES = [
'activityEvent',
'begin',
'error',
'flowEvent',
'increment',
'info',
'notifyAttachedServices',
'warn',
'timing',
'trace'
]
var MAILER_METHOD_NAMES = ['sendVerifyCode', 'sendVerifyLoginEmail',
'sendNewDeviceLoginNotification', 'sendPasswordChangedNotification',
'sendPasswordResetNotification', 'sendPostVerifyEmail']
const MAILER_METHOD_NAMES = [
'sendNewDeviceLoginNotification',
'sendPasswordChangedNotification',
'sendPasswordResetNotification',
'sendPostVerifyEmail',
'sendUnblockCode',
'sendVerifyCode',
'sendVerifyLoginEmail'
]
var METRICS_CONTEXT_METHOD_NAMES = ['stash', 'gather', 'validate']
const METRICS_CONTEXT_METHOD_NAMES = [
'gather',
'stash',
'validate'
]
var PUSH_METHOD_NAMES = ['notifyDeviceConnected', 'notifyDeviceDisconnected', 'notifyUpdate',
'pushToAllDevices', 'pushToDevices', 'notifyPasswordChanged',
'notifyPasswordReset']
const PUSH_METHOD_NAMES = [
'notifyDeviceConnected',
'notifyDeviceDisconnected',
'notifyPasswordChanged',
'notifyPasswordReset',
'notifyUpdate',
'pushToAllDevices',
'pushToDevices'
]
module.exports = {
mockCustoms: mockObject(CUSTOMS_METHOD_NAMES),
@ -51,7 +103,7 @@ function mockDB (data, errors) {
errors = errors || {}
return mockObject(DB_METHOD_NAMES)({
account: sinon.spy(function () {
account: sinon.spy(() => {
return P.resolve({
email: data.email,
emailCode: data.emailCode,
@ -61,7 +113,7 @@ function mockDB (data, errors) {
wrapWrapKb: data.wrapWrapKb
})
}),
createAccount: sinon.spy(function () {
createAccount: sinon.spy(() => {
return P.resolve({
uid: data.uid,
email: data.email,
@ -70,8 +122,8 @@ function mockDB (data, errors) {
wrapWrapKb: data.wrapWrapKb
})
}),
createDevice: sinon.spy(function () {
return P.resolve(Object.keys(data.device).reduce(function (result, key) {
createDevice: sinon.spy(() => {
return P.resolve(Object.keys(data.device).reduce((result, key) => {
result[key] = data.device[key]
return result
}, {
@ -79,14 +131,14 @@ function mockDB (data, errors) {
createdAt: data.deviceCreatedAt
}))
}),
createKeyFetchToken: sinon.spy(function () {
createKeyFetchToken: sinon.spy(() => {
return P.resolve({
data: crypto.randomBytes(32),
tokenId: data.keyFetchTokenId,
uid: data.uid
})
}),
createPasswordForgotToken: sinon.spy(function () {
createPasswordForgotToken: sinon.spy(() => {
return P.resolve({
data: crypto.randomBytes(32),
passCode: data.passCode,
@ -94,12 +146,12 @@ function mockDB (data, errors) {
uid: data.uid
})
}),
createSessionToken: sinon.spy(function () {
createSessionToken: sinon.spy(() => {
return P.resolve({
data: crypto.randomBytes(32),
email: data.email,
emailVerified: data.emailVerified,
lastAuthAt: function () {
lastAuthAt: () => {
return Date.now()
},
tokenId: data.sessionTokenId,
@ -108,10 +160,10 @@ function mockDB (data, errors) {
uid: data.uid
})
}),
devices: sinon.spy(function () {
devices: sinon.spy(() => {
return P.resolve(data.devices || [])
}),
emailRecord: sinon.spy(function () {
emailRecord: sinon.spy(() => {
if (errors.emailRecord) {
return P.reject(errors.emailRecord)
}
@ -122,31 +174,31 @@ function mockDB (data, errors) {
email: data.email,
emailVerified: data.emailVerified,
kA: crypto.randomBytes(32),
lastAuthAt: function () {
lastAuthAt: () => {
return Date.now()
},
uid: data.uid,
wrapWrapKb: crypto.randomBytes(32)
})
}),
forgotPasswordVerified: sinon.spy(function () {
forgotPasswordVerified: sinon.spy(() => {
return P.resolve(data.accountResetToken)
}),
securityEvents: sinon.spy(function () {
securityEvents: sinon.spy(() => {
return P.resolve([])
}),
sessions: sinon.spy(function () {
sessions: sinon.spy(() => {
return P.resolve(data.sessions || [])
}),
updateDevice: sinon.spy(function (uid, sessionTokenId, device) {
updateDevice: sinon.spy((uid, sessionTokenId, device) => {
return P.resolve(device)
}),
sessionTokenWithVerificationStatus: sinon.spy(function () {
sessionTokenWithVerificationStatus: sinon.spy(() => {
return P.resolve({
tokenVerified: true
})
}),
verifyTokens: sinon.spy(function () {
verifyTokens: sinon.spy(() => {
if (errors.verifyTokens) {
return P.reject(errors.verifyTokens)
}
@ -156,12 +208,9 @@ function mockDB (data, errors) {
}
function mockObject (methodNames) {
return function (methods) {
return methodNames.reduce(function (object, name) {
object[name] = methods && methods[name] || sinon.spy(function () {
return P.resolve()
})
return methods => {
return methodNames.reduce((object, name) => {
object[name] = methods && methods[name] || sinon.spy(() => P.resolve())
return object
}, {})
}
@ -171,14 +220,14 @@ function mockDevices (data) {
data = data || {}
return {
upsert: sinon.spy(function () {
upsert: sinon.spy(() => {
return P.resolve({
id: data.deviceId || crypto.randomBytes(16),
name: data.deviceName || 'mock device name',
type: data.deviceType || 'desktop'
})
}),
synthesizeName: sinon.spy(function () {
synthesizeName: sinon.spy(() => {
return data.deviceName || null
})
}
@ -188,8 +237,8 @@ function mockDevices (data) {
// You can pass in an object of custom logging methods
// if you need to e.g. make assertions about logged values.
function mockLog (methods) {
var log = extend({}, methods)
LOG_METHOD_NAMES.forEach(function(name) {
const log = extend({}, methods)
LOG_METHOD_NAMES.forEach((name) => {
if (!log[name]) {
log[name] = function() {}
}
@ -202,8 +251,9 @@ function mockLog (methods) {
function spyLog (methods) {
methods = extend({}, methods)
methods.messages = methods.messages || []
LOG_METHOD_NAMES.forEach(function(name) {
LOG_METHOD_NAMES.forEach(name => {
if (!methods[name]) {
// arrow function would alter `this` inside the method
methods[name] = function() {
this.messages.push({
level: name,