Merge pull request #13787 from nijel/second-factor

Second authentication factor
This commit is contained in:
Michal Čihař 2017-11-01 18:13:12 +01:00 committed by GitHub
commit 45d1924e70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 2166 additions and 3 deletions

View File

@ -10,6 +10,7 @@ build:
dependencies:
before:
- composer install
- composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server
- ./vendor/bin/phpcs --config-set installed_paths `pwd`/vendor/phpmyadmin/coding-standard
tests:
override:

View File

@ -57,7 +57,10 @@
"symfony/polyfill-mbstring": "^1.3"
},
"conflict": {
"tecnickcom/tcpdf": "<6.2"
"tecnickcom/tcpdf": "<6.2",
"pragmarx/google2fa": "<2.0",
"bacon/bacon-qr-code": "<1.0",
"samyoul/u2f-php-server": "<1.1"
},
"suggest": {
"ext-openssl": "Cookie encryption",
@ -68,7 +71,10 @@
"ext-zip": "For zip import and export",
"ext-gd2": "For image transformations",
"ext-mbstring": "For best performance",
"tecnickcom/tcpdf": "For PDF support"
"tecnickcom/tcpdf": "For PDF support",
"pragmarx/google2fa": "For 2FA authentication",
"bacon/bacon-qr-code": "For 2FA authentication",
"samyoul/u2f-php-server": "For FIDO U2F authentication"
},
"require-dev": {
"phpunit/phpunit": "~4.1",

View File

@ -3345,6 +3345,13 @@ Developer
* The setup script is enabled even with existing configuration.
* The setup does not try to connect to the MySQL server.
.. config:option:: $cfg['DBG']['simple2fa']
:type: boolean
:default: false
Can be used for testing second authentication factor.
.. _config-examples:
Examples

52
doc/second_factor.rst Normal file
View File

@ -0,0 +1,52 @@
.. _2fa:
Second authentication factor
============================
.. versionadded:: 4.8.0
Since phpMyAdmin 4.8.0 you can configure second authentication factor to be
used when logging into it. To use this, you first need to configure
:ref:`linked-tables`. Once this is done, every user can opt-in for second
authentication factor in the :guilabel:`Settings`.
Authentication Application
--------------------------
Using application for authentication is quite common approach based on HOTP and
TOTP. It is based on transmitting private key from phpMyAdmin to the
authentication application and the application is then able to generate one
time codes based on this key.
There are dozens of applications available for mobile phones to implement these
standards, the most widely used include:
* `FreeOTP for iOS, Android and Pebble <https://freeotp.github.io/>`_
* `Authy for iOS, Android, Chrome, OS X <https://www.authy.com/>`_
* `Google Authenticator for iOS <https://itunes.apple.com/us/app/google-authenticator/id388497605>`_
* `Google Authenticator for Android <https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2>`_
* `LastPass Authenticator for iOS, Android, OS X, Windows <https://lastpass.com/auth/>`_
Hardware Security Key
---------------------
Using hardware tokens is considered to be more secure than software based
solution. phpMyAdmin supports `FIDO U2F <https://en.wikipedia.org/wiki/Universal_2nd_Factor>`_
tokens.
There are several manufacturers of these tokens, for example:
* `youbico FIDO U2F Security Key <https://www.yubico.com/products/yubikey-hardware/fido-u2f-security-key/>`_
* `HyperFIDO <https://www.hypersecu.com/products/hyperfido>`_
* `ePass FIDO USB <https://www.ftsafe.com/onlinestore/product?id=21>`_
* `TREZOR Bitcoin wallet <https://shop.trezor.io?a=572b241135e1>`_ can `act as an U2F token <http://doc.satoshilabs.com/trezor-user/u2f.html>`_
Simple Second Factor
--------------------
This authentication is included for testing and demostration purposes only as
it really does not provide second factor, it just asks user to confirm login by
clicking on the button.
It should not be used in the production and is disabled unless
:config:option:`$cfg['DBG']['simple2fa']` is set.

View File

@ -1030,6 +1030,7 @@ are always ways to make your installation more secure:
* In case you don't want all MySQL users to be able to access
phpMyAdmin, you can use :config:option:`$cfg['Servers'][$i]['AllowDeny']['rules']` to limit them
or :config:option:`$cfg['Servers'][$i]['AllowRoot']` to deny root user access.
* Enable :ref:`2fa` for your account.
* Consider hiding phpMyAdmin behind an authentication proxy, so that
users need to authenticate prior to providing MySQL credentials
to phpMyAdmin. You can achieve this by configuring your web server to request

View File

@ -5,6 +5,7 @@ User Guide
:maxdepth: 2
settings
second_factor
transformations
bookmarks
privileges

View File

@ -742,6 +742,10 @@ $js_messages['strWeak'] = __('Weak');
$js_messages['strGood'] = __('Good');
$js_messages['strStrong'] = __('Strong');
/* U2F errors */
$js_messages['strU2FTimeout'] = __('Timed out waiting for security key activation.');
$js_messages['strU2FError'] = __('Failed security key activation (%s).');
echo "var PMA_messages = new Array();\n";
foreach ($js_messages as $name => $js_message) {
Sanitize::printJsValue("PMA_messages['" . $name . "']", $js_message);

55
js/u2f.js Normal file
View File

@ -0,0 +1,55 @@
/** global: AJAX */
/** global: PMA_messages */
/** global: u2f */
AJAX.registerOnload('u2f.js', function () {
var $inputReg = $('#u2f_registration_response');
if ($inputReg.length > 0) {
var $formReg = $inputReg.parents('form');
$formReg.find('input[type=submit]').hide();
setTimeout(function() {
// A magic JS function that talks to the USB device. This function will keep polling for the USB device until it finds one.
u2f.register([JSON.parse($inputReg.attr('data-request'))], JSON.parse($inputReg.attr('data-signatures')), function(data) {
// Handle returning error data
if(data.errorCode && data.errorCode !== 0) {
if (data.errorCode === 5) {
PMA_ajaxShowMessage(PMA_messages.strU2FTimeout, false);
} else {
PMA_ajaxShowMessage(
PMA_sprintf(PMA_messages.strU2FError, data.errorCode), false
);
}
return;
}
// Fill and submit form.
$inputReg.val(JSON.stringify(data));
$formReg.submit();
});
}, 1000);
}
var $inputAuth = $('#u2f_authentication_response');
if ($inputAuth.length > 0) {
var $formAuth = $inputAuth.parents('form');
$formAuth.find('input[type=submit]').hide();
setTimeout(function() {
// Magic JavaScript talking to your HID
u2f.sign(JSON.parse($inputAuth.attr('data-request')), function(data) {
// Handle returning error data
if(data.errorCode && data.errorCode !== 0) {
if (data.errorCode === 5) {
PMA_ajaxShowMessage(PMA_messages.strU2FTimeout, false);
} else {
PMA_ajaxShowMessage(
PMA_sprintf(PMA_messages.strU2FError, data.errorCode), false
);
}
return;
}
// Fill and submit form.
$inputAuth.val(JSON.stringify(data));
$formAuth.submit();
});
}, 1000);
}
});

748
js/vendor/u2f-api.js vendored Normal file
View File

@ -0,0 +1,748 @@
//Copyright 2014-2015 Google Inc. All rights reserved.
//Use of this source code is governed by a BSD-style
//license that can be found in the LICENSE file or at
//https://developers.google.com/open-source/licenses/bsd
/**
* @fileoverview The U2F api.
*/
'use strict';
/**
* Namespace for the U2F api.
* @type {Object}
*/
var u2f = u2f || {};
/**
* FIDO U2F Javascript API Version
* @number
*/
var js_api_version;
/**
* The U2F extension id
* @const {string}
*/
// The Chrome packaged app extension ID.
// Uncomment this if you want to deploy a server instance that uses
// the package Chrome app and does not require installing the U2F Chrome extension.
u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd';
// The U2F Chrome extension ID.
// Uncomment this if you want to deploy a server instance that uses
// the U2F Chrome extension to authenticate.
// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne';
/**
* Message types for messsages to/from the extension
* @const
* @enum {string}
*/
u2f.MessageTypes = {
'U2F_REGISTER_REQUEST': 'u2f_register_request',
'U2F_REGISTER_RESPONSE': 'u2f_register_response',
'U2F_SIGN_REQUEST': 'u2f_sign_request',
'U2F_SIGN_RESPONSE': 'u2f_sign_response',
'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request',
'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response'
};
/**
* Response status codes
* @const
* @enum {number}
*/
u2f.ErrorCodes = {
'OK': 0,
'OTHER_ERROR': 1,
'BAD_REQUEST': 2,
'CONFIGURATION_UNSUPPORTED': 3,
'DEVICE_INELIGIBLE': 4,
'TIMEOUT': 5
};
/**
* A message for registration requests
* @typedef {{
* type: u2f.MessageTypes,
* appId: ?string,
* timeoutSeconds: ?number,
* requestId: ?number
* }}
*/
u2f.U2fRequest;
/**
* A message for registration responses
* @typedef {{
* type: u2f.MessageTypes,
* responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse),
* requestId: ?number
* }}
*/
u2f.U2fResponse;
/**
* An error object for responses
* @typedef {{
* errorCode: u2f.ErrorCodes,
* errorMessage: ?string
* }}
*/
u2f.Error;
/**
* Data object for a single sign request.
* @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC, USB_INTERNAL}}
*/
u2f.Transport;
/**
* Data object for a single sign request.
* @typedef {Array<u2f.Transport>}
*/
u2f.Transports;
/**
* Data object for a single sign request.
* @typedef {{
* version: string,
* challenge: string,
* keyHandle: string,
* appId: string
* }}
*/
u2f.SignRequest;
/**
* Data object for a sign response.
* @typedef {{
* keyHandle: string,
* signatureData: string,
* clientData: string
* }}
*/
u2f.SignResponse;
/**
* Data object for a registration request.
* @typedef {{
* version: string,
* challenge: string
* }}
*/
u2f.RegisterRequest;
/**
* Data object for a registration response.
* @typedef {{
* version: string,
* keyHandle: string,
* transports: Transports,
* appId: string
* }}
*/
u2f.RegisterResponse;
/**
* Data object for a registered key.
* @typedef {{
* version: string,
* keyHandle: string,
* transports: ?Transports,
* appId: ?string
* }}
*/
u2f.RegisteredKey;
/**
* Data object for a get API register response.
* @typedef {{
* js_api_version: number
* }}
*/
u2f.GetJsApiVersionResponse;
//Low level MessagePort API support
/**
* Sets up a MessagePort to the U2F extension using the
* available mechanisms.
* @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
*/
u2f.getMessagePort = function(callback) {
if (typeof chrome != 'undefined' && chrome.runtime) {
// The actual message here does not matter, but we need to get a reply
// for the callback to run. Thus, send an empty signature request
// in order to get a failure response.
var msg = {
type: u2f.MessageTypes.U2F_SIGN_REQUEST,
signRequests: []
};
chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() {
if (!chrome.runtime.lastError) {
// We are on a whitelisted origin and can talk directly
// with the extension.
u2f.getChromeRuntimePort_(callback);
} else {
// chrome.runtime was available, but we couldn't message
// the extension directly, use iframe
u2f.getIframePort_(callback);
}
});
} else if (u2f.isAndroidChrome_()) {
u2f.getAuthenticatorPort_(callback);
} else if (u2f.isIosChrome_()) {
u2f.getIosPort_(callback);
} else {
// chrome.runtime was not available at all, which is normal
// when this origin doesn't have access to any extensions.
u2f.getIframePort_(callback);
}
};
/**
* Detect chrome running on android based on the browser's useragent.
* @private
*/
u2f.isAndroidChrome_ = function() {
var userAgent = navigator.userAgent;
return userAgent.indexOf('Chrome') != -1 &&
userAgent.indexOf('Android') != -1;
};
/**
* Detect chrome running on iOS based on the browser's platform.
* @private
*/
u2f.isIosChrome_ = function() {
return ["iPhone", "iPad", "iPod"].indexOf(navigator.platform) > -1;
};
/**
* Connects directly to the extension via chrome.runtime.connect.
* @param {function(u2f.WrappedChromeRuntimePort_)} callback
* @private
*/
u2f.getChromeRuntimePort_ = function(callback) {
var port = chrome.runtime.connect(u2f.EXTENSION_ID,
{'includeTlsChannelId': true});
setTimeout(function() {
callback(new u2f.WrappedChromeRuntimePort_(port));
}, 0);
};
/**
* Return a 'port' abstraction to the Authenticator app.
* @param {function(u2f.WrappedAuthenticatorPort_)} callback
* @private
*/
u2f.getAuthenticatorPort_ = function(callback) {
setTimeout(function() {
callback(new u2f.WrappedAuthenticatorPort_());
}, 0);
};
/**
* Return a 'port' abstraction to the iOS client app.
* @param {function(u2f.WrappedIosPort_)} callback
* @private
*/
u2f.getIosPort_ = function(callback) {
setTimeout(function() {
callback(new u2f.WrappedIosPort_());
}, 0);
};
/**
* A wrapper for chrome.runtime.Port that is compatible with MessagePort.
* @param {Port} port
* @constructor
* @private
*/
u2f.WrappedChromeRuntimePort_ = function(port) {
this.port_ = port;
};
/**
* Format and return a sign request compliant with the JS API version supported by the extension.
* @param {Array<u2f.SignRequest>} signRequests
* @param {number} timeoutSeconds
* @param {number} reqId
* @return {Object}
*/
u2f.formatSignRequest_ =
function(appId, challenge, registeredKeys, timeoutSeconds, reqId) {
if (js_api_version === undefined || js_api_version < 1.1) {
// Adapt request to the 1.0 JS API
var signRequests = [];
for (var i = 0; i < registeredKeys.length; i++) {
signRequests[i] = {
version: registeredKeys[i].version,
challenge: challenge,
keyHandle: registeredKeys[i].keyHandle,
appId: appId
};
}
return {
type: u2f.MessageTypes.U2F_SIGN_REQUEST,
signRequests: signRequests,
timeoutSeconds: timeoutSeconds,
requestId: reqId
};
}
// JS 1.1 API
return {
type: u2f.MessageTypes.U2F_SIGN_REQUEST,
appId: appId,
challenge: challenge,
registeredKeys: registeredKeys,
timeoutSeconds: timeoutSeconds,
requestId: reqId
};
};
/**
* Format and return a register request compliant with the JS API version supported by the extension..
* @param {Array<u2f.SignRequest>} signRequests
* @param {Array<u2f.RegisterRequest>} signRequests
* @param {number} timeoutSeconds
* @param {number} reqId
* @return {Object}
*/
u2f.formatRegisterRequest_ =
function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) {
if (js_api_version === undefined || js_api_version < 1.1) {
// Adapt request to the 1.0 JS API
for (var i = 0; i < registerRequests.length; i++) {
registerRequests[i].appId = appId;
}
var signRequests = [];
for (var i = 0; i < registeredKeys.length; i++) {
signRequests[i] = {
version: registeredKeys[i].version,
challenge: registerRequests[0],
keyHandle: registeredKeys[i].keyHandle,
appId: appId
};
}
return {
type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
signRequests: signRequests,
registerRequests: registerRequests,
timeoutSeconds: timeoutSeconds,
requestId: reqId
};
}
// JS 1.1 API
return {
type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
appId: appId,
registerRequests: registerRequests,
registeredKeys: registeredKeys,
timeoutSeconds: timeoutSeconds,
requestId: reqId
};
};
/**
* Posts a message on the underlying channel.
* @param {Object} message
*/
u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) {
this.port_.postMessage(message);
};
/**
* Emulates the HTML 5 addEventListener interface. Works only for the
* onmessage event, which is hooked up to the chrome.runtime.Port.onMessage.
* @param {string} eventName
* @param {function({data: Object})} handler
*/
u2f.WrappedChromeRuntimePort_.prototype.addEventListener =
function(eventName, handler) {
var name = eventName.toLowerCase();
if (name == 'message' || name == 'onmessage') {
this.port_.onMessage.addListener(function(message) {
// Emulate a minimal MessageEvent object
handler({'data': message});
});
} else {
console.error('WrappedChromeRuntimePort only supports onMessage');
}
};
/**
* Wrap the Authenticator app with a MessagePort interface.
* @constructor
* @private
*/
u2f.WrappedAuthenticatorPort_ = function() {
this.requestId_ = -1;
this.requestObject_ = null;
}
/**
* Launch the Authenticator intent.
* @param {Object} message
*/
u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) {
var intentUrl =
u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ +
';S.request=' + encodeURIComponent(JSON.stringify(message)) +
';end';
document.location = intentUrl;
};
/**
* Tells what type of port this is.
* @return {String} port type
*/
u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() {
return "WrappedAuthenticatorPort_";
};
/**
* Emulates the HTML 5 addEventListener interface.
* @param {string} eventName
* @param {function({data: Object})} handler
*/
u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) {
var name = eventName.toLowerCase();
if (name == 'message') {
var self = this;
/* Register a callback to that executes when
* chrome injects the response. */
window.addEventListener(
'message', self.onRequestUpdate_.bind(self, handler), false);
} else {
console.error('WrappedAuthenticatorPort only supports message');
}
};
/**
* Callback invoked when a response is received from the Authenticator.
* @param function({data: Object}) callback
* @param {Object} message message Object
*/
u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ =
function(callback, message) {
var messageObject = JSON.parse(message.data);
var intentUrl = messageObject['intentURL'];
var errorCode = messageObject['errorCode'];
var responseObject = null;
if (messageObject.hasOwnProperty('data')) {
responseObject = /** @type {Object} */ (
JSON.parse(messageObject['data']));
}
callback({'data': responseObject});
};
/**
* Base URL for intents to Authenticator.
* @const
* @private
*/
u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ =
'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE';
/**
* Wrap the iOS client app with a MessagePort interface.
* @constructor
* @private
*/
u2f.WrappedIosPort_ = function() {};
/**
* Launch the iOS client app request
* @param {Object} message
*/
u2f.WrappedIosPort_.prototype.postMessage = function(message) {
var str = JSON.stringify(message);
var url = "u2f://auth?" + encodeURI(str);
location.replace(url);
};
/**
* Tells what type of port this is.
* @return {String} port type
*/
u2f.WrappedIosPort_.prototype.getPortType = function() {
return "WrappedIosPort_";
};
/**
* Emulates the HTML 5 addEventListener interface.
* @param {string} eventName
* @param {function({data: Object})} handler
*/
u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) {
var name = eventName.toLowerCase();
if (name !== 'message') {
console.error('WrappedIosPort only supports message');
}
};
/**
* Sets up an embedded trampoline iframe, sourced from the extension.
* @param {function(MessagePort)} callback
* @private
*/
u2f.getIframePort_ = function(callback) {
// Create the iframe
var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID;
var iframe = document.createElement('iframe');
iframe.src = iframeOrigin + '/u2f-comms.html';
iframe.setAttribute('style', 'display:none');
document.body.appendChild(iframe);
var channel = new MessageChannel();
var ready = function(message) {
if (message.data == 'ready') {
channel.port1.removeEventListener('message', ready);
callback(channel.port1);
} else {
console.error('First event on iframe port was not "ready"');
}
};
channel.port1.addEventListener('message', ready);
channel.port1.start();
iframe.addEventListener('load', function() {
// Deliver the port to the iframe and initialize
iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]);
});
};
//High-level JS API
/**
* Default extension response timeout in seconds.
* @const
*/
u2f.EXTENSION_TIMEOUT_SEC = 30;
/**
* A singleton instance for a MessagePort to the extension.
* @type {MessagePort|u2f.WrappedChromeRuntimePort_}
* @private
*/
u2f.port_ = null;
/**
* Callbacks waiting for a port
* @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>}
* @private
*/
u2f.waitingForPort_ = [];
/**
* A counter for requestIds.
* @type {number}
* @private
*/
u2f.reqCounter_ = 0;
/**
* A map from requestIds to client callbacks
* @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse))
* |function((u2f.Error|u2f.SignResponse)))>}
* @private
*/
u2f.callbackMap_ = {};
/**
* Creates or retrieves the MessagePort singleton to use.
* @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback
* @private
*/
u2f.getPortSingleton_ = function(callback) {
if (u2f.port_) {
callback(u2f.port_);
} else {
if (u2f.waitingForPort_.length == 0) {
u2f.getMessagePort(function(port) {
u2f.port_ = port;
u2f.port_.addEventListener('message',
/** @type {function(Event)} */ (u2f.responseHandler_));
// Careful, here be async callbacks. Maybe.
while (u2f.waitingForPort_.length)
u2f.waitingForPort_.shift()(u2f.port_);
});
}
u2f.waitingForPort_.push(callback);
}
};
/**
* Handles response messages from the extension.
* @param {MessageEvent.<u2f.Response>} message
* @private
*/
u2f.responseHandler_ = function(message) {
var response = message.data;
var reqId = response['requestId'];
if (!reqId || !u2f.callbackMap_[reqId]) {
console.error('Unknown or missing requestId in response.');
return;
}
var cb = u2f.callbackMap_[reqId];
delete u2f.callbackMap_[reqId];
cb(response['responseData']);
};
/**
* Dispatches an array of sign requests to available U2F tokens.
* If the JS API version supported by the extension is unknown, it first sends a
* message to the extension to find out the supported API version and then it sends
* the sign request.
* @param {string=} appId
* @param {string=} challenge
* @param {Array<u2f.RegisteredKey>} registeredKeys
* @param {function((u2f.Error|u2f.SignResponse))} callback
* @param {number=} opt_timeoutSeconds
*/
u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
if (js_api_version === undefined) {
// Send a message to get the extension to JS API version, then send the actual sign request.
u2f.getApiVersion(
function (response) {
js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version'];
console.log("Extension JS API Version: ", js_api_version);
u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
});
} else {
// We know the JS API version. Send the actual sign request in the supported API version.
u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds);
}
};
/**
* Dispatches an array of sign requests to available U2F tokens.
* @param {string=} appId
* @param {string=} challenge
* @param {Array<u2f.RegisteredKey>} registeredKeys
* @param {function((u2f.Error|u2f.SignResponse))} callback
* @param {number=} opt_timeoutSeconds
*/
u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) {
u2f.getPortSingleton_(function(port) {
var reqId = ++u2f.reqCounter_;
u2f.callbackMap_[reqId] = callback;
var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId);
port.postMessage(req);
});
};
/**
* Dispatches register requests to available U2F tokens. An array of sign
* requests identifies already registered tokens.
* If the JS API version supported by the extension is unknown, it first sends a
* message to the extension to find out the supported API version and then it sends
* the register request.
* @param {string=} appId
* @param {Array<u2f.RegisterRequest>} registerRequests
* @param {Array<u2f.RegisteredKey>} registeredKeys
* @param {function((u2f.Error|u2f.RegisterResponse))} callback
* @param {number=} opt_timeoutSeconds
*/
u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
if (js_api_version === undefined) {
// Send a message to get the extension to JS API version, then send the actual register request.
u2f.getApiVersion(
function (response) {
js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version'];
console.log("Extension JS API Version: ", js_api_version);
u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
callback, opt_timeoutSeconds);
});
} else {
// We know the JS API version. Send the actual register request in the supported API version.
u2f.sendRegisterRequest(appId, registerRequests, registeredKeys,
callback, opt_timeoutSeconds);
}
};
/**
* Dispatches register requests to available U2F tokens. An array of sign
* requests identifies already registered tokens.
* @param {string=} appId
* @param {Array<u2f.RegisterRequest>} registerRequests
* @param {Array<u2f.RegisteredKey>} registeredKeys
* @param {function((u2f.Error|u2f.RegisterResponse))} callback
* @param {number=} opt_timeoutSeconds
*/
u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) {
u2f.getPortSingleton_(function(port) {
var reqId = ++u2f.reqCounter_;
u2f.callbackMap_[reqId] = callback;
var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ?
opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC);
var req = u2f.formatRegisterRequest_(
appId, registeredKeys, registerRequests, timeoutSeconds, reqId);
port.postMessage(req);
});
};
/**
* Dispatches a message to the extension to find out the supported
* JS API version.
* If the user is on a mobile phone and is thus using Google Authenticator instead
* of the Chrome extension, don't send the request and simply return 0.
* @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback
* @param {number=} opt_timeoutSeconds
*/
u2f.getApiVersion = function(callback, opt_timeoutSeconds) {
u2f.getPortSingleton_(function(port) {
// If we are using Android Google Authenticator or iOS client app,
// do not fire an intent to ask which JS API version to use.
if (port.getPortType) {
var apiVersion;
switch (port.getPortType()) {
case 'WrappedIosPort_':
case 'WrappedAuthenticatorPort_':
apiVersion = 1.1;
break;
default:
apiVersion = 0;
break;
}
callback({ 'js_api_version': apiVersion });
return;
}
var reqId = ++u2f.reqCounter_;
u2f.callbackMap_[reqId] = callback;
var req = {
type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST,
timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ?
opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC),
requestId: reqId
};
port.postMessage(req);
});
};

