class-avcf-abilities-base.php
3 weeks ago
class-avcf-abilities-block-navigation.php
3 weeks ago
class-avcf-abilities-cache.php
3 weeks ago
class-avcf-abilities-content.php
3 weeks ago
class-avcf-abilities-core.php
3 weeks ago
class-avcf-abilities-global-styles.php
3 weeks ago
class-avcf-abilities-gutenberg.php
3 weeks ago
class-avcf-abilities-media.php
3 weeks ago
class-avcf-abilities-metadata.php
3 weeks ago
class-avcf-abilities-navigation.php
3 weeks ago
class-avcf-abilities-patterns.php
3 weeks ago
class-avcf-abilities-plugins.php
3 weeks ago
class-avcf-abilities-settings.php
3 weeks ago
class-avcf-abilities-taxonomies.php
3 weeks ago
class-avcf-abilities-templates.php
3 weeks ago
class-avcf-abilities-theme-files.php
3 weeks ago
class-avcf-abilities-themes.php
3 weeks ago
class-avcf-abilities-users.php
3 weeks ago
class-avcf-abilities-users.php
1057 lines
| 1 | <?php |
| 2 | /** |
| 3 | * User and permissions management MCP abilities. |
| 4 | * |
| 5 | * Registers Atarim/* abilities for working with WordPress user accounts — |
| 6 | * full CRUD, role changes, and detection of two-factor authentication |
| 7 | * status across the three most common 2FA plugins. |
| 8 | * |
| 9 | * Uses standard WordPress capability checks (list_users / create_users / |
| 10 | * edit_users / delete_users / promote_users). Token validation happens |
| 11 | * upstream in AVCF_MCP::avcf_mcp_authenticate_request, which maps the |
| 12 | * incoming token to a real WP user — these abilities then check that |
| 13 | * mapped user's caps via current_user_can(). This is the same model used |
| 14 | * by every other ability cluster in DoIt. |
| 15 | * |
| 16 | * Exposed abilities: |
| 17 | * atarim/list-users List users with role / email / registered date filters. |
| 18 | * atarim/get-user Single user by ID with full detail, optional meta keys. |
| 19 | * atarim/create-user Create a user account. |
| 20 | * atarim/update-user Update profile fields (not password — separate concern). |
| 21 | * atarim/delete-user Delete a user; refuses to leave content orphaned without explicit reassign target. |
| 22 | * atarim/change-user-role Dedicated role-change ability (replace, add, remove). |
| 23 | * atarim/get-2fa-status Read 2FA enrollment status across Two Factor, WP 2FA, Wordfence. |
| 24 | * |
| 25 | * Note: ability names registered here must also be added to the $tools array |
| 26 | * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the |
| 27 | * MCP server. |
| 28 | * |
| 29 | * @package atarim-visual-collaboration |
| 30 | */ |
| 31 | |
| 32 | if ( ! defined('ABSPATH') ) { |
| 33 | exit; |
| 34 | } |
| 35 | |
| 36 | class AVCF_Abilities_Users extends AVCF_Abilities_Base { |
| 37 | |
| 38 | /** |
| 39 | * Register all user management abilities. |
| 40 | */ |
| 41 | public function register() { |
| 42 | |
| 43 | // ---- list-users ---- |
| 44 | wp_register_ability( 'atarim/list-users', [ |
| 45 | 'label' => 'List Users', |
| 46 | 'description' => 'Returns user accounts on the site with role, email, display name, and registered date. Supports filtering by role and by registration date range, free-text search across login / email / display_name, and pagination. Does NOT include the last_login field — WordPress does not track this natively; if you need login history, query the activity log plugin if one is installed.', |
| 47 | 'category' => 'atarim', |
| 48 | 'input_schema' => [ |
| 49 | 'type' => 'object', |
| 50 | 'properties' => [ |
| 51 | 'role' => [ |
| 52 | 'type' => 'string', |
| 53 | 'description' => 'Filter by role slug (e.g. "administrator", "editor", "subscriber"). Omit for all roles.', |
| 54 | 'minLength' => 1, |
| 55 | ], |
| 56 | 'search' => [ |
| 57 | 'type' => 'string', |
| 58 | 'description' => 'Match login, email, or display name containing this string.', |
| 59 | 'minLength' => 1, |
| 60 | ], |
| 61 | 'registered_after' => [ |
| 62 | 'type' => 'string', |
| 63 | 'description' => 'Only users registered on or after this date. ISO 8601 or strtotime()-parseable.', |
| 64 | ], |
| 65 | 'registered_before' => [ |
| 66 | 'type' => 'string', |
| 67 | 'description' => 'Only users registered on or before this date.', |
| 68 | ], |
| 69 | 'orderby' => [ |
| 70 | 'type' => 'string', |
| 71 | 'enum' => [ 'ID', 'login', 'email', 'display_name', 'registered' ], |
| 72 | 'default' => 'registered', |
| 73 | ], |
| 74 | 'order' => [ |
| 75 | 'type' => 'string', |
| 76 | 'enum' => [ 'ASC', 'DESC' ], |
| 77 | 'default' => 'DESC', |
| 78 | ], |
| 79 | 'limit' => [ |
| 80 | 'type' => 'integer', |
| 81 | 'description' => 'Max users per page. -1 returns all (use carefully on large sites). Defaults to 50.', |
| 82 | 'default' => 50, |
| 83 | 'minimum' => -1, |
| 84 | ], |
| 85 | 'offset' => [ |
| 86 | 'type' => 'integer', |
| 87 | 'description' => 'Skip this many users (for pagination).', |
| 88 | 'default' => 0, |
| 89 | 'minimum' => 0, |
| 90 | ], |
| 91 | ], |
| 92 | 'additionalProperties' => false, |
| 93 | ], |
| 94 | 'output_schema' => [ |
| 95 | 'type' => 'object', |
| 96 | 'properties' => [ |
| 97 | 'total' => [ 'type' => 'integer' ], |
| 98 | 'returned' => [ 'type' => 'integer' ], |
| 99 | 'users' => [ |
| 100 | 'type' => 'array', |
| 101 | 'items' => [ |
| 102 | 'type' => 'object', |
| 103 | 'properties' => [ |
| 104 | 'id' => [ 'type' => 'integer' ], |
| 105 | 'login' => [ 'type' => 'string' ], |
| 106 | 'email' => [ 'type' => 'string' ], |
| 107 | 'display_name' => [ 'type' => 'string' ], |
| 108 | 'first_name' => [ 'type' => 'string' ], |
| 109 | 'last_name' => [ 'type' => 'string' ], |
| 110 | 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 111 | 'registered' => [ 'type' => 'string' ], |
| 112 | 'post_count' => [ 'type' => 'integer' ], |
| 113 | ], |
| 114 | ], |
| 115 | ], |
| 116 | ], |
| 117 | 'required' => [ 'total', 'returned', 'users' ], |
| 118 | ], |
| 119 | 'execute_callback' => function( $input = [] ) { |
| 120 | $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 50; |
| 121 | $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0; |
| 122 | |
| 123 | $args = [ |
| 124 | 'orderby' => isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'registered', |
| 125 | 'order' => ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC', |
| 126 | 'number' => ( $limit > 0 ) ? $limit : -1, |
| 127 | 'offset' => $offset, |
| 128 | ]; |
| 129 | |
| 130 | if ( ! empty( $input['role'] ) ) { |
| 131 | $args['role'] = sanitize_key( (string) $input['role'] ); |
| 132 | } |
| 133 | if ( ! empty( $input['search'] ) ) { |
| 134 | $args['search'] = '*' . esc_attr( (string) $input['search'] ) . '*'; |
| 135 | $args['search_columns'] = [ 'user_login', 'user_email', 'display_name' ]; |
| 136 | } |
| 137 | |
| 138 | // Date range filter — WP_User_Query supports date_query. |
| 139 | if ( ! empty( $input['registered_after'] ) || ! empty( $input['registered_before'] ) ) { |
| 140 | $date_query = []; |
| 141 | if ( ! empty( $input['registered_after'] ) ) { |
| 142 | list( $after, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_after'] ); |
| 143 | if ( $err !== null ) { |
| 144 | return [ 'total' => 0, 'returned' => 0, 'users' => [], 'message' => 'registered_after: ' . $err ]; |
| 145 | } |
| 146 | $date_query['after'] = $after; |
| 147 | } |
| 148 | if ( ! empty( $input['registered_before'] ) ) { |
| 149 | list( $before, , $err ) = $this->avcf_normalize_post_date( (string) $input['registered_before'] ); |
| 150 | if ( $err !== null ) { |
| 151 | return [ 'total' => 0, 'returned' => 0, 'users' => [], 'message' => 'registered_before: ' . $err ]; |
| 152 | } |
| 153 | $date_query['before'] = $before; |
| 154 | } |
| 155 | $date_query['inclusive'] = true; |
| 156 | $date_query['column'] = 'user_registered'; |
| 157 | $args['date_query'] = [ $date_query ]; |
| 158 | } |
| 159 | |
| 160 | $query = new \WP_User_Query( $args ); |
| 161 | $items = []; |
| 162 | foreach ( $query->get_results() as $user ) { |
| 163 | $items[] = [ |
| 164 | 'id' => (int) $user->ID, |
| 165 | 'login' => $user->user_login, |
| 166 | 'email' => $user->user_email, |
| 167 | 'display_name' => $user->display_name, |
| 168 | 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ), |
| 169 | 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ), |
| 170 | 'roles' => array_values( (array) $user->roles ), |
| 171 | 'registered' => $user->user_registered, |
| 172 | 'post_count' => (int) count_user_posts( $user->ID ), |
| 173 | ]; |
| 174 | } |
| 175 | |
| 176 | return [ |
| 177 | 'total' => (int) $query->get_total(), |
| 178 | 'returned' => count( $items ), |
| 179 | 'users' => $items, |
| 180 | ]; |
| 181 | }, |
| 182 | 'permission_callback' => function() { |
| 183 | return current_user_can( 'list_users' ); |
| 184 | }, |
| 185 | 'meta' => [ |
| 186 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 187 | 'annotations' => [ |
| 188 | 'readonly' => true, |
| 189 | 'destructive' => false, |
| 190 | 'idempotent' => true, |
| 191 | ], |
| 192 | ], |
| 193 | ] ); |
| 194 | |
| 195 | // ---- get-user ---- |
| 196 | wp_register_ability( 'atarim/get-user', [ |
| 197 | 'label' => 'Get User', |
| 198 | 'description' => 'Returns full detail for a single user by ID, login, or email. Optional include_meta_keys reads specific user meta fields (keys starting with "_" are excluded for safety even if requested). Useful before update-user or delete-user.', |
| 199 | 'category' => 'atarim', |
| 200 | 'input_schema' => [ |
| 201 | 'type' => 'object', |
| 202 | 'properties' => [ |
| 203 | 'id' => [ |
| 204 | 'type' => 'integer', |
| 205 | 'description' => 'User ID. Pass id, login, or email — not multiple.', |
| 206 | 'minimum' => 1, |
| 207 | ], |
| 208 | 'login' => [ |
| 209 | 'type' => 'string', |
| 210 | 'description' => 'User login. Pass id, login, or email — not multiple.', |
| 211 | 'minLength' => 1, |
| 212 | ], |
| 213 | 'email' => [ |
| 214 | 'type' => 'string', |
| 215 | 'description' => 'User email. Pass id, login, or email — not multiple.', |
| 216 | 'minLength' => 1, |
| 217 | ], |
| 218 | 'include_meta_keys' => [ |
| 219 | 'type' => 'array', |
| 220 | 'description' => 'List of user meta keys to read. Keys starting with "_" (private/internal) are excluded even if requested.', |
| 221 | 'items' => [ 'type' => 'string' ], |
| 222 | ], |
| 223 | ], |
| 224 | 'additionalProperties' => false, |
| 225 | ], |
| 226 | 'output_schema' => [ |
| 227 | 'type' => 'object', |
| 228 | 'properties' => [ |
| 229 | 'success' => [ 'type' => 'boolean' ], |
| 230 | 'id' => [ 'type' => 'integer' ], |
| 231 | 'login' => [ 'type' => 'string' ], |
| 232 | 'email' => [ 'type' => 'string' ], |
| 233 | 'display_name' => [ 'type' => 'string' ], |
| 234 | 'first_name' => [ 'type' => 'string' ], |
| 235 | 'last_name' => [ 'type' => 'string' ], |
| 236 | 'nickname' => [ 'type' => 'string' ], |
| 237 | 'description' => [ 'type' => 'string' ], |
| 238 | 'url' => [ 'type' => 'string' ], |
| 239 | 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 240 | 'registered' => [ 'type' => 'string' ], |
| 241 | 'post_count' => [ 'type' => 'integer' ], |
| 242 | 'meta' => [ 'type' => 'object' ], |
| 243 | 'message' => [ 'type' => 'string' ], |
| 244 | ], |
| 245 | 'required' => [ 'success', 'message' ], |
| 246 | ], |
| 247 | 'execute_callback' => function( $input = [] ) { |
| 248 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 249 | $login = isset( $input['login'] ) ? sanitize_user( (string) $input['login'] ) : ''; |
| 250 | $email = isset( $input['email'] ) ? sanitize_email( (string) $input['email'] ) : ''; |
| 251 | |
| 252 | $provided = (int) ( $id > 0 ) + (int) ( $login !== '' ) + (int) ( $email !== '' ); |
| 253 | if ( $provided === 0 ) { |
| 254 | return [ 'success' => false, 'message' => 'Pass one of: id, login, email.' ]; |
| 255 | } |
| 256 | if ( $provided > 1 ) { |
| 257 | return [ 'success' => false, 'message' => 'Pass exactly one of: id, login, email.' ]; |
| 258 | } |
| 259 | |
| 260 | $user = false; |
| 261 | if ( $id > 0 ) { |
| 262 | $user = get_user_by( 'id', $id ); |
| 263 | } elseif ( $login !== '' ) { |
| 264 | $user = get_user_by( 'login', $login ); |
| 265 | } else { |
| 266 | $user = get_user_by( 'email', $email ); |
| 267 | } |
| 268 | |
| 269 | if ( ! $user ) { |
| 270 | return [ 'success' => false, 'message' => 'User not found.' ]; |
| 271 | } |
| 272 | |
| 273 | $result = [ |
| 274 | 'success' => true, |
| 275 | 'id' => (int) $user->ID, |
| 276 | 'login' => $user->user_login, |
| 277 | 'email' => $user->user_email, |
| 278 | 'display_name' => $user->display_name, |
| 279 | 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ), |
| 280 | 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ), |
| 281 | 'nickname' => (string) get_user_meta( $user->ID, 'nickname', true ), |
| 282 | 'description' => (string) get_user_meta( $user->ID, 'description', true ), |
| 283 | 'url' => $user->user_url, |
| 284 | 'roles' => array_values( (array) $user->roles ), |
| 285 | 'registered' => $user->user_registered, |
| 286 | 'post_count' => (int) count_user_posts( $user->ID ), |
| 287 | 'message' => 'OK.', |
| 288 | ]; |
| 289 | |
| 290 | if ( ! empty( $input['include_meta_keys'] ) && is_array( $input['include_meta_keys'] ) ) { |
| 291 | $meta = []; |
| 292 | foreach ( $input['include_meta_keys'] as $key ) { |
| 293 | $key = (string) $key; |
| 294 | if ( $key === '' || strpos( $key, '_' ) === 0 ) { |
| 295 | continue; |
| 296 | } |
| 297 | $meta[ $key ] = get_user_meta( $user->ID, $key, true ); |
| 298 | } |
| 299 | $result['meta'] = $meta; |
| 300 | } |
| 301 | |
| 302 | return $result; |
| 303 | }, |
| 304 | 'permission_callback' => function() { |
| 305 | return current_user_can( 'list_users' ); |
| 306 | }, |
| 307 | 'meta' => [ |
| 308 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 309 | 'annotations' => [ |
| 310 | 'readonly' => true, |
| 311 | 'destructive' => false, |
| 312 | 'idempotent' => true, |
| 313 | ], |
| 314 | ], |
| 315 | ] ); |
| 316 | |
| 317 | // ---- create-user ---- |
| 318 | wp_register_ability( 'atarim/create-user', [ |
| 319 | 'label' => 'Create User', |
| 320 | 'description' => 'Creates a new user account. Required: login, email, password, role. Optional: first_name, last_name, display_name, url. WordPress validates email format and uniqueness, login uniqueness, password strength is the caller\'s responsibility. The password is never echoed back in the response.', |
| 321 | 'category' => 'atarim', |
| 322 | 'input_schema' => [ |
| 323 | 'type' => 'object', |
| 324 | 'properties' => [ |
| 325 | 'login' => [ |
| 326 | 'type' => 'string', |
| 327 | 'description' => 'Username. Must be unique site-wide; WordPress enforces this.', |
| 328 | 'minLength' => 1, |
| 329 | ], |
| 330 | 'email' => [ |
| 331 | 'type' => 'string', |
| 332 | 'description' => 'Email address. Must be unique site-wide; WordPress enforces this.', |
| 333 | 'minLength' => 3, |
| 334 | ], |
| 335 | 'password' => [ |
| 336 | 'type' => 'string', |
| 337 | 'description' => 'Password. The caller is responsible for strength. The password is hashed before storage and never echoed back.', |
| 338 | 'minLength' => 1, |
| 339 | ], |
| 340 | 'role' => [ |
| 341 | 'type' => 'string', |
| 342 | 'description' => 'Initial role slug (e.g. "subscriber", "editor", "administrator"). Defaults to the site default (usually "subscriber"). Pass an explicit role to be safe.', |
| 343 | 'minLength' => 1, |
| 344 | ], |
| 345 | 'first_name' => [ |
| 346 | 'type' => 'string', |
| 347 | ], |
| 348 | 'last_name' => [ |
| 349 | 'type' => 'string', |
| 350 | ], |
| 351 | 'display_name' => [ |
| 352 | 'type' => 'string', |
| 353 | 'description' => 'Public display name. Defaults to the login if omitted.', |
| 354 | ], |
| 355 | 'url' => [ |
| 356 | 'type' => 'string', |
| 357 | 'description' => 'User\'s personal/profile URL.', |
| 358 | ], |
| 359 | ], |
| 360 | 'required' => [ 'login', 'email', 'password' ], |
| 361 | 'additionalProperties' => false, |
| 362 | ], |
| 363 | 'output_schema' => [ |
| 364 | 'type' => 'object', |
| 365 | 'properties' => [ |
| 366 | 'success' => [ 'type' => 'boolean' ], |
| 367 | 'id' => [ 'type' => 'integer' ], |
| 368 | 'login' => [ 'type' => 'string' ], |
| 369 | 'email' => [ 'type' => 'string' ], |
| 370 | 'display_name' => [ 'type' => 'string' ], |
| 371 | 'roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 372 | 'message' => [ 'type' => 'string' ], |
| 373 | ], |
| 374 | 'required' => [ 'success', 'message' ], |
| 375 | ], |
| 376 | 'execute_callback' => function( $input = [] ) { |
| 377 | $login = isset( $input['login'] ) ? sanitize_user( (string) $input['login'], true ) : ''; |
| 378 | $email = isset( $input['email'] ) ? sanitize_email( (string) $input['email'] ) : ''; |
| 379 | $password = isset( $input['password'] ) ? (string) $input['password'] : ''; |
| 380 | |
| 381 | if ( $login === '' || $email === '' || $password === '' ) { |
| 382 | return [ 'success' => false, 'message' => 'login, email, and password are required.' ]; |
| 383 | } |
| 384 | if ( ! is_email( $email ) ) { |
| 385 | return [ 'success' => false, 'message' => sprintf( 'Email "%s" is not a valid email address.', $email ) ]; |
| 386 | } |
| 387 | if ( username_exists( $login ) ) { |
| 388 | return [ 'success' => false, 'message' => sprintf( 'A user with login "%s" already exists.', $login ) ]; |
| 389 | } |
| 390 | if ( email_exists( $email ) ) { |
| 391 | return [ 'success' => false, 'message' => sprintf( 'A user with email "%s" already exists.', $email ) ]; |
| 392 | } |
| 393 | |
| 394 | $role = isset( $input['role'] ) ? sanitize_key( (string) $input['role'] ) : ''; |
| 395 | if ( $role !== '' && ! wp_roles()->is_role( $role ) ) { |
| 396 | return [ 'success' => false, 'message' => sprintf( 'Role "%s" does not exist on this site.', $role ) ]; |
| 397 | } |
| 398 | |
| 399 | $userdata = [ |
| 400 | 'user_login' => $login, |
| 401 | 'user_email' => $email, |
| 402 | 'user_pass' => $password, |
| 403 | 'first_name' => isset( $input['first_name'] ) ? sanitize_text_field( (string) $input['first_name'] ) : '', |
| 404 | 'last_name' => isset( $input['last_name'] ) ? sanitize_text_field( (string) $input['last_name'] ) : '', |
| 405 | 'display_name' => isset( $input['display_name'] ) ? sanitize_text_field( (string) $input['display_name'] ) : $login, |
| 406 | 'user_url' => isset( $input['url'] ) ? esc_url_raw( (string) $input['url'] ) : '', |
| 407 | ]; |
| 408 | if ( $role !== '' ) { |
| 409 | $userdata['role'] = $role; |
| 410 | } |
| 411 | |
| 412 | $user_id = wp_insert_user( $userdata ); |
| 413 | if ( is_wp_error( $user_id ) ) { |
| 414 | return [ 'success' => false, 'message' => 'Create failed: ' . $user_id->get_error_message() ]; |
| 415 | } |
| 416 | |
| 417 | $created = get_userdata( $user_id ); |
| 418 | |
| 419 | return [ |
| 420 | 'success' => true, |
| 421 | 'id' => (int) $user_id, |
| 422 | 'login' => $created->user_login, |
| 423 | 'email' => $created->user_email, |
| 424 | 'display_name' => $created->display_name, |
| 425 | 'roles' => array_values( (array) $created->roles ), |
| 426 | 'message' => 'User created.', |
| 427 | ]; |
| 428 | }, |
| 429 | 'permission_callback' => function() { |
| 430 | return current_user_can( 'create_users' ); |
| 431 | }, |
| 432 | 'meta' => [ |
| 433 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 434 | 'annotations' => [ |
| 435 | 'readonly' => false, |
| 436 | 'destructive' => false, |
| 437 | 'idempotent' => false, |
| 438 | ], |
| 439 | ], |
| 440 | ] ); |
| 441 | |
| 442 | // ---- update-user ---- |
| 443 | wp_register_ability( 'atarim/update-user', [ |
| 444 | 'label' => 'Update User', |
| 445 | 'description' => 'Updates profile fields on an existing user. Only id is required; pass any subset of email, display_name, first_name, last_name, nickname, description, url, password. Omitted fields are left unchanged. To change role, use the dedicated change-user-role ability — it has tighter validation. The password, if changed, is hashed and never echoed back.', |
| 446 | 'category' => 'atarim', |
| 447 | 'input_schema' => [ |
| 448 | 'type' => 'object', |
| 449 | 'properties' => [ |
| 450 | 'id' => [ |
| 451 | 'type' => 'integer', |
| 452 | 'description' => 'User ID.', |
| 453 | 'minimum' => 1, |
| 454 | ], |
| 455 | 'email' => [ |
| 456 | 'type' => 'string', |
| 457 | 'description' => 'New email. WordPress enforces uniqueness.', |
| 458 | 'minLength' => 3, |
| 459 | ], |
| 460 | 'display_name' => [ |
| 461 | 'type' => 'string', |
| 462 | ], |
| 463 | 'first_name' => [ |
| 464 | 'type' => 'string', |
| 465 | ], |
| 466 | 'last_name' => [ |
| 467 | 'type' => 'string', |
| 468 | ], |
| 469 | 'nickname' => [ |
| 470 | 'type' => 'string', |
| 471 | ], |
| 472 | 'description' => [ |
| 473 | 'type' => 'string', |
| 474 | 'description' => 'Bio / about field.', |
| 475 | ], |
| 476 | 'url' => [ |
| 477 | 'type' => 'string', |
| 478 | ], |
| 479 | 'password' => [ |
| 480 | 'type' => 'string', |
| 481 | 'description' => 'New password. Hashed before storage; never echoed back. Caller is responsible for strength.', |
| 482 | 'minLength' => 1, |
| 483 | ], |
| 484 | ], |
| 485 | 'required' => [ 'id' ], |
| 486 | 'additionalProperties' => false, |
| 487 | ], |
| 488 | 'output_schema' => [ |
| 489 | 'type' => 'object', |
| 490 | 'properties' => [ |
| 491 | 'success' => [ 'type' => 'boolean' ], |
| 492 | 'id' => [ 'type' => 'integer' ], |
| 493 | 'login' => [ 'type' => 'string' ], |
| 494 | 'email' => [ 'type' => 'string' ], |
| 495 | 'display_name' => [ 'type' => 'string' ], |
| 496 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 497 | 'message' => [ 'type' => 'string' ], |
| 498 | ], |
| 499 | 'required' => [ 'success', 'message' ], |
| 500 | ], |
| 501 | 'execute_callback' => function( $input = [] ) { |
| 502 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 503 | if ( $id <= 0 ) { |
| 504 | return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ]; |
| 505 | } |
| 506 | |
| 507 | $user = get_userdata( $id ); |
| 508 | if ( ! $user ) { |
| 509 | return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ]; |
| 510 | } |
| 511 | |
| 512 | $userdata = [ 'ID' => $id ]; |
| 513 | $updated = []; |
| 514 | |
| 515 | if ( array_key_exists( 'email', $input ) ) { |
| 516 | $email = sanitize_email( (string) $input['email'] ); |
| 517 | if ( ! is_email( $email ) ) { |
| 518 | return [ 'success' => false, 'message' => sprintf( 'Email "%s" is not a valid email address.', $email ) ]; |
| 519 | } |
| 520 | // Check uniqueness — only error if a DIFFERENT user already has this email. |
| 521 | $existing = email_exists( $email ); |
| 522 | if ( $existing && (int) $existing !== $id ) { |
| 523 | return [ 'success' => false, 'message' => sprintf( 'Another user with email "%s" already exists.', $email ) ]; |
| 524 | } |
| 525 | $userdata['user_email'] = $email; |
| 526 | $updated[] = 'email'; |
| 527 | } |
| 528 | if ( array_key_exists( 'display_name', $input ) ) { |
| 529 | $userdata['display_name'] = sanitize_text_field( (string) $input['display_name'] ); |
| 530 | $updated[] = 'display_name'; |
| 531 | } |
| 532 | if ( array_key_exists( 'first_name', $input ) ) { |
| 533 | $userdata['first_name'] = sanitize_text_field( (string) $input['first_name'] ); |
| 534 | $updated[] = 'first_name'; |
| 535 | } |
| 536 | if ( array_key_exists( 'last_name', $input ) ) { |
| 537 | $userdata['last_name'] = sanitize_text_field( (string) $input['last_name'] ); |
| 538 | $updated[] = 'last_name'; |
| 539 | } |
| 540 | if ( array_key_exists( 'nickname', $input ) ) { |
| 541 | $userdata['nickname'] = sanitize_text_field( (string) $input['nickname'] ); |
| 542 | $updated[] = 'nickname'; |
| 543 | } |
| 544 | if ( array_key_exists( 'description', $input ) ) { |
| 545 | $userdata['description'] = wp_kses_post( (string) $input['description'] ); |
| 546 | $updated[] = 'description'; |
| 547 | } |
| 548 | if ( array_key_exists( 'url', $input ) ) { |
| 549 | $userdata['user_url'] = esc_url_raw( (string) $input['url'] ); |
| 550 | $updated[] = 'url'; |
| 551 | } |
| 552 | if ( array_key_exists( 'password', $input ) ) { |
| 553 | $pw = (string) $input['password']; |
| 554 | if ( $pw === '' ) { |
| 555 | return [ 'success' => false, 'message' => 'password cannot be empty.' ]; |
| 556 | } |
| 557 | $userdata['user_pass'] = $pw; |
| 558 | $updated[] = 'password'; |
| 559 | } |
| 560 | |
| 561 | if ( count( $userdata ) === 1 ) { |
| 562 | return [ 'success' => false, 'message' => 'No fields provided to update.' ]; |
| 563 | } |
| 564 | |
| 565 | $result = wp_update_user( $userdata ); |
| 566 | if ( is_wp_error( $result ) ) { |
| 567 | return [ 'success' => false, 'message' => 'Update failed: ' . $result->get_error_message() ]; |
| 568 | } |
| 569 | |
| 570 | $fresh = get_userdata( $id ); |
| 571 | |
| 572 | return [ |
| 573 | 'success' => true, |
| 574 | 'id' => $id, |
| 575 | 'login' => $fresh->user_login, |
| 576 | 'email' => $fresh->user_email, |
| 577 | 'display_name' => $fresh->display_name, |
| 578 | 'updated' => $updated, |
| 579 | 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ), |
| 580 | ]; |
| 581 | }, |
| 582 | 'permission_callback' => function() { |
| 583 | return current_user_can( 'edit_users' ); |
| 584 | }, |
| 585 | 'meta' => [ |
| 586 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 587 | 'annotations' => [ |
| 588 | 'readonly' => false, |
| 589 | 'destructive' => false, |
| 590 | 'idempotent' => false, |
| 591 | ], |
| 592 | ], |
| 593 | ] ); |
| 594 | |
| 595 | // ---- delete-user ---- |
| 596 | wp_register_ability( 'atarim/delete-user', [ |
| 597 | 'label' => 'Delete User', |
| 598 | 'description' => 'Deletes a user account. WordPress requires deciding what happens to posts owned by the deleted user. By default this ability HARD-FAILS if the user owns any posts and reassign_to is not provided — the response includes the post count so the AI can choose. Pass reassign_to: <user_id> to reassign content to another user, or reassign_to: 0 explicitly to delete posts along with the user. On multisite this removes the user from the current site only (not the network).', |
| 599 | 'category' => 'atarim', |
| 600 | 'input_schema' => [ |
| 601 | 'type' => 'object', |
| 602 | 'properties' => [ |
| 603 | 'id' => [ |
| 604 | 'type' => 'integer', |
| 605 | 'description' => 'User ID to delete.', |
| 606 | 'minimum' => 1, |
| 607 | ], |
| 608 | 'reassign_to' => [ |
| 609 | 'type' => 'integer', |
| 610 | 'description' => 'Reassign owned posts to this user ID. Pass 0 to delete posts along with the user. Omit to hard-fail when posts exist (forces the AI to make an explicit choice).', |
| 611 | 'minimum' => 0, |
| 612 | ], |
| 613 | ], |
| 614 | 'required' => [ 'id' ], |
| 615 | 'additionalProperties' => false, |
| 616 | ], |
| 617 | 'output_schema' => [ |
| 618 | 'type' => 'object', |
| 619 | 'properties' => [ |
| 620 | 'success' => [ 'type' => 'boolean' ], |
| 621 | 'id' => [ 'type' => 'integer' ], |
| 622 | 'login' => [ 'type' => 'string' ], |
| 623 | 'post_count' => [ 'type' => 'integer' ], |
| 624 | 'reassigned_to' => [ 'type' => 'integer' ], |
| 625 | 'message' => [ 'type' => 'string' ], |
| 626 | ], |
| 627 | 'required' => [ 'success', 'message' ], |
| 628 | ], |
| 629 | 'execute_callback' => function( $input = [] ) { |
| 630 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 631 | if ( $id <= 0 ) { |
| 632 | return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ]; |
| 633 | } |
| 634 | |
| 635 | $user = get_userdata( $id ); |
| 636 | if ( ! $user ) { |
| 637 | return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ]; |
| 638 | } |
| 639 | |
| 640 | // Refuse to delete the current user (mapped from token) — same as wp-admin behaviour. |
| 641 | if ( $id === get_current_user_id() ) { |
| 642 | return [ 'success' => false, 'id' => $id, 'login' => $user->user_login, 'message' => 'Cannot delete the user currently authenticated for this request.' ]; |
| 643 | } |
| 644 | |
| 645 | // Ensure plugin functions are available — wp_delete_user lives in wp-admin/includes/user.php. |
| 646 | if ( ! function_exists( 'wp_delete_user' ) ) { |
| 647 | require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 648 | } |
| 649 | |
| 650 | $post_count = (int) count_user_posts( $id ); |
| 651 | |
| 652 | $reassign_provided = array_key_exists( 'reassign_to', $input ); |
| 653 | $reassign_to = $reassign_provided ? (int) $input['reassign_to'] : null; |
| 654 | |
| 655 | // Hard-fail when posts exist and reassign target wasn't explicitly given. |
| 656 | if ( $post_count > 0 && ! $reassign_provided ) { |
| 657 | return [ |
| 658 | 'success' => false, |
| 659 | 'id' => $id, |
| 660 | 'login' => $user->user_login, |
| 661 | 'post_count' => $post_count, |
| 662 | 'message' => sprintf( |
| 663 | 'User "%s" owns %d post(s). Pass reassign_to: <user_id> to reassign them, or reassign_to: 0 to delete the posts along with the user.', |
| 664 | $user->user_login, |
| 665 | $post_count |
| 666 | ), |
| 667 | ]; |
| 668 | } |
| 669 | |
| 670 | if ( $reassign_provided && $reassign_to > 0 ) { |
| 671 | if ( $reassign_to === $id ) { |
| 672 | return [ 'success' => false, 'message' => 'reassign_to cannot be the same as the user being deleted.' ]; |
| 673 | } |
| 674 | $target = get_userdata( $reassign_to ); |
| 675 | if ( ! $target ) { |
| 676 | return [ 'success' => false, 'message' => sprintf( 'reassign_to target user %d does not exist.', $reassign_to ) ]; |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | // Effective reassign value for wp_delete_user: 0 / positive int, or null which WP treats as "delete posts". |
| 681 | $effective_reassign = ( $reassign_provided && $reassign_to > 0 ) ? $reassign_to : null; |
| 682 | |
| 683 | if ( is_multisite() ) { |
| 684 | if ( ! function_exists( 'remove_user_from_blog' ) ) { |
| 685 | require_once ABSPATH . 'wp-admin/includes/ms.php'; |
| 686 | } |
| 687 | $result = remove_user_from_blog( $id, get_current_blog_id(), $effective_reassign ); |
| 688 | } else { |
| 689 | $result = wp_delete_user( $id, $effective_reassign ); |
| 690 | } |
| 691 | |
| 692 | if ( is_wp_error( $result ) ) { |
| 693 | return [ 'success' => false, 'message' => 'Delete failed: ' . $result->get_error_message() ]; |
| 694 | } |
| 695 | if ( $result === false ) { |
| 696 | return [ 'success' => false, 'message' => 'Delete failed: WordPress reported the operation did not complete.' ]; |
| 697 | } |
| 698 | |
| 699 | return [ |
| 700 | 'success' => true, |
| 701 | 'id' => $id, |
| 702 | 'login' => $user->user_login, |
| 703 | 'post_count' => $post_count, |
| 704 | 'reassigned_to' => $effective_reassign === null ? 0 : (int) $effective_reassign, |
| 705 | 'message' => $effective_reassign === null |
| 706 | ? sprintf( 'User "%s" deleted; %d post(s) deleted along with the user.', $user->user_login, $post_count ) |
| 707 | : sprintf( 'User "%s" deleted; %d post(s) reassigned to user %d.', $user->user_login, $post_count, $effective_reassign ), |
| 708 | ]; |
| 709 | }, |
| 710 | 'permission_callback' => function() { |
| 711 | return current_user_can( 'delete_users' ); |
| 712 | }, |
| 713 | 'meta' => [ |
| 714 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 715 | 'annotations' => [ |
| 716 | 'readonly' => false, |
| 717 | 'destructive' => true, |
| 718 | 'idempotent' => false, |
| 719 | ], |
| 720 | ], |
| 721 | ] ); |
| 722 | |
| 723 | // ---- change-user-role ---- |
| 724 | wp_register_ability( 'atarim/change-user-role', [ |
| 725 | 'label' => 'Change User Role', |
| 726 | 'description' => 'Changes a user\'s role(s). Three modes: "replace" (default) — set the user\'s roles to exactly the listed role(s), removing all others; "add" — add the listed role(s) without removing existing ones; "remove" — remove the listed role(s) while keeping any others. Most sites use a single role per user — pass mode: replace with a single role for the common case. Refuses to remove the last administrator on the site, and refuses to change the role of the user currently authenticated for this request.', |
| 727 | 'category' => 'atarim', |
| 728 | 'input_schema' => [ |
| 729 | 'type' => 'object', |
| 730 | 'properties' => [ |
| 731 | 'id' => [ |
| 732 | 'type' => 'integer', |
| 733 | 'description' => 'User ID.', |
| 734 | 'minimum' => 1, |
| 735 | ], |
| 736 | 'roles' => [ |
| 737 | 'type' => 'array', |
| 738 | 'description' => 'Role slugs to apply.', |
| 739 | 'items' => [ 'type' => 'string', 'minLength' => 1 ], |
| 740 | 'minItems' => 1, |
| 741 | ], |
| 742 | 'mode' => [ |
| 743 | 'type' => 'string', |
| 744 | 'description' => '"replace" (default): roles becomes exactly the listed roles, all others removed. "add": listed roles are added. "remove": listed roles are removed.', |
| 745 | 'enum' => [ 'replace', 'add', 'remove' ], |
| 746 | 'default' => 'replace', |
| 747 | ], |
| 748 | ], |
| 749 | 'required' => [ 'id', 'roles' ], |
| 750 | 'additionalProperties' => false, |
| 751 | ], |
| 752 | 'output_schema' => [ |
| 753 | 'type' => 'object', |
| 754 | 'properties' => [ |
| 755 | 'success' => [ 'type' => 'boolean' ], |
| 756 | 'id' => [ 'type' => 'integer' ], |
| 757 | 'login' => [ 'type' => 'string' ], |
| 758 | 'previous_roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 759 | 'current_roles' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 760 | 'mode' => [ 'type' => 'string' ], |
| 761 | 'message' => [ 'type' => 'string' ], |
| 762 | ], |
| 763 | 'required' => [ 'success', 'message' ], |
| 764 | ], |
| 765 | 'execute_callback' => function( $input = [] ) { |
| 766 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 767 | if ( $id <= 0 ) { |
| 768 | return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ]; |
| 769 | } |
| 770 | |
| 771 | $user = get_userdata( $id ); |
| 772 | if ( ! $user ) { |
| 773 | return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $id ) ]; |
| 774 | } |
| 775 | if ( $id === get_current_user_id() ) { |
| 776 | return [ 'success' => false, 'message' => 'Cannot change the role of the user currently authenticated for this request.' ]; |
| 777 | } |
| 778 | |
| 779 | $roles_in = isset( $input['roles'] ) && is_array( $input['roles'] ) ? $input['roles'] : []; |
| 780 | $roles = array_values( array_filter( array_map( 'sanitize_key', $roles_in ) ) ); |
| 781 | if ( empty( $roles ) ) { |
| 782 | return [ 'success' => false, 'message' => 'roles is required and must be a non-empty array.' ]; |
| 783 | } |
| 784 | |
| 785 | $wp_roles = wp_roles(); |
| 786 | foreach ( $roles as $r ) { |
| 787 | if ( ! $wp_roles->is_role( $r ) ) { |
| 788 | return [ 'success' => false, 'message' => sprintf( 'Role "%s" does not exist on this site.', $r ) ]; |
| 789 | } |
| 790 | } |
| 791 | |
| 792 | $mode = ( isset( $input['mode'] ) && in_array( $input['mode'], [ 'replace', 'add', 'remove' ], true ) ) |
| 793 | ? $input['mode'] |
| 794 | : 'replace'; |
| 795 | |
| 796 | $previous_roles = array_values( (array) $user->roles ); |
| 797 | |
| 798 | // Last-administrator guard. Trigger if the operation would REMOVE administrator from this user |
| 799 | // AND there are no other administrators on the site. |
| 800 | $would_lose_admin = false; |
| 801 | if ( in_array( 'administrator', $previous_roles, true ) ) { |
| 802 | if ( $mode === 'replace' && ! in_array( 'administrator', $roles, true ) ) { |
| 803 | $would_lose_admin = true; |
| 804 | } elseif ( $mode === 'remove' && in_array( 'administrator', $roles, true ) ) { |
| 805 | $would_lose_admin = true; |
| 806 | } |
| 807 | } |
| 808 | if ( $would_lose_admin ) { |
| 809 | $other_admins = get_users( [ |
| 810 | 'role' => 'administrator', |
| 811 | 'exclude' => [ $id ], |
| 812 | 'number' => 1, |
| 813 | 'fields' => 'ID', |
| 814 | ] ); |
| 815 | if ( empty( $other_admins ) ) { |
| 816 | return [ |
| 817 | 'success' => false, |
| 818 | 'message' => 'Cannot remove the administrator role from the last admin on the site.', |
| 819 | ]; |
| 820 | } |
| 821 | } |
| 822 | |
| 823 | // Apply the change. |
| 824 | $u = new \WP_User( $id ); |
| 825 | if ( $mode === 'replace' ) { |
| 826 | // set_role only takes one role. For multi-role replace we wipe and add. |
| 827 | if ( count( $roles ) === 1 ) { |
| 828 | $u->set_role( $roles[0] ); |
| 829 | } else { |
| 830 | foreach ( $previous_roles as $r ) { |
| 831 | $u->remove_role( $r ); |
| 832 | } |
| 833 | foreach ( $roles as $r ) { |
| 834 | $u->add_role( $r ); |
| 835 | } |
| 836 | } |
| 837 | } elseif ( $mode === 'add' ) { |
| 838 | foreach ( $roles as $r ) { |
| 839 | $u->add_role( $r ); |
| 840 | } |
| 841 | } else { // remove |
| 842 | foreach ( $roles as $r ) { |
| 843 | $u->remove_role( $r ); |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | // Reload to confirm. |
| 848 | $fresh = get_userdata( $id ); |
| 849 | $current_roles = array_values( (array) $fresh->roles ); |
| 850 | |
| 851 | return [ |
| 852 | 'success' => true, |
| 853 | 'id' => $id, |
| 854 | 'login' => $fresh->user_login, |
| 855 | 'previous_roles' => $previous_roles, |
| 856 | 'current_roles' => $current_roles, |
| 857 | 'mode' => $mode, |
| 858 | 'message' => sprintf( 'Roles updated (mode: %s). Was: [%s]. Now: [%s].', $mode, implode( ', ', $previous_roles ), implode( ', ', $current_roles ) ), |
| 859 | ]; |
| 860 | }, |
| 861 | 'permission_callback' => function() { |
| 862 | return current_user_can( 'promote_users' ); |
| 863 | }, |
| 864 | 'meta' => [ |
| 865 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 866 | 'annotations' => [ |
| 867 | 'readonly' => false, |
| 868 | 'destructive' => false, |
| 869 | 'idempotent' => true, |
| 870 | ], |
| 871 | ], |
| 872 | ] ); |
| 873 | |
| 874 | // ---- get-2fa-status ---- |
| 875 | wp_register_ability( 'atarim/get-2fa-status', [ |
| 876 | 'label' => 'Get 2FA Status', |
| 877 | 'description' => 'Returns the two-factor authentication enrollment status for a user, detecting across the three most common 2FA plugins: Two Factor (the WP.org plugin), WP 2FA (Melapress), and Wordfence. The response includes which provider plugin is in use on the site and whether the specified user has 2FA enabled. If no supported 2FA plugin is detected, provider is "none" and enabled is null (unknown — not false).', |
| 878 | 'category' => 'atarim', |
| 879 | 'input_schema' => [ |
| 880 | 'type' => 'object', |
| 881 | 'properties' => [ |
| 882 | 'id' => [ |
| 883 | 'type' => 'integer', |
| 884 | 'description' => 'User ID to check.', |
| 885 | 'minimum' => 1, |
| 886 | ], |
| 887 | ], |
| 888 | 'required' => [ 'id' ], |
| 889 | 'additionalProperties' => false, |
| 890 | ], |
| 891 | 'output_schema' => [ |
| 892 | 'type' => 'object', |
| 893 | 'properties' => [ |
| 894 | 'success' => [ 'type' => 'boolean' ], |
| 895 | 'id' => [ 'type' => 'integer' ], |
| 896 | 'provider' => [ 'type' => 'string' ], |
| 897 | 'enabled' => [ 'type' => [ 'boolean', 'null' ] ], |
| 898 | 'methods' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 899 | 'message' => [ 'type' => 'string' ], |
| 900 | ], |
| 901 | 'required' => [ 'success', 'message' ], |
| 902 | ], |
| 903 | 'execute_callback' => function( $input = [] ) { |
| 904 | $id = isset( $input['id'] ) ? (int) $input['id'] : 0; |
| 905 | if ( $id <= 0 ) { |
| 906 | return [ 'success' => false, 'provider' => 'none', 'enabled' => null, 'message' => 'id is required and must be a positive integer.' ]; |
| 907 | } |
| 908 | if ( ! get_userdata( $id ) ) { |
| 909 | return [ 'success' => false, 'id' => $id, 'provider' => 'none', 'enabled' => null, 'message' => sprintf( 'User %d not found.', $id ) ]; |
| 910 | } |
| 911 | |
| 912 | $status = $this->avcf_detect_2fa_for_user( $id ); |
| 913 | |
| 914 | return [ |
| 915 | 'success' => true, |
| 916 | 'id' => $id, |
| 917 | 'provider' => $status['provider'], |
| 918 | 'enabled' => $status['enabled'], |
| 919 | 'methods' => $status['methods'], |
| 920 | 'message' => $status['provider'] === 'none' |
| 921 | ? 'No supported 2FA plugin detected on this site.' |
| 922 | : sprintf( '%s plugin detected; user 2FA enabled: %s.', |
| 923 | $status['provider'], |
| 924 | $status['enabled'] === null ? 'unknown' : ( $status['enabled'] ? 'yes' : 'no' ) |
| 925 | ), |
| 926 | ]; |
| 927 | }, |
| 928 | 'permission_callback' => function() { |
| 929 | return current_user_can( 'list_users' ); |
| 930 | }, |
| 931 | 'meta' => [ |
| 932 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 933 | 'annotations' => [ |
| 934 | 'readonly' => true, |
| 935 | 'destructive' => false, |
| 936 | 'idempotent' => true, |
| 937 | ], |
| 938 | ], |
| 939 | ] ); |
| 940 | } |
| 941 | |
| 942 | /** |
| 943 | * Detect 2FA enrollment for a user across the supported plugins. |
| 944 | * |
| 945 | * Returns an array of the form: |
| 946 | * [ 'provider' => string, 'enabled' => bool|null, 'methods' => string[] ] |
| 947 | * |
| 948 | * Providers are checked in order; the first plugin detected wins. If no |
| 949 | * supported 2FA plugin is active, returns provider: "none", enabled: null. |
| 950 | * |
| 951 | * @param int $user_id |
| 952 | * @return array |
| 953 | */ |
| 954 | private function avcf_detect_2fa_for_user( $user_id ) { |
| 955 | // --- Two Factor (https://wordpress.org/plugins/two-factor/) --- |
| 956 | if ( class_exists( '\Two_Factor_Core' ) ) { |
| 957 | // Two_Factor_Core::get_enabled_providers_for_user returns array of provider keys |
| 958 | // (e.g. 'Two_Factor_Totp', 'Two_Factor_Email'). Empty array means not enrolled. |
| 959 | $providers = []; |
| 960 | if ( is_callable( [ '\Two_Factor_Core', 'get_enabled_providers_for_user' ] ) ) { |
| 961 | $providers = (array) \Two_Factor_Core::get_enabled_providers_for_user( $user_id ); |
| 962 | } |
| 963 | // Normalize method names for the response. |
| 964 | $methods = array_map( |
| 965 | function( $p ) { |
| 966 | $p = str_replace( 'Two_Factor_', '', (string) $p ); |
| 967 | return strtolower( $p ); |
| 968 | }, |
| 969 | $providers |
| 970 | ); |
| 971 | return [ |
| 972 | 'provider' => 'two-factor', |
| 973 | 'enabled' => ! empty( $providers ), |
| 974 | 'methods' => array_values( $methods ), |
| 975 | ]; |
| 976 | } |
| 977 | |
| 978 | // --- WP 2FA (Melapress) --- |
| 979 | // Sentinel: WP2FA\WP2FA class, or wp2fa_freemius() function (loaded via Freemius). |
| 980 | if ( class_exists( '\WP2FA\WP2FA' ) || function_exists( 'wp2fa_freemius' ) ) { |
| 981 | // WP 2FA stores enabled methods in user meta keyed by method. |
| 982 | // The strongest signal: wp_2fa_totp_key set (TOTP enrolled), |
| 983 | // or wp_2fa_email_token_key set (email 2FA enrolled), |
| 984 | // or wp_2fa_backup_methods_enabled non-empty. |
| 985 | $methods = []; |
| 986 | if ( get_user_meta( $user_id, 'wp_2fa_totp_key', true ) ) { |
| 987 | $methods[] = 'totp'; |
| 988 | } |
| 989 | if ( get_user_meta( $user_id, 'wp_2fa_email_token_key', true ) ) { |
| 990 | $methods[] = 'email'; |
| 991 | } |
| 992 | $backup = get_user_meta( $user_id, 'wp_2fa_backup_methods_enabled', true ); |
| 993 | if ( ! empty( $backup ) ) { |
| 994 | $methods[] = 'backup_codes'; |
| 995 | } |
| 996 | // Fallback: WP 2FA also sets wp_2fa_user_setup_complete on enrollment. |
| 997 | $setup_complete = get_user_meta( $user_id, 'wp_2fa_user_setup_complete', true ); |
| 998 | $enabled = ! empty( $methods ) || ! empty( $setup_complete ); |
| 999 | |
| 1000 | return [ |
| 1001 | 'provider' => 'wp-2fa', |
| 1002 | 'enabled' => $enabled, |
| 1003 | 'methods' => $methods, |
| 1004 | ]; |
| 1005 | } |
| 1006 | |
| 1007 | // --- Wordfence --- |
| 1008 | // Sentinel: WORDFENCE_VERSION constant or wordfence class. |
| 1009 | if ( defined( 'WORDFENCE_VERSION' ) || class_exists( '\wordfence' ) ) { |
| 1010 | global $wpdb; |
| 1011 | $methods = []; |
| 1012 | $enabled = null; // we may not be able to read Wordfence's table; default to unknown. |
| 1013 | if ( $wpdb ) { |
| 1014 | $table = $wpdb->base_prefix . 'wfconfig'; |
| 1015 | // Wordfence Premium stores 2FA secrets in wfTwoFactor for Premium / wfconfig key for free. |
| 1016 | // The cleanest signal we can rely on without poking premium-only internals: a user-meta key. |
| 1017 | $wf_meta = get_user_meta( $user_id, '_wf_twoFactorEnabled', true ); |
| 1018 | if ( $wf_meta ) { |
| 1019 | $enabled = true; |
| 1020 | $methods[] = 'wordfence'; |
| 1021 | } |
| 1022 | // Fallback: check wfTwoFactor secrets table when present. |
| 1023 | if ( $enabled === null ) { |
| 1024 | $secrets_table = $wpdb->base_prefix . 'wfTwoFactor'; |
| 1025 | $exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $secrets_table ) ); |
| 1026 | if ( $exists === $secrets_table ) { |
| 1027 | $row = $wpdb->get_var( $wpdb->prepare( |
| 1028 | "SELECT COUNT(*) FROM `{$secrets_table}` WHERE userID = %d", |
| 1029 | $user_id |
| 1030 | ) ); |
| 1031 | if ( $row !== null ) { |
| 1032 | $enabled = ( (int) $row > 0 ); |
| 1033 | if ( $enabled ) { |
| 1034 | $methods[] = 'wordfence'; |
| 1035 | } |
| 1036 | } |
| 1037 | } |
| 1038 | } |
| 1039 | if ( $enabled === null ) { |
| 1040 | $enabled = false; // Wordfence is active but we found no enrollment signal. |
| 1041 | } |
| 1042 | } |
| 1043 | return [ |
| 1044 | 'provider' => 'wordfence', |
| 1045 | 'enabled' => $enabled, |
| 1046 | 'methods' => $methods, |
| 1047 | ]; |
| 1048 | } |
| 1049 | |
| 1050 | return [ |
| 1051 | 'provider' => 'none', |
| 1052 | 'enabled' => null, |
| 1053 | 'methods' => [], |
| 1054 | ]; |
| 1055 | } |
| 1056 | } |
| 1057 |