'18+ Leeftijdscheck', 'id_verification' => 'ID-verificatie', 'driving_license_b' => 'Rijbewijs B', ); public static function init() { if ( ! class_exists( 'WooCommerce' ) ) { add_action( 'admin_notices', function () { echo '

' . esc_html__( 'EdiVerify vereist WooCommerce.', 'ediverify-woocommerce' ) . '

'; } ); return; } add_filter( 'woocommerce_settings_tabs_array', array( __CLASS__, 'add_tab' ), 50 ); add_action( 'woocommerce_settings_tabs_ediverify', array( __CLASS__, 'render_settings' ) ); add_action( 'woocommerce_update_options_ediverify', array( __CLASS__, 'save_settings' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ) ); add_action( 'woocommerce_review_order_before_submit', array( __CLASS__, 'checkout_field' ) ); add_action( 'woocommerce_checkout_process', array( __CLASS__, 'validate' ) ); add_action( 'woocommerce_checkout_create_order', array( __CLASS__, 'save_to_order' ), 10, 2 ); add_action( 'woocommerce_admin_order_data_after_billing_address', array( __CLASS__, 'admin_order_note' ) ); add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), function ( $links ) { array_unshift( $links, '' . esc_html__( 'Instellingen', 'ediverify-woocommerce' ) . '' ); return $links; } ); } public static function add_tab( $tabs ) { $tabs['ediverify'] = __( 'EdiVerify', 'ediverify-woocommerce' ); return $tabs; } public static function settings() { $cats = array(); foreach ( get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false ) ) as $t ) { if ( ! is_wp_error( $t ) ) { $cats[ $t->term_id ] = $t->name; } } return array( array( 'title' => __( 'EdiVerify', 'ediverify-woocommerce' ), 'type' => 'title', 'desc' => __( 'Verifieer leeftijd of identiteit in de checkout zonder persoonsgegevens op te slaan.', 'ediverify-woocommerce' ), 'id' => 'ediverify_section' ), array( 'title' => __( 'API-sleutel', 'ediverify-woocommerce' ), 'id' => 'ediverify_api_key', 'type' => 'password', 'desc' => __( 'Begint met live_ of test_.', 'ediverify-woocommerce' ), 'default' => 'test_' ), array( 'title' => __( 'Verplichte controle', 'ediverify-woocommerce' ), 'id' => 'ediverify_check', 'type' => 'select', 'options' => self::CHECKS, 'default' => 'age_over_18' ), array( 'title' => __( 'Toepassen op', 'ediverify-woocommerce' ), 'id' => 'ediverify_scope', 'type' => 'select', 'options' => array( 'all' => __( 'Alle producten', 'ediverify-woocommerce' ), 'selected' => __( 'Alleen geselecteerde categorieën of producten', 'ediverify-woocommerce' ) ), 'default' => 'all' ), array( 'title' => __( '18+ categorieën', 'ediverify-woocommerce' ), 'id' => 'ediverify_categories', 'type' => 'multiselect', 'class' => 'wc-enhanced-select', 'options' => $cats, 'default' => array() ), array( 'title' => __( 'Product-ID\'s', 'ediverify-woocommerce' ), 'id' => 'ediverify_products', 'type' => 'text', 'desc' => __( 'Kommagescheiden, bijv. 12,45,98', 'ediverify-woocommerce' ), 'default' => '' ), array( 'title' => __( 'Ed25519 publieke sleutel', 'ediverify-woocommerce' ), 'id' => 'ediverify_ed25519_x', 'type' => 'text', 'desc' => __( 'Waarde "x" uit /.well-known/jwks.json (base64url). Leeg = sleutel van ediverify.nl. Zelf-hosters vullen hier hun eigen sleutel in.', 'ediverify-woocommerce' ), 'default' => '' ), array( 'title' => __( 'Server-side controle', 'ediverify-woocommerce' ), 'id' => 'ediverify_remote_verify', 'type' => 'select', 'options' => array( 'fallback' => __( 'Alleen als offline Ed25519-controle niet mogelijk is (aanbevolen)', 'ediverify-woocommerce' ), 'always' => __( 'Altijd, naast de offline controle', 'ediverify-woocommerce' ), 'never' => __( 'Nooit', 'ediverify-woocommerce' ) ), 'default' => 'fallback' ), array( 'title' => __( 'API-adres', 'ediverify-woocommerce' ), 'id' => 'ediverify_api_base', 'type' => 'text', 'desc' => __( 'Laat leeg voor de gehoste dienst, of vul het adres van je eigen verifier-bridge in.', 'ediverify-woocommerce' ), 'default' => '' ), array( 'type' => 'sectionend', 'id' => 'ediverify_section' ), ); } public static function render_settings() { woocommerce_admin_fields( self::settings() ); } public static function save_settings() { woocommerce_update_options( self::settings() ); } public static function check() { $c = get_option( 'ediverify_check', 'age_over_18' ); return isset( self::CHECKS[ $c ] ) ? $c : 'age_over_18'; } public static function cart_requires_verification() { if ( ! WC()->cart || WC()->cart->is_empty() ) { return false; } if ( 'all' === get_option( 'ediverify_scope', 'all' ) ) { return true; } $cats = array_map( 'intval', (array) get_option( 'ediverify_categories', array() ) ); $ids = array_filter( array_map( 'intval', explode( ',', (string) get_option( 'ediverify_products', '' ) ) ) ); foreach ( WC()->cart->get_cart() as $item ) { $pid = (int) $item['product_id']; if ( in_array( $pid, $ids, true ) || ( $cats && has_term( $cats, 'product_cat', $pid ) ) ) { return true; } } return false; } public static function enqueue() { if ( ! function_exists( 'is_checkout' ) || ! is_checkout() || ! self::cart_requires_verification() ) { return; } wp_enqueue_script( 'ediverify-widget', EDIVERIFY_WC_BASE . '/widget.js', array(), EDIVERIFY_WC_VERSION, true ); $cfg = wp_json_encode( array( 'check' => self::check(), 'merchant' => get_bloginfo( 'name' ), 'label' => self::CHECKS[ self::check() ], ) ); wp_add_inline_script( 'ediverify-widget', "(function(){var c={$cfg};document.addEventListener('click',function(e){var b=e.target.closest('#ediverify-start');if(!b)return;e.preventDefault();EdiVerify.open({check:c.check,merchant:c.merchant,reference:'wc-'+Date.now(),onVerified:function(r){document.getElementById('ediverify_attestation_token').value=r.token;var s=document.getElementById('ediverify-status');s.textContent=c.label+' bevestigd ('+r.id+')';s.style.color='#059669';b.style.display='none';}});});})();" ); } public static function checkout_field() { if ( ! self::cart_requires_verification() ) { return; } echo '
'; echo '

' . esc_html( sprintf( __( 'Voor deze bestelling is een %s vereist.', 'ediverify-woocommerce' ), self::CHECKS[ self::check() ] ) ) . '

'; echo '

' . esc_html__( 'Verifieer met NL Wallet, iDIN of Yivi. Er worden geen persoonsgegevens opgeslagen.', 'ediverify-woocommerce' ) . '

'; echo ''; echo ''; wp_nonce_field( 'ediverify_checkout', 'ediverify_nonce' ); echo '
'; } public static function b64url_decode( $s ) { $s = strtr( (string) $s, '-_', '+/' ); $pad = strlen( $s ) % 4; if ( $pad ) { $s .= str_repeat( '=', 4 - $pad ); } return base64_decode( $s, true ); } public static function public_key() { $x = trim( (string) get_option( 'ediverify_ed25519_x', '' ) ); $raw = self::b64url_decode( $x ? $x : EDIVERIFY_WC_DEFAULT_ED25519_X ); return ( false !== $raw && 32 === strlen( $raw ) ) ? $raw : false; } /** * Offline Ed25519 verification of a compact token ".". * Returns the decoded payload array, false if invalid, or null if offline verification is not possible. */ public static function verify_ed25519( $token ) { $parts = explode( '.', (string) $token ); if ( 2 !== count( $parts ) ) { return false; } $pk = self::public_key(); if ( ! $pk || ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return null; } $sig = self::b64url_decode( $parts[1] ); if ( false === $sig || 64 !== strlen( $sig ) ) { return false; } try { if ( ! sodium_crypto_sign_verify_detached( $sig, $parts[0], $pk ) ) { return false; } } catch ( \SodiumException $e ) { return false; } $data = json_decode( (string) self::b64url_decode( $parts[0] ), true ); return is_array( $data ) ? $data : false; } /** Checks the boolean attestation. Returns the verification id or false. */ public static function validate_token( $token ) { static $cache = array(); if ( isset( $cache[ $token ] ) ) { return $cache[ $token ]; } $mode = get_option( 'ediverify_remote_verify', 'fallback' ); $payload = self::verify_ed25519( $token ); if ( false === $payload ) { return $cache[ $token ] = false; } if ( null === $payload ) { // Offline not possible (no sodium or no key): fall back to the API. if ( 'never' === $mode ) { return $cache[ $token ] = false; } $remote = self::remote_verify( $token ); if ( ! $remote ) { return $cache[ $token ] = false; } $payload = array( 'typ' => 'ediverify-attestation+v1', 'vid' => $remote['verificationId'], 'att' => $remote['attribute'], 'res' => $remote['result'], 'exp' => time() + 60, 'iat' => time(), 'env' => $remote['environment'] ?? '' ); } elseif ( 'always' === $mode && ! self::remote_verify( $token ) ) { return $cache[ $token ] = false; } $now = time(); $ok = 'ediverify-attestation+v1' === ( $payload['typ'] ?? '' ) && true === ( $payload['res'] ?? null ) && self::check() === ( $payload['att'] ?? '' ) && isset( $payload['exp'], $payload['iat'] ) && $now <= (int) $payload['exp'] + 60 && (int) $payload['iat'] <= $now + 60 && preg_match( '/^ver_[0-9a-f]{24}$/', (string) ( $payload['vid'] ?? '' ) ); // A live_ key must never accept sandbox attestations. if ( $ok && 0 === strpos( (string) get_option( 'ediverify_api_key', '' ), 'live_' ) && 'production' !== ( $payload['env'] ?? '' ) ) { $ok = false; } return $cache[ $token ] = ( $ok ? $payload['vid'] : false ); } public static function api_base() { $base = trim( (string) get_option( 'ediverify_api_base', '' ) ); return $base ? untrailingslashit( esc_url_raw( $base ) ) : EDIVERIFY_WC_BASE; } /** Server-side HMAC check via POST /api/v1/verifications (action=verify_token). */ public static function remote_verify( $token ) { static $cache = array(); if ( isset( $cache[ $token ] ) ) { return $cache[ $token ]; } $resp = wp_remote_post( self::api_base() . '/api/v1/verifications', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . get_option( 'ediverify_api_key', '' ), ), 'body' => wp_json_encode( array( 'action' => 'verify_token', 'token' => $token ) ), ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { return $cache[ $token ] = false; } $body = json_decode( wp_remote_retrieve_body( $resp ), true ); $ok = is_array( $body ) && true === ( $body['valid'] ?? false ) && self::check() === ( $body['attribute'] ?? '' ); return $cache[ $token ] = ( $ok ? $body : false ); } public static function validate() { if ( ! self::cart_requires_verification() ) { return; } $nonce = isset( $_POST['ediverify_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['ediverify_nonce'] ) ) : ''; $token = isset( $_POST['ediverify_attestation_token'] ) ? sanitize_text_field( wp_unslash( $_POST['ediverify_attestation_token'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'ediverify_checkout' ) || ! self::validate_token( $token ) ) { wc_add_notice( sprintf( __( 'Rond eerst de %s af met EdiVerify voordat je de bestelling plaatst.', 'ediverify-woocommerce' ), self::CHECKS[ self::check() ] ), 'error' ); } } public static function save_to_order( $order, $data ) { if ( empty( $_POST['ediverify_attestation_token'] ) ) { return; } $ref = self::validate_token( sanitize_text_field( wp_unslash( $_POST['ediverify_attestation_token'] ) ) ); if ( $ref ) { $label = 'age_over_18' === self::check() ? '18+ Geverifieerd' : self::CHECKS[ self::check() ] . ' geverifieerd'; $order->update_meta_data( '_ediverify_ref', $ref ); $order->update_meta_data( '_ediverify_status', $label . ' (Geen persoonsgegevens opgeslagen)' ); } } public static function admin_order_note( $order ) { $ref = $order->get_meta( '_ediverify_ref' ); if ( $ref ) { echo '

EdiVerify: ' . esc_html( $order->get_meta( '_ediverify_status' ) ) . '
' . esc_html__( 'Referentie:', 'ediverify-woocommerce' ) . ' ' . esc_html( $ref ) . '

'; } } } add_action( 'plugins_loaded', array( 'EdiVerify_WC', 'init' ) );