PluginProbe ʕ •ᴥ•ʔ
CloudSecure WP Security / 1.4.10
CloudSecure WP Security v1.4.10
1.4.10 1.4.9 trunk 0.9.0 1.0.2 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.3.1 1.3.10 1.3.11 1.3.12 1.3.13 1.3.14 1.3.15 1.3.16 1.3.17 1.3.18 1.3.19 1.3.2 1.3.20 1.3.21 1.3.22 1.3.23 1.3.24 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8
cloudsecure-wp-security / modules / two-factor-authentication.php
cloudsecure-wp-security / modules Last commit date
admin 1 month ago cli 1 month ago lib 1 month ago captcha.php 3 months ago cloudsecure-wp.php 1 month ago common.php 2 months ago config.php 2 years ago disable-access-system-file.php 1 month ago disable-author-query.php 2 years ago disable-login.php 2 months ago disable-restapi.php 2 months ago disable-xmlrpc.php 1 year ago htaccess.php 3 months ago login-log.php 2 months ago login-notification.php 1 month ago rename-login-page.php 3 months ago restrict-admin-page.php 1 month ago server-error-notification.php 1 month ago two-factor-authentication.php 1 month ago unify-messages.php 2 years ago update-notice.php 7 months ago waf-engine.php 1 month ago waf.php 1 month ago
two-factor-authentication.php
2881 lines
1 <?php
2
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 class CloudSecureWP_Two_Factor_Authentication extends CloudSecureWP_Common {
8 private const KEY_FEATURE = 'two_factor_authentication';
9 private const KEY_XMLRPC_LOGIN = 'two_factor_authentication_xmlrpc_login';
10 private const OPTION_PREFIX = 'cloudsecurewp_2fa_data_';
11 private const SESSION_EXPIRY = 300;
12 private const CLEANUP_TIMEOUT = 60;
13 private const CLEANUP_BATCH_SIZE = 1000;
14 private const LOGIN_TABLE_NAME = 'cloudsecurewp_2fa_login';
15 private const AUTH_TABLE_NAME = 'cloudsecurewp_2fa_auth';
16 private const LATE_LIMIT_TIME_LIST = array(
17 3 => 60, // 3回で1分間
18 6 => 300, // 6回で5分間
19 9 => 600, // 9回で10分間
20 12 => 900, // 12回で15分間
21 );
22 private const LATE_LIMIT_TIME_MAX = 1200; // 最大20分間
23 private const REPLAY_LOCK_TIMEOUT = 2;
24 private const SETUP_SECRET_PREFIX = 'cloudsecurewp_2fa_setup_secret_';
25 private const SETUP_SECRET_TIMEOUT = 180;
26 public const USER_AUTH_METHOD_NONE = 0;
27 public const USER_AUTH_METHOD_APP = 1;
28 public const USER_AUTH_METHOD_EMAIL = 2;
29 public const USER_AUTH_METHOD_RECOVERY = 3;
30 public const AUTH_APP_INTERVAL = 30;
31 public const AUTH_EMAIL_INTERVAL = 60;
32 public const TWO_FACTOR_EMAIL_SEND = 'wp_cloudsecurewp_two_factor_authentication_email_send';
33 public const EMAIL_SEND_LIMIT_TIME = 30;
34 public const TWO_FACTOR_LAST_SUCCESS = 'wp_cloudsecurewp_two_factor_authentication_last_success';
35
36 private $config;
37
38 /**
39 * @var CloudSecureWP_Disable_Login
40 */
41 private $disable_login;
42
43 /**
44 * @var CloudSecureWP_Login_Log
45 */
46 private $login_log;
47
48 /**
49 * @var CloudSecureWP_Disable_XMLRPC
50 */
51 private $disable_xmlrpc;
52
53 /**
54 * 1段階目で WordPress が作成したセッショントークンを収集する�
55 �列
56 *
57 * @var array
58 */
59 private $password_auth_tokens = array();
60
61 /**
62 * 2FA完了処理中フラグ(wp_login の再帰防止)
63 *
64 * @var bool
65 */
66 private $completing_2fa_login = false;
67
68 function __construct( array $info, CloudSecureWP_Config $config, CloudSecureWP_Disable_Login $disable_login, CloudSecureWP_Login_Log $login_log, CloudSecureWP_Disable_XMLRPC $disable_xmlrpc ) {
69 parent::__construct( $info );
70 $this->config = $config;
71 $this->disable_login = $disable_login;
72 $this->login_log = $login_log;
73 $this->disable_xmlrpc = $disable_xmlrpc;
74 }
75
76 /**
77 * 機能毎のKEY取得
78 *
79 * @return string
80 */
81 public function get_feature_key(): string {
82 return self::KEY_FEATURE;
83 }
84
85 /**
86 * 有効無効判定
87 *
88 * @return bool
89 */
90 public function is_enabled(): bool {
91 return $this->config->get( $this->get_feature_key() ) === 't';
92 }
93
94 /**
95 * 初期設定値取得
96 *
97 * @return array
98 */
99 public function get_default(): array {
100 return array(
101 self::KEY_FEATURE => 'f',
102 self::KEY_XMLRPC_LOGIN => '1',
103 );
104 }
105
106 /**
107 * 設定値key取得
108 */
109 public function get_keys(): array {
110 return array( self::KEY_FEATURE, self::KEY_XMLRPC_LOGIN );
111 }
112
113 /**
114 * 設定値取得
115 */
116 public function get_settings(): array {
117 $settings = array();
118 $keys = $this->get_keys();
119
120 foreach ( $keys as $key ) {
121 $settings[ $key ] = $this->config->get( $key );
122 }
123
124 return $settings;
125 }
126
127 /**
128 * 設定値保存
129 *
130 * @param array $settings
131 *
132 * @return void
133 */
134 public function save_settings( array $settings ): void {
135 $keys = $this->get_keys();
136
137 foreach ( $keys as $key ) {
138 $this->config->set( $key, $settings[ $key ] ?? '' );
139 }
140 $this->config->save();
141 }
142
143 /**
144 * 有効化
145 *
146 * @return void
147 */
148 public function activate(): void {
149 $settings = $this->get_default();
150 $this->save_settings( $settings );
151 $this->create_auth_table();
152 $this->create_login_table();
153 }
154
155 /**
156 * 無効化
157 *
158 * @return void
159 */
160 public function deactivate(): void {
161 $settings = $this->get_settings();
162 $settings[ self::KEY_FEATURE ] = 'f';
163 $this->save_settings( $settings );
164 }
165
166 /**
167 * 管理画面上での有効無効判定
168 * 2段階認証の管理画面で「変更を保存」ボタンを押下時、
169 * is_enabled()のみを使うとデバイス登録のメニューが正しく表示されない。
170 *
171 * @return bool
172 */
173 public function is_enabled_on_screen(): bool {
174 if ( isset( $_POST['two_factor_authentication'] ) && ! empty( $_POST['two_factor_authentication'] ) ) {
175 return $this->check_environment() && sanitize_text_field( $_POST['two_factor_authentication'] ) === 't';
176 }
177
178 return $this->is_enabled();
179 }
180
181 /**
182 * 有効な権限グループに含まれるかどうか
183 *
184 * @param $role
185 *
186 * @return bool
187 */
188 private function is_role_enabled( $role ): bool {
189 return in_array( $role, get_option( 'cloudsecurewp_two_factor_authentication_roles', array() ) );
190 }
191
192 /**
193 * 2段階認証のメッセージを出力
194 *
195 * @param string $message メッセージ
196 *
197 * @return void
198 */
199 private function login_message( string $message ) {
200 if ( empty( $message ) ) {
201 return;
202 }
203 echo '<div class="notice notice-success">' . esc_html( apply_filters( 'login_messages', $message ) ) . "</div>\n";
204 }
205
206 /**
207 * 2段階認証のエラーを出力
208 *
209 * @param string $error エラーメッセージ
210 *
211 * @return void
212 */
213 private function login_error( string $error ) {
214 if ( empty( $error ) ) {
215 return;
216 }
217 echo '<div id="login_error" class="notice notice-error">' . esc_html( apply_filters( 'login_errors', $error ) ) . "</div>\n";
218 }
219
220 /**
221 * 画面表示用のメールアドレスを生成
222 *
223 * @param int $user_id ユーザーID
224 *
225 * @return string
226 */
227 public function mask_email( int $user_id ): string {
228 $email = get_userdata( $user_id )->user_email;
229
230 list( $user, $full_domain ) = explode( '@', $email );
231
232 $last_dot_pos = strrpos( $full_domain, '.' );
233 $domain_name = substr( $full_domain, 0, $last_dot_pos );
234 $tld = substr( $full_domain, $last_dot_pos );
235
236 [ $masked_user, $masked_domain ] = array_map(
237 function( $str ) {
238 $showVisible = ( mb_strlen( $str ) > 2 ) ? 2 : 1;
239 return mb_substr( $str, 0, $showVisible ) . '*****';
240 },
241 [ $user, $domain_name ]
242 );
243
244 $masked_address = $masked_user . '@' . $masked_domain . $tld;
245
246 return $masked_address;
247 }
248
249 /**
250 * 2段階認証のログインフォームを出力
251 *
252 * @param string $login_token
253 * @param int $auth_method
254 * @param bool $has_recovery
255 * @param string $email_address
256 * @param int $remaining_seconds
257 *
258 * @return void
259 */
260 private function login_form( string $login_token, int $auth_method, bool $has_recovery, string $email_address, int $remaining_seconds ) {
261 ?>
262 <form name="loginform" id="loginform"
263 action="<?php echo esc_url( site_url( 'wp-login.php?action=cloudsecurewp_validate_2fa', 'login_post' ) ); ?>" method="post">
264 <?php wp_nonce_field( $this->get_feature_key() . '_csrf_' . $login_token ); ?>
265 <div class="two-fa-form">
266 <input type="hidden" name="login_token" value="<?php echo esc_attr( $login_token ); ?>">
267 <input type="hidden" name="redirect_to"
268 value="<?php echo esc_attr( wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ?? '' ), admin_url() ) ); ?>"/>
269 <input type="hidden" name="testcookie" value="1"/>
270 <?php if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) : ?>
271 <!-- リカバリーコード�
272 �力フォーム -->
273 <input type="hidden" name="recovery_code" value="1">
274 <div class="text-area">
275 <p>バックアップとして保存したリカバリーコードを�
276 �力してください。</p>
277 </div>
278 <div class="input-area">
279 <p>
280 <label for="authenticator_code">リカバリーコード</label>
281 <input type="text" name="authenticator_code" id="authenticator_code" class="input two-fa-input"
282 value="" size="20" autocomplete="off"/>
283 </p>
284 </div>
285 <script type="text/javascript">document.getElementById("authenticator_code").focus();</script>
286 <?php else : ?>
287 <!-- 通常の認証コード�
288 �力フォーム -->
289 <div class="two-fa-text-area">
290 <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
291 <p><strong><?php echo esc_html( $email_address ); ?></strong>に送信された認証コードを�
292 �力してください。</p>
293 <p>※メールが届かない場合、コードを再送信してください。</p>
294 <?php else : ?>
295 <p>デバイスのGoogle Authenticator に表示されている認証コードを�
296 �力してください。</p>
297 <?php endif; ?>
298 </div>
299 <div class="two-fa-input-area">
300 <p>
301 <label for="authenticator_code">認証コード(6桁)</label>
302 <input type="text" name="authenticator_code" id="authenticator_code" class="input two-fa-input"
303 value="" size="20" autocomplete="one-time-code"/>
304 </p>
305 </div>
306 <script type="text/javascript">document.getElementById("authenticator_code").focus();</script>
307 <?php endif; ?>
308 <div>
309 <div class="two-fa-btn-area">
310 <button type="submit" name="wp-submit" id="wp-submit" class="button button-primary two-fa-btn" style="order: 2;">
311 <?php esc_attr_e( 'Log In' ); ?>
312 </button>
313 <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
314 <button type="submit" name="resend_2fa_email" value="1" id="resend_2fa_email_btn" class="button two-fa-btn" data-cooldown="30" data-remaining_seconds="<?php echo esc_attr( $remaining_seconds ); ?>" style="order: 1;">
315 再送信
316 </button>
317 <?php endif; ?>
318 </div>
319 <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
320 <div id="resend_cooldown_message" class="two-fa-cooldown-message" style="display: none;"></div>
321 <?php endif; ?>
322 </div>
323 <div class="two-fa-link-area">
324 <?php if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) : ?>
325 <!-- 通常の認証コード�
326 �力に戻るボタン -->
327 <button type="submit" name="back_to_auth_code" value="1" class="two-fa-link">
328 ← 認証コード�
329 �力に戻る
330 </button>
331 <?php elseif ( $has_recovery ) : ?>
332 <!-- リカバリーコード�
333 �力へのボタン -->
334 <div class="separator">
335 または
336 </div>
337 <button type="submit" name="use_recovery_code" value="1" class="two-fa-link">
338 リカバリーコードを使用する
339 </button>
340 <?php endif; ?>
341 </div>
342 </div>
343 </form>
344 <style>
345 .two-fa-form {
346 display: flex;
347 flex-direction: column;
348 gap: 24px;
349 }
350 .two-fa-text-area {
351 display: flex;
352 flex-direction: column;
353 gap: 3px;
354 }
355 .two-fa-input {
356 margin: 0 !important;
357 }
358 .two-fa-btn-area {
359 display: flex;
360 gap: 16px;
361 justify-content: center;
362 align-items: center;
363 }
364 .two-fa-btn {
365 padding: 0 !important;
366 margin: 0 !important;
367 width: 50%;
368 height: 36px;
369 }
370 .two-fa-link-area {
371 display: flex;
372 flex-direction: column;
373 gap: 16px;
374 text-align: center;
375 }
376 .two-fa-link {
377 padding: 0;
378 border: none;
379 background: none;
380 color: #2271b1;
381 text-decoration: underline;
382 cursor: pointer;
383 }
384 .separator {
385 display: flex;
386 align-items: center;
387 color: #646970;
388 font-size: 14px;
389 }
390 .separator::before,
391 .separator::after {
392 content: "";
393 flex: 1;
394 height: 1px;
395 background: #B2B2B2;
396 }
397 .separator::before {
398 margin-right: 8px;
399 }
400
401 .separator::after {
402 margin-left: 8px;
403 }
404 .two-fa-cooldown-message {
405 font-size: 12px;
406 text-align: left;
407 color: #646970;
408 }
409 </style>
410 <script type="text/javascript">
411 (function() {
412 let emailAbleSendTime = null;
413
414 const resendBtn = document.getElementById('resend_2fa_email_btn');
415 const cooldownMessage = document.getElementById('resend_cooldown_message');
416 if (!resendBtn || !cooldownMessage) return;
417
418 // サーバーから受け取った残り秒数を使って送信可能時刻を計算
419 const remainingSeconds = parseInt(resendBtn.getAttribute('data-remaining_seconds') || '0', 10);
420 emailAbleSendTime = Date.now() + (remainingSeconds * 1000);
421
422 function updateTimerDisplay() {
423 // 現在時刻と送信可能時刻を比較して残り秒数を計算
424 const now = Date.now();
425 const remainingTime = Math.ceil((emailAbleSendTime - now) / 1000);
426
427 if (remainingTime > 0) {
428 resendBtn.disabled = true;
429 cooldownMessage.style.display = 'block';
430 cooldownMessage.textContent = remainingTime + '秒後 再送信できます';
431 setTimeout(updateTimerDisplay, 1000);
432 } else {
433 resendBtn.disabled = false;
434 cooldownMessage.style.display = 'none';
435 }
436 }
437
438 // 表示時にタイマーを開始
439 <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
440 updateTimerDisplay();
441 <?php endif; ?>
442 })();
443 </script>
444 <?php
445 }
446
447 /**
448 * デバイス登録がまだのユーザーは、デバイス登録画面にリダイレクト
449 *
450 * @param $user_login
451 * @param $user
452 *
453 * @return void
454 * @noinspection PhpUnusedParameterInspection
455 */
456 public function redirect_if_not_two_factor_authentication_registered( $user_login, $user ) {
457 $auth_info = $this->get_2fa_auth_info( $user->ID );
458 $secret = ( count( $auth_info ) > 0 && isset( $auth_info['secret'] ) ) ? true : false;
459
460 if ( isset( $user->roles[0] ) ) {
461 if ( $this->is_enabled() && $this->is_role_enabled( $user->roles[0] ) && ! $secret && $_SERVER['REQUEST_URI'] !== '/wp-admin/admin.php?page=cloudsecurewp_two_factor_authentication_registration' ) {
462 wp_redirect( admin_url( 'admin.php?page=cloudsecurewp_two_factor_authentication_registration' ) );
463 exit;
464 }
465 }
466 }
467
468 /**
469 * WordPress標準機能のユーザー一覧に表示するcolumnを追加
470 */
471 public function add_2factor_state_2user_list( $columns ) {
472 $new_columns = [];
473
474 foreach ( $columns as $key => $value ) {
475 $new_columns[ $key ] = $value;
476
477 if ( $key === 'role' ) {
478 $new_columns['is_2factor'] = '2段階認証';
479 }
480 }
481
482 return $new_columns;
483 }
484
485 /**
486 * WordPress標準機能のユーザー一覧に表示する二段階認証の設定状�
487 �を指定
488 */
489 public function show_2factor_state_2user_list( $value, $column_name, $user_id ) {
490 if ( $column_name === 'is_2factor' ) {
491 $auth_info = $this->get_2fa_auth_info( $user_id );
492 if ( count( $auth_info ) === 0 ) {
493 // データ移行漏れ対応
494 $auth_info = $this->repair_migration_gaps( $user_id );
495 }
496 $value = '未設定';
497 if ( count( $auth_info ) > 0 ) {
498 $value = '設定済';
499 }
500 return $value;
501 }
502 return $value;
503 }
504
505 /**
506 * option keyを作成
507 *
508 * @param string $token
509 *
510 * @return string
511 */
512 private function create_option_key( string $token ): string {
513 return self::OPTION_PREFIX . $token;
514 }
515
516 /**
517 * option dataを登録
518 *
519 * @param string $key
520 * @param mixed $data
521 *
522 * @return void
523 */
524 private function set_option_data( string $key, $data ): void {
525 update_option( $key, $data, false );
526 }
527
528 /**
529 * option dataを取得
530 *
531 * @param string $key
532 *
533 * @return array|false データが存在しないまたは、有効期限切れの場合FALSEを返却
534 */
535 private function get_option_data( string $key ) {
536
537 $data = get_option( $key );
538
539 // データが存在しない
540 if ( ! $data || ! is_array( $data ) ) {
541 return false;
542 }
543
544 // 有効期限切れ
545 if ( ! isset( $data['expires'] ) || $data['expires'] <= time() ) {
546 return false;
547 }
548
549 // 有効なデータを返却
550 return $data;
551 }
552
553 /**
554 * option dataを削除
555 *
556 * @param string $key
557 *
558 * @return void
559 */
560 private function delete_option_data( string $key ): void {
561 delete_option( $key );
562 }
563
564 /**
565 * 2段階認証が�
566 要かどうか判定処理
567 *
568 * @param mixed $user
569 *
570 * @return bool
571 */
572 private function is_2fa_required( $user ): bool {
573
574 // 2段階認証が無効な場合
575 if ( ! $this->is_enabled() ) {
576 return false;
577 }
578
579 // 有効な権限グループに含まれない場合
580 if ( ! isset( $user->roles[0] ) || ! $this->is_role_enabled( $user->roles[0] ) ) {
581 return false;
582 }
583
584 return true;
585 }
586
587 /**
588 * 2段階認証画面を表示
589 *
590 * @param string $login_token
591 * @param int $user_id
592 * @param int $auth_method
593 * @param bool $has_recovery
594 * @param string $email_address
595 * @param bool $is_send_email
596 *
597 * @return void
598 */
599 private function show_two_factor_form( string $login_token, int $user_id, int $auth_method, bool $has_recovery, string $email_address, bool $is_send_email ): void {
600 $message = '';
601 $error = '';
602 $remaining_seconds = 0;
603
604 // 再送信メッセージ
605 if ( array_key_exists( 'resend_2fa_email', $_REQUEST ) ) {
606 if ( $is_send_email ) {
607 $message = '認証コードを再送信しました。';
608 } else {
609 $error = '認証コードの再送信は30秒に1回までです。しばらく時間をおいてから再度お試しください。';
610 }
611 }
612
613 // エラーメッセージ
614 if ( array_key_exists( 'wp-submit', $_REQUEST ) && array_key_exists( 'authenticator_code', $_REQUEST ) ) {
615 if ( sanitize_text_field( $_REQUEST['authenticator_code'] ) ) {
616 $error = '認証コードが間違っているか、有効期限が切れています。';
617 } else {
618 $error = '認証コードが�
619 �力されていません。';
620 }
621 }
622
623 if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) {
624 // メール認証の場合、送信可能になるまでの残り秒数を計算
625 $able_send_time = $this->get_email_able_send_time( $user_id );
626 $remaining_seconds = max( 0, $able_send_time - time() );
627 }
628
629 // 2FA画面を表示
630 login_header( '2段階認証画面' );
631 $this->login_message( $message );
632 $this->login_error( $error );
633 $this->login_form( $login_token, $auth_method, $has_recovery, $email_address, $remaining_seconds );
634 login_footer();
635 exit;
636 }
637
638 /**
639 * メール認証コードの再送信処理
640 *
641 * @param int $user_id
642 * @param string $secret
643 *
644 * @return bool true: 送信成功、false: 送信失敗
645 */
646 private function send_2fa_email( int $user_id, string $secret = '' ): bool {
647 // 送信制限チェック(30秒間)
648 $current_time = time();
649 $able_sent_time = $this->get_email_able_send_time( $user_id );
650
651 if ( $current_time < $able_sent_time ) {
652 // 30秒以�
653 の再送信は処理しない(エラーは表示しない)
654 return false;
655 }
656
657 // シークレットキーを取得
658 if ( $secret === '' ) {
659 $auth_info = $this->get_2fa_auth_info( $user_id );
660 $secret = $auth_info['secret'];
661 }
662 // 新しいコードを生成して送信
663 $code = CloudSecureWP_Time_Based_One_Time_Password::create_code_for_email( $secret, self::AUTH_EMAIL_INTERVAL );
664 $result = $this->send_code( $user_id, $code, self::AUTH_EMAIL_INTERVAL, 'login' );
665 if ( ! $result ) {
666 return false;
667 }
668
669 // 最終送信時刻を更新
670 $this->update_email_able_send_time( $user_id );
671
672 return true;
673 }
674
675 /**
676 * 最後に認証成功した time_slice を取得
677 * 未設定(初回ログイン)の場合は false を返す
678 *
679 * @param int $user_id
680 *
681 * @return int|false
682 */
683 private function get_last_success_slice( int $user_id ) {
684 $value = get_user_meta( $user_id, self::TWO_FACTOR_LAST_SUCCESS, true );
685 if ( $value === '' || $value === false || $value === null ) {
686 return false;
687 }
688 $int_value = absint( $value );
689
690 if ( $int_value === 0 ) {
691 return false;
692 }
693 return $int_value;
694 }
695
696 /**
697 * 最後に認証成功した time_slice を保存
698 *
699 * @param int $user_id
700 * @param int $time_slice
701 *
702 * @return bool 保存成功時 true、DB書き込み失敗時 false
703 */
704 private function update_last_success_slice( int $user_id, int $time_slice ): bool {
705 $result = update_user_meta( $user_id, self::TWO_FACTOR_LAST_SUCCESS, $time_slice );
706 return $result !== false;
707 }
708
709 /**
710 * TOTP/メール OTP のリプレイ防止付き検証
711 *
712 * @param int $user_id
713 * @param string $code
714 * @param int $auth_method
715 *
716 * @return bool true: 検証成功、false: 検証失敗
717 */
718 private function verify_totp_with_replay_protection( int $user_id, string $code, int $auth_method ): bool {
719 global $wpdb;
720
721 // シークレットキーを取得
722 $auth_info = $this->get_2fa_auth_info( $user_id );
723 if ( ! $auth_info || ! isset( $auth_info['secret'] ) ) {
724 return false;
725 }
726 $secret_key = $auth_info['secret'];
727
728 $time_step = ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) ? self::AUTH_EMAIL_INTERVAL : self::AUTH_APP_INTERVAL;
729
730 // コード認証
731 $matched_slice = CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, $time_step );
732
733 if ( $matched_slice === false ) {
734 // 認証失敗
735 return false;
736 }
737
738 // 使用済みコードの確認
739 $lock_name = 'cloudsecurewp_2fa_verify' . $user_id;
740
741 // ロックを取得
742 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
743 $acquired = $wpdb->get_var(
744 $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, self::REPLAY_LOCK_TIMEOUT )
745 );
746
747 if ( $acquired !== '1' ) {
748 // ロック取得失敗:認証を失敗とみなす(リプレイ攻撃の可能性)
749 return false;
750 }
751
752 try {
753 $last_slice = $this->get_last_success_slice( $user_id );
754
755 if ( $last_slice !== false && $matched_slice <= $last_slice ) {
756 // 使用済みコード:認証失敗
757 return false;
758 }
759
760 // 最終成功スライスを更新(失敗時はリプレイ防止が機能しないため認証失敗とする)
761 if ( ! $this->update_last_success_slice( $user_id, $matched_slice ) ) {
762 return false;
763 }
764 return true;
765
766 } finally {
767 // ロック解放
768 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
769 $wpdb->query(
770 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
771 );
772 }
773 }
774
775 /**
776 * 2段階認証コード検証処理
777 *
778 * @param int $user_id
779 * @param string $code
780 * @param int $auth_method
781 *
782 * @return bool
783 */
784 private function verify_2fa_code( int $user_id, string $code, int $auth_method ): bool {
785 // リカバリーコードでの認証の場合(time_slice 管理対象外)
786 if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) {
787 return CloudSecureWP_Recovery_Codes::verify_code( $user_id, $code );
788 }
789
790 // メール認証・アプリ認証の場合
791 return $this->verify_totp_with_replay_protection( $user_id, $code, $auth_method );
792 }
793
794 // =========================================================================
795 // 新ログインフロー: id・passの認証 (authenticate チェーン → wp_login)
796 // =========================================================================
797
798 /**
799 * authenticate フィルター(優�
800 �度 PHP_INT_MAX):
801 * 2FA対象ユーザーの認証Cookie送信をブロックする
802 *
803 * @param mixed $user
804 * @param string $username
805 * @param string $password
806 *
807 * @return mixed
808 */
809 public function block_auth_cookies_for_2fa_user( $user, $username, $password ) {
810 if ( ! ( $user instanceof WP_User ) ) {
811 return $user;
812 }
813
814 $auth_info = $this->get_2fa_auth_info( $user->ID );
815 if ( count( $auth_info ) === 0 ) {
816 $auth_info = $this->repair_migration_gaps( $user->ID );
817 }
818 $has_2fa_secret = count( $auth_info ) > 0 && ! empty( $auth_info['secret'] );
819
820 if ( $this->is_2fa_required( $user ) && $has_2fa_secret && did_action( 'login_init' ) ) {
821 add_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX );
822 }
823 return $user;
824 }
825
826 /**
827 * set_auth_cookie / set_logged_in_cookie フック:
828 * WordPressコアがDBに作成したセッショントークンを収集する
829 *
830 * @param string $cookie クッキー文字列
831 *
832 * @return void
833 */
834 public function collect_auth_cookie_tokens( $cookie ): void {
835 $parsed = wp_parse_auth_cookie( $cookie );
836 if ( ! empty( $parsed['token'] ) ) {
837 $this->password_auth_tokens[] = $parsed['token'];
838 }
839 }
840
841 /**
842 * wp_login アクション(優�
843 �度 0):
844 * 2FA対象ユーザーの場合、セッションを作成して2FA画面を表示し exit する
845 *
846 * @param string $user_login
847 * @param WP_User $user
848 *
849 * @return void
850 */
851 public function maybe_show_two_factor_login( $user_login, $user ): void {
852 // 2段階認証が完了している場合
853 if ( $this->completing_2fa_login ) {
854 return;
855 }
856
857 // 2段階認証が不要な場合
858 if ( ! $this->is_2fa_required( $user ) ) {
859 return;
860 }
861
862 // 認証�
863 報の取得
864 $auth_info = $this->get_2fa_auth_info( $user->ID );
865 if ( count( $auth_info ) === 0 || empty( $auth_info['secret'] ) ) {
866 return;
867 }
868
869 // WordPressコアがDBに作成したセッショントークンを破棄
870 $this->destroy_collected_session_tokens( $user->ID );
871 wp_clear_auth_cookie();
872
873 // リカバリーコードの有無
874 $has_recovery = true;
875 $recovery_codes = $auth_info['recovery'];
876 if ( ! $recovery_codes || ! is_array( $recovery_codes ) || count( $recovery_codes ) === 0 ) {
877 $has_recovery = false;
878 }
879
880 // option key生成
881 $session_token = bin2hex( random_bytes( 16 ) );
882 $option_key = $this->create_option_key( $session_token );
883
884 // 保存用ログインデータ作成
885 $option_data = array(
886 'user_id' => $user->ID,
887 'user_login' => sanitize_text_field( $_POST['log'] ?? '' ),
888 'ip' => $this->get_client_ip(),
889 'auth_method' => intval( $auth_info['method'] ),
890 'expires' => time() + self::SESSION_EXPIRY,
891 'created' => time(),
892 'has_recovery' => $has_recovery,
893 'email_address' => $this->mask_email( $user->ID ),
894 'rememberme' => sanitize_text_field( $_POST['rememberme'] ?? '' ),
895 );
896
897 // データを保存
898 $this->set_option_data( $option_key, $option_data );
899
900 // メール認証の場合、コードを生成して送信
901 if ( intval( $auth_info['method'] ) === self::USER_AUTH_METHOD_EMAIL ) {
902 $this->send_2fa_email( $user->ID, $auth_info['secret'] );
903 }
904
905 // 2FA画面を表示して、処理終了
906 $this->show_two_factor_form(
907 $session_token,
908 $user->ID,
909 intval( $auth_info['method'] ),
910 $has_recovery,
911 $option_data['email_address'],
912 false
913 );
914 }
915
916 /**
917 * 収集したセッショントークンをDBから破棄する
918 *
919 * @param int $user_id
920 *
921 * @return void
922 */
923 private function destroy_collected_session_tokens( int $user_id ): void {
924 if ( empty( $this->password_auth_tokens ) ) {
925 return;
926 }
927 $manager = WP_Session_Tokens::get_instance( $user_id );
928 foreach ( $this->password_auth_tokens as $token ) {
929 $manager->destroy( $token );
930 }
931 $this->password_auth_tokens = array();
932 }
933
934 // =========================================================================
935 // 新ログインフロー: 2FAコードの認証 (login_form_cloudsecurewp_validate_2fa)
936 // =========================================================================
937
938 /**
939 * login_form_cloudsecurewp_validate_2fa アクション:
940 * 2FAコードの検証を行う専用エントリポイント
941 *
942 * @return void
943 */
944 public function validate_two_factor_login(): void {
945 // GETリクエストはエラー・ログなしでログインページへリダイレクト
946 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== strtoupper( $_SERVER['REQUEST_METHOD'] ) ) {
947 wp_safe_redirect( wp_login_url() );
948 exit;
949 }
950
951 // ログイントークンを取得
952 $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
953
954 // CSRFトークンを検証
955 if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), $this->get_feature_key() . '_csrf_' . $login_token ) === false ) {
956 $this->redirect_to_login( 'session_expired' );
957 }
958
959 // ログイン�
960 報を取得
961 $option_key = $this->create_option_key( $login_token );
962 $option_data = $this->get_option_data( $option_key );
963 if ( $option_data === false ) {
964 $this->redirect_to_login( 'session_expired' );
965 }
966
967 // ユーザー存在確認
968 $user = get_user_by( 'id', $option_data['user_id'] );
969 if ( ! $user ) {
970 $this->delete_option_data( $option_key );
971 $this->redirect_to_login( 'user_not_found' );
972 }
973
974 // レートリミットチェック
975 $limit_time = $this->two_factor_rate_limit();
976 if ( $limit_time > 0 ) {
977 $this->write_log( self::LOGIN_STATUS_DISABLED, $user->user_login );
978 $this->delete_option_data( $option_key );
979 $this->redirect_to_login( 'rate_limited' );
980 }
981
982 // 認証方法の設定
983 $auth_method = intval( $option_data['auth_method'] );
984
985 // 認証コード再送信
986 if ( isset( $_POST['resend_2fa_email'] ) ) {
987 // メール再送信
988 $result = $this->send_2fa_email( $option_data['user_id'] );
989
990 $this->show_two_factor_form(
991 $login_token,
992 $option_data['user_id'],
993 $auth_method,
994 $option_data['has_recovery'],
995 $option_data['email_address'],
996 $result
997 );
998 }
999
1000 // リカバリーコード�
1001 �力画面への切り替え
1002 if ( isset( $_POST['use_recovery_code'] ) ) {
1003 $this->show_two_factor_form(
1004 $login_token,
1005 $option_data['user_id'],
1006 self::USER_AUTH_METHOD_RECOVERY,
1007 $option_data['has_recovery'],
1008 $option_data['email_address'],
1009 false
1010 );
1011 }
1012
1013 // 認証コード�
1014 �力画面への切り替え
1015 if ( isset( $_POST['back_to_auth_code'] ) ) {
1016 $this->show_two_factor_form(
1017 $login_token,
1018 $option_data['user_id'],
1019 $auth_method,
1020 $option_data['has_recovery'],
1021 $option_data['email_address'],
1022 false
1023 );
1024 }
1025
1026 // リカバリーコードでの認証の場合
1027 $verify_method = $auth_method;
1028 if ( isset( $_POST['recovery_code'] ) ) {
1029 $verify_method = self::USER_AUTH_METHOD_RECOVERY;
1030 }
1031
1032 // 認証コード取得
1033 $auth_code = sanitize_text_field( $_POST['authenticator_code'] ?? '' );
1034
1035 // 2FAコード検証
1036 if ( $this->verify_2fa_code( $user->ID, $auth_code, $verify_method ) ) {
1037 // 認証成功の場合
1038 $this->complete_two_factor_login( $user, $option_data, $option_key );
1039 }
1040
1041 // 認証失敗の場合
1042 // 失敗回数をインクリメント
1043 $this->increment_fail_count();
1044
1045 $this->write_log( self::LOGIN_STATUS_FAILED, $option_data['user_login'] );
1046
1047 // レートリミットの確認
1048 $limit_time = $this->two_factor_rate_limit();
1049 if ( $limit_time > 0 ) {
1050 // レートリミットが発動した場合、ログイン画面にリダイレクト
1051 $this->delete_option_data( $option_key );
1052 $this->redirect_to_login( 'rate_limited' );
1053 }
1054
1055 // 2FA画面を再表示して、処理終了
1056 $this->show_two_factor_form(
1057 $login_token,
1058 $option_data['user_id'],
1059 $verify_method,
1060 $option_data['has_recovery'],
1061 $option_data['email_address'],
1062 false
1063 );
1064 }
1065
1066 /**
1067 * 2FA認証成功後のログイン完了処理
1068 *
1069 * @param WP_User $user
1070 * @param array $option_data
1071 * @param string $option_key
1072 *
1073 * @return void
1074 */
1075 private function complete_two_factor_login( WP_User $user, array $option_data, string $option_key ): void {
1076 // 失敗回数をリセット
1077 $this->reset_fail_count();
1078 // セッションデータを削除
1079 $this->delete_option_data( $option_key );
1080 // メール認証の送信可能時刻をリセット
1081 $this->delete_email_able_send_time( $user->ID );
1082
1083 // ログインCookieを発行
1084 $remember = ( isset( $option_data['rememberme'] ) && 'forever' === $option_data['rememberme'] );
1085 wp_set_auth_cookie( $user->ID, $remember );
1086
1087 // 再帰防止フラグ
1088 $this->completing_2fa_login = true;
1089
1090 // wp_login を明示発火(ログイン履歴 / ログイン通知 / 管理画面制限更新が実行される)
1091 do_action( 'wp_login', $user->user_login, $user );
1092
1093 // リダイレクトURLの検証とリダイレクト
1094 $redirect_to = wp_validate_redirect( wp_unslash( $_POST['redirect_to'] ?? '' ), admin_url() );
1095 $redirect_to = apply_filters( 'login_redirect', $redirect_to, $redirect_to, $user );
1096 wp_safe_redirect( $redirect_to );
1097 exit;
1098 }
1099
1100 /**
1101 * ログインページにリダイレクト(エラーメッセージ付き)
1102 *
1103 * @param string $error_code エラーコード
1104 *
1105 * @return never
1106 */
1107 private function redirect_to_login( string $error_code ): void {
1108 $url = add_query_arg( 'cloudsecurewp_2fa_error', $error_code, wp_login_url() );
1109 wp_safe_redirect( $url );
1110 exit;
1111 }
1112
1113 /**
1114 * wp_login_errors フィルター:
1115 * リダイレクト時のクエリパラメータに応じてエラーメッセージを表示
1116 *
1117 * @param WP_Error $errors
1118 * @param string $redirect_to
1119 *
1120 * @return WP_Error
1121 */
1122 public function filter_login_errors( WP_Error $errors, string $redirect_to ): WP_Error {
1123 if ( ! isset( $_GET['cloudsecurewp_2fa_error'] ) ) {
1124 return $errors;
1125 }
1126
1127 $error_code = sanitize_text_field( $_GET['cloudsecurewp_2fa_error'] );
1128 switch ( $error_code ) {
1129 case 'session_expired':
1130 $errors->add( 'cloudsecurewp_2fa_session_expired', 'セッションの有効期限が切れました。再度ログインしてください。' );
1131 break;
1132 case 'user_not_found':
1133 $errors->add( 'cloudsecurewp_2fa_user_not_found', 'ユーザー�
1134 報が見つかりません。再度ログインしてください。' );
1135 break;
1136 case 'rate_limited':
1137 $limit_time = $this->two_factor_rate_limit();
1138 if ( $limit_time > 0 ) {
1139 $errors->add( 'cloudsecurewp_2fa_rate_limited', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく�
1140 ってから再度お試しください。" );
1141 }
1142 break;
1143 default:
1144 break;
1145 }
1146
1147 // ページ表示後にURLからクエリパラメータを削除(リロード時に再表示されないようにする)
1148 add_action(
1149 'login_footer',
1150 function() {
1151 ?>
1152 <script>
1153 if ( window.history && window.history.replaceState ) {
1154 var url = new URL( window.location.href );
1155 url.searchParams.delete( 'cloudsecurewp_2fa_error' );
1156 window.history.replaceState( {}, '', url.toString() );
1157 }
1158 </script>
1159 <?php
1160 }
1161 );
1162
1163 return $errors;
1164 }
1165
1166 /**
1167 * 認証フック: 2段階認証ログイン無効チェック
1168 *
1169 * @param mixed $user
1170 * @param string $username
1171 * @param string $password
1172 *
1173 * @return mixed
1174 */
1175 public function two_factor_disable_login_check( $user, $username, $password ) {
1176 // GETリクエストはレートリミットチェックを行わない
1177 if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== strtoupper( $_SERVER['REQUEST_METHOD'] ) ) {
1178 return $user;
1179 }
1180
1181 // レートリミットの無効時間を取得
1182 $limit_time = $this->two_factor_rate_limit();
1183 if ( $limit_time > 0 ) {
1184 // ログインログに記録
1185 $this->write_log( self::LOGIN_STATUS_DISABLED, sanitize_text_field( $_POST['log'] ?? '' ) );
1186
1187 return new WP_Error( 'empty_username', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく�
1188 ってから再度お試しください。" );
1189 }
1190 return $user;
1191 }
1192
1193
1194 /**
1195 * 2FAセッションデータを取得
1196 *
1197 * @param int $last_option_id
1198 * @param int $limit
1199 *
1200 * @return array
1201 */
1202 private function fetch_2fa_sessions( int $last_option_id, int $limit ): array {
1203 global $wpdb;
1204
1205 // セッションキーの接頭辞でLIKE検索
1206 $like = $wpdb->esc_like( self::OPTION_PREFIX ) . '%';
1207
1208 // SQL実行(LIKE検索を行うため、意図的に直接クエリを実行する)
1209 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1210 $options = $wpdb->get_results(
1211 $wpdb->prepare(
1212 "SELECT
1213 option_id,
1214 option_name,
1215 option_value
1216 FROM
1217 {$wpdb->options}
1218 WHERE TRUE
1219 AND option_name LIKE %s
1220 AND %d < option_id
1221 ORDER BY
1222 option_id ASC
1223 LIMIT
1224 %d",
1225 $like,
1226 $last_option_id,
1227 $limit
1228 )
1229 );
1230
1231 return $options ?? array();
1232 }
1233
1234 /**
1235 * 期限切れセッションデータを収集
1236 *
1237 * @param array $options
1238 *
1239 * @return array ['log_data' => array, 'delete_option_names' => array]
1240 */
1241 private function collect_expired_session_data( array $options ): array {
1242 // ログ登録用データリスト初期化
1243 $log_data = array();
1244 // 削除対象のoption_nameリスト
1245 $delete_option_names = array();
1246
1247 foreach ( $options as $option ) {
1248 // シリアライズ形式でない場合はスキップ
1249 if ( ! is_serialized( $option->option_value ) ) {
1250 continue;
1251 }
1252
1253 // option_valueを連想�
1254 �列に変換
1255 $data = unserialize( $option->option_value, array( 'allowed_classes' => false ) );
1256
1257 // �
1258 �列でない場合はスキップ
1259 if ( ! is_array( $data ) ) {
1260 continue;
1261 }
1262
1263 // 有効期限が切れている場合
1264 if ( isset( $data['expires'] ) && $data['expires'] <= time() ) {
1265
1266 // ログ登録用データを収集
1267 $log_data[] = array(
1268 'name' => $data['user_login'] ?? '',
1269 'ip' => $data['ip'] ?? $this->get_client_ip(),
1270 'status' => self::LOGIN_STATUS_FAILED,
1271 'method' => self::METHOD_PAGE,
1272 'login_at' => wp_date( 'Y-m-d H:i:s', $data['created'] ), // WPのタイムゾーンに変更して登録
1273 );
1274
1275 // 削除対象のoption_nameを収集
1276 $delete_option_names[] = $option->option_name;
1277 }
1278 }
1279
1280 return array(
1281 'log_data' => $log_data,
1282 'delete_option_names' => $delete_option_names,
1283 );
1284 }
1285
1286 /**
1287 * ログイン失敗ログを一括登録
1288 * (呼び出し�
1289 �でトランザクションを管理すること)
1290 *
1291 * @param array $log_data
1292 *
1293 * @return void
1294 * @throws Exception SQLエラー発生時.
1295 */
1296 private function insert_login_failed_logs( array $log_data ): void {
1297 if ( empty( $log_data ) ) {
1298 return;
1299 }
1300
1301 global $wpdb;
1302
1303 // プレースホルダーと値の準備
1304 $values = array();
1305 $placeholders = array();
1306
1307 // 収集したログデータを一括登録用に変換
1308 foreach ( $log_data as $log ) {
1309 $values[] = $log['name'];
1310 $values[] = $log['ip'];
1311 $values[] = $log['status'];
1312 $values[] = $log['method'];
1313 $values[] = $log['login_at'];
1314 $placeholders[] = '(%s, %s, %d, %d, %s)';
1315 }
1316
1317 // SQL実行(一括登録を行うため、意図的に直接クエリを実行する)
1318 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1319 $result = $wpdb->query(
1320 $wpdb->prepare(
1321 "INSERT INTO `{$wpdb->prefix}cloudsecurewp_login_log`
1322 (`name`, `ip`, `status`, `method`, `login_at`)
1323 VALUES " . implode( ', ', $placeholders ),
1324 $values
1325 )
1326 );
1327
1328 // SQLエラーチェック
1329 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1330 throw new Exception( 'Failed to insert login logs.' );
1331 }
1332 }
1333
1334 /**
1335 * 指定されたオプションを一括削除
1336 * (呼び出し�
1337 �でトランザクションを管理すること)
1338 *
1339 * @param array $option_names
1340 *
1341 * @return void
1342 * @throws Exception SQLエラー発生時.
1343 */
1344 private function delete_options( array $option_names ): void {
1345 if ( empty( $option_names ) ) {
1346 return;
1347 }
1348
1349 global $wpdb;
1350
1351 // プレースホルダー作成
1352 $placeholders = implode( ', ', array_fill( 0, count( $option_names ), '%s' ) );
1353
1354 // SQL実行(一括削除を行うため、意図的に直接クエリを実行する)
1355 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1356 $result = $wpdb->query(
1357 $wpdb->prepare(
1358 "DELETE FROM
1359 {$wpdb->options}
1360 WHERE
1361 option_name IN ($placeholders)",
1362 $option_names
1363 )
1364 );
1365
1366 // SQLエラーチェック
1367 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1368 throw new Exception( 'Failed to delete options.' );
1369 }
1370 }
1371
1372 /**
1373 * 期限切れログイン�
1374 報セッションのクリーンアップ処理本体
1375 *
1376 * @return void
1377 */
1378 private function process_cleanup_expired_sessions(): void {
1379 global $wpdb;
1380
1381 $last_option_id = 0;
1382
1383 while ( true ) {
1384 try {
1385 // 2FAセッションデータを取得
1386 $options = $this->fetch_2fa_sessions( $last_option_id, self::CLEANUP_BATCH_SIZE );
1387
1388 // 取得するレコードがなくなったら終了
1389 if ( empty( $options ) ) {
1390 break;
1391 }
1392
1393 // 最後に取得したoption_idを更新
1394 $last_option_id = end( $options )->option_id;
1395
1396 // 期限切れセッションデータを収集
1397 $result = $this->collect_expired_session_data( $options );
1398 $log_data = $result['log_data'];
1399 $delete_option_names = $result['delete_option_names'];
1400
1401 if ( empty( $delete_option_names ) && empty( $log_data ) ) {
1402 continue;
1403 }
1404
1405 // トランザクション開始
1406 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1407 $wpdb->query( 'START TRANSACTION' );
1408
1409 // ログデータを一括登録
1410 $this->insert_login_failed_logs( $log_data );
1411
1412 // オプションを一括削除
1413 $this->delete_options( $delete_option_names );
1414
1415 // トランザクションコミット
1416 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1417 $wpdb->query( 'COMMIT' );
1418
1419 } catch ( Exception $e ) {
1420 // エラー発生時はロールバック
1421 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1422 $wpdb->query( 'ROLLBACK' );
1423 break;
1424 }
1425 }
1426 }
1427
1428 /**
1429 * 期限切れの2FAセッションをクリーンアップ
1430 *
1431 * @return void
1432 */
1433 public function cleanup_expired_sessions(): void {
1434 global $wpdb;
1435
1436 // ロック名
1437 $lock_name = 'cloudsecurewp_2fa_cleanup_lock';
1438 // クリーンアップ処理の完了を�
1439 つ最大秒数
1440 $timeout = self::CLEANUP_TIMEOUT;
1441
1442 // ロックを取得
1443 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1444 $get_lock = $wpdb->get_var(
1445 $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_name )
1446 );
1447
1448 if ( $get_lock === '1' ) {
1449 // ロック取得成功(クリーンアップ実行�
1450
1451
1452 try {
1453 // クリーンアップ処理実行
1454 $this->process_cleanup_expired_sessions();
1455 } catch ( Exception $e ) {
1456 // クリーンアップ処理実行で失敗しても�
1457 部でロールバックするため、ここでは何もしない
1458 } finally {
1459 // ロック解放
1460 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1461 $wpdb->query(
1462 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
1463 );
1464 }
1465 } else {
1466 // ロック取得失敗(�
1467 機�
1468
1469
1470 // リーダーのクリーンアップ完了を�
1471
1472 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1473 $acquired_signal = $wpdb->get_var(
1474 $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, $timeout )
1475 );
1476
1477 if ( $acquired_signal === '1' ) {
1478 // ロック取得成功(クリーンアップ処理終了)
1479 // 即座にロック解放(�
1480 機完了の合図として使うだけ)
1481 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1482 $wpdb->query(
1483 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
1484 );
1485 }
1486 }
1487 }
1488
1489 /**
1490 * ユーザの2faログイン�
1491 報取得
1492 *
1493 * @param string $ip
1494 *
1495 * @return array $login_info
1496 */
1497 private function get_2fa_login_info( string $ip ): array {
1498 global $wpdb;
1499
1500 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1501 $sql = "SELECT * FROM {$table_name} WHERE ip = %s";
1502
1503 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1504 $row = $wpdb->get_row( $wpdb->prepare( $sql, $ip ), ARRAY_A );
1505
1506 return $row ?? array();
1507 }
1508
1509 /**
1510 * ユーザの2fa認証�
1511 報取得
1512 *
1513 * @param int $user_id
1514 *
1515 * @return array $auth_info
1516 */
1517 public function get_2fa_auth_info( int $user_id ): array {
1518 global $wpdb;
1519
1520 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1521 $sql = "SELECT * FROM {$table_name} WHERE user_id = %d";
1522
1523 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1524 $row = $wpdb->get_row( $wpdb->prepare( $sql, $user_id ), ARRAY_A );
1525
1526 if ( ! empty( $row ) && isset( $row['recovery'] ) && is_string( $row['recovery'] ) ) {
1527 // JSON形式を優�
1528 �でデコード(新形式)
1529 $decoded = json_decode( $row['recovery'], true );
1530 if ( is_array( $decoded ) ) {
1531 $row['recovery'] = $decoded;
1532 } elseif ( is_serialized( $row['recovery'] ) ) {
1533 // フォールバック:serialize形式をデコード(旧形式)
1534 $unserialized = unserialize( $row['recovery'], array( 'allowed_classes' => false ) );
1535 $row['recovery'] = is_array( $unserialized ) ? $unserialized : null;
1536 } else {
1537 // JSON でも serialize でもない場合は null を設定
1538 $row['recovery'] = null;
1539 }
1540 }
1541
1542 return $row ?? array();
1543 }
1544
1545 /**
1546 * リカバリーコードの登録状況取得
1547 *
1548 * @param int $user_id
1549 *
1550 * @return bool true:登録済み、false:未登録
1551 */
1552 public function has_recovery_codes( int $user_id ): bool {
1553 $auth_info = $this->get_2fa_auth_info( $user_id );
1554
1555 if ( empty( $auth_info ) || is_null( $auth_info['recovery'] ) ) {
1556 return false;
1557 }
1558
1559 return true;
1560 }
1561
1562 /**
1563 * ユーザの2fa認証方法設定
1564 *
1565 * @param int $user_id
1566 * @param int $method
1567 * @param string $secret
1568 *
1569 * @return void
1570 */
1571 public function setting_2fa_auth_info( int $user_id, int $method, string $secret ): void {
1572 global $wpdb;
1573
1574 // 認証�
1575 報取得
1576 $auth_info = $this->get_2fa_auth_info( $user_id );
1577
1578 // トランザクション開始
1579 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1580 $wpdb->query( 'START TRANSACTION' );
1581
1582 try {
1583 if ( ! empty( $auth_info ) ) {
1584 // 既に登録されている場合は更新
1585 $this->update_2fa_auth_method( $user_id, $method, $secret );
1586 } else {
1587 // 登録されていない場合は新規登録
1588 $this->insert_2fa_auth_method( $user_id, $method, $secret );
1589 }
1590
1591 // リプレイ防止マーカーをリセット
1592 delete_user_meta( $user_id, self::TWO_FACTOR_LAST_SUCCESS );
1593 if ( ! empty( $wpdb->last_error ) ) {
1594 throw new Exception( 'Failed to reset last success slice.' );
1595 }
1596
1597 // トランザクションコミット
1598 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1599 $wpdb->query( 'COMMIT' );
1600
1601 } catch ( Exception $e ) {
1602 // エラー発生時はロールバック
1603 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1604 $wpdb->query( 'ROLLBACK' );
1605 throw $e;
1606 }
1607 }
1608
1609 /**
1610 * ユーザの2fa認証方法更新
1611 *
1612 * @param int $user_id
1613 * @param int $method
1614 * @param string $secret
1615 *
1616 * @return void
1617 * @throws Exception SQLエラー発生時.
1618 */
1619 private function update_2fa_auth_method( int $user_id, int $method, string $secret ): void {
1620 global $wpdb;
1621
1622 // 認証�
1623 報を更新
1624 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1625 $result = $wpdb->update(
1626 $wpdb->prefix . 'cloudsecurewp_2fa_auth',
1627 array(
1628 'method' => $method,
1629 'secret' => $secret,
1630 ),
1631 array( 'user_id' => $user_id )
1632 );
1633
1634 // SQLエラーチェック
1635 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1636 throw new Exception( 'Failed to update 2fa auth method.' );
1637 }
1638 }
1639
1640 /**
1641 * ユーザの2fa認証方法登録
1642 *
1643 * @param int $user_id
1644 * @param int $method
1645 * @param string $secret
1646 *
1647 * @return void
1648 * @throws Exception SQLエラー発生時.
1649 */
1650 private function insert_2fa_auth_method( int $user_id, int $method, string $secret ): void {
1651 global $wpdb;
1652
1653 // 認証�
1654 報を新規登録
1655 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1656 $result = $wpdb->insert(
1657 $wpdb->prefix . 'cloudsecurewp_2fa_auth',
1658 array(
1659 'user_id' => $user_id,
1660 'secret' => $secret,
1661 'recovery' => null,
1662 'method' => $method,
1663 )
1664 );
1665
1666 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1667 throw new Exception( 'Failed to insert 2fa auth method.' );
1668 }
1669 }
1670
1671 /**
1672 * ログイン失敗回数インクリメント処理
1673 *
1674 * @return void
1675 * @throws Exception SQLエラー発生時.
1676 */
1677 private function increment_fail_count(): void {
1678 global $wpdb;
1679
1680 // テーブル名取得
1681 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1682
1683 // 登録データ作成
1684 $ip = $this->get_client_ip();
1685 $now_datetime = current_time( 'mysql' );
1686 $data = array(
1687 'ip' => $ip,
1688 'status' => self::LOGIN_STATUS_FAILED,
1689 'failed_count' => 1,
1690 'login_at' => $now_datetime,
1691 );
1692
1693 $row = $this->get_2fa_login_info( $ip );
1694
1695 if ( empty( $row ) ) {
1696 // レコードが存在していない場合は新規登録
1697 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1698 $wpdb->insert( $table_name, $data );
1699 } else {
1700 // レコードが存在している場合はカウントアップ
1701 $data['failed_count'] = (int) $row['failed_count'] + 1;
1702
1703 // 3回失敗ごとにステータスを無効化に更新
1704 if ( $data['failed_count'] % 3 === 0 ) {
1705 $data['status'] = self::LOGIN_STATUS_DISABLED;
1706 }
1707
1708 // 失敗回数とステータスを更新
1709 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1710 $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
1711 }
1712 }
1713
1714 /**
1715 * ログインログ記録処理(失敗時)
1716 *
1717 * @param int $status ログインステータス
1718 *
1719 * @return void
1720 */
1721 private function write_log( int $status, string $name ): void {
1722 global $wpdb;
1723
1724 // ログインログに記録
1725 $ip = $this->get_client_ip();
1726 $method = $this->login_log->is_xmlrpc() ? self::METHOD_XMLRPC : self::METHOD_PAGE;
1727 $this->login_log->write_log( $name, $ip, $status, $method );
1728 }
1729
1730 /**
1731 * ログイン失敗回数リセット処理
1732 *
1733 * @return void
1734 */
1735 private function reset_fail_count(): void {
1736 global $wpdb;
1737
1738 // テーブル名取得
1739 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1740
1741 // 登録データ作成
1742 $ip = $this->get_client_ip();
1743 $now_datetime = current_time( 'mysql' );
1744 $data = array(
1745 'ip' => $ip,
1746 'status' => self::LOGIN_STATUS_SUCCESS,
1747 'failed_count' => 0,
1748 'login_at' => $now_datetime,
1749 );
1750
1751 $row = $this->get_2fa_login_info( $ip );
1752
1753 if ( empty( $row ) ) {
1754 // レコードが存在していない場合は新規登録
1755 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1756 $wpdb->insert( $table_name, $data );
1757 } else {
1758 if ( $row['status'] === self::LOGIN_STATUS_SUCCESS ) {
1759 // 既に成功状�
1760 �の場合は何もしない
1761 return;
1762 }
1763 // 失敗回数とステータスを更新
1764 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1765 $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
1766
1767 }
1768 }
1769
1770 /**
1771 * レートリミットの無効時間を取得
1772 * 無効ではない場合は0を返す
1773 *
1774 * @return int レートリミットの残り時間(分)
1775 */
1776 private function two_factor_rate_limit(): int {
1777 // 現在のクライアントIPアドレスでレコードを取得
1778 $ip = $this->get_client_ip();
1779 $row = $this->get_2fa_login_info( $ip );
1780
1781 if ( ! empty( $row ) && (int) $row['status'] === self::LOGIN_STATUS_DISABLED ) {
1782 // 無効時間チェック
1783 $limit_time = self::LATE_LIMIT_TIME_LIST[ (int) $row['failed_count'] ] ?? self::LATE_LIMIT_TIME_MAX;
1784 $now_time = strtotime( current_time( 'mysql' ) );
1785 $block_time = strtotime( $row['login_at'] ) + $limit_time;
1786
1787 if ( $now_time < $block_time ) {
1788 return (int) ceil( ( $block_time - $now_time ) / 60 );
1789 }
1790 }
1791
1792 return 0;
1793 }
1794
1795 /**
1796 * メール最終送信時刻更新処理
1797 *
1798 * @param int $user_id
1799 *
1800 * @return void
1801 */
1802 private function update_email_able_send_time( int $user_id ): void {
1803 // 送信可能時刻を更新
1804 $able_send_time = time() + self::EMAIL_SEND_LIMIT_TIME;
1805 update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, $able_send_time );
1806 }
1807
1808 /**
1809 * メール最終送信時刻リセット処理
1810 *
1811 * @param int $user_id
1812 *
1813 * @return void
1814 */
1815 private function delete_email_able_send_time( int $user_id ): void {
1816 // 送信可能時刻をリセット
1817 update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, 0 );
1818 }
1819
1820 /**
1821 * メール最終送信時刻取得処理
1822 *
1823 * @param int $user_id
1824 *
1825 * @return int 最終送信時刻(unixタイムスタンプ)
1826 */
1827 private function get_email_able_send_time( int $user_id ): int {
1828 // 最終送信時刻を取得
1829 $last_send = get_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, true );
1830
1831 if ( ! $last_send ) {
1832 return 0;
1833 }
1834
1835 return (int) $last_send;
1836 }
1837
1838 /**
1839 * wp_usermetaから2fa関連データを取得
1840 *
1841 * @param int $last_umeta_id
1842 * @param int $batch_size
1843 *
1844 * @return array
1845 */
1846 private function fetch_user_metas( int $last_umeta_id, int $batch_size ): array {
1847 global $wpdb;
1848
1849 $secret_key = $wpdb->get_blog_prefix() . 'cloudsecurewp_two_factor_authentication_secret';
1850
1851 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1852 $user_metas = $wpdb->get_results(
1853 $wpdb->prepare(
1854 "SELECT umeta_id, user_id, meta_value
1855 FROM {$wpdb->usermeta}
1856 WHERE meta_key = %s
1857 AND umeta_id > %d
1858 ORDER BY umeta_id ASC
1859 LIMIT %d",
1860 $secret_key,
1861 $last_umeta_id,
1862 $batch_size
1863 ),
1864 ARRAY_A
1865 );
1866
1867 return $user_metas ?? array();
1868 }
1869
1870 /**
1871 * authテーブルに既に存在するuser_idを事前確認し、usermetaを「既存」「新規」に分類して返す
1872 *
1873 * @param array $user_metas
1874 *
1875 * @return array ['existing_umeta_ids' => array, 'new_user_metas' => array]
1876 */
1877 private function split_user_metas_by_auth_existence( array $user_metas ): array {
1878 global $wpdb;
1879
1880 $all_user_ids = array_column( $user_metas, 'user_id' );
1881 $placeholders_pre = implode( ', ', array_fill( 0, count( $all_user_ids ), '%d' ) );
1882
1883 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1884 $existing_user_ids = $wpdb->get_col(
1885 $wpdb->prepare(
1886 "SELECT user_id FROM {$wpdb->prefix}" . self::AUTH_TABLE_NAME . " WHERE user_id IN ($placeholders_pre)",
1887 $all_user_ids
1888 )
1889 );
1890 $existing_user_ids = array_map( 'intval', $existing_user_ids );
1891
1892 $existing_umeta_ids = array();
1893 $new_user_metas = array();
1894
1895 foreach ( $user_metas as $user_meta ) {
1896 if ( in_array( (int) $user_meta['user_id'], $existing_user_ids, true ) ) {
1897 $existing_umeta_ids[] = (int) $user_meta['umeta_id'];
1898 } else {
1899 $new_user_metas[] = $user_meta;
1900 }
1901 }
1902
1903 return array(
1904 'existing_umeta_ids' => $existing_umeta_ids,
1905 'new_user_metas' => $new_user_metas,
1906 );
1907 }
1908
1909 /**
1910 * usermeta データをバルクインサート用データに変換
1911 *
1912 * @param array $user_metas
1913 *
1914 * @return array ['insert_data' => array, 'umeta_ids_map' => array]
1915 */
1916 private function convert_user_metas_to_insert_data( array $user_metas ): array {
1917 $insert_data = array();
1918 $umeta_ids_map = array();
1919
1920 foreach ( $user_metas as $user_meta ) {
1921 $user_id = (int) $user_meta['user_id'];
1922 $umeta_id = (int) $user_meta['umeta_id'];
1923 $meta_value = $user_meta['meta_value'];
1924
1925 // Base32デコードしてバイナリに変換
1926 $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $meta_value );
1927
1928 // デコード失敗または空の場合はスキップ
1929 if ( ! $binary_secret ) {
1930 continue;
1931 }
1932
1933 // バイナリデータを16進数に変換
1934 $hex_secret = bin2hex( $binary_secret );
1935
1936 // データを蓄積
1937 $insert_data[] = array(
1938 'user_id' => $user_id,
1939 'secret' => $hex_secret,
1940 'recovery' => null,
1941 'method' => self::USER_AUTH_METHOD_APP,
1942 );
1943
1944 // umeta_idとuser_idのマッピングを保存
1945 $umeta_ids_map[ $user_id ] = $umeta_id;
1946 }
1947
1948 return array(
1949 'insert_data' => $insert_data,
1950 'umeta_ids_map' => $umeta_ids_map,
1951 );
1952 }
1953
1954 /**
1955 * 2fa認証データのバルクインサート
1956 * (呼び出し�
1957 �でトランザクションを管理すること)
1958 *
1959 * @param array $data_list
1960 *
1961 * @return array 失敗したuser_idのリスト
1962 * @throws Exception SQLエラー発生時.
1963 */
1964 private function bulk_insert_2fa_auth( array $data_list ): array {
1965 if ( empty( $data_list ) ) {
1966 return array();
1967 }
1968
1969 global $wpdb;
1970
1971 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1972
1973 // 挿�
1974 �を試みるuser_idのリスト
1975 $target_user_ids = array_column( $data_list, 'user_id' );
1976
1977 // プレースホルダーと値の準備
1978 $values = array();
1979 $placeholders = array();
1980
1981 foreach ( $data_list as $data ) {
1982 $values[] = $data['user_id'];
1983 $values[] = $data['secret'];
1984 $values[] = $data['method'];
1985 $placeholders[] = '(%d, %s, null, %d)';
1986 }
1987
1988 // SQL実行(INSERT IGNOREで重複などのエラーを無視)
1989 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1990 $result = $wpdb->query(
1991 $wpdb->prepare(
1992 "INSERT IGNORE INTO {$table_name}
1993 (`user_id`, `secret`, `recovery`, `method`)
1994 VALUES " . implode( ', ', $placeholders ),
1995 $values
1996 )
1997 );
1998
1999 // SQLエラーチェック(INSERT IGNOREは重複エラーを返さないが、その他のエラーはチェック)
2000 if ( $result === false || ! empty( $wpdb->last_error ) ) {
2001 throw new Exception( 'Failed to bulk insert 2fa auth data.' );
2002 }
2003
2004 // 失敗したuser_id(挿�
2005 �されなかったID)を特定
2006 // INSERT IGNOREは重複時に0行を挿�
2007 �するため、
2008 // 挿�
2009 �されなかった数 = 試行数 - 実際に挿�
2010 �された行数
2011 $failed_count = count( $target_user_ids ) - $result;
2012
2013 // 失敗したIDを特定したい場合は、挿�
2014 �後に存在確認が�
2015
2016 if ( $failed_count > 0 ) {
2017 // 挿�
2018 �後に実際に存在するuser_idを確認
2019 $placeholders_check = implode( ', ', array_fill( 0, count( $target_user_ids ), '%d' ) );
2020 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2021 $inserted_user_ids = $wpdb->get_col(
2022 $wpdb->prepare(
2023 "SELECT user_id FROM {$table_name} WHERE user_id IN ($placeholders_check)",
2024 $target_user_ids
2025 )
2026 );
2027
2028 // 失敗したuser_idを特定
2029 $failed_user_ids = array_diff( $target_user_ids, $inserted_user_ids );
2030 return array_values( $failed_user_ids );
2031 }
2032
2033 return array();
2034 }
2035
2036 /**
2037 * wp_usermetaから指定されたレコードを削除
2038 * (呼び出し�
2039 �でトランザクションを管理すること)
2040 *
2041 * @param array $umeta_ids
2042 *
2043 * @return void
2044 * @throws Exception SQLエラー発生時.
2045 */
2046 private function delete_user_metas( array $umeta_ids ): void {
2047 if ( empty( $umeta_ids ) ) {
2048 return;
2049 }
2050
2051 global $wpdb;
2052
2053 $delete_placeholders = implode( ', ', array_fill( 0, count( $umeta_ids ), '%d' ) );
2054 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
2055 $result = $wpdb->query(
2056 $wpdb->prepare(
2057 "DELETE FROM {$wpdb->usermeta} WHERE umeta_id IN ($delete_placeholders)",
2058 $umeta_ids
2059 )
2060 );
2061
2062 // 削除エラーチェック
2063 if ( $result === false || ! empty( $wpdb->last_error ) ) {
2064 throw new Exception( 'Failed to delete usermeta.' );
2065 }
2066 }
2067
2068 /**
2069 * 2fa既存ユーザーデータの移行処理
2070 *
2071 * @return void
2072 */
2073 public function migrate_2fa_user_data(): void {
2074 global $wpdb;
2075
2076 $batch_size = self::CLEANUP_BATCH_SIZE;
2077 $last_umeta_id = 0;
2078
2079 while ( true ) {
2080 try {
2081 // wp_usermetaからシークレットキーをバッチサイズごとに取得
2082 $user_metas = $this->fetch_user_metas( $last_umeta_id, $batch_size );
2083
2084 // 取得するレコードがなくなったら終了
2085 if ( empty( $user_metas ) ) {
2086 break;
2087 }
2088
2089 // 最後に取得したumeta_idを更新
2090 $last_umeta_id = end( $user_metas )['umeta_id'];
2091
2092 // authテーブルに既存のuser_idを確認し、既存/新規に分類
2093 $split_result = $this->split_user_metas_by_auth_existence( $user_metas );
2094 $existing_umeta_ids = $split_result['existing_umeta_ids'];
2095 $new_user_metas = $split_result['new_user_metas'];
2096
2097 // 新規ユーザーのみバルクインサート用データに変換
2098 $conversion_result = $this->convert_user_metas_to_insert_data( $new_user_metas );
2099 $insert_data = $conversion_result['insert_data'];
2100 $umeta_ids_map = $conversion_result['umeta_ids_map'];
2101
2102 // トランザクション開始
2103 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2104 $wpdb->query( 'START TRANSACTION' );
2105
2106 // 新規ユーザーのみINSERT
2107 $failed_user_ids = array();
2108 if ( ! empty( $insert_data ) ) {
2109 $failed_user_ids = $this->bulk_insert_2fa_auth( $insert_data );
2110 }
2111
2112 // INSERT成功したuser_idのumeta_idを取得
2113 $target_user_ids = array_column( $insert_data, 'user_id' );
2114 $successful_user_ids = array_diff( $target_user_ids, $failed_user_ids );
2115 $successful_umeta_ids = array();
2116 foreach ( $successful_user_ids as $user_id ) {
2117 if ( isset( $umeta_ids_map[ $user_id ] ) ) {
2118 $successful_umeta_ids[] = $umeta_ids_map[ $user_id ];
2119 }
2120 }
2121
2122 // 既存ユーザー + INSERT成功ユーザーのusermetaを削除
2123 $delete_umeta_ids = array_merge( $existing_umeta_ids, $successful_umeta_ids );
2124 $this->delete_user_metas( $delete_umeta_ids );
2125
2126 // トランザクションコミット
2127 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2128 $wpdb->query( 'COMMIT' );
2129
2130 } catch ( Exception $e ) {
2131 // エラー発生時はロールバック
2132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2133 $wpdb->query( 'ROLLBACK' );
2134 break;
2135 }
2136 }
2137 }
2138
2139 /**
2140 * リカバリーコードをserialize形式からJSON形式に一括変換
2141 *
2142 * @return void
2143 */
2144 public function migrate_recovery_codes_to_json(): void {
2145 global $wpdb;
2146
2147 $last_user_id = 0;
2148
2149 while ( true ) {
2150 try {
2151 // serialize形式のリカバリーコードをバッチ取得
2152 $rows = $this->fetch_serialized_recovery_codes( $last_user_id, self::CLEANUP_BATCH_SIZE );
2153
2154 // 取得するレコードがなくなったら終了
2155 if ( empty( $rows ) ) {
2156 break;
2157 }
2158
2159 // 最後に取得したuser_idを更新
2160 $last_user_id = end( $rows )->user_id;
2161
2162 // トランザクション開始
2163 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2164 $wpdb->query( 'START TRANSACTION' );
2165
2166 // バッチ単位で変換
2167 $this->convert_recovery_codes_batch( $rows );
2168
2169 // トランザクションコミット
2170 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2171 $wpdb->query( 'COMMIT' );
2172
2173 } catch ( Exception $e ) {
2174 // エラー発生時はロールバック
2175 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2176 $wpdb->query( 'ROLLBACK' );
2177 break;
2178 }
2179 }
2180 }
2181
2182 /**
2183 * serialize形式のリカバリーコードを持つレコードをバッチ取得
2184 *
2185 * @param int $last_user_id
2186 * @param int $limit
2187 *
2188 * @return array
2189 */
2190 private function fetch_serialized_recovery_codes( int $last_user_id, int $limit ): array {
2191 global $wpdb;
2192
2193 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
2194
2195 // JSON�
2196 �列は '[' で始まるため、それ以外(serialize形式は 'a:' で始まる)を抽出
2197 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2198 $rows = $wpdb->get_results(
2199 $wpdb->prepare(
2200 "SELECT user_id, recovery FROM {$table_name} WHERE recovery IS NOT NULL AND recovery NOT LIKE %s AND user_id > %d ORDER BY user_id ASC LIMIT %d",
2201 $wpdb->esc_like( '[' ) . '%',
2202 $last_user_id,
2203 $limit
2204 )
2205 );
2206
2207 return $rows ?? array();
2208 }
2209
2210 /**
2211 * リカバリーコードをserialize形式からJSON形式に変換して更新
2212 *
2213 * @param array $rows
2214 *
2215 * @return void
2216 * @throws Exception 変換エラー発生時.
2217 */
2218 private function convert_recovery_codes_batch( array $rows ): void {
2219 global $wpdb;
2220
2221 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
2222
2223 $case_parts = array();
2224 $values = array();
2225 $user_ids = array();
2226
2227 foreach ( $rows as $row ) {
2228 if ( ! is_serialized( $row->recovery ) ) {
2229 continue;
2230 }
2231 $codes = unserialize( $row->recovery, array( 'allowed_classes' => false ) );
2232
2233 if ( ! is_array( $codes ) ) {
2234 continue;
2235 }
2236
2237 $json_codes = wp_json_encode( array_values( $codes ) );
2238 if ( $json_codes === false ) {
2239 continue;
2240 }
2241 $case_parts[] = 'WHEN %d THEN %s';
2242 $values[] = $row->user_id;
2243 $values[] = $json_codes;
2244 $user_ids[] = $row->user_id;
2245 }
2246
2247 if ( empty( $user_ids ) ) {
2248 return;
2249 }
2250
2251 $case_sql = implode( ' ', $case_parts );
2252 $id_placeholders = implode( ', ', array_fill( 0, count( $user_ids ), '%d' ) );
2253 $values = array_merge( $values, $user_ids );
2254
2255 // SQL実行(バルクアップデートを行うため、意図的に直接クエリを実行する)
2256 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2257 $result = $wpdb->query(
2258 $wpdb->prepare(
2259 "UPDATE {$table_name} SET recovery = CASE user_id {$case_sql} END WHERE user_id IN ({$id_placeholders})",
2260 $values
2261 )
2262 );
2263
2264 if ( $result === false || ! empty( $wpdb->last_error ) ) {
2265 throw new Exception( 'Failed to bulk update recovery codes: ' . $wpdb->last_error );
2266 }
2267 }
2268
2269 /**
2270 * 2faログインテーブル作成
2271 *
2272 * @return void
2273 */
2274 private function create_login_table(): void {
2275 global $wpdb;
2276 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
2277 $table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
2278
2279 if ( is_null( $table ) ) {
2280 $charset_collate = $wpdb->get_charset_collate();
2281
2282 $sql = "CREATE TABLE {$table_name} (
2283 ip varchar( 39 ) NOT NULL DEFAULT '',
2284 status int NOT NULL DEFAULT 0,
2285 failed_count int NOT NULL DEFAULT 0,
2286 login_at datetime NOT NULL,
2287 PRIMARY KEY (ip)
2288 ) {$charset_collate}";
2289
2290 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
2291 dbDelta( $sql );
2292 }
2293 }
2294
2295 /**
2296 * 2fa認証テーブル作成
2297 *
2298 * @return void
2299 */
2300 private function create_auth_table(): void {
2301 global $wpdb;
2302 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
2303 $table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
2304
2305 if ( is_null( $table ) ) {
2306 $charset_collate = $wpdb->get_charset_collate();
2307
2308 $sql = "CREATE TABLE {$table_name} (
2309 user_id bigint(20) UNSIGNED NOT NULL,
2310 secret varchar(255) NOT NULL,
2311 recovery longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
2312 method int(11) UNSIGNED NOT NULL DEFAULT 1,
2313 PRIMARY KEY (user_id)
2314 ) {$charset_collate}";
2315
2316 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
2317 dbDelta( $sql );
2318 }
2319 }
2320
2321 /**
2322 * 2段階認証のテーブル作成とデータ移行を一括実行
2323 *
2324 * @return void
2325 */
2326 public function setup_2fa_tables(): void {
2327 $this->create_login_table();
2328 $this->create_auth_table();
2329 $this->migrate_2fa_user_data();
2330 }
2331
2332 /**
2333 * データ移行漏れ修復処理
2334 *
2335 * @param int $user_id
2336 *
2337 * @return array
2338 * @throws Exception SQLエラー発生時.
2339 */
2340 public function repair_migration_gaps( int $user_id ): array {
2341 global $wpdb;
2342
2343 // 2fa認証テーブルが存在しない場合は作成
2344 $this->create_auth_table();
2345 // 2faログインテーブルが存在しない場合は作成
2346 $this->create_login_table();
2347
2348 // 2fa認証�
2349 報を取得
2350 $auth_info = $this->get_2fa_auth_info( $user_id );
2351 if ( count( $auth_info ) !== 0 ) {
2352 // 既に認証�
2353 報が存在する場合は何もしない
2354 return $auth_info;
2355 }
2356
2357 // wp_usermetaからシークレットキーを取得
2358 $secret_key = $wpdb->get_blog_prefix() . 'cloudsecurewp_two_factor_authentication_secret';
2359 $secret = get_user_meta( $user_id, $secret_key, true );
2360 if ( ! $secret ) {
2361 // シークレットキーが存在しない場合は何もしない
2362 return [];
2363 }
2364
2365 // Base32デコードしてバイナリに変換(トランザクション前に検証)
2366 $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $secret );
2367 if ( ! $binary_secret ) {
2368 // デコード失敗の場合は処理しない
2369 return [];
2370 }
2371
2372 // バイナリデータを16進数に変換
2373 $hex_secret = bin2hex( $binary_secret );
2374
2375 try {
2376 // トランザクション開始
2377 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2378 $wpdb->query( 'START TRANSACTION' );
2379
2380 // 2fa認証�
2381 報を登録
2382 $this->insert_2fa_auth_method( $user_id, self::USER_AUTH_METHOD_APP, $hex_secret );
2383
2384 // シークレットキーをwp_usermetaから削除
2385 $result = delete_user_meta( $user_id, $secret_key );
2386 if ( ! $result || ! empty( $wpdb->last_error ) ) {
2387 throw new Exception( 'Failed to delete secret key from user meta.' );
2388 }
2389
2390 // トランザクションコミット
2391 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2392 $wpdb->query( 'COMMIT' );
2393
2394 $auth_info = $this->get_2fa_auth_info( $user_id );
2395 return $auth_info;
2396 } catch ( Exception $e ) {
2397 // エラー発生時はロールバック
2398 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2399 $wpdb->query( 'ROLLBACK' );
2400 return [];
2401 }
2402 }
2403
2404 /**
2405 * AJAX: 秘密鍵を生成(アプリ認証時に使用)
2406 *
2407 * @return void
2408 */
2409 public function ajax_generate_key(): void {
2410 // nonceチェック
2411 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2412
2413 if ( ! current_user_can( 'read' ) ) {
2414 wp_send_json_error();
2415 }
2416
2417 try {
2418 // 秘密鍵を生成
2419 $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
2420
2421 wp_send_json_success( $secret_key_data );
2422 return;
2423
2424 } catch ( Exception $e ) {
2425 $secret_key_data = [];
2426 wp_send_json_error( $secret_key_data );
2427 return;
2428 }
2429 }
2430
2431 /**
2432 * AJAX: 秘密鍵を生成してメール送信(メール認証時に使用)
2433 *
2434 * @return void
2435 */
2436 public function ajax_generate_key_and_send_email(): void {
2437 // nonceチェック
2438 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2439
2440 if ( ! current_user_can( 'read' ) ) {
2441 wp_send_json_error();
2442 }
2443
2444 // 返却JSONレスポンス初期化
2445 $json_response = [
2446 'is_send_email' => false,
2447 'remaining_seconds' => 0,
2448 ];
2449
2450 $user_id = get_current_user_id();
2451
2452 // 最終送信時刻から30秒以�
2453 ならそのまま返す
2454 $able_send_time = $this->get_email_able_send_time( $user_id );
2455 $now_time = time();
2456 $remaining_seconds = $able_send_time - $now_time;
2457 if ( $remaining_seconds > 0 ) {
2458 $json_response['remaining_seconds'] = $remaining_seconds;
2459 wp_send_json_success( $json_response );
2460 return;
2461 }
2462
2463 try {
2464 // 秘密鍵を生成
2465 $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
2466 // 認証コードを生成
2467 $code = CloudSecureWP_Time_Based_One_Time_Password::create_code_for_email( $secret_key_data['hex'], self::AUTH_EMAIL_INTERVAL );
2468 // メール送信
2469 $result = $this->send_code( $user_id, $code, self::AUTH_EMAIL_INTERVAL, 'setting' );
2470 if ( ! $result ) {
2471 wp_send_json_error( $json_response );
2472 return;
2473 }
2474 // 最終送信時刻を更新
2475 $this->update_email_able_send_time( $user_id );
2476
2477 // 秘密鍵を保存
2478 $transient_key = self::SETUP_SECRET_PREFIX . $user_id;
2479 set_transient( $transient_key, $secret_key_data['hex'], self::SETUP_SECRET_TIMEOUT );
2480
2481 $json_response['is_send_email'] = true;
2482 $json_response['remaining_seconds'] = self::EMAIL_SEND_LIMIT_TIME;
2483 wp_send_json_success( $json_response );
2484 return;
2485
2486 } catch ( Exception $e ) {
2487 wp_send_json_error( $json_response );
2488 return;
2489 }
2490 }
2491
2492 /**
2493 * AJAX: 認証コードの検証と保存
2494 *
2495 * @return void
2496 */
2497 public function ajax_verify_auth_code(): void {
2498 // nonceチェック
2499 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2500
2501 if ( ! current_user_can( 'read' ) ) {
2502 wp_send_json_error();
2503 }
2504
2505 // 返却JSONレスポンス初期化
2506 $json_response = [
2507 'has_recovery' => false,
2508 ];
2509
2510 $user_id = get_current_user_id();
2511
2512 $method = sanitize_text_field( $_POST['method'] );
2513 $interval = ( $method === 'app' ) ? self::AUTH_APP_INTERVAL : self::AUTH_EMAIL_INTERVAL;
2514 $auth_method = ( $method === 'app' ) ? self::USER_AUTH_METHOD_APP : self::USER_AUTH_METHOD_EMAIL;
2515 $code = isset( $_POST['code'] ) ? sanitize_text_field( $_POST['code'] ) : '';
2516
2517 if ( $method === 'app' ) {
2518 $secret_key = isset( $_POST['secret_key'] ) ? sanitize_text_field( $_POST['secret_key'] ) : '';
2519 } else {
2520 $transient_key = self::SETUP_SECRET_PREFIX . $user_id;
2521 $secret_key = get_transient( $transient_key );
2522 if ( ! $secret_key ) {
2523 wp_send_json_error( $json_response );
2524 return;
2525 }
2526 }
2527
2528 if ( CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, $interval ) === false ) {
2529 wp_send_json_error( $json_response );
2530 return;
2531 }
2532
2533 // 認証成功 - データベースに保存
2534 try {
2535 // 2fa認証�
2536 報を設定
2537 $this->setting_2fa_auth_info( $user_id, $auth_method, $secret_key );
2538
2539 // メール認証の場合
2540 if ( $method === 'email' ) {
2541 // 一時保存していた秘密鍵を削除
2542 delete_transient( $transient_key );
2543 // メール認証の送信可能時間をリセット
2544 $this->delete_email_able_send_time( $user_id );
2545 }
2546
2547 // リカバリーコードの設定状況を取得
2548 $has_recovery = $this->has_recovery_codes( $user_id );
2549 $json_response['has_recovery'] = $has_recovery;
2550 wp_send_json_success( $json_response );
2551 return;
2552 } catch ( Exception $e ) {
2553 wp_send_json_error( $json_response );
2554 return;
2555 }
2556 }
2557
2558 /**
2559 * AJAX: リカバリーコード生成
2560 *
2561 * @return void
2562 */
2563 public function ajax_generate_recovery_codes(): void {
2564 // nonceチェック
2565 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2566
2567 if ( ! current_user_can( 'read' ) ) {
2568 wp_send_json_error();
2569 }
2570
2571 // 返却JSONレスポンス初期化
2572 $json_response = [
2573 'codes' => [],
2574 ];
2575
2576 // 2段階認証が設定されているかチェック
2577 try {
2578 $user_id = get_current_user_id();
2579 $auth_info = $this->get_2fa_auth_info( $user_id );
2580 if ( count( $auth_info ) === 0 ) {
2581 wp_send_json_error( $json_response );
2582 return;
2583 }
2584
2585 // リカバリーコードを生成
2586 $codes = CloudSecureWP_Recovery_Codes::initialize_codes( $user_id );
2587 if ( count( $codes ) === 0 ) {
2588 wp_send_json_error( $json_response );
2589 return;
2590 }
2591
2592 // 平文コードを返却(これは1度だけ表示される)
2593 $json_response['codes'] = $codes;
2594 wp_send_json_success( $json_response );
2595 return;
2596
2597 } catch ( Exception $e ) {
2598 wp_send_json_error( $json_response );
2599 return;
2600 }
2601 }
2602
2603 /**
2604 * ユーザーに認証コードをメール送信
2605 *
2606 * @param int $user_id
2607 * @param string $code
2608 * @param int $time_step 時間間隔(秒)
2609 * @param string $status 'login' または 'setting'
2610 *
2611 * @return bool true: メール送信成功、false: メール送信失敗
2612 */
2613 private function send_code( int $user_id, string $code, int $time_step, string $status ): bool {
2614 $user = get_userdata( $user_id );
2615 if ( ! $user ) {
2616 return false;
2617 }
2618 $expire = $time_step / 60;
2619 $to = $user->user_email;
2620 $subject = '2段階認証コード';
2621
2622 if ( $status === 'login' ) {
2623 $body = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . " にログインしようとしています。\n";
2624 $body .= "ログインを完了するには、以下の2段階認証コードを�
2625 �力してください。\n\n";
2626 } else {
2627 $body = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . "で2段階認証を設定しています。\n";
2628 $body .= "セットアップを完了するには、以下の2段階認証コードを�
2629 �力してください。\n\n";
2630 }
2631
2632 $body .= "2段階認証コード: {$code}\n\n";
2633 $body .= "このコードは{$expire}分間有効です。\n";
2634 $body .= "もしこのメールに心当たりがない場合は、第三�
2635 があなたのパスワードを使用してログインを試みた可能性があります。\n";
2636 $body .= "速やかにパスワードを変更することをお勧めします。\n\n";
2637 $body .= "--\n";
2638 $body .= "CloudSecure WP Security\n";
2639
2640 return $this->wp_send_mail( $to, esc_html( $subject ), esc_html( $body ) );
2641 }
2642
2643 /**
2644 * 1.4.8 アップデート時にXML-RPCログイン制御のデフォルト値を設定
2645 * 既存環境で新キーが未設定の場合に拒否(1)を補完する
2646 *
2647 * @return void
2648 */
2649 public function migrate_xmlrpc_login_default(): void {
2650 $current = $this->config->get( self::KEY_XMLRPC_LOGIN );
2651 if ( null === $current || '' === $current ) {
2652 $this->config->set( self::KEY_XMLRPC_LOGIN, '1' );
2653 $this->config->save();
2654 }
2655 }
2656
2657 /**
2658 * 1.4.8 アップデート時に対象ユーザーへの通知処理
2659 * XML-RPCログイン制御による影響がある可能性のある環境の�
2660 �管理�
2661 が対応
2662 *
2663 * @return void
2664 */
2665 public function send_update_notice(): void {
2666 if ( $this->is_enabled() && ! ( $this->disable_xmlrpc->is_enabled() && $this->disable_xmlrpc->is_xmlrpc_disabled() ) ) {
2667 update_option( 'cloudsecurewp_notice_148_xmlrpc', '1' );
2668 add_action(
2669 'plugins_loaded',
2670 function() {
2671 $this->send_148_update_mail();
2672 },
2673 10
2674 );
2675 }
2676 }
2677
2678 /**
2679 * 1.4.8 アップデート通知メールを�
2680 �管理�
2681 に送信
2682 */
2683 private function send_148_update_mail(): void {
2684 $admins = get_users( array( 'role' => 'administrator' ) );
2685
2686 $subject = '2段階認証のXML-RPCログイン制限に関するお知らせ';
2687
2688 $settings_url = admin_url( 'admin.php?page=cloudsecurewp_two_factor_authentication' );
2689
2690 $body = "本プラグインのアップデートにより、2段階認証の設定に「XML-RPC経由のログインを遮断する」オプションが追加されました。\n";
2691 $body .= "\n";
2692 $body .= "このオプションはデフォルトで有効になっています。\n";
2693 $body .= "外部アプリやスマートフォンアプリ等からXML-RPCを使用してログインしている場合、\n";
2694 $body .= "2段階認証の対象権限グループに属するユーザーのログインが遮断されます。\n";
2695 $body .= "\n";
2696 $body .= "XML-RPC経由のログインを引き続き許可する場合は、下記の設定画面から変更してください。\n";
2697 $body .= $settings_url . "\n";
2698 $body .= "\n";
2699 $body .= "--\n";
2700 $body .= "CloudSecure WP Security\n";
2701
2702 foreach ( $admins as $admin ) {
2703 $this->wp_send_mail( $admin->user_email, $subject, $body );
2704 }
2705 }
2706
2707 /**
2708 * 1.4.8 XML-RPCログイン制限追加の通知表示
2709 */
2710 public function admin_notice_148_xmlrpc(): void {
2711 if ( ! current_user_can( 'administrator' ) ) {
2712 return;
2713 }
2714
2715 if ( get_option( 'cloudsecurewp_notice_148_xmlrpc' ) !== '1' ) {
2716 return;
2717 }
2718
2719 $settings_url = admin_url( 'admin.php?page=cloudsecurewp_two_factor_authentication' );
2720 $nonce = wp_create_nonce( 'cloudsecurewp_dismiss_notice_148' );
2721 ?>
2722 <div class="notice notice-warning" id="cloudsecurewp-notice-148">
2723 <p><strong>【CloudSecure WP Security】</strong></p>
2724 <p>アップデートにより、2段階認証機能の設定に「XML-RPC経由のログインを遮断する」オプションが追加されました。<br/>
2725 このオプションは初期状�
2726 �で有効になっており、2段階認証の対象権限グループに属するユーザーのXML-RPC経由のログインが遮断されます。<br/>
2727 XML-RPCを利用してスマホアプリ等の外部ツールにログインしている方は、<a href="<?php echo esc_url( $settings_url ); ?>">設定画面</a>からご確認ください。</p>
2728 <p><button type="button" class="button" id="cloudsecurewp-notice-148-btn">確認しました</button></p>
2729 </div>
2730 <script>
2731 document.getElementById('cloudsecurewp-notice-148-btn').addEventListener('click', function() {
2732 var xhr = new XMLHttpRequest();
2733 xhr.open('POST', '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>');
2734 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
2735 xhr.onload = function() {
2736 if (xhr.status === 200) {
2737 var el = document.getElementById('cloudsecurewp-notice-148');
2738 if (el) el.remove();
2739 }
2740 };
2741 xhr.send('action=cloudsecurewp_dismiss_notice_148&_wpnonce=<?php echo esc_js( $nonce ); ?>');
2742 });
2743 </script>
2744 <?php
2745 }
2746
2747 /**
2748 * 1.4.8 通知の確認ボタン押下時のAJAXハンドラー
2749 */
2750 public function ajax_dismiss_notice_148(): void {
2751 check_ajax_referer( 'cloudsecurewp_dismiss_notice_148' );
2752
2753 if ( ! current_user_can( 'administrator' ) ) {
2754 wp_send_json_error();
2755 }
2756
2757 delete_option( 'cloudsecurewp_notice_148_xmlrpc' );
2758 wp_send_json_success();
2759 }
2760
2761 /**
2762 * XML-RPC経由のログインが拒否設定かどうか
2763 *
2764 * @return bool
2765 */
2766 public function is_xmlrpc_login_denied(): bool {
2767 return $this->config->get( self::KEY_XMLRPC_LOGIN ) === '1';
2768 }
2769
2770 /**
2771 * XML-RPC経由のログインを拒否すべきかどうかを判定
2772 * 2FA有効 かつ XML-RPCログイン拒否設定 かつ ユーザーが2FA対象ロールの場合にtrueを返す
2773 *
2774 * @param WP_User $user
2775 *
2776 * @return bool
2777 */
2778 public function should_deny_xmlrpc_login( $user ): bool {
2779 if ( ! $this->is_enabled() ) {
2780 return false;
2781 }
2782
2783 if ( ! $this->is_xmlrpc_login_denied() ) {
2784 return false;
2785 }
2786
2787 if ( ! ( $user instanceof WP_User ) || ! isset( $user->roles[0] ) ) {
2788 return false;
2789 }
2790
2791 return $this->is_role_enabled( $user->roles[0] );
2792 }
2793
2794 /**
2795 * XML-RPC認証時にログインを拒否するフィルタコールバック
2796 * authenticateフィルタから呼ばれ、対象ユーザーの場合WP_Errorを返す
2797 *
2798 * @param WP_User|WP_Error|null $user
2799 * @param string $username
2800 * @param string $password
2801 *
2802 * @return WP_User|WP_Error|null
2803 */
2804 public function deny_xmlrpc_authentication( $user, $username, $password ) {
2805 if ( $user instanceof WP_User ) {
2806 $user_obj = $user;
2807 } else {
2808 $user_obj = get_user_by( 'login', $username );
2809 if ( ! $user_obj ) {
2810 $user_obj = get_user_by( 'email', $username );
2811 }
2812 }
2813
2814 if ( $user_obj && $this->should_deny_xmlrpc_login( $user_obj ) ) {
2815 return new WP_Error( 'xmlrpc_login_denied', 'XML-RPC経由のログインは許可されていません。' );
2816 }
2817 return $user;
2818 }
2819
2820 /**
2821 * 旧トランジェントキー(2fa_setup_secret_)を新キー(cloudsecurewp_2fa_setup_secret_)に移行
2822 *
2823 * @return void
2824 */
2825 public function migrate_2fa_setup_secret_transient_keys(): void {
2826 global $wpdb;
2827
2828 // 期限切れの旧トランジェント(値+タイムアウト)を一括削除
2829 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
2830 $wpdb->query(
2831 $wpdb->prepare(
2832 "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
2833 WHERE a.option_name LIKE %s
2834 AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) )
2835 AND b.option_value < %d",
2836 $wpdb->esc_like( '_transient_2fa_setup_secret_' ) . '%',
2837 time()
2838 )
2839 );
2840
2841 // 残った有効期限�
2842 レコードをタイムアウトレコード起点で取得
2843 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
2844 $active_records = $wpdb->get_results(
2845 $wpdb->prepare(
2846 "SELECT option_name, option_value AS timeout_value
2847 FROM {$wpdb->options}
2848 WHERE option_name LIKE %s
2849 AND option_value <= %d",
2850 $wpdb->esc_like( '_transient_timeout_2fa_setup_secret_' ) . '%',
2851 time() + self::SETUP_SECRET_TIMEOUT
2852 )
2853 );
2854
2855 if ( empty( $active_records ) ) {
2856 return;
2857 }
2858
2859 // 有効期限�
2860 のレコードのキーを移行
2861 foreach ( $active_records as $record ) {
2862 $old_key = substr( $record->option_name, strlen( '_transient_timeout_' ) );
2863
2864 $data = get_transient( $old_key );
2865 if ( false === $data ) {
2866 continue;
2867 }
2868
2869 // 残り有効期限(秒)を(最低1秒を保証して無期限トランジェントの作成を防ぐ)
2870 $expiration = max( 1, (int) $record->timeout_value - time() );
2871
2872 $user_id = substr( $old_key, strlen( '2fa_setup_secret_' ) );
2873
2874 // 新キーで保存し旧キーを削除(WP関数が値・タイムアウトのペアを保証)
2875 set_transient( self::SETUP_SECRET_PREFIX . $user_id, $data, $expiration );
2876
2877 delete_transient( $old_key );
2878 }
2879 }
2880 }
2881