Locked account updates.
* `lockAccount` takes both `lockedAt` and `unlockCode` * Expose the `unlockCode` endpoint to get the unlock code * Add an `/account/lock` endpoint. Used for testing. * Sending an account unlock email requires the account to be locked. * Add API docs for /account/lock * Point to the mozilla repo for fxa-auth-db-mem * Add an `enableLockout` configuration option. * Extract the route removal code into a module. * Add a new error `accountNotLocked`. Error is only returned for `/account/unlock/resend_code` * A new `locked` event is logged whenever an account is locked. Can be used to determine the number of locked accounts over a timespan.
This commit is contained in:
Родитель
087abb2d99
Коммит
cbad29619c
|
@ -56,7 +56,7 @@ function main() {
|
|||
.done(
|
||||
function (db) {
|
||||
database = db
|
||||
customs = new Customs(config.customsUrl, database)
|
||||
customs = new Customs(config.customsUrl)
|
||||
var routes = require('../routes')(
|
||||
log,
|
||||
error,
|
||||
|
|
|
@ -236,6 +236,11 @@ module.exports = function (fs, path, url, convict) {
|
|||
certPath: {
|
||||
doc: "path to SSL certificate in PEM format if serving over https",
|
||||
default: path.resolve(__dirname, '../cert.pem')
|
||||
},
|
||||
lockoutEnabled: {
|
||||
doc: 'Is account lockout enabled',
|
||||
format: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -20,5 +20,6 @@
|
|||
"region": "us-east-1"
|
||||
},
|
||||
"customsUrl": "none",
|
||||
"trustedJKUs": ["http://127.0.0.1:8080/.well-known/public-keys"]
|
||||
"trustedJKUs": ["http://127.0.0.1:8080/.well-known/public-keys"],
|
||||
"lockoutEnabled": true
|
||||
}
|
||||
|
|
15
customs.js
15
customs.js
|
@ -7,8 +7,7 @@
|
|||
|
||||
module.exports = function (log, error) {
|
||||
|
||||
function Customs(url, db) {
|
||||
this.db = db
|
||||
function Customs(url) {
|
||||
if (url === 'none') {
|
||||
this.pool = {
|
||||
post: function () { return P({ block: false })},
|
||||
|
@ -57,20 +56,16 @@
|
|||
}
|
||||
)
|
||||
.then(
|
||||
(function (result) {
|
||||
if (result.lockout) {
|
||||
return this.db.lockAccount(account)
|
||||
}
|
||||
}).bind(this)
|
||||
)
|
||||
.then(
|
||||
function () {},
|
||||
function (result) {
|
||||
return { lockout: !!result.lockout }
|
||||
},
|
||||
function (err) {
|
||||
log.error({ op: 'customs.flag.1', email: email, err: err })
|
||||
// If this happens, either:
|
||||
// - (1) the url in config doesn't point to a real customs server
|
||||
// - (2) the customs server returned an internal server error
|
||||
// Either way, allow the request through so we fail open.
|
||||
return { lockout: false }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
33
db/httpdb.js
33
db/httpdb.js
|
@ -4,6 +4,7 @@
|
|||
|
||||
var Pool = require('../pool')
|
||||
|
||||
var crypto = require('crypto')
|
||||
var butil = require('../crypto/butil')
|
||||
var unbuffer = butil.unbuffer
|
||||
var bufferize = butil.bufferize
|
||||
|
@ -440,19 +441,41 @@ module.exports = function (
|
|||
}
|
||||
|
||||
DB.prototype.lockAccount = function (account) {
|
||||
log.trace({ op: 'DB.lockAccount', uid: account && account.uid })
|
||||
var unlockCode = crypto.randomBytes(16).toString('hex');
|
||||
log.trace({ op: 'DB.lockAccount', uid: account && account.uid, unlockCode: unlockCode })
|
||||
|
||||
return this.pool.post(
|
||||
'/account/' + account.uid.toString('hex') + '/lock',
|
||||
{ lockedAt: Date.now() }
|
||||
{
|
||||
lockedAt: Date.now(),
|
||||
unlockCode: unlockCode
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DB.prototype.unlockAccount = function (account) {
|
||||
log.trace({ op: 'DB.unlockAccount', uid: account && account.uid })
|
||||
return this.pool.post(
|
||||
'/account/' + account.uid.toString('hex') + '/unlock',
|
||||
{ }
|
||||
)
|
||||
'/account/' + account.uid.toString('hex') + '/unlock'
|
||||
);
|
||||
}
|
||||
|
||||
DB.prototype.unlockCode = function (account) {
|
||||
log.trace({ op: 'DB.unlockCode', uid: account && account.uid })
|
||||
return this.pool.get(
|
||||
'/account/' + account.uid.toString('hex') + '/unlockCode'
|
||||
)
|
||||
.then(
|
||||
function (body) {
|
||||
return bufferize(body).unlockCode
|
||||
},
|
||||
function (err) {
|
||||
if (err.statusCode === 404) {
|
||||
err = error.accountNotLocked()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DB.prototype.verifyEmail = function (account) {
|
||||
|
|
45
docs/api.md
45
docs/api.md
|
@ -81,6 +81,7 @@ The currently-defined error responses are:
|
|||
* status code 400, errno 119: incorrect API version for this account
|
||||
* status code 400, errno 120: incorrect email case
|
||||
* status code 400, errno 121: account is locked
|
||||
* status code 400, errno 122: account is not locked
|
||||
* status code 503, errno 201: service temporarily unavailable to due high load (see [backoff protocol](#backoff-protocol))
|
||||
* any status code, errno 999: unknown error
|
||||
|
||||
|
@ -110,6 +111,7 @@ Since this is a HTTP-based protocol, clients should be prepared to gracefully ha
|
|||
* [GET /v1/account/keys (:lock: keyFetchToken) (verf-required)](#get-v1accountkeys)
|
||||
* [POST /v1/account/reset (:lock: accountResetToken)](#post-v1accountreset)
|
||||
* [POST /v1/account/destroy](#post-v1accountdestroy)
|
||||
* [POST /v1/account/lock](#post-v1accountlock)
|
||||
|
||||
* Authentication
|
||||
* [POST /v1/account/login](#post-v1accountlogin)
|
||||
|
@ -508,6 +510,47 @@ Failing requests may be due to the following errors:
|
|||
* status code 400, errno 120: incorrect email case
|
||||
* status code 400, errno 121: account is locked
|
||||
|
||||
## POST /v1/account/lock
|
||||
|
||||
HAWK-authenticated.
|
||||
|
||||
This locks an account and prevents the user from performing any action that requires a password until their email address is re-verified. This endpoint is for testing only and is disabled for production.
|
||||
|
||||
__Parameters__
|
||||
|
||||
* email - the email address for the account
|
||||
* authPW - the PBKDF2/HKDF stretched password as a hex string
|
||||
|
||||
### Request
|
||||
|
||||
```sh
|
||||
curl -v \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api-accounts.dev.lcip.org/v1/account/lock \
|
||||
-d '{
|
||||
"email": "me@example.com",
|
||||
"authPW": "996bc6b1aa63cd69856a2ec81cbf19d5c8a604713362df9ee15c2bf07128efab"
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
Successful requests will produce a "200 OK" and a json body.
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
Failing requests may be due to the following errors:
|
||||
|
||||
* status code 400, errno 102: attempt to access an account that does not exist
|
||||
* status code 400, errno 103: incorrect password
|
||||
* status code 400, errno 106: request body was not valid json
|
||||
* status code 400, errno 107: request body contains invalid parameters
|
||||
* status code 400, errno 108: request body missing required parameters
|
||||
* status code 411, errno 112: content-length header was not provided
|
||||
* status code 413, errno 113: request body too large
|
||||
|
||||
## POST /v1/account/unlock/resend_code
|
||||
|
||||
|
@ -552,6 +595,7 @@ Failing requests may be due to the following errors:
|
|||
* status code 400, errno 108: request body missing required parameters
|
||||
* status code 411, errno 112: content-length header was not provided
|
||||
* status code 413, errno 113: request body too large
|
||||
* status code 400, errno 122: account is not locked
|
||||
|
||||
|
||||
## POST /v1/account/unlock/verify_code
|
||||
|
@ -591,7 +635,6 @@ Successful requests will produce a "200 OK" response with an empty JSON body:
|
|||
|
||||
Failing requests may be due to the following errors:
|
||||
|
||||
* status code 400, errno 102: attempt to access an account that does not exist
|
||||
* status code 400, errno 105: invalid verification code
|
||||
* status code 400, errno 106: request body was not valid json
|
||||
* status code 400, errno 107: request body contains invalid parameters
|
||||
|
|
16
error.js
16
error.js
|
@ -340,4 +340,18 @@ AppError.lockedAccount = function () {
|
|||
})
|
||||
}
|
||||
|
||||
module.exports = AppError
|
||||
AppError.accountNotLocked = function (email) {
|
||||
return new AppError(
|
||||
{
|
||||
code: 400,
|
||||
error: 'Bad Request',
|
||||
errno: 122,
|
||||
message: 'Account is not locked'
|
||||
},
|
||||
{
|
||||
email: email
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = AppError;
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -35,7 +35,7 @@
|
|||
"bunyan": "1.2.0",
|
||||
"compute-cluster": "0.0.9",
|
||||
"convict": "0.6",
|
||||
"fxa-auth-mailer": "git+https://github.com/mozilla/fxa-auth-mailer.git#rfk/unlock-email",
|
||||
"fxa-auth-mailer": "git+https://github.com/mozilla/fxa-auth-mailer.git#a2d00cf6",
|
||||
"hapi": "7.5.3",
|
||||
"hapi-auth-hawk": "1.1.1",
|
||||
"hkdf": "0.0.2",
|
||||
|
@ -50,7 +50,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"ass": "0.0.4",
|
||||
"fxa-auth-db-mem": "0.18.2",
|
||||
"fxa-auth-db-mem": "git+https://github.com/mozilla/fxa-auth-db-mem.git#49e33d4a",
|
||||
"grunt": "0.4.5",
|
||||
"grunt-cli": "0.1.13",
|
||||
"grunt-contrib-jshint": "0.10.0",
|
||||
|
|
|
@ -24,7 +24,8 @@ module.exports = function (
|
|||
domain,
|
||||
resendBlackoutPeriod,
|
||||
customs,
|
||||
isPreVerified
|
||||
isPreVerified,
|
||||
checkPassword
|
||||
) {
|
||||
|
||||
var routes = [
|
||||
|
@ -228,61 +229,56 @@ module.exports = function (
|
|||
if (emailRecord.lockedAt) {
|
||||
throw error.lockedAccount()
|
||||
}
|
||||
var password = new Password(
|
||||
authPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.matches(emailRecord.verifyHash)
|
||||
.then(
|
||||
function (match) {
|
||||
if (!match) {
|
||||
return customs.flag(request.app.clientAddress, emailRecord)
|
||||
.then(
|
||||
function () {
|
||||
throw error.incorrectPassword(emailRecord.email, email)
|
||||
}
|
||||
)
|
||||
}
|
||||
return db.createSessionToken(
|
||||
{
|
||||
uid: emailRecord.uid,
|
||||
email: emailRecord.email,
|
||||
emailCode: emailRecord.emailCode,
|
||||
emailVerified: emailRecord.emailVerified,
|
||||
verifierSetAt: emailRecord.verifierSetAt
|
||||
return checkPassword(emailRecord, authPW, request.app.clientAddress)
|
||||
.then(
|
||||
function (match) {
|
||||
if (!match) {
|
||||
throw error.incorrectPassword(emailRecord.email, email)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (sessionToken) {
|
||||
if (request.query.keys !== 'true') {
|
||||
return P({
|
||||
sessionToken: sessionToken
|
||||
})
|
||||
}
|
||||
return password.unwrap(emailRecord.wrapWrapKb)
|
||||
.then(
|
||||
function (wrapKb) {
|
||||
return db.createKeyFetchToken(
|
||||
{
|
||||
uid: emailRecord.uid,
|
||||
kA: emailRecord.kA,
|
||||
wrapKb: wrapKb,
|
||||
emailVerified: emailRecord.emailVerified
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (keyFetchToken) {
|
||||
return {
|
||||
sessionToken: sessionToken,
|
||||
keyFetchToken: keyFetchToken
|
||||
return db.createSessionToken(
|
||||
{
|
||||
uid: emailRecord.uid,
|
||||
email: emailRecord.email,
|
||||
emailCode: emailRecord.emailCode,
|
||||
emailVerified: emailRecord.emailVerified,
|
||||
verifierSetAt: emailRecord.verifierSetAt
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (sessionToken) {
|
||||
if (request.query.keys !== 'true') {
|
||||
return P({
|
||||
sessionToken: sessionToken
|
||||
})
|
||||
}
|
||||
)
|
||||
var password = new Password(
|
||||
authPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.unwrap(emailRecord.wrapWrapKb)
|
||||
.then(
|
||||
function (wrapKb) {
|
||||
return db.createKeyFetchToken(
|
||||
{
|
||||
uid: emailRecord.uid,
|
||||
kA: emailRecord.kA,
|
||||
wrapKb: wrapKb,
|
||||
emailVerified: emailRecord.emailVerified
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (keyFetchToken) {
|
||||
return {
|
||||
sessionToken: sessionToken,
|
||||
keyFetchToken: keyFetchToken
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -530,6 +526,8 @@ module.exports = function (
|
|||
handler: function (request, reply) {
|
||||
log.begin('Account.UnlockCodeResend', request)
|
||||
var email = request.payload.email
|
||||
var emailRecord
|
||||
|
||||
customs.check(
|
||||
request.app.clientAddress,
|
||||
email,
|
||||
|
@ -538,10 +536,20 @@ module.exports = function (
|
|||
db.emailRecord.bind(db, email)
|
||||
)
|
||||
.then(
|
||||
function (emailRecord) {
|
||||
function (_emailRecord) {
|
||||
if (! _emailRecord.lockedAt) {
|
||||
throw error.accountNotLocked(email)
|
||||
}
|
||||
|
||||
emailRecord = _emailRecord
|
||||
return db.unlockCode(emailRecord)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (unlockCode) {
|
||||
return mailer.sendUnlockCode(
|
||||
emailRecord,
|
||||
emailRecord.emailCode,
|
||||
unlockCode,
|
||||
{
|
||||
service: request.payload.service,
|
||||
redirectTo: request.payload.redirectTo,
|
||||
|
@ -579,14 +587,19 @@ module.exports = function (
|
|||
function (account) {
|
||||
// If the account isn't actually locked, they may be
|
||||
// e.g. clicking a stale link. Silently succeed.
|
||||
if (!account.lockedAt) {
|
||||
return true
|
||||
if (! account.lockedAt) {
|
||||
return
|
||||
}
|
||||
if (!butil.buffersAreEqual(code, account.emailCode)) {
|
||||
throw error.invalidVerificationCode()
|
||||
}
|
||||
log.event('unlocked', { email: account.email, uid: account.uid })
|
||||
return db.unlockAccount(account)
|
||||
return db.unlockCode(account)
|
||||
.then(
|
||||
function (expectedCode) {
|
||||
if (!butil.buffersAreEqual(code, expectedCode)) {
|
||||
throw error.invalidVerificationCode()
|
||||
}
|
||||
log.event('unlocked', { email: account.email, uid: account.uid })
|
||||
return db.unlockAccount(account)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.done(
|
||||
|
@ -670,21 +683,12 @@ module.exports = function (
|
|||
if (emailRecord.lockedAt) {
|
||||
throw error.lockedAccount()
|
||||
}
|
||||
var password = new Password(
|
||||
authPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.matches(emailRecord.verifyHash)
|
||||
|
||||
return checkPassword(emailRecord, authPW, request.app.clientAddress)
|
||||
.then(
|
||||
function (match) {
|
||||
if (!match) {
|
||||
return customs.flag(request.app.clientAddress, emailRecord)
|
||||
.then(
|
||||
function () {
|
||||
throw error.incorrectPassword(emailRecord.email, form.email)
|
||||
}
|
||||
)
|
||||
throw error.incorrectPassword(emailRecord.email, form.email)
|
||||
}
|
||||
return db.deleteAccount(emailRecord)
|
||||
}
|
||||
|
@ -704,6 +708,60 @@ module.exports = function (
|
|||
|
||||
if (isProduction) {
|
||||
delete routes[0].config.validate.payload.preVerified
|
||||
} else {
|
||||
// programmatic account lockout is only available in non-production mode.
|
||||
routes.push({
|
||||
method: 'POST',
|
||||
path: '/account/lock',
|
||||
config: {
|
||||
validate: {
|
||||
payload: {
|
||||
email: validators.email().required(),
|
||||
authPW: isA.string().min(64).max(64).regex(HEX_STRING).required()
|
||||
}
|
||||
}
|
||||
},
|
||||
handler: function (request, reply) {
|
||||
log.begin('Account.lock', request)
|
||||
var form = request.payload
|
||||
var email = form.email
|
||||
var authPW = Buffer(form.authPW, 'hex')
|
||||
|
||||
customs.check(
|
||||
request.app.clientAddress,
|
||||
email,
|
||||
'accountLock')
|
||||
.then(db.emailRecord.bind(db, email))
|
||||
.then(
|
||||
function (emailRecord) {
|
||||
// The account is already locked, silently succeed.
|
||||
if (emailRecord.lockedAt) {
|
||||
return true
|
||||
}
|
||||
return checkPassword(emailRecord, authPW, request.app.clientAddress)
|
||||
.then(
|
||||
function (match) {
|
||||
// a bit of a strange one, only lock the account if the
|
||||
// password matches, otherwise let customs handle any account
|
||||
// lock.
|
||||
if (! match) {
|
||||
throw error.incorrectPassword(emailRecord.email, email)
|
||||
}
|
||||
|
||||
log.event('locked', { email: emailRecord.email, uid: emailRecord.uid })
|
||||
return db.lockAccount(emailRecord)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.done(
|
||||
function () {
|
||||
reply({})
|
||||
},
|
||||
reply
|
||||
)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return routes
|
||||
|
|
|
@ -25,6 +25,7 @@ module.exports = function (
|
|||
var isPreVerified = require('../preverifier')(jwks, error, config)
|
||||
var defaults = require('./defaults')(log, P, db, error)
|
||||
var idp = require('./idp')(log, serverPublicKey)
|
||||
var checkPassword = require('./utils/password_check')(log, config, Password, customs, db)
|
||||
var account = require('./account')(
|
||||
log,
|
||||
crypto,
|
||||
|
@ -41,7 +42,8 @@ module.exports = function (
|
|||
config.domain,
|
||||
config.smtp.resendBlackoutPeriod,
|
||||
customs,
|
||||
isPreVerified
|
||||
isPreVerified,
|
||||
checkPassword
|
||||
)
|
||||
var password = require('./password')(
|
||||
log,
|
||||
|
@ -52,7 +54,8 @@ module.exports = function (
|
|||
config.smtp.redirectDomain,
|
||||
mailer,
|
||||
config.verifierVersion,
|
||||
customs
|
||||
customs,
|
||||
checkPassword
|
||||
)
|
||||
var session = require('./session')(log, isA, error, db)
|
||||
var sign = require('./sign')(log, isA, error, signer, db, config.domain)
|
||||
|
|
|
@ -17,7 +17,8 @@ module.exports = function (
|
|||
redirectDomain,
|
||||
mailer,
|
||||
verifierVersion,
|
||||
customs
|
||||
customs,
|
||||
checkPassword
|
||||
) {
|
||||
|
||||
function failVerifyAttempt(passwordForgotToken) {
|
||||
|
@ -49,22 +50,17 @@ module.exports = function (
|
|||
if (emailRecord.lockedAt) {
|
||||
throw error.lockedAccount()
|
||||
}
|
||||
var password = new Password(
|
||||
oldAuthPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.matches(emailRecord.verifyHash)
|
||||
return checkPassword(emailRecord, oldAuthPW, request.app.clientAddress)
|
||||
.then(
|
||||
function (match) {
|
||||
if (!match) {
|
||||
return customs.flag(request.app.clientAddress, emailRecord)
|
||||
.then(
|
||||
function () {
|
||||
throw error.incorrectPassword(emailRecord.email, form.email)
|
||||
}
|
||||
)
|
||||
throw error.incorrectPassword(emailRecord.email, form.email)
|
||||
}
|
||||
var password = new Password(
|
||||
oldAuthPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.unwrap(emailRecord.wrapWrapKb)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/* 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/. */
|
||||
|
||||
/**
|
||||
* Check if the password a user entered matches the one on
|
||||
* file for the account. If it does not, flag the account with
|
||||
* customs. If customs says the account should be locked,
|
||||
* lock the acocunt. Higher levels will take care of
|
||||
* returning an error to the user.
|
||||
*/
|
||||
|
||||
module.exports = function (log, config, Password, customs, db) {
|
||||
return function (emailRecord, authPW, clientAddress) {
|
||||
var password = new Password(
|
||||
authPW,
|
||||
emailRecord.authSalt,
|
||||
emailRecord.verifierVersion
|
||||
)
|
||||
return password.matches(emailRecord.verifyHash)
|
||||
.then(
|
||||
function (match) {
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
|
||||
return customs.flag(clientAddress, emailRecord)
|
||||
.then(
|
||||
function (result) {
|
||||
if (result.lockout && config.lockoutEnabled) {
|
||||
log.event('locked', { email: emailRecord.email, uid: emailRecord.uid })
|
||||
return db.lockAccount(emailRecord)
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return match
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -369,6 +369,18 @@ ClientApi.prototype.passwordForgotStatus = function (passwordForgotTokenHex) {
|
|||
)
|
||||
}
|
||||
|
||||
ClientApi.prototype.accountLock = function (email, authPW) {
|
||||
return this.doRequest(
|
||||
'POST',
|
||||
this.baseURL + '/account/lock',
|
||||
null,
|
||||
{
|
||||
email: email,
|
||||
authPW: authPW.toString('hex')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ClientApi.prototype.accountUnlockResendCode = function (email, options) {
|
||||
options = options || {}
|
||||
return this.doRequest(
|
||||
|
|
|
@ -311,6 +311,10 @@ Client.prototype.verifyPasswordResetCode = function (code) {
|
|||
)
|
||||
}
|
||||
|
||||
Client.prototype.lockAccount = function () {
|
||||
return this.api.accountLock(this.email, this.authPW)
|
||||
}
|
||||
|
||||
Client.prototype.resendAccountUnlockCode = function () {
|
||||
return this.api.accountUnlockResendCode(this.email, this.options)
|
||||
}
|
||||
|
|
|
@ -12,16 +12,6 @@ var nock = require('nock')
|
|||
|
||||
var Customs = require('../../customs.js')(log, error)
|
||||
|
||||
var MockDB = {
|
||||
locked: {},
|
||||
lockAccount: function(account) {
|
||||
MockDB.locked[account.uid] = true;
|
||||
},
|
||||
unlockAccount: function(account) {
|
||||
delete MockDB.locked[account.uid];
|
||||
}
|
||||
}
|
||||
|
||||
var CUSTOMS_URL_REAL = 'http://localhost:7000'
|
||||
var CUSTOMS_URL_MISSING = 'http://localhost:7001'
|
||||
|
||||
|
@ -39,7 +29,7 @@ test(
|
|||
function (t) {
|
||||
t.plan(7)
|
||||
|
||||
customsNoUrl = new Customs('none', MockDB)
|
||||
customsNoUrl = new Customs('none')
|
||||
|
||||
t.ok(customsNoUrl, 'got a customs object with a none url')
|
||||
|
||||
|
@ -58,7 +48,7 @@ test(
|
|||
return customsNoUrl.flag(ip, { email: email, uid: "12345" })
|
||||
})
|
||||
.then(function(result) {
|
||||
t.equal(result, undefined, 'Nothing is returned when /failedLoginAttempt succeeds')
|
||||
t.equal(result.lockout, false, 'lockout is false when /failedLoginAttempt returns `lockout: false`')
|
||||
t.pass('Passed /failedLoginAttempt')
|
||||
}, function(error) {
|
||||
t.fail('We should have failed open for /failedLoginAttempt')
|
||||
|
@ -78,9 +68,9 @@ test(
|
|||
test(
|
||||
'can create a customs object with a url',
|
||||
function (t) {
|
||||
t.plan(18)
|
||||
t.plan(16)
|
||||
|
||||
customsWithUrl = new Customs(CUSTOMS_URL_REAL, MockDB)
|
||||
customsWithUrl = new Customs(CUSTOMS_URL_REAL)
|
||||
|
||||
t.ok(customsWithUrl, 'got a customs object with a valid url')
|
||||
|
||||
|
@ -106,8 +96,7 @@ test(
|
|||
return customsWithUrl.flag(ip, { email: email, uid: "12345" })
|
||||
})
|
||||
.then(function(result) {
|
||||
t.equal(result, undefined, 'Nothing is returned when /failedLoginAttempt succeeds')
|
||||
t.ok(!MockDB.locked["12345"], 'We ignore /failedLoginAttempt {lockout:false}')
|
||||
t.equal(result.lockout, false, 'lockout is false when /failedLoginAttempt returns false')
|
||||
t.pass('Passed /failedLoginAttempt')
|
||||
}, function(error) {
|
||||
t.fail('We should not have failed here for /failedLoginAttempt : err=' + error)
|
||||
|
@ -122,6 +111,7 @@ test(
|
|||
t.fail('We should not have failed here for /passwordReset : err=' + error)
|
||||
})
|
||||
.then(function() {
|
||||
// request is blocked
|
||||
return customsWithUrl.check(email, ip, action)
|
||||
})
|
||||
.then(function(result) {
|
||||
|
@ -136,11 +126,11 @@ test(
|
|||
t.equal(error.output.headers['retry-after'], 10001, 'retryAfter header is correct')
|
||||
})
|
||||
.then(function() {
|
||||
// account is locked
|
||||
return customsWithUrl.flag(ip, { email: email, uid: "12345" })
|
||||
})
|
||||
.then(function(result) {
|
||||
t.equal(result, undefined, 'Nothing is returned when /failedLoginAttempt succeeds')
|
||||
t.ok(MockDB.locked["12345"], 'We lockout on /failedLoginAttempt {lockout:true}')
|
||||
t.equal(result.lockout, true, 'lockout is true when /failedLoginAttempt returns `lockout: true`')
|
||||
t.pass('Passed /failedLoginAttempt with lockout')
|
||||
}, function(error) {
|
||||
t.fail('We should not have failed here for /failedLoginAttempt : err=' + error)
|
||||
|
@ -154,7 +144,7 @@ test(
|
|||
function (t) {
|
||||
t.plan(7)
|
||||
|
||||
customsInvalidUrl = new Customs(CUSTOMS_URL_MISSING, MockDB)
|
||||
customsInvalidUrl = new Customs(CUSTOMS_URL_MISSING)
|
||||
|
||||
t.ok(customsInvalidUrl, 'got a customs object with a non-existant service url')
|
||||
|
||||
|
@ -173,7 +163,7 @@ test(
|
|||
return customsInvalidUrl.flag(ip, { email: email, uid: "12345" })
|
||||
})
|
||||
.then(function(result) {
|
||||
t.equal(result, undefined, 'Nothing is returned when /failedLoginAttempt succeeds')
|
||||
t.equal(result.lockout, false, 'lockout is false when /failedLoginAttempt hits an invalid endpoint')
|
||||
t.pass('Passed /failedLoginAttempt')
|
||||
}, function(error) {
|
||||
t.fail('We should have failed open (no url provided) for /failedLoginAttempt')
|
||||
|
|
|
@ -349,6 +349,43 @@ DB.connect(config[config.db.backend])
|
|||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'account lockout',
|
||||
function (t) {
|
||||
return db.lockAccount(ACCOUNT)
|
||||
.then(function() {
|
||||
t.pass('lockAccount should succeed')
|
||||
return db.emailRecord(ACCOUNT.email)
|
||||
})
|
||||
.then(function(emailRecord) {
|
||||
t.ok(emailRecord.lockedAt, 'emailRecord should have a lockedAt date set')
|
||||
return db.unlockCode(ACCOUNT)
|
||||
})
|
||||
.then(function(unlockCode) {
|
||||
t.ok(unlockCode, 'unlockCode should be returned for a locked account')
|
||||
return db.unlockAccount(ACCOUNT)
|
||||
})
|
||||
.then(function() {
|
||||
t.pass('unlockAccount should succeed')
|
||||
return db.emailRecord(ACCOUNT.email)
|
||||
})
|
||||
.then(function(emailRecord) {
|
||||
t.equal(emailRecord.lockedAt, null, 'an unlocked account should have no lockedAt')
|
||||
return db.unlockCode(ACCOUNT)
|
||||
})
|
||||
.then(function () {
|
||||
t.fail('unlockCode on an unlocked account should fail')
|
||||
}, function (err) {
|
||||
t.equal(err.errno, 122, 'unlockCode on an unlocked account should fail with an account not locked error')
|
||||
|
||||
return db.unlockAccount(ACCOUNT)
|
||||
})
|
||||
.then(function () {
|
||||
t.pass('unlockAccount on an unlocked account should succeed')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'account deletion',
|
||||
function (t) {
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
/* 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/. */
|
||||
|
||||
var P = require('../../promise')
|
||||
var test = require('../ptaptest')
|
||||
var MockLog = { event: function () { } }
|
||||
var config = { lockoutEnabled: true }
|
||||
var Password = require('../../crypto/password')(MockLog, config)
|
||||
|
||||
var triggersLockout = false;
|
||||
var MockCustoms = {
|
||||
flag: function (clientAddress, emailRecord) {
|
||||
return P({ lockout: triggersLockout })
|
||||
}
|
||||
}
|
||||
|
||||
var MockDB = {
|
||||
locked: {},
|
||||
isLocked: function (uid) {
|
||||
return !! this.locked[uid];
|
||||
},
|
||||
lockAccount: function(account) {
|
||||
MockDB.locked[account.uid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
var checkPassword = require('../../routes/utils/password_check')(MockLog, config, Password, MockCustoms, MockDB)
|
||||
|
||||
test(
|
||||
'password check with correct password',
|
||||
function (t) {
|
||||
var authPW = new Buffer('aaaaaaaaaaaaaaaa')
|
||||
var emailRecord = {
|
||||
uid: 'correct_password',
|
||||
verifyHash: null,
|
||||
verifierVersion: 0,
|
||||
authSalt: new Buffer('bbbbbbbbbbbbbbbb')
|
||||
}
|
||||
|
||||
var password = new Password(
|
||||
authPW, emailRecord.authSalt, emailRecord.verifierVersion);
|
||||
|
||||
return password.verifyHash()
|
||||
.then(
|
||||
function (hash) {
|
||||
emailRecord.verifyHash = hash
|
||||
|
||||
return checkPassword(emailRecord, authPW, '10.0.0.1')
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (matches) {
|
||||
t.ok(matches, 'password matches, checkPassword returns true')
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'password check with incorrect password that does not trigger lockout',
|
||||
function (t) {
|
||||
var authPW = new Buffer('aaaaaaaaaaaaaaaa')
|
||||
var emailRecord = {
|
||||
uid: 'not_locked',
|
||||
verifyHash: null,
|
||||
verifierVersion: 0,
|
||||
authSalt: new Buffer('bbbbbbbbbbbbbbbb')
|
||||
}
|
||||
|
||||
var password= new Password(
|
||||
authPW, emailRecord.authSalt, emailRecord.verifierVersion);
|
||||
|
||||
return password.verifyHash()
|
||||
.then(
|
||||
function (hash) {
|
||||
emailRecord.verifyHash = hash
|
||||
|
||||
var incorrectAuthPW = new Buffer('cccccccccccccccc')
|
||||
|
||||
triggersLockout = false;
|
||||
return checkPassword(emailRecord, incorrectAuthPW, '10.0.0.1')
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (match) {
|
||||
t.equal(!!match, false, 'password does not match, checkPassword returns false')
|
||||
t.equal(MockDB.isLocked('not_locked'), false, 'account was not marked as locked');
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'password check with incorrect password that triggers lockout',
|
||||
function (t) {
|
||||
var authPW = new Buffer('aaaaaaaaaaaaaaaa')
|
||||
var emailRecord = {
|
||||
uid: 'locked',
|
||||
verifyHash: null,
|
||||
verifierVersion: 0,
|
||||
authSalt: new Buffer('bbbbbbbbbbbbbbbb')
|
||||
}
|
||||
|
||||
var password= new Password(
|
||||
authPW, emailRecord.authSalt, emailRecord.verifierVersion);
|
||||
|
||||
return password.verifyHash()
|
||||
.then(
|
||||
function (hash) {
|
||||
emailRecord.verifyHash = hash
|
||||
|
||||
var incorrectAuthPW = new Buffer('cccccccccccccccc')
|
||||
|
||||
triggersLockout = true;
|
||||
return checkPassword(emailRecord, incorrectAuthPW, '10.0.0.1')
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (match) {
|
||||
t.equal(!!match, false, 'password does not match, checkPassword returns false')
|
||||
t.equal(MockDB.isLocked('locked'), true, 'account was not marked as locked');
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -24,6 +24,7 @@ require('simplesmtp').createSimpleServer(
|
|||
var link = mail.headers['x-link']
|
||||
var rc = mail.headers['x-recovery-code']
|
||||
var vc = mail.headers['x-verify-code']
|
||||
var uc = mail.headers['x-unlock-code']
|
||||
var name = emailName(mail.headers.to)
|
||||
if (vc) {
|
||||
console.log('\x1B[32m', link, '\x1B[39m')
|
||||
|
@ -31,6 +32,9 @@ require('simplesmtp').createSimpleServer(
|
|||
else if (rc) {
|
||||
console.log('\x1B[34m', link, '\x1B[39m')
|
||||
}
|
||||
else if (uc) {
|
||||
console.log('\x1B[36m %s', link, '\x1B[39m')
|
||||
}
|
||||
else {
|
||||
console.error('\x1B[31mNo verify code match\x1B[39m')
|
||||
console.error(mail)
|
||||
|
|
|
@ -32,8 +32,11 @@ TestServer.start(config)
|
|||
)
|
||||
.then(
|
||||
function () {
|
||||
// There's no public API to force an account into the "locked" state,
|
||||
// but this will re-send the email regardless of actual account state.
|
||||
return client.lockAccount(email, password)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.resendAccountUnlockCode()
|
||||
}
|
||||
)
|
||||
|
@ -71,6 +74,11 @@ TestServer.start(config)
|
|||
return server.mailbox.waitForEmail(email)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.lockAccount(email, password)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.resendAccountUnlockCode()
|
||||
|
@ -94,6 +102,89 @@ TestServer.start(config)
|
|||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'resend account unlock code for account that is not locked',
|
||||
function (t) {
|
||||
var email = server.uniqueEmail()
|
||||
var password = 'allyourbasearebelongtous'
|
||||
var client = null
|
||||
return Client.createAndVerify(config.publicUrl, email, password, server.mailbox)
|
||||
.then(
|
||||
function () {
|
||||
return Client.login(config.publicUrl, email, password)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (x) {
|
||||
client = x
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.resendAccountUnlockCode()
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
t.fail('resendAccountUnlockCode is expected to fail')
|
||||
},
|
||||
function (err) {
|
||||
t.equal(err.code, 400, '400 status code')
|
||||
t.equal(err.errno, 122, 'account is not locked errno')
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
're-verify an account unlock when an account is not locked',
|
||||
function (t) {
|
||||
var email = server.uniqueEmail()
|
||||
var password = 'allyourbasearebelongtous'
|
||||
var client = null
|
||||
var code = null
|
||||
return Client.createAndVerify(config.publicUrl, email, password, server.mailbox)
|
||||
.then(
|
||||
function () {
|
||||
return Client.login(config.publicUrl, email, password)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (x) {
|
||||
client = x
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.lockAccount(email, password)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return client.resendAccountUnlockCode()
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
return server.mailbox.waitForCode(email)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function (_code) {
|
||||
code = _code
|
||||
return client.verifyAccountUnlockCode(client.uid, code)
|
||||
}
|
||||
)
|
||||
.then(
|
||||
function () {
|
||||
// the user may be re-verifying a stale link,
|
||||
// silently succeed.
|
||||
return client.verifyAccountUnlockCode(client.uid, code)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'teardown',
|
||||
function (t) {
|
||||
|
|
Загрузка…
Ссылка в новой задаче