| 1 |
<?php |
| 2 |
/** |
| 3 |
* User class |
| 4 |
* |
| 5 |
* @package Teydea_Studio\Password_Reset |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Teydea_Studio\Password_Reset; |
| 9 |
|
| 10 |
use Teydea_Studio\Password_Reset\Dependencies\Utils; |
| 11 |
use WP_Error; |
| 12 |
use WP_User; |
| 13 |
|
| 14 |
if ( ! defined( 'ABSPATH' ) ) { |
| 15 |
exit; // @codeCoverageIgnore |
| 16 |
} |
| 17 |
|
| 18 |
/** |
| 19 |
* The "User" class |
| 20 |
*/ |
| 21 |
class User extends Utils\User { |
| 22 |
/** |
| 23 |
* User meta key (short form): the "password reset enforcement" request |
| 24 |
* |
| 25 |
* Short, plugin-relative form — {@see Utils\User::get_prefixed_meta_key()} |
| 26 |
* prepends the plugin's data prefix when reading/writing, producing |
| 27 |
* the stored key `password_reset_enforcement__request`. |
| 28 |
* |
| 29 |
* @var string |
| 30 |
*/ |
| 31 |
const USER_META_KEY__REQUEST = 'request'; |
| 32 |
|
| 33 |
/** |
| 34 |
* Configuration of the "password reset enforcement" request of this user |
| 35 |
* |
| 36 |
* @var ?array{requested_at:int,requested_by:int|string,with_current_password_allowed:bool} |
| 37 |
*/ |
| 38 |
protected ?array $request_config = null; |
| 39 |
|
| 40 |
/** |
| 41 |
* Flag to indicate whether the user data has been loaded or not |
| 42 |
* |
| 43 |
* @var bool |
| 44 |
*/ |
| 45 |
protected bool $is_request_config_loaded = false; |
| 46 |
|
| 47 |
/** |
| 48 |
* Send email with a password reset link |
| 49 |
* |
| 50 |
* @return void |
| 51 |
*/ |
| 52 |
public function send_email_with_link(): void { |
| 53 |
$user = $this->get_user(); |
| 54 |
|
| 55 |
if ( $user instanceof WP_User ) { |
| 56 |
/** |
| 57 |
* Filter content of the message sent in email |
| 58 |
* |
| 59 |
* Password reset is mandatory in this case, hence removing the |
| 60 |
* "nothing will happen" paragraph. |
| 61 |
* |
| 62 |
* @param string $message Original message. |
| 63 |
* |
| 64 |
* @return string Filtered message. |
| 65 |
*/ |
| 66 |
$filter = function ( string $message ): string { |
| 67 |
return str_replace( __( 'If this was a mistake, ignore this email and nothing will happen.' ) . "\r\n\r\n", '', $message ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- intentionally, because in this case we want to use the same translation as in WordPress core. |
| 68 |
}; |
| 69 |
|
| 70 |
/** |
| 71 |
* Scope the filter to this single send; on bulk operations the closure |
| 72 |
* would otherwise accumulate on the filter chain, once per processed user. |
| 73 |
*/ |
| 74 |
add_filter( 'retrieve_password_message', $filter ); |
| 75 |
retrieve_password( $user->user_login ); |
| 76 |
remove_filter( 'retrieve_password_message', $filter ); |
| 77 |
} |
| 78 |
} |
| 79 |
|
| 80 |
/** |
| 81 |
* Check if this user is authorized to force a password reset for the target user |
| 82 |
* |
| 83 |
* The plugin's managing capability alone must not authorize acting on an |
| 84 |
* arbitrary target: it is granted to every site administrator, so on |
| 85 |
* multisite it would otherwise let a subsite admin act on super admins |
| 86 |
* and on users of other sites. WordPress core's `edit_user` meta |
| 87 |
* capability already encodes the correct per-target rules — on |
| 88 |
* multisite it requires `manage_network_users` and denies non |
| 89 |
* super-admins editing super admins — so deferring to it mirrors the |
| 90 |
* guards core applies to destructive user actions in |
| 91 |
* "wp-admin/users.php" without second-guessing them. |
| 92 |
* |
| 93 |
* @param int $target_user_id ID of the user the action would affect. |
| 94 |
* |
| 95 |
* @return bool Boolean "true" if this user is authorized to force a password reset for the target user, "false" otherwise. |
| 96 |
*/ |
| 97 |
public function can_force_password_reset_for( int $target_user_id ): bool { |
| 98 |
$user_id = $this->get_user_id(); |
| 99 |
|
| 100 |
if ( null === $user_id || $target_user_id === $user_id ) { |
| 101 |
return false; |
| 102 |
} |
| 103 |
|
| 104 |
return user_can( $user_id, 'edit_user', $target_user_id ); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Add user meta to controll the password reset request for this user |
| 109 |
* |
| 110 |
* @param int|string $requestor ID of user who requested the password reset, string "WP-CLI" if requested via WP-CLI, or other string identifier. |
| 111 |
* @param bool $with_current_password_allowed Whether the current password is allowed to initiate the password reset process or not. |
| 112 |
* |
| 113 |
* @return void |
| 114 |
*/ |
| 115 |
public function force_password_reset( $requestor, bool $with_current_password_allowed ): void { |
| 116 |
$this->update_meta( |
| 117 |
self::USER_META_KEY__REQUEST, |
| 118 |
[ |
| 119 |
'requested_at' => Utils\Date_Time::get_utc_timestamp(), |
| 120 |
'requested_by' => $requestor, |
| 121 |
'with_current_password_allowed' => $with_current_password_allowed, |
| 122 |
], |
| 123 |
); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Remove the password reset request data from user meta |
| 128 |
* |
| 129 |
* This is triggered only after user successfully reset their password |
| 130 |
* |
| 131 |
* @return void |
| 132 |
*/ |
| 133 |
public function remove_password_reset_enforcement(): void { |
| 134 |
$this->delete_meta( self::USER_META_KEY__REQUEST ); |
| 135 |
} |
| 136 |
|
| 137 |
/** |
| 138 |
* Check if a password reset was requested for this user |
| 139 |
* |
| 140 |
* @return bool Whether the password reset is required or not. |
| 141 |
*/ |
| 142 |
public function is_password_reset_required(): bool { |
| 143 |
if ( null === $this->get_user_id() ) { |
| 144 |
return false; |
| 145 |
} |
| 146 |
|
| 147 |
return is_array( $this->get_password_reset_request_data() ); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Get the password reset event data |
| 152 |
* |
| 153 |
* @return ?array{requested_at:int,requested_by:int|string,with_current_password_allowed:bool} Password reset event data, or null if no password reset was requested. |
| 154 |
*/ |
| 155 |
public function get_password_reset_request_data(): ?array { |
| 156 |
if ( false === $this->is_request_config_loaded ) { |
| 157 |
// Update the flag to avoid multiple loading attempts. |
| 158 |
$this->is_request_config_loaded = true; |
| 159 |
|
| 160 |
/** @var array<string,mixed> $meta_value */ |
| 161 |
$meta_value = $this->get_meta_as_array( self::USER_META_KEY__REQUEST ); |
| 162 |
|
| 163 |
if ( ! empty( $meta_value ) ) { |
| 164 |
/** |
| 165 |
* Normalize the stored shape here so every consumer receives a |
| 166 |
* fully-populated, type-coerced array. A missing |
| 167 |
* "with_current_password_allowed" defaults to the strict branch. |
| 168 |
*/ |
| 169 |
$requested_by = $meta_value['requested_by'] ?? 0; |
| 170 |
|
| 171 |
$this->request_config = [ |
| 172 |
'requested_at' => Utils\Type::ensure_int( $meta_value['requested_at'] ?? 0 ), |
| 173 |
'requested_by' => is_string( $requested_by ) ? $requested_by : Utils\Type::ensure_int( $requested_by ), |
| 174 |
'with_current_password_allowed' => Utils\Type::ensure_bool( $meta_value['with_current_password_allowed'] ?? false ), |
| 175 |
]; |
| 176 |
} |
| 177 |
} |
| 178 |
|
| 179 |
return $this->request_config; |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Resolve the display name of the user (or actor) who requested the reset |
| 184 |
* |
| 185 |
* @param int|string $requestor Numeric user ID, or a string identifier (e.g. "WP-CLI"). |
| 186 |
* |
| 187 |
* @return string Human-readable requestor label. |
| 188 |
*/ |
| 189 |
public function get_requestor_display_name( $requestor ): string { |
| 190 |
return is_string( $requestor ) |
| 191 |
? $requestor |
| 192 |
: ( get_the_author_meta( 'display_name', Utils\Type::ensure_int( $requestor ) ) ?: __( 'an unknown user', 'password-reset-enforcement' ) ); |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Get the link to the password reset form |
| 197 |
* |
| 198 |
* @return null|string|WP_Error Link to the password reset form; instance of WP_Error in case of "spammy", non-existed, or non-logged-in users. |
| 199 |
*/ |
| 200 |
public function get_password_reset_form_link() { |
| 201 |
$login_url = wp_login_url(); |
| 202 |
$request_config = $this->get_password_reset_request_data(); |
| 203 |
|
| 204 |
if ( null === $request_config || false === $request_config['with_current_password_allowed'] ) { |
| 205 |
$link = add_query_arg( |
| 206 |
[ 'action' => 'lostpassword' ], |
| 207 |
$login_url, |
| 208 |
); |
| 209 |
} else { |
| 210 |
$user = $this->get_user(); |
| 211 |
$key = ''; |
| 212 |
|
| 213 |
if ( $user instanceof WP_User ) { |
| 214 |
$key = get_password_reset_key( $user ); |
| 215 |
} |
| 216 |
|
| 217 |
// This can only happen to users who are marked as "spammy" or don't exist. |
| 218 |
if ( null === $user || empty( $key ) || $key instanceof WP_Error ) { |
| 219 |
return new WP_Error( |
| 220 |
'password_invalidated', |
| 221 |
__( '<strong>Error:</strong> Your current password has been invalidated. Please contact the administrator to get the new password.', 'password-reset-enforcement' ), |
| 222 |
); |
| 223 |
} |
| 224 |
|
| 225 |
$link = add_query_arg( |
| 226 |
[ |
| 227 |
'action' => 'rp', |
| 228 |
'key' => $key, |
| 229 |
'login' => rawurlencode( $user->user_login ), |
| 230 |
'wp_lang' => get_user_locale( $user ), |
| 231 |
], |
| 232 |
$login_url, |
| 233 |
); |
| 234 |
} |
| 235 |
|
| 236 |
return $link; |
| 237 |
} |
| 238 |
} |
| 239 |
|