<?php
/**
* Class used to define Validation Service.
*
* @package automattic/jetpack-account-protection
*/
namespace Automattic\Jetpack\Account_Protection;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
/**
* Class Validation_Service
*/
class Validation_Service {
/**
* Connection manager dependency.
*
* @var Connection_Manager
*/
private $connection_manager;
/**
* Constructor for dependency injection.
*
* @param Connection_Manager|null $connection_manager Connection manager dependency.
*/
public function __construct(
?Connection_Manager $connection_manager = null
) {
$this->connection_manager = $connection_manager ?? new Connection_Manager();
}
/**
* Dependency decoupling so we can test this class.
*
* @param string $password_prefix The password prefix to be checked.
* @return array|\WP_Error
*/
protected function request_suffixes( string $password_prefix ) {
return Client::wpcom_json_api_request_as_blog(
'/jetpack-protect-weak-password/' . $password_prefix,
'2',
array( 'method' => 'GET' ),
null,
'wpcom'
);
}
/**
* Return validation initial state.
*
* @param bool $user_specific Whether or not to include user specific checks.
*
* @return array An array of all validation statuses and messages.
*/
public function get_validation_initial_state( $user_specific ): array {
$base_conditions = array(
'core' => array(
'status' => null,
'message' => __( 'Strong password', 'jetpack-account-protection' ),
'info' => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ),
),
'contains_backslash' => array(
'status' => null,
'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ),
'info' => null,
),
'invalid_length' => array(
'status' => null,
'message' => sprintf(
/* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
__( 'Between %1$d and %2$d characters', 'jetpack-account-protection' ),
$this->get_min_length(),
$this->get_max_length()
),
'info' => null,
),
'leaked' => array(
'status' => null,
'message' => __( 'Not a leaked password', 'jetpack-account-protection' ),
'info' => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ),
),
);
if ( ! $user_specific ) {
return $base_conditions;
}
$user_specific_conditions = array(
'matches_user_data' => array(
'status' => null,
'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ),
'info' => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ),
),
'recent' => array(
'status' => null,
'message' => __( 'Not used recently', 'jetpack-account-protection' ),
'info' => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ),
),
);
return array_merge( $base_conditions, $user_specific_conditions );
}
/**
* Return validation state - client-side.
*
* @param string $password The password to check.
* @param bool $user_specific Whether or not to run user specific checks.
*
* @return array An array of the status of each check.
*/
public function get_validation_state( string $password, $user_specific ): array {
$validation_state = $this->get_validation_initial_state( $user_specific );
$validation_state['contains_backslash']['status'] = $this->contains_backslash( $password );
$validation_state['invalid_length']['status'] = $this->is_invalid_length( $password );
$validation_state['leaked']['status'] = $this->is_leaked_password( $password );
if ( ! $user_specific ) {
return $validation_state;
}
// Run checks on existing user data
$user = wp_get_current_user();
$validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password );
$validation_state['recent']['status'] = $this->is_recent_password_hash( $user, $password );
return $validation_state;
}
/**
* Return all validation errors - server-side.
*
* @param string $password The password to check.
* @param bool $user_specific Whether or not to run user specific checks.
* @param \stdClass|null $user The user data or null.
*
* @return array The validation errors (if any).
*/
public function get_validation_errors( string $password, $user_specific = false, $user = null ): array {
$errors = array();
if ( empty( $password ) ) {
$errors[] = __( '<strong>Error:</strong> The password cannot be a space or all spaces.', 'jetpack-account-protection' );
}
if ( $this->contains_backslash( $password ) ) {
$errors[] = __( '<strong>Error:</strong> Passwords may not contain the character "\\".', 'jetpack-account-protection' );
}
if ( $this->is_invalid_length( $password ) ) {
$errors[] = sprintf(
/* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
__( '<strong>Error:</strong> The password must be between %1$d and %2$d characters.', 'jetpack-account-protection' ),
$this->get_min_length(),
$this->get_max_length()
);
}
if ( $this->is_leaked_password( $password ) ) {
$errors[] = __( '<strong>Error:</strong> The password was found in a public leak.', 'jetpack-account-protection' );
}
// Skip user-specific checks during password reset
if ( $user_specific ) {
// Run checks on new user data
if ( $this->matches_user_data( $user, $password ) ) {
$errors[] = __( '<strong>Error:</strong> The password matches new user data.', 'jetpack-account-protection' );
}
if ( $this->is_recent_password_hash( $user, $password ) ) {
$errors[] = __( '<strong>Error:</strong> The password was used recently.', 'jetpack-account-protection' );
}
}
return $errors;
}
/**
* Check if the password contains a backslash.
*
* @param string $password The password to check.
*
* @return bool True if the password contains a backslash, false otherwise.
*/
public function contains_backslash( string $password ): bool {
return strpos( $password, '\\' ) !== false;
}
/**
* Check if the password length is within the allowed range.
*
* @param string $password The password to check.
*
* @return bool True if the password is between get_min_length() and get_max_length() characters, false otherwise.
*/
public function is_invalid_length( string $password ): bool {
$length = strlen( $password );
return $length < $this->get_min_length() || $length > $this->get_max_length();
}
/**
* Get the minimum allowed password length.
*
* @return int The minimum allowed password length.
*/
public function get_min_length(): int {
/**
* Filters the minimum allowed password length for Account Protection.
*
* The default is a floor: values below it are ignored, so the filter can only
* raise the minimum, never lower it.
*
* @since 0.3.4
*
* @param int $min_length The minimum allowed password length.
*/
$min_length = (int) apply_filters( 'jetpack_account_protection_validation_min_length', Config::VALIDATION_SERVICE_MIN_LENGTH );
return max( Config::VALIDATION_SERVICE_MIN_LENGTH, $min_length );
}
/**
* Get the maximum allowed password length.
*
* @return int The maximum allowed password length.
*/
public function get_max_length(): int {
/**
* Filters the maximum allowed password length for Account Protection.
*
* The default is a floor: values below it are ignored, so the filter can only
* raise the maximum, never lower it.
*
* @since 0.3.4
*
* @param int $max_length The maximum allowed password length.
*/
$max_length = (int) apply_filters( 'jetpack_account_protection_validation_max_length', Config::VALIDATION_SERVICE_MAX_LENGTH );
return max( Config::VALIDATION_SERVICE_MAX_LENGTH, $max_length );
}
/**
* Check if the password matches any user data.
*
* @param \WP_User|\stdClass|null $user The user.
* @param string $password The password to check.
*
* @return bool True if the password matches any user data, false otherwise.
*/
public function matches_user_data( $user, string $password ): bool {
if ( ! $user ) {
return false;
}
$email_parts = explode( '@', $user->user_email ); // test@example.com
$email_username = $email_parts[0]; // 'test'
$email_domain = $email_parts[1]; // 'example.com'
$email_provider = explode( '.', $email_domain )[0]; // 'example'
$user_data = array(
$user->user_login ?? '',
$user->display_name ?? '',
$user->first_name ?? '',
$user->last_name ?? '',
$user->user_email ?? '',
$email_username ?? '',
$email_provider ?? '',
$user->nickname ?? '',
);
$password_lower = strtolower( $password );
foreach ( $user_data as $data ) {
// Skip if $data is 3 characters or less.
if ( strlen( $data ) <= 3 ) {
continue;
}
if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) {
return true;
}
}
return false;
}
/**
* Check if the password is in the list of compromised/common passwords.
*
* @param string $password The password to check.
*
* @return bool True if the password is in the list of compromised/common passwords, false otherwise.
*/
public function is_leaked_password( string $password ): bool {
if ( ! $this->connection_manager->is_connected() ) {
return false;
}
$hashed_password = sha1( $password );
$password_prefix = substr( $hashed_password, 0, 5 );
$response = $this->request_suffixes( $password_prefix );
$response_code = wp_remote_retrieve_response_code( $response );
if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$password_suffix = substr( $hashed_password, 5 );
if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) {
return true;
}
if ( in_array( $password_suffix, $body['common'] ?? array(), true ) ) {
return true;
}
return false;
}
/**
* Check if the password is the current password for the user.
*
* @param int $user_id The user ID.
* @param string $password The password to check.
*
* @return bool True if the password is the current password, false otherwise.
*/
public function is_current_password( int $user_id, string $password ): bool {
$user = get_userdata( $user_id );
if ( ! $user ) {
return false;
}
return wp_check_password( $password, $user->user_pass, $user->ID );
}
/**
* Check if the password has been used recently by the user.
*
* @param \WP_User|\stdClass $user The user data.
* @param string $password The password to check.
*
* @return bool True if the password was recently used, false otherwise.
*/
public function is_recent_password_hash( $user, string $password ): bool {
// Skip on user creation
if ( empty( $user->ID ) ) {
return false;
}
$user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID );
if ( $this->is_current_password( $user_data->ID, $password ) ) {
return true;
}
$recent_passwords = get_user_meta( $user->ID, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, true );
if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) {
return false;
}
foreach ( $recent_passwords as $old_hashed_password ) {
if ( wp_check_password( $password, $old_hashed_password ) ) {
return true;
}
}
return false;
}
}