PluginProbe ʕ •ᴥ•ʔ
CloudSecure WP Security / 1.4.6
CloudSecure WP Security v1.4.6
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 3 months ago cli 8 months ago lib 3 months ago captcha.php 3 months ago cloudsecure-wp.php 3 months ago common.php 3 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 3 months ago disable-restapi.php 7 months ago disable-xmlrpc.php 1 year ago htaccess.php 3 months ago login-log.php 3 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 3 months ago two-factor-authentication.php 3 months ago unify-messages.php 2 years ago update-notice.php 7 months ago waf-engine.php 3 months ago waf.php 3 months ago
two-factor-authentication.php
2302 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( wp_validate_redirect( wp_unslash( $_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 if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), $this->get_feature_key() . '_csrf' ) === false ) {
729 return new WP_Error( 'empty_username', 'セッションの有効期限が切れました。再度ログインしてください。' );
730 }
731
732 // ログイントークンを取得
733 $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
734
735 // ログイン�
736 報を取得
737 $option_key = $this->create_option_key( $login_token );
738 $option_data = $this->get_option_data( $option_key );
739
740 // ログインが有効期限切れの場合
741 // ログイン成功時にまとめてクリーンアップ処理を実行するため、ここでは消さない
742 if ( $option_data === false ) {
743 return new WP_Error( 'empty_username', 'セッションの有効期限が切れました。再度ログインしてください。' );
744 }
745
746 // ユーザーオブジェクト取得
747 $user = get_user_by( 'id', $option_data['user_id'] );
748 if ( ! $user ) {
749 $this->delete_option_data( $option_key );
750 return new WP_Error( 'empty_username', 'ユーザー�
751 報が見つかりません。再度ログインしてください。' );
752 }
753
754 // 認証方法の設定
755 $auth_method = intVal( $option_data['auth_method'] );
756
757 // 認証コード再送信
758 if ( isset( $_POST['resend_2fa_email'] ) ) {
759 // メール再送信
760 $result = $this->send_2fa_email( $option_data['user_id'] );
761
762 $this->show_two_factor_form(
763 $login_token,
764 $auth_method,
765 $option_data['has_recovery'],
766 $option_data['email_address'],
767 $result
768 );
769 }
770
771 // リカバリーコード�
772 �力画面への切り替え
773 if ( isset( $_POST['use_recovery_code'] ) ) {
774 $this->show_two_factor_form(
775 $login_token,
776 self::USER_AUTH_METHOD_RECOVERY,
777 $option_data['has_recovery'],
778 $option_data['email_address'],
779 false
780 );
781 }
782
783 // 認証コード�
784 �力画面への切り替え
785 if ( isset( $_POST['back_to_auth_code'] ) ) {
786 $this->show_two_factor_form(
787 $login_token,
788 $auth_method,
789 $option_data['has_recovery'],
790 $option_data['email_address'],
791 false
792 );
793 }
794
795 // リカバリコードでの認証の場合
796 if ( isset( $_POST['recovery_code'] ) ) {
797 $auth_method = self::USER_AUTH_METHOD_RECOVERY;
798 }
799
800 // ログイン�
801 報の復�
802
803 $_POST['log'] = $option_data['user_login'];
804 $_POST['auth_method'] = $auth_method;
805 $_POST['has_recovery'] = $option_data['has_recovery'];
806 $_POST['email_address'] = $option_data['email_address'];
807
808 return $user;
809 }
810
811 /**
812 * 認証フック: 2段階認証ログイン無効チェック
813 *
814 * @param mixed $user
815 * @param string $username
816 * @param string $password
817 *
818 * @return mixed
819 */
820 public function two_factor_disable_login_check( $user, $username, $password ) {
821 // レートリミットの無効時間を取得
822 $limit_time = $this->two_factor_rate_limit();
823 if ( $limit_time > 0 ) {
824 // ログインログに記録
825 $this->write_log( self::LOGIN_STATUS_DISABLED );
826
827 return new WP_Error( 'empty_username', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく�
828 ってから再度お試しください。" );
829 }
830 return $user;
831 }
832
833
834 /**
835 * 認証フック: 2段階認証処理
836 *
837 * @param mixed $user
838 * @param string $username
839 * @param string $password
840 *
841 * @return mixed
842 */
843 public function authenticate_with_two_factor( $user, $username, $password ) {
844 // 初回アクセス、または初回認証時
845 if ( ! isset( $_POST['authenticator_code'] ) ) {
846 // 認証失敗の場合
847 if ( is_wp_error( $user ) ) {
848 return $user;
849 }
850
851 // 2段階認証が不要な場合
852 if ( ! $this->is_2fa_required( $user ) ) {
853 return $user;
854 }
855
856 // 認証�
857 報の取得
858 $auth_info = $this->get_2fa_auth_info( $user->ID );
859 // 認証�
860 報が存在しない場合
861 if ( count( $auth_info ) === 0 ) {
862 // データ移行漏れ対応
863 $auth_info = $this->repair_migration_gaps( $user->ID );
864 if ( count ( $auth_info ) === 0 ) {
865 return $user;
866 }
867 }
868
869 // リカバリーコードの有無
870 $has_recovery = true;
871 $recovery_codes = $auth_info['recovery'];
872 if ( ! $recovery_codes || ! is_array( $recovery_codes ) || count( $recovery_codes ) === 0 ) {
873 $has_recovery = false;
874 }
875
876 // option key生成
877 $session_token = bin2hex( random_bytes( 16 ) );
878 $option_key = $this->create_option_key( $session_token );
879
880 // 保存用ログインデータ作成
881 $option_data = array(
882 'user_id' => $user->ID,
883 'user_login' => sanitize_text_field( $_POST['log'] ?? '' ),
884 'auth_method' => intVal( $auth_info['method'] ),
885 'expires' => time() + self::SESSION_EXPIRY,
886 'created' => time(),
887 'has_recovery' => $has_recovery,
888 'email_address' => $this->mask_email( $user->ID ),
889 );
890
891 // データを保存
892 $this->set_option_data( $option_key, $option_data );
893
894 // メール認証の場合、コードを生成して送信
895 if ( intVal( $auth_info['method'] ) === self::USER_AUTH_METHOD_EMAIL ) {
896 $this->send_2fa_email( $user->ID, $auth_info['secret'] );
897 }
898
899 // 2FA画面を表示して、処理終了
900 $this->show_two_factor_form(
901 $session_token,
902 intVal( $auth_info['method'] ),
903 $has_recovery,
904 $option_data['email_address'],
905 false
906 );
907 }
908
909 // 2FA関連のPOSTデータ取得
910 $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
911 $auth_code = sanitize_text_field( $_POST['authenticator_code'] ?? '' );
912
913 // option key取得
914 $option_key = $this->create_option_key( $login_token );
915
916 // $userがWP_Errorの場合は処理をスキップ
917 if ( is_wp_error( $user ) ) {
918 return $user;
919 }
920
921 // 2段階認証成功の場合
922 if ( $this->verify_2fa_code( $user->ID, $auth_code, intval( $_POST['auth_method'] ) ) ) {
923 // 失敗回数をリセット
924 $this->reset_fail_count();
925 // セッションデータを削除
926 $this->delete_option_data( $option_key );
927 // メール認証の送信可能時間をリセット
928 $this->delete_email_able_send_time( $user->ID );
929 return $user;
930 }
931
932 // 2段階認証失敗の場合
933 // 失敗回数をインクリメント
934 $this->increment_fail_count();
935
936 // レートリミットの確認
937 $limit_time = $this->two_factor_rate_limit();
938
939 $this->write_log( self::LOGIN_STATUS_FAILED );
940
941 if ( $limit_time > 0 ) {
942 // ステータス無効状�
943 �の場合
944 return new WP_Error( 'empty_username', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく�
945 ってから再度お試しください。" );
946 }
947
948 // 2FA画面を再表示して、処理終了
949 $this->show_two_factor_form(
950 $login_token,
951 intval( $_POST['auth_method'] ),
952 (bool) $_POST['has_recovery'],
953 sanitize_email( wp_unslash( $_POST['email_address'] ?? '' ) ),
954 false
955 );
956 }
957
958 /**
959 * 2FAセッションデータを取得
960 *
961 * @param int $last_option_id
962 * @param int $limit
963 *
964 * @return array
965 */
966 private function fetch_2fa_sessions( int $last_option_id, int $limit ): array {
967 global $wpdb;
968
969 // セッションキーの接頭辞でLIKE検索
970 $like = $wpdb->esc_like( self::OPTION_PREFIX ) . '%';
971
972 // SQL実行(LIKE検索を行うため、意図的に直接クエリを実行する)
973 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
974 $options = $wpdb->get_results(
975 $wpdb->prepare(
976 "SELECT
977 option_id,
978 option_name,
979 option_value
980 FROM
981 {$wpdb->options}
982 WHERE TRUE
983 AND option_name LIKE %s
984 AND %d < option_id
985 ORDER BY
986 option_id ASC
987 LIMIT
988 %d",
989 $like,
990 $last_option_id,
991 $limit
992 )
993 );
994
995 return $options ?? array();
996 }
997
998 /**
999 * 期限切れセッションデータを収集
1000 *
1001 * @param array $options
1002 *
1003 * @return array ['log_data' => array, 'delete_option_names' => array]
1004 */
1005 private function collect_expired_session_data( array $options ): array {
1006 // ログ登録用データリスト初期化
1007 $log_data = array();
1008 // 削除対象のoption_nameリスト
1009 $delete_option_names = array();
1010
1011 foreach ( $options as $option ) {
1012 // シリアライズ形式でない場合はスキップ
1013 if ( ! is_serialized( $option->option_value ) ) {
1014 continue;
1015 }
1016
1017 // option_valueを連想�
1018 �列に変換
1019 $data = unserialize( $option->option_value, array( 'allowed_classes' => false ) );
1020
1021 // �
1022 �列でない場合はスキップ
1023 if ( ! is_array( $data ) ) {
1024 continue;
1025 }
1026
1027 // 有効期限が切れている場合
1028 if ( isset( $data['expires'] ) && $data['expires'] <= time() ) {
1029
1030 // ログ登録用データを収集
1031 $log_data[] = array(
1032 'name' => $data['user_login'],
1033 'ip' => $this->get_client_ip(),
1034 'status' => self::LOGIN_STATUS_FAILED,
1035 'method' => self::METHOD_PAGE,
1036 'login_at' => wp_date( 'Y-m-d H:i:s', $data['created'] ), // WPのタイムゾーンに変更して登録
1037 );
1038
1039 // 削除対象のoption_nameを収集
1040 $delete_option_names[] = $option->option_name;
1041 }
1042 }
1043
1044 return array(
1045 'log_data' => $log_data,
1046 'delete_option_names' => $delete_option_names,
1047 );
1048 }
1049
1050 /**
1051 * ログイン失敗ログを一括登録
1052 * (呼び出し�
1053 �でトランザクションを管理すること)
1054 *
1055 * @param array $log_data
1056 *
1057 * @return void
1058 * @throws Exception SQLエラー発生時.
1059 */
1060 private function insert_login_failed_logs( array $log_data ): void {
1061 if ( empty( $log_data ) ) {
1062 return;
1063 }
1064
1065 global $wpdb;
1066
1067 // プレースホルダーと値の準備
1068 $values = array();
1069 $placeholders = array();
1070
1071 // 収集したログデータを一括登録用に変換
1072 foreach ( $log_data as $log ) {
1073 $values[] = $log['name'];
1074 $values[] = $log['ip'];
1075 $values[] = $log['status'];
1076 $values[] = $log['method'];
1077 $values[] = $log['login_at'];
1078 $placeholders[] = '(%s, %s, %d, %d, %s)';
1079 }
1080
1081 // SQL実行(一括登録を行うため、意図的に直接クエリを実行する)
1082 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1083 $result = $wpdb->query(
1084 $wpdb->prepare(
1085 "INSERT INTO `{$wpdb->prefix}cloudsecurewp_login_log`
1086 (`name`, `ip`, `status`, `method`, `login_at`)
1087 VALUES " . implode( ', ', $placeholders ),
1088 $values
1089 )
1090 );
1091
1092 // SQLエラーチェック
1093 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1094 throw new Exception( 'Failed to insert login logs: ' . $wpdb->last_error );
1095 }
1096 }
1097
1098 /**
1099 * 指定されたオプションを一括削除
1100 * (呼び出し�
1101 �でトランザクションを管理すること)
1102 *
1103 * @param array $option_names
1104 *
1105 * @return void
1106 * @throws Exception SQLエラー発生時.
1107 */
1108 private function delete_options( array $option_names ): void {
1109 if ( empty( $option_names ) ) {
1110 return;
1111 }
1112
1113 global $wpdb;
1114
1115 // プレースホルダー作成
1116 $placeholders = implode( ', ', array_fill( 0, count( $option_names ), '%s' ) );
1117
1118 // SQL実行(一括削除を行うため、意図的に直接クエリを実行する)
1119 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1120 $result = $wpdb->query(
1121 $wpdb->prepare(
1122 "DELETE FROM
1123 {$wpdb->options}
1124 WHERE
1125 option_name IN ($placeholders)",
1126 $option_names
1127 )
1128 );
1129
1130 // SQLエラーチェック
1131 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1132 throw new Exception( 'Failed to delete options: ' . $wpdb->last_error );
1133 }
1134 }
1135
1136 /**
1137 * 期限切れログイン�
1138 報セッションのクリーンアップ処理本体
1139 *
1140 * @return void
1141 */
1142 private function process_cleanup_expired_sessions(): void {
1143 global $wpdb;
1144
1145 $last_option_id = 0;
1146
1147 while ( true ) {
1148 try {
1149 // 2FAセッションデータを取得
1150 $options = $this->fetch_2fa_sessions( $last_option_id, self::CLEANUP_BATCH_SIZE );
1151
1152 // 取得するレコードがなくなったら終了
1153 if ( empty( $options ) ) {
1154 break;
1155 }
1156
1157 // 最後に取得したoption_idを更新
1158 $last_option_id = end( $options )->option_id;
1159
1160 // 期限切れセッションデータを収集
1161 $result = $this->collect_expired_session_data( $options );
1162 $log_data = $result['log_data'];
1163 $delete_option_names = $result['delete_option_names'];
1164
1165 if ( empty( $delete_option_names ) && empty( $log_data ) ) {
1166 continue;
1167 }
1168
1169 // トランザクション開始
1170 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1171 $wpdb->query( 'START TRANSACTION' );
1172
1173 // ログデータを一括登録
1174 $this->insert_login_failed_logs( $log_data );
1175
1176 // オプションを一括削除
1177 $this->delete_options( $delete_option_names );
1178
1179 // トランザクションコミット
1180 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1181 $wpdb->query( 'COMMIT' );
1182
1183 } catch ( Exception $e ) {
1184 // エラー発生時はロールバック
1185 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1186 $wpdb->query( 'ROLLBACK' );
1187 break;
1188 }
1189 }
1190 }
1191
1192 /**
1193 * 期限切れの2FAセッションをクリーンアップ
1194 *
1195 * @return void
1196 */
1197 public function cleanup_expired_sessions(): void {
1198 global $wpdb;
1199
1200 // ロック名
1201 $lock_name = 'cloudsecurewp_2fa_cleanup_lock';
1202 // クリーンアップ処理の完了を�
1203 つ最大秒数
1204 $timeout = self::CLEANUP_TIMEOUT;
1205
1206 // ロックを取得
1207 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1208 $get_lock = $wpdb->get_var(
1209 $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock_name )
1210 );
1211
1212 if ( $get_lock === '1' ) {
1213 // ロック取得成功(クリーンアップ実行�
1214
1215
1216 try {
1217 // クリーンアップ処理実行
1218 $this->process_cleanup_expired_sessions();
1219 } catch ( Exception $e ) {
1220 // クリーンアップ処理実行で失敗しても�
1221 部でロールバックするため、ここでは何もしない
1222 } finally {
1223 // ロック解放
1224 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1225 $wpdb->query(
1226 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
1227 );
1228 }
1229 } else {
1230 // ロック取得失敗(�
1231 機�
1232
1233
1234 // リーダーのクリーンアップ完了を�
1235
1236 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1237 $acquired_signal = $wpdb->get_var(
1238 $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, $timeout )
1239 );
1240
1241 if ( $acquired_signal === '1' ) {
1242 // ロック取得成功(クリーンアップ処理終了)
1243 // 即座にロック解放(�
1244 機完了の合図として使うだけ)
1245 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1246 $wpdb->query(
1247 $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name )
1248 );
1249 }
1250 }
1251 }
1252
1253 /**
1254 * ユーザの2faログイン�
1255 報取得
1256 *
1257 * @param string $ip
1258 *
1259 * @return array $login_info
1260 */
1261 private function get_2fa_login_info( string $ip ): array {
1262 global $wpdb;
1263
1264 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1265 $sql = "SELECT * FROM {$table_name} WHERE ip = %s";
1266
1267 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1268 $row = $wpdb->get_row( $wpdb->prepare( $sql, $ip ), ARRAY_A );
1269
1270 return $row ?? array();
1271 }
1272
1273 /**
1274 * ユーザの2fa認証�
1275 報取得
1276 *
1277 * @param int $user_id
1278 *
1279 * @return array $auth_info
1280 */
1281 public function get_2fa_auth_info( int $user_id ): array {
1282 global $wpdb;
1283
1284 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1285 $sql = "SELECT * FROM {$table_name} WHERE user_id = %d";
1286
1287 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1288 $row = $wpdb->get_row( $wpdb->prepare( $sql, $user_id ), ARRAY_A );
1289
1290 if ( ! empty( $row ) && isset( $row['recovery'] ) && is_string( $row['recovery'] ) ) {
1291 // JSON形式を優�
1292 �でデコード(新形式)
1293 $decoded = json_decode( $row['recovery'], true );
1294 if ( is_array( $decoded ) ) {
1295 $row['recovery'] = $decoded;
1296 } elseif ( is_serialized( $row['recovery'] ) ) {
1297 // フォールバック:serialize形式をデコード(旧形式)
1298 $unserialized = unserialize( $row['recovery'], array( 'allowed_classes' => false ) );
1299 $row['recovery'] = is_array( $unserialized ) ? $unserialized : null;
1300 } else {
1301 // JSON でも serialize でもない場合は null を設定
1302 $row['recovery'] = null;
1303 }
1304 }
1305
1306 return $row ?? array();
1307 }
1308
1309 /**
1310 * リカバリーコードの登録状況取得
1311 *
1312 * @param int $user_id
1313 *
1314 * @return bool true:登録済み、false:未登録
1315 */
1316 public function has_recovery_codes( int $user_id ): bool {
1317 $auth_info = $this->get_2fa_auth_info( $user_id );
1318
1319 if ( empty( $auth_info ) || is_null( $auth_info['recovery'] ) ) {
1320 return false;
1321 }
1322
1323 return true;
1324 }
1325
1326 /**
1327 * ユーザの2fa認証方法設定
1328 *
1329 * @param int $user_id
1330 * @param int $method
1331 * @param string $secret
1332 *
1333 * @return void
1334 */
1335 public function setting_2fa_auth_info( int $user_id, int $method, string $secret ): void {
1336 // 認証�
1337 報取得
1338 $auth_info = $this->get_2fa_auth_info( $user_id );
1339
1340 if ( ! empty( $auth_info ) ) {
1341 // 既に登録されている場合は更新
1342 $this->update_2fa_auth_method( $user_id, $method, $secret );
1343 } else {
1344 // 登録されていない場合は新規登録
1345 $this->insert_2fa_auth_method( $user_id, $method, $secret );
1346 }
1347 }
1348
1349 /**
1350 * ユーザの2fa認証方法更新
1351 *
1352 * @param int $user_id
1353 * @param int $method
1354 * @param string $secret
1355 * @return void
1356 */
1357 private function update_2fa_auth_method( int $user_id, int $method, string $secret ): void {
1358 global $wpdb;
1359
1360 // 認証�
1361 報を更新
1362 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1363 $wpdb->update(
1364 $wpdb->prefix . 'cloudsecurewp_2fa_auth',
1365 array(
1366 'method' => $method,
1367 'secret' => $secret,
1368 ),
1369 array( 'user_id' => $user_id )
1370 );
1371 }
1372
1373 /**
1374 * ユーザの2fa認証方法登録
1375 *
1376 * @param int $user_id
1377 * @param int $method
1378 * @param string $secret
1379 *
1380 * @return void
1381 */
1382 private function insert_2fa_auth_method( int $user_id, int $method, string $secret ): void {
1383 global $wpdb;
1384
1385 // 認証�
1386 報を新規登録
1387 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1388 $wpdb->insert(
1389 $wpdb->prefix . 'cloudsecurewp_2fa_auth',
1390 array(
1391 'user_id' => $user_id,
1392 'secret' => $secret,
1393 'recovery' => null,
1394 'method' => $method,
1395 )
1396 );
1397 }
1398
1399 /**
1400 * ログイン失敗回数インクリメント処理
1401 *
1402 * @return void
1403 * @throws Exception SQLエラー発生時.
1404 */
1405 private function increment_fail_count(): void {
1406 global $wpdb;
1407
1408 // テーブル名取得
1409 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1410
1411 // 登録データ作成
1412 $ip = $this->get_client_ip();
1413 $now_datetime = current_time( 'mysql' );
1414 $data = array(
1415 'ip' => $ip,
1416 'status' => self::LOGIN_STATUS_FAILED,
1417 'failed_count' => 1,
1418 'login_at' => $now_datetime,
1419 );
1420
1421 $row = $this->get_2fa_login_info( $ip );
1422
1423 if ( empty( $row ) ) {
1424 // レコードが存在していない場合は新規登録
1425 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1426 $wpdb->insert( $table_name, $data );
1427 } else {
1428 // レコードが存在している場合はカウントアップ
1429 $data['failed_count'] = (int) $row['failed_count'] + 1;
1430
1431 // 3回失敗ごとにステータスを無効化に更新
1432 if ( $data['failed_count'] % 3 === 0 ) {
1433 $data['status'] = self::LOGIN_STATUS_DISABLED;
1434 }
1435
1436 // 失敗回数とステータスを更新
1437 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1438 $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
1439 }
1440 }
1441
1442 /**
1443 * ログインログ記録処理(失敗時)
1444 *
1445 * @param int $status ログインステータス
1446 *
1447 * @return void
1448 */
1449 private function write_log( int $status ): void {
1450 global $wpdb;
1451
1452 // ログインログに記録
1453 $name = sanitize_text_field( $_POST['log'] ?? '' );
1454 $ip = $this->get_client_ip();
1455 $method = $this->login_log->is_xmlrpc() ? self::METHOD_XMLRPC : self::METHOD_PAGE;
1456 $this->login_log->write_log( $name, $ip, $status, $method );
1457 }
1458
1459 /**
1460 * ログイン失敗回数リセット処理
1461 *
1462 * @return void
1463 */
1464 private function reset_fail_count(): void {
1465 global $wpdb;
1466
1467 // テーブル名取得
1468 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1469
1470 // 登録データ作成
1471 $ip = $this->get_client_ip();
1472 $now_datetime = current_time( 'mysql' );
1473 $data = array(
1474 'ip' => $ip,
1475 'status' => self::LOGIN_STATUS_SUCCESS,
1476 'failed_count' => 0,
1477 'login_at' => $now_datetime,
1478 );
1479
1480 $row = $this->get_2fa_login_info( $ip );
1481
1482 if ( empty( $row ) ) {
1483 // レコードが存在していない場合は新規登録
1484 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1485 $wpdb->insert( $table_name, $data );
1486 } else {
1487 if ( $row['status'] === self::LOGIN_STATUS_SUCCESS ) {
1488 // 既に成功状�
1489 �の場合は何もしない
1490 return;
1491 }
1492 // 失敗回数とステータスを更新
1493 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1494 $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
1495
1496 }
1497 }
1498
1499 /**
1500 * レートリミットの無効時間を取得
1501 * 無効ではない場合は0を返す
1502 *
1503 * @return int レートリミットの残り時間(分)
1504 */
1505 private function two_factor_rate_limit(): int {
1506 // 現在のクライアントIPアドレスでレコードを取得
1507 $ip = $this->get_client_ip();
1508 $row = $this->get_2fa_login_info( $ip );
1509
1510 if ( ! empty( $row ) && (int) $row['status'] === self::LOGIN_STATUS_DISABLED ) {
1511 // 無効時間チェック
1512 $limit_time = self::LATE_LIMIT_TIME_LIST[ (int) $row['failed_count'] ] ?? self::LATE_LIMIT_TIME_MAX;
1513 $now_time = strtotime( current_time( 'mysql' ) );
1514 $block_time = strtotime( $row['login_at'] ) + $limit_time;
1515
1516 if ( $now_time < $block_time ) {
1517 return (int) ceil( ( $block_time - $now_time ) / 60 );
1518 }
1519 }
1520
1521 return 0;
1522 }
1523
1524 /**
1525 * メール最終送信時刻更新処理
1526 *
1527 * @param int $user_id
1528 *
1529 * @return void
1530 */
1531 private function update_email_able_send_time( int $user_id ): void {
1532 // 送信可能時刻を更新
1533 $able_send_time = time() + self::EMAIL_SEND_LIMIT_TIME;
1534 update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, $able_send_time );
1535 }
1536
1537 /**
1538 * メール最終送信時刻リセット処理
1539 *
1540 * @param int $user_id
1541 *
1542 * @return void
1543 */
1544 private function delete_email_able_send_time( int $user_id ): void {
1545 // 送信可能時刻をリセット
1546 update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, 0 );
1547 }
1548
1549 /**
1550 * メール最終送信時刻取得処理
1551 *
1552 * @param int $user_id
1553 *
1554 * @return int 最終送信時刻(unixタイムスタンプ)
1555 */
1556 private function get_email_able_send_time( int $user_id ): int {
1557 // 最終送信時刻を取得
1558 $last_send = get_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, true );
1559
1560 if ( ! $last_send ) {
1561 return 0;
1562 }
1563
1564 return (int) $last_send;
1565 }
1566
1567 /**
1568 * wp_usermetaから2fa関連データを取得
1569 *
1570 * @param int $last_umeta_id
1571 * @param int $batch_size
1572 *
1573 * @return array
1574 */
1575 private function fetch_user_metas( int $last_umeta_id, int $batch_size ): array {
1576 global $wpdb;
1577
1578 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1579 $user_metas = $wpdb->get_results(
1580 $wpdb->prepare(
1581 "SELECT umeta_id, user_id, meta_value
1582 FROM {$wpdb->usermeta}
1583 WHERE meta_key = %s
1584 AND umeta_id > %d
1585 ORDER BY umeta_id ASC
1586 LIMIT %d",
1587 self::TWO_FACTOR_SECRET_KEY,
1588 $last_umeta_id,
1589 $batch_size
1590 ),
1591 ARRAY_A
1592 );
1593
1594 return $user_metas ?? array();
1595 }
1596
1597 /**
1598 * usermeta データをバルクインサート用データに変換
1599 *
1600 * @param array $user_metas
1601 *
1602 * @return array ['insert_data' => array, 'umeta_ids_map' => array]
1603 */
1604 private function convert_user_metas_to_insert_data( array $user_metas ): array {
1605 $insert_data = array();
1606 $umeta_ids_map = array();
1607
1608 foreach ( $user_metas as $user_meta ) {
1609 $user_id = (int) $user_meta['user_id'];
1610 $umeta_id = (int) $user_meta['umeta_id'];
1611 $meta_value = $user_meta['meta_value'];
1612
1613 // Base32デコードしてバイナリに変換
1614 $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $meta_value );
1615
1616 // バイナリデータを16進数に変換
1617 $hex_secret = bin2hex( $binary_secret );
1618
1619 // データを蓄積
1620 $insert_data[] = array(
1621 'user_id' => $user_id,
1622 'secret' => $hex_secret,
1623 'recovery' => null,
1624 'method' => self::USER_AUTH_METHOD_APP,
1625 );
1626
1627 // umeta_idとuser_idのマッピングを保存
1628 $umeta_ids_map[ $user_id ] = $umeta_id;
1629 }
1630
1631 return array(
1632 'insert_data' => $insert_data,
1633 'umeta_ids_map' => $umeta_ids_map,
1634 );
1635 }
1636
1637 /**
1638 * 2fa認証データのバルクインサート
1639 * (呼び出し�
1640 �でトランザクションを管理すること)
1641 *
1642 * @param array $data_list
1643 *
1644 * @return array 失敗したuser_idのリスト
1645 * @throws Exception SQLエラー発生時.
1646 */
1647 private function bulk_insert_2fa_auth( array $data_list ): array {
1648 if ( empty( $data_list ) ) {
1649 return array();
1650 }
1651
1652 global $wpdb;
1653
1654 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1655
1656 // 挿�
1657 �を試みるuser_idのリスト
1658 $target_user_ids = array_column( $data_list, 'user_id' );
1659
1660 // プレースホルダーと値の準備
1661 $values = array();
1662 $placeholders = array();
1663
1664 foreach ( $data_list as $data ) {
1665 $values[] = $data['user_id'];
1666 $values[] = $data['secret'];
1667 $values[] = $data['method'];
1668 $placeholders[] = '(%d, %s, null, %d)';
1669 }
1670
1671 // SQL実行(INSERT IGNOREで重複などのエラーを無視)
1672 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1673 $result = $wpdb->query(
1674 $wpdb->prepare(
1675 "INSERT IGNORE INTO {$table_name}
1676 (`user_id`, `secret`, `recovery`, `method`)
1677 VALUES " . implode( ', ', $placeholders ),
1678 $values
1679 )
1680 );
1681
1682 // SQLエラーチェック(INSERT IGNOREは重複エラーを返さないが、その他のエラーはチェック)
1683 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1684 throw new Exception( 'Failed to bulk insert 2fa auth data: ' . $wpdb->last_error );
1685 }
1686
1687 // 失敗したuser_id(挿�
1688 �されなかったID)を特定
1689 // INSERT IGNOREは重複時に0行を挿�
1690 �するため、
1691 // 挿�
1692 �されなかった数 = 試行数 - 実際に挿�
1693 �された行数
1694 $failed_count = count( $target_user_ids ) - $result;
1695
1696 // 失敗したIDを特定したい場合は、挿�
1697 �後に存在確認が�
1698
1699 if ( $failed_count > 0 ) {
1700 // 挿�
1701 �後に実際に存在するuser_idを確認
1702 $placeholders_check = implode( ', ', array_fill( 0, count( $target_user_ids ), '%d' ) );
1703 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1704 $inserted_user_ids = $wpdb->get_col(
1705 $wpdb->prepare(
1706 "SELECT user_id FROM {$table_name} WHERE user_id IN ($placeholders_check)",
1707 $target_user_ids
1708 )
1709 );
1710
1711 // 失敗したuser_idを特定
1712 $failed_user_ids = array_diff( $target_user_ids, $inserted_user_ids );
1713 return array_values( $failed_user_ids );
1714 }
1715
1716 return array();
1717 }
1718
1719 /**
1720 * wp_usermetaから指定されたレコードを削除
1721 * (呼び出し�
1722 �でトランザクションを管理すること)
1723 *
1724 * @param array $umeta_ids
1725 *
1726 * @return void
1727 * @throws Exception SQLエラー発生時.
1728 */
1729 private function delete_user_metas( array $umeta_ids ): void {
1730 if ( empty( $umeta_ids ) ) {
1731 return;
1732 }
1733
1734 global $wpdb;
1735
1736 $delete_placeholders = implode( ', ', array_fill( 0, count( $umeta_ids ), '%d' ) );
1737 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1738 $result = $wpdb->query(
1739 $wpdb->prepare(
1740 "DELETE FROM {$wpdb->usermeta} WHERE umeta_id IN ($delete_placeholders)",
1741 $umeta_ids
1742 )
1743 );
1744
1745 // 削除エラーチェック
1746 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1747 throw new Exception( 'Failed to delete usermeta: ' . $wpdb->last_error );
1748 }
1749 }
1750
1751 /**
1752 * 2fa既存ユーザーデータの移行処理
1753 *
1754 * @return void
1755 */
1756 public function migrate_2fa_user_data(): void {
1757 global $wpdb;
1758
1759 $batch_size = self::CLEANUP_BATCH_SIZE;
1760 $last_umeta_id = 0;
1761
1762 while ( true ) {
1763 try {
1764 // wp_usermetaからシークレットキーをバッチサイズごとに取得
1765 $user_metas = $this->fetch_user_metas( $last_umeta_id, $batch_size );
1766
1767 // 取得するレコードがなくなったら終了
1768 if ( empty( $user_metas ) ) {
1769 break;
1770 }
1771
1772 // 最後に取得したumeta_idを更新
1773 $last_umeta_id = end( $user_metas )['umeta_id'];
1774
1775 // バルクインサート用のデータを準備
1776 $conversion_result = $this->convert_user_metas_to_insert_data( $user_metas );
1777 $insert_data = $conversion_result['insert_data'];
1778 $umeta_ids_map = $conversion_result['umeta_ids_map'];
1779
1780 // 登録するデータがない場合は次のバッチへ
1781 if ( empty( $insert_data ) ) {
1782 continue;
1783 }
1784
1785 // トランザクション開始
1786 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1787 $wpdb->query( 'START TRANSACTION' );
1788
1789 // バルクインサート実行(失敗したuser_idのリストを取得)
1790 $failed_user_ids = $this->bulk_insert_2fa_auth( $insert_data );
1791
1792 // 成功したuser_idを特定
1793 $target_user_ids = array_column( $insert_data, 'user_id' );
1794 $successful_user_ids = array_diff( $target_user_ids, $failed_user_ids );
1795
1796 // 成功したレコードのumeta_idを取得
1797 $successful_umeta_ids = array();
1798 foreach ( $successful_user_ids as $user_id ) {
1799 if ( isset( $umeta_ids_map[ $user_id ] ) ) {
1800 $successful_umeta_ids[] = $umeta_ids_map[ $user_id ];
1801 }
1802 }
1803
1804 // 成功したレコードのみwp_usermetaから削除
1805 $this->delete_user_metas( $successful_umeta_ids );
1806
1807 // トランザクションコミット
1808 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1809 $wpdb->query( 'COMMIT' );
1810
1811 } catch ( Exception $e ) {
1812 // エラー発生時はロールバック
1813 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1814 $wpdb->query( 'ROLLBACK' );
1815 break;
1816 }
1817 }
1818 }
1819
1820 /**
1821 * リカバリーコードをserialize形式からJSON形式に一括変換
1822 *
1823 * @return void
1824 */
1825 public function migrate_recovery_codes_to_json(): void {
1826 global $wpdb;
1827
1828 $last_user_id = 0;
1829
1830 while ( true ) {
1831 try {
1832 // serialize形式のリカバリーコードをバッチ取得
1833 $rows = $this->fetch_serialized_recovery_codes( $last_user_id, self::CLEANUP_BATCH_SIZE );
1834
1835 // 取得するレコードがなくなったら終了
1836 if ( empty( $rows ) ) {
1837 break;
1838 }
1839
1840 // 最後に取得したuser_idを更新
1841 $last_user_id = end( $rows )->user_id;
1842
1843 // トランザクション開始
1844 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1845 $wpdb->query( 'START TRANSACTION' );
1846
1847 // バッチ単位で変換
1848 $this->convert_recovery_codes_batch( $rows );
1849
1850 // トランザクションコミット
1851 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1852 $wpdb->query( 'COMMIT' );
1853
1854 } catch ( Exception $e ) {
1855 // エラー発生時はロールバック
1856 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1857 $wpdb->query( 'ROLLBACK' );
1858 break;
1859 }
1860 }
1861 }
1862
1863 /**
1864 * serialize形式のリカバリーコードを持つレコードをバッチ取得
1865 *
1866 * @param int $last_user_id
1867 * @param int $limit
1868 *
1869 * @return array
1870 */
1871 private function fetch_serialized_recovery_codes( int $last_user_id, int $limit ): array {
1872 global $wpdb;
1873
1874 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1875
1876 // JSON�
1877 �列は '[' で始まるため、それ以外(serialize形式は 'a:' で始まる)を抽出
1878 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1879 $rows = $wpdb->get_results(
1880 $wpdb->prepare(
1881 "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",
1882 $wpdb->esc_like( '[' ) . '%',
1883 $last_user_id,
1884 $limit
1885 )
1886 );
1887
1888 return $rows ?? array();
1889 }
1890
1891 /**
1892 * リカバリーコードをserialize形式からJSON形式に変換して更新
1893 *
1894 * @param array $rows
1895 *
1896 * @return void
1897 * @throws Exception 変換エラー発生時.
1898 */
1899 private function convert_recovery_codes_batch( array $rows ): void {
1900 global $wpdb;
1901
1902 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1903
1904 $case_parts = array();
1905 $values = array();
1906 $user_ids = array();
1907
1908 foreach ( $rows as $row ) {
1909 if ( ! is_serialized( $row->recovery ) ) {
1910 continue;
1911 }
1912 $codes = unserialize( $row->recovery, array( 'allowed_classes' => false ) );
1913
1914 if ( ! is_array( $codes ) ) {
1915 continue;
1916 }
1917
1918 $json_codes = wp_json_encode( array_values( $codes ) );
1919 if ( $json_codes === false ) {
1920 continue;
1921 }
1922 $case_parts[] = 'WHEN %d THEN %s';
1923 $values[] = $row->user_id;
1924 $values[] = $json_codes;
1925 $user_ids[] = $row->user_id;
1926 }
1927
1928 if ( empty( $user_ids ) ) {
1929 return;
1930 }
1931
1932 $case_sql = implode( ' ', $case_parts );
1933 $id_placeholders = implode( ', ', array_fill( 0, count( $user_ids ), '%d' ) );
1934 $values = array_merge( $values, $user_ids );
1935
1936 // SQL実行(バルクアップデートを行うため、意図的に直接クエリを実行する)
1937 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1938 $result = $wpdb->query(
1939 $wpdb->prepare(
1940 "UPDATE {$table_name} SET recovery = CASE user_id {$case_sql} END WHERE user_id IN ({$id_placeholders})",
1941 $values
1942 )
1943 );
1944
1945 if ( $result === false || ! empty( $wpdb->last_error ) ) {
1946 throw new Exception( 'Failed to bulk update recovery codes: ' . $wpdb->last_error );
1947 }
1948 }
1949
1950 /**
1951 * 2faログインテーブル作成
1952 *
1953 * @return void
1954 */
1955 private function create_login_table(): void {
1956 global $wpdb;
1957 $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
1958 $table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
1959
1960 if ( is_null( $table ) ) {
1961 $charset_collate = $wpdb->get_charset_collate();
1962
1963 $sql = "CREATE TABLE {$table_name} (
1964 ip varchar( 39 ) NOT NULL DEFAULT '',
1965 status int NOT NULL DEFAULT 0,
1966 failed_count int NOT NULL DEFAULT 0,
1967 login_at datetime NOT NULL,
1968 PRIMARY KEY (ip)
1969 ) {$charset_collate}";
1970
1971 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1972 dbDelta( $sql );
1973 }
1974 }
1975
1976 /**
1977 * 2fa認証テーブル作成
1978 *
1979 * @return void
1980 */
1981 private function create_auth_table(): void {
1982 global $wpdb;
1983 $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
1984 $table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
1985
1986 if ( is_null( $table ) ) {
1987 $charset_collate = $wpdb->get_charset_collate();
1988
1989 $sql = "CREATE TABLE {$table_name} (
1990 user_id bigint(20) UNSIGNED NOT NULL,
1991 secret varchar(255) NOT NULL,
1992 recovery longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
1993 method int(11) UNSIGNED NOT NULL DEFAULT 1,
1994 PRIMARY KEY (user_id)
1995 ) {$charset_collate}";
1996
1997 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
1998 dbDelta( $sql );
1999 }
2000 }
2001
2002 /**
2003 * 2段階認証のテーブル作成とデータ移行を一括実行
2004 *
2005 * @return void
2006 */
2007 public function setup_2fa_tables(): void {
2008 $this->create_login_table();
2009 $this->create_auth_table();
2010 $this->migrate_2fa_user_data();
2011 }
2012
2013 /**
2014 * データ移行漏れ修復処理
2015 *
2016 * @param int $user_id
2017 *
2018 * @return array
2019 */
2020 public function repair_migration_gaps( int $user_id ): array {
2021 global $wpdb;
2022
2023 // 2fa認証テーブルが存在しない場合は作成
2024 $this->create_auth_table();
2025 // 2faログインテーブルが存在しない場合は作成
2026 $this->create_login_table();
2027
2028 // 2fa認証�
2029 報を取得
2030 $auth_info = $this->get_2fa_auth_info( $user_id );
2031 if ( count( $auth_info ) !== 0 ) {
2032 // 既に認証�
2033 報が存在する場合は何もしない
2034 return $auth_info;
2035 }
2036
2037 // wp_usermetaからシークレットキーを取得
2038 $secret = get_user_meta( $user_id, self::TWO_FACTOR_SECRET_KEY, true );
2039 if ( ! $secret ) {
2040 // シークレットキーが存在しない場合は何もしない
2041 return [];
2042 }
2043
2044 try {
2045 // トランザクション開始
2046 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2047 $wpdb->query( 'START TRANSACTION' );
2048
2049 // 登録データ作成
2050 // Base32デコードしてバイナリに変換
2051 $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $secret );
2052 // バイナリデータを16進数に変換
2053 $hex_secret = bin2hex( $binary_secret );
2054
2055 // 2fa認証�
2056 報を登録
2057 $this->insert_2fa_auth_method( $user_id, self::USER_AUTH_METHOD_APP, $hex_secret );
2058
2059 // シークレットキーをwp_usermetaから削除
2060 delete_user_meta( $user_id, self::TWO_FACTOR_SECRET_KEY );
2061
2062 // トランザクションコミット
2063 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2064 $wpdb->query( 'COMMIT' );
2065 $auth_info = $this->get_2fa_auth_info( $user_id );
2066 return $auth_info;
2067 } catch ( Exception $e ) {
2068 // エラー発生時はロールバック
2069 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2070 $wpdb->query( 'ROLLBACK' );
2071 return [];
2072 }
2073 }
2074
2075 /**
2076 * AJAX: 秘密鍵を生成(アプリ認証時に使用)
2077 *
2078 * @return void
2079 */
2080 public function ajax_generate_key(): void {
2081 // nonceチェック
2082 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2083
2084 if ( ! current_user_can( 'read' ) ) {
2085 wp_send_json_error();
2086 }
2087
2088 // 秘密鍵を生成
2089 $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
2090
2091 wp_send_json_success( $secret_key_data );
2092 }
2093
2094 /**
2095 * AJAX: 秘密鍵を生成してメール送信(メール認証時に使用)
2096 *
2097 * @return void
2098 */
2099 public function ajax_generate_key_and_send_email(): void {
2100 // nonceチェック
2101 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2102
2103 if ( ! current_user_can( 'read' ) ) {
2104 wp_send_json_error();
2105 }
2106
2107 // 返却JSONレスポンス初期化
2108 $json_response = [
2109 'is_send_email' => false,
2110 'remaining_seconds' => 0,
2111 ];
2112
2113 $user_id = get_current_user_id();
2114
2115 // 最終送信時刻から30秒以�
2116 ならそのまま返す
2117 $able_send_time = $this->get_email_able_send_time( $user_id );
2118 $now_time = time();
2119 $remaining_seconds = $able_send_time - $now_time;
2120 if ( $remaining_seconds > 0 ) {
2121 $json_response['remaining_seconds'] = $remaining_seconds;
2122 wp_send_json_success( $json_response );
2123 }
2124
2125 try {
2126 // 秘密鍵を生成
2127 $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
2128 // 認証コードを生成
2129 $code = CloudSecureWP_Time_Based_One_Time_Password::create_code_for_email( $secret_key_data['hex'], self::AUTH_EMAIL_INTERVAL );
2130 // メール送信
2131 $this->send_code( $user_id, $code, self::AUTH_EMAIL_INTERVAL, 'setting' );
2132 // 最終送信時刻を更新
2133 $this->update_email_able_send_time( $user_id );
2134
2135 // 秘密鍵を保存
2136 $transient_key = '2fa_setup_secret_' . $user_id;
2137 set_transient( $transient_key, $secret_key_data['hex'], 180 );
2138
2139 $json_response['is_send_email'] = true;
2140 $json_response['remaining_seconds'] = self::EMAIL_SEND_LIMIT_TIME;
2141 wp_send_json_success( $json_response );
2142
2143 } catch ( Exception $e ) {
2144 wp_send_json_error( $json_response );
2145 }
2146 }
2147
2148 /**
2149 * AJAX: 認証コードの検証と保存
2150 *
2151 * @return void
2152 */
2153 public function ajax_verify_auth_code(): void {
2154 // nonceチェック
2155 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2156
2157 if ( ! current_user_can( 'read' ) ) {
2158 wp_send_json_error();
2159 }
2160
2161 // 返却JSONレスポンス初期化
2162 $json_response = [
2163 'has_recovery' => false,
2164 ];
2165
2166 $user_id = get_current_user_id();
2167
2168 $method = sanitize_text_field( $_POST['method'] );
2169 $interval = ( $method === 'app' ) ? self::AUTH_APP_INTERVAL : self::AUTH_EMAIL_INTERVAL;
2170 $auth_method = ( $method === 'app' ) ? self::USER_AUTH_METHOD_APP : self::USER_AUTH_METHOD_EMAIL;
2171 $code = isset( $_POST['code'] ) ? sanitize_text_field( $_POST['code'] ) : '';
2172
2173 if ( $method === 'app' ) {
2174 $secret_key = isset( $_POST['secret_key'] ) ? sanitize_text_field( $_POST['secret_key'] ) : '';
2175 } else {
2176 $transient_key = '2fa_setup_secret_' . $user_id;
2177 $secret_key = get_transient( $transient_key );
2178 if ( ! $secret_key ) {
2179 wp_send_json_error( $json_response );
2180 }
2181 }
2182
2183 if ( ! CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, $interval ) ) {
2184 wp_send_json_error( $json_response );
2185 }
2186
2187 // 認証成功 - データベースに保存
2188 try {
2189 global $wpdb;
2190
2191 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2192 $wpdb->query( 'START TRANSACTION' );
2193
2194 // 2fa認証�
2195 報を設定
2196 $this->setting_2fa_auth_info( $user_id, $auth_method, $secret_key );
2197
2198 // メール認証の場合
2199 if ( $method === 'email' ) {
2200 // 一時保存していた秘密鍵を削除
2201 delete_transient( $transient_key );
2202 // メール認証の送信可能時間をリセット
2203 $this->delete_email_able_send_time( $user_id );
2204 }
2205
2206 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2207 $wpdb->query( 'COMMIT' );
2208
2209 // リカバリーコードの設定状況を取得
2210 $has_recovery = $this->has_recovery_codes( $user_id );
2211 $json_response['has_recovery'] = $has_recovery;
2212 wp_send_json_success( $json_response );
2213 } catch ( Exception $e ) {
2214 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
2215 $wpdb->query( 'ROLLBACK' );
2216
2217 wp_send_json_error( $json_response );
2218 }
2219 }
2220
2221 /**
2222 * AJAX: リカバリーコード生成
2223 *
2224 * @return void
2225 */
2226 public function ajax_generate_recovery_codes(): void {
2227 // nonceチェック
2228 check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
2229
2230 if ( ! current_user_can( 'read' ) ) {
2231 wp_send_json_error();
2232 }
2233
2234 // 返却JSONレスポンス初期化
2235 $json_response = [
2236 'codes' => [],
2237 ];
2238
2239 // 2段階認証が設定されているかチェック
2240 try {
2241 $user_id = get_current_user_id();
2242 $auth_info = $this->get_2fa_auth_info( $user_id );
2243 if ( count( $auth_info ) === 0 ) {
2244 wp_send_json_error( $json_response );
2245 }
2246
2247 // リカバリーコードを生成
2248 $codes = CloudSecureWP_Recovery_Codes::initialize_codes( $user_id );
2249 if ( count( $codes ) === 0 ) {
2250 wp_send_json_error( $json_response );
2251 }
2252
2253 // 平文コードを返却(これは1度だけ表示される)
2254 $json_response['codes'] = $codes;
2255 wp_send_json_success( $json_response );
2256
2257 } catch ( Exception $e ) {
2258 wp_send_json_error( $json_response );
2259 }
2260 }
2261
2262 /**
2263 * ユーザーに認証コードをメール送信
2264 *
2265 * @param int $user_id
2266 * @param string $code
2267 * @param int $time_step 時間間隔(秒)
2268 * @param string $status 'login' または 'setting'
2269 *
2270 * @return void
2271 */
2272 private function send_code( int $user_id, string $code, int $time_step, string $status ): void {
2273 $user = get_userdata( $user_id );
2274 if ( ! $user ) {
2275 return;
2276 }
2277 $expire = $time_step / 60;
2278 $to = $user->user_email;
2279 $subject = '2段階認証コード';
2280
2281 if ( $status === 'login' ) {
2282 $body = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . " にログインしようとしています。\n";
2283 $body .= "ログインを完了するには、以下の2段階認証コードを�
2284 �力してください。\n\n";
2285 } else {
2286 $body = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . "で2段階認証を設定しています。\n";
2287 $body .= "セットアップを完了するには、以下の2段階認証コードを�
2288 �力してください。\n\n";
2289 }
2290
2291 $body .= "2段階認証コード: {$code}\n\n";
2292 $body .= "このコードは{$expire}分間有効です。\n";
2293 $body .= "もしこのメールに心当たりがない場合は、第三�
2294 があなたのパスワードを使用してログインを試みた可能性があります。\n";
2295 $body .= "速やかにパスワードを変更することをお勧めします。\n\n";
2296 $body .= "--\n";
2297 $body .= "CloudSecure WP Security\n";
2298
2299 $this->wp_send_mail( $to, esc_html( $subject ), esc_html( $body ) );
2300 }
2301 }
2302