Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.81% covered (success)
92.81%
129 / 139
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validation_Service
92.81% covered (success)
92.81%
129 / 139
76.92% covered (warning)
76.92%
10 / 13
42.66
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%
41 / 41
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%
19 / 19
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
 get_min_length
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_max_length
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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' => sprintf(
74                    /* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
75                    __( 'Between %1$d and %2$d characters', 'jetpack-account-protection' ),
76                    $this->get_min_length(),
77                    $this->get_max_length()
78                ),
79                'info'    => null,
80            ),
81            'leaked'             => array(
82                'status'  => null,
83                'message' => __( 'Not a leaked password', 'jetpack-account-protection' ),
84                'info'    => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ),
85            ),
86        );
87
88        if ( ! $user_specific ) {
89            return $base_conditions;
90        }
91
92        $user_specific_conditions = array(
93            'matches_user_data' => array(
94                'status'  => null,
95                'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ),
96                'info'    => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ),
97            ),
98            'recent'            => array(
99                'status'  => null,
100                'message' => __( 'Not used recently', 'jetpack-account-protection' ),
101                'info'    => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ),
102            ),
103        );
104
105        return array_merge( $base_conditions, $user_specific_conditions );
106    }
107
108    /**
109     * Return validation state - client-side.
110     *
111     * @param string $password The password to check.
112     * @param bool   $user_specific Whether or not to run user specific checks.
113     *
114     * @return array An array of the status of each check.
115     */
116    public function get_validation_state( string $password, $user_specific ): array {
117        $validation_state = $this->get_validation_initial_state( $user_specific );
118
119        $validation_state['contains_backslash']['status'] = $this->contains_backslash( $password );
120        $validation_state['invalid_length']['status']     = $this->is_invalid_length( $password );
121        $validation_state['leaked']['status']             = $this->is_leaked_password( $password );
122
123        if ( ! $user_specific ) {
124            return $validation_state;
125        }
126
127        // Run checks on existing user data
128        $user = wp_get_current_user();
129        $validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password );
130        $validation_state['recent']['status']            = $this->is_recent_password_hash( $user, $password );
131
132        return $validation_state;
133    }
134
135    /**
136     * Return all validation errors - server-side.
137     *
138     * @param string         $password The password to check.
139     * @param bool           $user_specific Whether or not to run user specific checks.
140     * @param \stdClass|null $user The user data or null.
141     *
142     * @return array The validation errors (if any).
143     */
144    public function get_validation_errors( string $password, $user_specific = false, $user = null ): array {
145        $errors = array();
146
147        if ( empty( $password ) ) {
148            $errors[] = __( '<strong>Error:</strong> The password cannot be a space or all spaces.', 'jetpack-account-protection' );
149        }
150
151        if ( $this->contains_backslash( $password ) ) {
152            $errors[] = __( '<strong>Error:</strong> Passwords may not contain the character "\\".', 'jetpack-account-protection' );
153        }
154
155        if ( $this->is_invalid_length( $password ) ) {
156            $errors[] = sprintf(
157                /* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
158                __( '<strong>Error:</strong> The password must be between %1$d and %2$d characters.', 'jetpack-account-protection' ),
159                $this->get_min_length(),
160                $this->get_max_length()
161            );
162        }
163
164        if ( $this->is_leaked_password( $password ) ) {
165            $errors[] = __( '<strong>Error:</strong> The password was found in a public leak.', 'jetpack-account-protection' );
166        }
167
168        // Skip user-specific checks during password reset
169        if ( $user_specific ) {
170            // Run checks on new user data
171            if ( $this->matches_user_data( $user, $password ) ) {
172                $errors[] = __( '<strong>Error:</strong> The password matches new user data.', 'jetpack-account-protection' );
173            }
174            if ( $this->is_recent_password_hash( $user, $password ) ) {
175                $errors[] = __( '<strong>Error:</strong> The password was used recently.', 'jetpack-account-protection' );
176            }
177        }
178
179        return $errors;
180    }
181
182    /**
183     * Check if the password contains a backslash.
184     *
185     * @param string $password The password to check.
186     *
187     * @return bool True if the password contains a backslash, false otherwise.
188     */
189    public function contains_backslash( string $password ): bool {
190        return strpos( $password, '\\' ) !== false;
191    }
192
193    /**
194     * Check if the password length is within the allowed range.
195     *
196     * @param string $password The password to check.
197     *
198     * @return bool True if the password is between get_min_length() and get_max_length() characters, false otherwise.
199     */
200    public function is_invalid_length( string $password ): bool {
201        $length = strlen( $password );
202        return $length < $this->get_min_length() || $length > $this->get_max_length();
203    }
204
205    /**
206     * Get the minimum allowed password length.
207     *
208     * @return int The minimum allowed password length.
209     */
210    public function get_min_length(): int {
211        /**
212         * Filters the minimum allowed password length for Account Protection.
213         *
214         * The default is a floor: values below it are ignored, so the filter can only
215         * raise the minimum, never lower it.
216         *
217         * @since 0.3.4
218         *
219         * @param int $min_length The minimum allowed password length.
220         */
221        $min_length = (int) apply_filters( 'jetpack_account_protection_validation_min_length', Config::VALIDATION_SERVICE_MIN_LENGTH );
222        return max( Config::VALIDATION_SERVICE_MIN_LENGTH, $min_length );
223    }
224
225    /**
226     * Get the maximum allowed password length.
227     *
228     * @return int The maximum allowed password length.
229     */
230    public function get_max_length(): int {
231        /**
232         * Filters the maximum allowed password length for Account Protection.
233         *
234         * The default is a floor: values below it are ignored, so the filter can only
235         * raise the maximum, never lower it.
236         *
237         * @since 0.3.4
238         *
239         * @param int $max_length The maximum allowed password length.
240         */
241        $max_length = (int) apply_filters( 'jetpack_account_protection_validation_max_length', Config::VALIDATION_SERVICE_MAX_LENGTH );
242        return max( Config::VALIDATION_SERVICE_MAX_LENGTH, $max_length );
243    }
244
245    /**
246     * Check if the password matches any user data.
247     *
248     * @param \WP_User|\stdClass|null $user The user.
249     * @param string                  $password The password to check.
250     *
251     * @return bool True if the password matches any user data, false otherwise.
252     */
253    public function matches_user_data( $user, string $password ): bool {
254        if ( ! $user ) {
255            return false;
256        }
257
258        $email_parts    = explode( '@', $user->user_email ); // test@example.com
259        $email_username = $email_parts[0]; // 'test'
260        $email_domain   = $email_parts[1]; // 'example.com'
261        $email_provider = explode( '.', $email_domain )[0]; // 'example'
262
263        $user_data = array(
264            $user->user_login ?? '',
265            $user->display_name ?? '',
266            $user->first_name ?? '',
267            $user->last_name ?? '',
268            $user->user_email ?? '',
269            $email_username ?? '',
270            $email_provider ?? '',
271            $user->nickname ?? '',
272        );
273
274        $password_lower = strtolower( $password );
275
276        foreach ( $user_data as $data ) {
277            // Skip if $data is 3 characters or less.
278            if ( strlen( $data ) <= 3 ) {
279                continue;
280            }
281
282            if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) {
283                return true;
284            }
285        }
286
287        return false;
288    }
289
290    /**
291     * Check if the password is in the list of compromised/common passwords.
292     *
293     * @param string $password The password to check.
294     *
295     * @return bool True if the password is in the list of compromised/common passwords, false otherwise.
296     */
297    public function is_leaked_password( string $password ): bool {
298        if ( ! $this->connection_manager->is_connected() ) {
299            return false;
300        }
301
302        $hashed_password = sha1( $password );
303        $password_prefix = substr( $hashed_password, 0, 5 );
304
305        $response = $this->request_suffixes( $password_prefix );
306
307        $response_code = wp_remote_retrieve_response_code( $response );
308
309        if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
310            return false;
311        }
312
313        $body = json_decode( wp_remote_retrieve_body( $response ), true );
314
315        $password_suffix = substr( $hashed_password, 5 );
316        if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) {
317            return true;
318        }
319
320        if ( in_array( $password_suffix, $body['common'] ?? array(), true ) ) {
321            return true;
322        }
323
324        return false;
325    }
326
327    /**
328     * Check if the password is the current password for the user.
329     *
330     * @param int    $user_id  The user ID.
331     * @param string $password The password to check.
332     *
333     * @return bool True if the password is the current password, false otherwise.
334     */
335    public function is_current_password( int $user_id, string $password ): bool {
336        $user = get_userdata( $user_id );
337        if ( ! $user ) {
338            return false;
339        }
340
341        return wp_check_password( $password, $user->user_pass, $user->ID );
342    }
343
344    /**
345     * Check if the password has been used recently by the user.
346     *
347     * @param \WP_User|\stdClass $user The user data.
348     * @param string             $password The password to check.
349     *
350     * @return bool True if the password was recently used, false otherwise.
351     */
352    public function is_recent_password_hash( $user, string $password ): bool {
353        // Skip on user creation
354        if ( empty( $user->ID ) ) {
355            return false;
356        }
357
358        $user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID );
359        if ( $this->is_current_password( $user_data->ID, $password ) ) {
360            return true;
361        }
362
363        $recent_passwords = get_user_meta( $user->ID, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, true );
364        if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) {
365            return false;
366        }
367
368        foreach ( $recent_passwords as $old_hashed_password ) {
369            if ( wp_check_password( $password, $old_hashed_password ) ) {
370                return true;
371            }
372        }
373
374        return false;
375    }
376}