diff --git a/bin/key_server.js b/bin/key_server.js index 91f5dab9..95c58915 100644 --- a/bin/key_server.js +++ b/bin/key_server.js @@ -110,6 +110,7 @@ function main() { customs.close() mailer.stop() database.close() + process.exit() //XXX: because of openid dep ಠ_ಠ } ) } diff --git a/config/index.js b/config/index.js index 6861e9db..3859ac83 100644 --- a/config/index.js +++ b/config/index.js @@ -291,6 +291,12 @@ var conf = convict({ format: Boolean, env: 'LOCKOUT_ENABLED', default: false + }, + openIdProviders: { + doc: 'root urls of allowed OpenID providers', + format: Array, + default: [], + env: 'OPENID_PROVIDERS' } }) @@ -316,4 +322,9 @@ var options = { conf.validate(options) +conf.set('isProduction', conf.get('env') === 'prod') + +conf.set('openIdVerifyUrl', conf.get('publicUrl') + '/v1/account/openid/login') + + module.exports = conf diff --git a/lib/db.js b/lib/db.js index 3b308569..b75806fc 100644 --- a/lib/db.js +++ b/lib/db.js @@ -368,6 +368,24 @@ module.exports = function ( ) } + DB.prototype.openIdRecord = function (id) { + log.trace({ op: 'DB.openIdRecord', id: id }) + return this.pool.get('/openIdRecord/' + Buffer(id, 'utf8').toString('hex')) + .then( + function (body) { + var data = bufferize(body) + data.emailVerified = !!data.emailVerified + return data + }, + function (err) { + if (err.statusCode === 404) { + err = error.unknownAccount() + } + throw err + } + ) + } + DB.prototype.account = function (uid) { log.trace({ op: 'DB.account', uid: uid }) return this.pool.get('/account/' + uid.toString('hex')) diff --git a/lib/routes/account.js b/lib/routes/account.js index 1d1b89e7..877381d3 100644 --- a/lib/routes/account.js +++ b/lib/routes/account.js @@ -7,6 +7,9 @@ var HEX_STRING = validators.HEX_STRING var BASE64_JWT = validators.BASE64_JWT var butil = require('../crypto/butil') +var openid = require('openid') +var url = require('url') +var qs = require('querystring') module.exports = function ( log, @@ -18,16 +21,28 @@ module.exports = function ( db, mailer, Password, - redirectDomain, - verifierVersion, - isProduction, - domain, - resendBlackoutPeriod, + config, customs, isPreVerified, checkPassword ) { + var openIdExtensions = [ + new openid.AttributeExchange( + { + 'http://axschema.org/contact/email': 'optional' + } + ) + ] + + function isOpenIdProviderAllowed(id) { + return config.openIdProviders.some( + function (allowed) { + return id.indexOf(allowed) === 0 + } + ) + } + var routes = [ { method: 'POST', @@ -39,7 +54,7 @@ module.exports = function ( 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(), + redirectTo: validators.redirectTo(config.smtp.redirectDomain).optional(), resume: isA.string().max(2048).optional(), preVerifyToken: isA.string().max(2048).regex(BASE64_JWT).optional() } @@ -74,7 +89,7 @@ module.exports = function ( .then(isPreVerified.bind(null, form.email, form.preVerifyToken)) .then( function (preverified) { - var password = new Password(authPW, authSalt, verifierVersion) + var password = new Password(authPW, authSalt, config.verifierVersion) return password.verifyHash() .then( function (verifyHash) { @@ -331,6 +346,196 @@ module.exports = function ( ) } }, + { + method: 'GET', + path: '/account/openid/authenticate', + handler: function (request, reply) { + var id = request.query.identifier + if (!isOpenIdProviderAllowed(id)) { + log.info({ op: 'Account.openid.authenticate', id: id }) + return reply.redirect( + config.contentServer.url + '/openid?' + qs.stringify( + { + err: 'This OpenID Provider is not allowed' + } + ) + ) + } + openid.authenticate( + id, + config.openIdVerifyUrl, + null, // realm + false, // immediate + false, // stateless + function (err, authUrl) { + if (err) { + log.error({ op: 'Account.openid.authenticate', err: err }) + return reply.redirect( + config.contentServer.url + '/openid?' + qs.stringify( + { + err: err.message + } + ) + ) + } + reply.redirect(authUrl) + }, + openIdExtensions, + false // strict + ) + } + }, + { + method: 'GET', + path: '/account/openid/login', + handler: function (request, reply) { + if (!request.url.search) { + return reply( + '\n' + + '' + + '' + + 'http://specs.openid.net/auth/2.0/return_to' + + '' + config.openIdVerifyUrl + '' + + '' + ).type('application/xrds+xml') + } + log.info({ op: 'Account.openid', url: request.url }) + openid.verifyAssertion( + url.format(request.url), + function (err, assertion) { + if (err || !assertion || !assertion.authenticated) { + log.warn({ op: 'Account.openid', err: err, assertion: assertion }) + return reply.redirect( + config.contentServer.url + '/openid?err=Unknown%20Account' + ) + } + var id = assertion.claimedIdentifier + var locale = request.app.acceptLanguage + + if (!isOpenIdProviderAllowed(id)) { + log.warn({op: 'Account.openid', id: id }) + return reply.redirect( + config.contentServer.url + '/openid?' + qs.stringify( + { + err: 'This OpenID Provider is not allowed' + } + ) + ) + } + db.openIdRecord(id) + .then( + function (record) { + return record + }, + function (err) { + if (err.errno !== 102) { + throw err + } + var uid = uuid.v4('binary') + var email = assertion.email || uid.toString('hex') + '@firefox.com' + var authSalt = crypto.randomBytes(32) + var kA = crypto.randomBytes(32) + return db.createAccount( + { + uid: uid, + createdAt: Date.now(), + email: email, + emailCode: crypto.randomBytes(16), + emailVerified: true, + kA: kA, + wrapWrapKb: crypto.randomBytes(32), + accountResetToken: null, + passwordForgotToken: null, + authSalt: authSalt, + verifierVersion: 0, + verifyHash: crypto.randomBytes(32), + openId: id, + verifierSetAt: Date.now(), + locale: locale + } + ) + } + ) + .then( + function (account) { + return db.createSessionToken( + { + uid: account.uid, + email: account.email, + emailCode: account.emailCode, + emailVerified: true, + verifierSetAt: account.verifierSetAt + } + ) + .then( + function (sessionToken) { + return db.createKeyFetchToken( + { + uid: account.uid, + kA: account.kA, + // wrapKb is undefined without a password + // wrapWrapKb has the properties we need for this + // value; Its stable, random, and will change on + // account reset. + wrapKb: account.wrapWrapKb, + emailVerified: true + } + ) + .then( + function (keyFetchToken) { + return { + sessionToken: sessionToken, + keyFetchToken: keyFetchToken, + unwrapBKey: butil.xorBuffers( + account.kA, + account.wrapWrapKb + ) + } + } + ) + } + ) + .then( + function (tokens) { + reply.redirect( + config.contentServer.url + '/openid?' + + qs.stringify( + { + uid: tokens.sessionToken.uid.toString('hex'), + email: account.email, + session: tokens.sessionToken.data.toString('hex'), + key: tokens.keyFetchToken ? + tokens.keyFetchToken.data.toString('hex') + : undefined, + unwrap: tokens.unwrapBKey.toString('hex'), + service: 'sync', + context: 'fx_desktop_v2' + } + ) + ) + } + ) + } + ) + .catch( + function (err) { + log.error({ op: 'Account.openid', err: err }) + reply.redirect( + config.contentServer.url + '/openid?' + qs.stringify( + { + err: err.toString() + } + ) + ) + } + ) + }, + false, // stateless + openIdExtensions, + false // strict + ) + } + }, { method: 'GET', path: '/account/status', @@ -438,7 +643,7 @@ module.exports = function ( validate: { payload: { service: isA.string().max(16).alphanum().optional(), - redirectTo: validators.redirectTo(redirectDomain).optional(), + redirectTo: validators.redirectTo(config.smtp.redirectDomain).optional(), resume: isA.string().max(2048).optional() } } @@ -449,7 +654,7 @@ module.exports = function ( db.updateSessionTokenInBackground(sessionToken, request.headers['user-agent']) var service = request.payload.service || request.query.service if (sessionToken.emailVerified || - Date.now() - sessionToken.verifierSetAt < resendBlackoutPeriod) { + Date.now() - sessionToken.verifierSetAt < config.smtp.resendBlackoutPeriod) { return reply({}) } customs.check( @@ -523,7 +728,7 @@ module.exports = function ( payload: { email: validators.email().required(), service: isA.string().max(16).alphanum().optional(), - redirectTo: validators.redirectTo(redirectDomain).optional(), + redirectTo: validators.redirectTo(config.smtp.redirectDomain).optional(), resume: isA.string().max(2048).optional() } } @@ -639,7 +844,7 @@ module.exports = function ( var accountResetToken = request.auth.credentials var authPW = Buffer(request.payload.authPW, 'hex') var authSalt = crypto.randomBytes(32) - var password = new Password(authPW, authSalt, verifierVersion) + var password = new Password(authPW, authSalt, config.verifierVersion) return password.verifyHash() .then( function (verifyHash) { @@ -705,7 +910,7 @@ module.exports = function ( ) .then( function () { - log.event('delete', { uid: emailRecord.uid.toString('hex') + '@' + domain }) + log.event('delete', { uid: emailRecord.uid.toString('hex') + '@' + config.domain }) return {} } ) @@ -716,7 +921,7 @@ module.exports = function ( } ] - if (isProduction) { + if (config.isProduction) { delete routes[0].config.validate.payload.preVerified } else { // programmatic account lockout is only available in non-production mode. diff --git a/lib/routes/index.js b/lib/routes/index.js index 1b4eddf1..1c7177cf 100644 --- a/lib/routes/index.js +++ b/lib/routes/index.js @@ -20,7 +20,6 @@ module.exports = function ( config, customs ) { - var isProduction = config.env === 'prod' var isPreVerified = require('../preverifier')(error, config) var defaults = require('./defaults')(log, P, db, error) var idp = require('./idp')(log, serverPublicKey) @@ -35,11 +34,7 @@ module.exports = function ( db, mailer, Password, - config.smtp.redirectDomain, - config.verifierVersion, - isProduction, - config.domain, - config.smtp.resendBlackoutPeriod, + config, customs, isPreVerified, checkPassword diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 64bb4584..353cf7c4 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -317,7 +317,7 @@ "dependencies": { "esprima": { "version": "1.0.4", - "from": "esprima@>=1.0.4 <1.1.0", + "from": "esprima@>=1.0.2 <1.1.0", "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" } } @@ -352,8 +352,8 @@ }, "fxa-auth-db-mysql": { "version": "0.42.0", - "from": "git+https://github.com/mozilla/fxa-auth-db-mysql.git#a09790c20fa8c9f4a2c6f108b86aabf07038220f", - "resolved": "git+https://github.com/mozilla/fxa-auth-db-mysql.git#a09790c20fa8c9f4a2c6f108b86aabf07038220f", + "from": "git+https://github.com/mozilla/fxa-auth-db-mysql.git#master", + "resolved": "git+https://github.com/mozilla/fxa-auth-db-mysql.git#354fc512d181b002b7aa62740889eaf60511a2d7", "dependencies": { "bluebird": { "version": "2.1.3", @@ -885,7 +885,7 @@ }, "isarray": { "version": "0.0.1", - "from": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "from": "isarray@0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" }, "string_decoder": { @@ -1308,7 +1308,7 @@ }, "fxa-auth-mailer": { "version": "1.0.7", - "from": "git+https://github.com/mozilla/fxa-auth-mailer.git#6aac195ff6d1692b239c7906fd1becd95ee85ebf", + "from": "git+https://github.com/mozilla/fxa-auth-mailer.git#master", "resolved": "git+https://github.com/mozilla/fxa-auth-mailer.git#6aac195ff6d1692b239c7906fd1becd95ee85ebf", "dependencies": { "bluebird": { @@ -1421,8 +1421,8 @@ }, "fxa-content-server-l10n": { "version": "0.0.0", - "from": "git://github.com/mozilla/fxa-content-server-l10n.git#4bbc8a6a71e45cf0abcf6d4de2219c6b0488c0e6", - "resolved": "git://github.com/mozilla/fxa-content-server-l10n.git#4bbc8a6a71e45cf0abcf6d4de2219c6b0488c0e6" + "from": "git://github.com/mozilla/fxa-content-server-l10n.git", + "resolved": "git://github.com/mozilla/fxa-content-server-l10n.git#e89599581ac4b362b8bde684565712e22195647c" }, "handlebars": { "version": "1.3.0", @@ -2153,11 +2153,6 @@ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz" } } - }, - "dtrace-provider": { - "version": "0.2.8", - "from": "dtrace-provider@>=0.2.8 <0.3.0", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz" } } } @@ -2180,7 +2175,7 @@ "dependencies": { "encoding": { "version": "0.1.11", - "from": "encoding@>=0.1.11 <0.2.0", + "from": "encoding@*", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.11.tgz", "dependencies": { "iconv-lite": { @@ -2647,7 +2642,7 @@ }, "es5-ext": { "version": "0.10.7", - "from": "es5-ext@>=0.10.6 <0.11.0", + "from": "es5-ext@>=0.10.4 <0.11.0", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.7.tgz", "dependencies": { "es6-symbol": { @@ -2659,7 +2654,7 @@ }, "es6-iterator": { "version": "0.1.3", - "from": "es6-iterator@>=0.1.1 <0.2.0", + "from": "es6-iterator@>=0.1.3 <0.2.0", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz", "dependencies": { "es6-symbol": { @@ -2698,12 +2693,12 @@ }, "es5-ext": { "version": "0.10.7", - "from": "es5-ext@>=0.10.6 <0.11.0", + "from": "es5-ext@>=0.10.4 <0.11.0", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.7.tgz" }, "es6-iterator": { "version": "0.1.3", - "from": "es6-iterator@>=0.1.1 <0.2.0", + "from": "es6-iterator@>=0.1.3 <0.2.0", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-0.1.3.tgz" }, "es6-symbol": { @@ -2726,9 +2721,9 @@ } }, "espree": { - "version": "2.2.3", + "version": "2.2.4", "from": "espree@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-2.2.3.tgz" + "resolved": "https://registry.npmjs.org/espree/-/espree-2.2.4.tgz" }, "estraverse": { "version": "2.0.0", @@ -2741,9 +2736,9 @@ "resolved": "https://registry.npmjs.org/estraverse-fb/-/estraverse-fb-1.3.1.tgz" }, "globals": { - "version": "8.3.0", + "version": "8.4.0", "from": "globals@>=8.0.0 <9.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-8.3.0.tgz" + "resolved": "https://registry.npmjs.org/globals/-/globals-8.4.0.tgz" }, "inquirer": { "version": "0.8.5", @@ -2929,9 +2924,9 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.2.5.tgz" }, "fast-levenshtein": { - "version": "1.0.6", + "version": "1.0.7", "from": "fast-levenshtein@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-1.0.6.tgz" + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-1.0.7.tgz" } } }, @@ -3414,7 +3409,7 @@ "dependencies": { "inherits": { "version": "2.0.1", - "from": "inherits@>=2.0.1 <2.1.0", + "from": "inherits@>=2.0.1 <3.0.0", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" }, "typedarray": { @@ -3722,7 +3717,7 @@ "dependencies": { "ansi-regex": { "version": "0.2.1", - "from": "ansi-regex@>=0.2.1 <0.3.0", + "from": "ansi-regex@>=0.2.0 <0.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" } } @@ -3734,7 +3729,7 @@ "dependencies": { "ansi-regex": { "version": "0.2.1", - "from": "ansi-regex@>=0.2.1 <0.3.0", + "from": "ansi-regex@>=0.2.0 <0.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" } } @@ -3845,6 +3840,11 @@ } } }, + "openid": { + "version": "0.5.13", + "from": "openid@0.5.13", + "resolved": "https://registry.npmjs.org/openid/-/openid-0.5.13.tgz" + }, "pem-jwk": { "version": "1.5.1", "from": "pem-jwk@1.5.1", @@ -3857,7 +3857,7 @@ "dependencies": { "inherits": { "version": "2.0.1", - "from": "inherits@>=2.0.0 <3.0.0", + "from": "inherits@>=2.0.1 <3.0.0", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" }, "minimalistic-assert": { diff --git a/package.json b/package.json index a5e97dd7..c3eeecf0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "hkdf": "0.0.2", "joi": "6.4.1", "mozlog": "2.0.1", + "openid": "0.5.13", "poolee": "1.0.0", "request": "2.55.0", "scrypt-hash": "1.1.12", diff --git a/test/local/openid_tests.js b/test/local/openid_tests.js new file mode 100644 index 00000000..33c42595 --- /dev/null +++ b/test/local/openid_tests.js @@ -0,0 +1,116 @@ +/* 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 test = require('../ptaptest') +var TestServer = require('../test_server') +var qs = require('querystring') +var url = require('url') +var Pool = require('poolee') + +process.env.OPENID_PROVIDERS = 'https://me.yahoo.com,https://openid.example.com' +var config = require('../../config').getProperties() + +TestServer.start(config) +.then(function main(server) { + + test( + 'openid authenticate redirects to provider', + function (t) { + Pool.request( + config.publicUrl + '/v1/account/openid/authenticate?' + qs.stringify( + { + identifier: 'https://me.yahoo.com' + } + ), + function (err, res) { + t.equal(res.statusCode, 302) + var location = url.parse(res.headers.location) + t.equal(location.hostname, 'open.login.yahooapis.com') + t.end() + } + ) + } + ) + + test( + 'openid authenticate rejects providers not on the allow list', + function (t) { + Pool.request( + config.publicUrl + '/v1/account/openid/authenticate?' + qs.stringify( + { + identifier: 'https://example.com' + } + ), + function (err, res) { + t.equal(res.statusCode, 302) + var location = url.parse(res.headers.location, true) + t.equal(location.query.err, 'This OpenID Provider is not allowed') + t.end() + } + ) + } + ) + + test( + 'openid authenticate rejects allowed but invalid providers', + function (t) { + // this should never happen in real life + Pool.request( + config.publicUrl + '/v1/account/openid/authenticate?' + qs.stringify( + { + identifier: 'https://openid.example.com' + } + ), + function (err, res) { + t.equal(res.statusCode, 302) + var location = url.parse(res.headers.location, true) + t.equal(location.query.err, 'No providers found for the given identifier') + t.end() + } + ) + } + ) + + test( + 'bare request to openid login returns the XRDS doc', + function (t) { + Pool.request( + config.publicUrl + '/v1/account/openid/login', + function (err, res, body) { + t.equal(res.statusCode, 200) + t.equal(res.headers['content-type'], 'application/xrds+xml') + t.end() + } + ) + } + ) + + test( + 'openid login rejects invalid assertions', + function (t) { + Pool.request( + config.publicUrl + '/v1/account/openid/login?foo=bar', + function (err, res) { + t.equal(res.statusCode, 302) + var location = url.parse(res.headers.location, true) + t.equal(location.query.err, 'Unknown Account') + t.end() + } + ) + } + ) + + // test( + // 'openid login rejects valid assertions for non-allowed providers' + // ) + + + test( + 'teardown', + function (t) { + server.stop() + t.end() + } + ) +}) diff --git a/test/ptaptest.js b/test/ptaptest.js index 0c388adf..2583d968 100644 --- a/test/ptaptest.js +++ b/test/ptaptest.js @@ -34,6 +34,9 @@ require('ass') var tap = require('tap') module.exports = function(name, testfunc) { + if (!testfunc) { + return tap.test(name) + } var wrappedtestfunc = function(t) { var res = testfunc(t) if (typeof res !== 'undefined') {