File: //wordpress/mu-plugins/wpcloud-password-protection.php
<?php
/**
* This mu-plugin blocks compromised passwords using Bkismet API.
*
* @author Bastion Team
*/
// Protection from accidental double loading.
if ( ! class_exists( 'WP_Cloud_Password_Protection' ) ) {
class WP_Cloud_Password_Protection {
private $api_key;
private $api_endpoint;
private $api_timeout;
private $fail_open;
/**
* Constructor - set up hooks
*/
public function __construct() {
// phpcs:ignore Universal.Operators.DisallowShortTernary.Found
$this->api_key = getenv( 'WPCLOUD_PASSWORD_PROTECTION_API_KEY' ) ?: 'cGXeXoJtO7Yb7hBnO4jx*PvZVrL%9u0@VlLx2xTmZd5g514YwTnUaJh7K6Z';
$this->api_endpoint = 'https://public-api.wordpress.com/bkismet/v1/password';
$this->api_timeout = 5;
$this->fail_open = true;
// Only register hooks if password protection is enabled and API key is configured.
if ( ! $this->is_enabled() ) {
return;
}
add_filter( 'wp_authenticate_user', array( $this, 'check_compromised_password_on_authenticate' ), 5, 2 );
add_action( 'user_profile_update_errors', array( $this, 'check_compromised_password_on_profile_update' ), 5, 3 );
add_action( 'validate_password_reset', array( $this, 'check_compromised_password_on_reset' ), 5, 2 );
}
/**
* Check if password protection is enabled for the site
*
* @return bool
*/
private function is_enabled() {
if ( defined( 'WPC_PASSWORD_PROTECTION_ENABLED' ) ) {
return WPC_PASSWORD_PROTECTION_ENABLED;
}
// Support the legacy WP Cloud-prefixed constant for existing integrations.
if ( defined( 'WPCLOUD_ENABLED_PASSWORD_PROTECTION' ) ) {
return WPCLOUD_ENABLED_PASSWORD_PROTECTION;
}
$enabled_clients = array( '18', '32', '129' );
if ( in_array( ATOMIC_CLIENT_ID, $enabled_clients, true ) ) {
return true;
}
return false;
}
/**
* Check if user role meets minimum role requirement
*
* @param WP_User|null $user User object.
* @return bool
*/
private function should_check_user_capabilities( $user ) {
// Potentially new user, check the password anyway.
if ( empty( $user ) || ! isset( $user->ID ) ) {
return true;
}
if ( ! user_can( $user->ID, 'publish_posts' ) && ! user_can( $user->ID, 'edit_published_posts' ) ) {
return false;
}
return true;
}
/**
* Check if a password is compromised using the Bkismet API
*
* @param string $password The plaintext password to check.
* @param string $context Optional context for logging (e.g., 'signin', 'password_change').
* @return int Number of times the password has been seen compromised (0 if not compromised).
*/
public function check_if_password_is_compromised( $password, $context = null ) {
// Calculate SHA1 hash and split into prefix/suffix.
$hash = strtoupper( sha1( $password ) );
$prefix = substr( $hash, 0, 5 );
$suffix = substr( $hash, 5 );
// Make API request.
$response = $this->api_request( $prefix );
// Handle API errors.
if ( is_wp_error( $response ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
sprintf(
'Password Protection: API error in context "%s": %s in %s on line %d',
$context,
$response->get_error_message(),
__FILE__,
__LINE__
)
);
// Fail open or closed based on configuration.
if ( $this->fail_open ) {
return 0;
}
return 1;
}
// Check if suffix is in the returned list.
if ( isset( $response['suffixes'] ) ) {
$suffixes = $response['suffixes'];
} else {
$suffixes = array();
}
$is_compromised = in_array( strtolower( $suffix ), array_map( 'strtolower', $suffixes ), true );
if ( ! $is_compromised ) {
return 0;
}
// Log compromised password detection.
if ( ! is_null( $context ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
sprintf(
'Password Protection: Compromised password detected during context "%s" in %s on line %d',
$context,
__FILE__,
__LINE__
)
);
}
return 1;
}
/**
* Make API request to Bkismet password endpoint
*
* @param string $sha1_prefix First 5 characters of SHA1 hash.
* @return array|WP_Error Response data array or WP_Error on failure.
*/
private function api_request( $sha1_prefix ) {
$response = wp_remote_post(
$this->api_endpoint,
array(
'timeout' => $this->api_timeout,
'headers' => array(
'X-API-Key' => $this->api_key,
'Content-Type' => 'application/x-www-form-urlencoded',
),
'body' => array(
'prefix' => $sha1_prefix,
),
)
);
// Check for network errors.
if ( is_wp_error( $response ) ) {
return $response;
}
// Check response code.
$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return new WP_Error(
'bkismet_api_error',
sprintf( 'API returned status code %d: %s', $response_code, $response['body'] )
);
}
// Parse response body.
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
// Validate response structure.
if ( ! is_array( $data ) || ! isset( $data['data'] ) ) {
return new WP_Error(
'bkismet_api_invalid_response',
'Invalid API response format'
);
}
return $data['data'];
}
/**
* Block login attempts with compromised passwords
*
* This hook runs on wp_authenticate_user filter, which is called after password validation
* but before other authentication checks.
*
* @param WP_User|WP_Error $user WP_User object or WP_Error if authentication failed.
* @param string $password Password to check against the user.
* @return WP_User|WP_Error User object or error.
*/
public function check_compromised_password_on_authenticate( $user, $password ) {
// Skip if already an error or no valid user.
if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) || empty( $password ) ) {
return $user;
}
// Check if user role meets minimum requirement.
if ( ! $this->should_check_user_capabilities( $user ) ) {
return $user;
}
// Check if password is compromised.
$compromised = $this->check_if_password_is_compromised( $password, 'signin' );
if ( ! $compromised ) {
return $user;
}
// Log the compromised login attempt.
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
sprintf(
'Password Protection: Blocked login for user "%s" (ID: %d) with compromised password in %s on line %d',
$user->user_login,
$user->ID,
__FILE__,
__LINE__
)
);
// Return error to block login.
return new WP_Error(
'compromisable_account',
sprintf(
// translators: %s - lost password url.
__( 'Your account has been blocked as a security precaution. To continue, you must <a href="%s">reset your password</a>.' ),
wp_lostpassword_url()
)
);
}
/**
* Validate password on user profile update
*
* @param WP_Error $errors Error object.
* @param bool $update Whether this is an update.
* @param WP_User $user User object.
*/
public function check_compromised_password_on_profile_update( $errors, $update, $user ) {
// Only check if a new password is being set.
if ( empty( $_POST['pass1'] ) ) {
return;
}
if ( ! $this->should_check_user_capabilities( $user ) ) {
return;
}
$password = wp_unslash( $_POST['pass1'] );
// Check if password is compromised.
$compromised = $this->check_if_password_is_compromised( $password, 'password_change' );
if ( ! $compromised ) {
return;
}
$errors->add(
'pass',
__( '<strong>Error</strong>: This password is known to be included in compromised password lists. Please choose something more unique.' )
);
}
/**
* Validate password on password reset
*
* @param WP_Error $errors Error object.
* @param WP_User $user User object.
*/
public function check_compromised_password_on_reset( $errors, $user ) {
// Only check if a new password is being set.
if ( empty( $_POST['pass1'] ) ) {
return;
}
// Check if user role meets minimum requirement.
if ( ! $this->should_check_user_capabilities( $user ) ) {
return;
}
$password = wp_unslash( $_POST['pass1'] );
// Check if password is compromised.
$compromised = $this->check_if_password_is_compromised( $password, 'password_reset' );
if ( ! $compromised ) {
return;
}
$errors->add(
'pass',
__( '<strong>Error</strong>: This password is known to be included in compromised password lists. Please choose something more unique.' )
);
}
}
new WP_Cloud_Password_Protection();
}