| @@ -173,8 +173,11 @@ | ||
| 173 | 173 | // Enforcement beyond wp-admin: REST and the front end, so an expired |
| 174 | 174 | // password cannot keep operating outside the redirect (2.11.9). |
| 175 | 175 | add_filter( 'rest_authentication_errors', array( $this, 'block_expired_password_rest' ), 20 ); |
| 176 | 176 | add_action( 'template_redirect', array( $this, 'force_password_change_frontend' ) ); |
| 177 | + // The profile is the only screen where the change can be made, and | |
| 178 | + // WooCommerce keeps accounts with no back-end role out of wp-admin. | |
| 179 | + add_filter( 'woocommerce_prevent_admin_access', array( $this, 'allow_profile_for_password_change' ) ); | |
| 177 | 180 | add_filter( 'authenticate', array( $this, 'block_expired_password_xmlrpc' ), 30, 1 ); |
| 178 | 181 | add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 ); |
| 179 | 182 | add_action( 'user_register', array( $this, 'set_initial_password_date' ) ); |
| 180 | 183 | add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 ); |
| @@ -284,9 +287,11 @@ | ||
| 284 | 287 | * @return bool |
| 285 | 288 | */ |
| 286 | 289 | private function is_insecure_username( $username ) { |
| 287 | 290 | $username = strtolower( trim( $username ) ); |
| 288 | - $insecure_usernames = $this->options['insecure_usernames'] ?? array(); | |
| 291 | + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with | |
| 292 | + // no field on screen is a leftover, not a choice. See its docblock. | |
| 293 | + $insecure_usernames = $this->get_insecure_usernames(); | |
| 289 | 294 | |
| 290 | 295 | return in_array( $username, array_map( 'strtolower', $insecure_usernames ), true ); |
| 291 | 296 | } |
| 292 | 297 | |
| @@ -988,9 +993,14 @@ | ||
| 988 | 993 | * |
| 989 | 994 | * @return array |
| 990 | 995 | */ |
| 991 | 996 | public function get_insecure_usernames() { |
| 992 | - return $this->options['insecure_usernames'] ?? array(); | |
| 997 | + // The single reader of this list, so what blocks and what the screen | |
| 998 | + // reports can never disagree. | |
| 999 | + return Vigilante_Settings::list_or_shipped( | |
| 1000 | + $this->options['insecure_usernames'] ?? array(), | |
| 1001 | + array( 'user_security', 'insecure_usernames' ) | |
| 1002 | + ); | |
| 993 | 1003 | } |
| 994 | 1004 | |
| 995 | 1005 | /** |
| 996 | 1006 | * Check for existing insecure admin users |
| @@ -1341,9 +1351,14 @@ | ||
| 1341 | 1351 | return; |
| 1342 | 1352 | } |
| 1343 | 1353 | |
| 1344 | 1354 | $settings = $this->options['registration_approval'] ?? array(); |
| 1345 | - $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' ); | |
| 1355 | + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with | |
| 1356 | + // no field on screen is a leftover, not a choice. See its docblock. | |
| 1357 | + $affected_roles = Vigilante_Settings::list_or_shipped( | |
| 1358 | + $settings['affected_roles'] ?? array(), | |
| 1359 | + array( 'user_security', 'registration_approval', 'affected_roles' ) | |
| 1360 | + ); | |
| 1346 | 1361 | |
| 1347 | 1362 | // Check if user role requires approval |
| 1348 | 1363 | $user_roles = $user->roles; |
| 1349 | 1364 | $needs_approval = array_intersect( $user_roles, $affected_roles ); |
| @@ -2307,11 +2322,13 @@ | ||
| 2307 | 2322 | } |
| 2308 | 2323 | return; |
| 2309 | 2324 | } |
| 2310 | 2325 | |
| 2311 | - // Check if must change password | |
| 2312 | - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2313 | - if ( $must_change ) { | |
| 2326 | + // Ask what the redirect asks. From 2.11.9 to 3.0.3 this read only the | |
| 2327 | + // flag, which is written at login, while force_password_change_redirect() | |
| 2328 | + // also works the expiry out on the spot: a session still open when the | |
| 2329 | + // password expired was sent to the profile with no notice saying why. | |
| 2330 | + if ( $this->must_change_password( $user_id ) ) { | |
| 2314 | 2331 | global $pagenow; |
| 2315 | 2332 | $on_profile = ( 'profile.php' === $pagenow ); |
| 2316 | 2333 | ?> |
| 2317 | 2334 | <div class="notice notice-error"> |
| @@ -2318,8 +2335,9 @@ | ||
| 2318 | 2335 | <p> |
| 2319 | 2336 | <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong> |
| 2320 | 2337 | <?php if ( $on_profile ) : ?> |
| 2321 | 2338 | <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?> |
| 2339 | + <a href="#password"><?php esc_html_e( 'Go to the password field', 'vigilante' ); ?></a> | |
| 2322 | 2340 | <?php else : ?> |
| 2323 | 2341 | <?php |
| 2324 | 2342 | printf( |
| 2325 | 2343 | /* translators: %s: Link to profile */ |
| @@ -2338,28 +2356,40 @@ | ||
| 2338 | 2356 | <?php |
| 2339 | 2357 | return; |
| 2340 | 2358 | } |
| 2341 | 2359 | |
| 2342 | - // Show warning if expiring soon | |
| 2360 | + // Show warning if expiring soon. get_days_until_expiration() rounds down, | |
| 2361 | + // so 0 here is less than a day left: an expired password never gets this | |
| 2362 | + // far, must_change_password() has answered for it above. Until 3.0.3 the | |
| 2363 | + // check asked for more than 0, so the last day showed nothing and the | |
| 2364 | + // next thing the user saw was the forced change. | |
| 2343 | 2365 | $days_left = $this->get_days_until_expiration( $user_id ); |
| 2344 | 2366 | $warning_days = absint( $settings['warning_days'] ?? 14 ); |
| 2345 | 2367 | |
| 2346 | - if ( $days_left > 0 && $days_left <= $warning_days ) { | |
| 2368 | + if ( $warning_days > 0 && $days_left >= 0 && $days_left <= $warning_days ) { | |
| 2347 | 2369 | ?> |
| 2348 | 2370 | <div class="notice notice-warning is-dismissible"> |
| 2349 | 2371 | <p> |
| 2350 | 2372 | <?php |
| 2351 | - printf( | |
| 2352 | - /* translators: 1: Number of days, 2: Link to profile */ | |
| 2353 | - esc_html( _n( | |
| 2354 | - 'Your password will expire in %1$d day. Please %2$s.', | |
| 2355 | - 'Your password will expire in %1$d days. Please %2$s.', | |
| 2356 | - $days_left, | |
| 2357 | - 'vigilante' | |
| 2358 | - ) ), | |
| 2359 | - absint( $days_left ), | |
| 2360 | - '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>' | |
| 2361 | - ); | |
| 2373 | + if ( $days_left < 1 ) { | |
| 2374 | + printf( | |
| 2375 | + /* translators: %s: Link to profile */ | |
| 2376 | + esc_html__( 'Your password expires in less than a day. Please %s.', 'vigilante' ), | |
| 2377 | + '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>' | |
| 2378 | + ); | |
| 2379 | + } else { | |
| 2380 | + printf( | |
| 2381 | + /* translators: 1: Number of days, 2: Link to profile */ | |
| 2382 | + esc_html( _n( | |
| 2383 | + 'Your password will expire in %1$d day. Please %2$s.', | |
| 2384 | + 'Your password will expire in %1$d days. Please %2$s.', | |
| 2385 | + $days_left, | |
| 2386 | + 'vigilante' | |
| 2387 | + ) ), | |
| 2388 | + absint( $days_left ), | |
| 2389 | + '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>' | |
| 2390 | + ); | |
| 2391 | + } | |
| 2362 | 2392 | ?> |
| 2363 | 2393 | </p> |
| 2364 | 2394 | </div> |
| 2365 | 2395 | <?php |
| @@ -2410,8 +2440,63 @@ | ||
| 2410 | 2440 | return $this->is_password_expired( $user_id ); |
| 2411 | 2441 | } |
| 2412 | 2442 | |
| 2413 | 2443 | /** |
| 2444 | + * Whether this request is the user's own profile form and nothing else | |
| 2445 | + * | |
| 2446 | + * A user who must change an expired password is let through to the profile, | |
| 2447 | + * because that is where the change is made. Until 3.0.3 "the profile" meant | |
| 2448 | + * the name of the script, and profile.php can be asked for other things: the | |
| 2449 | + * admin bootstrap runs a plugin page or an importer named in the address | |
| 2450 | + * whatever the script is, and fires the hook of any action it is sent | |
| 2451 | + * (wp-admin/admin.php), and the profile script edits whichever user the | |
| 2452 | + * request names (wp-admin/user-edit.php). The pending change could be put | |
| 2453 | + * off while part of the admin area was still in use. | |
| 2454 | + * | |
| 2455 | + * Two requests are the profile form: the one that shows it, which posts | |
| 2456 | + * nothing, and the one that saves it carrying the nonce WordPress printed | |
| 2457 | + * on this user's own form. Parameters in the address that WordPress does | |
| 2458 | + * nothing with are left alone, so a proxy or a browser extension that adds | |
| 2459 | + * one to every address does not send the user round in circles. | |
| 2460 | + * | |
| 2461 | + * Meant for admin_init. wp-admin/profile.php defines IS_PROFILE_PAGE before | |
| 2462 | + * it loads the admin bootstrap, so the constant is there when that script is | |
| 2463 | + * the one running. The network admin and user dashboard copies load the | |
| 2464 | + * bootstrap first: from them the user is taken to the site profile, where | |
| 2465 | + * the notice is shown. | |
| 2466 | + * | |
| 2467 | + * @return bool | |
| 2468 | + */ | |
| 2469 | + private function is_own_profile_request() { | |
| 2470 | + global $pagenow, $plugin_page; | |
| 2471 | + | |
| 2472 | + if ( 'profile.php' !== $pagenow || ! defined( 'IS_PROFILE_PAGE' ) || ! IS_PROFILE_PAGE ) { | |
| 2473 | + return false; | |
| 2474 | + } | |
| 2475 | + | |
| 2476 | + // A plugin page or an importer named in the address. | |
| 2477 | + if ( isset( $plugin_page ) || isset( $_GET['import'] ) ) { | |
| 2478 | + return false; | |
| 2479 | + } | |
| 2480 | + | |
| 2481 | + // Somebody else's profile. | |
| 2482 | + $own_id = (string) get_current_user_id(); | |
| 2483 | + if ( ! empty( $_REQUEST['user_id'] ) && $own_id !== $_REQUEST['user_id'] ) { | |
| 2484 | + return false; | |
| 2485 | + } | |
| 2486 | + | |
| 2487 | + // No action: the form being shown, which posts nothing. | |
| 2488 | + if ( empty( $_REQUEST['action'] ) ) { | |
| 2489 | + return empty( $_POST ); | |
| 2490 | + } | |
| 2491 | + | |
| 2492 | + // An action: only the form being saved. | |
| 2493 | + return 'update' === $_REQUEST['action'] | |
| 2494 | + && isset( $_REQUEST['_wpnonce'] ) | |
| 2495 | + && false !== wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'update-user_' . $own_id ); | |
| 2496 | + } | |
| 2497 | + | |
| 2498 | + /** | |
| 2414 | 2499 | * Force a user with an expired password to change it, on wp-admin and AJAX |
| 2415 | 2500 | * |
| 2416 | 2501 | * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an |
| 2417 | 2502 | * expired-password session kept working through admin-ajax, and the REST API |
| @@ -2419,9 +2504,11 @@ | ||
| 2419 | 2504 | * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag |
| 2420 | 2505 | * is only read by one redirect. It is now enforced on every entry point, |
| 2421 | 2506 | * here for wp-admin and AJAX and in the three methods below for REST, the |
| 2422 | 2507 | * front end and XML-RPC. The only thing an affected user can still do is |
| 2423 | - * change the password on profile.php or log out. | |
| 2508 | + * change the password on profile.php or log out. From 2.11.9 to 3.0.3 that | |
| 2509 | + * was not quite true, because any request to profile.php was let through: | |
| 2510 | + * see is_own_profile_request(). | |
| 2424 | 2511 | */ |
| 2425 | 2512 | public function force_password_change_redirect() { |
| 2426 | 2513 | if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { |
| 2427 | 2514 | return; |
| @@ -2436,15 +2523,46 @@ | ||
| 2436 | 2523 | 403 |
| 2437 | 2524 | ); |
| 2438 | 2525 | } |
| 2439 | 2526 | |
| 2440 | - // profile.php is where the change happens; do not redirect it onto itself. | |
| 2441 | - global $pagenow; | |
| 2442 | - if ( 'profile.php' === $pagenow ) { | |
| 2527 | + // The profile form is where the change happens; do not redirect it onto | |
| 2528 | + // itself. Anything else profile.php is asked for goes back to the form. | |
| 2529 | + if ( $this->is_own_profile_request() ) { | |
| 2443 | 2530 | return; |
| 2444 | 2531 | } |
| 2445 | 2532 | |
| 2446 | - wp_safe_redirect( admin_url( 'profile.php#password' ) ); | |
| 2533 | + $this->redirect_to_profile(); | |
| 2534 | + } | |
| 2535 | + | |
| 2536 | + /** | |
| 2537 | + * Send a user who must change an expired password to their profile | |
| 2538 | + * | |
| 2539 | + * To the top of it, with no "#password" fragment (it had one from 1.0.0 to | |
| 2540 | + * 3.0.3): the browser jumped straight to the password row and left the | |
| 2541 | + * notice that says why, printed at the top of the page, out of sight. The | |
| 2542 | + * notice carries the link to the field instead. | |
| 2543 | + * | |
| 2544 | + * wp_safe_redirect() answers an address it does not take for the site's | |
| 2545 | + * own with a redirect to admin_url(), the dashboard, and the dashboard sent | |
| 2546 | + * the user here again, and so on until the browser gave up. It only knows | |
| 2547 | + * the home host, so that happened on a site whose WordPress lives on a host | |
| 2548 | + * of its own, and also where the host has non-ASCII letters stored as | |
| 2549 | + * written or is an IPv6 literal. The address sent is built here from the | |
| 2550 | + * site's own settings and carries nothing from the request, so the fallback | |
| 2551 | + * for this one call is that same address: what admin_url() says is as good | |
| 2552 | + * for the profile as it is for the dashboard WordPress falls back to. | |
| 2553 | + */ | |
| 2554 | + private function redirect_to_profile() { | |
| 2555 | + $profile = admin_url( 'profile.php' ); | |
| 2556 | + | |
| 2557 | + add_filter( | |
| 2558 | + 'wp_safe_redirect_fallback', | |
| 2559 | + static function () use ( $profile ) { | |
| 2560 | + return $profile; | |
| 2561 | + } | |
| 2562 | + ); | |
| 2563 | + | |
| 2564 | + wp_safe_redirect( $profile ); | |
| 2447 | 2565 | exit; |
| 2448 | 2566 | } |
| 2449 | 2567 | |
| 2450 | 2568 | /** |
| @@ -2482,13 +2600,46 @@ | ||
| 2482 | 2600 | if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { |
| 2483 | 2601 | return; |
| 2484 | 2602 | } |
| 2485 | 2603 | |
| 2486 | - wp_safe_redirect( admin_url( 'profile.php#password' ) ); | |
| 2487 | - exit; | |
| 2604 | + $this->redirect_to_profile(); | |
| 2488 | 2605 | } |
| 2489 | 2606 | |
| 2490 | 2607 | /** |
| 2608 | + * Let a user who must change an expired password reach the profile in a WooCommerce store | |
| 2609 | + * | |
| 2610 | + * WooCommerce sends accounts with no back-end role (a customer, a subscriber) | |
| 2611 | + * from wp-admin to My Account, and force_password_change_frontend() sends | |
| 2612 | + * them from there back to the profile. With one of those roles in Affected | |
| 2613 | + * Roles the two redirects chased each other until the browser gave up, from | |
| 2614 | + * 2.11.9 to 3.0.3, and the account could neither use the store nor change | |
| 2615 | + * the password. | |
| 2616 | + * | |
| 2617 | + * WooCommerce's redirect is lifted on the profile and nowhere else, and only | |
| 2618 | + * while the change is pending: from any other wp-admin screen the account | |
| 2619 | + * still ends up on the profile, and once the new password is saved | |
| 2620 | + * WooCommerce sends it back to My Account as it always does. | |
| 2621 | + * | |
| 2622 | + * What this depends on is not the caller's to choose: the user comes from | |
| 2623 | + * the session, whether the change is pending from must_change_password(), | |
| 2624 | + * and the request has to be the profile form and nothing else, which is | |
| 2625 | + * is_own_profile_request(), the same test the redirect uses. What it gives | |
| 2626 | + * is the user's own profile, which WordPress opens to any account that can | |
| 2627 | + * read (wp-admin/menu.php); WooCommerce's redirect is the store's | |
| 2628 | + * preference, not a permission. | |
| 2629 | + * | |
| 2630 | + * @param bool $prevent_access Whether WooCommerce is about to send this request out of wp-admin. | |
| 2631 | + * @return bool | |
| 2632 | + */ | |
| 2633 | + public function allow_profile_for_password_change( $prevent_access ) { | |
| 2634 | + if ( $prevent_access && is_user_logged_in() && $this->is_own_profile_request() && $this->must_change_password( get_current_user_id() ) ) { | |
| 2635 | + return false; | |
| 2636 | + } | |
| 2637 | + | |
| 2638 | + return $prevent_access; | |
| 2639 | + } | |
| 2640 | + | |
| 2641 | + /** | |
| 2491 | 2642 | * Refuse XML-RPC calls from a user whose password has expired |
| 2492 | 2643 | * |
| 2493 | 2644 | * The last of the four non-wp-admin entry points. XML-RPC authenticates on |
| 2494 | 2645 | * every call with the account credentials (a password or an application |
| @@ -3232,9 +3383,9 @@ | ||
| 3232 | 3383 | |
| 3233 | 3384 | // Check if this user's role requires approval |
| 3234 | 3385 | $needs_approval = false; |
| 3235 | 3386 | if ( ! empty( $registration_approval['enabled'] ) ) { |
| 3236 | - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' ); | |
| 3387 | + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) ); | |
| 3237 | 3388 | $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) ); |
| 3238 | 3389 | } |
| 3239 | 3390 | |
| 3240 | 3391 | // Check if email verification is enabled |
| @@ -3346,9 +3497,9 @@ | ||
| 3346 | 3497 | if ( empty( $registration_approval['enabled'] ) ) { |
| 3347 | 3498 | return false; |
| 3348 | 3499 | } |
| 3349 | 3500 | |
| 3350 | - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' ); | |
| 3501 | + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) ); | |
| 3351 | 3502 | return ! empty( array_intersect( $user->roles, $affected_roles ) ); |
| 3352 | 3503 | } |
| 3353 | 3504 | |
| 3354 | 3505 | /** |