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