| 1 |
<?php |
| 2 |
/** |
| 3 |
* Forgot-password / reset-password form handlers. |
| 4 |
* |
| 5 |
* Two `admin-post.php` actions, both available to logged-out visitors: |
| 6 |
* |
| 7 |
* - `storeengine/forgot_password/request_reset` |
| 8 |
* Accept an email, hand off to WP core's retrieve_password() so the |
| 9 |
* branded HTML reset email (PasswordReset class) goes out, then |
| 10 |
* redirect back with a generic success flag. Generic on purpose — |
| 11 |
* differentiating "email found" vs "email not found" would leak |
| 12 |
* account existence. |
| 13 |
* |
| 14 |
* - `storeengine/forgot_password/set_password` |
| 15 |
* Validate the {key, login} pair against WP core's |
| 16 |
* check_password_reset_key(), then call reset_password() so the |
| 17 |
* standard `password_reset` action chain fires for integrations. |
| 18 |
* |
| 19 |
* Both flows route through the existing AbstractPostHandler pipeline so |
| 20 |
* we inherit its nonce verification, field sanitization, and |
| 21 |
* allow_visitor_action plumbing without reimplementing it. |
| 22 |
*/ |
| 23 |
|
| 24 |
namespace StoreEngine\Post; |
| 25 |
|
| 26 |
if ( ! defined( 'ABSPATH' ) ) { |
| 27 |
exit; |
| 28 |
} |
| 29 |
|
| 30 |
use StoreEngine\Classes\AbstractPostHandler; |
| 31 |
use StoreEngine\Utils\Helper; |
| 32 |
use WP_Error; |
| 33 |
use WP_User; |
| 34 |
|
| 35 |
class ForgotPassword extends AbstractPostHandler { |
| 36 |
|
| 37 |
public function __construct() { |
| 38 |
$this->actions = [ |
| 39 |
'forgot_password/request_reset' => [ |
| 40 |
'callback' => [ $this, 'request_reset' ], |
| 41 |
'capability' => '', |
| 42 |
'allow_visitor_action' => true, |
| 43 |
'fields' => [ |
| 44 |
'email' => 'string', |
| 45 |
], |
| 46 |
], |
| 47 |
'forgot_password/set_password' => [ |
| 48 |
'callback' => [ $this, 'set_password' ], |
| 49 |
'capability' => '', |
| 50 |
'allow_visitor_action' => true, |
| 51 |
'fields' => [ |
| 52 |
'key' => 'string', |
| 53 |
'login' => 'string', |
| 54 |
'pass1' => 'string', |
| 55 |
'pass2' => 'string', |
| 56 |
], |
| 57 |
], |
| 58 |
]; |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Step 1: visitor enters email, we ask WP to send the reset email. |
| 63 |
* |
| 64 |
* We always redirect to the same success state regardless of whether |
| 65 |
* the email matches a real account — leaking existence here is the |
| 66 |
* classic account-enumeration mistake. |
| 67 |
*/ |
| 68 |
public function request_reset( array $payload ) { |
| 69 |
$endpoint_url = Helper::get_account_endpoint_url( 'forgot-password' ); |
| 70 |
|
| 71 |
$email = isset( $payload['email'] ) ? sanitize_email( $payload['email'] ) : ''; |
| 72 |
if ( ! $email || ! is_email( $email ) ) { |
| 73 |
wp_safe_redirect( add_query_arg( 'reset_error', 'invalid_email', $endpoint_url ) ); |
| 74 |
die(); |
| 75 |
} |
| 76 |
|
| 77 |
// retrieve_password() validates that the account exists, generates |
| 78 |
// a reset key, persists it via update_user_meta, and then fires |
| 79 |
// `retrieve_password_notification_email` — which our PasswordReset |
| 80 |
// email class hooks to send the branded HTML version. |
| 81 |
// |
| 82 |
// On unknown email it returns a WP_Error; we deliberately swallow it |
| 83 |
// so the response is identical to the success path. |
| 84 |
retrieve_password( $email ); |
| 85 |
|
| 86 |
wp_safe_redirect( add_query_arg( 'reset_sent', '1', $endpoint_url ) ); |
| 87 |
die(); |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* Step 2: visitor returns via the email link with a {key, login} pair, |
| 92 |
* picks a new password, submits. |
| 93 |
*/ |
| 94 |
public function set_password( array $payload ) { |
| 95 |
$endpoint_url = Helper::get_account_endpoint_url( 'forgot-password' ); |
| 96 |
|
| 97 |
$key = isset( $payload['key'] ) ? sanitize_text_field( $payload['key'] ) : ''; |
| 98 |
$login = isset( $payload['login'] ) ? sanitize_text_field( $payload['login'] ) : ''; |
| 99 |
$pass1 = $payload['pass1'] ?? ''; |
| 100 |
$pass2 = $payload['pass2'] ?? ''; |
| 101 |
|
| 102 |
if ( ! $key || ! $login ) { |
| 103 |
return new WP_Error( |
| 104 |
'invalid_reset_link', |
| 105 |
__( 'This reset link is missing required information. Please request a new one.', 'storeengine' ), |
| 106 |
[ 'status' => 400, 'title' => __( 'Invalid reset link', 'storeengine' ) ] |
| 107 |
); |
| 108 |
} |
| 109 |
|
| 110 |
if ( '' === $pass1 || '' === $pass2 ) { |
| 111 |
wp_safe_redirect( add_query_arg( |
| 112 |
[ 'key' => $key, 'login' => $login, 'reset_error' => 'empty' ], |
| 113 |
$endpoint_url |
| 114 |
) ); |
| 115 |
die(); |
| 116 |
} |
| 117 |
|
| 118 |
// Minimum-length policy. Filterable so a site can tighten it (e.g. |
| 119 |
// require 12+ chars) without having to fork this handler. WP doesn't |
| 120 |
// enforce a minimum at the reset_password() layer, so without this |
| 121 |
// check `'a'` would be a valid new password. |
| 122 |
$min_length = (int) apply_filters( 'storeengine/auth/min_password_length', 8 ); |
| 123 |
if ( strlen( $pass1 ) < $min_length ) { |
| 124 |
wp_safe_redirect( add_query_arg( |
| 125 |
[ 'key' => $key, 'login' => $login, 'reset_error' => 'too_short' ], |
| 126 |
$endpoint_url |
| 127 |
) ); |
| 128 |
die(); |
| 129 |
} |
| 130 |
|
| 131 |
if ( $pass1 !== $pass2 ) { |
| 132 |
wp_safe_redirect( add_query_arg( |
| 133 |
[ 'key' => $key, 'login' => $login, 'reset_error' => 'mismatch' ], |
| 134 |
$endpoint_url |
| 135 |
) ); |
| 136 |
die(); |
| 137 |
} |
| 138 |
|
| 139 |
$user = check_password_reset_key( $key, $login ); |
| 140 |
if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) { |
| 141 |
return new WP_Error( |
| 142 |
'expired_reset_link', |
| 143 |
__( 'This reset link is invalid or has expired. Please request a new one.', 'storeengine' ), |
| 144 |
[ 'status' => 400, 'title' => __( 'Invalid reset link', 'storeengine' ) ] |
| 145 |
); |
| 146 |
} |
| 147 |
|
| 148 |
reset_password( $user, $pass1 ); |
| 149 |
|
| 150 |
// Land the customer on the StoreEngine dashboard, not wp-login.php. |
| 151 |
// For logged-out visitors, the dashboard page's [storeengine_dashboard] |
| 152 |
// shortcode renders [storeengine_login_form] — the branded sign-in |
| 153 |
// form. Using storeengine_login_url() here was unsafe because it |
| 154 |
// falls back to wp_login_url() whenever auth_redirect_type isn't |
| 155 |
// exactly 'storeengine' or the dashboard_page setting is missing, |
| 156 |
// dropping the customer on WP's default login screen at the end of |
| 157 |
// what's supposed to be a fully branded flow. |
| 158 |
wp_safe_redirect( add_query_arg( 'password_updated', '1', Helper::get_dashboard_url() ) ); |
| 159 |
die(); |
| 160 |
} |
| 161 |
} |
| 162 |
|