View File

@ -7,11 +7,16 @@
*/
namespace PhpMyAdmin\Plugins;
use PhpMyAdmin\Config;
use PhpMyAdmin\Core;
use PhpMyAdmin\IpAllowDeny;
use PhpMyAdmin\Logging;
use PhpMyAdmin\Message;
use PhpMyAdmin\Response;
use PhpMyAdmin\Sanitize;
use PhpMyAdmin\SecondFactor;
use PhpMyAdmin\Session;
use PhpMyAdmin\Template;
use PhpMyAdmin\Url;
/**
@ -297,4 +302,39 @@ abstract class AuthenticationPlugin
$this->showFailure('empty-denied');
}
}
/**
* Checks whether two factor authentication is active
* for given user and performs it.
*
* @return void
*/
public function checkSecondFactor()
{
$second = new SecondFactor($this->user);
/* Do we need to show the form? */
if ($second->check()) {
return;
}
$response = Response::getInstance();
if ($response->loginPage()) {
if (defined('TESTSUITE')) {
return true;
} else {
exit;
}
}
echo Template::get('login/header')->render(['theme' => $GLOBALS['PMA_Theme']]);
Message::rawNotice(
__('You have enabled two factor authentication, please confirm your login.')
)->display();
echo Template::get('login/second')->render(['form' => $second->render()]);
echo Template::get('login/footer')->render();
echo Config::renderFooter();
if (! defined('TESTSUITE')) {
exit;
}
}
}

