Plugin Directory

Changeset 3462167


Ignore:
Timestamp:
02/16/2026 03:03:52 AM (4 days ago)
Author:
cloudsecure
Message:

2段階認証機能にメール認証およびリカバリーコードの追加
2段階認証によるログインのセキュリティ強化
軽微な修正

Location:
cloudsecure-wp-security/trunk
Files:
3 added
14 edited

Legend:

Unmodified
Added
Removed
  • cloudsecure-wp-security/trunk/assets/css/style.css

    r3317930 r3462167  
    134134/* エラーメッセージと完了メッセージ */
    135135#cloudsecure-wp-security .error-box,
    136 #cloudsecure-wp-security .success-box {
     136#cloudsecure-wp-security .success-box,
     137#cloudsecure-wp-security .info-box {
    137138    position: relative;
    138139    margin-bottom: 12px;
     
    150151
    151152#cloudsecure-wp-security .error-box:last-child,
    152 #cloudsecure-wp-security .success-box:last-child {
     153#cloudsecure-wp-security .success-box:last-child,
     154#cloudsecure-wp-security .info-box:last-child {
    153155    margin-bottom: 24px;
    154156}
    155157
    156158#cloudsecure-wp-security .error-box::after,
    157 #cloudsecure-wp-security .success-box::after {
     159#cloudsecure-wp-security .success-box::after,
     160#cloudsecure-wp-security .info-box::after {
    158161    position: absolute;
    159162    top: 0;
     
    172175}
    173176
     177#cloudsecure-wp-security .info-box::after {
     178    background-color: #72aee6;
     179}
     180
    174181#cloudsecure-wp-security .error-box.red {
    175182    background-color: #fbefef;
     
    178185#cloudsecure-wp-security .success-box.green {
    179186    background-color: #ebf8ee;
     187}
     188
     189#cloudsecure-wp-security .info-box.blue {
     190    background-color: #f0f6fc;
    180191}
    181192
     
    243254
    244255#cloudsecure-wp-security .box-row.pb-0 {
    245 padding-bottom: 0;
     256    padding-bottom: 0;
    246257}
    247258
    248259#cloudsecure-wp-security .box-row.flex-start {
    249 align-items: flex-start;
     260    align-items: flex-start;
    250261}
    251262
    252263#cloudsecure-wp-security .box-row:last-child {
    253 border: none;
     264    border: none;
    254265}
    255266
     
    10531064    }
    10541065}
     1066/* 以下 2段階認証の認証方法設定ページのスタイル */
     1067/* ボタン関連 */
     1068#two-fa-setting-area .button {
     1069    font-size: 14px !important;
     1070    line-height: 0 !important;
     1071    min-height: 32px !important;
     1072}
     1073#two-fa-setting-area .button-gray {
     1074    background-color: #b7b9bc !important;
     1075    border-color: #b7b9bc !important;
     1076    color: #fff !important;
     1077}
     1078
     1079#two-fa-setting-area .button-gray:hover {
     1080    background-color: #9fa2a6 !important;
     1081    border-color: #9fa2a6 !important;
     1082}
     1083
     1084#two-fa-setting-area .button-blue {
     1085    background-color: #329FF1 !important;
     1086    border-color: #329FF1 !important;
     1087    color: #fff !important;
     1088}
     1089#two-fa-setting-area .button-blue:hover {
     1090    background-color: #2088D6 !important;
     1091    border-color: #2088D6 !important;
     1092}
     1093/* コンテンツスタイル関連 */
     1094#cloudsecure-wp-security #two-fa-setting-area .box {
     1095    margin: 0 !important;
     1096    padding: 0 !important;
     1097}
     1098#cloudsecure-wp-security #two-fa-setting-area .box-bottom {
     1099    padding: 0 24px !important;
     1100}
     1101#cloudsecure-wp-security #two-fa-setting-area .box-row {
     1102    padding: 24px 0 !important;
     1103}
     1104#cloudsecure-wp-security #two-fa-setting-area .box-row-title {
     1105    display: flex;
     1106    flex-direction: column;
     1107    gap: 4px;
     1108}
     1109#cloudsecure-wp-security #two-fa-setting-area .status-area {
     1110    display: flex;
     1111    justify-content: space-between;
     1112    align-items: center;
     1113}
     1114/* リカバリーコード説明 */
     1115#cloudsecure-wp-security #two-fa-setting-area .description-recovery-code {
     1116    width: 211px;
     1117    padding: 6px 12px;
     1118    border-radius: 4px;
     1119    background-color: #F7F7F8;
     1120}
     1121#cloudsecure-wp-security #two-fa-setting-area .description-text {
     1122    margin: 0;
     1123    font-size: 11px;
     1124    font-weight: 400;
     1125    color: #646970;
     1126}
     1127/* アイコン付きステータス表示エリア */
     1128#two-fa-setting-area .status-registered-back-none {
     1129    display: inline-flex;
     1130    align-items: center;
     1131    gap: 4px;
     1132    color: #00A32A;
     1133}
     1134#two-fa-setting-area .status-not-registered-back-none {
     1135    display: inline-flex;
     1136    align-items: center;
     1137    gap: 8px;
     1138    color: #d73a49;
     1139}
     1140/* モーダルスタイル関連 */
     1141#two-fa-setting-area .setting-modal,
     1142#two-fa-setting-area .confirm-modal {
     1143    display: none;
     1144    position: fixed;
     1145    z-index: 99999;
     1146    left: 0;
     1147    top: 0;
     1148    width: 100%;
     1149    height: 100dvh;
     1150    background-color: rgba(0, 0, 0, 0.5);
     1151}
     1152#two-fa-setting-area .setting-modal-content,
     1153#two-fa-setting-area .confirm-modal-content {
     1154    position: absolute;
     1155    top: 50%;
     1156    left: 50%;
     1157    transform: translate(-50%, -50%);
     1158    background-color: #fff;
     1159    border-radius: 8px;
     1160    width: 90%;
     1161    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
     1162}
     1163#two-fa-setting-area .setting-modal-content {
     1164    max-width: 650px;
     1165}
     1166#two-fa-setting-area .confirm-modal-content {
     1167    max-width: 400px;
     1168}
     1169#two-fa-setting-area .setting-modal-header {
     1170    display: flex;
     1171    justify-content: space-between;
     1172    align-items: center;
     1173    padding: 16px;
     1174    border-bottom: 1px solid #DBDCDD;
     1175}
     1176#two-fa-setting-area .setting-modal-body {
     1177    display: flex;
     1178    flex-direction: column;
     1179    gap: 24px;
     1180    padding: 24px 24px 32px 24px;
     1181}
     1182#two-fa-setting-area .confirm-modal-body {
     1183    display: flex;
     1184    flex-direction: column;
     1185    gap: 32px;
     1186    padding: 24px;
     1187}
     1188#two-fa-setting-area .confirm-message-area {
     1189    display: flex;
     1190    flex-direction: column;
     1191    gap: 8px;
     1192}
     1193#two-fa-setting-area .confirm-message-body {
     1194    display: flex;
     1195    flex-direction: column;
     1196    gap: 4px;
     1197}
     1198#two-fa-setting-area .modal-btn-area-end{
     1199    display: flex;
     1200    justify-content: flex-end;
     1201    gap: 16px;
     1202}
     1203#two-fa-setting-area .modal-btn-area-center{
     1204    display: flex;
     1205    justify-content: center;
     1206    gap: 16px;
     1207}
     1208#two-fa-setting-area .modal-close {
     1209    cursor: pointer;
     1210}
     1211#cloudsecure-wp-security #two-fa-setting-area h2,
     1212#two-fa-setting-area .h2-text {
     1213    margin: 0;
     1214    font-size: 14px;
     1215}
     1216#two-fa-setting-area .modal-text,
     1217#two-fa-setting-area .status-text {
     1218    font-size: 13px !important;
     1219    margin: 0 !important;
     1220}
     1221#cloudsecure-wp-security #setting-modal .info-box,
     1222#cloudsecure-wp-security #setting-modal .error-box {
     1223    margin: 0 !important;
     1224    font-size: 13px !important;
     1225}
     1226/* 認証方法選択画面モーダル関連 */
     1227.auth-method-options {
     1228    display: flex;
     1229    flex-direction: column;
     1230    gap: 16px;
     1231}
     1232#cloudsecure-wp-security #two-fa-setting-area .circle-radio {
     1233    margin: 0 !important;
     1234    flex-shrink: 0 !important;
     1235}
     1236#cloudsecure-wp-security #two-fa-setting-area .circle-radio+label {
     1237    cursor: pointer !important;
     1238}
     1239#cloudsecure-wp-security #two-fa-setting-area .circle-radio+label::after {
     1240    position: absolute !important;
     1241    top: 50% !important;
     1242    transform: translateY(-50%) !important;
     1243}
     1244
     1245#cloudsecure-wp-security #two-fa-setting-area .circle-radio+label::before {
     1246    position: absolute !important; 
     1247    top: 50% !important;
     1248    transform: translateY(-50%) !important;
     1249}
     1250#two-fa-setting-area .auth-method-icon,
     1251#two-fa-setting-area .auth-method-text {
     1252    vertical-align: middle;
     1253}
     1254/* アプリ認証モーダル関連 */
     1255#two-fa-setting-area .qr-setup-container {
     1256    display: flex;
     1257    gap: 32px;
     1258    padding-left: 8px;
     1259}
     1260#two-fa-setting-area #qrcode {
     1261    display: inline-block;
     1262}
     1263#two-fa-setting-area .setup-key-section {
     1264    display: flex;
     1265    flex-direction: column;
     1266    gap: 24px;
     1267    padding-top: 16px;
     1268}
     1269#two-fa-setting-area .input-code-section {
     1270    display: flex;
     1271    align-items: center;
     1272    gap: 8px;
     1273}
     1274#two-fa-setting-area .verification-code-email::placeholder,
     1275#two-fa-setting-area .verification-code-app::placeholder {
     1276    color: #cccccc;
     1277}
     1278#two-fa-setting-area .verification-code-app {
     1279    width: 160px;
     1280    height: 38px;
     1281    padding: 4px 7px;
     1282    text-align: left;
     1283    border: 1px solid #DBDCDD;
     1284    border-radius: 4px;
     1285}
     1286/* メール認証モーダル関連 */
     1287#two-fa-setting-area .verification-code-email {
     1288    width: 207px;
     1289    height: 38px;
     1290    padding: 4px 7px;
     1291    text-align: left;
     1292    border: 1px solid #DBDCDD;
     1293    border-radius: 4px;
     1294}
     1295#two-fa-setting-area .resend-email-code-active {
     1296    pointer-events: auto;
     1297    color: #2271b1;
     1298    cursor: pointer;
     1299}
     1300#two-fa-setting-area .resend-email-code-inactive {
     1301    pointer-events: none;
     1302    color: #646970;
     1303    cursor: not-allowed;
     1304    text-decoration: none;
     1305}
     1306/* リカバリーコードモーダル関連 */
     1307#two-fa-setting-area .recovery-modal-body {
     1308    display: flex;
     1309    flex-direction: column;
     1310    gap: 24px;
     1311    padding-top: 24px;
     1312}
     1313#two-fa-setting-area .recovery-modal-body-top {
     1314    display: flex;
     1315    flex-direction: column;
     1316    gap: 16px;
     1317    padding: 0 24px;
     1318}
     1319#two-fa-setting-area .recovery-modal-body-bottom {
     1320    display: flex;
     1321    flex-direction: column;
     1322    gap: 10px;
     1323    padding: 16px 24px;
     1324    background-color: #f0f0f1;
     1325    border-radius: 0 0 8px 8px;
     1326}
     1327#two-fa-setting-area .recovery-text-area {
     1328    display: flex;
     1329    flex-direction: column;
     1330    gap: 4px;
     1331}
     1332#two-fa-setting-area .recovery-text {
     1333    display: flex;
     1334    gap: 8px;
     1335}
     1336#two-fa-setting-area .recovery-codes-grid {
     1337    display: grid;
     1338    grid-template-columns: max-content max-content;
     1339    justify-content: center;
     1340    gap: 8px 20px;             
     1341    padding: 12px 0;
     1342    border-radius: 8px;
     1343    border: 1px solid #D2D2D2;
     1344}
     1345#two-fa-setting-area .recovery-code-item {
     1346    background: none;
     1347    border: none;               
     1348    font-family: 'BIZ UDGothic', monospace;
     1349    font-size: 14px;
     1350    text-align: center;
     1351    letter-spacing: 1px;
     1352}
     1353#two-fa-setting-area .black-circle {
     1354    margin-top: 7px;
     1355    width: 4px;
     1356    height: 4px;
     1357    flex-shrink: 0;
     1358    background-color: black;
     1359    border-radius: 50%;
     1360    display: inline-block;
     1361    vertical-align: middle;
     1362}
     1363
     1364/* タブレット対応 */
     1365@media (max-width: 1024px) {
     1366    #cloudsecure-wp-security #two-fa-setting-area .box-row {
     1367        display: flex !important;
     1368        flex-direction: column !important;
     1369        column-gap: 20px !important;
     1370        align-items: flex-start !important;
     1371    }
     1372    #cloudsecure-wp-security #two-fa-setting-area .box-row-title {
     1373        flex-direction: row !important;
     1374        width: 100% !important;
     1375        align-items: center !important;
     1376    }
     1377    #cloudsecure-wp-security #two-fa-setting-area .box-row-content {
     1378        width: 100% !important;
     1379    }
     1380    #two-fa-setting-area .pc-only {
     1381        display: none;
     1382    }
     1383    #cloudsecure-wp-security #two-fa-setting-area .description-recovery-code {
     1384        width: auto;
     1385        display: inline-block;
     1386    }
     1387}
     1388
     1389/* スマートフォン対応 */
     1390@media (max-width: 768px) {
     1391    #cloudsecure-wp-security #two-fa-setting-area .box-row-title {
     1392        flex-direction: column !important;
     1393        width: 100% !important;
     1394        align-items: flex-start !important;
     1395    }
     1396    #cloudsecure-wp-security #two-fa-setting-area .status-area {
     1397        display: flex;
     1398        flex-direction: column;
     1399        gap: 16px;
     1400    }
     1401    #cloudsecure-wp-security #two-fa-setting-area .status-area-text,
     1402    #cloudsecure-wp-security #two-fa-setting-area .status-area-btn {
     1403        margin-right: auto !important;
     1404    }
     1405    #two-fa-setting-area .setting-modal-body {
     1406        display: flex;
     1407        flex-direction: column;
     1408        gap: 24px;
     1409        padding: 16px 16px 24px 16px;
     1410        max-height: 510px;
     1411        overflow: auto;
     1412    }
     1413    #two-fa-setting-area .qr-setup-container {
     1414        display: flex;
     1415        flex-direction: column;
     1416        align-items: center;
     1417        gap: 16px;
     1418    }               
     1419    #two-fa-setting-area .setup-key-section {
     1420        display: flex;
     1421        flex-direction: column;
     1422        gap: 16px;
     1423        text-align: center;
     1424        padding: 0;
     1425    }
     1426    #two-fa-setting-area .verification-code-email {
     1427        width: 180px;
     1428        height: 38px;
     1429        padding: 4px 7px;
     1430        text-align: left;
     1431        border: 1px solid #DBDCDD;
     1432        border-radius: 4px;
     1433    }
     1434    #two-fa-setting-area .recovery-modal-body {
     1435        display: flex;
     1436        flex-direction: column;
     1437        gap: 0;
     1438        padding: 0;
     1439    }
     1440    #two-fa-setting-area .recovery-modal-body-top {
     1441        display: flex;
     1442        flex-direction: column;
     1443        gap: 24px;
     1444        padding: 16px 16px 24px 16px;
     1445    }
     1446}
  • cloudsecure-wp-security/trunk/cloudsecure-wp.php

    r3444508 r3462167  
    1414 * Plugin URI:    https://wpplugin.cloudsecure.ne.jp/cloudsecure_wp_security
    1515 * Description:   管理画面とログインURLをサイバー攻撃から守る、安心の国産・日本語対応プラグインです。かんたんな設定を行うだけで、不正アクセスや不正ログインからあなたのWordPressを保護し、セキュリティが向上します。また、各機能の有効・無効(ON・OFF)や設定などをお好みにカスタマイズし、いつでも保護状態を管理できます。
    16  * Version:       1.3.24
     16 * Version:       1.4.0
    1717 * Requires PHP:  7.1
    1818 * Author:        CloudSecure,Inc.
  • cloudsecure-wp-security/trunk/modules/admin/two-factor-authentication-registration.php

    r3438297 r3462167  
    88    private $two_factor_authentication;
    99    /**
    10      * 管理画面にレンダリングするキー
     10     * 認証方法
     11     *
     12     * @var bool
     13     */
     14    private $auth_method;
     15    /**
     16     * リカバリーコードが登録済みかどうか
     17     *
     18     * @var bool
     19     */
     20    private $is_registered_recovery;
     21    /**
     22     * 使用可能なリカバリーコードの数
     23     *
     24     * @var bool
     25     */
     26    private $recovery_cnt;
     27    /**
     28     * ユーザのメールアドレス
    1129     *
    1230     * @var string
    1331     */
    14     private $default_key;
    15     /**
    16      * 秘密鍵が登録済みかどうか
    17      *
    18      * @var bool
    19      */
    20     private $is_registered;
     32    private $mail_address;
    2133
    2234    function __construct( array $info, CloudSecureWP_Two_Factor_Authentication $two_factor_authentication ) {
     
    3143     */
    3244    public function prepare_view_data(): void {
    33         $stored_secret       = get_user_option( 'cloudsecurewp_two_factor_authentication_secret' );
    34         $this->is_registered = ! empty( $stored_secret );
    35         $this->default_key   = '';
    36         if ( ! empty( $_POST ) && check_admin_referer( $this->two_factor_authentication->get_feature_key() . '_csrf' ) ) {
    37             if ( ! empty( $_POST['key'] ) ) {
    38                 $this->default_key = sanitize_text_field( $_POST['key'] );
    39             }
    40             if ( empty( $_POST['key'] ) ) {
    41                 $this->errors[] = 'セットアップキーを保存できませんでした。<br />セットアップキーを生成してください。';
    42 
    43                 return;
    44             }
    45             if ( empty( $_POST['google_authenticator_code'] ) ) {
    46                 $this->errors[] = 'セットアップキーを保存できませんでした。<br />QRコードをGoogle Authenticator アプリケーションでスキャンし、認証コードを入力してください。';
    47 
    48                 return;
    49             }
    50             $key                       = sanitize_text_field( $_POST['key'] );
    51             $google_authenticator_code = sanitize_text_field( $_POST['google_authenticator_code'] );
    52             if ( CloudSecureWP_Time_Based_One_Time_Password::verify_code( $key, $google_authenticator_code, 2 ) ) {
    53                 update_user_option( get_current_user_id(), 'cloudsecurewp_two_factor_authentication_secret', $key );
    54                 $this->messages[] = 'セットアップキーを保存しました。';
    55                 // 保存後は秘密鍵を非表示にする
    56                 $this->default_key   = '';
    57                 $this->is_registered = true;
    58             } else {
    59                 $this->errors[] = 'セットアップキーを保存できませんでした。<br />認証コードが間違っています。';
     45        $user_id   = get_current_user_id();
     46        $auth_info = $this->two_factor_authentication->get_2fa_auth_info( $user_id );
     47
     48        $this->auth_method            = $this->two_factor_authentication::USER_AUTH_METHOD_NONE;
     49        $this->is_registered_recovery = false;
     50        $this->recovery_cnt           = 0;
     51        $this->mail_address           = $this->two_factor_authentication->mask_email( $user_id );
     52
     53        if ( count( $auth_info ) === 0 ) {
     54            $auth_info = $this->two_factor_authentication->repair_migration_gaps( $user_id );
     55        }
     56       
     57        if ( count( $auth_info ) > 0 ) {
     58            $this->auth_method = intVal( $auth_info['method'] );
     59
     60            if ( ! is_null( $auth_info['recovery'] ) ) {
     61                $this->is_registered_recovery = true;
     62                $this->recovery_cnt           = count( $auth_info['recovery'] );
     63            }
     64        }
     65
     66        if ( isset( $_POST['message'] ) ) {
     67            $message = sanitize_text_field( $_POST['message'] ) ?? '';
     68            if ( ! empty( $message ) ) {
     69                $this->messages[] = $message;
     70            }
     71        }
     72        if ( isset( $_POST['error'] ) ) {
     73            $error = sanitize_text_field( $_POST['error'] ) ?? '';
     74            if ( ! empty( $error ) ) {
     75                $this->errors[] = $error;
    6076            }
    6177        }
     
    6884        ?>
    6985        <div class="title-block mb-12">
    70             <h1 class="title-block-title">デバイス登録 - 2段階認証</h1>
     86            <h1 class="title-block-title">2段階認証の設定</h1>
    7187            <p class="title-block-small-text">この機能のマニュアルは<a class="title-block-link" target="_blank"
    7288                                                                href="https://wpplugin.cloudsecure.ne.jp/cloudsecure_wp_security/two_factor_authentication.php">こちら</a>
     
    7490        </div>
    7591        <div class="title-bottom-text">
    76             2段階認証機能をアクティブにするには、セットアップキーを生成し、<a class="title-block-link" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Google Authenticator</a>  アプリケーションでQRコードを読み込んでください。<br />
    77             QRコードを読み込めない場合、<a class="title-block-link" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Google Authenticator</a>  アプリケーションにセットアップキーを入力してください
     92            2段階認証に使用する認証方法を設定します。<br />
     93            Google Authenticator またはメール認証のいずれかを使用できます
    7894        </div>
    7995        <?php
     
    85101    protected function page(): void {
    86102        ?>
    87         <form method="post">
     103        <div id="two-fa-setting-area">
    88104            <div class="box">
    89105                <div class="box-bottom">
    90                     <div class="box-row flex-start">
    91                         <div class="box-row-title not-label pt-12">
    92                             <label for="key">設定状況</label>
     106                    <div class="box-row">
     107                        <div class="box-row-title not-label">
     108                            <label for="key">設定方法</label>
    93109                        </div>
    94110                        <div class="box-row-content">
    95                                 <div class="flex">
    96                                     <?php if ( $this->is_registered ) : ?>
    97                                         <p>設定済</p>
     111                            <div id="auth-method-status" class="status-area" data-auth-method="<?php echo esc_attr( $this->auth_method ); ?>">
     112                                <?php if ( $this->auth_method === $this->two_factor_authentication::USER_AUTH_METHOD_APP ) : ?>
     113                                    <div class="status-area-text">                                     
     114                                        <p class="status-text">Authenticatorアプリ</p>
     115                                    </div>
     116                                <?php elseif ( $this->auth_method === $this->two_factor_authentication::USER_AUTH_METHOD_EMAIL ) : ?>
     117                                    <div class="status-area-text">
     118                                        <p class="status-text">メール認証</p>
     119                                    </div>
     120                                <?php else : ?>
     121                                    <div class="status-not-registered-back-none status-area-text">
     122                                        <span class="dashicons dashicons-warning"></span>
     123                                        <p class="status-text">未設定</p>
     124                                    </div>
     125                                <?php endif; ?>
     126                                <button id="register-method" type="button" class="status-area-btn button">
     127                                    <?php echo ( $this->auth_method === $this->two_factor_authentication::USER_AUTH_METHOD_NONE ? '設定' : '設定を変更' ); ?>
     128                                </button>
     129                            </div>
     130                        </div>
     131                    </div>
     132                    <div class="box-row">
     133                        <div class="box-row-title not-label">
     134                            <label for="key">リカバリーコード</label>
     135                            <div class="description-recovery-code">
     136                                <p class="description-text">デバイス紛失時などの緊急時、<br class="pc-only">認証代わりに使用できるコードです。</p>
     137                            </div>
     138                        </div>
     139                        <div class="box-row-content">
     140                            <div id="recovery-code-status" class="status-area" data-is-registered-recovery="<?php echo esc_attr( $this->is_registered_recovery ? 1 : 0 ); ?>">
     141                                <div class="status-area-text">
     142                                    <?php if ( $this->is_registered_recovery ) : ?>     
     143                                        <p class="status-text">生成済み(残り<?php echo esc_html( $this->recovery_cnt ); ?>個)</p>
    98144                                    <?php else : ?>
    99                                         <p>未設定</p>
     145                                        <div class="status-not-registered-back-none status-area-text">
     146                                            <span class="dashicons dashicons-warning"></span>
     147                                            <p class="status-text">未生成</p>
     148                                        </div>
     149                                        <?php if ( $this->auth_method === $this->two_factor_authentication::USER_AUTH_METHOD_NONE ) : ?>
     150                                            <p class="status-text description">※認証方法の設定完了後、生成可能になります。</p>
     151                                        <?php endif; ?>
    100152                                    <?php endif; ?>
    101153                                </div>
    102                         </div>
    103                     </div>
    104                     <div class="box-row flex-start">
    105                         <div class="box-row-title not-label pt-12">
    106                             <label for="key">セットアップキー</label>
    107                         </div>
    108                         <div class="box-row-content">
    109                             <div class="flex">
    110                                 <input type="text" id="key" name="key"
    111                                         value="<?php echo esc_attr( $this->default_key ); ?>" maxlength="16"
    112                                         readonly/>
    113                                 <button id="generate_key" type="button" class="button button-large">
    114                                     セットアップキーを生成
     154                                <button id="generate-recovery" type="button" class="status-area-btn button" <?php echo ( $this->auth_method === $this->two_factor_authentication::USER_AUTH_METHOD_NONE ? 'disabled' : '' ); ?>>
     155                                    <?php echo ( $this->is_registered_recovery ? '再生成' : '生成' ); ?>
    115156                                </button>
    116                             </div>
    117                             <div id="qrcode_container" hidden>
    118                                 <div id="qrcode"></div>
    119                                 <div class="flex onetime-password-container">
    120                                     <label for="google_authenticator_code">認証コード:</label>
    121                                     <input type="text" id="google_authenticator_code" name="google_authenticator_code"
    122                                             maxlength="6"/>
    123                                 </div>
    124157                            </div>
    125158                        </div>
     
    127160                </div>
    128161            </div>
    129             <div id="submit-btn-area">
    130                 <?php $this->nonce_wp( $this->two_factor_authentication->get_feature_key() ); ?>
    131                 <p id="guide_message" hidden>アプリケーションに表示された6桁の認証コードを入力し、「変更を保存」ボタンをクリックしてください。</p>
    132                 <?php $this->submit_button_wp(); ?>
    133             </div>
    134         </form>
    135         <style>
    136             #generate_key {
    137                 margin-left: 16px;
    138             }
    139 
    140             #qrcode {
    141                 margin: 32px 32px 16px;
    142             }
    143 
    144             .onetime-password-container {
    145                 margin-left: -20px;
    146             }
    147         </style>
     162
     163            <!-- 設定モーダル -->
     164            <div id="setting-modal" class="setting-modal" style="display: none;" data-setting-status="" data-regist-status="">
     165                <div class="setting-modal-content">
     166                    <div class="setting-modal-header">
     167                        <h2 id="setting-modal-title">
     168                            <!-- タイトル -->
     169                        </h2>
     170                        <span class="dashicons dashicons-no-alt modal-close"></span>
     171                    </div>
     172                    <div id="setting-modal-body" class="setting-modal-body">
     173                        <!-- テンプレート -->
     174                    </div>
     175                </div>
     176            </div>
     177
     178            <!-- 確認モーダル -->
     179            <div id="confirm-modal" class="confirm-modal" style="display: none;" data-confirm-status="">
     180                <div class="confirm-modal-content">
     181                    <div class="confirm-modal-body">
     182                        <div class="confirm-message-area">
     183                            <h2 id="confirm-message-title">
     184                                <!-- タイトル -->
     185                            </h2>
     186                            <div id="confirm-message-body" class="confirm-message-body">
     187                                <!-- 本文 -->
     188                            </div>
     189                        </div>
     190                        <div id="confirm-modal-btn" class="modal-btn-area-end">
     191                            <button type="button" id="confirm-modal-cancel" class="button button-gray modal-close style-cancel">キャンセル</button>
     192                            <button type="button" id="confirm-modal-ok" class="button button-blue modal-next style-next">OK</button>
     193                        </div>
     194                    </div>
     195                </div>
     196            </div>
     197
     198            <?php $this->nonce_wp( $this->two_factor_authentication->get_feature_key() ); ?>
     199            <input type="hidden" id="ajax-url" value="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" />
     200        </div>
     201
     202        <!-- 認証方法選択テンプレート -->
     203        <template id="select-method-modal">
     204            <p class="modal-text">認証方法を選択してください。</p>
     205            <div class="auth-method-options">
     206                <div class="auth-method-option">
     207                    <input type="radio" class="circle-radio" id="auth_method_app" name="auth_method_select" value="app" checked>
     208                    <label for="auth_method_app" class="auth-method-label">
     209                        <img src="<?php echo esc_url( plugin_dir_url( dirname( __FILE__, 2 ) ) . 'assets/images/icon_mobile.svg?v=1' ); ?>" alt="スマホアイコン" class="auth-method-icon">
     210                        <span class="auth-method-text">Google Authenticatorで認証する</span>
     211                    </label>
     212                </div>
     213                <div class="auth-method-option">
     214                    <input type="radio" class="circle-radio" id="auth_method_email" name="auth_method_select" value="email">
     215                    <label for="auth_method_email" class="auth-method-label">
     216                        <img src="<?php echo esc_url( plugin_dir_url( dirname( __FILE__, 2 ) ) . 'assets/images/icon_mail.svg?v=1' ); ?>" alt="メールアイコン" class="auth-method-icon">
     217                        <span class="auth-method-text">メールアドレスで認証する</span>
     218                    </label>
     219                </div>
     220            </div>
     221            <div class="modal-btn-area-end">
     222                <button type="button" id="setting-modal-cancel" class="button button-gray modal-close">キャンセル</button>
     223                <button type="button" id="setting-modal-next" class="button button-blue modal-next">次へ</button>
     224            </div>
     225        </template>
     226
     227        <!-- Google Authenticator設定テンプレート -->
     228        <template id="app-auth-modal">
     229            <div id="message-area" class="info-box blue" style="display: none;"></div>
     230            <div id="error-area" class="error-box red" style="display: none;"></div>
     231            <p class="modal-text">
     232                <a class="title-block-link" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Google Authenticator</a> で以下のQRコードを読み込み、表示された認証コードを入力してください。QRコードを読み込めない場合は、セットアップキーを手動で入力してください。
     233            </p>
     234            <div class="qr-setup-container">
     235                <div id="qrcode_container" class="qr-code-section">
     236                    <div id="qrcode">
     237                        <!-- QRコード表示エリア -->
     238                    </div>
     239                </div>
     240                <div class="setup-key-section">
     241                    <div class="generate-key-section" style="text-align: left;">
     242                        <p class="modal-text">セットアップキー:<span id="setup_key_display"></span></p>
     243                        <p class="modal-text" style="color: #2271b1;"><span class="dashicons dashicons-update"></span><a href="#" id="regenerate-key">新しいキーを生成</a></p>
     244                    </div>
     245                    <div class="input-code-section">
     246                        <label for="verification-code">認証コード(6桁)</label>
     247                        <input type="text" id="verification-code" name="verification_code" class="verification-code-app" placeholder="例)123456" />
     248                    </div>
     249                </div>
     250            </div>
     251            <div class="modal-btn-area-end">
     252                <input type="hidden" id="secret-key" value="" />
     253                <button type="button" id="setting-modal-cancel" class="button button-gray modal-close">キャンセル</button>
     254                <button type="button" id="setting-modal-next" class="button button-blue modal-next">設定を完了</button>
     255            </div>
     256        </template>
     257
     258        <!-- メール認証設定テンプレート -->
     259        <template id="email-auth-modal">
     260            <div id="message-area" class="info-box blue" style="display: none;"></div>
     261            <div id="error-area" class="error-box red" style="display: none;"></div>
     262            <p class="modal-text">
     263                ログイン時に登録されているメールアドレスに認証コードを送信しました。<br />
     264                受信した認証コードを入力してください。<br />
     265                メールアドレス: <strong><?php echo esc_html( $this->mail_address ); ?></strong>
     266            </p>
     267            <div class="input-code-section">
     268                <label for="verification-code">認証コード(6桁)</label>
     269                <input type="text" id="verification-code" name="verification_code" class="verification-code-email" placeholder="例)123456" />
     270            </div>
     271            <p class="description">
     272                ※メールが届かない場合は、迷惑メールフォルダをご確認ください。<br />
     273                 メールが見つからない場合は、<a href="#" id="resend-email-code" class="resend-email-code-inactive">認証コードを再送信</a> できます。<span class="countdown-message"></span>
     274            </p>
     275            <div class="modal-btn-area-end">
     276                <input type="hidden" id="secret-key" value="" />
     277                <button type="button" id="setting-modal-cancel" class="button button-gray modal-close">キャンセル</button>
     278                <button type="button" id="setting-modal-next" class="button button-blue modal-next">設定を完了</button>
     279            </div>
     280        </template>
     281
     282        <!-- リカバリーコードの作成案内テンプレート -->
     283        <template id="suggest-recovery-modal">
     284            <div class="status-registered-back-none">
     285                <span class="dashicons dashicons-yes-alt"></span>
     286                <p class="h2-text"><strong>2段階認証が有効になりました。</strong></p>
     287            </div>
     288            <div class="recovery-text-area">
     289                <p class="modal-text">続けて、<strong>リカバリーコードを生成し、安全な場所に保管してください。</strong></p>
     290                <p class="modal-text">未生成の場合、デバイス紛失時などにログインできなくなる恐れがあります。</p>
     291            </div>
     292            <div class="modal-btn-area-end">
     293                <button type="button" id="setting-modal-cancel" class="button button-gray modal-close">あとで生成</button>
     294                <button type="button" id="setting-modal-next" class="button button-blue modal-next">リカバリーコードを生成</button>
     295            </div>
     296        </template>
     297       
     298        <!-- リカバリーコードテンプレート(成功) -->
     299        <template id="recovery-code-modal-success">
     300            <div class="recovery-modal-body-top">
     301                <div class="recovery-text-area">
     302                    <p class="modal-text" style="margin-bottom: 4px;">リカバリーコードを生成しました。</p>
     303                    <div class="recovery-text">
     304                        <div class="black-circle"></div><p class="modal-text">この画面を閉じると<strong>コードは再表示できません</strong>。</p>
     305                    </div>
     306                    <div class="recovery-text">
     307                        <div class="black-circle"></div><p class="modal-text">各コードは1回のみ使用できます。</p>
     308                    </div>
     309                    <div class="recovery-text">
     310                        <div class="black-circle"></div><p class="modal-text">必ずコピーまたはダウンロードして安全な場所に保管してください。</p>
     311                    </div>
     312                </div>
     313                <div id="recovery-codes-container" class="recovery-codes-grid">
     314                    <!-- リカバリーコード表示エリア -->
     315                </div>
     316                <div class="modal-btn-area-center">
     317                    <button type="button" id="recovery-code-copy" class="button button-blue">コピー</button>
     318                    <button type="button" id="recovery-code-download" class="button button-blue">ダウンロード</button>
     319                </div>
     320            </div>
     321            <div class="recovery-modal-body-bottom">
     322                <div class="modal-btn-area-end">
     323                    <button type="button" id="recovery-modal-close" class="button modal-close">閉じる</button>
     324                </div>
     325            </div>
     326        </template>
     327
     328        <!-- リカバリーコードテンプレート(失敗) -->
     329        <template id="recovery-code-modal-failure">
     330            <div class="recovery-modal-body-top">
     331                <p class="modal-text">エラーが発生しました。しばらく待ってから再度お試しください。</p>
     332            </div>
     333            <div class="recovery-modal-body-bottom">
     334                <div class="modal-btn-area-end">
     335                    <button type="button" id="recovery-modal-close" class="button modal-close">閉じる</button>
     336                    <button type="button" id="recovery-modal-retry" class="button modal-next">再試行</button>
     337                </div>
     338            </div>
     339        </template>
     340
    148341        <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"
    149342                type="text/javascript"></script>
    150343        <script type="text/javascript">
    151             function quintetCount(buff) {
    152                 const quintets = Math.floor(buff.length / 5);
    153                 return buff.length % 5 === 0 ? quintets : quintets + 1;
    154             }
    155 
    156             function bufferToBase32(plain) {
    157                 let i = 0;
    158                 let j = 0;
    159                 let shiftIndex = 0;
    160                 let digit = 0;
    161                 const charTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    162                 const encoded = new Array(quintetCount(plain) * 8);
    163 
    164                 /* byte by byte は、quintet by quintet ほどきれいではありませんが、テストは少し速くなります。 再訪する必要があります。 */
    165                 while (i < plain.length) {
    166                     const current = plain[i];
    167 
    168                     if (shiftIndex > 3) {
    169                         digit = current & (0xff >> shiftIndex);
    170                         shiftIndex = (shiftIndex + 5) % 8;
    171                         digit = (digit << shiftIndex) | ((i + 1 < plain.length) ?
    172                             plain[i + 1] : 0) >> (8 - shiftIndex);
    173                         i++;
     344            const messageRegist           = '認証方法の設定が完了しました。';
     345            const messageUpdate           = '認証方法の変更が完了しました。';
     346            const messageError            = 'エラーが発生しました。しばらく待ってから再度お試しください。';
     347            const messageAppCodeEmpty     = '認証に失敗しました。<br />QRコードをGoogle Authenticator でスキャンし、認証コードを入力してください。';
     348            const messageAppCodeInvalid   = '認証に失敗しました。コードが正しいか確認し、もう一度お試しください。';
     349            const messageEmailCodeEmpty   = '認証に失敗しました。メールで送信された認証コードを入力してください';
     350            const messageEmailCodeInvalid = '認証に失敗しました。認証コードが間違っているか、有効期限が切れています。';
     351            const messageEmailSent        = '認証コードを再送信しました。メールをご確認ください。';
     352            const ajaxUrl                 = document.getElementById('ajax-url').value;
     353
     354            let emailSentTime    = null;
     355            let countdownTimeout = null;
     356            let qrcodeInstance   = null;
     357
     358            // モーダル内のすべてのボタンを非活性処理
     359            function disableModalButtons() {
     360                const settingModal = document.getElementById('setting-modal');
     361                const confirmModal = document.getElementById('confirm-modal');
     362               
     363                if (settingModal && settingModal.style.display === 'block') {
     364                    const buttons = settingModal.querySelectorAll('button');
     365                    buttons.forEach(btn => {
     366                        btn.disabled = true;
     367                    });
     368                    // ×ボタンも非活性化
     369                    const closeIcon = settingModal.querySelector('.dashicons.modal-close');
     370                    if (closeIcon) {
     371                        closeIcon.style.pointerEvents = 'none';
     372                        closeIcon.style.opacity = '0.5';
     373                    }
     374                }
     375               
     376                if (confirmModal && confirmModal.style.display === 'block') {
     377                    const buttons = confirmModal.querySelectorAll('button');
     378                    buttons.forEach(btn => {
     379                        btn.disabled = true;
     380                    });
     381                    // ×ボタンも非活性化
     382                    const closeIcon = confirmModal.querySelector('.dashicons.modal-close');
     383                    if (closeIcon) {
     384                        closeIcon.style.pointerEvents = 'none';
     385                        closeIcon.style.opacity = '0.5';
     386                    }
     387                }
     388            }
     389            // モーダル内のすべてのボタンを活性処理
     390            function enableModalButtons() {
     391                const settingModal = document.getElementById('setting-modal');
     392                const confirmModal = document.getElementById('confirm-modal');
     393               
     394                if (settingModal && settingModal.style.display === 'block') {
     395                    const buttons = settingModal.querySelectorAll('button');
     396                    buttons.forEach(btn => {
     397                        btn.disabled = false;
     398                    });
     399                    // ×ボタンも活性化
     400                    const closeIcon = settingModal.querySelector('.dashicons.modal-close');
     401                    if (closeIcon) {
     402                        closeIcon.style.pointerEvents = 'auto';
     403                        closeIcon.style.opacity = '1';
     404                    }
     405                }
     406               
     407                if (confirmModal && confirmModal.style.display === 'block') {
     408                    const buttons = confirmModal.querySelectorAll('button');
     409                    buttons.forEach(btn => {
     410                        btn.disabled = false;
     411                    });
     412                    // ×ボタンも活性化
     413                    const closeIcon = confirmModal.querySelector('.dashicons.modal-close');
     414                    if (closeIcon) {
     415                        closeIcon.style.pointerEvents = 'auto';
     416                        closeIcon.style.opacity = '1';
     417                    }
     418                }
     419            }
     420            // ページリロード処理
     421            function reloadPageWithMessage(message, error) {
     422                const form = document.createElement('form');
     423                form.method = 'POST';
     424                form.action = window.location.href;
     425               
     426                const inputMessage = document.createElement('input');
     427                inputMessage.type = 'hidden';
     428                inputMessage.name = 'message';
     429                inputMessage.value = message;
     430
     431                const inputError = document.createElement('input');
     432                inputError.type = 'hidden';
     433                inputError.name = 'error';
     434                inputError.value = error;
     435               
     436                form.appendChild(inputMessage);
     437                form.appendChild(inputError);
     438                document.body.appendChild(form);
     439                form.submit();
     440            }
     441            // モーダル内にメッセージ表示処理
     442            function showModalMessage(message) {
     443                const modalBody   = document.getElementById('setting-modal-body');
     444                const messageArea = document.getElementById('message-area');
     445                const errorArea   = document.getElementById('error-area');
     446                if (errorArea) {
     447                    errorArea.innerHTML = '';
     448                    errorArea.style.display = 'none';
     449                }
     450                if (messageArea) {
     451                    messageArea.innerHTML = message;
     452                    messageArea.style.display = 'block';
     453                }
     454                setTimeout(() => {
     455                    modalBody.scrollTop = 0;
     456                }, 0);
     457            }
     458            // モーダル内にエラー表示処理
     459            function showModalError(message) {
     460                const modalBody   = document.getElementById('setting-modal-body');
     461                const messageArea = document.getElementById('message-area');
     462                const errorArea   = document.getElementById('error-area');
     463                if (messageArea) {
     464                    messageArea.innerHTML = '';
     465                    messageArea.style.display = 'none';
     466                }
     467                if (errorArea) {
     468                    errorArea.innerHTML = message;
     469                    errorArea.style.display = 'block';
     470                }
     471                setTimeout(() => {
     472                    modalBody.scrollTop = 0;
     473                }, 0);
     474            }
     475            // 再送信リンクの有効/無効の制御処理
     476            function updateResendLinkState() {
     477                const resendLink = document.getElementById('resend-email-code');
     478                const countdownSpan = document.querySelector('.countdown-message');
     479               
     480                if (!resendLink || !emailSentTime || !countdownSpan) {
     481                    return;
     482                }
     483
     484                // 現在時刻と送信可能時刻を比較して残り秒数を計算
     485                const now = Date.now();
     486                const remaining = Math.ceil((emailSentTime - now) / 1000);
     487
     488                if (remaining > 0) {
     489                    // リンクを非活性化してカウントダウン表示
     490                    resendLink.className = 'resend-email-code-inactive';
     491                    countdownSpan.textContent = ` (${remaining}秒後)`;
     492                   
     493                    // 1秒後に再度更新
     494                    countdownTimeout = setTimeout(updateResendLinkState, 1000);
     495                } else {
     496                    // リンクを有効化してカウントダウン非表示
     497                    resendLink.className = 'resend-email-code-active';
     498                    countdownSpan.textContent = '';
     499
     500                    // タイマーを停止(これ以上更新不要)
     501                    countdownTimeout = null;
     502                }
     503            }
     504            // カウントダウンタイマーを開始処理
     505            function startCountdown() {
     506                // 既存のタイマーがあればクリア
     507                if (countdownTimeout) {
     508                    clearTimeout(countdownTimeout);
     509                    countdownTimeout = null;
     510                }
     511               
     512                // 即座に状態を更新
     513                updateResendLinkState();
     514            }
     515            // QRコードを生成・表示処理
     516            function displayQRCode(key) {
     517                const qrcodeElement = document.getElementById('qrcode');
     518                const setupKeyDisplay = document.getElementById('setup_key_display');
     519               
     520                if (!qrcodeElement) {
     521                    console.error('QRコード要素が見つかりません');
     522                    return
     523                }
     524
     525                // 既存のQRコードを完全にクリア
     526                qrcodeElement.innerHTML = '';
     527                qrcodeInstance = null;
     528
     529                // 新しいQRCodeインスタンスを作成
     530                qrcodeInstance = new QRCode(qrcodeElement, {
     531                    correctLevel: QRCode.CorrectLevel.M,
     532                    width: 200,
     533                    height: 200
     534                });
     535
     536                // ユーザー名を取得
     537                const nameElement = document.querySelector('.display-name');
     538                const name = nameElement ? nameElement.textContent : 'User';
     539
     540                // QRコードを生成
     541                const otpauthUrl = `otpauth://totp/${encodeURIComponent(name)}?secret=${key}&issuer=${location.hostname}`;
     542                qrcodeInstance.makeCode(otpauthUrl);
     543
     544                // セットアップキーを表示
     545                if (setupKeyDisplay) {
     546                    setupKeyDisplay.textContent = key;
     547                }
     548
     549                // 認証コード入力欄をクリア&フォーカス
     550                setTimeout(function() {
     551                    const codeInput = document.getElementById('verification-code');
     552                    if (codeInput) {
     553                        codeInput.value = '';
     554                        codeInput.focus();
     555                    }
     556                }, 100);
     557            }
     558            // メール認証コードを送信処理
     559            function generateSecretKeyAndsendEmail(resendFlg) {
     560                const nonce = document.getElementById('_wpnonce').value;
     561               
     562                if (!resendFlg) {
     563                    disableModalButtons();
     564                }
     565               
     566                // AJAXでサーバーに送信
     567                jQuery.ajax({
     568                    url: ajaxUrl,
     569                    type: 'POST',
     570                    data: {
     571                        action: 'cloudsecurewp_generate_key_and_send_email',
     572                        nonce: nonce,
     573                    }
     574                })
     575                .done(function(response) {
     576                    if (response.success) {
     577                        if (resendFlg === false) {
     578                            openAuthModal('email');
     579                        }
     580                        if (resendFlg && response.data.is_send_email) {
     581                            showModalMessage(messageEmailSent);
     582                        }
     583                        // APIから受け取った残り秒数を使って送信可能時刻を計算
     584                        const remainingSeconds = parseInt(response.data.remaining_seconds, 10);
     585                        emailSentTime = Date.now() + (remainingSeconds * 1000);
     586                        setTimeout(function() {
     587                            startCountdown();
     588                            const codeInput = document.getElementById('verification-code');
     589                            if (codeInput) {
     590                                codeInput.value = '';
     591                                codeInput.focus();
     592                            }
     593                            if (!resendFlg) {
     594                                enableModalButtons();
     595                            }
     596                        }, 100);
    174597                    } else {
    175                         digit = (current >> (8 - (shiftIndex + 5))) & 0x1f;
    176                         shiftIndex = (shiftIndex + 5) % 8;
    177                         if (shiftIndex === 0) {
    178                             i++;
     598                        reloadPageWithMessage('', messageError);                                               
     599                    }
     600                    return;
     601                })
     602                .fail(function(xhr) {
     603                    reloadPageWithMessage('', messageError);
     604                    return;
     605                });
     606            }
     607            // 秘密鍵生成処理(サーバー側から取得)
     608            function generateSecretKey(reGenerateFlg) {
     609                const nonce = document.getElementById('_wpnonce').value;
     610
     611                disableModalButtons();
     612
     613                // AJAXでサーバーから秘密鍵を取得
     614                jQuery.ajax({
     615                    url: ajaxUrl,
     616                    type: 'POST',
     617                    data: {
     618                        action: 'cloudsecurewp_generate_key',
     619                        nonce: nonce
     620                    }
     621                })
     622                .done(function(response) {
     623                    if (response.success) {
     624                        const hexKey    = response.data.hex;
     625                        const base32Key = response.data.base32;
     626
     627                        // モーダルを表示
     628                        if (reGenerateFlg === false) {
     629                            openAuthModal('app');
    179630                        }
    180                     }
    181 
    182                     encoded[j] = charTable[digit];
    183                     j++;
    184                 }
    185 
    186                 for (i = j; i < encoded.length; i++) {
    187                     encoded[i] = '=';
    188                 }
    189 
    190                 return encoded.join('');
    191             }
    192 
    193             function generateKey() {
    194                 return bufferToBase32(crypto.getRandomValues(new Uint8Array(10)))
    195             }
    196 
    197             const qrcode = new QRCode('qrcode', {correctLevel: QRCode.CorrectLevel.M})
    198 
    199             function showQRCode(key) {
    200                 document.getElementById('qrcode_container').hidden = false
    201                 document.getElementById('guide_message').hidden = false
    202                 const name = document.querySelector('.display-name').textContent
    203 
    204                 // https://github.com/google/google-authenticator/wiki/Key-Uri-Format
    205                 qrcode.makeCode(`otpauth://totp/${encodeURIComponent(name)}?secret=${key}&issuer=${location.hostname}`)
    206 
    207                 document.getElementById('google_authenticator_code').focus()
    208             }
    209 
    210             // ページ読み込み時に秘密鍵が入力されている場合はQRコードを表示
    211             // (認証コードエラー時の再表示のため)
    212             const secretKey = document.getElementById('key').value
    213             if (secretKey) {
    214                 showQRCode(secretKey)
    215             }
    216 
    217             document.getElementById('generate_key').addEventListener('click', () => {
    218                 const newKey = generateKey()
    219                 document.getElementById('key').value = newKey
    220                 showQRCode(newKey)
     631                       
     632                        // DOMが構築されるのを待ってから処理を実行
     633                        setTimeout(function() {
     634                            const secretKeyEle = document.getElementById('secret-key');
     635                            if (secretKeyEle) {
     636                                secretKeyEle.value = hexKey;
     637                            }
     638                            displayQRCode(base32Key);
     639                            enableModalButtons();
     640                        }, 100);
     641                    } else {
     642                        reloadPageWithMessage('', messageError);
     643                    }
     644                    return;
     645                })
     646                .fail(function(xhr) {
     647                    reloadPageWithMessage('', messageError);
     648                    return;
     649                });
     650            }
     651            // 認証コード検証処理
     652            function verifyCode(method) {
     653                const secretKey        = document.getElementById('secret-key').value;
     654                const codeInput        = document.getElementById('verification-code');
     655                const code             = codeInput.value.trim();
     656                const nextBtn          = document.getElementById('setting-modal-next');
     657                const authMethodStatus = document.getElementById('auth-method-status').dataset.authMethod;
     658                const isRegistered     = (authMethodStatus !== '0') ? true : false;
     659                const nonce            = document.getElementById('_wpnonce').value;
     660
     661                disableModalButtons();
     662
     663                if (!code) {
     664                    if (method === 'app') {
     665                        showModalError(messageAppCodeEmpty);
     666                    } else {
     667                        showModalError(messageEmailCodeEmpty);
     668                    }
     669                    enableModalButtons();
     670                    codeInput.value = '';
     671                    codeInput.focus();
     672                } else {
     673                    // AJAXでサーバーに送信
     674                    jQuery.ajax({
     675                        url: ajaxUrl,
     676                        type: 'POST',
     677                        data: {
     678                            action: 'cloudsecurewp_verify_auth_code',
     679                            nonce: nonce,
     680                            secret_key: secretKey,
     681                            code: code,
     682                            method: method
     683                        }
     684                    })
     685                    .done(function(response) {
     686                        if (response.success) {
     687                            if (response.data.has_recovery) {
     688                                closeSettingModal();
     689                                if (isRegistered) {
     690                                    reloadPageWithMessage(messageUpdate, '');
     691                                } else {
     692                                    reloadPageWithMessage(messageRegist, '');
     693                                }
     694                            } else {
     695                                document.getElementById('setting-modal').dataset.registStatus = isRegistered ? 'update' : 'regist';
     696                                openSuggestRecoveryModal();
     697                                enableModalButtons();
     698                            }
     699                            return;                     
     700                        } else {
     701                            if (method === 'app') {
     702                                showModalError(messageAppCodeInvalid);
     703                            } else {
     704                                showModalError(messageEmailCodeInvalid);
     705                            }
     706                            enableModalButtons();
     707                            codeInput.value = '';
     708                            codeInput.focus();
     709                            return;
     710                        }                       
     711                    })
     712                    .fail(function(xhr) {
     713                        reloadPageWithMessage('', messageError);
     714                        return;
     715                    })
     716                }
     717            }
     718            // リカバリーコード生成処理
     719            function generateRecoveryCode(openModalFlg) {
     720                const nonce      = document.getElementById('_wpnonce').value;
     721
     722                if (openModalFlg) {
     723                    disableModalButtons();
     724                }
     725
     726                // AJAXでサーバーに送信
     727                jQuery.ajax({
     728                    url: ajaxUrl,
     729                    type: 'POST',
     730                    data: {
     731                        action: 'cloudsecurewp_generate_recovery_codes',
     732                        nonce: nonce
     733                    }
     734                })
     735                .done(function(response) {
     736                    if (response.success && Array.isArray(response.data.codes) && response.data.codes.length > 0) {
     737                        // 成功時のリカバリーコードモーダルを表示
     738                        closeConfirmModal();
     739                        openRecoveryCodesSuccessModal(response.data.codes)
     740                        enableModalButtons();
     741                        return;
     742                    } else {
     743                        // 失敗時のリカバリーコードモーダルを表示
     744                        closeConfirmModal();
     745                        openRecoveryCodesFailureModal()
     746                        enableModalButtons();
     747                        return;
     748                    }
     749                })
     750                .fail(function(xhr) {
     751                    // 失敗時のリカバリーコードモーダルを表示
     752                    closeConfirmModal();
     753                    openRecoveryCodesFailureModal()
     754                    enableModalButtons();
     755                    return;
     756                })
     757            }
     758            // リカバリーコードのコピー処理
     759            function codeCopy() {
     760                const codeItems = document.querySelectorAll('.recovery-code-item');
     761                const codes = Array.from(codeItems).map(item => item.textContent);
     762                const codesText = codes.join('\n');
     763               
     764                // HTTPS環境またはlocalhost: Clipboard APIを使用
     765                if (navigator.clipboard && navigator.clipboard.writeText) {
     766                    navigator.clipboard.writeText(codesText).then(function() {
     767                        alert('クリップボードにコピーしました。');
     768                    }).catch(function() {
     769                        fallbackCopy(codesText);
     770                    });
     771                } else {
     772                    // HTTP環境: フォールバック処理
     773                    fallbackCopy(codesText);
     774                }
     775            }           
     776            // HTTP環境用のフォールバックコピー処理
     777            function fallbackCopy(text) {
     778                const textarea = document.createElement('textarea');
     779                textarea.value = text;
     780                textarea.style.position = 'fixed';
     781                textarea.style.top = '0';
     782                textarea.style.left = '0';
     783                textarea.style.opacity = '0';
     784                document.body.appendChild(textarea);
     785                textarea.focus();
     786                textarea.select();
     787               
     788                try {
     789                    const successful = document.execCommand('copy');
     790                    if (successful) {
     791                        alert('クリップボードにコピーしました。');
     792                    } else {
     793                        alert('コピーに失敗しました。手動でコードをコピーしてください。');
     794                    }
     795                } catch (err) {
     796                    alert('コピーに失敗しました。手動でコードをコピーしてください。');
     797                } finally {
     798                    document.body.removeChild(textarea);
     799                }
     800            }
     801            // リカバリーコードのダウンロード処理
     802            function codeDownload() {
     803                const codeItems = document.querySelectorAll('.recovery-code-item');
     804                const codes = Array.from(codeItems).map(item => item.textContent);
     805                const codesText = codes.join('\n');
     806                const blob = new Blob([codesText], { type: 'text/plain' });
     807                const url = URL.createObjectURL(blob);
     808                const a = document.createElement('a');
     809                a.href = url;
     810                a.download = 'recovery-codes.txt';
     811                a.style.display = 'none';
     812                document.body.appendChild(a);
     813                a.click();
     814               
     815                setTimeout(function() {
     816                    document.body.removeChild(a);
     817                    URL.revokeObjectURL(url);
     818                }, 100);
     819            }
     820            // 選択モーダル表示処理
     821            function openSelectModal() {
     822                const settingModal = document.getElementById('setting-modal');
     823                const targetTitle  = document.getElementById('setting-modal-title');
     824                const targetBody   = document.getElementById('setting-modal-body');
     825                const template     = document.getElementById('select-method-modal');
     826
     827                if (template) {
     828                    settingModal.dataset.settingStatus = 'select_method';
     829
     830                    targetTitle.textContent = '認証方法の設定';
     831                    targetBody.innerHTML    = '';
     832
     833                    const clone = document.importNode(template.content, true);
     834                    targetBody.appendChild(clone);
     835
     836                    settingModal.style.display = 'block';
     837                    document.body.style.overflowY = 'hidden';
     838                }
     839            }
     840            // 認証モーダル表示処理
     841            function openAuthModal(method) {
     842                const settingModal = document.getElementById('setting-modal');
     843                const settingTitle = document.getElementById('setting-modal-title');
     844                const settingBody  = document.getElementById('setting-modal-body');
     845                let template       = null;
     846
     847                settingModal.dataset.settingStatus = method;
     848
     849                if (method === 'app') {
     850                    template = document.getElementById('app-auth-modal');
     851                } else if (method === 'email') {
     852                    template = document.getElementById('email-auth-modal');
     853                }
     854
     855                settingBody.innerHTML = '';
     856               
     857                const clone = document.importNode(template.content, true);
     858                settingBody.appendChild(clone);
     859            }
     860            // リカバリーコード生成提案モーダル表示処理
     861            function openSuggestRecoveryModal() {
     862                const settingModal = document.getElementById('setting-modal');
     863                const targetTitle  = document.getElementById('setting-modal-title');
     864                const targetBody   = document.getElementById('setting-modal-body');
     865                const template     = document.getElementById('suggest-recovery-modal');
     866                const clone        = document.importNode(template.content, true);
     867                settingModal.dataset.settingStatus = 'suggest_recovery';
     868                targetBody.innerHTML = '';
     869                targetBody.appendChild(clone);
     870            }
     871            // 成功時リカバリーコードモーダル表示処理
     872            function openRecoveryCodesSuccessModal(codes) {
     873                const settingModal = document.getElementById('setting-modal');
     874                const settingTitle = document.getElementById('setting-modal-title');
     875                const settingBody  = document.getElementById('setting-modal-body');
     876
     877                settingModal.dataset.settingStatus = 'recovery_codes';
     878                settingTitle.textContent           = 'リカバリーコード';
     879                settingBody.innerHTML              = '';
     880
     881                const template = document.getElementById('recovery-code-modal-success');
     882                const clone    = document.importNode(template.content, true);
     883                settingBody.appendChild(clone);
     884
     885                // リカバリーコードを表示
     886                const container = document.getElementById('recovery-codes-container');     
     887                codes.forEach(function(code) {
     888                    const codeItem = document.createElement('div');
     889                    codeItem.className = 'recovery-code-item';
     890                    codeItem.textContent = code;
     891                    container.appendChild(codeItem);
     892                })
     893
     894                settingBody.className = 'recovery-modal-body';
     895                settingModal.style.display = 'block';
     896            }
     897            // 失敗時リカバリーコードモーダル表示処理
     898            function openRecoveryCodesFailureModal() {
     899                const settingModal = document.getElementById('setting-modal');
     900                const settingTitle = document.getElementById('setting-modal-title');
     901                const settingBody  = document.getElementById('setting-modal-body');
     902
     903                settingModal.dataset.settingStatus = 'recovery_codes';
     904                settingTitle.textContent           = 'リカバリーコードの生成に失敗しました';
     905                settingBody.innerHTML              = '';
     906
     907                const template = document.getElementById('recovery-code-modal-failure');
     908                const clone    = document.importNode(template.content, true);
     909                settingBody.appendChild(clone);
     910
     911                settingBody.className = 'recovery-modal-body';
     912                settingModal.style.display = 'block';
     913            }
     914            // 確認モーダル表示処理
     915            function openConfirmModal(status) {
     916                const contentBody  = document.getElementById('content-body');
     917                const modal        = document.getElementById('confirm-modal');
     918                const title        = document.getElementById('confirm-message-title');
     919                const modalBody    = document.getElementById('confirm-message-body');
     920
     921                if (status === 'recovery') {
     922                    title.textContent = 'リカバリーコードを再生成しますか?';
     923                    modalBody.innerHTML    = `
     924                        <div class="recovery-text">
     925                            <div class="black-circle"></div><p class="modal-text">誰にも見られていない安全な環境で実行してください。</p>
     926                        </div>
     927                        <div class="recovery-text">
     928                            <div class="black-circle"></div><p class="modal-text">再生成を行うと既存のリカバリーコードはすべて使用できなくなります。</p>
     929                        </div>
     930                    `;
     931                    document.body.style.overflowY = 'hidden';
     932                } else {
     933                    title.textContent = '処理を中断しますか?';
     934                    modalBody.innerHTML    = '<p class="modal-text">保存していない変更は失われます。</p>';
     935                }
     936
     937                modal.dataset.confirmStatus = status;
     938                modal.style.display = 'block';
     939            }
     940            // 設定モーダル非表示処理
     941            function closeSettingModal() {
     942                const settingModal = document.getElementById('setting-modal');
     943                const settingTitle  = document.getElementById('setting-modal-title');
     944                const settingBody   = document.getElementById('setting-modal-body');
     945
     946                settingTitle.innerHTML = '';
     947                settingBody.innerHTML  = '';
     948                settingBody.className = 'setting-modal-body';
     949                settingModal.style.display = 'none';
     950            }
     951            // 確認モーダル非表示処理
     952            function closeConfirmModal() {
     953                const confirmModal = document.getElementById('confirm-modal');
     954                const confirmTitle  = document.getElementById('confirm-message-title');
     955                const confirmBody   = document.getElementById('confirm-message-body');
     956
     957                confirmTitle.innerHTML = '';
     958                confirmBody.innerHTML  = '';
     959                confirmModal.style.display = 'none';
     960            }
     961            // 設定モーダルの次へボタン押下時のアクション
     962            function handleSettingNext(status) {
     963                if (status === 'select_method') {
     964                    // 次へボタン押下時の処理
     965                    const selectedMethod = document.querySelector('input[name="auth_method_select"]:checked').value;
     966                    if (selectedMethod === 'app') {
     967                        // アプリ認証の場合はQRコード生成処理を実行
     968                        generateSecretKey(false);
     969                    } else {
     970                        generateSecretKeyAndsendEmail(false);
     971                    }
     972                } else if (status === 'app' || status === 'email') {
     973                    // 設定を完了ボタン押下時の処理
     974                    verifyCode(status);
     975                } else if (status === 'suggest_recovery' || status === 'recovery_codes') {
     976                    // リカバリーコードを生成ボタン押下時の処理
     977                    generateRecoveryCode(true);
     978                }
     979            }
     980            // 確認モーダルの次へボタン押下時のアクション
     981            function handleConfirmNext(status) {
     982                if (status === 'recovery') {
     983                    generateRecoveryCode(true);
     984                } else {
     985                    if (countdownTimeout) {
     986                        clearTimeout(countdownTimeout);
     987                        countdownTimeout = null;
     988                    }
     989                    closeConfirmModal();
     990                    closeSettingModal();
     991                    document.body.style.overflowY = 'auto';
     992                }
     993            }
     994            // 設定モーダルの閉じるボタン押下時のアクション
     995            function handleSettingClose(status) {
     996                if (status === 'select_method') {
     997                    closeSettingModal();
     998                    document.body.style.overflowY = 'auto';
     999                } else if (status === 'suggest_recovery' || status === 'recovery_codes') {
     1000                    const registStatus = document.getElementById('setting-modal').dataset.registStatus;
     1001                    if (registStatus === 'regist') {
     1002                        reloadPageWithMessage(messageRegist, '');
     1003                    } else if (registStatus === 'update') {
     1004                        reloadPageWithMessage(messageUpdate, '');
     1005                    } else {
     1006                        reloadPageWithMessage('', '');
     1007                    }
     1008                } else {
     1009                    openConfirmModal(status);
     1010                }
     1011            }
     1012            // リカバリーコード生成ボタン押下時のアクション
     1013            function handleCreateRecovery() {
     1014                // リカバリーコードの生成状態を確認
     1015                const registerMethodBtn    = document.getElementById('register-method');
     1016                const generateRecoveryBtn  = document.getElementById('generate-recovery');
     1017                const isRegisteredRecovery = document.getElementById('recovery-code-status').dataset.isRegisteredRecovery;
     1018                if (isRegisteredRecovery === '1') {
     1019                    // 既にリカバリーコードが登録されている場合は確認モーダルを表示
     1020                    openConfirmModal('recovery');
     1021                } else {
     1022                    // リカバリーコードが未登録の場合は直接生成処理を実行
     1023                    registerMethodBtn.disabled   = true;
     1024                    generateRecoveryBtn.disabled = true;
     1025                    generateRecoveryCode(false);
     1026                }
     1027            }
     1028
     1029            // 画面読み込み時の処理
     1030            document.addEventListener('DOMContentLoaded', function() {
     1031                const registerMethodBtn       = document.getElementById('register-method');
     1032                const generateRecoveryBtn     = document.getElementById('generate-recovery');
     1033                const settingModal            = document.getElementById('setting-modal');
     1034                const confirmModal            = document.getElementById('confirm-modal');
     1035
     1036                // 認証方法「設定」ボタンのイベント付与
     1037                if (registerMethodBtn) {
     1038                    registerMethodBtn.addEventListener('click', function() {
     1039                        openSelectModal();
     1040                    })
     1041                }
     1042                // リカバリーコード「生成」ボタンのイベント付与
     1043                if (generateRecoveryBtn) {
     1044                    generateRecoveryBtn.addEventListener('click', function() {
     1045                        handleCreateRecovery();
     1046                    })
     1047                }
     1048                // 設定モーダル内のボタンイベント付与
     1049                if (settingModal) {
     1050                    settingModal.addEventListener('click', (e) => {
     1051                        const nextSettingBtn   = e.target.closest('.modal-next');
     1052                        const closeSettingBtn  = e.target.closest('.modal-close');
     1053                        const regenerateKeyBtn = e.target.closest('#regenerate-key');
     1054                        const resendEmailBtn   = e.target.closest('#resend-email-code');
     1055                        const codeCopyBtn      = e.target.closest('#recovery-code-copy');
     1056                        const codeDownloadBtn  = e.target.closest('#recovery-code-download');
     1057                        // 共通「次へ」ボタンイベント付与
     1058                        if (nextSettingBtn) {
     1059                            const settingStatus = settingModal.dataset.settingStatus;
     1060                            handleSettingNext(settingStatus);
     1061                        }
     1062                        // 共通「キャンセル」ボタン「×」ボタンイベント付与
     1063                        if (closeSettingBtn) {
     1064                            const settingStatus = settingModal.dataset.settingStatus;
     1065                            handleSettingClose(settingStatus);
     1066                        }
     1067                        // シークレットキー再生成リンクイベント付与
     1068                        if (regenerateKeyBtn) {
     1069                            generateSecretKey(true);
     1070                        }
     1071                        // メール認証コード再送信リンクイベント付与
     1072                        if (resendEmailBtn) {
     1073                            generateSecretKeyAndsendEmail(true);
     1074                        }
     1075                        // リカバリーコードコピーイベント付与
     1076                        if (codeCopyBtn) {
     1077                            codeCopy();
     1078                        }
     1079                        // リカバリーコードダウンロードイベント付与
     1080                        if (codeDownloadBtn) {
     1081                            codeDownload();
     1082                        }
     1083                    });
     1084                }
     1085                // 確認モーダル内のボタンイベント付与
     1086                if (confirmModal) {
     1087                    confirmModal.addEventListener('click', (e) => {
     1088                        const nextConfirmBtn  = e.target.closest('.modal-next');
     1089                        const closeConfirmBtn = e.target.closest('.modal-close');
     1090
     1091                        // 共通「次へ」ボタンイベント付与
     1092                        if (nextConfirmBtn) {
     1093                            const confirmStatus = confirmModal.dataset.confirmStatus;
     1094                            handleConfirmNext(confirmStatus);
     1095                        }
     1096                        // 共通「キャンセル」ボタン「×」ボタンイベント付与
     1097                        if (closeConfirmBtn) {
     1098                            closeConfirmModal();
     1099                        }
     1100                    })
     1101                }
    2211102            })
    2221103        </script>
     1104
    2231105        <?php
    2241106    }
  • cloudsecure-wp-security/trunk/modules/admin/two-factor-authentication.php

    r3124889 r3462167  
    5151
    5252                $this->two_factor_authentication->save_settings( $this->datas );
    53 
    5453            }
    5554        }
     
    7574        <div class="title-bottom-text">
    7675            ユーザー名とパスワードの入力に加え、別のコードで追加認証を行います。<br />
    77             <b>※利用するには、<a class="title-block-link" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Google Authenticator</a>  アプリケーションでデバイスを登録する必要があります。</b>
     76            2段階認証機能を利用するには、各ユーザーが自身で認証方法の設定を行う必要があります。<br />
     77            <?php if ( 't' === $this->datas['two_factor_authentication'] ) : ?>
     78                <b>※認証方法が未設定の場合は、<a href="<?php echo esc_url( admin_url( 'admin.php?page=cloudsecurewp_two_factor_authentication_registration' ) ); ?>">認証方法の設定</a>を行ってください。</b><br />
     79            <?php endif; ?>
    7880        </div>
    7981        <?php
     
    111113                                        value="<?php echo esc_attr( $role ); ?>"<?php checked( in_array( $role, $roles ) ); ?> />
    112114                                <label for="<?php echo esc_attr( $role ); ?>"><?php echo esc_html_x( ucfirst( $role ), 'User role' ); ?></label>
    113                                 <?php if ( $key !== $lastkey ) : ?>
    114                                     <br/>
    115                                 <?php endif; ?>
     115                                <br/>
    116116                            <?php endforeach; ?>
     117                            <p class="description">
     118                                ユーザーごとの設定状況(未設定/設定済)は <a href="<?php echo esc_url( admin_url( 'users.php' ) ); ?>">ユーザー一覧</a> 画面で確認してください。
     119                            </p>
    117120                        </div>
    118121                    </div>
  • cloudsecure-wp-security/trunk/modules/cloudsecure-wp.php

    r3444508 r3462167  
    2020require_once __DIR__ . '/../really-simple-captcha/really-simple-captcha.php';
    2121require_once __DIR__ . '/lib/class-time-based-one-time-password.php';
     22require_once __DIR__ . '/lib/class-recovery-codes.php';
    2223require_once __DIR__ . '/login-log.php';
    2324require_once __DIR__ . '/two-factor-authentication.php';
     
    141142
    142143            if ( $this->disable_access_system_file->is_enabled() ) {
    143                 add_action( 'plugins_loaded', array( $this->disable_access_system_file, 'init' ), 10 );
     144                add_action( 'plugins_loaded', array( $this->disable_access_system_file, 'init' ), 11 );
    144145            }
    145146
     
    258259            }
    259260
     261            if ( $this->two_factor_authentication->is_enabled() ) {
     262                // 2段階認証のAJAXハンドラー
     263                add_action( 'wp_ajax_cloudsecurewp_generate_key', array( $this->two_factor_authentication, 'ajax_generate_key' ) );
     264                add_action( 'wp_ajax_cloudsecurewp_generate_key_and_send_email', array( $this->two_factor_authentication, 'ajax_generate_key_and_send_email' ) );
     265                add_action( 'wp_ajax_cloudsecurewp_verify_auth_code', array( $this->two_factor_authentication, 'ajax_verify_auth_code' ) );
     266                add_action( 'wp_ajax_cloudsecurewp_generate_recovery_codes', array( $this->two_factor_authentication, 'ajax_generate_recovery_codes' ) );
     267            }
     268
    260269            if ( $this->two_factor_authentication->is_enabled() && 'xmlrpc.php' !== basename( $_SERVER['SCRIPT_NAME'] ) && ! is_admin() ) {
    261270                add_filter( 'sanitize_user', array( $this->two_factor_authentication, 'restore_login_name' ), 0, 1 );
    262271                add_filter( 'authenticate', array( $this->two_factor_authentication, 'restore_login_session' ), 0, 3 );
    263                 add_filter( 'authenticate', array( $this->two_factor_authentication, 'authenticate_with_two_factor' ), 100, 3 );
     272                add_filter( 'authenticate', array( $this->two_factor_authentication, 'two_factor_disable_login_check' ), 100, 3 );
     273                add_filter( 'authenticate', array( $this->two_factor_authentication, 'authenticate_with_two_factor' ), 101, 3 );
    264274                add_action( 'wp_login', array( $this->two_factor_authentication, 'redirect_if_not_two_factor_authentication_registered' ), 10, 2 );
    265275            }
     
    356366
    357367        if ( $this->two_factor_authentication->is_enabled_on_screen() ) {
    358             add_menu_page( 'デバイス登録', '2段階認証', 'read', $slug . '_two_factor_authentication_registration', array( $this, 'm_two_factor_authentication_registration' ), 'dashicons-lock', 72 );
     368            add_menu_page( '2段階認証の設定', '2段階認証の設定', 'read', $slug . '_two_factor_authentication_registration', array( $this, 'm_two_factor_authentication_registration' ), 'dashicons-lock', 72 );
    359369        }
    360370    }
     
    681691        }
    682692
     693        if ( version_compare( $old_version, '1.4.0' ) < 0 ) {
     694            $this->two_factor_authentication->setup_2fa_tables();
     695        }
     696
    683697        $this->config->set( 'version', $now_version );
    684698        $this->config->save();
  • cloudsecure-wp-security/trunk/modules/common.php

    r3153634 r3462167  
    3131        self::LOGIN_STATUS_DISABLED => '無効',
    3232    );
     33
     34    protected const METHOD_PAGE   = 1;
     35    protected const METHOD_XMLRPC = 2;
     36    protected const METHODS       = array(
     37        self::METHOD_PAGE   => 'ログインページ',
     38        self::METHOD_XMLRPC => 'XML-RPC',
     39    );
    3340    protected $info;
    3441
  • cloudsecure-wp-security/trunk/modules/disable-access-system-file.php

    r3186863 r3462167  
    8585        $remove_rules        = array(
    8686            'ajax_editor'    => array( '950005' ),
     87            'ajax_customize' => array( '950005' ),
     88            'rest_api'       => array( '950005' ),
    8789        );
    8890
  • cloudsecure-wp-security/trunk/modules/lib/class-time-based-one-time-password.php

    r3101467 r3462167  
    66
    77/**
    8  * タイムベースドワンタイムパスワードアルゴリズムの2段階認証のためのクラス
     8 * TOTPアルゴリズムの2段階認証のためのクラス
    99 */
    1010class CloudSecureWP_Time_Based_One_Time_Password {
    11     private static $digits = 6;
     11    private static $digits      = 6;
     12    private static $discrepancy = 1;
    1213
    1314    /**
    1415     * 指定されたシークレットと時点を使用してコードを計算
    1516     *
    16      * @param string   $secret
    17      * @param int|null $time_slice
     17     * @param string $secret
     18     * @param int    $time_slice
    1819     *
    1920     * @return string
    2021     */
    21     public static function get_code( string $secret, int $time_slice = null ): string {
    22         if ( $time_slice === null ) {
    23             $time_slice = floor( time() / 30 );
    24         }
    25 
    26         $secret_key = self::base32_decode( $secret );
     22    public static function get_code( string $secret, int $time_slice ): string {
     23        // 16進数をバイナリデータに変換
     24        $secret_key = hex2bin( $secret );
    2725
    2826        // 時間をバイナリ文字列にパック
     
    4846    /**
    4947     * コードが正しいかどうかを検証
    50      * $discrepancy*30 秒前から今から $discrepancy*30 秒までのコードを受け入れます。
    51      *
    52      * @param string   $secret
    53      * @param string   $code
    54      * @param int      $discrepancy 30 秒単位で許容される時間のずれ (8 は前後 4 分を意味します)
    55      * @param int|null $current_time_slice
     48     * 前後1つ分の時間スライスを許容
     49     *
     50     * @param string $secret
     51     * @param string $code
     52     * @param int    $time_step 時間間隔(秒)デフォルトは30秒
    5653     *
    5754     * @return bool
    5855     */
    59     public static function verify_code( string $secret, string $code, int $discrepancy = 1, int $current_time_slice = null ): bool {
    60         if ( $current_time_slice === null ) {
    61             $current_time_slice = floor( time() / 30 );
    62         }
     56    public static function verify_code( string $secret, string $code, int $time_step ): bool {
     57        $current_time_slice = floor( time() / $time_step );
    6358
    6459        if ( strlen( $code ) !== 6 ) {
     
    6661        }
    6762
    68         for ( $i = - $discrepancy; $i <= $discrepancy; ++$i ) {
     63        for ( $i = - self::$discrepancy; $i <= self::$discrepancy; ++$i ) {
    6964            $calculated_code = self::get_code( $secret, $current_time_slice + $i );
    7065            if ( self::timing_safe_equals( $calculated_code, $code ) ) {
     
    7974     * Base32をデコード
    8075     *
    81      * @param $secret
     76     * @param string $secret
    8277     *
    8378     * @return bool|string
    8479     */
    85     protected static function base32_decode( $secret ) {
     80    public static function base32_decode( $secret ) {
    8681        if ( empty( $secret ) ) {
    8782            return '';
     
    151146        return $result === 0;
    152147    }
     148
     149    /**
     150     * メール認証用のコードを生成
     151     *
     152     * @param string $secret
     153     * @param int    $time_step 時間間隔(秒)
     154     *
     155     * @return string
     156     */
     157    public static function create_code_for_email( string $secret, int $time_step ): string {
     158
     159        // 現在の時間スライスを計算
     160        $time_slice = floor( time() / $time_step );
     161
     162        // コードを生成
     163        return self::get_code( $secret, $time_slice );
     164    }
     165
     166    /**
     167     * Base32エンコード
     168     *
     169     * @param string $binary バイナリデータ
     170     *
     171     * @return string
     172     */
     173    public static function base32_encode( string $binary ): string {
     174        if ( '' === $binary ) {
     175            return '';
     176        }
     177
     178        $base32_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
     179        $v            = 0;
     180        $vbits        = 0;
     181        $ret          = '';
     182
     183        // 1. 5ビットずつ切り出して変換
     184        for ( $i = 0, $j = strlen( $binary ); $i < $j; $i++ ) {
     185            $v    <<= 8;
     186            $v     += ord( $binary[ $i ] );
     187            $vbits += 8;
     188
     189            while ( $vbits >= 5 ) {
     190                $vbits -= 5;
     191                $ret   .= $base32_chars[ ( $v >> $vbits ) & 31 ];
     192            }
     193        }
     194
     195        // 2. 余ったビットの処理
     196        if ( $vbits > 0 ) {
     197            $v  <<= ( 5 - $vbits );
     198            $ret .= $base32_chars[ $v & 31 ];
     199        }
     200
     201        // 3. RFC 4648 に基づくパディング処理(デコーダーのチェックをパスするために必須)
     202        // Base32は8文字(40ビット)単位でブロックを作る必要がある
     203        $padding = ( 8 - ( strlen( $ret ) % 8 ) ) % 8;
     204        $ret    .= str_repeat( '=', $padding );
     205
     206        return $ret;
     207    }
     208
     209    /**
     210     * ランダムな秘密鍵を生成
     211     *
     212     * @return array ['hex' => 16進数文字列, 'base32' => Base32文字列]
     213     */
     214    public static function generate_secret_key(): array {
     215        // ランダムなバイナリデータを生成
     216        $binary = random_bytes( 10 );
     217
     218        // 16進数に変換
     219        $hex = bin2hex( $binary );
     220
     221        // Base32エンコード
     222        $base32 = self::base32_encode( $binary );
     223
     224        return array(
     225            'hex'    => $hex,
     226            'base32' => $base32,
     227        );
     228    }
    153229}
  • cloudsecure-wp-security/trunk/modules/login-log.php

    r3124889 r3462167  
    2424    );
    2525
    26     private const METHOD_PAGE   = 1;
    27     private const METHOD_XMLRPC = 2;
    28     // private const METHOD_RESTAPI = 3;
    29     private const METHODS = array(
    30         self::METHOD_PAGE   => 'ログインページ',
    31         self::METHOD_XMLRPC => 'XML-RPC',
    32         // self::METHOD_RESTAPI => 'REST API',
    33     );
    3426    private const MAX_LOG = 10000;
    3527    private $config;
  • cloudsecure-wp-security/trunk/modules/two-factor-authentication.php

    r3422588 r3462167  
    66
    77class 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;
     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;
    1331
    1432    private $config;
     
    103121        $settings = $this->get_default();
    104122        $this->save_settings( $settings );
     123        $this->create_auth_table();
     124        $this->create_login_table();
    105125    }
    106126
     
    143163
    144164    /**
     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    /**
    145179     * 2段階認証のエラーを出力
    146180     *
    147      * @return void
    148      */
    149     private function login_error() {
    150         if ( array_key_exists( 'google_authenticator_code', $_REQUEST ) ) {
    151             if ( sanitize_text_field( $_REQUEST['google_authenticator_code'] ) ) {
    152                 $errors = '認証コードが間違っているか、有効期限が切れています。';
    153             } else {
    154                 $errors = '認証コードが入力されていません。';
    155             }
    156             echo '<div id="login_error">' . esc_html( apply_filters( 'login_errors', $errors ) ) . "</div>\n";
    157         }
     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;
    158219    }
    159220
     
    162223     *
    163224     * @param string $login_token
    164      *
    165      * @return void
    166      */
    167     private function login_form( $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 ) {
    168233        ?>
    169234        <form name="loginform" id="loginform"
    170235                action="<?php echo esc_url( site_url( 'wp-login.php', 'login_post' ) ); ?>" method="post">
    171             <?php if ( array_key_exists( 'rememberme', $_REQUEST ) && 'forever' === sanitize_text_field( $_REQUEST['rememberme'] ) ) : ?>
    172                 <input name="rememberme" type="hidden" id="rememberme" value="forever"/>
    173             <?php endif; ?>
    174             <input type="hidden" name="login_token" value="<?php echo esc_attr( $login_token ); ?>">
    175             <p>
    176                 <label for="google_authenticator_code">認証コード</label>
    177                 <input type="text" name="google_authenticator_code" id="google_authenticator_code" class="input"
    178                         value="" size="20" autocomplete="one-time-code"/>
    179             </p>
    180             <script type="text/javascript">document.getElementById("google_authenticator_code").focus();</script>
    181             <p>デバイスのGoogle Authenticator アプリケーションに表示されている6桁の認証コードを入力してください。</p>
    182             <p class="submit">
    183236                <?php wp_nonce_field( $this->get_feature_key() . '_csrf' ); ?>
    184                 <input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large"
    185                         value="<?php esc_attr_e( 'Log In' ); ?>"/>
     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 ); ?>">
    186242                <input type="hidden" name="redirect_to"
    187243                        value="<?php echo esc_attr( sanitize_text_field( $_REQUEST['redirect_to'] ?? admin_url() ) ); ?>"/>
    188244                <input type="hidden" name="testcookie" value="1"/>
    189             </p>
     245                <?php if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) : ?>
     246                    <!-- リカバリーコード入力フォーム -->
     247                    <input type="hidden" name="recovery_code" value="1">
     248                    <div class="text-area">
     249                        <p>バックアップとして保存したリカバリーコードを入力してください。</p>
     250                    </div>
     251                    <div class="input-area">
     252                        <p>
     253                            <label for="authenticator_code">リカバリーコード</label>
     254                            <input type="text" name="authenticator_code" id="authenticator_code" class="input two-fa-input"
     255                                    value="" size="20" autocomplete="off"/>
     256                        </p>
     257                    </div>
     258                    <script type="text/javascript">document.getElementById("authenticator_code").focus();</script>
     259                <?php else : ?>
     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>に送信された認証コードを入力してください。</p>
     264                            <p>※メールが届かない場合、コードを再送信してください。</p>
     265                        <?php else : ?>
     266                            <p>デバイスのGoogle Authenticator に表示されている認証コードを入力してください。</p>
     267                        <?php endif; ?>
     268                    </div>
     269                    <div class="two-fa-input-area">
     270                        <p>
     271                            <label for="authenticator_code">認証コード(6桁)</label>
     272                            <input type="text" name="authenticator_code" id="authenticator_code" class="input two-fa-input"
     273                                    value="" size="20" autocomplete="one-time-code"/>
     274                        </p>
     275                    </div>
     276                    <script type="text/javascript">document.getElementById("authenticator_code").focus();</script>                 
     277                <?php endif; ?>
     278                <div>
     279                    <div class="two-fa-btn-area">
     280                            <button type="submit" name="wp-submit" id="wp-submit" class="button button-primary two-fa-btn" style="order: 2;">
     281                                <?php esc_attr_e( 'Log In' ); ?>
     282                            </button>
     283                        <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
     284                            <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;">
     285                                再送信
     286                            </button>
     287                        <?php endif; ?>
     288                    </div>
     289                    <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
     290                        <div id="resend_cooldown_message" class="two-fa-cooldown-message" style="display: none;"></div>
     291                    <?php endif; ?>
     292                </div>
     293                <div class="two-fa-link-area">
     294                    <?php if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) : ?>
     295                        <!-- 通常の認証コード入力に戻るボタン -->
     296                        <button type="submit" name="back_to_auth_code" value="1" class="two-fa-link">
     297                            ← 認証コード入力に戻る
     298                        </button>
     299                    <?php elseif ( $has_recovery ) : ?>
     300                        <!-- リカバリーコード入力へのボタン -->
     301                        <div class="separator">
     302                            または
     303                        </div>
     304                        <button type="submit" name="use_recovery_code" value="1" class="two-fa-link">
     305                            リカバリーコードを使用する
     306                        </button>
     307                    <?php endif; ?>
     308                </div>
     309            </div>
    190310        </form>
     311        <style>
     312            .two-fa-form {
     313                display: flex;
     314                flex-direction: column;
     315                gap: 24px;
     316            }
     317            .two-fa-text-area {
     318                display: flex;
     319                flex-direction: column;
     320                gap: 3px;
     321            }
     322            .two-fa-input {
     323                margin: 0 !important;
     324            }
     325            .two-fa-btn-area {
     326                display: flex;
     327                gap: 16px;
     328                justify-content: center;
     329                align-items: center;
     330            }
     331            .two-fa-btn {
     332                padding: 0 !important;
     333                margin: 0 !important;
     334                width: 50%;
     335                height: 36px;
     336            }
     337            .two-fa-link-area {
     338                display: flex;
     339                flex-direction: column;
     340                gap: 16px;
     341                text-align: center;
     342            }
     343            .two-fa-link {
     344                padding: 0;
     345                border: none;
     346                background: none;
     347                color: #2271b1;
     348                text-decoration: underline;
     349                cursor: pointer;
     350            }
     351            .separator {
     352                display: flex;
     353                align-items: center;
     354                color: #646970;
     355                font-size: 14px;
     356            }
     357            .separator::before,
     358            .separator::after {
     359                content: "";
     360                flex: 1;
     361                height: 1px;
     362                background: #B2B2B2;
     363            }
     364            .separator::before {
     365                margin-right: 8px;
     366            }
     367
     368            .separator::after {
     369                margin-left: 8px;
     370            }
     371            .two-fa-cooldown-message {
     372                font-size: 12px;
     373                text-align: left;
     374                color: #646970;
     375            }
     376        </style>
     377        <script type="text/javascript">
     378            (function() {
     379                let emailAbleSendTime = null;
     380
     381                const resendBtn       = document.getElementById('resend_2fa_email_btn');
     382                const cooldownMessage = document.getElementById('resend_cooldown_message');
     383                if (!resendBtn || !cooldownMessage) return;
     384
     385                // サーバーから受け取った残り秒数を使って送信可能時刻を計算
     386                const remainingSeconds = parseInt(resendBtn.getAttribute('data-remaining_seconds') || '0', 10);
     387                emailAbleSendTime = Date.now() + (remainingSeconds * 1000);
     388
     389                function updateTimerDisplay() {
     390                    // 現在時刻と送信可能時刻を比較して残り秒数を計算
     391                    const now = Date.now();
     392                    const remainingTime = Math.ceil((emailAbleSendTime - now) / 1000);
     393
     394                    if (remainingTime > 0) {
     395                        resendBtn.disabled = true;
     396                        cooldownMessage.style.display = 'block';
     397                        cooldownMessage.textContent = remainingTime + '秒後 再送信できます';
     398                        setTimeout(updateTimerDisplay, 1000);
     399                    } else {
     400                        resendBtn.disabled = false;
     401                        cooldownMessage.style.display = 'none';
     402                    }
     403                }
     404
     405                // 表示時にタイマーを開始
     406                <?php if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) : ?>
     407                    updateTimerDisplay();
     408                <?php endif; ?>
     409            })();
     410        </script>
    191411        <?php
    192412    }
     
    202422     */
    203423    public function redirect_if_not_two_factor_authentication_registered( $user_login, $user ) {
    204         $secret = get_user_option( 'cloudsecurewp_two_factor_authentication_secret', $user->ID );
     424        $auth_info = $this->get_2fa_auth_info( $user->ID );
     425        $secret    = ( count( $auth_info ) > 0 && isset( $auth_info['secret'] ) ) ? true : false;
    205426
    206427        if ( isset( $user->roles[0] ) ) {
     
    234455    public function show_2factor_state_2user_list( $value, $column_name, $user_id ) {
    235456        if ( $column_name === 'is_2factor' ) {
    236             $value = get_user_meta( $user_id, 'wp_cloudsecurewp_two_factor_authentication_secret', true );
    237             return $value !== '' ? '設定済' : '未設定';
     457            $auth_info = $this->get_2fa_auth_info( $user_id );
     458            if ( count( $auth_info ) === 0 ) {
     459                // データ移行漏れ対応
     460                $auth_info = $this->repair_migration_gaps( $user_id );
     461            }
     462            $value = '未設定';
     463            if ( count ( $auth_info ) > 0 ) {
     464                $value = '設定済';
     465            }
     466            return $value;
    238467        }
    239468        return $value;
    240     }
    241 
    242     /**
    243      * ユーザの2faシークレットキー取得
    244      *
    245      * @param int $user_id
    246      *
    247      * @return mixed
    248      */
    249     private function get_2fa_secret_key( int $user_id ) {
    250         return get_user_option( 'cloudsecurewp_two_factor_authentication_secret', $user_id );
    251469    }
    252470
     
    329547        }
    330548
    331         // 2faシークレットキーが存在しない場合
    332         if ( ! $this->get_2fa_secret_key( $user->ID ) ) {
    333             return false;
    334         }
    335 
    336549        return true;
    337550    }
     
    341554     *
    342555     * @param string $login_token
    343      *
    344      * @return void
    345      */
    346     private function show_two_factor_form( string $login_token ) {
     556     * @param int    $auth_method
     557     * @param bool   $has_recovery
     558     * @param string $email_address
     559     * @param bool   $is_send_email
     560     *
     561     * @return void
     562     */
     563    private function show_two_factor_form( string $login_token, int $auth_method, bool $has_recovery, string $email_address, bool $is_send_email ): void {
     564        $message           = '';
     565        $error             = '';
     566        $remaining_seconds = 0;
     567
     568        // 再送信メッセージ
     569        if ( array_key_exists( 'resend_2fa_email', $_REQUEST ) ) {
     570            if ( $is_send_email ) {
     571                $message = '認証コードを再送信しました。';
     572            } else {
     573                $error = '認証コードの再送信は30秒に1回までです。しばらく時間をおいてから再度お試しください。';
     574            }
     575        }
     576
     577        // エラーメッセージ
     578        if ( array_key_exists( 'wp-submit', $_REQUEST ) && array_key_exists( 'authenticator_code', $_REQUEST ) ) {
     579            if ( sanitize_text_field( $_REQUEST['authenticator_code'] ) ) {
     580                $error = '認証コードが間違っているか、有効期限が切れています。';
     581            } else {
     582                $error = '認証コードが入力されていません。';
     583            }
     584        }
     585
     586        if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) {
     587            // メール認証の場合、送信可能になるまでの残り秒数を計算
     588            $able_send_time    = $this->get_email_able_send_time( $this->get_option_data( $this->create_option_key( $login_token ) )['user_id'] );
     589            $remaining_seconds = max( 0, $able_send_time - time() );
     590        }
     591
    347592        // 2FA画面を表示
    348593        login_header( '2段階認証画面' );
    349         $this->login_error();
    350         $this->login_form( $login_token );
     594        $this->login_message( $message );
     595        $this->login_error( $error );
     596        $this->login_form( $login_token, $auth_method, $has_recovery, $email_address, $remaining_seconds );
    351597        login_footer();
    352598        exit;
     
    354600
    355601    /**
    356      * POSTデータから2FA関連の値を安全に取得
    357      *
    358      * @return array
    359      */
    360     private function get_2fa_post_data(): array {
    361         return array(
    362             'login_token'                => sanitize_text_field( $_POST['login_token'] ?? '' ),
    363             'google_authenticator_code'  => sanitize_text_field( $_POST['google_authenticator_code'] ?? '' ),
    364         );
     602     * メール認証コードの再送信処理
     603     *
     604     * @param int    $user_id
     605     * @param string $secret
     606     *
     607     * @return bool true: 送信成功、false: 送信制限中
     608     */
     609    private function send_2fa_email( int $user_id, string $secret = '' ): bool {
     610        // 送信制限チェック(30秒間)
     611        $current_time   = time();
     612        $able_sent_time = $this->get_email_able_send_time( $user_id );
     613
     614        if ( $current_time < $able_sent_time ) {
     615            // 30秒以内の再送信は処理しない(エラーは表示しない)
     616            return false;
     617        }
     618
     619        // シークレットキーを取得
     620        if ( $secret === '' ) {
     621            $auth_info = $this->get_2fa_auth_info( $user_id );
     622            $secret    = $auth_info['secret'];
     623        }
     624        // 新しいコードを生成して送信
     625        $code = CloudSecureWP_Time_Based_One_Time_Password::create_code_for_email( $secret, self::AUTH_EMAIL_INTERVAL );
     626        $this->send_code( $user_id, $code, self::AUTH_EMAIL_INTERVAL, 'login' );
     627
     628        // 最終送信時刻を更新
     629        $this->update_email_able_send_time( $user_id );
     630
     631        return true;
    365632    }
    366633
     
    370637     * @param int    $user_id
    371638     * @param string $code
     639     * @param int    $auth_method
    372640     *
    373641     * @return bool
    374642     */
    375     private function verify_2fa_code( int $user_id, string $code ): bool {
    376 
    377         // 2faシークレットキー取得
    378         $secret_key = $this->get_2fa_secret_key( $user_id );
    379 
    380         // 2faシークレットキーが存在しない場合
    381         if ( ! $secret_key ) {
    382             return true;
    383         }
    384 
    385         // 2段階認証コードが有効な場合
    386         if ( CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, 2 ) ) {
    387             return true;
    388         }
    389 
    390         // 認証失敗
    391         return false;
     643    private function verify_2fa_code( int $user_id, string $code, int $auth_method ): bool {
     644        // リカバリーコードでの認証の場合
     645        if ( $auth_method === self::USER_AUTH_METHOD_RECOVERY ) {
     646            return CloudSecureWP_Recovery_Codes::verify_code( $user_id, $code );
     647        }
     648
     649        // シークレットキーを取得
     650        $auth_info = $this->get_2fa_auth_info( $user_id );
     651        if ( ! $auth_info || ! isset( $auth_info['secret'] ) ) {
     652            return false;
     653        }
     654        $secret_key = $auth_info['secret'];
     655
     656        // メール認証の場合(60秒間隔)
     657        if ( $auth_method === self::USER_AUTH_METHOD_EMAIL ) {
     658            return CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, self::AUTH_EMAIL_INTERVAL );
     659        }
     660
     661        // アプリ認証の場合(30秒間隔)
     662        return CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, self::AUTH_APP_INTERVAL );
    392663    }
    393664
     
    403674
    404675        // 初回アクセス・または初回認証の場合、何もしない
    405         if ( ! isset( $_POST['google_authenticator_code'] ) ) {
     676        if ( ! isset( $_POST['authenticator_code'] ) ) {
    406677            return $username;
    407678        }
    408679
    409         // 2FA関連のPOSTデータ取得
    410         $post_data = $this->get_2fa_post_data();
     680        // ログイントークンを取得
     681        $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
    411682
    412683        // ログイン情報を取得
    413         $option_key  = $this->create_option_key( $post_data['login_token'] );
     684        $option_key  = $this->create_option_key( $login_token );
    414685        $option_data = $this->get_option_data( $option_key );
    415686
     
    433704     */
    434705    public function restore_login_session( $user, $username, $password ) {
    435 
    436706        // 初回アクセス・または初回認証の場合、何もしない
    437         if ( ! isset( $_POST['google_authenticator_code'] ) ) {
     707        if ( ! isset( $_POST['authenticator_code'] ) ) {
    438708            return $user;
    439709        }
     
    442712        check_admin_referer( $this->get_feature_key() . '_csrf' );
    443713
    444         // 2FA関連のPOSTデータ取得
    445         $post_data = $this->get_2fa_post_data();
     714        // ログイントークンを取得
     715        $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
    446716
    447717        // ログイン情報を取得
    448         $option_key  = $this->create_option_key( $post_data['login_token'] );
     718        $option_key  = $this->create_option_key( $login_token );
    449719        $option_data = $this->get_option_data( $option_key );
    450720
     
    462732        }
    463733
    464         // ログイン情報のPOSTデータ復元
    465         $_POST['log'] = $option_data['user_login'];
     734        // 認証方法の設定
     735        $auth_method = intVal( $option_data['auth_method'] );
     736
     737        // 認証コード再送信
     738        if ( isset( $_POST['resend_2fa_email'] ) ) {
     739            // メール再送信
     740            $result = $this->send_2fa_email( $option_data['user_id'] );
     741
     742            $this->show_two_factor_form(
     743                $login_token,
     744                $auth_method,
     745                $option_data['has_recovery'],
     746                $option_data['email_address'],
     747                $result
     748            );
     749        }
     750
     751        // リカバリーコード入力画面への切り替え
     752        if ( isset( $_POST['use_recovery_code'] ) ) {
     753            $this->show_two_factor_form(
     754                $login_token,
     755                self::USER_AUTH_METHOD_RECOVERY,
     756                $option_data['has_recovery'],
     757                $option_data['email_address'],
     758                false
     759            );
     760        }
     761
     762        // 認証コード入力画面への切り替え
     763        if ( isset( $_POST['back_to_auth_code'] ) ) {
     764            $this->show_two_factor_form(
     765                $login_token,
     766                $auth_method,
     767                $option_data['has_recovery'],
     768                $option_data['email_address'],
     769                false
     770            );
     771        }
     772
     773        // リカバリコードでの認証の場合
     774        if ( isset( $_POST['recovery_code'] ) ) {
     775            $auth_method = self::USER_AUTH_METHOD_RECOVERY;
     776        }
     777
     778        // ログイン情報の復元
     779        $_POST['log']           = $option_data['user_login'];
     780        $_POST['auth_method']   = $auth_method;
     781        $_POST['has_recovery']  = $option_data['has_recovery'];
     782        $_POST['email_address'] = $option_data['email_address'];
    466783
    467784        return $user;
     
    469786
    470787    /**
    471      * 認証フック: 2段階認証処理
     788     * 認証フック: 2段階認証ログイン無効チェック
    472789     *
    473790     * @param mixed  $user
     
    477794     * @return mixed
    478795     */
     796    public function two_factor_disable_login_check( $user, $username, $password ) {
     797        // レートリミットの無効時間を取得
     798        $limit_time = $this->two_factor_rate_limit();
     799        if ( $limit_time > 0 ) {
     800            // ログインログに記録
     801            $this->write_log( self::LOGIN_STATUS_DISABLED );
     802
     803            return new WP_Error( 'empty_username', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく待ってから再度お試しください。" );
     804        }
     805        return $user;
     806    }
     807
     808
     809    /**
     810     * 認証フック: 2段階認証処理
     811     *
     812     * @param mixed  $user
     813     * @param string $username
     814     * @param string $password
     815     *
     816     * @return mixed
     817     */
    479818    public function authenticate_with_two_factor( $user, $username, $password ) {
    480 
    481819        // 初回アクセス、または初回認証時
    482         if ( ! isset( $_POST['google_authenticator_code'] ) ) {
    483 
     820        if ( ! isset( $_POST['authenticator_code'] ) ) {
    484821            // 認証失敗の場合
    485822            if ( is_wp_error( $user ) ) {
     
    492829            }
    493830
     831            // 認証情報の取得
     832            $auth_info = $this->get_2fa_auth_info( $user->ID );
     833            // 認証情報が存在しない場合
     834            if ( count( $auth_info ) === 0 ) {
     835                // データ移行漏れ対応
     836                $auth_info = $this->repair_migration_gaps( $user->ID );
     837                if ( count ( $auth_info ) === 0 ) {
     838                    return $user;
     839                }
     840            }
     841
     842            // リカバリーコードの有無
     843            $has_recovery   = true;
     844            $recovery_codes = $auth_info['recovery'];
     845            if ( ! $recovery_codes || ! is_array( $recovery_codes ) || count( $recovery_codes ) === 0 ) {
     846                $has_recovery = false;
     847            }
     848
    494849            // option key生成
    495850            $session_token = bin2hex( random_bytes( 16 ) );
    496851            $option_key    = $this->create_option_key( $session_token );
    497852
    498             // 保存用認証データ作成
     853            // 保存用ログインデータ作成
    499854            $option_data = array(
    500                 'user_id'    => $user->ID,
    501                 'user_login' => sanitize_text_field( $_POST['log'] ?? '' ),
    502                 'expires'    => time() + self::SESSION_EXPIRY,
    503                 'created'    => time(),
     855                'user_id'         => $user->ID,
     856                'user_login'      => sanitize_text_field( $_POST['log'] ?? '' ),
     857                'auth_method'     => intVal( $auth_info['method'] ),
     858                'expires'         => time() + self::SESSION_EXPIRY,
     859                'created'         => time(),
     860                'has_recovery'    => $has_recovery,
     861                'email_address'   => $this->mask_email( $user->ID ),
    504862            );
    505863
     
    507865            $this->set_option_data( $option_key, $option_data );
    508866
     867            // メール認証の場合、コードを生成して送信
     868            if ( intVal( $auth_info['method'] ) === self::USER_AUTH_METHOD_EMAIL ) {
     869                $this->send_2fa_email( $user->ID, $auth_info['secret'] );
     870            }
     871
    509872            // 2FA画面を表示して、処理終了
    510             $this->show_two_factor_form( $session_token );
     873            $this->show_two_factor_form(
     874                $session_token,
     875                intVal( $auth_info['method'] ),
     876                $has_recovery,
     877                $option_data['email_address'],
     878                false
     879            );
    511880        }
    512881
    513882        // 2FA関連のPOSTデータ取得
    514         $post_data = $this->get_2fa_post_data();
     883        $login_token = sanitize_text_field( $_POST['login_token'] ?? '' );
     884        $auth_code   = sanitize_text_field( $_POST['authenticator_code'] ?? '' );
    515885
    516886        // option key取得
    517         $option_key = $this->create_option_key( $post_data['login_token'] );
     887        $option_key = $this->create_option_key( $login_token );
    518888
    519889        // $userがWP_Errorの場合は処理をスキップ
     
    523893
    524894        // 2段階認証成功の場合
    525         if ( $this->verify_2fa_code( $user->ID, $post_data['google_authenticator_code'] ) ) {
     895        if ( $this->verify_2fa_code( $user->ID, $auth_code, $_POST['auth_method'] ) ) {
     896            // 失敗回数をリセット
     897            $this->reset_fail_count();
     898            // セッションデータを削除
    526899            $this->delete_option_data( $option_key );
     900            // メール認証の送信可能時間をリセット
     901            $this->delete_email_able_send_time( $user->ID );
    527902            return $user;
    528903        }
    529904
    530         // ログイン失敗時の処理を実行(ログイン回数・ログインログ)
    531         do_action( 'wp_login_failed', $_POST['log'], $user );
     905        // 2段階認証失敗の場合
     906        // 失敗回数をインクリメント
     907        $this->increment_fail_count();
     908
     909        // レートリミットの確認
     910        $limit_time = $this->two_factor_rate_limit();
     911
     912        $this->write_log( self::LOGIN_STATUS_FAILED );
     913
     914        if ( $limit_time > 0 ) {
     915            // ステータス無効状態の場合
     916            return new WP_Error( 'empty_username', "失敗回数が上限に達したため、{$limit_time}分間ログインできません。しばらく待ってから再度お試しください。" );
     917        }
    532918
    533919        // 2FA画面を再表示して、処理終了
    534         $this->show_two_factor_form( $post_data['login_token'] );
     920        $this->show_two_factor_form(
     921            $login_token,
     922            $_POST['auth_method'],
     923            $_POST['has_recovery'],
     924            $_POST['email_address'],
     925            false
     926        );
    535927    }
    536928
     
    603995                $log_data[] = array(
    604996                    'name'     => $data['user_login'],
    605                     'ip'       => $this->get_client_ip( '' ),
     997                    'ip'       => $this->get_client_ip(),
    606998                    'status'   => self::LOGIN_STATUS_FAILED,
    607                     'method'   => 1,
     999                    'method'   => self::METHOD_PAGE,
    6081000                    'login_at' => wp_date( 'Y-m-d H:i:s', $data['created'] ), // WPのタイムゾーンに変更して登録
    6091001                );
     
    8121204        }
    8131205    }
     1206
     1207    /**
     1208     * ユーザの2faログイン情報取得
     1209     *
     1210     * @param string $ip
     1211     *
     1212     * @return array $login_info
     1213     */
     1214    private function get_2fa_login_info( string $ip ): array {
     1215        global $wpdb;
     1216
     1217        $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
     1218        $sql        = "SELECT * FROM {$table_name} WHERE ip = %s";
     1219
     1220        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1221        $row = $wpdb->get_row( $wpdb->prepare( $sql, $ip ), ARRAY_A );
     1222
     1223        return $row ?? array();
     1224    }
     1225
     1226    /**
     1227     * ユーザの2fa認証情報取得
     1228     *
     1229     * @param int $user_id
     1230     *
     1231     * @return array $auth_info
     1232     */
     1233    public function get_2fa_auth_info( int $user_id ): array {
     1234        global $wpdb;
     1235
     1236        $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
     1237        $sql        = "SELECT * FROM {$table_name} WHERE user_id = %d";
     1238
     1239        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1240        $row = $wpdb->get_row( $wpdb->prepare( $sql, $user_id ), ARRAY_A );
     1241
     1242        if ( ! empty( $row ) && isset( $row['recovery'] ) ) {
     1243            $row['recovery'] = maybe_unserialize( $row['recovery'] );
     1244        }
     1245
     1246        return $row ?? array();
     1247    }
     1248
     1249    /**
     1250     * リカバリーコードの登録状況取得
     1251     *
     1252     * @param int $user_id
     1253     *
     1254     * @return bool true:登録済み、false:未登録
     1255     */
     1256    public function has_recovery_codes( int $user_id ): bool {
     1257        $auth_info = $this->get_2fa_auth_info( $user_id );
     1258
     1259        if ( empty( $auth_info ) || is_null( $auth_info['recovery'] ) ) {
     1260            return false;
     1261        }
     1262
     1263        return true;
     1264    }
     1265
     1266    /**
     1267     * ユーザの2fa認証方法設定
     1268     *
     1269     * @param int    $user_id
     1270     * @param int    $method
     1271     * @param string $secret
     1272     *
     1273     * @return void
     1274     */
     1275    public function setting_2fa_auth_info( int $user_id, int $method, string $secret ): void {
     1276        // 認証情報取得
     1277        $auth_info = $this->get_2fa_auth_info( $user_id );
     1278
     1279        if ( ! empty( $auth_info ) ) {
     1280            // 既に登録されている場合は更新
     1281            $this->update_2fa_auth_method( $user_id, $method, $secret );
     1282        } else {
     1283            // 登録されていない場合は新規登録
     1284            $this->insert_2fa_auth_method( $user_id, $method, $secret );
     1285        }
     1286    }
     1287
     1288    /**
     1289     * ユーザの2fa認証方法更新
     1290     *
     1291     * @param int    $user_id
     1292     * @param int    $method
     1293     * @param string $secret
     1294     * @return void
     1295     */
     1296    private function update_2fa_auth_method( int $user_id, int $method, string $secret ): void {
     1297        global $wpdb;
     1298
     1299        // 認証情報を更新
     1300        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1301        $wpdb->update(
     1302            $wpdb->prefix . 'cloudsecurewp_2fa_auth',
     1303            array(
     1304                'method' => $method,
     1305                'secret' => $secret,
     1306            ),
     1307            array( 'user_id' => $user_id )
     1308        );
     1309    }
     1310
     1311    /**
     1312     * ユーザの2fa認証方法登録
     1313     *
     1314     * @param int    $user_id
     1315     * @param int    $method
     1316     * @param string $secret
     1317     *
     1318     * @return void
     1319     */
     1320    private function insert_2fa_auth_method( int $user_id, int $method, string $secret ): void {
     1321        global $wpdb;
     1322
     1323        // 認証情報を新規登録
     1324        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1325        $wpdb->insert(
     1326            $wpdb->prefix . 'cloudsecurewp_2fa_auth',
     1327            array(
     1328                'user_id'  => $user_id,
     1329                'secret'   => $secret,
     1330                'recovery' => null,
     1331                'method'   => $method,
     1332            )
     1333        );
     1334    }
     1335
     1336    /**
     1337     * ログイン失敗回数インクリメント処理
     1338     *
     1339     * @return void
     1340     * @throws Exception SQLエラー発生時.
     1341     */
     1342    private function increment_fail_count(): void {
     1343        global $wpdb;
     1344
     1345        // テーブル名取得
     1346        $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
     1347
     1348        // 登録データ作成
     1349        $ip           = $this->get_client_ip();
     1350        $now_datetime = current_time( 'mysql' );
     1351        $data         = array(
     1352            'ip'           => $ip,
     1353            'status'       => self::LOGIN_STATUS_FAILED,
     1354            'failed_count' => 1,
     1355            'login_at'     => $now_datetime,
     1356        );
     1357
     1358        $row = $this->get_2fa_login_info( $ip );
     1359
     1360        if ( empty( $row ) ) {
     1361            // レコードが存在していない場合は新規登録
     1362            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1363            $wpdb->insert( $table_name, $data );
     1364        } else {
     1365            // レコードが存在している場合はカウントアップ
     1366            $data['failed_count'] = (int) $row['failed_count'] + 1;
     1367
     1368            // 3回失敗ごとにステータスを無効化に更新
     1369            if ( $data['failed_count'] % 3 === 0 ) {
     1370                $data['status'] = self::LOGIN_STATUS_DISABLED;
     1371            }
     1372
     1373            // 失敗回数とステータスを更新
     1374            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1375            $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
     1376        }
     1377    }
     1378
     1379    /**
     1380     * ログインログ記録処理(失敗時)
     1381     *
     1382     * @param int $status ログインステータス
     1383     *
     1384     * @return void
     1385     */
     1386    private function write_log( int $status ): void {
     1387        global $wpdb;
     1388
     1389        // ログインログに記録
     1390        $name   = sanitize_text_field( $_POST['log'] ?? '' );
     1391        $ip     = $this->get_client_ip();
     1392        $method = $this->login_log->is_xmlrpc() ? self::METHOD_XMLRPC : self::METHOD_PAGE;
     1393        $this->login_log->write_log( $name, $ip, $status, $method );
     1394    }
     1395
     1396    /**
     1397     * ログイン失敗回数リセット処理
     1398     *
     1399     * @return void
     1400     */
     1401    private function reset_fail_count(): void {
     1402        global $wpdb;
     1403
     1404        // テーブル名取得
     1405        $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
     1406
     1407        // 登録データ作成
     1408        $ip           = $this->get_client_ip();
     1409        $now_datetime = current_time( 'mysql' );
     1410        $data         = array(
     1411            'ip'           => $ip,
     1412            'status'       => self::LOGIN_STATUS_SUCCESS,
     1413            'failed_count' => 0,
     1414            'login_at'     => $now_datetime,
     1415        );
     1416
     1417        $row = $this->get_2fa_login_info( $ip );
     1418
     1419        if ( empty( $row ) ) {
     1420            // レコードが存在していない場合は新規登録
     1421            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1422            $wpdb->insert( $table_name, $data );
     1423        } else {
     1424            if ( $row['status'] === self::LOGIN_STATUS_SUCCESS ) {
     1425                // 既に成功状態の場合は何もしない
     1426                return;
     1427            }
     1428            // 失敗回数とステータスを更新
     1429            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1430            $wpdb->update( $table_name, $data, array( 'ip' => $ip ) );
     1431
     1432        }
     1433    }
     1434
     1435    /**
     1436     * レートリミットの無効時間を取得
     1437     * 無効ではない場合は0を返す
     1438     *
     1439     * @return int レートリミットの残り時間(分)
     1440     */
     1441    private function two_factor_rate_limit(): int {
     1442        // 現在のクライアントIPアドレスでレコードを取得
     1443        $ip  = $this->get_client_ip();
     1444        $row = $this->get_2fa_login_info( $ip );
     1445
     1446        if ( ! empty( $row ) && (int) $row['status'] === self::LOGIN_STATUS_DISABLED ) {
     1447            // 無効時間チェック
     1448            $limit_time = self::LATE_LIMIT_TIME_LIST[ (int) $row['failed_count'] ] ?? self::LATE_LIMIT_TIME_MAX;
     1449            $now_time   = strtotime( current_time( 'mysql' ) );
     1450            $block_time = strtotime( $row['login_at'] ) + $limit_time;
     1451
     1452            if ( $now_time < $block_time ) {
     1453                return (int) ceil( ( $block_time - $now_time ) / 60 );
     1454            }
     1455        }
     1456
     1457        return 0;
     1458    }
     1459
     1460    /**
     1461     * メール最終送信時刻更新処理
     1462     *
     1463     * @param int $user_id
     1464     *
     1465     * @return void
     1466     */
     1467    private function update_email_able_send_time( int $user_id ): void {
     1468        // 送信可能時刻を更新
     1469        $able_send_time = time() + self::EMAIL_SEND_LIMIT_TIME;
     1470        update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, $able_send_time );
     1471    }
     1472
     1473    /**
     1474     * メール最終送信時刻リセット処理
     1475     *
     1476     * @param int $user_id
     1477     *
     1478     * @return void
     1479     */
     1480    private function delete_email_able_send_time( int $user_id ): void {
     1481        // 送信可能時刻をリセット
     1482        update_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, 0 );
     1483    }
     1484
     1485    /**
     1486     * メール最終送信時刻取得処理
     1487     *
     1488     * @param int $user_id
     1489     *
     1490     * @return int 最終送信時刻(unixタイムスタンプ)
     1491     */
     1492    private function get_email_able_send_time( int $user_id ): int {
     1493        // 最終送信時刻を取得
     1494        $last_send = get_user_meta( $user_id, self::TWO_FACTOR_EMAIL_SEND, true );
     1495
     1496        if ( ! $last_send ) {
     1497            return 0;
     1498        }
     1499
     1500        return (int) $last_send;
     1501    }
     1502
     1503    /**
     1504     * wp_usermetaから2fa関連データを取得
     1505     *
     1506     * @param int $last_umeta_id
     1507     * @param int $batch_size
     1508     *
     1509     * @return array
     1510     */
     1511    private function fetch_user_metas( int $last_umeta_id, int $batch_size ): array {
     1512        global $wpdb;
     1513
     1514        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1515        $user_metas = $wpdb->get_results(
     1516            $wpdb->prepare(
     1517                "SELECT umeta_id, user_id, meta_value
     1518                FROM {$wpdb->usermeta}
     1519                WHERE meta_key = %s
     1520                    AND umeta_id > %d
     1521                ORDER BY umeta_id ASC
     1522                LIMIT %d",
     1523                self::TWO_FACTOR_SECRET_KEY,
     1524                $last_umeta_id,
     1525                $batch_size
     1526            ),
     1527            ARRAY_A
     1528        );
     1529
     1530        return $user_metas ?? array();
     1531    }
     1532
     1533    /**
     1534     * usermeta データをバルクインサート用データに変換
     1535     *
     1536     * @param array $user_metas
     1537     *
     1538     * @return array ['insert_data' => array, 'umeta_ids_map' => array]
     1539     */
     1540    private function convert_user_metas_to_insert_data( array $user_metas ): array {
     1541        $insert_data   = array();
     1542        $umeta_ids_map = array();
     1543
     1544        foreach ( $user_metas as $user_meta ) {
     1545            $user_id    = (int) $user_meta['user_id'];
     1546            $umeta_id   = (int) $user_meta['umeta_id'];
     1547            $meta_value = $user_meta['meta_value'];
     1548
     1549            // Base32デコードしてバイナリに変換
     1550            $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $meta_value );
     1551
     1552            // バイナリデータを16進数に変換
     1553            $hex_secret = bin2hex( $binary_secret );
     1554
     1555            // データを蓄積
     1556            $insert_data[] = array(
     1557                'user_id'  => $user_id,
     1558                'secret'   => $hex_secret,
     1559                'recovery' => null,
     1560                'method'   => self::USER_AUTH_METHOD_APP,
     1561            );
     1562
     1563            // umeta_idとuser_idのマッピングを保存
     1564            $umeta_ids_map[ $user_id ] = $umeta_id;
     1565        }
     1566
     1567        return array(
     1568            'insert_data'   => $insert_data,
     1569            'umeta_ids_map' => $umeta_ids_map,
     1570        );
     1571    }
     1572
     1573    /**
     1574     * 2fa認証データのバルクインサート
     1575     * (呼び出し元でトランザクションを管理すること)
     1576     *
     1577     * @param array $data_list
     1578     *
     1579     * @return array 失敗したuser_idのリスト
     1580     * @throws Exception SQLエラー発生時.
     1581     */
     1582    private function bulk_insert_2fa_auth( array $data_list ): array {
     1583        if ( empty( $data_list ) ) {
     1584            return array();
     1585        }
     1586
     1587        global $wpdb;
     1588
     1589        $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
     1590
     1591        // 挿入を試みるuser_idのリスト
     1592        $target_user_ids = array_column( $data_list, 'user_id' );
     1593
     1594        // プレースホルダーと値の準備
     1595        $values       = array();
     1596        $placeholders = array();
     1597
     1598        foreach ( $data_list as $data ) {
     1599            $values[]       = $data['user_id'];
     1600            $values[]       = $data['secret'];
     1601            $values[]       = $data['method'];
     1602            $placeholders[] = '(%d, %s, null, %d)';
     1603        }
     1604
     1605        // SQL実行(INSERT IGNOREで重複などのエラーを無視)
     1606        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1607        $result = $wpdb->query(
     1608            $wpdb->prepare(
     1609                "INSERT IGNORE INTO {$table_name}
     1610                (`user_id`, `secret`, `recovery`, `method`)
     1611                VALUES " . implode( ', ', $placeholders ),
     1612                $values
     1613            )
     1614        );
     1615
     1616        // SQLエラーチェック(INSERT IGNOREは重複エラーを返さないが、その他のエラーはチェック)
     1617        if ( $result === false || ! empty( $wpdb->last_error ) ) {
     1618            throw new Exception( 'Failed to bulk insert 2fa auth data: ' . $wpdb->last_error );
     1619        }
     1620
     1621        // 失敗したuser_id(挿入されなかったID)を特定
     1622        // INSERT IGNOREは重複時に0行を挿入するため、
     1623        // 挿入されなかった数 = 試行数 - 実際に挿入された行数
     1624        $failed_count = count( $target_user_ids ) - $result;
     1625
     1626        // 失敗したIDを特定したい場合は、挿入後に存在確認が必要
     1627        if ( $failed_count > 0 ) {
     1628            // 挿入後に実際に存在するuser_idを確認
     1629            $placeholders_check = implode( ', ', array_fill( 0, count( $target_user_ids ), '%d' ) );
     1630            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1631            $inserted_user_ids = $wpdb->get_col(
     1632                $wpdb->prepare(
     1633                    "SELECT user_id FROM {$table_name} WHERE user_id IN ($placeholders_check)",
     1634                    $target_user_ids
     1635                )
     1636            );
     1637
     1638            // 失敗したuser_idを特定
     1639            $failed_user_ids = array_diff( $target_user_ids, $inserted_user_ids );
     1640            return array_values( $failed_user_ids );
     1641        }
     1642
     1643        return array();
     1644    }
     1645
     1646    /**
     1647     * wp_usermetaから指定されたレコードを削除
     1648     * (呼び出し元でトランザクションを管理すること)
     1649     *
     1650     * @param array $umeta_ids
     1651     *
     1652     * @return void
     1653     * @throws Exception SQLエラー発生時.
     1654     */
     1655    private function delete_user_metas( array $umeta_ids ): void {
     1656        if ( empty( $umeta_ids ) ) {
     1657            return;
     1658        }
     1659
     1660        global $wpdb;
     1661
     1662        $delete_placeholders = implode( ', ', array_fill( 0, count( $umeta_ids ), '%d' ) );
     1663        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     1664        $result = $wpdb->query(
     1665            $wpdb->prepare(
     1666                "DELETE FROM {$wpdb->usermeta} WHERE umeta_id IN ($delete_placeholders)",
     1667                $umeta_ids
     1668            )
     1669        );
     1670
     1671        // 削除エラーチェック
     1672        if ( $result === false || ! empty( $wpdb->last_error ) ) {
     1673            throw new Exception( 'Failed to delete usermeta: ' . $wpdb->last_error );
     1674        }
     1675    }
     1676
     1677    /**
     1678     * 2fa既存ユーザーデータの移行処理
     1679     *
     1680     * @return void
     1681     */
     1682    public function migrate_2fa_user_data(): void {
     1683        global $wpdb;
     1684
     1685        $batch_size    = self::CLEANUP_BATCH_SIZE;
     1686        $last_umeta_id = 0;
     1687
     1688        while ( true ) {
     1689            try {
     1690                // wp_usermetaからシークレットキーをバッチサイズごとに取得
     1691                $user_metas = $this->fetch_user_metas( $last_umeta_id, $batch_size );
     1692
     1693                // 取得するレコードがなくなったら終了
     1694                if ( empty( $user_metas ) ) {
     1695                    break;
     1696                }
     1697
     1698                // 最後に取得したumeta_idを更新
     1699                $last_umeta_id = end( $user_metas )['umeta_id'];
     1700
     1701                // バルクインサート用のデータを準備
     1702                $conversion_result = $this->convert_user_metas_to_insert_data( $user_metas );
     1703                $insert_data       = $conversion_result['insert_data'];
     1704                $umeta_ids_map     = $conversion_result['umeta_ids_map'];
     1705
     1706                // 登録するデータがない場合は次のバッチへ
     1707                if ( empty( $insert_data ) ) {
     1708                    continue;
     1709                }
     1710
     1711                // トランザクション開始
     1712                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1713                $wpdb->query( 'START TRANSACTION' );
     1714
     1715                // バルクインサート実行(失敗したuser_idのリストを取得)
     1716                $failed_user_ids = $this->bulk_insert_2fa_auth( $insert_data );
     1717
     1718                // 成功したuser_idを特定
     1719                $target_user_ids     = array_column( $insert_data, 'user_id' );
     1720                $successful_user_ids = array_diff( $target_user_ids, $failed_user_ids );
     1721
     1722                // 成功したレコードのumeta_idを取得
     1723                $successful_umeta_ids = array();
     1724                foreach ( $successful_user_ids as $user_id ) {
     1725                    if ( isset( $umeta_ids_map[ $user_id ] ) ) {
     1726                        $successful_umeta_ids[] = $umeta_ids_map[ $user_id ];
     1727                    }
     1728                }
     1729
     1730                // 成功したレコードのみwp_usermetaから削除
     1731                $this->delete_user_metas( $successful_umeta_ids );
     1732
     1733                // トランザクションコミット
     1734                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1735                $wpdb->query( 'COMMIT' );
     1736
     1737            } catch ( Exception $e ) {
     1738                // エラー発生時はロールバック
     1739                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1740                $wpdb->query( 'ROLLBACK' );
     1741                break;
     1742            }
     1743        }
     1744    }
     1745
     1746    /**
     1747     * 2faログインテーブル作成
     1748     *
     1749     * @return void
     1750     */
     1751    private function create_login_table(): void {
     1752        global $wpdb;
     1753        $table_name = $wpdb->prefix . self::LOGIN_TABLE_NAME;
     1754        $table      = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
     1755
     1756        if ( is_null( $table ) ) {
     1757            $charset_collate = $wpdb->get_charset_collate();
     1758
     1759            $sql = "CREATE TABLE {$table_name} (
     1760                ip varchar( 39 ) NOT NULL DEFAULT '',
     1761                status int NOT NULL DEFAULT 0,
     1762                failed_count int NOT NULL DEFAULT 0,
     1763                login_at datetime NOT NULL,
     1764                PRIMARY KEY  (ip)
     1765                ) {$charset_collate}";
     1766
     1767            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1768            dbDelta( $sql );
     1769        }
     1770    }
     1771
     1772    /**
     1773     * 2fa認証テーブル作成
     1774     *
     1775     * @return void
     1776     */
     1777    private function create_auth_table(): void {
     1778        global $wpdb;
     1779        $table_name = $wpdb->prefix . self::AUTH_TABLE_NAME;
     1780        $table      = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
     1781
     1782        if ( is_null( $table ) ) {
     1783            $charset_collate = $wpdb->get_charset_collate();
     1784
     1785            $sql = "CREATE TABLE {$table_name} (
     1786                user_id bigint(20) UNSIGNED NOT NULL,
     1787                secret varchar(255) NOT NULL,
     1788                recovery longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
     1789                method int(11) UNSIGNED NOT NULL DEFAULT 1,
     1790                PRIMARY KEY  (user_id)
     1791                ) {$charset_collate}";
     1792
     1793            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     1794            dbDelta( $sql );
     1795        }
     1796    }
     1797
     1798    /**
     1799     * 2段階認証のテーブル作成とデータ移行を一括実行
     1800     *
     1801     * @return void
     1802     */
     1803    public function setup_2fa_tables(): void {
     1804        $this->create_login_table();
     1805        $this->create_auth_table();
     1806        $this->migrate_2fa_user_data();
     1807    }
     1808
     1809    /**
     1810     * データ移行漏れ修復処理
     1811     *
     1812     * @param int $user_id
     1813     *
     1814     * @return array
     1815     */
     1816    public function repair_migration_gaps( int $user_id ): array {
     1817        global $wpdb;
     1818
     1819        // 2fa認証テーブルが存在しない場合は作成
     1820        $this->create_auth_table();
     1821        // 2faログインテーブルが存在しない場合は作成
     1822        $this->create_login_table();
     1823
     1824        // 2fa認証情報を取得
     1825        $auth_info = $this->get_2fa_auth_info( $user_id );
     1826        if ( count( $auth_info ) !== 0 ) {
     1827            // 既に認証情報が存在する場合は何もしない
     1828            return $auth_info;
     1829        }
     1830
     1831        // wp_usermetaからシークレットキーを取得
     1832        $secret = get_user_meta( $user_id, self::TWO_FACTOR_SECRET_KEY, true );
     1833        if ( ! $secret ) {
     1834            // シークレットキーが存在しない場合は何もしない
     1835            return [];
     1836        }
     1837
     1838        try {
     1839            // トランザクション開始
     1840            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1841            $wpdb->query( 'START TRANSACTION' );
     1842
     1843            // 登録データ作成
     1844            // Base32デコードしてバイナリに変換
     1845            $binary_secret = CloudSecureWP_Time_Based_One_Time_Password::base32_decode( $secret );
     1846            // バイナリデータを16進数に変換
     1847            $hex_secret = bin2hex( $binary_secret );
     1848
     1849            // 2fa認証情報を登録
     1850            $this->insert_2fa_auth_method( $user_id, self::USER_AUTH_METHOD_APP, $hex_secret );
     1851
     1852            // シークレットキーをwp_usermetaから削除
     1853            delete_user_meta( $user_id, self::TWO_FACTOR_SECRET_KEY );
     1854
     1855            // トランザクションコミット
     1856            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1857            $wpdb->query( 'COMMIT' );
     1858            $auth_info = $this->get_2fa_auth_info( $user_id );
     1859            return $auth_info;
     1860        } catch ( Exception $e ) {
     1861            // エラー発生時はロールバック
     1862            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1863            $wpdb->query( 'ROLLBACK' );
     1864            return [];
     1865        }
     1866    }
     1867
     1868    /**
     1869     * AJAX: 秘密鍵を生成(アプリ認証時に使用)
     1870     *
     1871     * @return void
     1872     */
     1873    public function ajax_generate_key(): void {
     1874        // nonceチェック
     1875        check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
     1876
     1877        // 秘密鍵を生成
     1878        $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
     1879
     1880        wp_send_json_success( $secret_key_data );
     1881    }
     1882
     1883    /**
     1884     * AJAX: 秘密鍵を生成してメール送信(メール認証時に使用)
     1885     *
     1886     * @return void
     1887     */
     1888    public function ajax_generate_key_and_send_email(): void {
     1889        // nonceチェック
     1890        check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
     1891
     1892        // 返却JSONレスポンス初期化
     1893        $json_response = [
     1894            'is_send_email'     => false,
     1895            'remaining_seconds' => 0,
     1896        ];
     1897
     1898        $user_id = get_current_user_id();
     1899
     1900        // 最終送信時刻から30秒以内ならそのまま返す
     1901        $able_send_time    = $this->get_email_able_send_time( $user_id );
     1902        $now_time          = time();
     1903        $remaining_seconds = $able_send_time - $now_time;
     1904        if ( $remaining_seconds > 0 ) {
     1905            $json_response['remaining_seconds'] = $remaining_seconds;
     1906            wp_send_json_success( $json_response );
     1907        }
     1908
     1909        try {
     1910            // 秘密鍵を生成
     1911            $secret_key_data = CloudSecureWP_Time_Based_One_Time_Password::generate_secret_key();
     1912            // 認証コードを生成
     1913            $code = CloudSecureWP_Time_Based_One_Time_Password::create_code_for_email( $secret_key_data['hex'], self::AUTH_EMAIL_INTERVAL );
     1914            // メール送信
     1915            $this->send_code( $user_id, $code, self::AUTH_EMAIL_INTERVAL, 'setting' );
     1916            // 最終送信時刻を更新
     1917            $this->update_email_able_send_time( $user_id );
     1918
     1919            // 秘密鍵を保存
     1920            $transient_key = '2fa_setup_secret_' . $user_id;
     1921            set_transient( $transient_key, $secret_key_data['hex'], 180 );
     1922
     1923            $json_response['is_send_email']     = true;
     1924            $json_response['remaining_seconds'] = self::EMAIL_SEND_LIMIT_TIME;
     1925            wp_send_json_success( $json_response );
     1926
     1927        } catch ( Exception $e ) {
     1928            wp_send_json_error( $json_response );
     1929        }
     1930    }
     1931
     1932    /**
     1933     * AJAX: 認証コードの検証と保存
     1934     *
     1935     * @return void
     1936     */
     1937    public function ajax_verify_auth_code(): void {
     1938        // nonceチェック
     1939        check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
     1940
     1941        // 返却JSONレスポンス初期化
     1942        $json_response = [
     1943            'has_recovery' => false,
     1944        ];
     1945
     1946        $user_id = get_current_user_id();
     1947
     1948        $method      = sanitize_text_field( $_POST['method'] );
     1949        $interval    = ( $method === 'app' ) ? self::AUTH_APP_INTERVAL : self::AUTH_EMAIL_INTERVAL;
     1950        $auth_method = ( $method === 'app' ) ? self::USER_AUTH_METHOD_APP : self::USER_AUTH_METHOD_EMAIL;
     1951        $code        = isset( $_POST['code'] ) ? sanitize_text_field( $_POST['code'] ) : '';
     1952
     1953        if ( $method === 'app' ) {
     1954            $secret_key = isset( $_POST['secret_key'] ) ? sanitize_text_field( $_POST['secret_key'] ) : '';
     1955        } else {
     1956            $transient_key = '2fa_setup_secret_' . $user_id;
     1957            $secret_key    = get_transient( $transient_key );
     1958            if ( ! $secret_key ) {
     1959                wp_send_json_error( $json_response );
     1960            }
     1961        }
     1962
     1963        if ( ! CloudSecureWP_Time_Based_One_Time_Password::verify_code( $secret_key, $code, $interval ) ) {
     1964            wp_send_json_error( $json_response );
     1965        }
     1966
     1967        // 認証成功 - データベースに保存
     1968        try {
     1969            global $wpdb;
     1970
     1971            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1972            $wpdb->query( 'START TRANSACTION' );
     1973
     1974            // 2fa認証情報を設定
     1975            $this->setting_2fa_auth_info( $user_id, $auth_method, $secret_key );
     1976
     1977            // メール認証の場合
     1978            if ( $method === 'email' ) {
     1979                // 一時保存していた秘密鍵を削除
     1980                delete_transient( $transient_key );
     1981                // メール認証の送信可能時間をリセット
     1982                $this->delete_email_able_send_time( $user_id );
     1983            }
     1984
     1985            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1986            $wpdb->query( 'COMMIT' );
     1987
     1988            // リカバリーコードの設定状況を取得
     1989            $has_recovery                  = $this->has_recovery_codes( $user_id );
     1990            $json_response['has_recovery'] = $has_recovery;
     1991            wp_send_json_success( $json_response );
     1992        } catch ( Exception $e ) {
     1993            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
     1994            $wpdb->query( 'ROLLBACK' );
     1995
     1996            wp_send_json_error( $json_response );
     1997        }
     1998    }
     1999
     2000    /**
     2001     * AJAX: リカバリーコード生成
     2002     *
     2003     * @return void
     2004     */
     2005    public function ajax_generate_recovery_codes(): void {
     2006        // nonceチェック
     2007        check_ajax_referer( $this->get_feature_key() . '_csrf', 'nonce', true );
     2008
     2009        // 返却JSONレスポンス初期化
     2010        $json_response = [
     2011            'codes' => [],
     2012        ];
     2013
     2014        // 2段階認証が設定されているかチェック
     2015        try {
     2016            $user_id   = get_current_user_id();
     2017            $auth_info = $this->get_2fa_auth_info( $user_id );
     2018            if ( count( $auth_info ) === 0 ) {
     2019                wp_send_json_error( $json_response );
     2020            }
     2021
     2022            // リカバリーコードを生成
     2023            $codes = CloudSecureWP_Recovery_Codes::initialize_codes( $user_id );
     2024            if ( count( $codes ) === 0 ) {
     2025                wp_send_json_error( $json_response );
     2026            }
     2027
     2028            // 平文コードを返却(これは1度だけ表示される)
     2029            $json_response['codes'] = $codes;
     2030            wp_send_json_success( $json_response );
     2031
     2032        } catch ( Exception $e ) {
     2033            wp_send_json_error( $json_response );
     2034        }
     2035    }
     2036
     2037    /**
     2038     * ユーザーに認証コードをメール送信
     2039     *
     2040     * @param int    $user_id
     2041     * @param string $code
     2042     * @param int    $time_step 時間間隔(秒)
     2043     * @param string $status 'login' または 'setting'
     2044     *
     2045     * @return void
     2046     */
     2047    private function send_code( int $user_id, string $code, int $time_step, string $status ): void {
     2048        $user = get_userdata( $user_id );
     2049        if ( ! $user ) {
     2050            return;
     2051        }
     2052        $expire  = $time_step / 60;
     2053        $to      = $user->user_email;
     2054        $subject = '2段階認証コード';
     2055
     2056        if ( $status === 'login' ) {
     2057            $body  = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . " にログインしようとしています。\n";
     2058            $body .= "ログインを完了するには、以下の2段階認証コードを入力してください。\n\n";
     2059        } else {
     2060            $body  = "ユーザー {$user->user_login} が" . get_bloginfo( 'name' ) . "で2段階認証を設定しています。\n";
     2061            $body .= "セットアップを完了するには、以下の2段階認証コードを入力してください。\n\n";
     2062        }
     2063
     2064        $body .= "2段階認証コード: {$code}\n\n";
     2065        $body .= "このコードは{$expire}分間有効です。\n";
     2066        $body .= "もしこのメールに心当たりがない場合は、第三者があなたのパスワードを使用してログインを試みた可能性があります。\n";
     2067        $body .= "速やかにパスワードを変更することをお勧めします。\n\n";
     2068        $body .= "--\n";
     2069        $body .= "CloudSecure WP Security\n";
     2070
     2071        $this->wp_send_mail( $to, esc_html( $subject ), esc_html( $body ) );
     2072    }
    8142073}
  • cloudsecure-wp-security/trunk/modules/waf-engine.php

    r3400140 r3462167  
    508508        $results = array(
    509509            'is_matched'   => false,
    510             'match_string' => ''
     510            'match_string' => '',
    511511        );
    512         $matches               = array();
     512        $matches = array();
    513513
    514514        // リクエスト情報配列のkeyとvalueの変換
     
    541541        $results = array(
    542542            'is_matched'   => false,
    543             'match_string' => ''
     543            'match_string' => '',
    544544        );
    545545        $matches = array();
     
    631631     * @param array  $request_items
    632632     * @param array  $remove_rules
    633      * @return bool
    634      */
    635     public function is_remove_rule( $rule_id, $request_items, $remove_rules, $acf_post_types ): bool {
    636         $is_rule_removed = false;
     633     * @return array
     634     */
     635    public function is_remove_rule( $rule_id, $request_items, $remove_rules, $acf_post_types ): array {
     636        $is_rule_removed         = false;
     637        $modify_remove_variables = array();
    637638
    638639        if ( isset( $remove_rules['woocommerce'] ) ) {
     
    657658                if ( in_array( $rule_id, $remove_rules['ajax_customize'], true ) ) {
    658659                    if ( ( $request_items['args']['customize_autosaved'] ?? '' ) === 'on' || ( $request_items['args']['wp_customize'] ?? '' ) === 'on' ) {
     660                        $action = $request_items['args']['action'] ?? '';
     661                        if ( $action === 'update-widget' ) {
     662                            // actionがupdate-widgetの場合はルール全体を除外(cocoon)
     663                            $is_rule_removed = true;
     664                        } else {
     665                            // それ以外の場合はcustomized, customize_changeset_dataキーのみ除外
     666                            $modify_remove_variables['args'] = array( 'customized', 'customize_changeset_data' );
     667                        }
     668                    }
     669                }
     670            // ウィジェット保存時(cocoonテーマ)の除外
     671            } elseif ( preg_match( '/wp-admin\/widgets\.php/', $_SERVER['HTTP_REFERER'] ?? '' ) === 1 ) {
     672                if ( in_array( $rule_id, $remove_rules['ajax_customize'], true ) ) {
     673                    if ( ( $request_items['args']['action'] ?? '' ) === 'save-widget' ) {
     674                        $is_rule_removed = true;
     675                    }
     676                }
     677            // メニュー操作時(cocoonテーマ)の除外
     678            } elseif ( preg_match( '/wp-admin\/nav-menus\.php/', $_SERVER['HTTP_REFERER'] ?? '' ) === 1 ) {
     679                if ( in_array( $rule_id, $remove_rules['ajax_customize'], true ) ) {
     680                    $action = $request_items['args']['action'] ?? '';
     681                    if ( $action === 'update' || $action === 'edit' ) {
    659682                        $is_rule_removed = true;
    660683                    }
     
    664687
    665688        if ( isset( $remove_rules['rest_api'] ) ) {
    666             // 投稿・編集の操作は特定のルールを除外する(rest_api)
    667             if ( preg_match( '/templates|blocks|template-parts|navigation|global-styles|pages|posts|batch/', $request_items['request_filename'] ) === 1 ) {
     689            // 投稿・編集(templates, blocks, template-parts, navigation, pages, posts)の場合はcontentキーを除外
     690            if ( preg_match( '/templates|blocks|template-parts|navigation|pages|posts/', $request_items['request_filename'] ) === 1 ) {
    668691                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    669692                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    670                         $is_rule_removed = true;
     693                        $modify_remove_variables['args'] = array( 'content' );
     694                    }
     695                }
     696
     697            // global-styles の場合はstylesキーを除外
     698            } elseif ( preg_match( '/global-styles/', $request_items['request_filename'] ) === 1 ) {
     699                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
     700                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
     701                        $modify_remove_variables['args'] = array( '/^styles/' );
     702                    }
     703                }
     704
     705            // batch の場合はrequestsキーを除外
     706            } elseif ( preg_match( '/batch/', $request_items['request_filename'] ) === 1 ) {
     707                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
     708                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
     709                        $modify_remove_variables['args'] = array( '/^requests/' );
    671710                    }
    672711                }
     
    675714            } elseif ( preg_match( '/wp-admin\/post\.php/', $request_items['request_filename'] ) === 1 ) {
    676715                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    677                     if ( ( $request_items['args']['action'] ?? '' ) === 'editpost' ) {
    678                         $is_rule_removed = true;
    679                     }
    680                 }
    681 
    682             // nishikiルール除外
     716                    $action = $request_items['args']['action'] ?? '';
     717                    if ( $action === 'editpost' ) {
     718                        $is_rule_removed = true;
     719                    } elseif ( $action === 'post-quickdraft-save' ) {
     720                        $modify_remove_variables['args'] = array( 'content' );
     721                    }
     722                }
     723
     724            // カスタマイズ機能からの投稿作成時の除外
     725            } elseif ( preg_match( '/wp-admin\/customize\.php|customize_changeset_uuid/', $_SERVER['HTTP_REFERER'] ?? '' ) === 1 ) {
     726                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
     727                    if ( ( $request_items['args']['customize_autosaved'] ?? '' ) === 'on' || ( $request_items['args']['wp_customize'] ?? '' ) === 'on' ) {
     728                        $action = $request_items['args']['action'] ?? '';
     729                        if ( $action === 'customize-nav-menus-insert-auto-draft' ) {
     730                            $modify_remove_variables['args'] = array( '/^params/' );
     731                        }
     732                    }
     733                }
     734
     735            // nishiki の場合はcontentキーを除外
    683736            } elseif ( preg_match( '/wp\/v2\/nishiki_pro_(patterns|content)/', $request_items['request_filename'] ) === 1 ) {
    684737                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    685738                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    686                         $is_rule_removed = true;
    687                     }
    688                 }
    689 
    690             // xeriteルール除外
     739                        $modify_remove_variables['args'] = array( 'content' );
     740                    }
     741                }
     742
     743            // xerite の場合はcontentキーを除外
    691744            } elseif ( preg_match( '/wp\/v2\/xw_block_patterns/', $request_items['request_filename'] ) === 1 ) {
    692745                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    693746                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    694                         $is_rule_removed = true;
    695                     }
    696                 }
    697 
    698             // Lightningルール除外
     747                        $modify_remove_variables['args'] = array( 'content' );
     748                    }
     749                }
     750
     751            // Lightning の場合はcontentキーを除外
    699752            } elseif ( preg_match( '/wp\/v2\/(cta|vk-block-patterns)/', $request_items['request_filename'] ) === 1 ) {
    700753                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    701754                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    702                         $is_rule_removed = true;
    703                     }
    704                 }
    705 
    706             // SWELLルール除外
     755                        $modify_remove_variables['args'] = array( 'content' );
     756                    }
     757                }
     758
     759            // SWELL の場合はcontentキーを除外
    707760            } elseif ( preg_match( '/wp\/v2\/(lp|blog_parts)/', $request_items['request_filename'] ) === 1 ) {
    708761                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    709762                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    710                         $is_rule_removed = true;
    711                     }
    712                 }
    713 
    714             // Advanced Custom Fields除外
    715             // カスタム投稿タイプキーは小文字、アンダースコア、ダッシュのみを許容するが、念のためarray_mapで正規表現用にエスケープする
    716             } elseif ( preg_match( '/wp\/v2\/(' . implode( '|', array_map( 'preg_quote', $acf_post_types ) ) . ')/', $request_items['request_filename'] ) === 1 ) {
     763                        $modify_remove_variables['args'] = array( 'content' );
     764                    }
     765                }
     766
     767            // Snow Monkey の場合はcontentキーを除外
     768            } elseif ( preg_match( '/wp\/v2\/snow-monkey-search/', $request_items['request_filename'] ) === 1 ) {
    717769                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
    718770                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
    719                         $is_rule_removed = true;
    720                     }
    721                 }
    722             }
    723         }
    724 
    725         if ( isset( $remove_rules['coccon'] ) ) {
    726             // cocconルール除外
    727             if ( preg_match( '/wp-admin\/admin\.php\?page\=theme-(settings|func-text|ranking|affiliate-tag)/', $_SERVER['HTTP_REFERER'] ?? '' ) === 1 ) {
    728                 if ( in_array( $rule_id, $remove_rules['coccon'], true ) ) {
     771                        $modify_remove_variables['args'] = array( 'content' );
     772                    }
     773                }
     774
     775            // Advanced Custom Fields の場合はcontentキーを除外
     776            // カスタム投稿タイプキーは小文字、アンダースコア、ダッシュのみを許容するが、念のためarray_mapで正規表現用にエスケープする
     777            } elseif ( ! empty( $acf_post_types ) && preg_match( '/wp\/v2\/(' . implode( '|', array_map( 'preg_quote', $acf_post_types ) ) . ')/', $request_items['request_filename'] ) === 1 ) {
     778                if ( in_array( $rule_id, $remove_rules['rest_api'], true ) ) {
     779                    if ( preg_match( '/_locale\=user/', $_SERVER['QUERY_STRING'] ?? '' ) === 1 ) {
     780                        $modify_remove_variables['args'] = array( 'content' );
     781                    }
     782                }
     783            }
     784        }
     785
     786        // cocoonテーマでの除外処理(theme-func-text, theme-settings, theme-ranking, theme-affiliate-tag)
     787        if ( isset( $remove_rules['cocoon'] ) ) {
     788            $referer = $_SERVER['HTTP_REFERER'] ?? '';
     789            if ( preg_match( '/wp-admin\/admin\.php\?page\=theme-(func-text|settings|ranking|affiliate-tag)/', $referer ) === 1 ) {
     790                if ( in_array( $rule_id, $remove_rules['cocoon'], true ) ) {
    729791                    $action = $request_items['args']['action'] ?? '';
    730792                    if ( $action === 'new' || $action === 'edit' ) {
     
    751813
    752814        if ( isset( $remove_rules['vkexunit'] ) ) {
    753             // vkExUnitルール除外(メイン設定)
     815            // vkExUnitルール除外(メイン設定)
    754816            if ( preg_match( '/wp-admin\/admin.php\?page\=vkExUnit_main_setting/', $_SERVER['HTTP_REFERER'] ?? '' ) === 1 ) {
    755817                if ( in_array( $rule_id, $remove_rules['vkexunit'], true ) ) {
     
    760822            }
    761823
    762             // vkExUnitルール除外(cssカスタマイズ)
     824            // vkExUnitルール除外(cssカスタマイズ)
    763825            if ( preg_match( '/wp-admin\/admin.php\?page\=vkExUnit_css_customize/', $_SERVER['REQUEST_URI'] ?? '' ) === 1 ) {
    764826                if ( in_array( $rule_id, $remove_rules['vkexunit'], true ) ) {
     
    821883            }
    822884        }
    823         return $is_rule_removed;
     885        return array(
     886            'is_removed'              => $is_rule_removed,
     887            'modify_remove_variables' => $modify_remove_variables,
     888        );
    824889    }
    825890
     
    916981
    917982            // 特定の操作の場合、特定のルールを除外する
    918             $is_rule_removed = $this->is_remove_rule( $waf_rule['id'], $request_items, $remove_rules, $acf_post_types );
    919 
    920             if ( $is_rule_removed ) {
     983            $remove_rule_result = $this->is_remove_rule( $waf_rule['id'], $request_items, $remove_rules, $acf_post_types );
     984
     985            if ( $remove_rule_result['is_removed'] ) {
    921986                continue;
     987            }
     988
     989            // ルールのremove_variablesを動的に変更
     990            if ( ! empty( $remove_rule_result['modify_remove_variables'] ) ) {
     991                $waf_rule['remove_variables'] = array_merge_recursive(
     992                    $waf_rule['remove_variables'],
     993                    $remove_rule_result['modify_remove_variables']
     994                );
    922995            }
    923996
  • cloudsecure-wp-security/trunk/modules/waf.php

    r3230168 r3462167  
    1212    private const KEY_AVAILABLE_RULES    = self::KEY_FEATURE . '_available_rules';
    1313    private const RULES_CATEGORY         = self::KEY_FEATURE . '_rules_category';
    14     private const RULES_CATEGORY_VARUES  = array( 1, 2, 4, 8, 16 );
     14    private const RULES_CATEGORY_VALUES  = array( 1, 2, 4, 8, 16 );
    1515    private const RULES_CATEGORY_NAMES   = array(
    1616        'SQLインジェクション',
     
    7878            self::KEY_SEND_ADMIN_MAIL => self::SEND_ADMIN_MAIL_VALUES[1],
    7979            self::KEY_SEND_AT         => array(),
    80             self::KEY_AVAILABLE_RULES => 63,
     80            self::KEY_AVAILABLE_RULES => 31,
    8181        );
    8282
     
    9393        $ret = array(
    9494            self::KEY_SEND_ADMIN_MAIL => self::SEND_ADMIN_MAIL_VALUES,
    95             self::KEY_AVAILABLE_RULES => self::RULES_CATEGORY_VARUES,
     95            self::KEY_AVAILABLE_RULES => self::RULES_CATEGORY_VALUES,
    9696            self::RULES_CATEGORY      => array(
    97                 self::RULES_CATEGORY_VARUES[0] => self::RULES_CATEGORY_NAMES[0],
    98                 self::RULES_CATEGORY_VARUES[1] => self::RULES_CATEGORY_NAMES[1],
    99                 self::RULES_CATEGORY_VARUES[2] => self::RULES_CATEGORY_NAMES[2],
    100                 self::RULES_CATEGORY_VARUES[3] => self::RULES_CATEGORY_NAMES[3],
    101                 self::RULES_CATEGORY_VARUES[4] => self::RULES_CATEGORY_NAMES[4],
     97                self::RULES_CATEGORY_VALUES[0] => self::RULES_CATEGORY_NAMES[0],
     98                self::RULES_CATEGORY_VALUES[1] => self::RULES_CATEGORY_NAMES[1],
     99                self::RULES_CATEGORY_VALUES[2] => self::RULES_CATEGORY_NAMES[2],
     100                self::RULES_CATEGORY_VALUES[3] => self::RULES_CATEGORY_NAMES[3],
     101                self::RULES_CATEGORY_VALUES[4] => self::RULES_CATEGORY_NAMES[4],
    102102            ),
    103103        );
     
    277277        $settings        = $this->get_settings();
    278278        $send_at         = $settings[ self::KEY_SEND_AT ] ?? array();
     279        $tmp_send_at     = 0;
    279280
    280281        if ( ! empty( $send_at ) && is_array( $send_at ) ) {
     
    285286                }
    286287            }
    287         } else {
    288             $tmp_send_at = 0;
    289288        }
    290289
     
    320319            'rest_api'       => array( '950004', '950001', '950007', '950006' ),
    321320            'comment'        => array( '950004' ),
    322             'coccon'         => array( '950004' ),
     321            'cocoon'         => array( '950004' ),
    323322            'emanon'         => array( '950004', '950001', '950007' ),
    324323            'vkexunit'       => array( '950004', '950001', '950007' ),
  • cloudsecure-wp-security/trunk/readme.txt

    r3444508 r3462167  
    44Requires at least: 5.3.15
    55Tested up to: 6.9
    6 Stable tag: 1.3.24
     6Stable tag: 1.4.0
    77License: GPLv2 or later
    88License URI: http://www.gnu.org/licenses/gpl-2.0.html
     
    107107== Changelog ==
    108108
     109= 1.4.0 =
     110* 2段階認証機能にメール認証およびリカバリーコードの追加
     111* 2段階認証によるログインのセキュリティ強化
     112* 軽微な修正
     113
    109114= 1.3.24 =
    110115* 軽微な修正
  • cloudsecure-wp-security/trunk/uninstall.php

    r3124889 r3462167  
    2727    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}cloudsecurewp_server_error" );
    2828    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}cloudsecurewp_waf_log" );
     29    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}cloudsecurewp_2fa_auth" );
     30    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}cloudsecurewp_2fa_login" );
    2931}
    3032
Note: See TracChangeset for help on using the changeset viewer.