Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.13% covered (success)
92.13%
117 / 127
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validation_Service
92.13% covered (success)
92.13%
117 / 127
72.73% covered (warning)
72.73%
8 / 11
40.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 request_suffixes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 get_validation_initial_state
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
2
 get_validation_state
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 get_validation_errors
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 contains_backslash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_invalid_length
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 matches_user_data
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 is_leaked_password
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 is_current_password
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_recent_password_hash
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
1<?php
2/**
3 * Class used to define Validation Service.
4 *
5 * @package automattic/jetpack-account-protection
6 */
7
8namespace Automattic\Jetpack\Account_Protection;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12
13/**
14 * Class Validation_Service
15 */
16class Validation_Service {
17
18    /**
19     * Connection manager dependency.
20     *
21     * @var Connection_Manager
22     */
23    private $connection_manager;
24
25    /**
26     * Constructor for dependency injection.
27     *
28     * @param Connection_Manager|null $connection_manager Connection manager dependency.
29     */
30    public function __construct(
31        ?Connection_Manager $connection_manager = null
32    ) {
33        $this->connection_manager = $connection_manager ?? new Connection_Manager();
34    }
35
36    /**
37     * Dependency decoupling so we can test this class.
38     *
39     * @param string $password_prefix The password prefix to be checked.
40     * @return array|\WP_Error
41     */
42    protected function request_suffixes( string $password_prefix ) {
43        return Client::wpcom_json_api_request_as_blog(
44            '/jetpack-protect-weak-password/' . $password_prefix,
45            '2',
46            array( 'method' => 'GET' ),
47            null,
48            'wpcom'
49        );
50    }
51
52    /**
53     * Return validation initial state.
54     *
55     * @param bool $user_specific Whether or not to include user specific checks.
56     *
57     * @return array An array of all validation statuses and messages.
58     */
59    public function get_validation_initial_state( $user_specific ): array {
60        $base_conditions = array(
61            'core'               => array(
62                'status'  => null,
63                'message' => __( 'Strong password', 'jetpack-account-protection' ),
64                'info'    => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ),
65            ),
66            'contains_backslash' => array(
67                'status'  => null,
68                'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ),
69                'info'    => null,
70            ),
71            'invalid_length'     => array(
72                'status'  => null,
73                'message' => __( 'Between 6 and 150 characters', 'jetpack-account-protection' ),
74                'info'    => null,
75            ),
76            'leaked'             => array(
77                'status'  => null,
78                'message' => __( 'Not a leaked password', 'jetpack-account-protection' ),
79                'info'    => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ),
80            ),
81        );
82
83        if ( ! $user_specific ) {
84            return $base_conditions;
85        }
86
87        $user_specific_conditions = array(
88            'matches_user_data' => array(
89                'status'  => null,
90                'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ),
91                'info'    => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ),
92            ),
93            'recent'            => array(
94                'status'  => null,
95                'message' => __( 'Not used recently', 'jetpack-account-protection' ),
96                'info'    => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ),
97            ),
98        );
99
100        return array_merge( $base_conditions, $user_specific_conditions );
101    }
102
103    /**
104     * Return validation state - client-side.
105     *
106     * @param string $password The password to check.
107     * @param bool   $user_specific Whether or not to run user specific checks.
108     *
109     * @return array An array of the status of each check.
110     */
111    public function get_validation_state( string $password, $user_specific ): array {
112        $validation_state = $this->get_validation_initial_state( $user_specific );
113
114        $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password );
115        $validation_state['invalid_length']['status']     = $this->is_invalid_length( $password );
116        $validation_state['leaked']['status']             = $this->is_leaked_password( $password );
117
118        if ( ! $user_specific ) {
119            return $validation_state;
120        }
121
122        // Run checks on existing user data
123        $user = wp_get_current_user();
124        $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password );
125        $validation_state['recent']['status']            = $this->is_recent_password_hash( $user, $password );
126
127        return $validation_state;
128    }
129
130    /**
131     * Return all validation errors - server-side.
132     *
133     * @param string         $password The password to check.
134     * @param bool           $user_specific Whether or not to run user specific checks.
135     * @param \stdClass|null $user The user data or null.
136     *
137     * @return array The validation errors (if any).
138     */
139    public function get_validation_errors( string $password, $user_specific = false, $user = null ): array {
140        $errors = array();
141
142        if ( empty( $password ) ) {
143            $errors[] = __( '<strong>Error:</strong> The password cannot be a space or all spaces.', 'jetpack-account-protection' );
144        }
145
146        if ( $this->contains_backslash( $password ) ) {
147            $errors[] = __( '<strong>Error:</strong> Passwords may not contain the character "\\".', 'jetpack-account-protection' );
148        }
149
150        if ( $this->is_invalid_length( $password ) ) {
151            $errors[] = __( '<strong>Error:</strong> The password must be between 6 and 150 characters.', 'jetpack-account-protection' );
152        }
153
154        if ( $this->is_leaked_password( $password ) ) {
155            $errors[] = __( '<strong>Error:</strong> The password was found in a public leak.', 'jetpack-account-protection' );
156        }
157
158        // Skip user-specific checks during password reset
159        if ( $user_specific ) {
160            // Run checks on new user data
161            if ( $this->matches_user_data( $user, $password ) ) {
162                $errors[] = __( '<strong>Error:</strong> The password matches new user data.', 'jetpack-account-protection' );
163            }
164            if ( $this->is_recent_password_hash( $user, $password ) ) {
165                $errors[] = __( '<strong>Error:</strong> The password was used recently.', 'jetpack-account-protection' );
166            }
167        }
168
169        return $errors;
170    }
171
172    /**
173     * Check if the password contains a backslash.
174     *
175     * @param string $password The password to check.
176     *
177     * @return bool True if the password contains a backslash, false otherwise.
178     */
179    public function contains_backslash( string $password ): bool {
180        return strpos( $password, '\\' ) !== false;
181    }
182
183    /**
184     * Check if the password length is within the allowed range.
185     *
186     * @param string $password The password to check.
187     *
188     * @return bool True if the password is between 6 and 150 characters, false otherwise.
189     */
190    public function is_invalid_length( string $password ): bool {
191        $length = strlen( $password );
192        return $length < Config::VALIDATION_SERVICE_MIN_LENGTH || $length > Config::VALIDATION_SERVICE_MAX_LENGTH;
193    }
194
195    /**
196     * Check if the password matches any user data.
197     *
198     * @param \WP_User|\stdClass|null $user The user.
199     * @param string                  $password The password to check.
200     *
201     * @return bool True if the password matches any user data, false otherwise.
202     */
203    public function matches_user_data( $user, string $password ): bool {
204        if ( ! $user ) {
205            return false;
206        }
207
208        $email_parts    = explode( '@', $user->user_email ); // test@example.com
209        $email_username = $email_parts[0]; // 'test'
210        $email_domain   = $email_parts[1]; // 'example.com'
211        $email_provider = explode( '.', $email_domain )[0]; // 'example'
212
213        $user_data = array(
214            $user->user_login ?? '',
215            $user->display_name ?? '',
216            $user->first_name ?? '',
217            $user->last_name ?? '',
218            $user->user_email ?? '',
219            $email_username ?? '',
220            $email_provider ?? '',
221            $user->nickname ?? '',
222        );
223
224        $password_lower = strtolower( $password );
225
226        foreach ( $user_data as $data ) {
227            // Skip if $data is 3 characters or less.
228            if ( strlen( $data ) <= 3 ) {
229                continue;
230            }
231
232            if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) {
233                return true;
234            }
235        }
236
237        return false;
238    }
239
240    /**
241     * Check if the password is in the list of compromised/common passwords.
242     *
243     * @param string $password The password to check.
244     *
245     * @return bool True if the password is in the list of compromised/common passwords, false otherwise.
246     */
247    public function is_leaked_password( string $password ): bool {
248        if ( ! $this->connection_manager->is_connected() ) {
249            return false;
250        }
251
252        $hashed_password = sha1( $password );
253        $password_prefix = substr( $hashed_password, 0, 5 );
254
255        $response = $this->request_suffixes( $password_prefix );
256
257        $response_code = wp_remote_retrieve_response_code( $response );
258
259        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
260            return false;
261        }
262
263        $body = json_decode( wp_remote_retrieve_body( $response ), true );
264
265        $password_suffix = substr( $hashed_password, 5 );
266        if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) {
267            return true;
268        }
269
270        if ( in_array( $password_suffix, $body['common'] ?? array(), true ) ) {
271            return true;
272        }
273
274        return false;
275    }
276
277    /**
278     * Check if the password is the current password for the user.
279     *
280     * @param int    $user_id  The user ID.
281     * @param string $password The password to check.
282     *
283     * @return bool True if the password is the current password, false otherwise.
284     */
285    public function is_current_password( int $user_id, string $password ): bool {
286        $user = get_userdata( $user_id );
287        if ( ! $user ) {
288            return false;
289        }
290
291        return wp_check_password( $password, $user->user_pass, $user->ID );
292    }
293
294    /**
295     * Check if the password has been used recently by the user.
296     *
297     * @param \WP_User|\stdClass $user The user data.
298     * @param string             $password The password to check.
299     *
300     * @return bool True if the password was recently used, false otherwise.
301     */
302    public function is_recent_password_hash( $user, string $password ): bool {
303        // Skip on user creation
304        if ( empty( $user->ID ) ) {
305            return false;
306        }
307
308        $user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID );
309        if ( $this->is_current_password( $user_data->ID, $password ) ) {
310            return true;
311        }
312
313        $recent_passwords = get_user_meta( $user->ID, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, true );
314        if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) {
315            return false;
316        }
317
318        foreach ( $recent_passwords as $old_hashed_password ) {
319            if ( wp_check_password( $password, $old_hashed_password ) ) {
320                return true;
321            }
322        }
323
324        return false;
325    }
326}