View File

@ -0,0 +1,142 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Second authentication factor handling
*
* @package PhpMyAdmin
*/
namespace PhpMyAdmin\Plugins\SecondFactor;
use PhpMyAdmin\SecondFactor;
use PhpMyAdmin\Template;
use PhpMyAdmin\Plugins\SecondFactorPlugin;
use PragmaRX\Google2FA\Google2FA;
/**
* HOTP and TOTP based second factor
*
* Also known as Google, Authy, or OTP
*/
class Application extends SecondFactorPlugin
{
/**
* @var string
*/
public static $id = 'application';
protected $_google2fa;
/**
* Creates object
*
* @param SecondFactor $second SecondFactor instance
*/
public function __construct(SecondFactor $second)
{
parent::__construct($second);
$this->_google2fa = new Google2FA();
$this->_google2fa->setWindow(8);
if (!isset($this->_second->config['settings']['secret'])) {
$this->_second->config['settings']['secret'] = '';
}
}
/**
* Get any property of this class
*
* @param string $property name of the property
*
* @return mixed|void if property exist, value of the relevant property
*/
public function __get($property)
{
switch ($property) {
case 'google2fa':
return $this->_google2fa;
}
}
/**
* Checks authentication, returns true on success
*
* @return boolean
*/
public function check()
{
$this->_provided = false;
if (!isset($_POST['2fa_code'])) {
return false;
}
$this->_provided = true;
return $this->_google2fa->verifyKey(
$this->_second->config['settings']['secret'], $_POST['2fa_code']
);
}
/**
* Renders user interface to enter second factor
*
* @return string HTML code
*/
public function render()
{
return Template::get('login/second/application')->render();
}
/**
* Renders user interface to configure second factor
*
* @return string HTML code
*/
public function setup()
{
$inlineUrl = $this->_google2fa->getQRCodeInline(
'phpMyAdmin (' . $this->getAppId(false) . ')',
$this->_second->user,
$this->_second->config['settings']['secret']
);
return Template::get('login/second/application_configure')->render([
'image' => $inlineUrl,
]);
}
/**
* Performs backend configuration
*
* @return boolean
*/
public function configure()
{
if (! isset($_SESSION['2fa_application_key'])) {
$_SESSION['2fa_application_key'] = $this->_google2fa->generateSecretKey();
}
$this->_second->config['settings']['secret'] = $_SESSION['2fa_application_key'];
$result = $this->check();
if ($result) {
unset($_SESSION['2fa_application_key']);
}
return $result;
}
/**
* Get user visible name
*
* @return string
*/
public static function getName()
{
return __('Authentication application');
}
/**
* Get user visible description
*
* @return string
*/
public static function getDescription()
{
return __('Provides authentication using HOTP and TOTP applications such as FreeOTP, Google Authenticator or Authy.');
}
}

