608 строки
19 KiB
JavaScript
608 строки
19 KiB
JavaScript
/* 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 validators = require('./validators')
|
|
var HEX_STRING = validators.HEX_STRING
|
|
var BASE64_JWT = validators.BASE64_JWT
|
|
|
|
var butil = require('../crypto/butil')
|
|
|
|
module.exports = function (
|
|
log,
|
|
crypto,
|
|
P,
|
|
uuid,
|
|
isA,
|
|
error,
|
|
db,
|
|
mailer,
|
|
Password,
|
|
redirectDomain,
|
|
verifierVersion,
|
|
isProduction,
|
|
domain,
|
|
resendBlackoutPeriod,
|
|
customs,
|
|
isPreVerified
|
|
) {
|
|
|
|
var routes = [
|
|
{
|
|
method: 'POST',
|
|
path: '/account/create',
|
|
config: {
|
|
validate: {
|
|
payload: {
|
|
email: validators.email().required(),
|
|
authPW: isA.string().min(64).max(64).regex(HEX_STRING).required(),
|
|
preVerified: isA.boolean(),
|
|
service: isA.string().max(16).alphanum().optional(),
|
|
redirectTo: validators.redirectTo(redirectDomain).optional(),
|
|
resume: isA.string().max(2048).optional(),
|
|
preVerifyToken: isA.string().max(2048).regex(BASE64_JWT).optional()
|
|
}
|
|
}
|
|
},
|
|
handler: function accountCreate(request, reply) {
|
|
log.begin('Account.create', request)
|
|
var form = request.payload
|
|
var email = form.email
|
|
var authSalt = crypto.randomBytes(32)
|
|
var authPW = Buffer(form.authPW, 'hex')
|
|
var locale = request.app.acceptLanguage
|
|
customs.check(
|
|
request.app.clientAddress,
|
|
email,
|
|
'accountCreate'
|
|
)
|
|
.then(db.emailRecord.bind(db, email))
|
|
.then(
|
|
function (emailRecord) {
|
|
// account exists
|
|
if (emailRecord.emailVerified) { throw error.accountExists(email) }
|
|
request.app.accountRecreated = true
|
|
return db.deleteAccount(emailRecord)
|
|
},
|
|
function (err) {
|
|
// unknown account
|
|
if (err.errno !== 102) { throw err }
|
|
}
|
|
)
|
|
.then(isPreVerified.bind(null, form.email, form.preVerifyToken))
|
|
.then(
|
|
function (preverified) {
|
|
var password = new Password(authPW, authSalt, verifierVersion)
|
|
return password.verifyHash()
|
|
.then(
|
|
function (verifyHash) {
|
|
return db.createAccount(
|
|
{
|
|
uid: uuid.v4('binary'),
|
|
createdAt: Date.now(),
|
|
email: email,
|
|
emailCode: crypto.randomBytes(16),
|
|
emailVerified: form.preVerified || preverified,
|
|
kA: crypto.randomBytes(32),
|
|
wrapWrapKb: crypto.randomBytes(32),
|
|
devices: {},
|
|
accountResetToken: null,
|
|
passwordForgotToken: null,
|
|
authSalt: authSalt,
|
|
verifierVersion: password.version,
|
|
verifyHash: verifyHash,
|
|
verifierSetAt: Date.now(),
|
|
locale: locale
|
|
}
|
|
)
|
|
.then(
|
|
function (account) {
|
|
if (account.emailVerified) {
|
|
log.event('verified', { email: account.email, uid: account.uid, locale: account.locale })
|
|
}
|
|
return db.createSessionToken(
|
|
{
|
|
uid: account.uid,
|
|
email: account.email,
|
|
emailCode: account.emailCode,
|
|
emailVerified: account.emailVerified,
|
|
verifierSetAt: account.verifierSetAt
|
|
}
|
|
)
|
|
.then(
|
|
function (sessionToken) {
|
|
if (request.query.keys !== 'true') {
|
|
return P({
|
|
account: account,
|
|
sessionToken: sessionToken
|
|
})
|
|
}
|
|
return password.unwrap(account.wrapWrapKb)
|
|
.then(
|
|
function (wrapKb) {
|
|
return db.createKeyFetchToken(
|
|
{
|
|
uid: account.uid,
|
|
kA: account.kA,
|
|
wrapKb: wrapKb,
|
|
emailVerified: account.emailVerified
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.then(
|
|
function (keyFetchToken) {
|
|
return {
|
|
account: account,
|
|
sessionToken: sessionToken,
|
|
keyFetchToken: keyFetchToken
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.then(
|
|
function (response) {
|
|
if (!response.account.emailVerified) {
|
|
mailer.sendVerifyCode(response.account, response.account.emailCode, {
|
|
service: form.service,
|
|
redirectTo: form.redirectTo,
|
|
resume: form.resume,
|
|
acceptLanguage: request.app.acceptLanguage
|
|
})
|
|
.fail(
|
|
function (err) {
|
|
log.error({ op: 'mailer.sendVerifyCode.1', err: err })
|
|
}
|
|
)
|
|
}
|
|
return response
|
|
}
|
|
)
|
|
.done(
|
|
function (response) {
|
|
var account = response.account
|
|
reply(
|
|
{
|
|
uid: account.uid.toString('hex'),
|
|
sessionToken: response.sessionToken.data.toString('hex'),
|
|
keyFetchToken: response.keyFetchToken ?
|
|
response.keyFetchToken.data.toString('hex')
|
|
: undefined,
|
|
authAt: response.sessionToken.lastAuthAt()
|
|
}
|
|
)
|
|
},
|
|
reply
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/account/login',
|
|
config: {
|
|
validate: {
|
|
payload: {
|
|
email: validators.email().required(),
|
|
authPW: isA.string().min(64).max(64).regex(HEX_STRING).required()
|
|
}
|
|
},
|
|
response: {
|
|
schema: {
|
|
uid: isA.string().regex(HEX_STRING).required(),
|
|
sessionToken: isA.string().regex(HEX_STRING).required(),
|
|
keyFetchToken: isA.string().regex(HEX_STRING).optional(),
|
|
verified: isA.boolean().required(),
|
|
authAt: isA.number().integer()
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
log.begin('Account.login', request)
|
|
var form = request.payload
|
|
var email = form.email
|
|
var authPW = Buffer(form.authPW, 'hex')
|
|
customs.check(
|
|
request.app.clientAddress,
|
|
email,
|
|
'accountLogin')
|
|
.then(db.emailRecord.bind(db, email))
|
|
.then(
|
|
function (emailRecord) {
|
|
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, email)
|
|
.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
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.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
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.done(
|
|
function (tokens) {
|
|
reply(
|
|
{
|
|
uid: tokens.sessionToken.uid.toString('hex'),
|
|
sessionToken: tokens.sessionToken.data.toString('hex'),
|
|
keyFetchToken: tokens.keyFetchToken ?
|
|
tokens.keyFetchToken.data.toString('hex')
|
|
: undefined,
|
|
verified: tokens.sessionToken.emailVerified,
|
|
authAt: tokens.sessionToken.lastAuthAt()
|
|
}
|
|
)
|
|
},
|
|
reply
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/account/devices',
|
|
config: {
|
|
auth: {
|
|
strategy: 'sessionToken'
|
|
},
|
|
response: {
|
|
schema: {
|
|
devices: isA.array()
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
log.begin('Account.devices', request)
|
|
var sessionToken = request.auth.credentials
|
|
db.accountDevices(sessionToken.uid)
|
|
.done(
|
|
function (devices) {
|
|
reply(
|
|
{
|
|
devices: Object.keys(devices)
|
|
}
|
|
)
|
|
},
|
|
reply
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/account/status',
|
|
config: {
|
|
auth: {
|
|
mode: 'optional',
|
|
strategy: 'sessionToken'
|
|
},
|
|
validate: {
|
|
query: {
|
|
uid: isA.string().min(32).max(32).regex(HEX_STRING)
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
var sessionToken = request.auth.credentials
|
|
if (sessionToken) {
|
|
reply({ exists: true, locale: sessionToken.locale })
|
|
}
|
|
else if (request.query.uid) {
|
|
var uid = Buffer(request.query.uid, 'hex')
|
|
db.account(uid)
|
|
.done(
|
|
function (account) {
|
|
reply({ exists: true })
|
|
},
|
|
function (err) {
|
|
if (err.errno === 102) {
|
|
return reply({ exists: false })
|
|
}
|
|
reply(err)
|
|
}
|
|
)
|
|
}
|
|
else {
|
|
reply(error.missingRequestParameter('uid'))
|
|
}
|
|
}
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/account/keys',
|
|
config: {
|
|
auth: {
|
|
strategy: 'keyFetchToken'
|
|
},
|
|
response: {
|
|
schema: {
|
|
bundle: isA.string().regex(HEX_STRING)
|
|
}
|
|
}
|
|
},
|
|
handler: function accountKeys(request, reply) {
|
|
log.begin('Account.keys', request)
|
|
var keyFetchToken = request.auth.credentials
|
|
if (!keyFetchToken.emailVerified) {
|
|
// don't delete the token on use until the account is verified
|
|
return reply(error.unverifiedAccount())
|
|
}
|
|
db.deleteKeyFetchToken(keyFetchToken)
|
|
.then(
|
|
function () {
|
|
return {
|
|
bundle: keyFetchToken.keyBundle.toString('hex')
|
|
}
|
|
}
|
|
)
|
|
.done(reply, reply)
|
|
}
|
|
},
|
|
{
|
|
method: 'GET',
|
|
path: '/recovery_email/status',
|
|
config: {
|
|
auth: {
|
|
strategy: 'sessionToken'
|
|
},
|
|
response: {
|
|
schema: {
|
|
email: validators.email().required(),
|
|
verified: isA.boolean().required()
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
log.begin('Account.RecoveryEmailStatus', request)
|
|
var sessionToken = request.auth.credentials
|
|
reply(
|
|
{
|
|
email: sessionToken.email,
|
|
verified: sessionToken.emailVerified
|
|
}
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/recovery_email/resend_code',
|
|
config: {
|
|
auth: {
|
|
strategy: 'sessionToken'
|
|
},
|
|
validate: {
|
|
payload: {
|
|
service: isA.string().max(16).alphanum().optional(),
|
|
redirectTo: validators.redirectTo(redirectDomain).optional(),
|
|
resume: isA.string().max(2048).optional()
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
log.begin('Account.RecoveryEmailResend', request)
|
|
var sessionToken = request.auth.credentials
|
|
if (sessionToken.emailVerified ||
|
|
Date.now() - sessionToken.verifierSetAt < resendBlackoutPeriod) {
|
|
return reply({})
|
|
}
|
|
customs.check(
|
|
request.app.clientAddress,
|
|
sessionToken.email,
|
|
'recoveryEmailResendCode')
|
|
.then(
|
|
mailer.sendVerifyCode.bind(
|
|
mailer,
|
|
sessionToken,
|
|
sessionToken.emailCode,
|
|
{
|
|
service: request.payload.service,
|
|
redirectTo: request.payload.redirectTo,
|
|
resume: request.payload.resume,
|
|
acceptLanguage: request.app.acceptLanguage
|
|
}
|
|
)
|
|
)
|
|
.done(
|
|
function () {
|
|
reply({})
|
|
},
|
|
reply
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/recovery_email/verify_code',
|
|
config: {
|
|
validate: {
|
|
payload: {
|
|
uid: isA.string().max(32).regex(HEX_STRING).required(),
|
|
code: isA.string().min(32).max(32).regex(HEX_STRING).required()
|
|
}
|
|
}
|
|
},
|
|
handler: function (request, reply) {
|
|
log.begin('Account.RecoveryEmailVerify', request)
|
|
var uid = request.payload.uid
|
|
var code = Buffer(request.payload.code, 'hex')
|
|
db.account(Buffer(uid, 'hex'))
|
|
.then(
|
|
function (account) {
|
|
if (!butil.buffersAreEqual(code, account.emailCode)) {
|
|
throw error.invalidVerificationCode()
|
|
}
|
|
log.event('verified', { email: account.email, uid: account.uid, locale: account.locale })
|
|
return db.verifyEmail(account)
|
|
}
|
|
)
|
|
.done(
|
|
function () {
|
|
reply({})
|
|
},
|
|
reply
|
|
)
|
|
}
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/account/reset',
|
|
config: {
|
|
auth: {
|
|
strategy: 'accountResetToken',
|
|
payload: 'required'
|
|
},
|
|
validate: {
|
|
payload: {
|
|
authPW: isA.string().min(64).max(64).regex(HEX_STRING).required()
|
|
}
|
|
}
|
|
},
|
|
handler: function accountReset(request, reply) {
|
|
log.begin('Account.reset', request)
|
|
var accountResetToken = request.auth.credentials
|
|
var authPW = Buffer(request.payload.authPW, 'hex')
|
|
var authSalt = crypto.randomBytes(32)
|
|
var password = new Password(authPW, authSalt, verifierVersion)
|
|
return password.verifyHash()
|
|
.then(
|
|
function (verifyHash) {
|
|
return db.resetAccount(
|
|
accountResetToken,
|
|
{
|
|
authSalt: authSalt,
|
|
verifyHash: verifyHash,
|
|
wrapWrapKb: crypto.randomBytes(32),
|
|
verifierVersion: password.version
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.then(
|
|
function () {
|
|
return db.account(accountResetToken.uid)
|
|
}
|
|
)
|
|
.then(
|
|
function (accountRecord) {
|
|
return customs.reset(accountRecord.email)
|
|
}
|
|
)
|
|
.then(
|
|
function () {
|
|
return {}
|
|
}
|
|
)
|
|
.done(reply, reply)
|
|
}
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/account/destroy',
|
|
config: {
|
|
validate: {
|
|
payload: {
|
|
email: validators.email().required(),
|
|
authPW: isA.string().min(64).max(64).regex(HEX_STRING).required()
|
|
}
|
|
}
|
|
},
|
|
handler: function accountDestroy(request, reply) {
|
|
log.begin('Account.destroy', request)
|
|
var form = request.payload
|
|
var authPW = Buffer(form.authPW, 'hex')
|
|
db.emailRecord(form.email)
|
|
.then(
|
|
function (emailRecord) {
|
|
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, form.email)
|
|
.then(
|
|
function () {
|
|
throw error.incorrectPassword(emailRecord.email, form.email)
|
|
}
|
|
)
|
|
}
|
|
return db.deleteAccount(emailRecord)
|
|
}
|
|
)
|
|
.then(
|
|
function () {
|
|
log.event('delete', { uid: emailRecord.uid.toString('hex') + '@' + domain })
|
|
return {}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
.done(reply, reply)
|
|
}
|
|
}
|
|
]
|
|
|
|
if (isProduction) {
|
|
delete routes[0].config.validate.payload.preVerified
|
|
}
|
|
|
|
return routes
|
|
}
|