feat: Sprint 2 — WordPress plugin (auth/api/site-info/admin) + web SiteConnector
Mirror to GitHub / mirror (push) Canceled after 0s
Mirror to GitHub / mirror (push) Canceled after 0s
- plugin: Wursor_Auth (token hashing, HMAC, scoped tokens), Wursor_API (REST + auth), Wursor_Site_Info (builder/capabilities/preflight), Wursor_Admin - plugin: test-auth.php (10 auth tests, run in a WP+PHP env) - web: SiteConnector pairing UI (code + poll + states) - api: PluginClient signs full REST route (matches WP get_route) - 92 unit tests green
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
class Wursor_Auth_Test extends WP_UnitTestCase {
|
||||
|
||||
private function sign( $secret, $timestamp, $method, $route, $body ) {
|
||||
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
|
||||
return hash_hmac( 'sha256', $canonical, $secret );
|
||||
}
|
||||
|
||||
public function tear_down() {
|
||||
Wursor_Auth::clear_tokens();
|
||||
parent::tear_down();
|
||||
}
|
||||
|
||||
public function test_store_tokens_hashes_tokens_not_plaintext() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
|
||||
$this->assertNotEquals( 'read-token', get_option( Wursor_Auth::OPTION_READ_HASH ) );
|
||||
$this->assertEquals( hash( 'sha256', 'read-token' ), get_option( Wursor_Auth::OPTION_READ_HASH ) );
|
||||
$this->assertNotEquals( 'hmac-secret', get_option( Wursor_Auth::OPTION_HMAC_SECRET ) );
|
||||
}
|
||||
|
||||
public function test_verify_token_accepts_matching_token() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$this->assertTrue( Wursor_Auth::verify_token( 'read-token', 'read' ) );
|
||||
$this->assertTrue( Wursor_Auth::verify_token( 'deploy-token', 'deploy' ) );
|
||||
}
|
||||
|
||||
public function test_verify_token_rejects_wrong_token() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$this->assertFalse( Wursor_Auth::verify_token( 'wrong', 'read' ) );
|
||||
}
|
||||
|
||||
public function test_read_token_does_not_verify_as_deploy() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$this->assertFalse( Wursor_Auth::verify_token( 'read-token', 'deploy' ) );
|
||||
}
|
||||
|
||||
public function test_hmac_accepts_valid_signature() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$ts = (string) time();
|
||||
$route = '/wursor/v1/site-info';
|
||||
$body = '';
|
||||
$this->assertTrue( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
|
||||
}
|
||||
|
||||
public function test_hmac_rejects_stale_timestamp() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$ts = (string) ( time() - 120 );
|
||||
$route = '/wursor/v1/site-info';
|
||||
$body = '';
|
||||
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'GET', $route, $body, $this->sign( 'hmac-secret', $ts, 'GET', $route, $body ) ) );
|
||||
}
|
||||
|
||||
public function test_hmac_rejects_tampered_body() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$ts = (string) time();
|
||||
$route = '/wursor/v1/files';
|
||||
$sig = $this->sign( 'hmac-secret', $ts, 'POST', $route, '{"a":1}' );
|
||||
$this->assertFalse( Wursor_Auth::verify_hmac( $ts, 'POST', $route, '{"a":2}', $sig ) );
|
||||
}
|
||||
|
||||
public function test_hmac_rejects_missing_signature() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
$this->assertFalse( Wursor_Auth::verify_hmac( (string) time(), 'GET', '/wursor/v1/site-info', '', null ) );
|
||||
}
|
||||
|
||||
public function test_rotated_tokens_invalidate_old_hashes() {
|
||||
Wursor_Auth::store_tokens( 'old-read', 'old-deploy', 'old-secret' );
|
||||
Wursor_Auth::store_tokens( 'new-read', 'new-deploy', 'new-secret' );
|
||||
|
||||
$this->assertFalse( Wursor_Auth::verify_token( 'old-read', 'read' ) );
|
||||
$this->assertTrue( Wursor_Auth::verify_token( 'new-read', 'read' ) );
|
||||
}
|
||||
|
||||
public function test_disconnect_clears_tokens() {
|
||||
Wursor_Auth::store_tokens( 'read-token', 'deploy-token', 'hmac-secret' );
|
||||
Wursor_Auth::clear_tokens();
|
||||
$this->assertFalse( Wursor_Auth::is_connected() );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Admin settings page: paste the Wursor pairing code, connect, disconnect.
|
||||
*/
|
||||
class Wursor_Admin {
|
||||
|
||||
public static function register_menu() {
|
||||
add_menu_page( 'Wursor', 'Wursor', 'manage_options', 'wursor', array( __CLASS__, 'render' ), 'dashicons-update' );
|
||||
}
|
||||
|
||||
public static function render() {
|
||||
self::handle_post();
|
||||
$connected = Wursor_Auth::is_connected();
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1>Wursor</h1>
|
||||
<?php if ( $connected ) : ?>
|
||||
<p>This site is connected to Wursor.</p>
|
||||
<form method="post">
|
||||
<input type="hidden" name="wursor_disconnect" value="1" />
|
||||
<?php submit_button( 'Disconnect' ); ?>
|
||||
</form>
|
||||
<?php else : ?>
|
||||
<p>Paste the pairing code from Wursor to connect this site.</p>
|
||||
<form method="post">
|
||||
<input type="text" name="wursor_pairing_code" maxlength="8" autocomplete="off" placeholder="ABCD1234" />
|
||||
<?php submit_button( 'Connect' ); ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
private static function handle_post() {
|
||||
if ( isset( $_POST['wursor_disconnect'] ) ) {
|
||||
Wursor_Auth::clear_tokens();
|
||||
return;
|
||||
}
|
||||
if ( empty( $_POST['wursor_pairing_code'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$code = sanitize_text_field( wp_unslash( $_POST['wursor_pairing_code'] ) );
|
||||
$api_url = get_option( 'wursor_api_url', 'https://api.wursor.dev' );
|
||||
|
||||
$response = wp_remote_post(
|
||||
rtrim( $api_url, '/' ) . '/sites/redeem',
|
||||
array(
|
||||
'body' => wp_json_encode( array( 'code' => $code, 'siteUrl' => home_url( '/' ) ) ),
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'timeout' => 15,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( isset( $body['readToken'], $body['deployToken'], $body['hmacSecret'] ) ) {
|
||||
Wursor_Auth::store_tokens( $body['readToken'], $body['deployToken'], $body['hmacSecret'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* REST API routes under /wp-json/wursor/v1/.
|
||||
* Every request requires a Bearer token with the right scope and a valid HMAC signature.
|
||||
*/
|
||||
class Wursor_API {
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route( 'wursor/v1', '/site-info', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( __CLASS__, 'get_site_info' ),
|
||||
'permission_callback' => array( __CLASS__, 'authorize_read' ),
|
||||
) );
|
||||
register_rest_route( 'wursor/v1', '/files', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( __CLASS__, 'stub' ),
|
||||
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||
) );
|
||||
register_rest_route( 'wursor/v1', '/db', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( __CLASS__, 'stub' ),
|
||||
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||
) );
|
||||
register_rest_route( 'wursor/v1', '/wp-cli', array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( __CLASS__, 'stub' ),
|
||||
'permission_callback' => array( __CLASS__, 'authorize_deploy' ),
|
||||
) );
|
||||
}
|
||||
|
||||
public static function authorize_read( WP_REST_Request $request ) {
|
||||
return self::authorize( $request, 'read' );
|
||||
}
|
||||
|
||||
public static function authorize_deploy( WP_REST_Request $request ) {
|
||||
return self::authorize( $request, 'deploy' );
|
||||
}
|
||||
|
||||
private static function authorize( WP_REST_Request $request, $scope ) {
|
||||
$auth = $request->get_header( 'authorization' );
|
||||
if ( ! is_string( $auth ) || 0 !== strpos( $auth, 'Bearer ' ) ) {
|
||||
return new WP_Error( 'wursor_unauthorized', 'Missing bearer token', array( 'status' => 401 ) );
|
||||
}
|
||||
$token = substr( $auth, 7 );
|
||||
|
||||
if ( 'deploy' === $scope ) {
|
||||
if ( ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
|
||||
return new WP_Error( 'wursor_forbidden', 'Deploy token required', array( 'status' => 403 ) );
|
||||
}
|
||||
} elseif ( ! Wursor_Auth::verify_token( $token, 'read' ) && ! Wursor_Auth::verify_token( $token, 'deploy' ) ) {
|
||||
return new WP_Error( 'wursor_unauthorized', 'Invalid token', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
$timestamp = $request->get_header( 'x-wursor-timestamp' );
|
||||
$signature = $request->get_header( 'x-wursor-signature' );
|
||||
$route = $request->get_route();
|
||||
$body = $request->get_body();
|
||||
|
||||
if ( ! Wursor_Auth::verify_hmac( $timestamp, $request->get_method(), $route, $body, $signature ) ) {
|
||||
return new WP_Error( 'wursor_bad_signature', 'Bad HMAC signature', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function get_site_info( WP_REST_Request $request ) {
|
||||
return Wursor_Site_Info::get_site_info();
|
||||
}
|
||||
|
||||
public static function stub( WP_REST_Request $request ) {
|
||||
return new WP_Error( 'wursor_not_implemented', 'Not implemented until Sprint 6', array( 'status' => 501 ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Token storage, verification, and request signing (HMAC).
|
||||
*
|
||||
* Tokens are stored only as SHA-256 hashes; the HMAC secret is stored encrypted
|
||||
* with a key derived from the site's AUTH_KEY + AUTH_SALT. See spikes/pairing-threat-model.md.
|
||||
*/
|
||||
class Wursor_Auth {
|
||||
const OPTION_READ_HASH = 'wursor_read_token_hash';
|
||||
const OPTION_DEPLOY_HASH = 'wursor_deploy_token_hash';
|
||||
const OPTION_HMAC_SECRET = 'wursor_hmac_secret';
|
||||
const MAX_SKEW_SECONDS = 60;
|
||||
|
||||
public static function store_tokens( $read_token, $deploy_token, $hmac_secret ) {
|
||||
update_option( self::OPTION_READ_HASH, hash( 'sha256', $read_token ), true );
|
||||
update_option( self::OPTION_DEPLOY_HASH, hash( 'sha256', $deploy_token ), true );
|
||||
update_option( self::OPTION_HMAC_SECRET, self::encrypt( $hmac_secret ), true );
|
||||
}
|
||||
|
||||
public static function clear_tokens() {
|
||||
delete_option( self::OPTION_READ_HASH );
|
||||
delete_option( self::OPTION_DEPLOY_HASH );
|
||||
delete_option( self::OPTION_HMAC_SECRET );
|
||||
}
|
||||
|
||||
public static function is_connected() {
|
||||
return false !== get_option( self::OPTION_READ_HASH );
|
||||
}
|
||||
|
||||
public static function verify_token( $token, $scope ) {
|
||||
$option = 'deploy' === $scope ? self::OPTION_DEPLOY_HASH : self::OPTION_READ_HASH;
|
||||
$stored = get_option( $option );
|
||||
return is_string( $stored ) && hash_equals( $stored, hash( 'sha256', $token ) );
|
||||
}
|
||||
|
||||
public static function verify_hmac( $timestamp, $method, $route, $body, $signature ) {
|
||||
if ( ! is_string( $signature ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( abs( time() - intval( $timestamp ) ) > self::MAX_SKEW_SECONDS ) {
|
||||
return false;
|
||||
}
|
||||
$canonical = $timestamp . "\n" . strtoupper( $method ) . "\n" . $route . "\n" . hash( 'sha256', $body );
|
||||
$expected = hash_hmac( 'sha256', $canonical, self::hmac_secret() );
|
||||
return hash_equals( $expected, $signature );
|
||||
}
|
||||
|
||||
private static function hmac_secret() {
|
||||
$encrypted = get_option( self::OPTION_HMAC_SECRET );
|
||||
return false === $encrypted ? '' : self::decrypt( $encrypted );
|
||||
}
|
||||
|
||||
private static function encryption_key() {
|
||||
return hash( 'sha256', wp_salt( 'auth' ) . wp_salt( 'auth_salt' ) );
|
||||
}
|
||||
|
||||
private static function encrypt( $value ) {
|
||||
$iv = random_bytes( 16 );
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt( $value, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
|
||||
if ( false === $ciphertext ) {
|
||||
return false;
|
||||
}
|
||||
return base64_encode( $iv . $tag . $ciphertext );
|
||||
}
|
||||
|
||||
private static function decrypt( $value ) {
|
||||
$data = base64_decode( $value, true );
|
||||
if ( false === $data || strlen( $data ) < 32 ) {
|
||||
return '';
|
||||
}
|
||||
$iv = substr( $data, 0, 16 );
|
||||
$tag = substr( $data, 16, 16 );
|
||||
$ciphertext = substr( $data, 32 );
|
||||
$plaintext = openssl_decrypt( $ciphertext, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag );
|
||||
return false === $plaintext ? '' : $plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Site information provider: theme, plugins, versions, builder, capability tiers, preflight.
|
||||
* Builder detection mirrors e2e/golden/src/builder-detect.ts (see ADR 0006).
|
||||
*/
|
||||
class Wursor_Site_Info {
|
||||
|
||||
public static function get_site_info() {
|
||||
$theme = wp_get_theme();
|
||||
return array(
|
||||
'theme' => $theme->get_stylesheet(),
|
||||
'plugins' => self::plugins(),
|
||||
'wordpress_version' => get_bloginfo( 'version' ),
|
||||
'php_version' => PHP_VERSION,
|
||||
'builder' => self::detect_builder(),
|
||||
'capabilities' => self::capabilities(),
|
||||
'preflight' => self::preflight(),
|
||||
);
|
||||
}
|
||||
|
||||
private static function plugins() {
|
||||
if ( ! function_exists( 'get_plugins' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
}
|
||||
$all = get_plugins();
|
||||
$active = (array) get_option( 'active_plugins', array() );
|
||||
$result = array();
|
||||
foreach ( $all as $plugin_file => $data ) {
|
||||
$result[] = array(
|
||||
'slug' => self::slug_from_file( $plugin_file ),
|
||||
'active' => in_array( $plugin_file, $active, true ),
|
||||
);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function active_slugs() {
|
||||
$active = (array) get_option( 'active_plugins', array() );
|
||||
return array_map( array( __CLASS__, 'slug_from_file' ), $active );
|
||||
}
|
||||
|
||||
private static function slug_from_file( $file ) {
|
||||
$dir = dirname( $file );
|
||||
return '.' === $dir ? basename( $file, '.php' ) : $dir;
|
||||
}
|
||||
|
||||
private static function front_page_id() {
|
||||
$front = (int) get_option( 'page_on_front' );
|
||||
if ( $front > 0 ) {
|
||||
return $front;
|
||||
}
|
||||
$pages = get_pages( array( 'number' => 1 ) );
|
||||
return empty( $pages ) ? 0 : $pages[0]->ID;
|
||||
}
|
||||
|
||||
private static function front_page_content() {
|
||||
$id = self::front_page_id();
|
||||
return $id > 0 ? (string) get_post_field( 'post_content', $id ) : '';
|
||||
}
|
||||
|
||||
private static function detect_builder() {
|
||||
$theme = wp_get_theme()->get_stylesheet();
|
||||
$active = self::active_slugs();
|
||||
$id = self::front_page_id();
|
||||
$content = self::front_page_content();
|
||||
|
||||
$elementor_mode = $id > 0 ? get_post_meta( $id, '_elementor_edit_mode', true ) : '';
|
||||
$elementor_data = $id > 0 ? get_post_meta( $id, '_elementor_data', true ) : '';
|
||||
$fl_builder = $id > 0 ? get_post_meta( $id, '_fl_builder_data', true ) : '';
|
||||
$et_pb = $id > 0 ? get_post_meta( $id, '_et_pb_use_builder', true ) : '';
|
||||
|
||||
if ( in_array( 'elementor', $active, true ) && ( '' !== $elementor_mode || '' !== $elementor_data ) ) {
|
||||
return 'elementor';
|
||||
}
|
||||
if ( in_array( 'beaver-builder-lite-version', $active, true ) && '' !== $fl_builder ) {
|
||||
return 'beaver';
|
||||
}
|
||||
if ( false !== stripos( $theme, 'divi' ) && 'on' === $et_pb ) {
|
||||
return 'divi';
|
||||
}
|
||||
if ( false !== strpos( $content, '<!-- wp:' ) ) {
|
||||
return 'gutenberg';
|
||||
}
|
||||
return 'classic';
|
||||
}
|
||||
|
||||
private static function capabilities() {
|
||||
$full = version_compare( get_bloginfo( 'version' ), '6.1', '>=' ) && version_compare( PHP_VERSION, '8.0', '>=' );
|
||||
$install_safe = ! defined( 'DISALLOW_FILE_MODS' ) || ! DISALLOW_FILE_MODS;
|
||||
return array(
|
||||
'content' => true,
|
||||
'design' => $full,
|
||||
'install' => $full && $install_safe,
|
||||
);
|
||||
}
|
||||
|
||||
private static function preflight() {
|
||||
return array(
|
||||
'https' => is_ssl(),
|
||||
'disallow_file_mods' => defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS,
|
||||
'disk_free' => function_exists( 'disk_free_space' ) ? disk_free_space( ABSPATH ) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Wursor
|
||||
* Description: Connects this WordPress site to Wursor for safe, previewed changes.
|
||||
* Version: 0.1.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: Wursor
|
||||
* License: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
require_once __DIR__ . '/src/class-auth.php';
|
||||
require_once __DIR__ . '/src/class-site-info.php';
|
||||
require_once __DIR__ . '/src/class-api.php';
|
||||
require_once __DIR__ . '/src/class-admin.php';
|
||||
|
||||
add_action('rest_api_init', array('Wursor_API', 'register_routes'));
|
||||
add_action('admin_menu', array('Wursor_Admin', 'register_menu'));
|
||||
Reference in New Issue
Block a user