View File

@ -0,0 +1,196 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Second authentication factor handling
*
* @package PhpMyAdmin
*/
namespace PhpMyAdmin\Plugins\SecondFactor;
use PhpMyAdmin\Response;
use PhpMyAdmin\SecondFactor;
use PhpMyAdmin\Template;
use PhpMyAdmin\Plugins\SecondFactorPlugin;
use Samyoul\U2F\U2FServer\U2FServer;
use Samyoul\U2F\U2FServer\U2FException;
/**
* Hardware key based second factor
*
* Supports FIDO U2F tokens
*/
class Key extends SecondFactorPlugin
{
/**
* @var string
*/
public static $id = 'key';
/**
* Creates object
*
* @param SecondFactor $second SecondFactor instance
*/
public function __construct(SecondFactor $second)
{
parent::__construct($second);
if (!isset($this->_second->config['settings']['registrations'])) {
$this->_second->config['settings']['registrations'] = [];
}
}
/**
* Returns array of U2F registration objects
*
* @return array
*/
public function getRegistrations()
{
$result = [];
foreach ($this->_second->config['settings']['registrations'] as $index => $data) {
$reg = new \StdClass;
$reg->keyHandle = $data['keyHandle'];
$reg->publicKey = $data['publicKey'];
$reg->certificate = $data['certificate'];
$reg->counter = $data['counter'];
$reg->index = $index;
$result[] = $reg;
}
return $result;
}
/**
* Checks authentication, returns true on success
*
* @return boolean
*/
public function check()
{
$this->_provided = false;
if (!isset($_POST['u2f_authentication_response']) || !isset($_SESSION['authenticationRequest'])) {
return false;
}
$this->_provided = true;
try {
$response = json_decode($_POST['u2f_authentication_response']);
if (is_null($response)) {
return false;
}
$authentication = U2FServer::authenticate(
$_SESSION['authenticationRequest'],
$this->getRegistrations(),
$response
);
$this->_second->config['settings']['registrations'][$authentication->index]['counter'] = $authentication->counter;
$this->_second->save();
return true;
} catch (U2FException $e) {
$this->_message = $e->getMessage();
return false;
}
}
/**
* Loads needed javascripts into the page
*
* @return void
*/
public function loadScripts()
{
$response = Response::getInstance();
$scripts = $response->getHeader()->getScripts();
$scripts->addFile('vendor/u2f-api.js');
$scripts->addFile('u2f.js');
}
/**
* Renders user interface to enter second factor
*
* @return string HTML code
*/
public function render()
{
$request = U2FServer::makeAuthentication(
$this->getRegistrations(),
$this->getAppId(true)
);
$_SESSION['authenticationRequest'] = $request;
$this->loadScripts();
return Template::get('login/second/key')->render([
'request' => json_encode($request),
]);
}
/**
* Renders user interface to configure second factor
*
* @return string HTML code
*/
public function setup()
{
$registrationData = U2FServer::makeRegistration(
$this->getAppId(true),
$this->getRegistrations()
);
$_SESSION['registrationRequest'] = $registrationData['request'];
$this->loadScripts();
return Template::get('login/second/key_configure')->render([
'request' => json_encode($registrationData['request']),
'signatures' => json_encode($registrationData['signatures']),
]);
}
/**
* Performs backend configuration
*
* @return boolean
*/
public function configure()
{
$this->_provided = false;
if (! isset($_POST['u2f_registration_response']) || ! isset($_SESSION['registrationRequest'])) {
return false;
}
$this->_provided = true;
try {
$response = json_decode($_POST['u2f_registration_response']);
if (is_null($response)) {
return false;
}
$registration = U2FServer::register(
$_SESSION['registrationRequest'], $response
);
$this->_second->config['settings']['registrations'][] = [
'keyHandle' => $registration->getKeyHandle(),
'publicKey' => $registration->getPublicKey(),
'certificate' => $registration->getCertificate(),
'counter' => $registration->getCounter(),
];
return true;
} catch (U2FException $e) {
$this->_message = $e->getMessage();
return false;
}
}
/**
* Get user visible name
*
* @return string
*/
public static function getName()
{
return __('Security key');
}
/**
* Get user visible description
*
* @return string
*/
public static function getDescription()
{
return __('Provides authentication using hardware security tokens supporting FIDO U2F.');
}
}

