PluginProbe
Teydea Password Reset – Force Password Reset & Expiration / trunk
Teydea Password Reset – Force Password Reset & Expiration vtrunk
trunk 1.0.0 1.1.0 1.1.1 1.10.0 1.10.1 1.10.2 1.11.0 1.11.1 1.12.0 1.12.1 1.13.0 1.2.0 1.3.0 1.4.0 1.5.0 1.6.0 1.7.0 1.7.1 1.7.2 1.8.0 1.9.0
password-reset-enforcement / src / class-user.php

class-user.php in Teydea Password Reset – Force Password Reset & Expiration trunk, at src/class-user.php

239 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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