diff --git a/.travis.yml b/.travis.yml index 4b421de337..b473ef665c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ node_js: - '0.10' - '0.12' - '4.4' - - '5.11' + - '5.12' - '6.2' diff --git a/Changelog.md b/Changelog.md index 34b0bda3de..afb0795d38 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,7 @@ +1.0.0-rc-6 ( 29/06/2016 ) + - AuthSwitch support and partial support for + plugin-based authentication #331 + 1.0.0-rc-5 ( 16/06/2016 ) - Fix incorrect releasing of dead pool connections #326, #325 - Allow pool options to be specified as URL params #327 diff --git a/README.md b/README.md index 592f2c4cb0..087822a57d 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,40 @@ co(function * () { ``` see examples in [/examples/promise-co-await](/examples/promise-co-await) +### Authentication switch request + +During connection phase the server may ask client to switch to a different auth method. +If `authSwitchHandler` connection config option is set it must be a function that receive +switch request data and respond via callback. Note that if `mysql_native_password` method is +requested it will be handled internally according to [Authentication::Native41]( https://dev.mysql.com/doc/internals/en/secure-password-authentication.html#packet-Authentication::Native41) and +`authSwitchHandler` won't be invoked. `authSwitchHandler` MAY be called multiple times if +plugin algorithm requires multiple roundtrips of data exchange between client and server. +First invocation always has `({pluginName, pluginData})` signature, following calls - `({pluginData})`. +The client respond with opaque blob matching requested plugin via `callback(null, data: Buffer)`. + +Example: (imaginary `ssh-key-auth` plugin) pseudo code + +```js +var conn = mysql.createConnection({ + user: 'test_user', + password: 'test', + database: 'test_database', + authSwitchHandler: function(data, cb) { + if (data.pluginName === 'ssh-key-auth') { + getPrivateKey((key) => { + var response = encrypt(key, data.pluginData); + // continue handshake by sending response data + // respond with error to propagate error to connect/changeUser handlers + cb(null, response); + }) + } + } +}); +``` + +Initial handshake always performed using `mysql_native_password` plugin. This will be possible to override in +the future versions. + ### Named placeholders You can use named placeholders for parameters by setting `namedPlaceholders` config value or query/execute time option. Named placeholders are converted to unnamed `?` on the client (mysql protocol does not support named parameters). If you reference parameter multiple times under the same name it is sent to server multiple times. diff --git a/examples/promise-co-await/await.js b/examples/promise-co-await/await.js index 7262e71714..5568b90c4b 100644 --- a/examples/promise-co-await/await.js +++ b/examples/promise-co-await/await.js @@ -1,7 +1,7 @@ var mysql = require('../../promise.js'); async function test() { - const c = await mysql.createConnection({ port: 3306, user: 'mycause_dev', namedPlaceholders: true, password: 'mycause' }); + const c = await mysql.createConnection({ port: 3306, user: 'testuser', namedPlaceholders: true, password: 'testpassword' }); console.log('connected!'); const [rows, fields] = await c.query('show databases'); console.log(rows); @@ -24,7 +24,7 @@ async function test() { console.log(end - start); await c.end(); - const p = mysql.createPool({ port: 3306, user: 'mycause_dev', namedPlaceholders: true, password: 'mycause' }); + const p = mysql.createPool({ port: 3306, user: 'testuser', namedPlaceholders: true, password: 'testpassword' }); console.log( await p.execute('select sleep(0.5)') ); console.log('after first pool sleep'); var start = +new Date() diff --git a/index.js b/index.js index 211da864b2..d83cfbe65e 100644 --- a/index.js +++ b/index.js @@ -21,9 +21,13 @@ exports.createPoolCluster = function (config) { return new PoolCluster(config); }; -module.exports.createServer = function () { +module.exports.createServer = function (handler) { var Server = require('./lib/server.js'); - return new Server(); + var s = new Server(); + if (handler) { + s.on('connection', handler); + } + return s; }; exports.escape = SqlString.escape; @@ -43,4 +47,3 @@ exports.__defineGetter__('createPoolClusterPromise', function () { }); module.exports.Types = require('./lib/constants/types.js'); - diff --git a/lib/commands/change_user.js b/lib/commands/change_user.js index 503fb2ad4e..7e56bed28a 100644 --- a/lib/commands/change_user.js +++ b/lib/commands/change_user.js @@ -3,44 +3,43 @@ var util = require('util'); var Command = require('./command.js'); var Packets = require('../packets/index.js'); var ClientConstants = require('../constants/client.js'); +var ClientHandshake = require('./client_handshake.js'); function ChangeUser (options, callback) { this.onResult = callback; - this._user = options.user; - this._password = options.password; - this._database = options.database; - this._passwordSha1 = options.passwordSha1; - this._charsetNumber = options.charsetNumber; - this._currentConfig = options.currentConfig; + this.user = options.user; + this.password = options.password; + this.database = options.database; + this.passwordSha1 = options.passwordSha1; + this.charsetNumber = options.charsetNumber; + this.currentConfig = options.currentConfig; Command.call(this); } util.inherits(ChangeUser, Command); +ChangeUser.prototype.handshakeResult = ClientHandshake.prototype.handshakeResult; +ChangeUser.prototype.calculateNativePasswordAuthToken = ClientHandshake.prototype.calculateNativePasswordAuthToken; + ChangeUser.prototype.start = function (packet, connection) { var packet = new Packets.ChangeUser({ - user : this._user, - database : this._database, - charsetNumber : this._charsetNumber, - password : this._password, - passwordSha1 : this._passwordSha1, + flags : connection.config.clientFlags, + user : this.user, + database : this.database, + charsetNumber : this.charsetNumber, + password : this.password, + passwordSha1 : this.passwordSha1, authPluginData1 : connection._handshakePacket.authPluginData1, authPluginData2 : connection._handshakePacket.authPluginData2 }); - this._currentConfig.user = this._user; - this._currentConfig.password = this._password; - this._currentConfig.database = this._database; - this._currentConfig.charsetNumber = this._charsetNumber; + this.currentConfig.user = this.user; + this.currentConfig.password = this.password; + this.currentConfig.database = this.database; + this.currentConfig.charsetNumber = this.charsetNumber; // reset prepared statements cache as all statements become invalid after changeUser connection._statements = {}; connection.writePacket(packet.toPacket()); - return ChangeUser.prototype.changeOk; + return ChangeUser.prototype.handshakeResult; }; -ChangeUser.prototype.changeOk = function (okPacket, connection) { - if (this.onResult) { - this.onResult(null); - } - return null; -}; module.exports = ChangeUser; diff --git a/lib/commands/client_handshake.js b/lib/commands/client_handshake.js index c26635ab37..b396f1ccac 100644 --- a/lib/commands/client_handshake.js +++ b/lib/commands/client_handshake.js @@ -45,11 +45,26 @@ ClientHandshake.prototype.sendCredentials = function (connection) { charsetNumber : connection.config.charsetNumber, authPluginData1: this.handshake.authPluginData1, authPluginData2: this.handshake.authPluginData2, - compress: connection.config.compress + compress: connection.config.compress, + connectAttributes: connection.config.connectAttributes }); connection.writePacket(handshakeResponse.toPacket()); }; +var auth41 = require('../auth_41.js'); +ClientHandshake.prototype.calculateNativePasswordAuthToken = function (authPluginData) { + // TODO: dont split into authPluginData1 and authPluginData2, instead join when 1 & 2 received + var authPluginData1 = authPluginData.slice(0, 8); + var authPluginData2 = authPluginData.slice(8, 20); + var authToken; + if (this.passwordSha1) { + authToken = auth41.calculateTokenFromPasswordSha(this.passwordSha1, authPluginData1, authPluginData2); + } else { + authToken = auth41.calculateToken(this.password, authPluginData1, authPluginData2); + } + return authToken; +}; + ClientHandshake.prototype.handshakeInit = function (helloPacket, connection) { var command = this; @@ -100,13 +115,61 @@ ClientHandshake.prototype.handshakeInit = function (helloPacket, connection) { return ClientHandshake.prototype.handshakeResult; }; -ClientHandshake.prototype.handshakeResult = function (okPacket, connection) { - // error is already checked in base class. Done auth. - connection.authorized = true; - if (connection.config.compress) { - var enableCompression = require('../compressed_protocol.js').enableCompression; - enableCompression(connection); +ClientHandshake.prototype.handshakeResult = function (packet, connection) { + var marker = packet.peekByte(); + if (marker === 0xfe || marker === 1) { + var asr, asrmd; + var authSwitchHandlerParams = {}; + if (marker === 1) { + asrmd = Packets.AuthSwitchRequestMoreData.fromPacket(packet); + authSwitchHandlerParams.pluginData = asrmd.data; + } else { + asr = Packets.AuthSwitchRequest.fromPacket(packet); + authSwitchHandlerParams.pluginName = asr.pluginName; + authSwitchHandlerParams.pluginData = asr.pluginData; + } + if (authSwitchHandlerParams.pluginName == 'mysql_native_password') { + var authToken = this.calculateNativePasswordAuthToken(authSwitchHandlerParams.pluginData); + connection.writePacket(new Packets.AuthSwitchResponse(authToken).toPacket()); + } else if (connection.config.authSwitchHandler) { + connection.config.authSwitchHandler(authSwitchHandlerParams, function (err, data) { + if (err) { + connection.emit('error', err); + return; + } + connection.writePacket(new Packets.AuthSwitchResponse(data).toPacket()); + }); + } else { + connection.emit('error', new Error('Server requires auth switch, but no auth switch handler provided')); + return null; + } + return ClientHandshake.prototype.handshakeResult; + } + + if (marker !== 0) { + var err = new Error('Unexpected packet during handshake phase'); + if (this.onResult) { + this.onResult(err); + } else { + connection.emit('error', err); + } + return null; + } + + // this should be called from ClientHandshake command only + // and skipped when called from ChangeUser command + if (!connection.authorized) { + connection.authorized = true; + if (connection.config.compress) { + var enableCompression = require('../compressed_protocol.js').enableCompression; + enableCompression(connection); + } + } + + if (this.onResult) { + this.onResult(null); } return null; }; + module.exports = ClientHandshake; diff --git a/lib/connection.js b/lib/connection.js index 268a765f23..5b2f273fbf 100644 --- a/lib/connection.js +++ b/lib/connection.js @@ -119,7 +119,7 @@ function Connection (opts) handshakeCommand.on('end', function () { connection._handshakePacket = handshakeCommand.handshake; connection.threadId = handshakeCommand.handshake.connectionId; - connection.emit('connect', handshakeCommand.handshake) + connection.emit('connect', handshakeCommand.handshake); }); this.addCommand(handshakeCommand); } @@ -249,7 +249,9 @@ Connection.prototype.handlePacket = function (packet) { if (packet) { console.log(' raw: ' + packet.buffer.slice(packet.offset, packet.offset + packet.length()).toString('hex')); console.trace(); - console.log(this._internalId + ' ' + this.connectionId + ' ==> ' + this._command._commandName + '#' + this._command.stateName() + '(' + [packet.sequenceId, packet.type(), packet.length()].join(',') + ')'); + var commandName = this._command ? this._command._commandName : '(no command)'; + var stateName = this._command ? this._command.stateName() : '(no command)'; + console.log(this._internalId + ' ' + this.connectionId + ' ==> ' + commandName + '#' + stateName + '(' + [packet.sequenceId, packet.type(), packet.length()].join(',') + ')'); } } if (!this._command) { @@ -649,6 +651,17 @@ Connection.prototype.serverHandshake = function serverHandshake (args) { // TODO: domainify Connection.prototype.end = function (callback) { var connection = this; + + if (this.config.isServer) { + connection._closing = true; + var quitCmd = new EventEmitter(); + setImmediate(function () { + connection.stream.end(); + quitCmd.emit('end'); + }); + return quitCmd; + } + // trigger error if more commands enqueued after end command var quitCmd = this.addCommand(new Commands.Quit(callback)); connection.addCommand = function () { diff --git a/lib/connection_config.js b/lib/connection_config.js index 1346791859..8461e50dbe 100644 --- a/lib/connection_config.js +++ b/lib/connection_config.js @@ -32,7 +32,6 @@ function ConnectionConfig (options) { this.trace = options.trace !== false; this.stringifyObjects = options.stringifyObjects || false; this.timezone = options.timezone || 'local'; - this.flags = options.flags || ''; this.queryFormat = options.queryFormat; this.pool = options.pool || undefined; this.ssl = (typeof options.ssl === 'string') @@ -64,8 +63,12 @@ function ConnectionConfig (options) { this.compress = options.compress || false; + this.authSwitchHandler = options.authSwitchHandler; + this.clientFlags = ConnectionConfig.mergeFlags(ConnectionConfig.getDefaultFlags(options), options.flags || ''); + + this.connectAttributes = options.connectAttributes; } ConnectionConfig.mergeFlags = function (default_flags, user_flags) { @@ -107,6 +110,15 @@ ConnectionConfig.getDefaultFlags = function (options) { defaultFlags.push('MULTI_STATEMENTS'); } + if (options && options.authSwitchHandler) { + defaultFlags.push('PLUGIN_AUTH'); + defaultFlags.push('PLUGIN_AUTH_LENENC_CLIENT_DATA'); + } + + if (options && options.connectAttributes) { + defaultFlags.push('CONNECT_ATTRS'); + } + return defaultFlags; }; diff --git a/lib/packets/auth_switch_request.js b/lib/packets/auth_switch_request.js new file mode 100644 index 0000000000..8c18e82864 --- /dev/null +++ b/lib/packets/auth_switch_request.js @@ -0,0 +1,37 @@ +// http://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest + +var Packet = require('../packets/packet'); + +function AuthSwitchRequest (opts) +{ + this.pluginName = opts.pluginName; + this.pluginData = opts.pluginData; +} + +AuthSwitchRequest.prototype.toPacket = function () +{ + var length = 6 + this.pluginName.length + this.pluginData.length; + var buffer = new Buffer(length); + var packet = new Packet(0, buffer, 0, length); + packet.offset = 4; + packet.writeInt8(0xfe); + packet.writeNullTerminatedString(this.pluginName); + packet.writeBuffer(this.pluginData); + return packet; +}; + +AuthSwitchRequest.fromPacket = function (packet) +{ + var marker = packet.readInt8(); + // assert marker == 0xfe? + + var name = packet.readNullTerminatedString(); + var data = packet.readBuffer(); + + return new AuthSwitchRequest({ + pluginName: name, + pluginData: data + }); +}; + +module.exports = AuthSwitchRequest; diff --git a/lib/packets/auth_switch_request_more_data.js b/lib/packets/auth_switch_request_more_data.js new file mode 100644 index 0000000000..b77f2f0888 --- /dev/null +++ b/lib/packets/auth_switch_request_more_data.js @@ -0,0 +1,32 @@ +// http://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest + +var Packet = require('../packets/packet'); + +function AuthSwitchRequestMoreData (data) +{ + this.data = data; +} + +AuthSwitchRequestMoreData.prototype.toPacket = function () +{ + var length = 5 + this.data.length; + var buffer = new Buffer(length); + var packet = new Packet(0, buffer, 0, length); + packet.offset = 4; + packet.writeInt8(0x01); + packet.writeBuffer(this.data); + return packet; +}; + +AuthSwitchRequestMoreData.fromPacket = function (packet) +{ + var marker = packet.readInt8(); + var data = packet.readBuffer(); + return new AuthSwitchRequestMoreData(data); +}; + +AuthSwitchRequestMoreData.verifyMarker = function (packet) { + return (packet.peekByte() == 0x01); +}; + +module.exports = AuthSwitchRequestMoreData; diff --git a/lib/packets/auth_switch_response.js b/lib/packets/auth_switch_response.js new file mode 100644 index 0000000000..f9fe861fa9 --- /dev/null +++ b/lib/packets/auth_switch_response.js @@ -0,0 +1,29 @@ +// http://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest + +var Packet = require('../packets/packet'); + +function AuthSwitchResponse (data) +{ + if (!Buffer.isBuffer(data)) { + data = Buffer(data); + } + this.data = data; +} + +AuthSwitchResponse.prototype.toPacket = function () +{ + var length = 4 + this.data.length; + var buffer = new Buffer(length); + var packet = new Packet(0, buffer, 0, length); + packet.offset = 4; + packet.writeBuffer(this.data); + return packet; +}; + +AuthSwitchResponse.fromPacket = function (packet) +{ + var data = packet.readBuffer(); + return new AuthSwitchResponse(data); +}; + +module.exports = AuthSwitchResponse; diff --git a/lib/packets/change_user.js b/lib/packets/change_user.js index 1ac31275ea..f363af7212 100644 --- a/lib/packets/change_user.js +++ b/lib/packets/change_user.js @@ -1,16 +1,18 @@ var CommandCode = require('../constants/commands.js'); +var ClientConstants = require('../constants/client.js'); var Packet = require('../packets/packet.js'); - var auth41 = require('../auth_41.js'); function ChangeUser (opts) { + this.flags = opts.flags; this.user = opts.user || ''; this.database = opts.database || ''; this.password = opts.password || ''; this.passwordSha1 = opts.passwordSha1; this.authPluginData1 = opts.authPluginData1; this.authPluginData2 = opts.authPluginData2; + this.connectAttributes = opts.connectAttrinutes || {}; var authToken; if (this.passwordSha1) { authToken = auth41.calculateTokenFromPasswordSha(this.passwordSha1, this.authPluginData1, this.authPluginData2); @@ -25,29 +27,62 @@ function ChangeUser (opts) // ChangeUser.fromPacket = function(packet) // }; -ChangeUser.prototype.toPacket = function () +ChangeUser.prototype.serializeToBuffer = function (buffer) { - if (typeof this.user != 'string') { - throw new Error('"user" connection config property must be a string'); - } - - if (typeof this.database != 'string') { - throw new Error('"database" connection config property must be a string'); + var self = this; + function isSet (flag) { + return self.flags & ClientConstants[flag]; } - var length = 4 + 1 + (1 + this.authToken.length) + (2 + this.user.length + this.database.length) + 2; - - var buffer = new Buffer(length); - var packet = new Packet(0, buffer, 0, length); + var packet = new Packet(0, buffer, 0, buffer.length); packet.offset = 4; packet.writeInt8(CommandCode.CHANGE_USER); packet.writeNullTerminatedString(this.user); - packet.writeInt8(this.authToken.length); - packet.writeBuffer(this.authToken); + if (isSet('SECURE_CONNECTION')) { + packet.writeInt8(this.authToken.length); + packet.writeBuffer(this.authToken); + } else { + packet.writeBuffer(this.authToken); + packet.writeInt8(0); + } packet.writeNullTerminatedString(this.database); packet.writeInt16(this.charsetNumber); + + if (isSet('PLUGIN_AUTH')) { + packet.writeNullTerminatedString('mysql_native_password'); + } + + if (isSet('CONNECT_ATTRS')) { + var connectAttributes = this.connectAttributes; + var attrNames = Object.keys(connectAttributes); + var keysLength = 0; + for (k = 0; k < attrNames.length; ++k) { + keysLength += Packet.lengthCodedStringLength(attrNames[k]); + keysLength += Packet.lengthCodedStringLength(connectAttributes[attrNames[k]]); + } + packet.writeLengthCodedNumber(keysLength); + for (k = 0; k < attrNames.length; ++k) { + packet.writeLengthCodedString(attrNames[k]); + packet.writeLengthCodedString(connectAttributes[attrNames[k]]); + } + } return packet; }; +ChangeUser.prototype.toPacket = function () +{ + if (typeof this.user != 'string') { + throw new Error('"user" connection config property must be a string'); + } + + if (typeof this.database != 'string') { + throw new Error('"database" connection config property must be a string'); + } + + // dry run: calculate resulting packet length + var p = this.serializeToBuffer(Packet.MockBuffer()); + return this.serializeToBuffer(new Buffer(p.offset)); +}; + module.exports = ChangeUser; diff --git a/lib/packets/handshake.js b/lib/packets/handshake.js index d18d2c84c0..dcbb6a325c 100644 --- a/lib/packets/handshake.js +++ b/lib/packets/handshake.js @@ -30,6 +30,13 @@ Handshake.fromPacket = function (packet) } // var len = Math.max(12, args.authPluginDataLength - 8); args.authPluginData2 = packet.readBuffer(12); + + // TODO: expose combined authPluginData1 + authPluginData2 as authPluginData + // + // TODO + // if capabilities & CLIENT_PLUGIN_AUTH { + // string[NUL] auth-plugin name + // } return new Handshake(args); }; diff --git a/lib/packets/handshake_response.js b/lib/packets/handshake_response.js index d02266d97e..6a51ce39c4 100644 --- a/lib/packets/handshake_response.js +++ b/lib/packets/handshake_response.js @@ -23,50 +23,108 @@ function HandshakeResponse (handshake) } this.authToken = authToken; this.charsetNumber = handshake.charsetNumber; + this.connectAttributes = handshake.connectAttributes; } HandshakeResponse.fromPacket = function (packet) { var args = {}; - // packet.skip(4); args.clientFlags = packet.readInt32(); + + function isSet (flag) { + return args.clientFlags & ClientConstants[flag]; + } + args.maxPacketSize = packet.readInt32(); args.charsetNumber = packet.readInt8(); packet.skip(23); args.user = packet.readNullTerminatedString(); - var authTokenLength = packet.readInt8(); - args.authToken = packet.readBuffer(authTokenLength); - args.database = packet.readNullTerminatedString(); - // return new HandshakeResponse(args); + var authTokenLength; + if (isSet('PLUGIN_AUTH_LENENC_CLIENT_DATA')) { + authTokenLength = packet.readLengthCodedNumber(); + args.authToken = packet.readBuffer(authTokenLength); + } else if (isSet('SECURE_CONNECTION')) { + authTokenLength = packet.readInt8(); + args.authToken = packet.readBuffer(authTokenLength); + } else { + args.authToken = packet.readNullTerminatedString(); + } if (isSet('CONNECT_WITH_DB')) { + args.database = packet.readNullTerminatedString(); + } + if (isSet('PLUGIN_AUTH')) { + args.authPluginName = packet.readNullTerminatedString(); + } + if (isSet('CONNECT_ATTRS')) { + var keysLength = packet.readLengthCodedNumber(); + var keysEnd = packet.offset + keysLength; + var attrs = {}; + while (packet.offset < keysEnd) { + attrs[packet.readLengthCodedString()] = packet.readLengthCodedString(); + } + args.connectAttributes = attrs; + } return args; }; -HandshakeResponse.prototype.toPacket = function () -{ - if (typeof this.user != 'string') { - throw new Error('"user" connection config property must be a string'); +HandshakeResponse.prototype.serializeResponse = function (buffer) { + var self = this; + function isSet (flag) { + return self.clientFlags & ClientConstants[flag]; } - if (typeof this.database != 'string') { - throw new Error('"database" connection config property must be a string'); - } - - var length = 36 + 23 + this.user.length + this.database.length; - - var buffer = new Buffer(length); - var packet = new Packet(0, buffer, 0, length); - buffer.fill(0); + var packet = new Packet(0, buffer, 0, buffer.length); packet.offset = 4; - packet.writeInt32(this.clientFlags); packet.writeInt32(0); // max packet size. todo: move to config packet.writeInt8(this.charsetNumber); packet.skip(23); packet.writeNullTerminatedString(this.user); - packet.writeInt8(this.authToken.length); - packet.writeBuffer(this.authToken); - packet.writeNullTerminatedString(this.database); + + var authTokenLength, k; + if (isSet('PLUGIN_AUTH_LENENC_CLIENT_DATA')) { + packet.writeLengthCodedNumber(this.authToken.length); + packet.writeBuffer(this.authToken); + } else if (isSet('SECURE_CONNECTION')) { + packet.writeInt8(this.authToken.length); + packet.writeBuffer(this.authToken); + } else { + packet.writeNullTerminatedString(Buffer(this.authToken)); + } if (isSet('CONNECT_WITH_DB')) { + packet.writeNullTerminatedString(this.database); + } + if (isSet('PLUGIN_AUTH')) { + // TODO: pass from config + packet.writeNullTerminatedString('mysql_native_password'); + } + if (isSet('CONNECT_ATTRS')) { + var connectAttributes = this.connectAttributes || {}; + var attrNames = Object.keys(connectAttributes); + var keysLength = 0; + for (k = 0; k < attrNames.length; ++k) { + keysLength += Packet.lengthCodedStringLength(attrNames[k]); + keysLength += Packet.lengthCodedStringLength(connectAttributes[attrNames[k]]); + } + packet.writeLengthCodedNumber(keysLength); + for (k = 0; k < attrNames.length; ++k) { + packet.writeLengthCodedString(attrNames[k]); + packet.writeLengthCodedString(connectAttributes[attrNames[k]]); + } + } return packet; }; +HandshakeResponse.prototype.toPacket = function () +{ + if (typeof this.user != 'string') { + throw new Error('"user" connection config prperty must be a string'); + } + if (typeof this.database != 'string') { + throw new Error('"database" connection config prperty must be a string'); + } + // dry run: calculate resulting packet length + var p = this.serializeResponse(Packet.MockBuffer()); + + return this.serializeResponse(new Buffer(p.offset)); +}; + module.exports = HandshakeResponse; diff --git a/lib/packets/index.js b/lib/packets/index.js index fc0622a562..9378b99013 100644 --- a/lib/packets/index.js +++ b/lib/packets/index.js @@ -1,4 +1,4 @@ -'binlog_dump register_slave ssl_request handshake handshake_response query resultset_header column_definition text_row binary_row prepare_statement close_statement prepared_statement_header execute change_user'.split(' ').forEach(function (name) { +'auth_switch_request auth_switch_response auth_switch_request_more_data binlog_dump register_slave ssl_request handshake handshake_response query resultset_header column_definition text_row binary_row prepare_statement close_statement prepared_statement_header execute change_user'.split(' ').forEach(function (name) { var ctor = require('./' + name + '.js'); module.exports[ctor.name] = ctor; // monkey-patch it to include name if debug is on @@ -53,7 +53,6 @@ module.exports.EOF.toPacket = function (warnings, statusFlags) { if (typeof warnings == 'undefined') { warnings = 0; } - if (typeof statusFlags == 'undefined') { statusFlags = 0; } diff --git a/lib/packets/packet.js b/lib/packets/packet.js index 0ba842d523..69bdd76608 100644 --- a/lib/packets/packet.js +++ b/lib/packets/packet.js @@ -516,8 +516,18 @@ Packet.prototype.parseLengthCodedFloat = function () { return this.parseFloat(this.readLengthCodedNumber()); }; +Packet.prototype.peekByte = function () { + return this.buffer[this.offset]; +}; + +// OxFE is often used as "Alt" flag - not ok, not error. +// For example, it's first byte of AuthSwitchRequest +Packet.prototype.isAlt = function () { + return this.peekByte() == 0xfe; +}; + Packet.prototype.isError = function () { - return this.buffer[this.offset] == 0xff; + return this.peekByte() == 0xff; }; Packet.prototype.asError = function () { @@ -670,4 +680,13 @@ Packet.prototype.type = function () { return ''; }; +Packet.MockBuffer = function () { + var noop = function () {}; + var res = Buffer(0); + for (var op in Buffer.prototype) { + res[op] = noop; + } + return res; +}; + module.exports = Packet; diff --git a/lib/server.js b/lib/server.js index fb2190690b..43b6d41c2f 100644 --- a/lib/server.js +++ b/lib/server.js @@ -21,6 +21,7 @@ Server.prototype._handleConnection = function (socket) { }; Server.prototype.listen = function (port, host, backlog, callback) { + this._port = port; this._server.listen.apply(this._server, arguments); return this; }; diff --git a/package.json b/package.json index ccfb57e640..20e8f2b94f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "devDependencies": { "assert-diff": "^1.0.1", "eslint": "^2.10.0", + "portfinder": "^1.0.3", "progress": "1.1.8", "urun": "0.0.8", "utest": "0.0.8" diff --git a/test/common.js b/test/common.js index 4a9d372212..24f457b372 100644 --- a/test/common.js +++ b/test/common.js @@ -58,7 +58,7 @@ module.exports.createConnection = function (args, callback) { driver = require('mysql'); } - var conn = driver.createConnection({ + var params = { host: config.host, rowsAsArray: args.rowsAsArray, user: (args && args.user) || config.user, @@ -66,14 +66,19 @@ module.exports.createConnection = function (args, callback) { database: (args && args.database) || config.database, multipleStatements: args ? args.multipleStatements : false, port: (args && args.port) || config.port, - debug: process.env.DEBUG, + debug: process.env.DEBUG || (args && args.debug), supportBigNumbers: args && args.supportBigNumbers, bigNumberStrings: args && args.bigNumberStrings, compress: (args && args.compress) || config.compress, decimalNumbers: args && args.decimalNumbers, - dateStrings: args && args.dateStrings - }); + dateStrings: args && args.dateStrings, + authSwitchHandler: args && args.authSwitchHandler + }; + + //console.log('cc params', params); + var conn = driver.createConnection(params); + /* conn.query('create database IF NOT EXISTS test', function (err) { if (err) { console.log('error during "create database IF NOT EXISTS test"', err); @@ -84,6 +89,7 @@ module.exports.createConnection = function (args, callback) { console.log('error during "use test"', err); } }); + */ return conn; }; @@ -104,6 +110,7 @@ module.exports.createTemplate = function () { var ClientFlags = require('../lib/constants/client.js'); +var portfinder = require('portfinder'); module.exports.createServer = function (onListening, handler) { var server = require('../index.js').createServer(); server.on('connection', function (conn) { @@ -125,7 +132,9 @@ module.exports.createServer = function (onListening, handler) { handler(conn); } }); - server.listen(3307, onListening); + portfinder.getPort(function (err, port) { + server.listen(port, onListening); + }); return server; }; diff --git a/test/integration/connection/test-binary-notnull-nulls.js b/test/integration/connection/test-binary-notnull-nulls.js index 6ab376acf5..91da620624 100644 --- a/test/integration/connection/test-binary-notnull-nulls.js +++ b/test/integration/connection/test-binary-notnull-nulls.js @@ -7,6 +7,8 @@ var conn = common.createConnection(); // it's possible to receive null values for columns marked with NOT_NULL flag // see https://github.com/sidorares/node-mysql2/issues/178 for info +conn.query('set sql_mode=""'); + conn.query('CREATE TEMPORARY TABLE `tmp_account` ( ' + ' `id` int(11) NOT NULL AUTO_INCREMENT, ' + ' `username` varchar(64) NOT NULL, ' + diff --git a/test/integration/connection/test-change-user-plugin-auth.js b/test/integration/connection/test-change-user-plugin-auth.js new file mode 100644 index 0000000000..2b02d87f38 --- /dev/null +++ b/test/integration/connection/test-change-user-plugin-auth.js @@ -0,0 +1,66 @@ +var assert = require('assert'); +var common = require('../../common'); +var connection = common.createConnection({ + authSwitchHandler: function () { + throw new Error('should not be called - we expect mysql_native_password ' + + 'plugin switch request to be handled by internal handler'); + } +}); + +// create test user first +connection.query('GRANT ALL ON *.* TO \'changeuser1\'@\'localhost\' IDENTIFIED BY \'changeuser1pass\''); +connection.query('GRANT ALL ON *.* TO \'changeuser2\'@\'localhost\' IDENTIFIED BY \'changeuser2pass\''); +connection.query('FLUSH PRIVILEGES'); + +connection.changeUser({ + user: 'changeuser1', + password: 'changeuser1pass' +}, function(err, res) { + assert.ifError(err); + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); + + connection.changeUser({ + user: 'changeuser2', + password: 'changeuser2pass' + }, function(err, res) { + + assert.ifError(err); + + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser2@localhost'}]); + + connection.changeUser({ + user: 'changeuser1', + passwordSha1: new Buffer('f961d39c82138dcec42b8d0dcb3e40a14fb7e8cd', 'hex') // sha1(changeuser1pass) + }, function(err, res) { + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); + + testIncorrectDb(); + }); + }); + }); + }); + }); +}); + +function testIncorrectDb() { + connection.end(); + // TODO figure out if stuff below is still relevant + /* + connection.on('error', function (err) { + assert.ok(err, 'got disconnect'); + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + }); + connection.changeUser({database: 'does-not-exist', }, function (err) { + assert.ok(err, 'got error'); + assert.equal(err.code, 'ER_BAD_DB_ERROR'); + assert.equal(err.fatal, true); + }); + connection.end(); + */ +} diff --git a/test/integration/connection/test-change-user.js b/test/integration/connection/test-change-user.js index e9643c53f1..422a6e02a9 100644 --- a/test/integration/connection/test-change-user.js +++ b/test/integration/connection/test-change-user.js @@ -10,54 +10,51 @@ connection.query('FLUSH PRIVILEGES'); connection.changeUser({ user: 'changeuser1', password: 'changeuser1pass' -}); -connection.query('select user()', function (err, rows) { - if (err) { - throw err; - } - assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); -}); - -connection.changeUser({ - user: 'changeuser2', - password: 'changeuser2pass' -}); - -connection.query('select user()', function (err, rows) { - if (err) { - throw err; - } - assert.deepEqual(rows, [{'user()': 'changeuser2@localhost'}]); -}); - -connection.changeUser({ - user: 'changeuser1', - passwordSha1: new Buffer('f961d39c82138dcec42b8d0dcb3e40a14fb7e8cd', 'hex') // sha1(changeuser1pass) -}); -connection.query('select user()', function (err, rows) { - if (err) { - throw err; - } - assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); -}); - -connection.end(); - -// from felixge/node-mysql/test/unit/connection/test-change-database-fatal-error.js: -// This test verifies that changeUser errors are treated as fatal errors. The -// rationale for that is that a failure to execute a changeUser sequence may -// cause unexpected behavior for queries that were enqueued under the -// assumption of changeUser to succeed. - -var beforeChange = 1; -connection.changeUser({database: 'does-not-exist'}, function (err) { - assert.ok(err, 'got error'); - assert.equal(err.code, 'ER_BAD_DB_ERROR'); - assert.equal(err.fatal, true); -}); - -connection.on('error', function (err) { - assert.ok(err, 'got disconnect'); - assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); - assert.equal(beforeChange, 1); -}); +}, function(err, res) { + assert.ifError(err); + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); + + connection.changeUser({ + user: 'changeuser2', + password: 'changeuser2pass' + }, function(err, res) { + + assert.ifError(err); + + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser2@localhost'}]); + + connection.changeUser({ + user: 'changeuser1', + passwordSha1: new Buffer('f961d39c82138dcec42b8d0dcb3e40a14fb7e8cd', 'hex') // sha1(changeuser1pass) + }, function(err, res) { + connection.query('select user()', function (err, rows) { + assert.ifError(err); + assert.deepEqual(rows, [{'user()': 'changeuser1@localhost'}]); + testIncorrectDb(); + }); + }); + }); + }); + }); +}); + +function testIncorrectDb() { + connection.end(); + // TODO figure out if stuff below is still relevant + /* + connection.on('error', function (err) { + assert.ok(err, 'got disconnect'); + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + }); + connection.changeUser({database: 'does-not-exist', }, function (err) { + assert.ok(err, 'got error'); + assert.equal(err.code, 'ER_BAD_DB_ERROR'); + assert.equal(err.fatal, true); + }); + connection.end(); + */ +} diff --git a/test/integration/connection/test-connect-sha1.js b/test/integration/connection/test-connect-sha1.js index a423ed40cb..2eda0e238b 100644 --- a/test/integration/connection/test-connect-sha1.js +++ b/test/integration/connection/test-connect-sha1.js @@ -11,54 +11,59 @@ function authenticate (params, cb) { cb(null); } +var _1_2 = false; +var _1_3 = false; + var queryCalls = 0; +var portfinder = require('portfinder'); +portfinder.getPort(function (err, port) { + var server = mysql.createServer(); -server.listen(3307); -server.on('connection', function (conn) { - conn.serverHandshake({ - protocolVersion: 10, - serverVersion: 'node.js rocks', - connectionId: 1234, - statusFlags: 2, - characterSet: 8, - capabilityFlags: 0xffffff, - authCallback: authenticate - }); - conn.on('query', function (sql) { - assert.equal(sql, 'select 1+1'); - queryCalls++; - conn.close(); + server.listen(port); + server.on('connection', function (conn) { + conn.serverHandshake({ + protocolVersion: 10, + serverVersion: 'node.js rocks', + connectionId: 1234, + statusFlags: 2, + characterSet: 8, + capabilityFlags: 0xffffff, + authCallback: authenticate + }); + conn.on('query', function (sql) { + assert.equal(sql, 'select 1+1'); + queryCalls++; + conn.close(); + }); }); -}); -var connection = mysql.createConnection({ - port: 3307, - user: 'testuser', - database: 'testdatabase', - passwordSha1: Buffer('8bb6118f8fd6935ad0876a3be34a717d32708ffd', 'hex') -}); + var connection = mysql.createConnection({ + port: port, + user: 'testuser', + database: 'testdatabase', + passwordSha1: Buffer('8bb6118f8fd6935ad0876a3be34a717d32708ffd', 'hex') + }); -connection.on('error', function (err) { - assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); -}); + connection.on('error', function (err) { + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + }); -connection.query('select 1+1', function (err) { - assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); - server._server.close(); -}); + connection.query('select 1+1', function (err) { + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + server._server.close(); + }); -var _1_2 = false; -var _1_3 = false; + connection.query('select 1+2', function (err) { + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + _1_2 = true; + }); -connection.query('select 1+2', function (err) { - assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); - _1_2 = true; -}); + connection.query('select 1+3', function (err) { + assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); + _1_3 = true; + }); -connection.query('select 1+3', function (err) { - assert.equal(err.code, 'PROTOCOL_CONNECTION_LOST'); - _1_3 = true; }); process.on('exit', function () { diff --git a/test/integration/connection/test-custom-date-parameter.js b/test/integration/connection/test-custom-date-parameter.js index c5e003bd20..5ebde5131c 100644 --- a/test/integration/connection/test-custom-date-parameter.js +++ b/test/integration/connection/test-custom-date-parameter.js @@ -13,6 +13,7 @@ Date = function () { return CustomDate; }(); +connection.query("set time_zone = '+00:00'"); connection.execute('SELECT UNIX_TIMESTAMP(?) t', [new Date('1990-08-08 UTC')], function (err, _rows, _fields) { if (err) { throw err; diff --git a/test/integration/connection/test-date-parameter.js b/test/integration/connection/test-date-parameter.js index c31bb955cb..554e5fa2d6 100644 --- a/test/integration/connection/test-date-parameter.js +++ b/test/integration/connection/test-date-parameter.js @@ -4,6 +4,7 @@ var assert = require('assert'); var rows = undefined; +connection.query("set time_zone = '+00:00'"); connection.execute('SELECT UNIX_TIMESTAMP(?) t', [new Date('1990-01-01 UTC')], function (err, _rows, _fields) { if (err) { throw err; diff --git a/test/integration/connection/test-datetime.js b/test/integration/connection/test-datetime.js index fe0dd1c657..bae3c4fc43 100644 --- a/test/integration/connection/test-datetime.js +++ b/test/integration/connection/test-datetime.js @@ -11,6 +11,7 @@ var date2 = '2010-12-10 14:12:09.019473'; var date3 = null; connection.query('CREATE TEMPORARY TABLE t (d1 DATE)'); +connection.query("set time_zone = '+00:00'"); connection.query('INSERT INTO t set d1=?', [date]); connection1.query('CREATE TEMPORARY TABLE t (d1 DATE, d2 TIMESTAMP, d3 DATETIME, d4 DATETIME)'); @@ -53,7 +54,6 @@ connection.execute('select * from t', function (err, _rows, _fields) { }); connection1.query('select * from t', function (err, _rows, _fields) { - console.log(_rows); if (err) { throw err; } @@ -61,7 +61,6 @@ connection1.query('select * from t', function (err, _rows, _fields) { }); connection1.execute('select * from t', function (err, _rows, _fields) { - console.log(_rows); if (err) { throw err; } diff --git a/test/integration/connection/test-disconnects.js b/test/integration/connection/test-disconnects.js index f840fd342c..a55e0a2738 100644 --- a/test/integration/connection/test-disconnects.js +++ b/test/integration/connection/test-disconnects.js @@ -9,7 +9,7 @@ var server; var connections = []; function test () { - var connection = common.createConnection({port: 3307}); + var connection = common.createConnection({port: server._port}); connection.query('SELECT 123', function (err, _rows, _fields) { if (err) { throw err; diff --git a/test/integration/connection/test-protocol-errors.js b/test/integration/connection/test-protocol-errors.js index 8e223d061f..6507eca6d1 100644 --- a/test/integration/connection/test-protocol-errors.js +++ b/test/integration/connection/test-protocol-errors.js @@ -21,7 +21,7 @@ var server = common.createServer(serverReady, function (conn) { var fields, error; var query = 'SELECT 1'; function serverReady () { - var connection = common.createConnection({port: 3307}); + var connection = common.createConnection({port: server._port}); connection.query(query, function (err, _rows, _fields) { if (err) { throw err; diff --git a/test/integration/connection/test-quit.js b/test/integration/connection/test-quit.js index b287146af4..07bad9c886 100644 --- a/test/integration/connection/test-quit.js +++ b/test/integration/connection/test-quit.js @@ -27,7 +27,7 @@ var server = common.createServer(serverReady, function (conn) { }); function serverReady () { - var connection = common.createConnection({port: 3307}); + var connection = common.createConnection({port: server._port}); connection.query(queryCli, function (err, _rows, _fields) { if (err) { diff --git a/test/integration/connection/test-stream-errors.js b/test/integration/connection/test-stream-errors.js index 8fc87e90d5..afcbd054d4 100644 --- a/test/integration/connection/test-stream-errors.js +++ b/test/integration/connection/test-stream-errors.js @@ -29,7 +29,7 @@ var server = common.createServer(serverReady, function (conn) { var receivedError1, receivedError2, receivedError3; var query = 'SELECT 1'; function serverReady () { - clientConnection = common.createConnection({port: 3307}); + clientConnection = common.createConnection({port: server._port}); clientConnection.query(query, function (err, _rows, _fields) { receivedError1 = err; }); diff --git a/test/integration/test-auth-switch.js b/test/integration/test-auth-switch.js new file mode 100644 index 0000000000..31ad148bed --- /dev/null +++ b/test/integration/test-auth-switch.js @@ -0,0 +1,116 @@ +var util = require('util'); +var mysql = require('../../index.js'); +var Command = require('../../lib/commands/command.js'); +var Packets = require('../../lib/packets/index.js'); + +var assert = require('assert'); + +function TestAuthSwitchHandshake (args) +{ + Command.call(this); + this.args = args; +} +util.inherits(TestAuthSwitchHandshake, Command); + +var connectAttributes = {foo: 'bar', baz: 'foo'}; + +TestAuthSwitchHandshake.prototype.start = function (packet, connection) { + var serverHelloPacket = new Packets.Handshake({ + protocolVersion: 10, + serverVersion: 'node.js rocks', + connectionId: 1234, + statusFlags: 2, + characterSet: 8, + capabilityFlags: 0xffffff + }); + this.serverHello = serverHelloPacket; + serverHelloPacket.setScrambleData(function (err) { + connection.writePacket(serverHelloPacket.toPacket(0)); + }); + return TestAuthSwitchHandshake.prototype.readClientReply; +}; + +TestAuthSwitchHandshake.prototype.readClientReply = function (packet, connection) { + var clientHelloReply = new Packets.HandshakeResponse.fromPacket(packet); + + assert.equal(clientHelloReply.user, 'test_user'); + assert.equal(clientHelloReply.database, 'test_database'); + assert.equal(clientHelloReply.authPluginName, 'mysql_native_password'); + assert.deepEqual(clientHelloReply.connectAttributes, connectAttributes); + + var asr = new Packets.AuthSwitchRequest(this.args); + connection.writePacket(asr.toPacket()); + return TestAuthSwitchHandshake.prototype.readClientAuthSwitchResponse; +}; + +var count = 0; + +TestAuthSwitchHandshake.prototype.readClientAuthSwitchResponse = function (packet, connection) { + var authSwitchResponse = new Packets.AuthSwitchResponse.fromPacket(packet); + + count++; + if (count < 10) { + var asrmd = new Packets.AuthSwitchRequestMoreData(Buffer('hahaha ' + count)); + connection.writePacket(asrmd.toPacket()); + return TestAuthSwitchHandshake.prototype.readClientAuthSwitchResponse; + } else { + connection.writeOk(); + return TestAuthSwitchHandshake.prototype.dispatchCommands; + } +}; + +TestAuthSwitchHandshake.prototype.dispatchCommands = function (packet, connection) { + // Quit command here + // TODO: assert it's actually Quit + connection.end(); + return TestAuthSwitchHandshake.prototype.dispatchCommands; +}; + +var server = mysql.createServer(function (conn) { + conn.addCommand(new TestAuthSwitchHandshake({ + pluginName: 'auth_test_plugin', + pluginData: Buffer('f\{tU-{K@BhfHt/-4^Z,') + })); +}); + +var fullAuthExchangeDone = false; + +var portfinder = require('portfinder'); +portfinder.getPort(function (err, port) { + + var makeSwitchHandler = function () { + var count = 0; + return function (data, cb) { + if (count == 0) { + assert.equal(data.pluginName, 'auth_test_plugin'); + } else { + assert.equal(data.pluginData.toString(), 'hahaha ' + count); + } + + if (count == 9) { + fullAuthExchangeDone = true; + } + count++; + cb(null, 'some data back' + count); + }; + }; + + server.listen(port); + var conn = mysql.createConnection({ + user: 'test_user', + password: 'test', + database: 'test_database', + port: port, + authSwitchHandler: makeSwitchHandler(), + connectAttributes: connectAttributes + }); + + conn.on('connect', function (data) { + assert.equal(data.serverVersion, 'node.js rocks'); + assert.equal(data.connectionId, 1234); + + conn.end(); + server.close(); + }); + +});