From 00f397e5ca2fab8155fe21a9f943b94219f34b30 Mon Sep 17 00:00:00 2001 From: "botinc-agent[bot]" <312035226+botinc-agent[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:42:59 +0000 Subject: [PATCH 1/3] Stop trusting the browser completion callback for the verified state POST /didit/v1/verify stored whatever `status` the visitor's browser sent, so any logged-in user (or a guest holding an order key) could post `{type: "completed", sessionId: "", status: "Approved"}` and mark themselves verified, unlocking [didit_gate] content and flipping the order's Didit status without ever completing a verification. The handler now reads the session decision from Didit's v3 decision endpoint with the site API key, uses that status, and only applies it when the session was created for the calling user (metadata.wp_user_id / vendor_data wp-{id}) or, for the order path, matches the session id stored on the order. A failed decision read answers 502 and stores nothing; the signed webhook still reconciles later. Adds tests/test-verify-endpoint.php, which fails on the previous head (forged completion unlocked the gate) and passes now. Co-Authored-By: Claude Opus 5 Co-authored-by: arosasg <46827020+arosasg@users.noreply.github.com> --- README.md | 4 +- didit-verify.php | 87 ++++++++++++++++++- readme.txt | 5 +- tests/test-verify-endpoint.php | 148 +++++++++++++++++++++++++++++++++ tests/wp-stubs.php | 32 +++++++ 5 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 tests/test-verify-endpoint.php diff --git a/README.md b/README.md index 583f164..6530d35 100644 --- a/README.md +++ b/README.md @@ -451,10 +451,12 @@ docker compose exec wordpress wp plugin install woocommerce --activate --allow-r ## Tests -Webhook signature verification is covered by a dependency-free PHP test suite: +Webhook signature verification, the browser completion callback and shortcode asset loading are covered by dependency-free PHP test suites: ```bash php tests/test-webhook-signature.php +php tests/test-verify-endpoint.php +php tests/test-shortcode-assets.php # or, without a local PHP: docker run --rm -v "$PWD":/app -w /app php:8.2-cli php tests/test-webhook-signature.php ``` diff --git a/didit-verify.php b/didit-verify.php index 4a21289..6e5d017 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,9 +18,10 @@ 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/'); +define('DIDIT_DECISION_URL', 'https://verification.didit.me/v3/session/%s/decision/'); final class Didit_Verify { @@ -1002,6 +1003,17 @@ public function rest_create_session($request) return rest_ensure_response(['url' => $url]); } + /** + * Browser completion callback: `POST /didit/v1/verify`. + * + * The SDK posts this from the visitor's browser when the flow closes, so the + * whole body is caller-controlled. The `status` it carries is therefore never + * stored: the verified state that [didit_gate] and the WooCommerce order hold + * are keyed on is read back from Didit's own decision endpoint, and only for + * a session that was created for this user or this order. The signed webhook + * remains the authoritative source; this path just lets the site reflect the + * result immediately without trusting the client for it. + */ public function rest_save_verification($request) { $user_id = get_current_user_id(); @@ -1014,10 +1026,29 @@ public function rest_save_verification($request) $type = sanitize_text_field($input['type'] ?? ''); $session_id = sanitize_text_field($input['sessionId'] ?? ''); - $status = sanitize_text_field($input['status'] ?? ''); if ('completed' === $type) { + if (!$session_id || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $session_id)) { + return new WP_Error('invalid_session', __('Invalid session id.', 'didit-verify'), ['status' => 400]); + } + + $decision = $this->fetch_session_decision($session_id); + if (!$decision) { + return new WP_Error( + 'decision_unavailable', + __('Could not confirm the verification result with Didit. It will be applied when the webhook arrives.', 'didit-verify'), + ['status' => 502] + ); + } + + // Authoritative: what Didit says about this session, not what the browser said. + $status = sanitize_text_field($decision['status']); + if ($user_id) { + if (!$this->decision_belongs_to_user($decision, $user_id)) { + return new WP_Error('session_mismatch', __('This verification session does not belong to you.', 'didit-verify'), ['status' => 403]); + } + 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')); @@ -1029,7 +1060,7 @@ public function rest_save_verification($request) } } - if ($order && $session_id && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id)) { + if ($order && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id)) { $this->wc_apply_verification_to_order($order, $status, 'browser'); } @@ -1041,6 +1072,54 @@ public function rest_save_verification($request) return rest_ensure_response(['saved' => true]); } + /** + * Read a session's decision from Didit with the site API key. + * + * @return array|null Decoded decision with a non-empty `status`, or null when + * the session is unknown, the key is missing or the call failed. + */ + private function fetch_session_decision($session_id) + { + $api_key = get_option('didit_api_key'); + if (empty($api_key)) { + return null; + } + + $response = wp_remote_get(sprintf(DIDIT_DECISION_URL, rawurlencode($session_id)), [ + 'headers' => ['x-api-key' => $api_key], + 'timeout' => 15, + ]); + if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) { + return null; + } + + $decision = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($decision) || empty($decision['status']) || !is_string($decision['status'])) { + return null; + } + return $decision; + } + + /** + * Whether a decision is for a session this site created for the given user. + * + * rest_create_session() stamps `metadata.wp_user_id` (which the caller cannot + * override) and, in the default vendor-data mode, `vendor_data = wp-{id}`; + * either one ties the session to the user. `metadata` is sent JSON-encoded at + * creation, so it is accepted back as an object or as that string. + */ + private function decision_belongs_to_user(array $decision, $user_id) + { + $meta = $decision['metadata'] ?? null; + if (is_string($meta)) { + $meta = json_decode($meta, true); + } + if (is_array($meta) && absint($meta['wp_user_id'] ?? 0) === absint($user_id)) { + return true; + } + return ('wp-' . absint($user_id)) === (string) ($decision['vendor_data'] ?? ''); + } + /** * Rebuild the canonical JSON form Didit signs for X-Signature-V2. * diff --git a/readme.txt b/readme.txt index 53edb5e..a132cc4 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: identity verification, kyc, woocommerce, age verification, id check Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 0.3.1 +Stable tag: 0.3.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -203,6 +203,9 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C == Changelog == += 0.3.2 = +* Security: the browser completion callback (`POST /didit/v1/verify`) no longer trusts the `status` sent by the client. The plugin now reads the session decision from Didit with the site API key and only applies it when the session was created for the calling user (or the order being verified). Previously any logged-in user could mark their own account as verified, unlocking `[didit_gate]` content, by posting a forged completion. + = 0.3.1 = * Page builders that render shortcode content outside the post content (for example Oxygen) no longer show an unstyled verification button or embedded container. The plugin stylesheet and the button appearance CSS are now always queued in the header, while the SDK script is still enqueued when the shortcode itself renders. * Webhook receiver now verifies `X-Signature-V2` first, falls back to the legacy `X-Signature`, and finally to `X-Signature-Simple`. A delivery is accepted as soon as one variant verifies, so a reverse proxy, CDN rule or security plugin that strips a single `X-*` header no longer causes a 401 "Missing or stale webhook signature". diff --git a/tests/test-verify-endpoint.php b/tests/test-verify-endpoint.php new file mode 100644 index 0000000..48d1e1e --- /dev/null +++ b/tests/test-verify-endpoint.php @@ -0,0 +1,148 @@ + '']; + $GLOBALS['didit_test_options']['didit_api_key'] = 'test-api-key'; +} + +function queue_decision(array $decision, $code = 200) +{ + $GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => $code], + 'body' => json_encode($decision), + ]; +} + +function completion_request($session_id, $status) +{ + return new Didit_Test_Request(json_encode([ + 'type' => 'completed', + 'sessionId' => $session_id, + 'status' => $status, + ])); +} + +function user_meta($user_id, $key) +{ + return $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] ?? null; +} + +$plugin = Didit_Verify::init(); +$USER = 7; + +echo "Forged completion without any Didit session\n"; +reset_state($USER); +// No decision is queued: an unknown session id gets no 200 from Didit. +$GLOBALS['didit_test_http_responses'][] = ['response' => ['code' => 404], 'body' => '{"detail":"Not found."}']; +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000001', 'Approved')); +ok($result instanceof WP_Error, 'a completion Didit does not know about is rejected'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); +ok('Approved' !== user_meta($USER, '_didit_status'), 'the caller-supplied "Approved" is not stored'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] stays locked'); +ok(empty($GLOBALS['didit_test_actions']), 'didit_verification_completed does not fire for a forged completion'); + +echo "\nOwn session, caller claims Approved, Didit says Declined\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000002', + 'status' => 'Declined', + 'vendor_data' => 'wp-7', + 'metadata' => ['wp_user_id' => 7], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000002', 'Approved')); +ok(!($result instanceof WP_Error), 'the completion is accepted'); +ok('Declined' === user_meta($USER, '_didit_status'), 'the stored status is the one Didit reported'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] stays locked'); +$request = $GLOBALS['didit_test_http_requests'][0] ?? null; +ok($request && 'https://verification.didit.me/v3/session/00000000-0000-4000-8000-000000000002/decision/' === $request['url'], + 'the decision is read from the v3 decision endpoint for the claimed session'); +ok($request && 'test-api-key' === ($request['args']['headers']['x-api-key'] ?? null), 'the decision read is authenticated with the site API key'); + +echo "\nSomeone else's approved session\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000003', + 'status' => 'Approved', + 'vendor_data' => 'wp-8', + 'metadata' => ['wp_user_id' => 8], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000003', 'Approved')); +ok($result instanceof WP_Error && 403 === ($result->get_error_data()['status'] ?? 0), 'a session created for another user is refused with 403'); +ok(null === user_meta($USER, '_didit_verified') && null === user_meta($USER, '_didit_status'), 'nothing is stored for the caller'); + +echo "\nDecision endpoint unavailable\n"; +reset_state($USER); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000004', 'Approved')); +ok($result instanceof WP_Error && 502 === ($result->get_error_data()['status'] ?? 0), 'a failed decision read answers 502 instead of trusting the browser'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); + +echo "\nOwn session approved by Didit\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000005', + 'status' => 'Approved', + 'vendor_data' => 'user@example.test', + // Session creation sends metadata JSON-encoded; the decision may hand it back as a string. + 'metadata' => json_encode(['wp_user_id' => 7, 'wp_email' => 'user@example.test']), +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000005', 'Declined')); +ok(!($result instanceof WP_Error), 'the completion is accepted'); +ok('Approved' === user_meta($USER, '_didit_status'), 'the stored status is Approved as Didit reported'); +ok(1 === user_meta($USER, '_didit_verified'), 'the caller is marked verified'); +ok('00000000-0000-4000-8000-000000000005' === user_meta($USER, '_didit_session_id'), 'the session mapping is stored'); +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] unlocks'); +ok(1 === count($GLOBALS['didit_test_actions']) && 'didit_verification_completed' === $GLOBALS['didit_test_actions'][0][0] + && 'Approved' === $GLOBALS['didit_test_actions'][0][3], 'didit_verification_completed fires with the Didit status'); + +echo "\nMalformed session id never reaches the API\n"; +reset_state($USER); +queue_decision(['status' => 'Approved', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request('../../v3/sessions/', 'Approved')); +ok($result instanceof WP_Error && 400 === ($result->get_error_data()['status'] ?? 0), 'a malformed session id is a 400'); +ok(empty($GLOBALS['didit_test_http_requests']), 'no request is made for it'); + +echo "\nCancelled callback\n"; +reset_state($USER); +$result = $plugin->rest_save_verification(new Didit_Test_Request(json_encode(['type' => 'cancelled', 'sessionId' => 'x']))); +ok(!($result instanceof WP_Error), 'a cancellation is acknowledged'); +ok(null === user_meta($USER, '_didit_status'), 'a cancellation stores nothing'); +ok(empty($GLOBALS['didit_test_http_requests']), 'a cancellation reads no decision'); + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index f692754..dc7c834 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -26,6 +26,11 @@ $GLOBALS['didit_test_enqueued_scripts'] = []; $GLOBALS['didit_test_localized_scripts'] = []; $GLOBALS['didit_test_inline_styles'] = []; +// The logged-in user rest_save_verification() acts for (0 = anonymous). +$GLOBALS['didit_test_current_user_id'] = 0; +// Queued wp_remote_get() responses (FIFO) and the requests that consumed them. +$GLOBALS['didit_test_http_responses'] = []; +$GLOBALS['didit_test_http_requests'] = []; function add_action() {} function add_filter() {} @@ -80,6 +85,31 @@ function get_users($args) { return array_slice($matches, 0, isset($args['number']) ? (int) $args['number'] : count($matches)); } +function is_user_logged_in() { return $GLOBALS['didit_test_current_user_id'] > 0; } +function get_current_user_id() { return (int) $GLOBALS['didit_test_current_user_id']; } +function do_shortcode($content) { return (string) $content; } +function wp_json_encode($data) { return json_encode($data); } +function is_wp_error($thing) { return $thing instanceof WP_Error; } + +function get_user_meta($user_id, $key, $single = false) { + $stored = $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] ?? ''; + return $single ? $stored : [$stored]; +} + +function wp_remote_get($url, $args = []) { + $GLOBALS['didit_test_http_requests'][] = ['url' => $url, 'args' => $args]; + $response = array_shift($GLOBALS['didit_test_http_responses']); + return null === $response ? new WP_Error('http_request_failed', 'no queued response') : $response; +} + +function wp_remote_retrieve_response_code($response) { + return is_array($response) ? ($response['response']['code'] ?? 0) : 0; +} + +function wp_remote_retrieve_body($response) { + return is_array($response) ? ($response['body'] ?? '') : ''; +} + function get_userdata($user_id) { return isset($GLOBALS['didit_test_users'][$user_id]) ? (object) ['ID' => $user_id] : false; } @@ -129,6 +159,8 @@ public function __construct($body, array $headers = []) { public function get_body() { return $this->body; } + public function get_json_params() { return json_decode($this->body, true); } + public function get_header($name) { $name = strtolower(str_replace('-', '_', $name)); return isset($this->headers[$name]) ? $this->headers[$name] : null; From ad5f11ff5fafa1f7b36dd658df5c7af176bb8ca3 Mon Sep 17 00:00:00 2001 From: "botinc-agent[bot]" <312035226+botinc-agent[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:38:03 +0000 Subject: [PATCH 2/3] Fix review findings on the verified-state callback and run tests in CI - UniLink mode: a completion that cannot be confirmed is refused with an actionable 409 instead of a misleading 502/403, [didit_gate] no longer offers a button that could never unlock it (administrators see how to switch to API mode), and the frontend only posts completions in API mode. - The button now shows the status the site saved, not the SDK's claim, and reports callback errors instead of displaying "Verified". - The user and the order are authorized independently: a session started as a guest for an order still updates that order after the customer logs in, without touching the logged-in user's own status. - rest_create_session() binds the session id to the logged-in user, so the callback and a simple-signature webhook resolve the user even when the browser callback never arrived or its decision read failed. - Add a GitHub Actions workflow (PHP 7.4 / 8.2 / 8.4): lint plus the three test suites. The repository had no workflow, so pull requests never reported checks. - German translations for the new front-end strings, readme upgrade notice. Co-Authored-By: Claude Opus 5 Co-authored-by: arosasg <46827020+arosasg@users.noreply.github.com> --- .github/workflows/tests.yml | 39 ++++++++++ README.md | 17 +++-- assets/js/didit-verify.js | 50 ++++++++++--- didit-verify.php | 72 +++++++++++++++---- languages/didit-verify-de_DE.mo | Bin 3846 -> 4854 bytes languages/didit-verify-de_DE.po | 19 +++++ languages/didit-verify-de_DE_formal.mo | Bin 3906 -> 4926 bytes languages/didit-verify-de_DE_formal.po | 19 +++++ languages/didit-verify.pot | 23 ++++++ readme.txt | 10 +++ tests/test-verify-endpoint.php | 94 ++++++++++++++++++++++++- tests/wp-stubs.php | 54 +++++++++++++- 12 files changed, 365 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..23571be --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + php: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Requires PHP: 7.4 in the plugin header, plus current releases. + php: ["7.4", "8.2", "8.4"] + container: php:${{ matrix.php }}-cli + steps: + - uses: actions/checkout@v4 + + - name: Lint PHP + run: | + set -e + for f in $(find . -name '*.php' -not -path './.git/*'); do + php -l "$f" + done + + - name: Webhook signature tests + run: php tests/test-webhook-signature.php + + - name: Verification callback tests + run: php tests/test-verify-endpoint.php + + - name: Shortcode asset tests + run: php tests/test-shortcode-assets.php diff --git a/README.md b/README.md index 6530d35..10d9085 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Open and complete the WordPress setup wizard. 2. Paste your UniLink URL (from [Didit Console](https://business.didit.me) → Workflow → Copy Link) 3. Save +> UniLink sessions are started from a shared URL, so the site cannot confirm who completed them. In UniLink mode results are shown in the button only: `[didit_gate]` stays locked, `[didit_status]` does not change and WooCommerce orders are not updated from the browser. Use API mode for any of those. + #### API mode (recommended for production) 1. Set **Mode** to `API Session` @@ -290,7 +292,7 @@ wordpress-plugin/ ├── languages/ # POT template + German translations (de_DE, de_DE_formal) ├── uninstall.php # Cleans up options on plugin deletion ├── readme.txt # WordPress.org plugin directory format -├── tests/ # Webhook signature tests (plain PHP, no dependencies) +├── tests/ # Webhook, completion callback and shortcode tests (plain PHP, no dependencies; run in CI) ├── docker-compose.yml # Local dev (WordPress + MySQL) └── README.md ``` @@ -301,7 +303,8 @@ 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 → Button shows the SDK result +(nothing is saved on the site) ``` ### API Flow @@ -313,8 +316,12 @@ User clicks button → PHP checks: nonce ✓ → login ✓ → rate limit ✓ → PHP calls Didit API with API key (server-side) → returns { url } → 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 +→ PHP stores the session id on the user (trusted session → user binding) +→ User completes → onComplete fires +→ JS sends POST /wp-json/didit/v1/verify { sessionId } +→ PHP reads GET /v3/session/{id}/decision/ with the API key, checks the session + belongs to this user (or to the order being verified), saves Didit's status +→ Button shows the status the site saved ``` ### WooCommerce Flow @@ -436,7 +443,7 @@ 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 a completion with Didit and save its decision to user/order meta (API mode only) | Login, or order key (after-purchase) | | `POST` | `/wp-json/didit/v1/webhook` | Didit webhook receiver (`status.updated`) | HMAC-SHA256 signature (`X-Signature-V2` → `X-Signature` → `X-Signature-Simple`) + timestamp | ## Uninstall diff --git a/assets/js/didit-verify.js b/assets/js/didit-verify.js index 99cc0ba..48ab5ce 100644 --- a/assets/js/didit-verify.js +++ b/assets/js/didit-verify.js @@ -7,20 +7,18 @@ var DiditSdk = window.DiditSDK.DiditSdk; var i18n = cfg.i18n || {}; - DiditSdk.shared.onComplete = function (result) { - var status = result.session ? result.session.status : ""; - + function setButtonState(type, status) { document.querySelectorAll(".didit-verify-btn").forEach(function (btn) { btn.classList.remove("didit-verified", "didit-declined", "didit-in-review"); - if (result.type === "completed" && status === "Approved") { + if (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") { + } else if (type === "completed" && status === "Declined") { btn.textContent = btn.dataset.text || "Verify Identity"; btn.disabled = false; - } else if (result.type === "completed") { + } else if (type === "completed") { btn.textContent = i18n.inReview || "Verification In Review"; btn.classList.add("didit-in-review"); btn.disabled = true; @@ -29,6 +27,10 @@ btn.disabled = false; } }); + } + + DiditSdk.shared.onComplete = function (result) { + var status = result.session ? result.session.status : ""; var hidden = document.getElementById("didit_session_id"); if (hidden && result.session && result.session.sessionId) { @@ -43,11 +45,19 @@ } catch (e) {} } - if (cfg.restUrl && cfg.nonce) { + // In API mode the site confirms a completion with Didit before saving it, so + // the button shows the status the site stored rather than the one the SDK + // reported. UniLink results cannot be saved, so they are not sent. + var confirmWithSite = cfg.mode === "api" && cfg.restUrl && cfg.nonce; + + if (!confirmWithSite || result.type !== "completed") { + setButtonState(result.type, status); + } + + if (confirmWithSite) { var verifyBody = { type: result.type, - sessionId: result.session ? result.session.sessionId : "", - status: result.session ? result.session.status : "" + sessionId: result.session ? result.session.sessionId : "" }; var orderBtn = document.querySelector(".didit-verify-btn[data-order-id]"); if (orderBtn) { @@ -62,7 +72,27 @@ "X-WP-Nonce": cfg.nonce }, body: JSON.stringify(verifyBody) - }).catch(function () {}); + }) + .then(function (r) { + return r.json().then(function (d) { + return { response: r, data: d }; + }); + }) + .then(function (res) { + if (result.type !== "completed") return; + if (res.response.ok) { + setButtonState("completed", res.data.status); + } else if (res.response.status === 401) { + // Anonymous visitor outside an order: nothing is stored on the site. + setButtonState(result.type, status); + } else { + setButtonState("error", ""); + alert((i18n.verificationError || "Verification error:") + " " + (res.data.message || "Error " + res.response.status)); + } + }) + .catch(function () { + if (result.type === "completed") setButtonState("error", ""); + }); } document.dispatchEvent(new CustomEvent("didit:complete", { detail: result })); diff --git a/didit-verify.php b/didit-verify.php index 6e5d017..3c6678e 100644 --- a/didit-verify.php +++ b/didit-verify.php @@ -193,6 +193,7 @@ public function field_mode() selected($v, 'api', false), esc_html__('API Session — recommended for production', 'didit-verify'), esc_html__('UniLink uses a fixed URL. API mode creates a unique session per user (requires Workflow ID + API Key).', 'didit-verify') + . ' ' . esc_html__('UniLink results cannot be confirmed by this site, so [didit_gate], [didit_status] and WooCommerce order verification require API mode.', 'didit-verify') ); } @@ -984,8 +985,16 @@ public function rest_create_session($request) return new WP_Error('api_error', __('No verification URL returned.', 'didit-verify'), ['status' => 500]); } + $created_session_id = sanitize_text_field($data['session_id'] ?? ''); + if ($created_session_id && is_user_logged_in()) { + // Trusted session -> user binding, recorded server-side so the completion + // callback and a simple-signature webhook can both resolve the user without + // relying on the browser callback having succeeded first. + update_user_meta(get_current_user_id(), '_didit_session_id', $created_session_id); + } + if ($order) { - $order->update_meta_data('_didit_session_id', sanitize_text_field($data['session_id'] ?? '')); + $order->update_meta_data('_didit_session_id', $created_session_id); $order->update_meta_data('_didit_session_url', esc_url_raw($url)); if (!$order->get_meta('_didit_status')) { $order->update_meta_data('_didit_status', 'Not Started'); @@ -993,7 +1002,7 @@ public function rest_create_session($request) $order->add_order_note(sprintf( /* translators: %s: Didit session id. */ __('Didit: verification session created (%s).', 'didit-verify'), - sanitize_text_field($data['session_id'] ?? '') + $created_session_id )); $order->save(); } @@ -1032,6 +1041,17 @@ public function rest_save_verification($request) return new WP_Error('invalid_session', __('Invalid session id.', 'didit-verify'), ['status' => 400]); } + // A UniLink session is started from a shared URL, so nothing on this site + // ties it to the visitor and its result cannot be stored without trusting + // the browser. Say so instead of failing later with a misleading error. + if ('api' !== get_option('didit_mode', 'unilink')) { + return new WP_Error( + 'unilink_not_verifiable', + __('This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them.', 'didit-verify'), + ['status' => 409] + ); + } + $decision = $this->fetch_session_decision($session_id); if (!$decision) { return new WP_Error( @@ -1044,11 +1064,17 @@ public function rest_save_verification($request) // Authoritative: what Didit says about this session, not what the browser said. $status = sanitize_text_field($decision['status']); - if ($user_id) { - if (!$this->decision_belongs_to_user($decision, $user_id)) { - return new WP_Error('session_mismatch', __('This verification session does not belong to you.', 'didit-verify'), ['status' => 403]); - } + // The user and the order are authorized independently: a session started + // as a guest for an order still belongs to that order after the customer + // logs in, but it must not change that logged-in user's own status. + $user_owns = $user_id && $this->session_belongs_to_user($decision, $session_id, $user_id); + $order_matches = $order && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id); + + if (!$user_owns && !$order_matches) { + return new WP_Error('session_mismatch', __('This verification session does not belong to you.', 'didit-verify'), ['status' => 403]); + } + if ($user_owns) { 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')); @@ -1060,11 +1086,13 @@ public function rest_save_verification($request) } } - if ($order && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id)) { + if ($order_matches) { $this->wc_apply_verification_to_order($order, $status, 'browser'); } - do_action('didit_verification_completed', $user_id, $session_id, $status); + do_action('didit_verification_completed', $user_owns ? $user_id : 0, $session_id, $status); + + return rest_ensure_response(['saved' => true, 'status' => $status]); } elseif ('cancelled' === $type) { do_action('didit_verification_cancelled', $user_id, $session_id); } @@ -1101,15 +1129,19 @@ private function fetch_session_decision($session_id) } /** - * Whether a decision is for a session this site created for the given user. + * Whether a session was created by this site for the given user. * - * rest_create_session() stamps `metadata.wp_user_id` (which the caller cannot - * override) and, in the default vendor-data mode, `vendor_data = wp-{id}`; - * either one ties the session to the user. `metadata` is sent JSON-encoded at - * creation, so it is accepted back as an object or as that string. + * rest_create_session() records the session id in the user's meta and stamps + * `metadata.wp_user_id` (which the caller cannot override) and, in the default + * vendor-data mode, `vendor_data = wp-{id}`; any one of them ties the session to + * the user. `metadata` is sent JSON-encoded at creation, so it is accepted back + * as an object or as that string. */ - private function decision_belongs_to_user(array $decision, $user_id) + private function session_belongs_to_user(array $decision, $session_id, $user_id) { + if (hash_equals((string) get_user_meta($user_id, '_didit_session_id', true), (string) $session_id)) { + return true; + } $meta = $decision['metadata'] ?? null; if (is_string($meta)) { $meta = json_decode($meta, true); @@ -1980,6 +2012,18 @@ public function render_gate_shortcode($atts, $content = null) ); } + if ('api' !== get_option('didit_mode', 'unilink')) { + // A UniLink completion can never unlock this gate, so offering the button + // would show "Verified" while the content stays locked. + return sprintf( + '

%s

%s
', + esc_html($a['message']), + current_user_can('manage_options') + ? '

' . esc_html__('Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings.', 'didit-verify') . '

' + : '' + ); + } + return sprintf( '

%s

%s
', esc_html($a['message']), diff --git a/languages/didit-verify-de_DE.mo b/languages/didit-verify-de_DE.mo index 0286516778ec1d8136235ac9fb71a156874bdbd5..e615e61fd25a14f94684581a80657411c7aa66c2 100644 GIT binary patch delta 1829 zcmaKqO=w+36vrnYtxeimO>65%8pmh?*5s*(u@por3H511tIyhYqulrIzSGN#ak@{bXW(nF4&Q_O;VNz+MMcAkCAiN6q6JLG9#{m|8Ecq9_pZVThN=?9V_ynxMmthCi z;0^c^93D{WID8vk#=T2$hWYGnrIukAJ^*)>v{)-A(F;D>M&eh$UzZ{aEU6O{X=?k=2q1)gC3E)-!m%lSU)){%{~d|ZYf zL$T~tbdV%= z$)uI;aNntpKd0Z6Yge>QwRQT8vnQ=v(Cs)h6CAgVmTm-2q~tD&8w=dVpUBFLBW4W& z{F24``01*ynKTt~a$0VynJlxenO0Oa=qxB@=x7@g?FN~2RcGOK2yQXBm z66clmc*l|c&UBEL39ed`}aj(?enB410i=^5)%Ss)g(AfMwZCzYQpD?d5A zOG4(%#u5>RBj;>!E{Iv(Q$dHtLtzDk2X8z8y5C5y! z_o>}(ET=a;EuGx1eM{}PX?{!EwFrV{oC(@CqwkDvnsm`nER)=>j4H%#)q!rYp8s1L USNOLOvzqJBCYALM_N?~)3o?(XasU7T delta 791 zcmY+?J1j#{9LMp0+gs1p`(2N=D)CCZ5=6Wc@kknjNerC`F|Y^*X$))zxiJ_-#DG}H z9c%`Jn8d&;Hti%ah=ut6uUmaR!TV5%Y1=S({kR`V33)4)y&nScPBc6NyW}5Eu?o2 z@c^}fN5>5B@46Sec)pC<*jqU~~ z@e%b!k?h|Dx^a~C5^4ii&ic!7gdDZu9n`2FFn|fvWDH`}=M|3asEtfuyobp=lU6)N zy8+ZBA~_;aT*CppMS`$j#&!5toQ@EjeJ4^s6fn_^Y3YXecRM-yO4Xc@svU=Rs$FYz zdQRLAVS{$3ARM0%j9n!BR~o%utRO?<)-%`A%f~(>gHdCSd-}|zCu$xUD^{a-*JUlH a*BfTGFJ`{`w#-X^$hz>qxvY)6b@vajx<{n| diff --git a/languages/didit-verify-de_DE.po b/languages/didit-verify-de_DE.po index c6861ad..8ea9bef 100644 --- a/languages/didit-verify-de_DE.po +++ b/languages/didit-verify-de_DE.po @@ -141,3 +141,22 @@ msgstr "Deine Bestellung wartet auf die Identitätsprüfung. Das dauert nur weni msgid "Verify your identity now" msgstr "Jetzt Identität verifizieren" +#: didit-verify.php:1041 +msgid "Invalid session id." +msgstr "Ungültige Sitzungs-ID." + +#: didit-verify.php:1050 +msgid "This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them." +msgstr "Diese Website nutzt den UniLink-Modus, der Verifizierungsergebnisse nicht bestätigen kann. Stelle Didit Verify auf den API-Session-Modus um, damit sie gespeichert werden." + +#: didit-verify.php:1059 +msgid "Could not confirm the verification result with Didit. It will be applied when the webhook arrives." +msgstr "Das Verifizierungsergebnis konnte nicht bei Didit bestätigt werden. Es wird übernommen, sobald der Webhook eintrifft." + +#: didit-verify.php:1074 +msgid "This verification session does not belong to you." +msgstr "Diese Verifizierungssitzung gehört nicht zu dir." + +#: didit-verify.php:2022 +msgid "Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings." +msgstr "Didit Verify ist im UniLink-Modus, der diesen Inhalt nicht freischalten kann. Stelle in den Einstellungen auf den API-Session-Modus um." diff --git a/languages/didit-verify-de_DE_formal.mo b/languages/didit-verify-de_DE_formal.mo index 28189c59c0a2af75817560e6f63d62ed3e2b7174..24936ef68b3a737c63f26b86c1587e9a3e5af1c4 100644 GIT binary patch delta 1841 zcmaKqPiP%Q9LJ~mqiIa6rrP)?#<6NZOw)7Pr>BKzJ>-kW6KY}lPm zNL1cItRD3A5YLtBO==0)Qc6K{P)~y3wIUuAkDfdf>*u$-sZA{o?0$A;XXf|)eSg1i z#xG5DE|2tmtQZqKV?2kpDfJY5yN4I!NUu`Ua1PetdvGWG9X{GZ4o`*Y$uYTa;E*5?*`4_y0`S5^JlW+n)0c-F@*nu_p z9ee?fZC7ePd=s9>y;HEt{KRcat-#apUfD-hB6tSg0T%eegPimA8acu@yQ zVwX)?>5lat?&Rn6YjW+XwyCyGA9wbUb&I+khh~!Fw$aj!z=@R1MR8-1+t`ULZyYge z2w;nk_5LGOT{CGa;^eg4Rx{pP*Gwy_8nWH`)goGvM7Pr$j8xrn5ZO?tqwN;dZRP`{GY5%hi>9ddQIP!Om8NZwKP~Z;#Iq%je#?!U`ob7FVn^LXv1>}^ zt8rFYkNfxbb!LOKOmNMbBqMW^G)>)c*<$RlOgjvfv$iN2U#Z_X?QN6VnFNz6`ant< zlTdG5teeEe?Y41~I*scAEfJbTzh0KoSm&wSysxO5^=m}yp56Y&jYaj^wm0pYdL<6C zbTYpsv?irDs;(8#Mun{T#MrbU3;bE6>iFkP6fvvWyr2lw2dqnFm4M8GY@Yj^lz&Rf zKTQ3LI67-rx&wnJM~}Pa#>I#n4cFvC)2ZrgrF&*@_xhQ^p&lfoMe@oE?lQmT^E=CC zyk2(RtoBBCIlXCYX(x*Qo4}SX_*b|olwU6#kAk-KbfD2qlP(#`W|C_)QibBJVh}Gy ZV1H}lEA|$O)-qjfIqJH8Vd#UNe*mXeu4w=O delta 791 zcmY+?%PWLY7{~Ev<{f5+8Tb1b%w*&;B@wxX4MUR4MhcrzvYCynG+D@w^kyXsE0-0` zf>IU|RyJ5j$wry~z=nLE*HE51^Ev08df(^#&bg?$tcbtlIktrgFv=O#HjzdQ+xem1 zu^E$?kA6d>4x2C=r?C(hF&9@;YZEJ2A7U}yqQ3tX{rHIu51(;0vg(a->)7cz`v4QW)6q7+7tlk(*y7L$Ya07#UUyhk*boV%k zZ>XJiWQug+1PKBaMvQJVX^fbAj-^C>+LkS` c=xleG<(^s7 '']; + $GLOBALS['didit_test_current_user_can'] = []; + $GLOBALS['didit_test_orders'] = []; $GLOBALS['didit_test_options']['didit_api_key'] = 'test-api-key'; + $GLOBALS['didit_test_options']['didit_workflow_id'] = 'test-workflow'; + $GLOBALS['didit_test_options']['didit_mode'] = 'api'; + $GLOBALS['didit_test_options']['didit_wc_mode'] = 'after_purchase'; } function queue_decision(array $decision, $code = 200) @@ -48,13 +53,13 @@ function queue_decision(array $decision, $code = 200) ]; } -function completion_request($session_id, $status) +function completion_request($session_id, $status, array $extra = []) { return new Didit_Test_Request(json_encode([ 'type' => 'completed', 'sessionId' => $session_id, 'status' => $status, - ])); + ] + $extra)); } function user_meta($user_id, $key) @@ -144,5 +149,90 @@ function user_meta($user_id, $key) ok(null === user_meta($USER, '_didit_status'), 'a cancellation stores nothing'); ok(empty($GLOBALS['didit_test_http_requests']), 'a cancellation reads no decision'); +echo "\nUniLink mode cannot confirm a completion\n"; +reset_state($USER); +$GLOBALS['didit_test_options']['didit_mode'] = 'unilink'; +queue_decision(['status' => 'Approved', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000006', 'Approved')); +ok($result instanceof WP_Error && 'unilink_not_verifiable' === $result->get_error_code() + && 409 === ($result->get_error_data()['status'] ?? 0), 'a UniLink completion is refused with an actionable 409'); +ok(false !== strpos($result->message, 'API Session mode'), 'the error tells the site how to fix it'); +ok(empty($GLOBALS['didit_test_http_requests']), 'no decision is read for it'); +ok(null === user_meta($USER, '_didit_status'), 'nothing is stored'); +$gate = $plugin->render_gate_shortcode([], 'SECRET'); +ok(false === strpos($gate, 'SECRET') && false === strpos($gate, 'didit-verify-btn'), + '[didit_gate] in UniLink mode stays locked and offers no button that could never unlock it'); +ok(false === strpos($gate, 'UniLink mode'), 'visitors do not see the configuration hint'); +$GLOBALS['didit_test_current_user_can'] = ['manage_options' => true]; +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'API Session mode'), 'administrators see how to fix the gate'); + +echo "\nAPI mode gate still offers the verification button\n"; +reset_state($USER); +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'didit-verify-btn'), '[didit_gate] renders the button in API mode'); + +echo "\nSession created as a guest for an order, completed after logging in\n"; +reset_state($USER); +$GLOBALS['didit_test_orders'][42] = new Didit_Test_Order(42, 'wc_order_key42', [ + '_didit_session_id' => '00000000-0000-4000-8000-000000000007', + '_didit_status' => 'Not Started', +]); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000007', + 'status' => 'Approved', + 'vendor_data' => 'order-42', + 'metadata' => ['wc_order_id' => 42], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000007', 'Approved', + ['order_id' => 42, 'order_key' => 'wc_order_key42'])); +ok(!($result instanceof WP_Error) && 'Approved' === ($result['status'] ?? null), 'the completion is accepted through the order binding'); +ok('Approved' === $GLOBALS['didit_test_orders'][42]->get_meta('_didit_status'), 'the order takes the Didit decision'); +ok(null === user_meta($USER, '_didit_status') && null === user_meta($USER, '_didit_verified'), + 'the logged-in user, who does not own the session, is not changed'); +ok(0 === ($GLOBALS['didit_test_actions'][0][1] ?? null), 'didit_verification_completed carries no user for an order-only completion'); + +echo "\nOrder key with a session id that is not the order's\n"; +reset_state($USER); +$GLOBALS['didit_test_orders'][43] = new Didit_Test_Order(43, 'wc_order_key43', [ + '_didit_session_id' => '00000000-0000-4000-8000-000000000008', +]); +queue_decision(['status' => 'Approved', 'vendor_data' => 'order-99', 'metadata' => ['wc_order_id' => 99]]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000009', 'Approved', + ['order_id' => 43, 'order_key' => 'wc_order_key43'])); +ok($result instanceof WP_Error && 403 === ($result->get_error_data()['status'] ?? 0), 'a session bound to neither the user nor the order is refused'); +ok('' === $GLOBALS['didit_test_orders'][43]->get_meta('_didit_status'), 'the order is not changed'); + +echo "\nSession binding is stored at creation\n"; +reset_state($USER); +$GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => 201], + 'body' => json_encode(['session_id' => '00000000-0000-4000-8000-000000000010', 'url' => 'https://verify.didit.me/session/abc']), +]; +$result = $plugin->rest_create_session(new Didit_Test_Request('{}')); +ok(is_array($result) && 'https://verify.didit.me/session/abc' === ($result['url'] ?? null), 'the session is created'); +ok('00000000-0000-4000-8000-000000000010' === user_meta($USER, '_didit_session_id'), + 'the session id is bound to the user server-side, before any browser callback'); +$GLOBALS['didit_test_users'] = [$USER => user_meta($USER, '_didit_session_id')]; +$GLOBALS['didit_test_options']['didit_webhook_secret'] = 'whsec'; +$ts = time(); +$status_payload = json_encode([ + 'timestamp' => $ts, + 'session_id' => '00000000-0000-4000-8000-000000000010', + 'status' => 'Approved', + 'webhook_type' => 'status.updated', +]); +$simple = hash_hmac('sha256', "{$ts}:00000000-0000-4000-8000-000000000010:Approved:status.updated", 'whsec'); +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'off'; +$result = $plugin->rest_webhook(new Didit_Test_Request($status_payload, ['X-Signature-Simple' => $simple])); +$GLOBALS['didit_test_options']['didit_webhook_secret'] = ''; +ok(is_array($result) && !empty($result['received']), 'a simple-signature webhook for that session is accepted'); +ok('Approved' === user_meta($USER, '_didit_status'), 'the webhook resolves the user from the creation-time binding without a browser callback'); + +echo "\nDecision without metadata, session bound at creation\n"; +reset_state($USER); +$GLOBALS['didit_test_user_meta'][$USER . '|_didit_session_id'] = '00000000-0000-4000-8000-000000000011'; +queue_decision(['session_id' => '00000000-0000-4000-8000-000000000011', 'status' => 'In Review', 'vendor_data' => 'custom-7']); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000011', 'Approved')); +ok(!($result instanceof WP_Error) && 'In Review' === user_meta($USER, '_didit_status'), 'the stored binding alone authorizes the user update'); + echo "\n{$passed} passed, {$failed} failed\n"; exit($failed > 0 ? 1 : 0); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index dc7c834..fae6d5a 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -10,6 +10,7 @@ define('ABSPATH', __DIR__ . '/'); define('MINUTE_IN_SECONDS', 60); +define('HOUR_IN_SECONDS', 3600); // Options the handler reads. Tests overwrite entries directly. $GLOBALS['didit_test_options'] = [ @@ -28,7 +29,9 @@ $GLOBALS['didit_test_inline_styles'] = []; // The logged-in user rest_save_verification() acts for (0 = anonymous). $GLOBALS['didit_test_current_user_id'] = 0; -// Queued wp_remote_get() responses (FIFO) and the requests that consumed them. +// Capabilities the current user holds, e.g. ['manage_options' => true]. +$GLOBALS['didit_test_current_user_can'] = []; +// Queued wp_remote_get()/wp_remote_post() responses (FIFO) and the requests that consumed them. $GLOBALS['didit_test_http_responses'] = []; $GLOBALS['didit_test_http_requests'] = []; @@ -102,6 +105,20 @@ function wp_remote_get($url, $args = []) { return null === $response ? new WP_Error('http_request_failed', 'no queued response') : $response; } +function wp_remote_post($url, $args = []) { + return wp_remote_get($url, $args + ['method' => 'POST']); +} + +function wp_unslash($value) { return $value; } +function get_transient($key) { return false; } +function set_transient($key, $value, $expiration = 0) { return true; } +function current_user_can($capability) { return !empty($GLOBALS['didit_test_current_user_can'][$capability]); } + +function wp_get_current_user() { + $id = get_current_user_id(); + return (object) ['ID' => $id, 'user_email' => $id ? "user{$id}@example.test" : '']; +} + function wp_remote_retrieve_response_code($response) { return is_array($response) ? ($response['response']['code'] ?? 0) : 0; } @@ -166,3 +183,38 @@ public function get_header($name) { return isset($this->headers[$name]) ? $this->headers[$name] : null; } } + +/** + * WooCommerce presence marker and the order surface the verification endpoints use. + * Orders live in $GLOBALS['didit_test_orders'] keyed by id. + */ +class WooCommerce {} + +class Didit_Test_Order { + public $id; + public $key; + public $meta = []; + public $notes = []; + public function __construct($id, $key, array $meta = []) { + $this->id = $id; + $this->key = $key; + $this->meta = $meta; + } + public function get_id() { return $this->id; } + public function get_order_key() { return $this->key; } + public function get_meta($key) { return $this->meta[$key] ?? ''; } + public function update_meta_data($key, $value) { $this->meta[$key] = $value; } + public function delete_meta_data($key) { unset($this->meta[$key]); } + public function add_order_note($note) { $this->notes[] = $note; } + public function has_status($status) { return false; } + public function save() {} +} + +function wc_get_order($order_id) { return $GLOBALS['didit_test_orders'][$order_id] ?? false; } + +function wc_get_orders($args) { + $session_id = $args['meta_query'][0]['value'] ?? null; + return array_values(array_filter($GLOBALS['didit_test_orders'] ?? [], function ($order) use ($session_id) { + return null !== $session_id && $order->get_meta('_didit_session_id') === $session_id; + })); +} From 6b8041dcb682b67cce664240b8b27c2c37c014ce Mon Sep 17 00:00:00 2001 From: "botinc-agent[bot]" <312035226+botinc-agent[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:27:47 +0000 Subject: [PATCH 3/3] Keep the granting session bound when a user starts another session Creating a session overwrote the user's `_didit_session_id`, so a later signed decline for the session that had approved the user could no longer resolve them when only X-Signature-Simple reached the site, and the gate stayed unlocked. New sessions are now recorded in the multi-value `_didit_pending_session_id` (capped at ten per user) while `_didit_session_id` keeps naming the session the stored status came from. The callback and the webhook accept either binding, and applying a decision moves that session out of the pending list. Also run the push checks on `master`, the repository's default branch. Co-Authored-By: Claude Fable 5.1 Co-authored-by: arosasg <46827020+arosasg@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- README.md | 6 +- didit-verify.php | 117 ++++++++++++++++++++++++--------- readme.txt | 2 +- tests/test-verify-endpoint.php | 83 ++++++++++++++++++++++- tests/wp-stubs.php | 69 +++++++++++++++++-- 6 files changed, 235 insertions(+), 44 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 23571be..21a90da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,7 +3,7 @@ name: Tests on: pull_request: push: - branches: [main] + branches: [master] workflow_dispatch: permissions: diff --git a/README.md b/README.md index 10d9085..6424617 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,8 @@ User clicks button → PHP checks: nonce ✓ → login ✓ → rate limit ✓ → PHP calls Didit API with API key (server-side) → returns { url } → JS calls DiditSdk.startVerification({ url }) → modal opens -→ PHP stores the session id on the user (trusted session → user binding) +→ PHP records the session id on the user as pending (trusted session → user binding); + the session behind the user's current status stays bound until a new decision lands → User completes → onComplete fires → JS sends POST /wp-json/didit/v1/verify { sessionId } → PHP reads GET /v3/session/{id}/decision/ with the API key, checks the session @@ -397,7 +398,8 @@ When a user completes verification, the plugin saves these fields to WordPress u | Meta key | Value | Description | |----------|-------|-------------| | `_didit_verified` | `1` | User is verified | -| `_didit_session_id` | UUID | Didit session ID | +| `_didit_session_id` | UUID | Didit session the stored status came from | +| `_didit_pending_session_id` | UUID (multi-value) | Sessions created since, still waiting for a decision | | `_didit_status` | `Approved` / `Pending` / `Declined` | Verification result | | `_didit_verified_at` | datetime | When verification was completed | diff --git a/didit-verify.php b/didit-verify.php index 3c6678e..a7236d1 100644 --- a/didit-verify.php +++ b/didit-verify.php @@ -25,6 +25,10 @@ final class Didit_Verify { + /** Multi-value user meta: sessions created for the user whose decision is still pending. */ + const PENDING_SESSION_META = '_didit_pending_session_id'; + /** Oldest pending bindings are dropped beyond this many per user. */ + const PENDING_SESSION_LIMIT = 10; private $block_session_id = ''; private $sdk_styles_enqueued = false; @@ -989,8 +993,11 @@ public function rest_create_session($request) if ($created_session_id && is_user_logged_in()) { // Trusted session -> user binding, recorded server-side so the completion // callback and a simple-signature webhook can both resolve the user without - // relying on the browser callback having succeeded first. - update_user_meta(get_current_user_id(), '_didit_session_id', $created_session_id); + // relying on the browser callback having succeeded first. It is kept apart + // from `_didit_session_id`, which stays on the session whose decision the + // user's current status came from, so starting a new session cannot detach + // the user from a decision that is later revoked. + $this->bind_pending_session(get_current_user_id(), $created_session_id); } if ($order) { @@ -1075,15 +1082,7 @@ public function rest_save_verification($request) } if ($user_owns) { - 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')); - - if ('Approved' === $status) { - update_user_meta($user_id, '_didit_verified', 1); - } else { - delete_user_meta($user_id, '_didit_verified'); - } + $this->apply_decision_to_user($user_id, $session_id, $status); } if ($order_matches) { @@ -1131,15 +1130,15 @@ private function fetch_session_decision($session_id) /** * Whether a session was created by this site for the given user. * - * rest_create_session() records the session id in the user's meta and stamps - * `metadata.wp_user_id` (which the caller cannot override) and, in the default - * vendor-data mode, `vendor_data = wp-{id}`; any one of them ties the session to - * the user. `metadata` is sent JSON-encoded at creation, so it is accepted back - * as an object or as that string. + * rest_create_session() records the session id in the user's meta (see + * session_bound_to_user()) and stamps `metadata.wp_user_id` (which the caller + * cannot override) and, in the default vendor-data mode, `vendor_data = wp-{id}`; + * any one of them ties the session to the user. `metadata` is sent JSON-encoded + * at creation, so it is accepted back as an object or as that string. */ private function session_belongs_to_user(array $decision, $session_id, $user_id) { - if (hash_equals((string) get_user_meta($user_id, '_didit_session_id', true), (string) $session_id)) { + if ($this->session_bound_to_user($user_id, $session_id)) { return true; } $meta = $decision['metadata'] ?? null; @@ -1152,6 +1151,67 @@ private function session_belongs_to_user(array $decision, $session_id, $user_id) return ('wp-' . absint($user_id)) === (string) ($decision['vendor_data'] ?? ''); } + /** + * Record a session this site just created for a user, without touching the + * session that currently backs the user's stored status. + * + * Two bindings exist per user: `_didit_session_id` names the session whose + * decision `_didit_status` / `_didit_verified` came from, and the multi-value + * `_didit_pending_session_id` lists sessions created since whose decision has not + * been applied yet. A webhook or callback for either one is accepted, so a later + * session cannot hide the revocation of the one that granted access, and a + * decision for the new session still finds the user. Pending bindings are capped + * so a user who keeps starting sessions does not grow the list without bound. + */ + private function bind_pending_session($user_id, $session_id) + { + $pending = array_map('strval', (array) get_user_meta($user_id, self::PENDING_SESSION_META, false)); + if (in_array((string) $session_id, $pending, true)) { + return; + } + add_user_meta($user_id, self::PENDING_SESSION_META, $session_id, false); + + $excess = count($pending) + 1 - self::PENDING_SESSION_LIMIT; + for ($i = 0; $i < $excess; $i++) { + // get_user_meta() returns values in insertion order: drop the oldest first. + delete_user_meta($user_id, self::PENDING_SESSION_META, $pending[$i]); + } + } + + /** + * Whether this site recorded the session for the user at creation or on a + * decision: the granting session or any pending one. + */ + private function session_bound_to_user($user_id, $session_id) + { + if (hash_equals((string) get_user_meta($user_id, '_didit_session_id', true), (string) $session_id)) { + return true; + } + foreach ((array) get_user_meta($user_id, self::PENDING_SESSION_META, false) as $pending) { + if (hash_equals((string) $pending, (string) $session_id)) { + return true; + } + } + return false; + } + + /** + * Store an authoritative decision for a session bound to the user. The session + * becomes the one the user's status is keyed on and leaves the pending list. + */ + private function apply_decision_to_user($user_id, $session_id, $status) + { + 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')); + if ('Approved' === $status) { + update_user_meta($user_id, '_didit_verified', 1); + } else { + delete_user_meta($user_id, '_didit_verified'); + } + delete_user_meta($user_id, self::PENDING_SESSION_META, $session_id); + } + /** * Rebuild the canonical JSON form Didit signs for X-Signature-V2. * @@ -1282,9 +1342,10 @@ private static function verify_webhook_signature($request, $raw, array $payload, /** * 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. + * The session id is written to user meta when the session is created and again + * when a decision 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. Both the granting session and pending sessions resolve. */ private function user_id_for_session($session_id) { @@ -1293,8 +1354,11 @@ private function user_id_for_session($session_id) } $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 + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + ['key' => '_didit_session_id', 'value' => $session_id], + ['key' => self::PENDING_SESSION_META, 'value' => $session_id], + ], 'number' => 1, 'fields' => 'ID', ]); @@ -1383,14 +1447,7 @@ public function rest_webhook($request) $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'); - } + $this->apply_decision_to_user($wp_user_id, $session_id, $status); } if (in_array($status, ['Approved', 'Declined'], true)) { diff --git a/readme.txt b/readme.txt index d4d5670..52b51cc 100644 --- a/readme.txt +++ b/readme.txt @@ -208,7 +208,7 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C = 0.3.2 = * Security: the browser completion callback (`POST /didit/v1/verify`) no longer trusts the `status` sent by the client. The plugin now reads the session decision from Didit with the site API key and only applies it when the session was created for the calling user (or the order being verified). Previously any logged-in user could mark their own account as verified, unlocking `[didit_gate]` content, by posting a forged completion. * The completion callback applies an order decision through the order key and the session stored on the order, even when the customer started the session as a guest and logged in afterwards. The logged-in user's own status only changes for a session created for that user. -* The session id is now linked to the WordPress user when the session is created, so a webhook signed only with `X-Signature-Simple` still finds the user if the browser callback never arrived. +* The session id is now linked to the WordPress user when the session is created, so a webhook signed only with `X-Signature-Simple` still finds the user if the browser callback never arrived. Sessions created since the last decision are tracked in `_didit_pending_session_id` (up to ten per user), while `_didit_session_id` keeps naming the session the stored status came from, so starting a new session never hides a later decline of the one that granted access. * UniLink mode no longer tries to save results it cannot confirm: `[didit_gate]` shows administrators how to switch to API mode instead of a button that could not unlock it, and the verification button in API mode shows the status the site saved. * Tests now run in GitHub Actions on PHP 7.4, 8.2 and 8.4. * Site owners: update, then review users with `_didit_status = Approved` whose `_didit_session_id` has no matching session in the Didit Console if you suspect abuse. diff --git a/tests/test-verify-endpoint.php b/tests/test-verify-endpoint.php index beb5866..c8742c8 100644 --- a/tests/test-verify-endpoint.php +++ b/tests/test-verify-endpoint.php @@ -209,9 +209,9 @@ function user_meta($user_id, $key) ]; $result = $plugin->rest_create_session(new Didit_Test_Request('{}')); ok(is_array($result) && 'https://verify.didit.me/session/abc' === ($result['url'] ?? null), 'the session is created'); -ok('00000000-0000-4000-8000-000000000010' === user_meta($USER, '_didit_session_id'), - 'the session id is bound to the user server-side, before any browser callback'); -$GLOBALS['didit_test_users'] = [$USER => user_meta($USER, '_didit_session_id')]; +ok(['00000000-0000-4000-8000-000000000010'] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'the session id is bound to the user server-side as pending, before any browser callback'); +ok(null === user_meta($USER, '_didit_session_id'), 'no decision has been stored yet'); $GLOBALS['didit_test_options']['didit_webhook_secret'] = 'whsec'; $ts = time(); $status_payload = json_encode([ @@ -226,6 +226,9 @@ function user_meta($user_id, $key) $GLOBALS['didit_test_options']['didit_webhook_secret'] = ''; ok(is_array($result) && !empty($result['received']), 'a simple-signature webhook for that session is accepted'); ok('Approved' === user_meta($USER, '_didit_status'), 'the webhook resolves the user from the creation-time binding without a browser callback'); +ok('00000000-0000-4000-8000-000000000010' === user_meta($USER, '_didit_session_id') + && [] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'the decided session becomes the granting session and leaves the pending list'); echo "\nDecision without metadata, session bound at creation\n"; reset_state($USER); @@ -234,5 +237,79 @@ function user_meta($user_id, $key) $result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000011', 'Approved')); ok(!($result instanceof WP_Error) && 'In Review' === user_meta($USER, '_didit_status'), 'the stored binding alone authorizes the user update'); + +function simple_webhook($plugin, $session_id, $status) +{ + $ts = time(); + $payload = json_encode([ + 'timestamp' => $ts, + 'session_id' => $session_id, + 'status' => $status, + 'webhook_type' => 'status.updated', + ]); + $simple = hash_hmac('sha256', "{$ts}:{$session_id}:{$status}:status.updated", 'whsec'); + $GLOBALS['didit_test_options']['didit_webhook_secret'] = 'whsec'; + $result = $plugin->rest_webhook(new Didit_Test_Request($payload, ['X-Signature-Simple' => $simple])); + $GLOBALS['didit_test_options']['didit_webhook_secret'] = ''; + return $result; +} + +function create_session($plugin, $session_id) +{ + $GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => 201], + 'body' => json_encode(['session_id' => $session_id, 'url' => 'https://verify.didit.me/session/' . $session_id]), + ]; + return $plugin->rest_create_session(new Didit_Test_Request('{}')); +} + +echo "\nStarting another session keeps the approved session revocable\n"; +reset_state($USER); +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'off'; +$A = '00000000-0000-4000-8000-00000000000a'; +$B = '00000000-0000-4000-8000-00000000000b'; +queue_decision(['session_id' => $A, 'status' => 'Approved', 'vendor_data' => 'wp-7', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request($A, 'Approved')); +ok(!($result instanceof WP_Error) && 1 === user_meta($USER, '_didit_verified'), 'session A is approved for the user'); +$result = create_session($plugin, $B); +ok(is_array($result) && isset($result['url']), 'the user starts a second session B'); +ok($A === user_meta($USER, '_didit_session_id'), 'the granting session A stays bound to the user'); +ok(['00000000-0000-4000-8000-00000000000b'] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), 'B is recorded as pending'); +ok('Approved' === user_meta($USER, '_didit_status') && false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), + 'starting B does not change the stored decision'); +$result = simple_webhook($plugin, $A, 'Declined'); +ok(is_array($result) && !empty($result['received']), 'a simple-signature Declined webhook for A is accepted'); +ok('Declined' === user_meta($USER, '_didit_status') && null === user_meta($USER, '_didit_verified'), + 'the decline of A revokes the user even though B was started afterwards'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] locks again'); +ok([$B] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), 'B is still pending'); + +echo "\nThe pending session is usable by the callback and the webhook\n"; +$GLOBALS['didit_test_http_requests'] = []; +queue_decision(['session_id' => $B, 'status' => 'In Review', 'vendor_data' => 'custom-7']); +$result = $plugin->rest_save_verification(completion_request($B, 'Approved')); +ok(!($result instanceof WP_Error) && 'In Review' === user_meta($USER, '_didit_status'), + 'the pending binding alone authorizes the callback for B, with the status Didit reports'); +ok($B === user_meta($USER, '_didit_session_id') && [] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'B becomes the granting session and leaves the pending list'); +$result = simple_webhook($plugin, $B, 'Approved'); +ok(is_array($result) && !empty($result['received']) && 1 === user_meta($USER, '_didit_verified'), + 'the simple-signature webhook for B still resolves the user after the callback'); +$result = simple_webhook($plugin, $A, 'Approved'); +ok(is_array($result) && !empty($result['received']) && $B === user_meta($USER, '_didit_session_id'), + 'a late webhook for the superseded session A no longer resolves the user'); + +echo "\nPending bindings are capped per user\n"; +reset_state($USER); +for ($i = 1; $i <= Didit_Verify::PENDING_SESSION_LIMIT + 2; $i++) { + create_session($plugin, sprintf('00000000-0000-4000-8000-0000000000%02d', $i)); +} +create_session($plugin, sprintf('00000000-0000-4000-8000-0000000000%02d', Didit_Verify::PENDING_SESSION_LIMIT + 2)); +$pending = get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false); +ok(Didit_Verify::PENDING_SESSION_LIMIT === count($pending), 'at most ' . Didit_Verify::PENDING_SESSION_LIMIT . ' pending sessions are kept'); +ok('00000000-0000-4000-8000-000000000003' === $pending[0] + && sprintf('00000000-0000-4000-8000-0000000000%02d', Didit_Verify::PENDING_SESSION_LIMIT + 2) === end($pending), + 'the oldest pending sessions are dropped first and a repeated session id is not duplicated'); + echo "\n{$passed} passed, {$failed} failed\n"; exit($failed > 0 ? 1 : 0); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index fae6d5a..eb06c9b 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -76,12 +76,38 @@ function get_option($name, $default = false) { : $default; } +/** + * Matches users in $GLOBALS['didit_test_users'] on their stored meta, supporting + * the `meta_key`/`meta_value` pair and a `meta_query` with an AND/OR relation. + * A user's entry value doubles as a `_didit_session_id` when no meta was written. + */ function get_users($args) { + $clauses = []; + if (isset($args['meta_key'], $args['meta_value'])) { + $clauses[] = ['key' => $args['meta_key'], 'value' => $args['meta_value']]; + } + $relation = 'AND'; + foreach ((array) ($args['meta_query'] ?? []) as $name => $clause) { + if ('relation' === $name) { + $relation = strtoupper((string) $clause); + continue; + } + $clauses[] = $clause; + } + $matches = []; foreach ($GLOBALS['didit_test_users'] as $id => $session_id) { - if (isset($args['meta_key'], $args['meta_value']) - && '_didit_session_id' === $args['meta_key'] - && $session_id === $args['meta_value']) { + $hits = 0; + foreach ($clauses as $clause) { + $values = array_map('strval', get_user_meta($id, $clause['key'], false)); + if ('_didit_session_id' === $clause['key'] && !$values && '' !== (string) $session_id) { + $values = [(string) $session_id]; + } + if (in_array((string) $clause['value'], $values, true)) { + $hits++; + } + } + if ($clauses && ('OR' === $relation ? $hits > 0 : $hits === count($clauses))) { $matches[] = $id; } } @@ -94,9 +120,25 @@ function do_shortcode($content) { return (string) $content; } function wp_json_encode($data) { return json_encode($data); } function is_wp_error($thing) { return $thing instanceof WP_Error; } +// Meta values are stored as a scalar (single) or a list (multi-value, via add_user_meta()). function get_user_meta($user_id, $key, $single = false) { - $stored = $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] ?? ''; - return $single ? $stored : [$stored]; + $slot = $user_id . '|' . $key; + if (!array_key_exists($slot, $GLOBALS['didit_test_user_meta'])) { + return $single ? '' : []; + } + $stored = $GLOBALS['didit_test_user_meta'][$slot]; + $values = is_array($stored) ? array_values($stored) : [$stored]; + return $single ? ($values[0] ?? '') : $values; +} + +function add_user_meta($user_id, $key, $value, $unique = false) { + $values = get_user_meta($user_id, $key, false); + if ($unique && $values) { + return false; + } + $values[] = $value; + $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] = $values; + return count($values); } function wp_remote_get($url, $args = []) { @@ -135,8 +177,21 @@ function update_user_meta($user_id, $key, $value) { $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] = $value; } -function delete_user_meta($user_id, $key) { - unset($GLOBALS['didit_test_user_meta'][$user_id . '|' . $key]); +function delete_user_meta($user_id, $key, $value = '') { + $slot = $user_id . '|' . $key; + if ('' === $value) { + unset($GLOBALS['didit_test_user_meta'][$slot]); + return true; + } + $remaining = array_values(array_filter(get_user_meta($user_id, $key, false), function ($stored) use ($value) { + return (string) $stored !== (string) $value; + })); + if ($remaining) { + $GLOBALS['didit_test_user_meta'][$slot] = $remaining; + } else { + unset($GLOBALS['didit_test_user_meta'][$slot]); + } + return true; } function do_action($hook) {