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