PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-account-protection / src / class-validation-service.php

class-validation-service.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-account-protection/src/class-validation-service.php

377 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class used to define Validation Service.
4 *
5 * @package automattic/jetpack-account-protection
6 */
7
8 namespace Automattic\Jetpack\Account_Protection;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12
13 /**
14 * Class Validation_Service
15 */
16 class 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 ); // [email protected]
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 }
377