質問

投稿者:SPKN110670
登録日:2026年4月30日(木)

多段階認証について

ログイン機能を有するご契約者様向けお手続きサイトを検討しています。 ログイン方法はID/PWの認証後、登録済みメールアドレス宛てのワンタイムパスワードでの追加認証(多段階認証)を予定しています。ご契約者様アカウントは契約者DBを構築し、その中でID/PW/ワンタイムパスワードを保持するため、アカウント管理の多段階認証機能は対象外となります。このような多段階認証は実装可能でしょうか、また、実装可能な場合はサンプル(再現可能なコード例も可能な限り含めて)もご提示いただけますか。おそらく、SPIRAL ver.2においては基本的なWebパーツ機能では実装できず、PHP等でコーディングが必要なのではないかと思っております。

更新日:2026年5月1日(金)
  • 6
いいね

コメント

  • 実装可能です。ご認識の通り、SPIRAL ver.2 の標準Webパーツのみでは 難しく、PHP(カスタムAPI機能)でのコーディングが必要になります。 具体的には以下の3要素を組み合わせます。 ・認証エリアAPI(/login / /oneTimeLogin) ・カスタムAPI(PHP) ・DB更新トリガによる非同期メール送信 ■ 全体フロー [step0] ログイン画面(ID/PW入力) │ fetch: {action:"send_ワンタイムパスワード", id, password} ▼ [カスタムAPI step2.php : send_ワンタイムパスワード] ① 認証エリアAPI /login で token + recordId を取得 ② ワンタイムパスワード6桁・nonce・有効期限を生成 ③ 契約者DBの本人レコードを PATCH → DB更新トリガ → 非同期アクションでメール送信 │ 返却: {ok:true, nonce} ▼ [step1] ワンタイムパスワード入力画面(6桁コード入力) │ fetch: {action:"verify_ワンタイムパスワード", nonce, ワンタイムパスワード_code} ▼ [カスタムAPI step2.php : verify_ワンタイムパスワード] ① 契約者DBから nonce 一致レコードを検索 ② ワンタイムパスワードコード一致・有効期限を検証 ③ used フラグを立てて再利用防止 ④ 保存済 token で /oneTimeLogin → ワンタイムURL取得 │ 返却: {ok:true, redirect_url} ▼ window.location.href でワンタイムURLにリダイレクト ↓ 契約者向けページに「ログイン済み」状態で着地

    • いいね
    2026年5月1日(金)
  • ■ 詳細ポイント 【1】認証フェーズ(/login) 通常のフォーム認証を経由せず、APIで認証エリアに直接ログインして エリア認証トークンを取得します。レスポンスには認証された本人の recordId も含まれるため、ワンタイムパスワード書き込み先のレコード特定にそのまま使用できます。 【2】ワンタイムパスワード通知フェーズ(DB更新 → トリガ) 取得したワンタイムパスワードコード等を契約者DBの本人レコードに更新します。 DB側に登録/更新トリガを設定しておけば、非同期でメール送信などのアクションが発火し、 登録済みメールアドレスに自動送信されます。 メール送信ロジックをコード側で実装しなくて済むのが利点です。 【3】検証フェーズ(ワンタイムパスワード入力 → /oneTimeLogin) ユーザーが入力したワンタイムパスワードを契約者DB上のレコードと突合し、保存済みの エリア認証トークンを使って /oneTimeLogin を呼び出します。返却 される「クエリ付きワンタイムURL」にリダイレクトすれば、認証エリア にログイン済の状態で目的ページに遷移します。 【4】必要なSPIRAL側の設定 ・契約者DBへの追加フィールド (ワンタイムパスワードコード/有効期限/nonce/エリア認証トークン/使用済フラグ) ・DB更新トリガ + 非同期アクション(メール送信) ・カスタムAPIの作成(PHPタブにサンプル貼付) ・APIエージェント種別のAPIキー発行 + 認証エリアへのアクセス権付与

    • いいね
    2026年5月1日(金)
  • ■ サンプルコード 以下5ファイルをサンプルとして添付いたします。 各ファイルのコメント先頭に役割と配置先を記載しています。 ・step0.html ・・・ ログインページ BODYタブ (ID/PW入力フォーム / SPIRAL標準ソースを流用) ・step0.js ・・・ ログインページ JSタブ (submit抑止 → カスタムAPI呼出 → step1へ遷移) ・step1.html ・・・ ワンタイムパスワード入力ページ BODYタブ (6桁ワンタイムパスワードコード入力フォーム) ・step1.js ・・・ ワンタイムパスワード入力ページ JSタブ (カスタムAPI呼出 → ワンタイムURLへリダイレクト) ・step2.php ・・・ カスタムAPI PHPタブ (send_ワンタイムパスワード / verify_ワンタイムパスワード を1ファイルに統合) ファイル冒頭の $CFG セクションに設定値(APIキー・サイトID・認証エリアID・契約者DBのID・遷移先パス等)をまとめていますので、 貴社環境に合わせて書き換えてご利用ください。

    step0.html
    <div class="sp-form-container">
        <!--/* id(id) */-->
        <div class="sp-form-item sp-form-field">
          <div class="sp-form-label" th:text="${fields['id'].label}">
            Label
          </div>
          <div class="sp-form-data">
            <input type="text" class="sp-form-control" name="id" autocomplete="off" th:value="${inputs['id']}" th:placeholder="${fields['id'].placeholder}">
          </div>
        </div>
        <!--/* password(password) */-->
        <div class="sp-form-item sp-form-field">
          <div class="sp-form-label" th:text="${fields['password'].label}">
            Label
          </div>
          <div class="sp-form-data">
            <input type="password" class="sp-form-control" name="password" autocomplete="off" th:placeholder="${fields['password'].placeholder}">
          </div>
        </div>
        <div class="sp-form-item sp-form-message">
          <span class="sp-form-error" th:if="${errors['login'] != null}" th:text="${errors['login'].message}">Error message</span>
        </div>
        <div class="sp-form-item sp-form-interaction">
          <button class="sp-form-login-button" type="submit" name="action" value="next" th:text="${step.loginButtonLabel}">Login</button>
        </div>
    
        <!--/* JS制御用 (busy / error 表示 + 環境設定) */-->
        <div id="otpBusyBox" style="display:none;margin-top:12px;color:#555;font-size:13px;text-align:center;">
          OTPコードを送信しています…
        </div>
        <div id="otpErrorBox" class="sp-form-item sp-form-message"
             style="display:none;margin-top:8px;">
          <span class="sp-form-error" id="otpErrorMsg"></span>
        </div>
    
        <input type="hidden" id="apiEndpoint" value="/_program/otpLogin">
        <input type="hidden" id="step1Url"    value="/otpRegist">
      </div>
    step0.js-1
    // ============================================================
    // step0 / JSタブ : 認証エリアログイン画面
    // SPIRAL デフォルトのログインフォーム ( <input name="id"> / <input name="password"> /
    // <button class="sp-form-login-button" type="submit"> ) をそのまま流用。
    // ボタン押下時に submit を止めて、独自カスタムAPI(send_otp) に投げる。
    // 成功で nonce を sessionStorage に保存して step1 に遷移。
    // ============================================================
    
    (function () {
        'use strict';
    
        var SS_KEY = 'spiral_otp_nonce';
    
        function $(id) { return document.getElementById(id); }
    
        function input(name) {
            return document.querySelector('input[name="' + name + '"]');
        }
        function val(name) {
            var el = input(name);
            return el ? (el.value || '') : '';
        }
        function cfg(id, fallback) {
            var el = $(id);
            return (el && el.value) ? el.value : fallback;
        }
    
        function showBusy(on) {
            var el = $('otpBusyBox');
            if (el) { el.style.display = on ? 'block' : 'none'; }
        }
        function showError(msg) {
            var box = $('otpErrorBox'), p = $('otpErrorMsg');
            if (!box || !p) { return; }
            if (msg) { p.textContent = msg; box.style.display = 'block'; }
            else     { p.textContent = '';  box.style.display = 'none';  }
        }
    step0.js-2
        function callApi(payload) {
            var endpoint = cfg('apiEndpoint', '/api/customApi');
            showBusy(true);
            showError('');
            return fetch(endpoint, {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload),
            }).then(function (res) {
                return res.json().then(function (json) { return { httpOk: res.ok, json: json }; });
            }).then(function (r) {
                showBusy(false);
                if (!r.httpOk || r.json.status !== 'success') {
                    throw new Error((r.json && r.json.errorMessage) || 'サーバーエラーが発生しました');
                }
                var data = r.json.data || {};
                if (data.ok === false) { throw new Error(data.message || '処理に失敗しました'); }
                return data;
            }).catch(function (err) {
                showBusy(false);
                throw err;
            });
        }
    
        function onSubmit(e) {
            if (e && e.preventDefault) { e.preventDefault(); }
            if (e && e.stopPropagation) { e.stopPropagation(); }
    
            var id = val('id'), pw = val('password');
            if (!id || !pw) {
                showError('IDとパスワードを入力してください');
                return false;
            }
    
            callApi({ action: 'send_otp', id: id, password: pw })
                .then(function (data) {
                    if (!data.nonce) { throw new Error('nonceが返却されませんでした'); }
                    try { sessionStorage.setItem(SS_KEY, data.nonce); } catch (e2) {}
                    window.location.href = cfg('step1Url', '/area1/step1');
                })
                .catch(function (err) {
                    showError(err.message || String(err));
                });
    
            return false;
        }
    
    
    step0.js-3
        document.addEventListener('DOMContentLoaded', function () {
            // 念のため前回 nonce を掃除
            try { sessionStorage.removeItem(SS_KEY); } catch (e) {}
    
            // ログインボタンの click を奪う ( type=submit を止める )
            var btn = document.querySelector('.sp-form-login-button');
            if (btn) {
                btn.addEventListener('click', onSubmit);
            }
    
            // 念のため form の submit も拾う ( ブラウザの enter 送信対策 )
            var form = btn ? btn.closest('form') : document.querySelector('form');
            if (form) {
                form.addEventListener('submit', onSubmit);
            }
        });
    })();
    step1.html
    <div style="max-width:480px;margin:60px auto;font-family:sans-serif;">
    
        <h2 style="text-align:center;border-bottom:2px solid #333;padding-bottom:8px;">OTP入力</h2>
        <p style="color:#555;font-size:13px;text-align:center;margin-top:16px;">
            ご登録のメールアドレスに送信した6桁のOTPコードを入力してください。<br>
            OTPコードは5分以内に入力する必要があります。
        </p>
    
        <form id="otpForm" onsubmit="return false;" style="display:grid;gap:14px;margin-top:24px;">
            <label>
                OTPコード(6桁)
                <input type="text" id="otpCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code"
                       style="width:100%;padding:10px;box-sizing:border-box;letter-spacing:0.4em;text-align:center;font-size:20px;">
            </label>
            <button type="button" id="otpBtn"
                    style="padding:10px 16px;background:#06a;color:#fff;border:0;cursor:pointer;font-size:14px;">
                ログインする
            </button>
            <button type="button" id="otpResetBtn"
                    style="padding:6px 12px;background:#eee;color:#333;border:1px solid #ccc;cursor:pointer;font-size:12px;">
                最初からやり直す
            </button>
        </form>
    
        <div id="busyBox" style="display:none;margin-top:16px;color:#555;font-size:13px;text-align:center;">
            認証中…
        </div>
        <div id="errorBox"
             style="display:none;color:#c33;border:1px solid #c33;background:#fff0f0;padding:12px;margin-top:16px;font-size:13px;">
            <strong>エラー</strong>
            <p id="errorMsg" style="margin:4px 0 0;white-space:pre-wrap;word-break:break-all;"></p>
        </div>
    
        <!-- 環境ごとに変更するエンドポイント / 戻り先 -->
        <input type="hidden" id="apiEndpoint" value="/_program/otpLogin">
        <input type="hidden" id="step0Url"    value="/otpRegist/login">
    
    </div>
    
    • いいね
    2026年5月1日(金)
  • 続き

    step1.js-1
    // ============================================================
    // step1 / JSタブ : OTP入力画面
    // sessionStorage から nonce を読み込み、OTPコードと一緒に
    // カスタムAPI(verify_otp) に送る。成功でワンタイムURLにリダイレクト。
    // ============================================================
    
    (function () {
        'use strict';
    
        var SS_KEY = 'spiral_otp_nonce';
    
        function $(id) { return document.getElementById(id); }
        function val(id) { var el = $(id); return el ? (el.value || '') : ''; }
    
        function showBusy(on) {
            var el = $('busyBox');
            if (el) { el.style.display = on ? 'block' : 'none'; }
        }
        function showError(msg) {
            var box = $('errorBox'), p = $('errorMsg');
            if (!box || !p) { return; }
            if (msg) { p.textContent = msg; box.style.display = 'block'; }
            else     { p.textContent = '';  box.style.display = 'none';  }
        }
    
        function readNonce() {
            try { return sessionStorage.getItem(SS_KEY) || ''; }
            catch (e) { return ''; }
        }
        function clearNonce() {
            try { sessionStorage.removeItem(SS_KEY); } catch (e) {}
        }
    step1.js-2
        function backToStep0() {
            var url = val('step0Url') || '/area1/step0';
            window.location.href = url;
        }
    
        function callApi(payload) {
            var endpoint = val('apiEndpoint') || '/api/customApi';
            showBusy(true);
            showError('');
            return fetch(endpoint, {
                method: 'POST',
                credentials: 'same-origin',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload),
            }).then(function (res) {
                return res.json().then(function (json) { return { httpOk: res.ok, json: json }; });
            }).then(function (r) {
                showBusy(false);
                if (!r.httpOk || r.json.status !== 'success') {
                    throw new Error((r.json && r.json.errorMessage) || 'サーバーエラーが発生しました');
                }
                var data = r.json.data || {};
                if (data.ok === false) { throw new Error(data.message || '処理に失敗しました'); }
                return data;
            }).catch(function (err) {
                showBusy(false);
                throw err;
            });
        }
    
        function onVerify() {
            var code = (val('otpCode') || '').trim();
            if (!/^\d{6}$/.test(code)) {
                showError('OTPコードは6桁の数字で入力してください');
                return;
            }
            var nonce = readNonce();
            if (!nonce) {
                showError('セッションが失われました。最初からやり直してください');
                setTimeout(backToStep0, 1500);
                return;
            }
    
    
    step1.js-3
            callApi({ action: 'verify_otp', nonce: nonce, otp_code: code })
                .then(function (data) {
                    if (!data.redirect_url) { throw new Error('リダイレクト先が取得できませんでした'); }
                    clearNonce();
                    window.location.href = data.redirect_url;
                })
                .catch(function (err) {
                    showError(err.message || String(err));
                });
        }
    
        function onReset() {
            clearNonce();
            backToStep0();
        }
    
        document.addEventListener('DOMContentLoaded', function () {
            // nonce が無い場合は step0 に戻す
            if (!readNonce()) {
                backToStep0();
                return;
            }
    
            var b1 = $('otpBtn');      if (b1) { b1.addEventListener('click', onVerify); }
            var b2 = $('otpResetBtn'); if (b2) { b2.addEventListener('click', onReset); }
    
            // Enter キーでも送信
            var c = $('otpCode');
            if (c) {
                c.focus();
                c.addEventListener('keydown', function (e) {
                    if (e.key === 'Enter') { e.preventDefault(); onVerify(); }
                });
            }
        });
    })();
    • いいね
    2026年5月1日(金)
  • 続き

    step2.php-1
    <?php
    /**
     * カスタムAPI(1ファイル統合版)
     * SPIRAL ver.2 / カスタムAPI / PHPタブ
     *
     * フロントエンド (step1.html / step1.js) からの fetch を受け、
     * action パラメータでフェーズを切り替える。
     *
     * ─────────────────────────────────────────────────────────────
     * Phase 1: action = "send_otp"
     *   入力 : { action: "send_otp", id: "<認証ID>", password: "<パスワード>" }
     *   処理 :
     *     1) /sites/{siteId}/authentications/{authId}/login で認証エリアにログイン
     *     2) レスポンスから token (エリア認証トークン) と recordId を取得
     *     3) 6桁OTP / 32文字nonce / 有効期限(+5分) を生成
     *     4) ログイン本人のレコードを PATCH (契約者DBの該当レコードを更新)
     *        (更新トリガ → 非同期アクション → メール送信が走る前提)
     *   返却 : { ok: true, nonce: "..." } / { ok: false, message: "..." }
     *
     * Phase 2: action = "verify_otp"
     *   入力 : { action: "verify_otp", nonce: "<送信時のnonce>", otp_code: "<6桁>" }
     *   処理 :
     *     1) 契約者DBから @otp_nonce 一致 & @used=0 のレコードを取得
     *     2) otp_code 一致 / 有効期限内 を確認
     *     3) used=1 に PATCH (再利用防止)
     *     4) 保存済 area_token で /oneTimeLogin → ワンタイムURL取得
     *   返却 : { ok: true, redirect_url: "https://..." } / { ok: false, message: "..." }
     * ─────────────────────────────────────────────────────────────
     *
     */
    
    // =============================================================================
    // 設定値(環境変数)
    // =============================================================================
    $CFG = [
        'apiKey'       => $SPIRAL->getEnvValue('SPIRAL_API_KEY'),
        'appId'        => $SPIRAL->getEnvValue('SPIRAL_APP_ID'),
        'dbIdOtp'      => $SPIRAL->getEnvValue('DB_ID_OTP'),
        'siteId'       => $SPIRAL->getEnvValue('SITE_ID'),
        'authId'       => '7729',                                   // 認証エリアID 
        'baseUrl'      => 'https://api.spiral-platform.com/v1',
        'redirectPath' => '/otpArea',                       // ログイン後のリダイレクト先 (公開URLのパス部分)
        'otpTtlMin'    => 5,                                        // OTP有効期間(分)
    ];
    
    
    
    
    step2.php-2
    // =============================================================================
    // 共通ヘルパ: SPIRAL API curl 呼び出し
    // =============================================================================
    function spiralCall($method, $url, $data, $apiKey) {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $apiKey,
            'X-Spiral-Api-Version: 1.1',
        ]);
        curl_setopt($ch, CURLOPT_URL, $url);
    
        if ($method !== 'GET') {
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
            if ($data !== null) {
                curl_setopt($ch, CURLOPT_POSTFIELDS,
                    json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
            }
        }
    
        $body = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
    
        return [
            'code' => (int)$code,
            'body' => json_decode($body, true),
            'raw'  => (string)$body,
        ];
    }
    
    
    
    step2.php-3
       // =======
    // Phase 1: ログイン → OTP生成 → DB登録
    // ======
    function phaseSendOtp($req, $cfg) {
        $id = isset($req['id'])       ? (string)$req['id']       : '';
        $pw = isset($req['password']) ? (string)$req['password'] : '';
    
        if ($id === '' || $pw === '') {
            return ['ok' => false, 'message' => 'IDとパスワードを入力してください'];
        }
    
        // -- 環境変数の事前検証 (空だと URL が不正になり 404 で "not found" になる) --
        $missing = [];
        if ((string)$cfg['siteId']  === '') { $missing[] = 'SITE_ID'; }
        if ((string)$cfg['apiKey']  === '') { $missing[] = 'SPIRAL_API_KEY'; }
        if ((string)$cfg['authId']  === '') { $missing[] = 'authId'; }
        if (!empty($missing)) {
            return [
                'ok'      => false,
                'message' => '設定値が空です: ' . implode(', ', $missing) . ' (SPIRAL カスタムAPIの環境変数を確認)',
            ];
        }
    
        // -- 1) /login で認証エリアにログイン --
        $loginUrl = $cfg['baseUrl']
                  . '/sites/'           . $cfg['siteId']
                  . '/authentications/' . $cfg['authId']
                  . '/login';
        $loginRes = spiralCall('POST', $loginUrl, ['id' => $id, 'password' => $pw], $cfg['apiKey']);
    
        if ($loginRes['code'] !== 200) {
            $m = isset($loginRes['body']['message']) ? (string)$loginRes['body']['message'] : '認証に失敗しました';
            return [
                'ok'      => false,
                'message' => 'ログインエラー: ' . $m
                           . ' (HTTP ' . $loginRes['code'] . ', URL=' . $loginUrl . ')',
            ];
        }
    
        $areaToken = isset($loginRes['body']['token'])    ? (string)$loginRes['body']['token']    : '';
        $recordId  = isset($loginRes['body']['recordId']) ? (string)$loginRes['body']['recordId'] : '';
        if ($areaToken === '') {
            return ['ok' => false, 'message' => '認証トークンが取得できませんでした'];
        }
        if ($recordId === '') {
            return ['ok' => false, 'message' => 'recordId が取得できませんでした (loginレスポンス確認)'];
        } 
    step2.php-4
    // -- 2) OTP / nonce / 有効期限を生成 --
        $otpCode   = str_pad((string)mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
        $nonce     = bin2hex(random_bytes(16));
        // SPIRAL の日時型は ISO 8601 / UTC ( 例: 2020-04-30T17:06:29Z )
        $expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + (int)$cfg['otpTtlMin'] * 60);
    
        // -- 3) ログイン本人のレコードを PATCH (更新トリガ → 非同期アクション → メール送信) --
        // /login のレスポンスに recordId が入っているので検索不要、直接 PATCH する
        $patchUrl  = $cfg['baseUrl']
                   . '/apps/' . $cfg['appId']
                   . '/dbs/'  . $cfg['dbIdOtp']
                   . '/records/' . rawurlencode($recordId);
        // 全値を文字列で送る (SPIRAL v2 仕様、数値型も "0"/"1" のような文字列でOK)
        $patchBody = [
            'otp_code'       => $otpCode,
            'otp_nonce'      => $nonce,
            'otp_expires_at' => $expiresAt,
            'area_token'     => $areaToken,
            'used'           => '0',
        ];
        $patchRes = spiralCall('PATCH', $patchUrl, $patchBody, $cfg['apiKey']);
    
        if ($patchRes['code'] < 200 || $patchRes['code'] >= 300) {
            return [
                'ok'      => false,
                'message' => 'OTPレコード更新失敗 (HTTP ' . $patchRes['code'] . ') '
                           . 'URL=' . $patchUrl . ' '
                           . 'RES=' . substr($patchRes['raw'], 0, 500),
            ];
        }
    
        return ['ok' => true, 'nonce' => $nonce];
    }
    step2.php-5
    // =============================================================================
    // Phase 2: OTP検証 → ワンタイムURL発行
    // =============================================================================
    function phaseVerifyOtp($req, $cfg) {
        $nonce    = isset($req['nonce'])    ? (string)$req['nonce']    : '';
        $otpInput = isset($req['otp_code']) ? (string)$req['otp_code'] : '';
    
        if ($nonce === '' || $otpInput === '') {
            return ['ok' => false, 'message' => 'OTPコードを入力してください'];
        }
    
        // -- 1) 契約者DBから nonce 一致 & used=0 を検索 --
        $whereExpr = "@otp_nonce = '" . str_replace("'", "''", $nonce) . "' AND @used = '0'";
        $searchUrl = $cfg['baseUrl']
                   . '/apps/' . $cfg['appId']
                   . '/dbs/'  . $cfg['dbIdOtp']
                   . '/records?where=' . rawurlencode($whereExpr);
        $searchRes = spiralCall('GET', $searchUrl, null, $cfg['apiKey']);
    
        if ($searchRes['code'] !== 200 || empty($searchRes['body']['items'])) {
            return ['ok' => false, 'message' => 'OTPコードが正しくありません'];
        }
    
        $rec        = $searchRes['body']['items'][0];
        $recordId   = isset($rec['_id'])             ? (string)$rec['_id']             : '';
        $storedCode = isset($rec['otp_code'])        ? (string)$rec['otp_code']        : '';
        $storedExp  = isset($rec['otp_expires_at'])  ? (string)$rec['otp_expires_at']  : '';
        $areaToken  = isset($rec['area_token'])      ? (string)$rec['area_token']      : '';
    
        // -- 2) コード/期限チェック --
        if ($storedCode === '' || $storedCode !== $otpInput) {
            return ['ok' => false, 'message' => 'OTPコードが正しくありません'];
        }
        // ISO 8601 / UTC で保存している前提
        if ($storedExp !== '' && strtotime($storedExp) < time()) {
            return ['ok' => false, 'message' => 'OTPの有効期限が切れました。最初からやり直してください'];
        }
        if ($areaToken === '') {
            return ['ok' => false, 'message' => '認証トークンが取得できませんでした'];
        }
    
    • いいね
    2026年5月1日(金)
  • 本回答に添付するコードは、仕組みの実現性を示すサンプルであり、 本番環境への適用を保証するものではありません。 特に、ワンタイムパスワード総当たり対策・タイミング攻撃対策・暗号学的乱数の利用・レート制限・監査ログ・エンドポイント保護等は、 未検証または簡易実装のままです。 本番投入前には貴社のセキュリティ要件に合わせた追加実装・ レビューを必ず実施してください。 ※クエスチョンボードの文字数制限の仕様上ファイルが小分けになってしまった事ご容赦ください。 以上

    step2.php-6
        // -- 3) used=1 に更新(再利用防止) --
        if ($recordId !== '') {
            $patchUrl = $cfg['baseUrl']
                      . '/apps/' . $cfg['appId']
                      . '/dbs/'  . $cfg['dbIdOtp']
                      . '/records/' . rawurlencode($recordId);
            spiralCall('PATCH', $patchUrl, ['used' => '1'], $cfg['apiKey']);
        }
    
        // -- 4) 保存済 area_token で /oneTimeLogin → ワンタイムURL --
        $otlUrl = $cfg['baseUrl']
                . '/sites/' . $cfg['siteId']
                . '/authentications/' . $cfg['authId']
                . '/oneTimeLogin';
        $otlRes = spiralCall('POST', $otlUrl, [
            'token' => $areaToken,
            'path'  => $cfg['redirectPath'],
        ], $cfg['apiKey']);
    
        if ($otlRes['code'] !== 200) {
            $m = isset($otlRes['body']['message']) ? (string)$otlRes['body']['message'] : 'ワンタイムURL発行に失敗しました';
            return [
                'ok'      => false,
                'message' => $m . ' (HTTP ' . $otlRes['code'] . ') '
                           . 'path=[' . $cfg['redirectPath'] . '] '
                           . 'RES=' . substr($otlRes['raw'], 0, 400),
            ];
        }
    
        $oneTimeUrl = isset($otlRes['body']['url']) ? (string)$otlRes['body']['url'] : '';
        if ($oneTimeUrl === '') {
            return ['ok' => false, 'message' => 'ワンタイムURLの取得結果が不正です'];
        }
    
        return ['ok' => true, 'redirect_url' => $oneTimeUrl];
    }
    
    // =============================================================================
    // ディスパッチ
    // =============================================================================
    $req    = $SPIRAL->getCustomApiRequestBody();
    $action = (isset($req['action']) ? (string)$req['action'] : '');
    
    if ($action === 'send_otp') {
        $result = phaseSendOtp($req, $CFG);
    } elseif ($action === 'verify_otp') {
        $result = phaseVerifyOtp($req, $CFG);
    } else {
        $result = ['ok' => false, 'message' => '不正なアクションです: ' . $action];
    }
    
    $SPIRAL->setCustomApiResponse($result);
    • いいね
    2026年5月1日(金)
あなたもログインして、
回答してみませんか?
質問がまとまらない方へ チャットコミュニティで気軽に聞いてみよう! 疑問や課題が整理できていなくても問題ありません。SPIRAL®で解決できる範囲がまだわからなくても質問できます。「ここで聞くと場違いかな?」というお悩みでも歓迎します。
  • Discordで聞く
  • Slackで聞く