View File

@ -0,0 +1,64 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Second authentication factor handling
*
* @package PhpMyAdmin
*/
namespace PhpMyAdmin\Plugins\SecondFactor;
use PhpMyAdmin\Plugins\SecondFactorPlugin;
use PhpMyAdmin\Template;
/**
* Simple second factor auth asking just for confirmation.
*
* This has no practical use, but can be used for testing.
*/
class Simple extends SecondFactorPlugin
{
/**
* @var string
*/
public static $id = 'simple';
/**
* Checks authentication, returns true on success
*
* @return boolean
*/
public function check()
{
return isset($_POST['2fa_confirm']);
}
/**
* Renders user interface to enter second factor
*
* @return string HTML code
*/
public function render()
{
return Template::get('login/second/simple')->render();
}
/**
* Get user visible name
*
* @return string
*/
public static function getName()
{
return __('Simple second factor');
}
/**
* Get user visible description
*
* @return string
*/
public static function getDescription()
{
return __('For testing purposes only!');
}
}

View File

@ -0,0 +1,165 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Second authentication factor handling
*
* @package PhpMyAdmin
*/
namespace PhpMyAdmin\Plugins;
use PhpMyAdmin\Core;
use PhpMyAdmin\Message;
use PhpMyAdmin\SecondFactor;
/**
* Second factor authentication plugin class
*
* This is basic implementation which does no
* additional authentication, subclasses are expected
* to implement this.
*/
class SecondFactorPlugin
{
/**
* @var string
*/
public static $id = '';
/**
* @var SecondFactor
*/
protected $_second;
/**
* @var boolean
*/
protected $_provided;
/**
* @var string
*/
protected $_message;
/**
* Creates object
*
* @param SecondFactor $second SecondFactor instance
*/
public function __construct(SecondFactor $second)
{
$this->_second = $second;
$this->_provided = false;
$this->_message = '';
}
/**
* Returns authentication error message
*
* @return string
*/
public function getError()
{
if ($this->_provided) {
if (!empty($this->_message)) {
return Message::rawError(
sprintf(__('Two-factor authentication failed: %s'), $this->_message)
)->getDisplay();
}
return Message::rawError(
__('Two-factor authentication failed.')
)->getDisplay();
}
return '';
}
/**
* Checks authentication, returns true on success
*
* @return boolean
*/
public function check()
{
return true;
}
/**
* Renders user interface to enter second factor
*
* @return string HTML code
*/
public function render()
{
return '';
}
/**
* Renders user interface to configure second factor
*
* @return string HTML code
*/
public function setup()
{
return '';
}
/**
* Performs backend configuration
*
* @return boolean
*/
public function configure()
{
return true;
}
/**
* Get user visible name
*
* @return string
*/
public static function getName()
{
return __('None two-factor');
}
/**
* Get user visible description
*
* @return string
*/
public static function getDescription()
{
return __('Login using password only.');
}
/**
* Return an applicaiton ID
*
* Either hostname or hostname with scheme.
*
* @param boolean $return_url Whether to generate URL
*
* @return string
*/
public function getAppId($return_url)
{
global $PMA_Config;
$url = $PMA_Config->get('PmaAbsoluteUri');
$parsed = [];
if (!empty($url)) {
$parsed = parse_url($url);
}
if (empty($parsed['scheme'])) {
$parsed['scheme'] = $PMA_Config->isHttps() ? 'https' : 'http';
}
if (empty($parsed['host'])) {
$parsed['host'] = Core::getenv('HTTP_HOST');
}
if ($return_url) {
return $parsed['scheme'] . '://' . $parsed['host'] . (!empty($parsed['port']) ? ':' . $parsed['port'] : '');
} else {
return $parsed['host'];
}
}
}

View File

