diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d3a04e5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,20 @@ +name: Plugin tests +on: [push, pull_request] +permissions: + contents: read +jobs: + php: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['7.4', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: php -l didit-verify.php && php -l uninstall.php + - run: php tests/test-shortcode-assets.php + - run: php tests/test-webhook-signature.php + - run: php tests/test-verification-authorization.php diff --git a/README.md b/README.md index 583f164..ceb1c03 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,15 @@ Identity verification for WordPress & WooCommerce using the [Didit SDK](https:// | **Product scope** | Require verification for all products, only selected products, or all except selected | | **Secure** | API key stays server-side; CSRF nonce + rate limiting on session endpoint | +## Verification decisions + +Use **API Session** mode for content gating and WooCommerce checkout. +UniLink is for launching a flow and reviewing the result manually in Didit. +The server binds each created session to its visitor or order, and retrieves the Didit decision before accepting a browser completion or checkout request. +Only `Approved` grants access; errors, `In Review`, `Declined`, expired sessions and unrelated IDs do not. +Signed webhooks update only sessions created by this site. +After upgrading from 0.3.1 or earlier, existing users need to verify again because earlier browser-reported approvals are not trusted. + ## Third-Party Service This plugin connects to the [Didit](https://didit.me) identity verification service. When a verification session is created (API mode), the plugin sends data to Didit's servers. The verification UI loads in an iframe from `verify.didit.me`. @@ -44,7 +53,7 @@ Open and complete the WordPress setup wizard. **Settings → Didit Verify** -#### UniLink mode (simplest) +#### UniLink mode (manual review) 1. Set **Mode** to `UniLink` 2. Paste your UniLink URL (from [Didit Console](https://business.didit.me) → Workflow → Copy Link) @@ -83,9 +92,9 @@ Customize the verification button from **Settings → Didit Verify → Button Ap |---------|---------|-------------| | **Button Text** | "Verify your Identity" | Label before verification | | **Success Text** | "Identity Verified ✓" | Label after verification | -| **Background Color** | `#2667ff` | Button background | +| **Background Color** | `#111111` | Button background | | **Text Color** | `#ffffff` | Button text | -| **Border Radius** | `8px` | Corner rounding (0 = square, 50 = pill) | +| **Border Radius** | `999px` | Corner rounding (0 = square, 999 = pill) | | **Padding** | `12px × 24px` | Vertical × horizontal | | **Font Size** | `16px` | Button font size | @@ -185,7 +194,7 @@ Fields left empty use the default text, which follows the site language — Germ The verification box appears on the **order confirmation page** (classic and block themes), the **My Account → order view**, and a verification link is added to **customer order emails**. Works for **guest customers** too — the order key authenticates the request, no login needed. - Sessions are created server-side from the order's billing data and **reused** across clicks/reloads (no duplicate sessions, no wasted rate limit). -- **Hold Orders** (optional): orders stay **On hold** until the Didit webhook confirms approval, then move to **Processing** automatically. Requires the Webhook Secret to be configured — the browser alone never releases a held order. +- **Hold Orders** (optional): orders stay **On hold** until Didit confirms approval through the decision API or a signed webhook, then move to **Processing** automatically. Requires the Webhook Secret to be configured — the browser alone never releases a held order. - **Reminders** (optional): email customers who haven't verified, every N days, capped at a maximum per order. Scheduled with Action Scheduler (ships with WooCommerce). Reminders stop on approval/decline or when the order is cancelled/refunded. #### Webhooks (recommended) @@ -301,7 +310,7 @@ wordpress-plugin/ ``` User clicks button → JS calls DiditSdk.startVerification({ url }) → Modal opens -→ User completes verification → onComplete fires → Button shows "Verified" +→ User completes verification → onComplete fires → Show submitted message for manual review ``` ### API Flow @@ -311,10 +320,11 @@ User clicks button → JS sends POST /wp-json/didit/v1/session with: X-WP-Nonce (CSRF) + billing data (if WC checkout) → PHP checks: nonce ✓ → login ✓ → rate limit ✓ -→ PHP calls Didit API with API key (server-side) → returns { url } +→ PHP calls Didit API with API key (server-side) → binds session to visitor → returns { url, sessionId } → JS calls DiditSdk.startVerification({ url }) → modal opens -→ User completes → onComplete fires → button shows "Verified" -→ JS sends POST /wp-json/didit/v1/verify → saves result to user meta +→ User completes → onComplete fires → JS requests server confirmation +→ PHP checks the binding and retrieves the Didit decision +→ Only a server-confirmed Approved decision shows "Verified" ``` ### WooCommerce Flow @@ -324,37 +334,24 @@ Same as above, plus: → Billing data (name, email, phone, address) auto-sent as expected_details → Country code converted from alpha-2 to alpha-3 automatically → Session ID written to hidden checkout field -→ On "Place Order", PHP validates the field is not empty +→ On "Place Order", PHP validates ownership and retrieves the current Didit decision +→ Checkout proceeds only for Approved → Session ID saved to order meta (_didit_session_id) → Visible in admin order screen ``` ## Security -The plugin acts as a **secure backend proxy** between the browser and the Didit API. A hacker cannot create sessions directly — every request goes through multiple security layers: - -| # | Layer | What it prevents | -|---|-------|-----------------| -| 1 | **CSRF nonce** | Cross-site request forgery. Request must originate from a page served by WordPress. | -| 2 | **Require login** | Anonymous abuse. Only registered WordPress users can create sessions (configurable, **ON by default**). | -| 3 | **Per-user rate limit** | Logged-in users: max **10 sessions/hour** per user. | -| 4 | **Per-IP rate limit** | Guests (if login not required): max **3 sessions/hour** per IP. | -| 5 | **API key server-only** | Key extraction. The Didit API key is in `wp_options` (database), never in HTML/JS. | -| 6 | **Input sanitization** | Injection. All fields are whitelisted and sanitized. Metadata from the server (`wp_user_id`, `wp_ip`) can never be overwritten by the frontend. | - -### Why `expected_details` from the frontend is safe - -The `expected_details` (name, country, address) are sent from the checkout form. A hacker could theoretically modify them — but this **only hurts themselves**: Didit compares these against the real identity document. Fake expected details → verification **fails**. They can't use this to pass verification. +The API key stays on the server. +Session creation requires a WordPress REST nonce and applies login settings and rate limits. +Guest checkout and valid after-purchase order links can start verification without a WordPress login. +A nonce is not proof of identity, and expected details from the browser are inputs to verification rather than authorization. -### Attack scenario analysis - -| Attack | Protection | -|--------|-----------| -| Hacker scripts mass session creation from another site | **Layer 1**: nonce rejected (wrong origin) | -| Hacker scripts mass creation from the WordPress site | **Layer 3/4**: rate limited to 10/hour per user, 3/hour per IP for guests. | -| Hacker creates a bot that reloads + creates sessions | **Layer 2**: must be logged in. **Layer 3**: 10/hour max per user. **Layer 4**: 3/hour per IP if guest. | -| Hacker sends fake billing data | **Safe**: only hurts their own verification (document won't match). | -| Hacker tries to extract the API key | **Layer 5**: key is only in the database, never in any response or HTML. | +The server records ownership when it creates a session. +Completion requests and checkout validation require that binding and a matching decision retrieved from Didit. +A browser-supplied status, arbitrary session ID or signed webhook metadata cannot assign a session to another visitor or order. +Only a known session and authenticated status update can update protected access. +API outages and unknown decisions fail closed. ## Customization @@ -391,13 +388,14 @@ When a user completes verification, the plugin saves these fields to WordPress u |----------|-------|-------------| | `_didit_verified` | `1` | User is verified | | `_didit_session_id` | UUID | Didit session ID | +| `_didit_confirmed_session_id` | UUID | Session whose status was confirmed by the server | | `_didit_status` | `Approved` / `Pending` / `Declined` | Verification result | | `_didit_verified_at` | datetime | When verification was completed | You can query this in PHP: ```php -$is_verified = get_user_meta($user_id, '_didit_verified', true); +$is_verified = Didit_Verify::init()->is_user_verified($user_id); ``` A **Didit** column appears in the admin Users list showing a green checkmark for verified users and a dash for unverified. @@ -436,12 +434,12 @@ document.addEventListener('didit:complete', function (e) { | Method | Endpoint | Description | Auth | |--------|----------|-------------|------| | `POST` | `/wp-json/didit/v1/session` | Create a verification session | CSRF nonce + login, or order key (after-purchase) | -| `POST` | `/wp-json/didit/v1/verify` | Save verification result to user/order meta | Login, or order key (after-purchase) | +| `POST` | `/wp-json/didit/v1/verify` | Confirm the bound session with Didit and update its status | REST nonce and browser/user binding, or a valid order key | | `POST` | `/wp-json/didit/v1/webhook` | Didit webhook receiver (`status.updated`) | HMAC-SHA256 signature (`X-Signature-V2` → `X-Signature` → `X-Signature-Simple`) + timestamp | ## Uninstall -When the plugin is deleted via the WordPress admin, `uninstall.php` removes all plugin options from the database. User meta (`_didit_verified`, etc.) is preserved so verification status is not lost. +When the plugin is deleted via the WordPress admin, `uninstall.php` removes all plugin options from the database. The plugin also removes its user verification metadata and session bindings. ## Install WooCommerce (for testing) @@ -469,3 +467,10 @@ python3 tests/generate-fixtures.py > tests/fixtures/webhook-signatures.json ## License GPL-2.0-or-later — Copyright © 2025 Didit. + +## Tests + +Run `php tests/test-shortcode-assets.php`, `php tests/test-webhook-signature.php` and `php tests/test-verification-authorization.php`. +The local end-to-end fixture in `tests/e2e/fixture-api.php` intercepts Didit HTTP calls only when `WP_ENVIRONMENT_TYPE` is `local`. +It must never be installed on a production site. +The release was also checked through actual WordPress REST requests, classic WooCommerce checkout and the Store API checkout with synthetic customers and upstream decisions. diff --git a/assets/css/didit-verify.css b/assets/css/didit-verify.css index 308a19b..f9ca913 100644 --- a/assets/css/didit-verify.css +++ b/assets/css/didit-verify.css @@ -22,4 +22,41 @@ max-width: 100%; border-radius: 0; } -} \ No newline at end of file +} +.didit-verify-btn:focus-visible { + outline: 2px solid #2567ff; + outline-offset: 3px; +} + +.didit-feedback { + max-width: 40rem; + margin: 12px 0 0; + font: inherit; + font-size: 14px; + line-height: 1.5; +} + +.didit-feedback:empty { display: none; } +.didit-feedback-error { color: #b91c1c; } +.didit-wc-verification { + padding: 24px; + border: 1px solid #e5e5e5; + border-radius: 12px; + margin-block: 24px; +} + +@media (prefers-color-scheme: dark) { + .didit-feedback-error { color: #fca5a5; } + .didit-embed-container { background: #202020; } + .didit-wc-verification { border-color: #444; } +} +.dark .didit-feedback-error, +[data-theme="dark"] .didit-feedback-error { color: #fca5a5; } +@media (max-width: 540px) { + .didit-wc-verification { padding: 16px; } + .didit-wc-verification .didit-verify-btn { width: 100%; } +} +@media (prefers-reduced-motion: reduce) { + .didit-verify-btn { transition: none; } +} +.didit-verification-description { font-size: 0.9em; opacity: .8; } diff --git a/assets/js/didit-verify.js b/assets/js/didit-verify.js index 99cc0ba..9d6590e 100644 --- a/assets/js/didit-verify.js +++ b/assets/js/didit-verify.js @@ -7,65 +7,101 @@ var DiditSdk = window.DiditSDK.DiditSdk; var i18n = cfg.i18n || {}; - DiditSdk.shared.onComplete = function (result) { - var status = result.session ? result.session.status : ""; + var activeButton = null; + var pendingResult = null; + + function feedback(btn, message, isError) { + var node = btn.parentNode.querySelector('.didit-feedback'); + if (!node) { + node = document.createElement('p'); + node.className = 'didit-feedback'; + node.setAttribute('role', 'status'); + node.setAttribute('aria-live', 'polite'); + btn.insertAdjacentElement('afterend', node); + } + node.textContent = message; + node.classList.toggle('didit-feedback-error', !!isError); + } - document.querySelectorAll(".didit-verify-btn").forEach(function (btn) { - btn.classList.remove("didit-verified", "didit-declined", "didit-in-review"); + function setCheckoutSession(sessionId) { + var hidden = document.getElementById('didit_session_id'); + if (hidden) hidden.value = sessionId; + if (window.wp && window.wp.data && window.wp.data.dispatch) { + try { + wp.data.dispatch('wc/store/checkout').setExtensionData('didit-verify', { sessionId: sessionId }); + } catch (e) { /* Classic checkout has no Blocks data store. */ } + } + } - if (result.type === "completed" && status === "Approved") { - btn.textContent = btn.dataset.success || "Verified"; - btn.classList.add("didit-verified"); - btn.disabled = true; - } else if (result.type === "completed" && status === "Declined") { - btn.textContent = btn.dataset.text || "Verify Identity"; - btn.disabled = false; - } else if (result.type === "completed") { - btn.textContent = i18n.inReview || "Verification In Review"; - btn.classList.add("didit-in-review"); + function confirmResult(result, btn) { + btn.textContent = i18n.confirming || 'Confirming verification…'; + btn.disabled = true; + setCheckoutSession(''); + var body = { type: 'completed', sessionId: result.session ? result.session.sessionId : '' }; + if (btn.dataset.orderId) { + body.order_id = btn.dataset.orderId; + body.order_key = btn.dataset.orderKey || ''; + } + return fetch(cfg.restUrl.replace('/session', '/verify'), { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce }, + body: JSON.stringify(body) + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) { + var error = new Error(data.message || i18n.confirmationError || 'Unable to confirm verification. Please try again.'); + error.code = data.code; + throw error; + } + return data; + }); + }).then(function (data) { + btn.classList.remove('didit-verified', 'didit-in-review'); + if (data.status === 'Approved') { + btn.textContent = btn.dataset.success || 'Verified'; + btn.classList.add('didit-verified'); btn.disabled = true; + pendingResult = null; + setCheckoutSession(data.sessionId); + feedback(btn, i18n.approved || 'Identity verified. You can continue.', false); + } else if (data.status === 'Declined' || data.status === 'Expired' || data.status === 'KYC Expired') { + pendingResult = null; + resetBtn(btn); + feedback(btn, data.status === 'Declined' ? (i18n.declined || 'Verification declined. Please try again.') : (i18n.expired || 'Verification expired. Please start again.'), true); } else { - btn.textContent = btn.dataset.text || "Verify Identity"; + pendingResult = result; + btn.textContent = i18n.checkStatus || 'Check status'; btn.disabled = false; + feedback(btn, i18n.inReview || 'Your verification is being reviewed. Check again shortly.', false); } + document.dispatchEvent(new CustomEvent('didit:complete', { detail: { type: 'completed', session: { sessionId: data.sessionId, status: data.status } } })); + }).catch(function (error) { + pendingResult = result; + btn.textContent = i18n.checkStatus || 'Check status'; + btn.disabled = false; + if (error.code === 'didit_session_mismatch') { + pendingResult = null; + resetBtn(btn); + } + feedback(btn, error.message, true); }); + } - var hidden = document.getElementById("didit_session_id"); - if (hidden && result.session && result.session.sessionId) { - hidden.value = result.type === "completed" ? result.session.sessionId : ""; - } - - if (window.wp && window.wp.data && window.wp.data.dispatch) { - try { - wp.data.dispatch("wc/store/checkout").setExtensionData("didit-verify", { - sessionId: result.type === "completed" && result.session ? result.session.sessionId : "" - }); - } catch (e) {} + DiditSdk.shared.onComplete = function (result) { + var btn = activeButton; + if (!btn) return; + if (result.type !== 'completed') { + pendingResult = null; + setCheckoutSession(''); + resetBtn(btn); + return; } - - if (cfg.restUrl && cfg.nonce) { - var verifyBody = { - type: result.type, - sessionId: result.session ? result.session.sessionId : "", - status: result.session ? result.session.status : "" - }; - var orderBtn = document.querySelector(".didit-verify-btn[data-order-id]"); - if (orderBtn) { - verifyBody.order_id = orderBtn.dataset.orderId; - verifyBody.order_key = orderBtn.dataset.orderKey || ""; - } - fetch(cfg.restUrl.replace("/session", "/verify"), { - method: "POST", - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - "X-WP-Nonce": cfg.nonce - }, - body: JSON.stringify(verifyBody) - }).catch(function () {}); + if (cfg.mode === 'unilink' && !btn.dataset.wc) { + resetBtn(btn); + feedback(btn, i18n.submitted || 'Verification submitted. Your result is available from the service provider.', false); + return; } - - document.dispatchEvent(new CustomEvent("didit:complete", { detail: result })); + confirmResult(result, btn); }; function getWcBillingData() { @@ -132,9 +168,16 @@ if (!btn || btn.disabled || btn.classList.contains("didit-verified")) return; e.preventDefault(); + if (pendingResult && activeButton === btn) { + confirmResult(pendingResult, btn); + return; + } + activeButton = btn; + pendingResult = null; btn.disabled = true; + feedback(btn, '', false); - if (cfg.mode === "unilink") { + if (cfg.mode === "unilink" && !btn.dataset.wc) { startSdk(cfg.unilinkUrl, btn); } else { btn.textContent = i18n.creatingSession || "Creating session\u2026"; @@ -169,7 +212,7 @@ startSdk(data.url, btn); }) .catch(function (err) { - alert((i18n.verificationError || "Verification error:") + " " + err.message); + feedback(btn, err.message, true); resetBtn(btn); }); } diff --git a/didit-verify.php b/didit-verify.php index 4a21289..cda3053 100644 --- a/didit-verify.php +++ b/didit-verify.php @@ -3,7 +3,7 @@ * Plugin Name: Didit Verify * Plugin URI: https://github.com/didit-protocol/plugin-wordpress * Description: Identity verification for WordPress & WooCommerce using the Didit SDK. - * Version: 0.3.1 + * Version: 0.3.2 * Author: Didit * Author URI: https://didit.me * License: GPL-2.0-or-later @@ -18,7 +18,7 @@ exit; } -define('DIDIT_VERIFY_VERSION', '0.3.1'); +define('DIDIT_VERIFY_VERSION', '0.3.2'); define('DIDIT_VERIFY_URL', plugin_dir_url(__FILE__)); define('DIDIT_API_URL', 'https://verification.didit.me/v3/session/'); @@ -44,6 +44,7 @@ private function __construct() add_action('admin_menu', [$this, 'admin_menu']); add_action('admin_init', [$this, 'admin_register_settings']); + add_action('admin_notices', [$this, 'configuration_notice']); add_action('admin_enqueue_scripts', [$this, 'admin_enqueue_scripts']); add_filter('manage_users_columns', [$this, 'users_column']); @@ -75,6 +76,19 @@ public function admin_menu() ); } + public function configuration_notice() + { + if (!current_user_can('manage_options') || 'settings_page_didit-verify' !== get_current_screen()->id) { + return; + } + if ('unilink' === get_option('didit_mode', 'unilink') || !get_option('didit_api_key')) { + echo '

' . esc_html__( + 'UniLink opens a verification flow for manual review. To protect content or checkout, use API Session mode with a Workflow ID and API Key. Only a confirmed Didit decision grants access. Users verified by older plugin versions must verify again.', + 'didit-verify' + ) . '

'; + } + } + public function admin_register_settings() { $fields = [ @@ -108,9 +122,9 @@ public function admin_register_settings() 'didit_webhook_secret' => ['sanitize_callback' => 'sanitize_text_field', 'default' => ''], 'didit_btn_text' => ['sanitize_callback' => 'sanitize_text_field', 'default' => ''], 'didit_btn_success_text' => ['sanitize_callback' => 'sanitize_text_field', 'default' => ''], - 'didit_btn_bg_color' => ['sanitize_callback' => 'sanitize_hex_color', 'default' => '#2667ff'], + 'didit_btn_bg_color' => ['sanitize_callback' => 'sanitize_hex_color', 'default' => '#111111'], 'didit_btn_text_color' => ['sanitize_callback' => 'sanitize_hex_color', 'default' => '#ffffff'], - 'didit_btn_border_radius' => ['sanitize_callback' => 'absint', 'default' => 8], + 'didit_btn_border_radius' => ['sanitize_callback' => 'absint', 'default' => 999], 'didit_btn_padding_v' => ['sanitize_callback' => 'absint', 'default' => 12], 'didit_btn_padding_h' => ['sanitize_callback' => 'absint', 'default' => 24], 'didit_btn_font_size' => ['sanitize_callback' => 'absint', 'default' => 16], @@ -416,9 +430,9 @@ public function field_logging() public function section_button_preview() { - $bg = get_option('didit_btn_bg_color', '#2667ff'); + $bg = get_option('didit_btn_bg_color', '#111111'); $color = get_option('didit_btn_text_color', '#ffffff'); - $rad = (int) get_option('didit_btn_border_radius', 8); + $rad = (int) get_option('didit_btn_border_radius', 999); $pv = (int) get_option('didit_btn_padding_v', 12); $ph = (int) get_option('didit_btn_padding_h', 24); $fs = (int) get_option('didit_btn_font_size', 16); @@ -435,7 +449,7 @@ public function section_button_preview() border-radius:px; padding:px px; font-size:px; - font-weight:600; + font-weight:500; font-family:inherit; cursor:pointer; line-height:1.4;"> @@ -473,7 +487,7 @@ public function field_btn_bg_color() printf( '

%s

', - esc_attr(get_option('didit_btn_bg_color', '#2667ff')), + esc_attr(get_option('didit_btn_bg_color', '#111111')), esc_html__('Button background color.', 'didit-verify') ); } @@ -491,9 +505,9 @@ public function field_btn_text_color() public function field_btn_border_radius() { printf( - ' px + ' px

%s

', - esc_attr(get_option('didit_btn_border_radius', 8)), + esc_attr(get_option('didit_btn_border_radius', 999)), esc_html__('Corner rounding in pixels. 0 = square, 50 = pill shape.', 'didit-verify') ); } @@ -794,9 +808,7 @@ public function register_routes() register_rest_route('didit/v1', '/verify', [ 'methods' => 'POST', 'callback' => [$this, 'rest_save_verification'], - 'permission_callback' => function ($request) { - return is_user_logged_in() || (bool) $this->validate_order_context($request->get_json_params()); - }, + 'permission_callback' => [$this, 'rest_check_permission'], ]); register_rest_route('didit/v1', '/webhook', [ @@ -817,7 +829,8 @@ public function rest_check_permission($request) return true; } - if (get_option('didit_require_login', true) && !is_user_logged_in()) { + if (get_option('didit_require_login', true) && !is_user_logged_in() + && !('checkout' === $this->wc_mode() && $this->wc_cart_requires_verification())) { return new WP_Error('rest_forbidden', __('You must be logged in to start verification.', 'didit-verify'), ['status' => 401]); } @@ -855,6 +868,8 @@ public function rest_create_session($request) return new WP_Error('not_configured', __('Didit API credentials are not configured.', 'didit-verify'), ['status' => 500]); } + // Bind approval to this browser before creating a session. Never bind a caller-supplied ID. + $owner = $this->verification_owner(true); $body = ['workflow_id' => $workflow_id]; $vendor_data = $this->resolve_vendor_data(); @@ -881,8 +896,8 @@ public function rest_create_session($request) $order = $this->validate_order_context($input); if ($order) { $existing_url = $order->get_meta('_didit_session_url'); - $existing_status = $order->get_meta('_didit_status'); - if ($existing_url && !in_array($existing_status, ['Approved', 'Declined', 'Expired', 'KYC Expired'], true)) { + $existing_status = $this->confirmed_order_status($order); + if ($existing_url && $this->stored_session((string) $order->get_meta('_didit_session_id')) && !in_array($existing_status, ['Approved', 'Declined', 'Expired', 'KYC Expired'], true)) { return rest_ensure_response(['url' => $existing_url]); } } @@ -979,14 +994,31 @@ public function rest_create_session($request) ? 'https://verify.didit.me/session/' . $data['session_token'] : null); - if (!$url) { + $session_id = sanitize_text_field($data['session_id'] ?? ''); + if (!$url || !$this->valid_session_id($session_id)) { return new WP_Error('api_error', __('No verification URL returned.', 'didit-verify'), ['status' => 500]); } + $user_id = get_current_user_id(); + if ($order && (int) $order->get_customer_id() !== $user_id) { + $user_id = 0; + } + $this->store_session($session_id, [ + 'owner' => $owner, + 'user_id' => $user_id, + 'order_id' => $order ? $order->get_id() : 0, + 'status' => 'Not Started', + 'event_timestamp' => 0, + 'consumed_order_id' => 0, + ]); + if ($user_id) { + update_user_meta($user_id, '_didit_pending_session_id', $session_id); + } + if ($order) { $order->update_meta_data('_didit_session_id', sanitize_text_field($data['session_id'] ?? '')); $order->update_meta_data('_didit_session_url', esc_url_raw($url)); - if (!$order->get_meta('_didit_status')) { + if (!$this->confirmed_order_status($order)) { $order->update_meta_data('_didit_status', 'Not Started'); } $order->add_order_note(sprintf( @@ -999,46 +1031,187 @@ public function rest_create_session($request) do_action('didit_session_created', $url, get_current_user_id() ?: null, $vendor_data); - return rest_ensure_response(['url' => $url]); + return rest_ensure_response(['url' => $url, 'sessionId' => $session_id]); } - public function rest_save_verification($request) + private function valid_session_id($session_id): bool { - $user_id = get_current_user_id(); - $input = $request->get_json_params(); - $order = $this->validate_order_context($input); + return is_string($session_id) && 1 === preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i', $session_id); + } - if (!$user_id && !$order) { - return new WP_Error('not_logged_in', 'User not logged in.', ['status' => 401]); - } + private function session_key($session_id): string + { + return 'didit_bound_' . hash('sha256', $session_id); + } - $type = sanitize_text_field($input['type'] ?? ''); - $session_id = sanitize_text_field($input['sessionId'] ?? ''); - $status = sanitize_text_field($input['status'] ?? ''); + private function stored_session($session_id) + { + return $this->valid_session_id($session_id) ? get_option($this->session_key($session_id), false) : false; + } - if ('completed' === $type) { - if ($user_id) { - update_user_meta($user_id, '_didit_session_id', $session_id); - update_user_meta($user_id, '_didit_status', $status); - update_user_meta($user_id, '_didit_verified_at', current_time('mysql')); + private function store_session($session_id, array $record) + { + update_option($this->session_key($session_id), $record, false); + } - if ('Approved' === $status) { - update_user_meta($user_id, '_didit_verified', 1); - } else { - delete_user_meta($user_id, '_didit_verified'); - } + private function verification_owner(bool $create = false): string + { + $cookie = $_COOKIE['didit_verification_owner'] ?? ''; + if (!is_string($cookie) || !preg_match('/^[a-f0-9]{64}$/', $cookie)) { + if (!$create) { + return ''; } + $cookie = bin2hex(random_bytes(32)); + setcookie('didit_verification_owner', $cookie, [ + 'expires' => time() + 30 * DAY_IN_SECONDS, + 'path' => '/', + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $_COOKIE['didit_verification_owner'] = $cookie; + } + return hash('sha256', $cookie); + } - if ($order && $session_id && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id)) { - $this->wc_apply_verification_to_order($order, $status, 'browser'); + private function session_belongs_to_request($record, $order = null): bool + { + if (!is_array($record)) { + return false; + } + if ($order) { + return (int) $record['order_id'] === (int) $order->get_id(); + } + return empty($record['order_id']) + && (0 === (int) $record['user_id'] || (int) $record['user_id'] === get_current_user_id()) + && hash_equals($record['owner'], $this->verification_owner()); + } + + private function normalize_status($status): string + { + if (!is_string($status)) { + return ''; + } + foreach (['Not Started', 'In Progress', 'In Review', 'Approved', 'Declined', 'Abandoned', 'Expired', 'KYC Expired'] as $known) { + if (strtolower(str_replace('_', ' ', $status)) === strtolower($known)) { + return $known; } + } + return ''; + } + private function confirm_session($session_id, $order = null) + { + $record = $this->stored_session($session_id); + if (!$this->session_belongs_to_request($record, $order)) { + return new WP_Error('didit_session_mismatch', __('Start a new verification from this page.', 'didit-verify'), ['status' => 403]); + } + $api_key = get_option('didit_api_key', ''); + if (!$api_key) { + return new WP_Error('didit_not_configured', __('API Session mode is required to confirm verification.', 'didit-verify'), ['status' => 503]); + } + $response = wp_remote_get(DIDIT_API_URL . rawurlencode($session_id) . '/decision/', [ + 'headers' => ['x-api-key' => $api_key], + 'timeout' => 15, + ]); + if (is_wp_error($response) || 200 !== wp_remote_retrieve_response_code($response)) { + return new WP_Error('didit_confirmation_unavailable', __('Unable to confirm verification. Please try again.', 'didit-verify'), ['status' => 503]); + } + $data = json_decode(wp_remote_retrieve_body($response), true); + $status = $this->normalize_status($data['status'] ?? ''); + if (!$status || ($data['session_id'] ?? '') !== $session_id) { + return new WP_Error('didit_invalid_decision', __('Unable to confirm verification. Please try again.', 'didit-verify'), ['status' => 502]); + } + $this->apply_confirmed_status($session_id, $record, $status, time()); + return $status; + } + + private function apply_confirmed_status($session_id, array $record, string $status, int $timestamp) + { + if ($timestamp < (int) ($record['event_timestamp'] ?? 0)) { + return; + } + $record['status'] = $status; + $record['event_timestamp'] = $timestamp; + $this->store_session($session_id, $record); + $user_id = (int) $record['user_id']; + if ($user_id && (string) get_user_meta($user_id, '_didit_pending_session_id', true) === $session_id) { + update_user_meta($user_id, '_didit_session_id', $session_id); + update_user_meta($user_id, '_didit_confirmed_session_id', $session_id); + update_user_meta($user_id, '_didit_status', $status); + update_user_meta($user_id, '_didit_verified_at', current_time('mysql')); + if ('Approved' === $status) { + update_user_meta($user_id, '_didit_verified', 1); + } else { + delete_user_meta($user_id, '_didit_verified'); + } + } + if (!empty($record['order_id']) && function_exists('wc_get_order')) { + $order = wc_get_order($record['order_id']); + if ($order && (string) $order->get_meta('_didit_session_id') === $session_id) { + $this->wc_apply_verification_to_order($order, $status, 'verified-server'); + } + } + if (in_array($status, ['Approved', 'Declined'], true)) { do_action('didit_verification_completed', $user_id, $session_id, $status); - } elseif ('cancelled' === $type) { - do_action('didit_verification_cancelled', $user_id, $session_id); } + } + + private function confirmed_user_status($user_id): string + { + $session_id = (string) get_user_meta($user_id, '_didit_confirmed_session_id', true); + // Earlier versions accepted browser claims. Those values must never unlock content. + if (!$session_id || $session_id !== (string) get_user_meta($user_id, '_didit_session_id', true)) { + return ''; + } + return (string) get_user_meta($user_id, '_didit_status', true); + } + + public function is_user_verified($user_id): bool + { + return 'Approved' === $this->confirmed_user_status(absint($user_id)); + } + + public function rest_save_verification($request) + { + $input = $request->get_json_params(); + $session_id = $input['sessionId'] ?? ''; + $type = $input['type'] ?? ''; + if ('cancelled' === $type) { + if ($this->session_belongs_to_request($this->stored_session($session_id), $this->validate_order_context($input))) { + do_action('didit_verification_cancelled', get_current_user_id(), $session_id); + } + return rest_ensure_response(['saved' => false, 'status' => '']); + } + $status = $this->confirm_session($session_id, $this->validate_order_context($input)); + if (is_wp_error($status)) { + return $status; + } + return rest_ensure_response(['saved' => true, 'status' => $status, 'sessionId' => $session_id]); + } + + private function checkout_session_is_approved($session_id): bool + { + $record = $this->stored_session($session_id); + if (!$this->session_belongs_to_request($record) || !empty($record['consumed_order_id'])) { + return false; + } + return 'Approved' === $this->confirm_session($session_id); + } - return rest_ensure_response(['saved' => true]); + private function attach_checkout_session($order, $session_id) + { + $record = $this->stored_session($session_id); + if (!$this->session_belongs_to_request($record) || 'Approved' !== $record['status'] || !empty($record['consumed_order_id'])) { + return; + } + $record['consumed_order_id'] = $order->get_id(); + $record['order_id'] = $order->get_id(); + $this->store_session($session_id, $record); + $order->update_meta_data('_didit_session_id', $session_id); + $order->update_meta_data('_didit_status', 'Approved'); + $order->update_meta_data('_didit_confirmed_session_id', $session_id); + $order->save(); } /** @@ -1168,29 +1341,6 @@ private static function verify_webhook_signature($request, $raw, array $payload, return ''; } - /** - * Resolve the WordPress user a session belongs to from stored state. - * - * The session id is written to user meta when the verification is saved, so this - * mapping is local and trustworthy - unlike the payload's `metadata.wp_user_id` and - * `vendor_data`, which X-Signature-Simple does not authenticate. - */ - private function user_id_for_session($session_id) - { - if (!$session_id) { - return 0; - } - - $users = get_users([ - 'meta_key' => '_didit_session_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => $session_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value - 'number' => 1, - 'fields' => 'ID', - ]); - - return $users ? absint($users[0]) : 0; - } - public function rest_webhook($request) { $secret = get_option('didit_webhook_secret', ''); @@ -1234,10 +1384,6 @@ public function rest_webhook($request) return new WP_Error('invalid_signature', __('Webhook signature mismatch.', 'didit-verify'), ['status' => 401]); } - // X-Signature-Simple signs the envelope only, so everything outside - // timestamp/session_id/status/webhook_type stays unauthenticated on that path. - $body_is_signed = ('simple' !== $verified_with); - if ('status.updated' !== ($payload['webhook_type'] ?? '')) { return rest_ensure_response(['received' => true, 'ignored' => true]); } @@ -1248,42 +1394,12 @@ public function rest_webhook($request) return new WP_Error('invalid_payload', __('Invalid webhook payload.', 'didit-verify'), ['status' => 400]); } - if (class_exists('WooCommerce')) { - $orders = wc_get_orders([ - 'limit' => -1, - 'meta_query' => [['key' => '_didit_session_id', 'value' => $session_id]], - ]); - foreach ($orders as $wc_order) { - $this->wc_apply_verification_to_order($wc_order, $status, 'webhook'); - } - } - - $wp_user_id = 0; - if ($body_is_signed) { - $meta = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; - $wp_user_id = absint($meta['wp_user_id'] ?? 0); - if (!$wp_user_id && preg_match('/^wp-(\d+)$/', (string) ($payload['vendor_data'] ?? ''), $m)) { - $wp_user_id = absint($m[1]); - } - } - if (!$wp_user_id) { - // Falls back to the session -> user mapping this site stored itself, which is the - // only usable source when the payload is unsigned beyond the envelope. - $wp_user_id = $this->user_id_for_session($session_id); - } - if ($wp_user_id && get_userdata($wp_user_id)) { - update_user_meta($wp_user_id, '_didit_session_id', $session_id); - update_user_meta($wp_user_id, '_didit_status', $status); - update_user_meta($wp_user_id, '_didit_verified_at', current_time('mysql')); - if ('Approved' === $status) { - update_user_meta($wp_user_id, '_didit_verified', 1); - } else { - delete_user_meta($wp_user_id, '_didit_verified'); - } - } - - if (in_array($status, ['Approved', 'Declined'], true)) { - do_action('didit_verification_completed', $wp_user_id, $session_id, $status); + // Signed metadata can still originate in a public UniLink. Only a binding created + // by this server is allowed to route an approval to a user or order. + $record = $this->stored_session($session_id); + $status = $this->normalize_status($status); + if ($record && $status) { + $this->apply_confirmed_status($session_id, $record, $status, $timestamp); } return rest_ensure_response(['received' => true]); @@ -1646,9 +1762,17 @@ private function wc_order_requires_verification($order): bool return $this->wc_items_require_verification($product_ids); } + private function confirmed_order_status($order): string + { + $session_id = (string) $order->get_meta('_didit_confirmed_session_id'); + return $session_id && $session_id === (string) $order->get_meta('_didit_session_id') + ? (string) $order->get_meta('_didit_status') : ''; + } + private function wc_apply_verification_to_order($order, string $status, string $source): bool { - $changed = (string) $order->get_meta('_didit_status') !== $status; + $changed = (string) $this->confirmed_order_status($order) !== $status; + $order->update_meta_data('_didit_confirmed_session_id', (string) $order->get_meta('_didit_session_id')); if ($changed) { $order->update_meta_data('_didit_status', $status); @@ -1660,10 +1784,10 @@ private function wc_apply_verification_to_order($order, string $status, string $ )); } - if ('Approved' === $status && 'webhook' === $source && 'yes' === $order->get_meta('_didit_held') && $order->has_status('on-hold')) { + if ('Approved' === $status && 'verified-server' === $source && 'yes' === $order->get_meta('_didit_held') && $order->has_status('on-hold')) { $order->delete_meta_data('_didit_held'); $order->save(); - $order->update_status('processing', __('Didit: identity verified — order released.', 'didit-verify')); + $order->update_status('processing', __('Didit: identity verified - order released.', 'didit-verify')); } elseif ($changed) { $order->save(); } @@ -1737,18 +1861,18 @@ private function enqueue_sdk_styles() wp_enqueue_style('didit-verify', DIDIT_VERIFY_URL . 'assets/css/didit-verify.css', [], DIDIT_VERIFY_VERSION); - $bg = esc_attr(get_option('didit_btn_bg_color', '#2667ff')); + $bg = esc_attr(get_option('didit_btn_bg_color', '#111111')); $tc = esc_attr(get_option('didit_btn_text_color', '#ffffff')); - $rad = (int) get_option('didit_btn_border_radius', 8); + $rad = (int) get_option('didit_btn_border_radius', 999); $pv = (int) get_option('didit_btn_padding_v', 12); $ph = (int) get_option('didit_btn_padding_h', 24); $fs = (int) get_option('didit_btn_font_size', 16); - $css = ".didit-verify-btn{background:{$bg};color:{$tc};border:none;border-radius:{$rad}px;padding:{$pv}px {$ph}px;font-size:{$fs}px;font-weight:600;font-family:inherit;cursor:pointer;line-height:1.4;transition:opacity .2s,box-shadow .2s;}" - . ".didit-verify-btn:hover{opacity:.9;box-shadow:0 4px 12px rgba(0,0,0,.2);}" + $css = ".didit-verify-btn{background:{$bg};color:{$tc};border:1px solid #666;border-radius:{$rad}px;padding:{$pv}px {$ph}px;font-size:{$fs}px;font-weight:500;font-family:inherit;cursor:pointer;line-height:1.4;transition:opacity .2s,box-shadow .2s;}" + . ".didit-verify-btn:hover{opacity:.9;box-shadow:none;}" . ".didit-verify-btn:disabled{opacity:.5;cursor:not-allowed;box-shadow:none;}" - . ".didit-verify-btn.didit-verified{background:#41D97F;opacity:1;}" - . ".didit-verify-btn.didit-in-review{background:#F59E0B;opacity:1;}" + . ".didit-verify-btn.didit-verified{background:#dcfce7;color:#166534;border-color:#86efac;opacity:1;}" + . ".didit-verify-btn.didit-in-review{background:#fef3c7;color:#92400e;border-color:#fcd34d;opacity:1;}" . ".didit-verify-btn.didit-declined{background:{$bg};}"; wp_add_inline_style('didit-verify', $css); @@ -1780,7 +1904,13 @@ private function enqueue_sdk_scripts() 'loggingEnabled' => (bool) get_option('didit_logging', false), 'i18n' => [ 'creatingSession' => __('Creating session…', 'didit-verify'), - 'inReview' => __('Verification In Review', 'didit-verify'), + 'inReview' => __('Your verification is being reviewed. Check again shortly.', 'didit-verify'), + 'confirming' => __('Confirming verification…', 'didit-verify'), + 'checkStatus' => __('Check status', 'didit-verify'), + 'approved' => __('Identity verified. You can continue.', 'didit-verify'), + 'declined' => __('Verification declined. Please try again.', 'didit-verify'), + 'expired' => __('Verification expired. Please start again.', 'didit-verify'), + 'submitted' => __('Verification submitted. Your result is available from the service provider.', 'didit-verify'), 'verificationError' => __('Verification error:', 'didit-verify'), 'noUrl' => __('No verification URL returned', 'didit-verify'), ], @@ -1861,7 +1991,7 @@ public function render_status_shortcode($atts) return sprintf('%s', esc_html($a['login_text'])); } - $status = get_user_meta(get_current_user_id(), '_didit_status', true); + $status = $this->confirmed_user_status(get_current_user_id()); if ('Approved' === $status) { return sprintf('%s', esc_html($a['verified_text'])); @@ -1889,7 +2019,7 @@ public function render_gate_shortcode($atts, $content = null) ); } - $status = get_user_meta(get_current_user_id(), '_didit_status', true); + $status = $this->confirmed_user_status(get_current_user_id()); if ('Approved' === $status) { return '
' . do_shortcode($content) . '
'; } @@ -1919,7 +2049,7 @@ public function users_column_content($output, $column_name, $user_id) if ('didit_verified' !== $column_name) { return $output; } - $status = get_user_meta($user_id, '_didit_status', true); + $status = $this->confirmed_user_status($user_id); $date = get_user_meta($user_id, '_didit_verified_at', true); if ('Approved' === $status) { @@ -2002,14 +2132,14 @@ public function wc_checkout_field() } ?> enqueue_sdk_assets(); $is_embedded = ('embedded' === get_option('didit_display_mode', 'modal')); $btn_text = $this->btn_text(); $btn_success = $this->btn_success_text(); ?> -
+

wc_box_title()); ?>

-

+

wc_checkout_text()); ?>

