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