@ -0,0 +1,248 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* Second authentication factor handling
*
* @package PhpMyAdmin
*/
namespace PhpMyAdmin;
use PhpMyAdmin\UserPreferences;
/**
* Second factor authentication wrapper class
*/
class SecondFactor
{
/**
* @var string
*/
public $user;
/**
* @var array
*/
public $config;
/**
* @var boolean
*/
protected $_writable;
/**
* @var PhpMyAdmin\Plugins\SecondFactorPlugin
*/
protected $_backend;
/**
* @var array
*/
protected $_available;
/**
* Creates new SecondFactor object
*
* @param string $user User name
*/
public function __construct($user)
{
$this->user = $user;
$this->_available = $this->getAvailable();
$this->config = $this->readConfig();
$this->_writable = ($this->config['type'] == 'db');
$this->_backend = $this->getBackend();
}
/**
* Reads the configuration
*
* @return array
*/
public function readConfig()
{
$result = [];
$config = UserPreferences::load();
if (isset($config['config_data']['2fa'])) {
$result = $config['config_data']['2fa'];
}
$result['type'] = $config['type'];
if (! isset($result['backend'])) {
$result['backend'] = '';
}
if (! isset($result['settings'])) {
$result['settings'] = [];
}
return $result;
}
/**
* Get any property of this class
*
* @param string $property name of the property
*
* @return mixed|void if property exist, value of the relevant property
*/
public function __get($property)
{
switch ($property) {
case 'backend':
return $this->_backend;
case 'available':
return $this->_available;
case 'writable':
return $this->_writable;
}
}
/**
* Returns list of available backends
*
* @return array
*/
public function getAvailable()
{
$result = [];
if ($GLOBALS['cfg']['DBG']['simple2fa']) {
$result[] = 'simple';
}
if (class_exists('PragmaRX\Google2FA\Google2FA') && class_exists('BaconQrCode\Renderer\Image\Png')) {
$result[] = 'application';
}
if (class_exists('Samyoul\U2F\U2FServer\U2FServer')) {
$result[] = 'key';
}
return $result;
}
/**
* Returns class name for given name
*
* @param string $name Backend name
*
* @return string
*/
public function getBackendClass($name)
{
$result = 'PhpMyAdmin\\Plugins\\SecondFactorPlugin';
if (in_array($name, $this->_available)) {
$result = 'PhpMyAdmin\\Plugins\\SecondFactor\\' . ucfirst($name);
}
return $result;
}
/**
* Returns backend for current user
*
* @return PhpMyAdmin\Plugins\SecondFactorPlugin
*/
public function getBackend()
{
$name = $this->getBackendClass($this->config['backend']);
return new $name($this);
}
/**
* Checks authentication, returns true on success
*
* @param boolean $skip_session Skip session cache
*
* @return boolean
*/
public function check($skip_session = false)
{
if ($skip_session) {
return $this->_backend->check();
}
if (empty($_SESSION['second_factor_check'])) {
$_SESSION['second_factor_check'] = $this->_backend->check();
}
return $_SESSION['second_factor_check'];
}
/**
* Renders user interface to enter second factor
*
* @return string HTML code
*/
public function render()
{
return $this->_backend->getError() . $this->_backend->render();
}
/**
* Renders user interface to configure second factor
*
* @return string HTML code
*/
public function setup()
{
return $this->_backend->getError() . $this->_backend->setup();
}
/**
* Saves current configuration.
*
* @return true|PhpMyAdmin\Message
*/
public function save()
{
return UserPreferences::persistOption('2fa', $this->config, null);
}
/**
* Changes second factor settings
*
* The object might stay in partialy changed setup
* if configuration fails.
*
* @param string $name Backend name
*
* @return boolean
*/
public function configure($name)
{
$this->config = [
'backend' => $name
];
if ($name === '') {
$cls = $this->getBackendClass($name);
$this->config['settings'] = [];
$this->_backend = new $cls($this);
} else {
if (! in_array($name, $this->_available)) {
return false;
}
$cls = $this->getBackendClass($name);
$this->config['settings'] = [];
$this->_backend = new $cls($this);
if (! $this->_backend->configure()) {
return false;
}
}
$result = $this->save();
if ($result !== true) {
$result->display();
}
return true;
}
/**
* Returns array with all available backends
*
* @return array
*/
public function getAllBackends()
{
$all = array_merge([''], $this->available);
$backends = [];
foreach ($all as $name) {
$cls = $this->getBackendClass($name);
$backends[] = [
'id' => $cls::$id,
'name' => $cls::getName(),
'description' => $cls::getDescription(),
];
}
return $backends;
}
}

View File

