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