Authy two-factor authentication to WordPress. Users opt in for an added level of security that relies on random codes from their mobile devices.
* Author: Erick Hitter
* Version: 0.2
* Author URI: http://www.ethitter.com/
* License: GPL2+
* Text Domain: authy_for_wp
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*/
/**
* AUTHY FOR WORDPRESS
* Main plugin class
*
* @package Authy for WordPress
* @since 0.1
*/
class Authy_WP {
/**
* Class variables
*/
// Oh look, a singleton
private static $__instance = null;
// Some plugin info
protected $name = 'Authy for WordPress';
// Parsed settings
private $settings = null;
// Is API ready, should plugin act?
protected $ready = false;
protected $sms = false;
// Authy API
protected $api = null;
protected $api_key = null;
protected $api_endpoint = null;
// Interface keys
protected $settings_page = 'authy-for-wp';
protected $users_page = 'authy-for-wp-user';
protected $sms_action = 'authy-for-wp-sms';
// Data storage keys
protected $settings_key = 'authy_for_wp';
protected $users_key = 'authy_for_wp_user';
// Settings field placeholders
protected $settings_fields = array();
protected $settings_field_defaults = array(
'label' => null,
'type' => 'text',
'sanitizer' => 'sanitize_text_field',
'section' => 'default',
'class' => null
);
// Default Authy data
protected $user_defaults = array(
'email' => null,
'phone' => null,
'country_code' => '+1',
'authy_id' => null
);
/**
* Singleton implementation
*
* @uses this::setup
* @return object
*/
public static function instance() {
if ( ! is_a( self::$__instance, 'Authy_WP' ) ) {
self::$__instance = new Authy_WP;
self::$__instance->setup();
}
return self::$__instance;
}
/**
* Silence is golden.
*/
private function __construct() {}
/**
* Plugin setup
*
* @uses this::register_settings_fields, this::prepare_api, add_action, add_filter
* @return null
*/
private function setup() {
require( 'authy-wp-api.php' );
$this->register_settings_fields();
$this->prepare_api();
// Plugin settings
add_action( 'admin_init', array( $this, 'action_admin_init' ) );
add_action( 'admin_menu', array( $this, 'action_admin_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'action_admin_enqueue_scripts' ) );
add_filter( 'plugin_action_links', array( $this, 'filter_plugin_action_links' ), 10, 2 );
// Anything other than plugin configuration belongs in here.
// Important to consider plugin state so we only load code when needed.
if ( $this->ready ) {
// Check SMS availability
add_action( 'init', array( $this, 'check_sms_availability' ) );
// User settings
add_action( 'show_user_profile', array( $this, 'action_show_user_profile' ) );
add_action( 'edit_user_profile', array( $this, 'action_edit_user_profile' ) );
add_action( 'wp_ajax_' . $this->users_page, array( $this, 'ajax_get_id' ) );
add_action( 'personal_options_update', array( $this, 'action_personal_options_update' ) );
add_action( 'edit_user_profile_update', array( $this, 'action_edit_user_profile_update' ) );
// Authentication
add_action( 'login_enqueue_scripts', array( $this, 'action_login_enqueue_scripts' ) );
add_action( 'wp_ajax_nopriv_' . $this->sms_action, array( $this, 'ajax_sms_login' ) );
add_action( 'login_form', array( $this, 'action_login_form' ), 50 );
add_filter( 'authenticate', array( $this, 'action_authenticate' ), 9999, 2 );
} else {
add_action( 'admin_notices', array( $this, 'action_admin_notices' ) );
}
}
/**
* Add settings fields for main plugin page
*
* @uses __
* @return null
*/
protected function register_settings_fields() {
$this->settings_fields = array(
array(
'name' => 'api_key_production',
'label' => __( 'Production API Key', 'authy_wp' ),
'type' => 'text',
'sanitizer' => 'alphanumeric'
),
array(
'name' => 'api_key_development',
'label' => __( 'Development API Key', 'authy_wp' ),
'type' => 'text',
'sanitizer' => 'alphanumeric'
)
);
}
/**
* Set class variables regarding API
* Instantiates the Authy API class into $this->api
*
* @uses this::get_setting, Authy_WP_API::instance
*/
protected function prepare_api() {
$endpoints = array(
'production' => 'https://api.authy.com',
'development' => 'http://sandbox-api.authy.com'
);
// Plugin page accepts keys for production and development.
// Cannot be toggled except via the `authy_wp_environment` filter.
$environment = $this->get_setting( 'environment' );
// API key is specific to the environment
$api_key = $this->get_setting( 'api_key_' . $environment );
// Only prepare the API endpoint if we have all information needed.
if ( $api_key && isset( $endpoints[ $environment ] ) ) {
$this->api_key = $api_key;
$this->api_endpoint = $endpoints[ $environment ];
$this->ready = true;
}
// Instantiate the API class
$this->api = Authy_WP_API::instance( $this->api_key, $this->api_endpoint );
}
/**
* COMMON PLUGIN ELEMENTS
*/
/**
* Register plugin's setting and validation callback
*
* @param action admin_init
* @uses register_setting
* @return null
*/
public function action_admin_init() {
register_setting( $this->settings_page, $this->settings_key, array( $this, 'validate_plugin_settings' ) );
}
/**
* Register plugin settings page and page's sections
*
* @uses add_options_page, add_settings_section
* @action admin_menu
* @return null
*/
public function action_admin_menu() {
add_options_page( $this->name, 'Authy for WP', 'manage_options', $this->settings_page, array( $this, 'plugin_settings_page' ) );
add_settings_section( 'default', '', array( $this, 'register_settings_page_sections' ), $this->settings_page );
}
/**
* Enqueue admin script for connection modal
*
* @uses get_current_screen, wp_enqueue_script, plugins_url, wp_localize_script, this::get_ajax_url, wp_enqueue_style
* @action admin_enqueue_scripts
* @return null
*/
public function action_admin_enqueue_scripts() {
if ( ! $this->ready )
return;
$current_screen = get_current_screen();
if ( 'profile' == $current_screen->base ) {
wp_enqueue_script( 'authy-wp-profile', plugins_url( 'assets/authy-wp-profile.js', __FILE__ ), array( 'jquery', 'thickbox' ), 1.01, true );
wp_localize_script( 'authy-wp-profile', 'AuthyForWP', array(
'ajax' => $this->get_ajax_url(),
'th_text' => __( 'Connection', 'authy_for_wp' ),
'button_text' => __( 'Manage Authy Connection', 'authy_for_wp' )
) );
wp_enqueue_style( 'thickbox' );
}
}
/**
* Add settings link to plugin row actions
*
* @param array $links
* @param string $plugin_file
* @uses menu_page_url, __
* @filter plugin_action_links
* @return array
*/
public function filter_plugin_action_links( $links, $plugin_file ) {
if ( false !== strpos( $plugin_file, pathinfo( __FILE__, PATHINFO_FILENAME ) ) )
$links['settings'] = '' . __( 'Settings', 'authy_for_wp' ) . ' ';
return $links;
}
/**
* Display an admin nag when plugin is active but API keys are missing
*
* @uses esc_html, _e, __, menu_page_url
* @action admin_notices
* @return string or null
*/
public function action_admin_notices() {
if ( ! $this->ready ) : ?>
name ); ?>:
Click here to configure.', 'authy_for_wp' ), menu_page_url( $this->settings_page, false ) ); ?>
settings ) || ! is_array( $this->settings ) ) {
$this->settings = get_option( $this->settings_key );
$this->settings = wp_parse_args( $this->settings, array(
'api_key_production' => '',
'api_key_development' => '',
'environment' => apply_filters( 'authy_wp_environment', 'production' )
) );
}
if ( isset( $this->settings[ $key ] ) )
$value = $this->settings[ $key ];
return $value;
}
/**
* Build Ajax URLs
*
* @uses add_query_arg, wp_create_nonce, admin_url
* @return string
*/
protected function get_ajax_url( $url = 'user' ) {
switch( $url ) {
default :
case 'user' :
return add_query_arg( array(
'action' => $this->users_page,
'nonce' => wp_create_nonce( $this->users_key . '_ajax' )
), admin_url( 'admin-ajax.php' ) );
break;
case 'sms' :
return add_query_arg( array(
'action' => $this->sms_action,
'username' => ''
), admin_url( 'admin-ajax.php' ) );
break;
}
}
/**
* Print common Ajax head element
*
* @uses wp_print_scripts, wp_print_styles
* @return string
*/
protected function ajax_head() {
?>
api->send_sms( 1 ), array( 503, false ) ) )
$this->sms = true;
}
/**
* GENERAL OPTIONS PAGE
*/
/**
* Populate settings page's sections
*
* @uses wp_parse_args, add_settings_field
* @return null
*/
public function register_settings_page_sections() {
foreach ( $this->settings_fields as $args ) {
$args = wp_parse_args( $args, $this->settings_field_defaults );
add_settings_field( $args['name'], $args['label'], array( $this, 'form_field_' . $args['type'] ), $this->settings_page, $args['section'], $args );
}
}
/**
* Render text input
*
* @param array $args
* @uses wp_parse_args, esc_attr, this::get_setting, esc_attr
* @return string or null
*/
public function form_field_text( $args ) {
$args = wp_parse_args( $args, $this->settings_field_defaults );
$name = esc_attr( $args['name'] );
if ( empty( $name ) )
return;
if ( is_null( $args['class'] ) )
$args['class'] = 'regular-text';
$value = $this->get_setting( $args['name'] );
?>
ready ) : ?>
%1$s and create an application for access to the Authy API.', 'authy_for_wp' ), 'http://www.authy.com/' ); ?>
settings_page . '-options' );
$settings_validated = array();
foreach ( $this->settings_fields as $field ) {
$field = wp_parse_args( $field, $this->settings_field_defaults );
if ( ! isset( $settings[ $field['name'] ] ) )
continue;
switch ( $field['type'] ) {
case 'text' :
switch ( $field['sanitizer'] ) {
case 'alphanumeric' :
$value = preg_replace( '#[^a-z0-9]#i', '', $settings[ $field['name' ] ] );
break;
default:
case 'sanitize_text_field' :
$value = sanitize_text_field( $settings[ $field['name'] ] );
break;
}
break;
default:
$value = sanitize_text_field( $settings[ $field['name'] ] );
break;
}
if ( isset( $value ) && ! empty( $value ) )
$settings_validated[ $field['name'] ] = $value;
}
return $settings_validated;
}
/**
* USER INFORMATION FUNCTIONS
*/
/**
* Add Authy data to a given user account
*
* @param int $user_id
* @param string $email
* @param string $phone
* @param string $country_code
* @uses this::user_has_authy_id, this::api::get_id, wp_parse_args, this::clear_authy_data, get_user_meta, update_user_meta
* @return null
*/
public function set_authy_data( $user_id, $email, $phone, $country_code ) {
// Retrieve user's existing Authy ID, or get one from Authy
if ( $this->user_has_authy_id( $user_id ) ) {
$authy_id = $this->get_user_authy_id( $user_id );
} else {
// Request an Authy ID with given user information
$authy_id = (int) $this->api->get_id( $email, $phone, $country_code );
if ( ! $authy_id )
unset( $authy_id );
}
// Build array of Authy data
$data_sanitized = array(
'email' => $email,
'phone' => $phone,
'country_code' => $country_code
);
if ( isset( $authy_id ) )
$data_sanitized['authy_id'] = $authy_id;
$data_sanitized = wp_parse_args( $data_sanitized, $this->user_defaults );
// Update Authy data if sufficient information is provided, otherwise clear the option out.
if ( empty( $data_sanitized['phone'] ) ) {
$this->clear_authy_data( $user_id );
} else {
$data = get_user_meta( $user_id, $this->users_key, true );
if ( ! is_array( $data ) )
$data = array();
$data[ $this->api_key ] = $data_sanitized;
update_user_meta( $user_id, $this->users_key, $data );
}
}
/**
* Retrieve a user's Authy data for a given API key
*
* @param int $user_id
* @param string $api_key
* @uses get_user_meta, wp_parse_args
* @return array
*/
protected function get_authy_data( $user_id, $api_key = null ) {
// Bail without a valid user ID
if ( ! $user_id )
return $this->user_defaults;
// Validate API key
if ( is_null( $api_key ) )
$api_key = $this->api_key;
else
$api_key = preg_replace( '#[a-z0-9]#i', '', $api_key );
// Get meta, which holds all Authy data by API key
$data = get_user_meta( $user_id, $this->users_key, true );
if ( ! is_array( $data ) )
$data = array();
// Return data for this API, if present, otherwise return default data
if ( array_key_exists( $api_key, $data ) )
return wp_parse_args( $data[ $api_key ], $this->user_defaults );
return $this->user_defaults;
}
/**
* Delete any stored Authy connections for the given user.
* Expected usage is somewhere where clearing is the known action.
*
* @param int $user_id
* @uses delete_user_meta
* @return null
*/
protected function clear_authy_data( $user_id ) {
delete_user_meta( $user_id, $this->users_key );
}
/**
* Check if a given user has an Authy ID set
*
* @param int $user_id
* @uses this::get_user_authy_id
* @return bool
*/
protected function user_has_authy_id( $user_id ) {
return (bool) $this->get_user_authy_id( $user_id );
}
/**
* Retrieve a given user's Authy ID
*
* @param int $user_id
* @uses this::get_authy_data
* @return int|bool
*/
protected function get_user_authy_id( $user_id ) {
$data = $this->get_authy_data( $user_id );
if ( is_array( $data ) && is_numeric( $data['authy_id'] ) )
return (int) $data['authy_id'];
return false;
}
/**
* USER SETTINGS PAGES
*/
/**
* Non-JS connection interface
*
* @param object $user
* @uses this::get_authy_data, esc_attr,
*/
public function action_show_user_profile( $user ) {
$meta = $this->get_authy_data( $user->ID );
?>
name ); ?>
users_key ] ) ? $_POST[ $this->users_key ] : false;
// Parse for nonce and API existence
if ( is_array( $authy_data ) && array_key_exists( 'nonce', $authy_data ) ) {
if ( wp_verify_nonce( $authy_data['nonce'], $this->users_key . 'edit_own' ) ) {
// Email address
$userdata = get_userdata( $user_id );
if ( is_object( $userdata ) && ! is_wp_error( $userdata ) )
$email = $userdata->data->user_email;
else
$email = null;
// Phone number
$phone = preg_replace( '#[^\d]#', '', $authy_data['phone'] );
$country_code = preg_replace( '#[^\d\+]#', '', $authy_data['country_code'] );
// Process information with Authy
$this->set_authy_data( $user_id, $email, $phone, $country_code );
} elseif ( wp_verify_nonce( $authy_data['nonce'], $this->users_key . 'disable_own' ) ) {
// Delete Authy usermeta if requested
if ( isset( $authy_data['disable_own'] ) )
$this->clear_authy_data( $user_id );
}
}
}
/**
* Allow sufficiently-priviledged users to disable another user's Authy service.
*
* @param object $user
* @uses current_user_can, this::user_has_authy_id, get_user_meta, wp_parse_args, esc_attr, wp_nonce_field
* @action edit_user_profile
* @return string
*/
public function action_edit_user_profile( $user ) {
if ( current_user_can( 'create_users' ) ) {
?>
Authy Two-factor Authentication
users_key . '_disable', "_{$this->users_key}_wpnonce" );
}
}
/**
* Ajax handler for users' connection manager
*
* @uses wp_verify_nonce, get_current_user_id, get_userdata, this::get_authy_data, this::ajax_head, body_class, esc_url, this::get_ajax_url, this::user_has_authy_id, _e, __, submit_button, wp_nonce_field, esc_attr, this::clear_authy_data, wp_safe_redirect, sanitize_email, this::set_authy_data
* @action wp_ajax_{$this->users_page}
* @return string
*/
public function ajax_get_id() {
// If nonce isn't set, bail
if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( $_REQUEST['nonce'], $this->users_key . '_ajax' ) ) {
?>get_authy_data( $user_id );
// Step
$step = isset( $_REQUEST['authy_step'] ) ? preg_replace( '#[^a-z0-9\-_]#i', '', $_REQUEST['authy_step'] ) : false;
// iframe head
$this->ajax_head();
// iframe body
?>>
users_key}_wpnonce"] ) && wp_verify_nonce( $_POST["_{$this->users_key}_wpnonce"], $this->users_key . '_disable' ) ) {
if ( isset( $_POST[ $this->users_key ] ) )
$this->clear_authy_data( $user_id );
}
}
/**
* AUTHENTICATION CHANGES
*/
/**
* Enqueue scripts to support SMS integration, if available
*
* @uses wp_enqueue_script, plugins_url, wp_enqueue_style
* @action login_enqueue_scripts
* @return null
*/
public function action_login_enqueue_scripts() {
if ( $this->ready && $this->sms ) {
wp_enqueue_script( 'authy-wp-login', plugins_url( 'assets/authy-wp-login.js', __FILE__ ), array( 'jquery', 'thickbox' ), 1.0, false );
wp_localize_script( 'authy-wp-login', 'AuthyForWP', array( 'ajax' => $this->get_ajax_url( 'sms' ) ) );
wp_enqueue_style( 'thickbox' );
}
}
/**
* Render Ajax modal for SMS tokens at login
*
* @uses this::ajax_head, body_class, _e, esc_url, this::get_ajax_url, sanitize_user, esc_attr, wp_nonce_field, submit_button, __, wp_verify_nonce, get_user_by, is_wp_error, this::user_has_authy_id, this::api::send_sms, this::get_user_authy_id
* @action wp_ajax_nopriv_{$this->sms_action}
* @return string
*/
public function ajax_sms_login() {
// iframe head
$this->ajax_head();
// iframe body
?>>
sms ) : ?> ( )
user_has_authy_id( $user->ID ) )
return $user;
// If a user has opted in, he/she must provide a token
if ( ! isset( $_POST['authy_token'] ) || empty( $_POST['authy_token'] ) )
return new WP_Error( 'authentication_failed', sprintf( __('ERROR : To log in as %s , you must provide an Authy token.'), $username ) );
// Check the specified token
$authy_id = $this->get_user_authy_id( $user->ID );
$authy_token = preg_replace( '#[^\d]#', '', $_POST['authy_token'] );
$api_check = $this->api->check_token( $authy_id, $authy_token );
// Act on API response
if ( false === $api_check )
return null;
elseif ( is_string( $api_check ) )
return new WP_Error( 'authentication_failed', __('ERROR : ' . $api_check ) );
return $user;
}
}
Authy_WP::instance();