@ -173,6 +173,7 @@ class UserPreferences
$whitelist['collation_connection'] = true;
$whitelist['Server/hide_db'] = true;
$whitelist['Server/only_db'] = true;
$whitelist['2fa'] = true;
foreach ($config_data as $path => $value) {
if (! isset($whitelist[$path]) || isset($blacklist[$path])) {
continue;

View File

@ -544,6 +544,8 @@ if (! defined('PMA_MINIMUM_COMMON')) {
$auth_plugin->rememberCredentials();
$auth_plugin->checkSecondFactor();
/* Log success */
Logging::logUser($cfg['Server']['user']);

View File

@ -3058,6 +3058,13 @@ $cfg['DBG']['sqllog'] = false;
*/
$cfg['DBG']['demo'] = false;
/**
* Enable Simple second factor
*
* @global boolean $cfg['DBG']['simple2fa']
*/
$cfg['DBG']['simple2fa'] = false;
/*******************************************************************************
* MySQL settings

View File

@ -9,6 +9,7 @@ use PhpMyAdmin\Config\Forms\User\UserFormList;
use PhpMyAdmin\Message;
use PhpMyAdmin\Relation;
use PhpMyAdmin\Sanitize;
use PhpMyAdmin\SecondFactor;
if (!defined('PHPMYADMIN')) {
exit;
@ -30,6 +31,13 @@ $content = PhpMyAdmin\Util::getHtmlTab(
'text' => __('Manage your settings')
)
) . "\n";
/* Second authentication factor */
$content .= PhpMyAdmin\Util::getHtmlTab(
array(
'link' => 'prefs_second.php',
'text' => __('Two-factor authentication')
)
) . "\n";
$script_name = basename($GLOBALS['PMA_PHP_SELF']);
foreach (UserFormList::getAll() as $formset) {
$formset_class = UserFormList::get($formset);

51
prefs_second.php Normal file
View File

@ -0,0 +1,51 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* User preferences management page
*
* @package PhpMyAdmin
*/
use PhpMyAdmin\Message;
use PhpMyAdmin\SecondFactor;
use PhpMyAdmin\Template;
/**
* Gets some core libraries and displays a top message if required
*/
require_once 'libraries/common.inc.php';
require 'libraries/user_preferences.inc.php';
$second_factor = new SecondFactor($GLOBALS['cfg']['Server']['user']);
if (isset($_POST['2fa_remove'])) {
if (! $second_factor->check(true)) {
echo Template::get('prefs_second_confirm')->render([
'form' => $second_factor->render(),
]);
exit;
} else {
$second_factor->configure('');
Message::rawNotice(__('Two-factor authentication has been removed.'))->display();
}
} elseif (isset($_POST['2fa_configure'])) {
if (! $second_factor->configure($_POST['2fa_configure'])) {
echo Template::get('prefs_second_configure')->render([
'form' => $second_factor->setup(),
'configure' => $_POST['2fa_configure'],
]);
exit;
} else {
Message::rawNotice(__('Two-factor authentication has been configured.'))->display();
}
}
$backend = $second_factor->backend;
echo Template::get('prefs_second')->render([
'enabled' => $second_factor->writable,
'num_backends' => count($second_factor->available),
'backend_id' => $backend::$id,
'backend_name' => $backend::getName(),
'backend_description' => $backend::getDescription(),
'backends' => $second_factor->getAllBackends(),
]);

View File

@ -228,7 +228,7 @@ if [ ! -d libraries/tcpdf ] ; then
# suggested package. Let's require it and then revert
# composer.json to original state.
cp composer.json composer.json.backup
composer require --update-no-dev tecnickcom/tcpdf
composer require --update-no-dev tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server
mv composer.json.backup composer.json
echo "* Cleanup of composer packages"
rm -rf \

View File

@ -0,0 +1,5 @@
<form method="POST" class="disableAjax">
{{ Url_getHiddenInputs() }}
{{ form|raw }}
<input type="submit" value="{% trans "Verify" %}" />
</form>

View File

@ -0,0 +1,4 @@
<p>
<label>{% trans "Authentication code:" %} <input type="text" name="2fa_code" /></label>
</p>
<p>{% trans "Open the two-factor authentication app on your device to view your authentication code and verify your identity." %}</p>

View File

@ -0,0 +1,10 @@
{{ Url_getHiddenInputs() }}
<p>
{% trans "Please scan following QR code into the two-factor authentication app on your device and enter authentication code it generates." %}
</p>
<p>
<img src="{{ image }}" />
</p>
<p>
<label>{% trans "Authentication code:" %} <input type="text" name="2fa_code" /></label>
</p>

View File

@ -0,0 +1,4 @@
<p>
{% trans "Please connect your FIDO U2F device into your computer's USB port. Then confirm login on the device." %}
</p>
<input id="u2f_authentication_response" name="u2f_authentication_response" value="" type="hidden" data-request="{{ request }}"/>

View File

@ -0,0 +1,4 @@
<p>
{% trans "Please connect your FIDO U2F device into your computer's USB port. Then confirm registration on the device." %}
</p>
<input id="u2f_registration_response" name="u2f_registration_response" value="" type="hidden" data-request="{{ request }}" data-signatures="{{ signatures }}"/>

View File

@ -0,0 +1 @@
<input type="hidden" name="2fa_confirm" value="1" />

View File

@ -0,0 +1,53 @@
<div class="group">
<h2>
{% trans "Two-factor authentication status" %}
{{ Util_showDocu('second_factor') }}
</h2>
<div class="group-cnt">
{% if enabled %}
{% if num_backends == 0 %}
<p>{% trans "Two-factor authentication is not available, please install optional dependencies to enable authentication backends." %}</p>
{% else %}
{% if backend_id %}
<p>{% trans "Two-factor authentication is available and configured for this account." %}</p>
{% else %}
<p>{% trans "Two-factor authentication is available, but not configured for this account." %}</p>
{% endif %}
{% endif %}
{% else %}
<p>{% trans "Two-factor authentication is not available, enable phpMyAdmin configuration storage to use it." %}</p>
{% endif %}
</div>
</div>
{% if backend_id %}
<div class="group">
<h2>{{ backend_name }}</h2>
<div class="group-cnt">
<p>{% trans "You have enabled two factor authentication." %}</p>
<p>{{ backend_description }}</p>
<form method="POST" action="prefs_second.php">
{{ Url_getHiddenInputs() }}
<input type="submit" name="2fa_remove" value="{% trans "Disable two-factor authentication" %}" />
</form>
</div>
</div>
{% else %}
<div class="group">
<h2>{% trans "Configure two-factor authentication" %}</h2>
<div class="group-cnt">
<form method="POST" action="prefs_second.php">
{{ Url_getHiddenInputs() }}
<option name="2fa_configure">
{% for backend in backends %}
<label>
<input type="radio" name="2fa_configure" {% if backend["id"] == "" %}checked="checked"{% endif %} value="{{ backend["id"] }}"/>
<strong>{{ backend["name"] }}</strong>
<p>{{ backend["description"] }}</p>
</label>
{% endfor %}
<input type="submit" value="{% trans "Configure two-factor authentication" %}" />
</form>
</div>
</div>
{% endif %}

View File

@ -0,0 +1,13 @@
<div class="group">
<h2>{% trans "Configure two-factor authentication" %}</h2>
<div class="group-cnt">
<form method="POST" action="prefs_second.php">
{{ Url_getHiddenInputs() }}
<input type="hidden" name="2fa_configure" value="{{ configure }}" />
{{ form|raw }}
<input type="submit" value="{% trans "Enable two-factor authentication" %}" />
</form>
</div>
</div>

View File

@ -0,0 +1,12 @@
<div class="group">
<h2>{% trans "Confirm disabling two-factor authentication" %}</h2>
<div class="group-cnt">
<form method="POST" action="prefs_second.php">
{{ Message_notice("By disabling two factor authentication you will be again able to login using password only."|trans) }}
{{ Url_getHiddenInputs() }}
{{ form|raw }}
<input type="hidden" name="2fa_remove" value="1" />
<input type="submit" value="{% trans "Disable two-factor authentication" %}" />
</form>
</div>
</div>

View File

@ -2,3 +2,5 @@
./test/install-runkit
composer install --no-interaction
# Install optional deps
composer require tecnickcom/tcpdf pragmarx/google2fa bacon/bacon-qr-code samyoul/u2f-php-server

View File

@ -0,0 +1,256 @@
<?php
/* vim: set expandtab sw=4 ts=4 sts=4: */
/**
* tests for SecondFactor class
*
* @package PhpMyAdmin-test
*/
namespace PhpMyAdmin\Tests;
use PhpMyAdmin\SecondFactor;
use Samyoul\U2F\U2FServer\RegistrationRequest;
use Samyoul\U2F\U2FServer\SignRequest;
/**
* Tests behaviour of SecondFactor class
*
* @package PhpMyAdmin-test
*/
class SecondFactorTest extends PmaTestCase
{
public function setUp()
{
$GLOBALS['server'] = 1;
}
/**
* Creates SecondFactor mock with custom configuration
*
* @param string $user Username
* @param array $config Second factor authentication configuraiton
*
* @return SecondFactor
*/
public function getSecondFactorMock($user, $config)
{
if (! isset($config['backend'])) {
$config['backend'] = '';
}
if (! isset($config['settings'])) {
$config['settings'] = [];
}
$result = $this->getMockbuilder('PhpMyAdmin\SecondFactor')
->setMethods(['readConfig'])
->disableOriginalConstructor()
->getMock();
$result->method('readConfig')->willReturn($config);
$result->__construct($user);
return $result;
}
public function testNone()
{
$object = $this->getSecondFactorMock('user', ['type' => 'db']);
$backend = $object->backend;
$this->assertEquals('', $backend::$id);
// Is always valid
$this->assertTrue($object->check(true));
// Test session persistence
$this->assertTrue($object->check());
$this->assertTrue($object->check());
$this->assertEquals('', $object->render());
$this->assertTrue($object->configure(''));
$this->assertEquals('', $object->setup());
}
public function testSimple()
{
$GLOBALS['cfg']['DBG']['simple2fa'] = true;
$object = $this->getSecondFactorMock('user', ['type' => 'db', 'backend' => 'simple']);
$backend = $object->backend;
$this->assertEquals('simple', $backend::$id);
$GLOBALS['cfg']['DBG']['simple2fa'] = false;
unset($_POST['2fa_confirm']);
$this->assertFalse($object->check(true));
$_POST['2fa_confirm'] = 1;
$this->assertTrue($object->check(true));
unset($_POST['2fa_confirm']);
/* Test rendering */
$this->assertNotEquals('', $object->render());
$this->assertEquals('', $object->setup());
}
public function testLoad()
{
$object = new SecondFactor('user');
$backend = $object->backend;
$this->assertEquals('', $backend::$id);
}
public function testConfigureSimple()
{
$GLOBALS['cfg']['DBG']['simple2fa'] = true;
$object = new SecondFactor('user');
$this->assertTrue($object->configure('simple'));
$backend = $object->backend;
$this->assertEquals('simple', $backend::$id);
$this->assertTrue($object->configure(''));
$backend = $object->backend;
$this->assertEquals('', $backend::$id);
$GLOBALS['cfg']['DBG']['simple2fa'] = false;
$object = new SecondFactor('user');
$this->assertFalse($object->configure('simple'));
}
public function testApplication()
{
$object = new SecondFactor('user');
if (! in_array('application', $object->available)) {
$this->markTestSkipped('google2fa not available');
}
/* Without providing code this should fail */
unset($_POST['2fa_code']);
$this->assertFalse($object->configure('application'));
/* Invalid code */
$_POST['2fa_code'] = 'invalid';
$this->assertFalse($object->configure('application'));
/* Generate valid code */
$google2fa = $object->backend->google2fa;
$_POST['2fa_code'] = $google2fa->oathHotp(
$object->config['settings']['secret'],
$google2fa->getTimestamp()
);
$this->assertTrue($object->configure('application'));
unset($_POST['2fa_code']);
/* Check code */
unset($_POST['2fa_code']);
$this->assertFalse($object->check(true));
$_POST['2fa_code'] = 'invalid';
$this->assertFalse($object->check(true));
$_POST['2fa_code'] = $google2fa->oathHotp(
$object->config['settings']['secret'],
$google2fa->getTimestamp()
);
$this->assertTrue($object->check(true));
unset($_POST['2fa_code']);
/* Test rendering */
$this->assertNotEquals('', $object->render());
$this->assertNotEquals('', $object->setup());
}
public function testKey()
{
$object = new SecondFactor('user');
if (! in_array('key', $object->available)) {
$this->markTestSkipped('u2f-php-server not available');
}
$_SESSION['registrationRequest'] = null;
/* Without providing code this should fail */
unset($_POST['u2f_registration_response']);
$this->assertFalse($object->configure('key'));
/* Invalid code */
$_POST['u2f_registration_response'] = 'invalid';
$this->assertFalse($object->configure('key'));
/* Invalid code */
$_POST['u2f_registration_response'] = '[]';
$this->assertFalse($object->configure('key'));
/* Without providing code this should fail */
unset($_POST['u2f_authentication_response']);
$this->assertFalse($object->check(true));
/* Invalid code */
$_POST['u2f_authentication_response'] = 'invalid';
$this->assertFalse($object->check(true));
/* Invalid code */
$_POST['u2f_authentication_response'] = '[]';
$this->assertFalse($object->check(true));
/* Test rendering */
$this->assertNotEquals('', $object->render());
$this->assertNotEquals('', $object->setup());
}
/**
* Test getting AppId
*/
public function testKeyAppId()
{
$object = new SecondFactor('user');
$GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'http://demo.example.com');
$this->assertEquals('http://demo.example.com', $object->backend->getAppId(true));
$this->assertEquals('demo.example.com', $object->backend->getAppId(false));
$GLOBALS['PMA_Config']->set('PmaAbsoluteUri', 'https://demo.example.com:123');
$this->assertEquals('https://demo.example.com:123', $object->backend->getAppId(true));
$this->assertEquals('demo.example.com', $object->backend->getAppId(false));
$GLOBALS['PMA_Config']->set('PmaAbsoluteUri', '');
$GLOBALS['PMA_Config']->set('is_https', true);
$_SERVER['HTTP_HOST'] = 'pma.example.com';
$this->assertEquals('https://pma.example.com', $object->backend->getAppId(true));
$this->assertEquals('pma.example.com', $object->backend->getAppId(false));
$GLOBALS['PMA_Config']->set('is_https', false);
$this->assertEquals('http://pma.example.com', $object->backend->getAppId(true));
$this->assertEquals('pma.example.com', $object->backend->getAppId(false));
}
/**
* Test based on upstream test data:
* https://github.com/Yubico/php-u2flib-server
*/
public function testKeyAuthentication()
{
$object = new SecondFactor('user');
if (! in_array('key', $object->available)) {
$this->markTestSkipped('u2f-php-server not available');
}
$_SESSION['registrationRequest'] = new RegistrationRequest('yKA0x075tjJ-GE7fKTfnzTOSaNUOWQxRd9TWz5aFOg8', 'http://demo.example.com');
unset($_POST['u2f_registration_response']);
$this->assertFalse($object->configure('key'));
$_POST['u2f_registration_response'] = '';
$this->assertFalse($object->configure('key'));
$_POST['u2f_registration_response'] = '{ "registrationData": "BQQtEmhWVgvbh-8GpjsHbj_d5FB9iNoRL8mNEq34-ANufKWUpVdIj6BSB_m3eMoZ3GqnaDy3RA5eWP8mhTkT1Ht3QAk1GsmaPIQgXgvrBkCQoQtMFvmwYPfW5jpRgoMPFxquHS7MTt8lofZkWAK2caHD-YQQdaRBgd22yWIjPuWnHOcwggLiMIHLAgEBMA0GCSqGSIb3DQEBCwUAMB0xGzAZBgNVBAMTEll1YmljbyBVMkYgVGVzdCBDQTAeFw0xNDA1MTUxMjU4NTRaFw0xNDA2MTQxMjU4NTRaMB0xGzAZBgNVBAMTEll1YmljbyBVMkYgVGVzdCBFRTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNsK2_Uhx1zOY9ym4eglBg2U5idUGU-dJK8mGr6tmUQflaNxkQo6IOc-kV4T6L44BXrVeqN-dpCPr-KKlLYw650wDQYJKoZIhvcNAQELBQADggIBAJVAa1Bhfa2Eo7TriA_jMA8togoA2SUE7nL6Z99YUQ8LRwKcPkEpSpOsKYWJLaR6gTIoV3EB76hCiBaWN5HV3-CPyTyNsM2JcILsedPGeHMpMuWrbL1Wn9VFkc7B3Y1k3OmcH1480q9RpYIYr-A35zKedgV3AnvmJKAxVhv9GcVx0_CewHMFTryFuFOe78W8nFajutknarupekDXR4tVcmvj_ihJcST0j_Qggeo4_3wKT98CgjmBgjvKCd3Kqg8n9aSDVWyaOZsVOhZj3Fv5rFu895--D4qiPDETozJIyliH-HugoQpqYJaTX10mnmMdCa6aQeW9CEf-5QmbIP0S4uZAf7pKYTNmDQ5z27DVopqaFw00MIVqQkae_zSPX4dsNeeoTTXrwUGqitLaGap5ol81LKD9JdP3nSUYLfq0vLsHNDyNgb306TfbOenRRVsgQS8tJyLcknSKktWD_Qn7E5vjOXprXPrmdp7g5OPvrbz9QkWa1JTRfo2n2AXV02LPFc-UfR9bWCBEIJBxvmbpmqt0MnBTHWnth2b0CU_KJTDCY3kAPLGbOT8A4KiI73pRW-e9SWTaQXskw3Ei_dHRILM_l9OXsqoYHJ4Dd3tbfvmjoNYggSw4j50l3unI9d1qR5xlBFpW5sLr8gKX4bnY4SR2nyNiOQNLyPc0B0nW502aMEUCIQDTGOX-i_QrffJDY8XvKbPwMuBVrOSO-ayvTnWs_WSuDQIgZ7fMAvD_Ezyy5jg6fQeuOkoJi8V2naCtzV-HTly8Nww=", "clientData": "eyAiY2hhbGxlbmdlIjogInlLQTB4MDc1dGpKLUdFN2ZLVGZuelRPU2FOVU9XUXhSZDlUV3o1YUZPZzgiLCAib3JpZ2luIjogImh0dHA6XC9cL2RlbW8uZXhhbXBsZS5jb20iLCAidHlwIjogIm5hdmlnYXRvci5pZC5maW5pc2hFbnJvbGxtZW50IiB9", "errorCode": 0 }';
$this->assertTrue($object->configure('key'));
unset($_POST['u2f_authentication_response']);
$this->assertFalse($object->check(true));
$_POST['u2f_authentication_response'] = '';
$this->assertFalse($object->check(true));
$_SESSION['authenticationRequest'] = [new SignRequest([
'challenge' => 'fEnc9oV79EaBgK5BoNERU5gPKM2XGYWrz4fUjgc0Q7g',
'keyHandle' => 'CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w',
'appId' => 'http://demo.example.com'
])];
$this->assertFalse($object->check(true));
$_POST['u2f_authentication_response'] = '{ "signatureData": "AQAAAAQwRQIhAI6FSrMD3KUUtkpiP0jpIEakql-HNhwWFngyw553pS1CAiAKLjACPOhxzZXuZsVO8im-HStEcYGC50PKhsGp_SUAng==", "clientData": "eyAiY2hhbGxlbmdlIjogImZFbmM5b1Y3OUVhQmdLNUJvTkVSVTVnUEtNMlhHWVdyejRmVWpnYzBRN2ciLCAib3JpZ2luIjogImh0dHA6XC9cL2RlbW8uZXhhbXBsZS5jb20iLCAidHlwIjogIm5hdmlnYXRvci5pZC5nZXRBc3NlcnRpb24iIH0=", "keyHandle": "CTUayZo8hCBeC-sGQJChC0wW-bBg99bmOlGCgw8XGq4dLsxO3yWh9mRYArZxocP5hBB1pEGB3bbJYiM-5acc5w", "errorCode": 0 }';
$this->assertTrue($object->check(true));
}
/**
* Test listing of available backends.
*/
public function testBackends()
{
$GLOBALS['cfg']['DBG']['simple2fa'] = true;
$object = new SecondFactor('user');
$backends = $object->getAllBackends();
$this->assertEquals(
count($object->available) + 1,
count($backends)
);
$GLOBALS['cfg']['DBG']['simple2fa'] = false;
}
}