%s
', @@ -2188,7 +2312,7 @@ public function wc_maybe_hold_order($order_id, $from, $to, $order) $holding || 'processing' !== $to || !get_option('didit_wc_hold', false) || 'after_purchase' !== $this->wc_mode() || !$order->get_meta('_didit_requires_verification') - || 'Approved' === $order->get_meta('_didit_status') + || 'Approved' === $this->confirmed_order_status($order) ) { return; } @@ -2204,7 +2328,7 @@ public function wc_email_verification_link($order, $sent_to_admin, $plain_text, if ( $sent_to_admin || 'after_purchase' !== $this->wc_mode() || !$order->get_meta('_didit_requires_verification') - || in_array($order->get_meta('_didit_status'), ['Approved', 'In Review'], true) + || in_array($this->confirmed_order_status($order), ['Approved', 'In Review'], true) ) { return; } @@ -2250,7 +2374,7 @@ public function wc_send_verification_reminder($order_id) if ( !$order || 'after_purchase' !== $this->wc_mode() || !get_option('didit_wc_reminders', false) || !$order->get_meta('_didit_requires_verification') - || in_array($order->get_meta('_didit_status'), ['Approved', 'Declined', 'In Review'], true) + || in_array($this->confirmed_order_status($order), ['Approved', 'Declined', 'In Review'], true) || in_array($order->get_status(), ['cancelled', 'refunded', 'failed'], true) ) { return; @@ -2301,16 +2425,16 @@ public function wc_block_checkout_field($block_content) return $block_content; } + $this->enqueue_sdk_assets(); $is_embedded = ('embedded' === get_option('didit_display_mode', 'modal')); $btn_text = $this->btn_text(); $btn_success = $this->btn_success_text(); ob_start(); ?> -
+

wc_box_title()); ?>

-

+

wc_checkout_text()); ?>