Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial Two Factor event logging. #643

Merged
merged 3 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions mu-plugins/plugin-tweaks/stream.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
add_filter( 'wp_stream_log_data', __NAMESPACE__ . '\include_user_name_in_creation_log' );
add_filter( 'wp_stream_is_record_excluded', __NAMESPACE__ . '\exclude_profile_updates_as_part_of_user_creation', 10, 2 );
add_filter( 'bbp_set_user_role', __NAMESPACE__ . '\log_forum_role_change', 10, 3 );
add_filter( 'wp_stream_connectors', __NAMESPACE__ . '\wp_stream_connectors' );

/**
* Stream by default logs new user registrations as 'New user registration' which doesn't come up in search-by-username.
Expand Down Expand Up @@ -71,6 +72,17 @@ function log_forum_role_change( $new_role, $user_id, $user ) {
return $new_role;
}

/**
* Load a connector to record Two Factor related events.
*/
function wp_stream_connectors( $connectors ) {
require_once __DIR__ . '/stream/class-connector-two-factor.php';

$connectors[ 'two-factor'] = new Connector_Two_Factor;

return $connectors;
}

/**
* Helper Functions
*/
Expand Down
347 changes: 347 additions & 0 deletions mu-plugins/plugin-tweaks/stream/class-connector-two-factor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
<?php
/**
* Connector for Two Factor
*/

namespace WordPressdotorg\MU_Plugins\Plugin_Tweaks\Stream;
use Two_Factor_Core;
use WP_Stream\Connector;
use WildWolf\WordPress\TwoFactorWebAuthn\WebAuthn_Credential_Store;

