Commit fc2083ed authored by agata's avatar agata

[auto] plugin: two-factor 0.6.0

parent 909b23ad
<?php
/**
* A compatability layer for some of the most popular plugins.
* A compatibility layer for some of the most popular plugins.
*
* Should be used with care because ideally we wouldn't need
* any integration specific code for this plugin. Everything should
......
......@@ -27,7 +27,29 @@ class Two_Factor_Core {
*
* @type string
*/
const USER_META_NONCE_KEY = '_two_factor_nonce';
const USER_META_NONCE_KEY = '_two_factor_nonce';
/**
* URL query paramater used for our custom actions.
*
* @var string
*/
const USER_SETTINGS_ACTION_QUERY_VAR = 'two_factor_action';
/**
* Nonce key for user settings.
*
* @var string
*/
const USER_SETTINGS_ACTION_NONCE_QUERY_ARG = '_two_factor_action_nonce';
/**
* Keep track of all the password-based authentication sessions that
* need to invalidated before the second factor authentication.
*
* @var array
*/
private static $password_auth_tokens = array();
/**
* Set up filters and actions.
......@@ -48,9 +70,21 @@ class Two_Factor_Core {
add_filter( 'wpmu_users_columns', array( __CLASS__, 'filter_manage_users_columns' ) );
add_filter( 'manage_users_custom_column', array( __CLASS__, 'manage_users_custom_column' ), 10, 3 );
/**
* Keep track of all the user sessions for which we need to invalidate the
* authentication cookies set during the initial password check.
*
* Is there a better way of doing this?
*/
add_action( 'set_auth_cookie', array( __CLASS__, 'collect_auth_cookie_tokens' ) );
add_action( 'set_logged_in_cookie', array( __CLASS__, 'collect_auth_cookie_tokens' ) );
// Run only after the core wp_authenticate_username_password() check.
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 50 );
add_action( 'admin_init', array( __CLASS__, 'trigger_user_settings_action' ) );
add_filter( 'two_factor_providers', array( __CLASS__, 'enable_dummy_method_for_debug' ) );
$compat->init();
}
......@@ -121,6 +155,149 @@ class Two_Factor_Core {
return $providers;
}
/**
* Enable the dummy method only during debugging.
*
* @param array $methods List of enabled methods.
*
* @return array
*/
public static function enable_dummy_method_for_debug( $methods ) {
if ( ! self::is_wp_debug() ) {
unset( $methods['Two_Factor_Dummy'] );
}
return $methods;
}
/**
* Check if the debug mode is enabled.
*
* @return boolean
*/
protected static function is_wp_debug() {
return ( defined( 'WP_DEBUG' ) && WP_DEBUG );
}
/**
* Get the user settings page URL.
*
* Fetch this from the plugin core after we introduce proper dependency injection
* and get away from the singletons at the provider level (should be handled by core).
*
* @param integer $user_id User ID.
*
* @return string
*/
protected static function get_user_settings_page_url( $user_id ) {
$page = 'user-edit.php';
if ( defined( 'IS_PROFILE_PAGE' ) && IS_PROFILE_PAGE ) {
$page = 'profile.php';
}
return add_query_arg(
array(
'user_id' => intval( $user_id ),
),
self_admin_url( $page )
);
}
/**
* Get the URL for resetting the secret token.
*
* @param integer $user_id User ID.
* @param string $action Custom two factor action key.
*
* @return string
*/
public static function get_user_update_action_url( $user_id, $action ) {
return wp_nonce_url(
add_query_arg(
array(
self::USER_SETTINGS_ACTION_QUERY_VAR => $action,
),
self::get_user_settings_page_url( $user_id )
),
sprintf( '%d-%s', $user_id, $action ),
self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG
);
}
/**
* Check if a user action is valid.
*
* @param integer $user_id User ID.
* @param string $action User action ID.
*
* @return boolean
*/
public static function is_valid_user_action( $user_id, $action ) {
$request_nonce = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_NONCE_QUERY_ARG, FILTER_SANITIZE_STRING );
return wp_verify_nonce(
$request_nonce,
sprintf( '%d-%s', $user_id, $action )
);
}
/**
* Get the ID of the user being edited.
*
* @return integer
*/
public static function current_user_being_edited() {
// Try to resolve the user ID from the request first.
if ( ! empty( $_REQUEST['user_id'] ) ) {
$user_id = intval( $_REQUEST['user_id'] );
if ( current_user_can( 'edit_user', $user_id ) ) {
return $user_id;
}
}
return get_current_user_id();
}
/**
* Trigger our custom update action if a valid
* action request is detected and passes the nonce check.
*
* @return void
*/
public static function trigger_user_settings_action() {
$action = filter_input( INPUT_GET, self::USER_SETTINGS_ACTION_QUERY_VAR, FILTER_SANITIZE_STRING );
$user_id = self::current_user_being_edited();
if ( ! empty( $action ) && self::is_valid_user_action( $user_id, $action ) ) {
/**
* This action is triggered when a valid Two Factor settings
* action is detected and it passes the nonce validation.
*
* @param integer $user_id User ID.
* @param string $action Settings action.
*/
do_action( 'two_factor_user_settings_action', $user_id, $action );
}
}
/**
* Keep track of all the authentication cookies that need to be
* invalidated before the second factor authentication.
*
* @param string $cookie Cookie string.
*
* @return void
*/
public static function collect_auth_cookie_tokens( $cookie ) {
$parsed = wp_parse_auth_cookie( $cookie );
if ( ! empty( $parsed['token'] ) ) {
self::$password_auth_tokens[] = $parsed['token'];
}
}
/**
* Get all Two-Factor Auth providers that are enabled for the specified|current user.
*
......@@ -139,7 +316,13 @@ class Two_Factor_Core {
}
$enabled_providers = array_intersect( $enabled_providers, array_keys( $providers ) );
return $enabled_providers;
/**
* Filter the enabled two-factor authentication providers for this user.
*
* @param array $enabled_providers The enabled providers.
* @param int $user_id The user ID.
*/
return apply_filters( 'two_factor_enabled_providers_for_user', $enabled_providers, $user->ID );
}
/**
......@@ -237,12 +420,35 @@ class Two_Factor_Core {
return;
}
// Invalidate the current login session to prevent from being re-used.
self::destroy_current_session_for_user( $user );
// Also clear the cookies which are no longer valid.
wp_clear_auth_cookie();
self::show_two_factor_login( $user );
exit;
}
/**
* Destroy the known password-based authentication sessions for the current user.
*
* Is there a better way of finding the current session token without
* having access to the authentication cookies which are just being set
* on the first password-based authentication request.
*
* @param \WP_User $user User object.
*
* @return void
*/
public static function destroy_current_session_for_user( $user ) {
$session_manager = WP_Session_Tokens::get_instance( $user->ID );
foreach ( self::$password_auth_tokens as $auth_token ) {
$session_manager->destroy( $auth_token );
}
}
/**
* Prevent login through XML-RPC and REST API for users with at least one
* two-factor method enabled.
......@@ -646,6 +852,8 @@ class Two_Factor_Core {
wp_set_auth_cookie( $user->ID, $rememberme );
do_action( 'two_factor_user_authenticated', $user );
// Must be global because that's how login_header() uses it.
global $interim_login;
$interim_login = isset( $_REQUEST['interim-login'] ); // WPCS: override ok.
......@@ -824,4 +1032,3 @@ class Two_Factor_Core {
return (bool) apply_filters( 'two_factor_rememberme', $rememberme );
}
}
......@@ -288,7 +288,7 @@ u2f.WrappedChromeRuntimePort_ = function(port) {
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
// Adapt request to the 1.0 JS API.
var signRequests = [];
for (var i = 0; i < registeredKeys.length; i++) {
signRequests[i] = {
......@@ -305,7 +305,7 @@ u2f.formatSignRequest_ =
requestId: reqId
};
}
// JS 1.1 API
// JS 1.1 API.
return {
type: u2f.MessageTypes.U2F_SIGN_REQUEST,
appId: appId,
......@@ -327,7 +327,7 @@ u2f.formatSignRequest_ =
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
// Adapt request to the 1.0 JS API.
for (var i = 0; i < registerRequests.length; i++) {
registerRequests[i].appId = appId;
}
......@@ -348,7 +348,7 @@ u2f.formatRegisterRequest_ =
requestId: reqId
};
}
// JS 1.1 API
// JS 1.1 API.
return {
type: u2f.MessageTypes.U2F_REGISTER_REQUEST,
appId: appId,
......@@ -380,7 +380,7 @@ u2f.WrappedChromeRuntimePort_.prototype.addEventListener =
var name = eventName.toLowerCase();
if (name == 'message' || name == 'onmessage') {
this.port_.onMessage.addListener(function(message) {
// Emulate a minimal MessageEvent object
// Emulate a minimal MessageEvent object.
handler({'data': message});
});
} else {
......
......@@ -164,7 +164,7 @@ class U2F
$offs = 1;
$pubKey = substr($rawReg, $offs, PUBKEY_LEN);
$offs += PUBKEY_LEN;
// decode the pubKey to make sure it's good
// Decode the pubKey to make sure it's good.
$tmpKey = $this->pubkey_to_pem($pubKey);
if($tmpKey === null) {
throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
......@@ -175,7 +175,7 @@ class U2F
$offs += $khLen;
$registration->keyHandle = $this->base64u_encode($kh);
// length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
// length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes).
$certLen = 4;
$certLen += ($regData[$offs + 2] << 8);
$certLen += $regData[$offs + 3];
......
......@@ -14,7 +14,7 @@
function login_header( $title = 'Log In', $message = '', $wp_error = null ) {
global $error, $interim_login, $action;
// Don't index any of these forms
// Don't index any of these forms.
add_action( 'login_head', 'wp_no_robots' );
add_action( 'login_head', 'wp_login_viewport_meta' );
......@@ -179,7 +179,7 @@ function login_header( $title = 'Log In', $message = '', $wp_error = null ) {
if ( !empty( $message ) )
echo $message . "\n";
// In case a plugin uses $error rather than the $wp_errors object
// In case a plugin uses $error rather than the $wp_errors object.
if ( !empty( $error ) ) {
$wp_error->add('error', $error);
unset($error);
......@@ -218,7 +218,7 @@ function login_header( $title = 'Log In', $message = '', $wp_error = null ) {
echo '<p class="message">' . apply_filters( 'login_messages', $messages ) . "</p>\n";
}
}
} // End of login_header()
} // End of login_header().
function wp_login_viewport_meta() {
?>
......
......@@ -163,7 +163,7 @@ class Two_Factor_Backup_Codes extends Two_Factor_Provider {
// Update counter.
$( '.two-factor-backup-codes-count' ).html( response.data.i18n.count );
// Build the download link
// Build the download link.
var txt_data = 'data:application/text;charset=utf-8,' + '\n';
txt_data += response.data.i18n.title.replace( /%s/g, document.domain ) + '\n\n';
......
......@@ -11,10 +11,17 @@ class Two_Factor_Email extends Two_Factor_Provider {
/**
* The user meta token key.
*
* @type string
* @var string
*/
const TOKEN_META_KEY = '_two_factor_email_token';
/**
* Store the timestamp when the token was generated.
*
* @var string
*/
const TOKEN_META_KEY_TIMESTAMP = '_two_factor_email_token_timestamp';
/**
* Name of the input field used for code resend.
*
......@@ -65,7 +72,10 @@ class Two_Factor_Email extends Two_Factor_Provider {
*/
public function generate_token( $user_id ) {
$token = $this->get_code();
update_user_meta( $user_id, self::TOKEN_META_KEY_TIMESTAMP, time() );
update_user_meta( $user_id, self::TOKEN_META_KEY, wp_hash( $token ) );
return $token;
}
......@@ -80,9 +90,65 @@ class Two_Factor_Email extends Two_Factor_Provider {
if ( ! empty( $hashed_token ) ) {
return true;
} else {
}
return false;
}
/**
* Has the user token validity timestamp expired.
*
* @param integer $user_id User ID.
*
* @return boolean
*/
public function user_token_has_expired( $user_id ) {
$token_lifetime = $this->user_token_lifetime( $user_id );
$token_ttl = $this->user_token_ttl( $user_id );
// Invalid token lifetime is considered an expired token.
if ( is_int( $token_lifetime ) && $token_lifetime <= $token_ttl ) {
return false;
}
return true;
}
/**
* Get the lifetime of a user token in seconds.
*
* @param integer $user_id User ID.
*
* @return integer|null Return `null` if the lifetime can't be measured.
*/
public function user_token_lifetime( $user_id ) {
$timestamp = intval( get_user_meta( $user_id, self::TOKEN_META_KEY_TIMESTAMP, true ) );
if ( ! empty( $timestamp ) ) {
return time() - $timestamp;
}
return null;
}
/**
* Return the token time-to-live for a user.
*
* @param integer $user_id User ID.
*
* @return integer
*/
public function user_token_ttl( $user_id ) {
$token_ttl = 15 * MINUTE_IN_SECONDS;
/**
* Number of seconds the token is considered valid
* after the generation.
*
* @param integer $token_ttl Token time-to-live in seconds.
* @param integer $user_id User ID.
*/
return (int) apply_filters( 'two_factor_token_ttl', $token_ttl, $user_id );
}
/**
......@@ -119,7 +185,11 @@ class Two_Factor_Email extends Two_Factor_Provider {
return false;
}
// Ensure that the token can't be re-used.
if ( $this->user_token_has_expired( $user_id ) ) {
return false;
}
// Ensure the token can be used only once.
$this->delete_token( $user_id );
return true;
......@@ -152,6 +222,23 @@ class Two_Factor_Email extends Two_Factor_Provider {
/* translators: %s: token */
$message = wp_strip_all_tags( sprintf( __( 'Enter %s to log in.', 'two-factor' ), $token ) );
/**
* Filter the token email subject.
*
* @param string $subject The email subject line.
* @param int $user_id The ID of the user.
*/
$subject = apply_filters( 'two_factor_token_email_subject', $subject, $user->ID );
/**
* Filter the token email message.
*
* @param string $message The email message.
* @param string $token The token.
* @param int $user_id The ID of the user.
*/
$message = apply_filters( 'two_factor_token_email_message', $message, $token, $user->ID );
return wp_mail( $user->user_email, $subject, $message );
}
......@@ -167,7 +254,7 @@ class Two_Factor_Email extends Two_Factor_Provider {
return;
}
if ( ! $this->user_has_token( $user->ID ) ) {
if ( ! $this->user_has_token( $user->ID ) || $this->user_token_has_expired( $user->ID ) ) {
$this->generate_and_email_token( $user );
}
......
......@@ -115,8 +115,6 @@ class Two_Factor_FIDO_U2F_Admin_List_Table extends WP_List_Table {
<td colspan="<?php echo esc_attr( $this->get_column_count() ); ?>" class="colspanchange">
<fieldset>
<div class="inline-edit-col">
<h4><?php esc_html_e( 'Quick Edit', 'two-factor' ); ?></h4>
<label>
<span class="title"><?php esc_html_e( 'Name', 'two-factor' ); ?></span>
<span class="input-text-wrap"><input type="text" name="name" class="ptitle" value="" /></span>
......
......@@ -48,10 +48,14 @@ class Two_Factor_FIDO_U2F_Admin {
return;
}
$user_id = get_current_user_id();
$user_id = Two_Factor_Core::current_user_being_edited();
if ( ! $user_id ) {
return;
}
$security_keys = Two_Factor_FIDO_U2F::get_security_keys( $user_id );
// @todo Ensure that scripts don't fail because of missing u2fL10n
// @todo Ensure that scripts don't fail because of missing u2fL10n.
try {
$data = Two_Factor_FIDO_U2F::$u2f->getRegisterData( $security_keys );
list( $req,$sigs ) = $data;
......@@ -81,6 +85,7 @@ class Two_Factor_FIDO_U2F_Admin {
*/
$translation_array = array(
'user_id' => $user_id,
'register' => array(
'request' => $req,
'sigs' => $sigs,
......@@ -255,9 +260,11 @@ class Two_Factor_FIDO_U2F_Admin {
* @static
*/
public static function catch_delete_security_key() {
$user_id = get_current_user_id();
if ( ! empty( $_REQUEST['delete_security_key'] ) ) {
$user_id = Two_Factor_Core::current_user_being_edited();
if ( ! empty( $user_id ) && ! empty( $_REQUEST['delete_security_key'] ) ) {
$slug = $_REQUEST['delete_security_key'];
check_admin_referer( "delete_security_key-{$slug}", '_nonce_delete_security_key' );
Two_Factor_FIDO_U2F::delete_security_key( $user_id, $slug );
......@@ -316,8 +323,7 @@ class Two_Factor_FIDO_U2F_Admin {
wp_die();
}
$user_id = get_current_user_id();
$user_id = Two_Factor_Core::current_user_being_edited();
$security_keys = Two_Factor_FIDO_U2F::get_security_keys( $user_id );
if ( ! $security_keys ) {
wp_die();
......
......@@ -34,7 +34,7 @@ class Two_Factor_FIDO_U2F extends Two_Factor_Provider {
*
* @var string
*/
const U2F_ASSET_VERSION = '0.2.0';
const U2F_ASSET_VERSION = '0.2.1';
/**
* Ensures only one instance of this class exists in memory at any one time.
......@@ -144,7 +144,7 @@ class Two_Factor_FIDO_U2F extends Two_Factor_Provider {
public function authentication_page( $user ) {
require_once( ABSPATH . '/wp-admin/includes/template.php' );
// U2F doesn't work without HTTPS
// U2F doesn't work without HTTPS.
if ( ! is_ssl() ) {
?>
<p><?php esc_html_e( 'U2F requires an HTTPS connection. Please use an alternative 2nd factor method.', 'two-factor' ); ?></p>
......@@ -367,7 +367,12 @@ class Two_Factor_FIDO_U2F extends Two_Factor_Provider {
$query = $wpdb->prepare( "SELECT umeta_id FROM $table WHERE meta_key = '%s' AND user_id = %d", self::REGISTERED_KEY_USER_META_KEY, $user_id );
if ( $keyHandle ) {
$query .= $wpdb->prepare( ' AND meta_value LIKE %s', '%:"' . $keyHandle . '";s:%' );
$key_handle_lookup = sprintf( ':"%s";s:', $keyHandle ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
$query .= $wpdb->prepare(
' AND meta_value LIKE %s',
'%' . $wpdb->esc_like( $key_handle_lookup ) . '%'
);
}
$meta_ids = $wpdb->get_col( $query );
......