/**
* Class - Connector_Two_Factor
*/
class Connector_Two_Factor extends Connector {
/**
* Connector slug
*
* @var string
*/
public $name = 'two-factor';

/**
* Actions registered for this connector
*
* @var array
*/
public $actions = array(
// Triggers "callback_{name}" funcs.
'update_user_meta', // Before user meta changes
'updated_user_meta', // After user meta changes

'two_factor_user_authenticated', // Authenticatd via 2FA
'wp_login_failed', // Failed login
);

/**
* Actions that need to be run early.
*
* @var array
*/
public $actions_early = [
// WebAuthN.. Must be run early, see ::register().
'wp_ajax_webauthn_register' => 5,
'wp_ajax_webauthn_delete_key' => 5,
// 'wp_ajax_webauthn_rename_key' => 5, // WordPress.org doesn't support this.
];

/**
* Tracked option keys
*
* @var array
*/
public $options = array();

/**
* Record the user_meta meta_value before updates.
*
* @var array
*/
public $user_meta = array();

/**
* Check if plugin dependencies are satisfied and add an admin notice if not
*
* @return bool
*/
public function is_dependency_satisfied() {
if ( class_exists( 'Two_Factor_Core' ) ) {
return true;
}

return false;
}

/**
* Return translated connector label
*
* @return string Translated connector label
*/
public function get_label() {
return esc_html__( 'Two Factor', 'wporg' );
}

/**
* Return translated action labels
*
* @return array Action label translations
*/
public function get_action_labels() {
return array(
'enabled' => 'Enabled',
'disabled' => 'Disabled',
'recovered' => 'Recovered',
'updated' => 'Updated',
'removed' => 'Removed',
'added' => 'Added',
'authenticated' => 'Authenticated',
);
}

/**
* Return translated context labels
*
* @return array Context label translations
*/
public function get_context_labels() {
return array(
'settings' => esc_html_x( 'Settings', 'two-factor', 'stream' ),
);
}

/**
* Register the connector
*/
public function register() {
parent::register();

add_filter( 'wp_stream_log_data', [ $this, 'log_override' ] );

// Immitate Streams actions, but with added priority.
foreach ( $this->actions_early as $hook => $priority ) {
add_action( $hook, [ $this, 'callback_' . $hook ], $priority, 99 );
}
}

/**
* Modify or prevent logging of some actions.
*
* @param array $data Record data.
*
* @return array|bool
*/
public function log_override( $data ) {
if ( ! is_array( $data ) ) {
return $data;
}

// If a login was made but no cookies are being sent (ie. hit the 2FA interstitial), don't log it.
if (
'users' === $data['connector'] &&
'sessions' === $data['context'] &&
'login' === $data['action'] &&
Two_Factor_Core::is_user_using_two_factor( $data['user_id'] ) &&
'set_logged_in_cookie' === current_filter() &&
has_filter( 'send_auth_cookies', '__return_false' )
) {
$data = false;
}

return $data;
}

/**
* Callback to watch for 2FA authenticated actions.
*
* @param \WP_User $user Authenticated user.
* @param object $provider The 2FA Provider used.
*/
public function callback_two_factor_user_authenticated( $user, $provider ) {
$this->log(
'Authenticated via %s',
array(
'provider' => $provider->get_key(),
),
$user->ID,
'two-factor',
'authenticated',
$user->ID
);
}

/**
* Callback to watch for failed logins with Two Factor errors.
*
* @param string $user_login User login.
* @param \WP_Error $error WP_Error object.
*/
public function callback_wp_login_failed( $user_login, $error ) {
if ( ! str_starts_with( $error->get_error_code(), 'two_factor_' ) ) {
return;
}

$user = get_user_by( 'login', $user_login );
if ( ! $user && is_email( $user_login ) ) {
$user = get_user_by( 'email', $user_login );
}

$this->log(
'%s Failed 2FA: %s %s',
array(
'display_name' => $user->display_name,
'code' => $error->get_error_code(),
'error' => $error->get_error_message(),
),
$user->ID,
'two-factor',
'failed',
$user->ID
);
}

/**
* Callback to watch for user_meta changes BEFORE it's made.
*
* @param int $meta_id Meta ID.
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param mixed $new_meta_value The NEW meta value.
*/
public function callback_update_user_meta( $meta_id, $user_id, $meta_key, $new_meta_value ) {
unset( $meta_id );

switch( $meta_key ) {
case '_two_factor_backup_codes':
case '_two_factor_totp_key':
case '_two_factor_enabled_providers':
$this->user_meta[ $user_id ][ $meta_key ] = get_user_meta( $user_id, $meta_key, true );
break;
}

}

/**
* Callback to watch for user_meta changes AFTER it's made.
*
* @param int $meta_id Meta ID.
* @param int $user_id User ID.
* @param string $meta_key Meta key.
* @param mixed $new_meta_value The NEW meta value.
*/
public function callback_updated_user_meta( $meta_id, $user_id, $meta_key, $new_meta_value ) {
unset( $meta_id );

$old_meta_value = $this->user_meta[ $user_id ][ $meta_key ] ?? null;
unset( $this->user_meta[ $user_id ][ $meta_key ] );

switch( $meta_key ) {
case '_two_factor_backup_codes':
$this->log(
'Updated backup codes',
array(),
$user_id,
'two-factor',
'updated'
);
break;
case '_two_factor_totp_key':
$this->log(
'Set TOTP secret key',
array(),
$user_id,
'two-factor',
'updated'
);
break;
case '_two_factor_enabled_providers':
$old_providers = $old_meta_value ?? [];
$new_providers = $new_meta_value ?? [];

$enabled_providers = array_diff( $new_providers, $old_providers );
$disabled_providers = array_diff( $old_providers, $new_providers );

foreach ( $enabled_providers as $provider ) {
$this->log(
'Enabled provider: %s',
array(
'provider' => $provider,
),
$user_id,
'two-factor',
'enabled'
);
}

foreach ( $disabled_providers as $provider ) {
$this->log(
'Disabled provider: %s',
array(
'provider' => $provider,
),
$user_id,
'two-factor',
'disabled'
);
}
break;
}
}

/**
* Callback to watch for WebAuthN key registrations.
*/
function callback_wp_ajax_webauthn_register() {
ob_start( function( $output ) {
$success = json_decode( $output, true )['success'] ?? false;

if ( $success ) {
$this->log(
'WebAuthN key registered: %s',
array(
'key-name' => wp_unslash( $_REQUEST['name'] ),
),
get_current_user_id(),
'webauthn',
'added'
);
}

return $output;
} );

}

/**
* Callback to watch for WebAuthN key deletions.
*/
function callback_wp_ajax_webauthn_delete_key() {
// Fetch the handle now, so that it's available if it's removed.
$user = get_user_by( 'ID', $_REQUEST['user_id'] ?? 0 );
$keys = $user ? ( new WebAuthn_Credential_Store() )->get_user_keys( $user ) : [];

ob_start( function( $output ) use( $keys ) {
$success = json_decode( $output, true )['success'] ?? false;

if ( $success ) {
$handle = wp_unslash( $_REQUEST['handle'] ?? '' );

$key = wp_list_filter( $keys, [ 'credential_id' => $handle ] );
$key = reset( $key );
$name = $key->name ?? '';

$this->log(
'WebAuthN key deleted: %s',
array(
'key-name' => $name,
),
get_current_user_id(),
'two-factor',
'removed'
);
}

return $output;
} );
}

}
Loading