How to force Authentication on REST API for Password protected page using custom table and fetch() without Plugin

After studying carefully 🤓 the WordPress REST API Handbook concerning

Home / REST API Handbook / Extending the REST API / Routes and Endpoints
Home / REST API Handbook / Extending the REST API / Adding Custom Endpoints

I realized I made a couple of mistakes.

Therefore, I wanted to share with you my findings.

Also, at the end I have some additional questions out of curiosity.

1.) Pass object ‘$request’ to the callback functions:

Before:function get_your_data() { ...
After : function get_your_data($request) { ...

2.) Create namespace and route outside of wp/v2 with your own version number:

Before:register_rest_route( 'wp/v2/your_private_page', '/data', array( ...
After : register_rest_route( 'your_private_page/v1', '/data', array( ...

Also adapt http-request URLs in your client side script:

Before:let url="https://example.com/wp-json/wp/v2/your_private_page/data";
After : let url="https://example.com/wp-json/your_private_page/v1/data";

3.) Add ‘permission_callback’ to your routes:

Before: I didn’t have it (just had the main callbacks)
After : Added prefix_get_private_data_permissions_check() as Permission Callback function

//  Permission Callback 
// 'ypp' is the Prefix I chose (ypp = Your Private Page)

function ypp_get_private_data_permissions_check() {
    // Restrict endpoint to browsers that have the wp-postpass_ cookie.

    if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) {
       return new WP_Error( 'rest_forbidden', esc_html__( 'OMG you can not create or edit private data.', 'my-text-domain' ), array( 'status' => 401 ) );
    };
    // This is a black-listing approach. You could alternatively do this via white-listing, by returning false here and changing the permissions check.
    return true;
};

// And then add the permission_callback to your POST and PUT routes:

add_action('rest_api_init', function() {
    /**
    * Register here your custom routes for your CRUD functions
    */
    register_rest_route( 'your_private_page/v1', '/data', array(
       array(
          'methods'  => WP_REST_Server::READABLE,
          'callback' => 'get_your_data',
          // Always allow.
          'permission_callback' => '__return_true' // <-- good practice, according to docs
       ),
       array(
          'methods'  => WP_REST_Server::CREATABLE,
          'callback' => 'insert_your_data',
          // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
          'permission_callback' => 'ypp_get_private_data_permissions_check', // <-- that was the missing part
       ),
       array(
          'methods'  => WP_REST_Server::EDITABLE,
          'callback' => 'update_your_data',
          // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
          'permission_callback' => 'ypp_get_private_data_permissions_check', // <-- that was the missing part
       ),
    ));
});

Obviously, that makes a lot of sense, because it’s on the server side (WordPress, php) where the authorization has to take place (dummy me, he he he)


4.) Complete code:

Following code-snippets worked for my selfhosted WordPress installation:

WordPress: version 5.7.2
PHP: version 7.4
host: hostmonster.com
client: Windows 10
browsers: tested on Chrome, Firefox, even Edge 😜 worked

Code (HTML with inline <script> ... </script>):

<form id="form1" name="form1">
  <button id="get" onclick="getValues()">GET</button>
  <button id="insert" onclick="insertValues()">CREATE</button>
  <button id="update" onclick="updateValues()">UPDATE</button>
</form>

<script>
let yourData = [];
let yourDataNew = {};
let yourDataUpdated = {};
let token = "";

function getValues() {
  event.preventDefault();
  //READ data
  getYourData();
};
function insertValues() {
  event.preventDefault();
  //CREATE new datarecord
  yourDataNew = {"column_1": "test-1", "column_2": "test-2"};
  insertYourData(yourDataNew);
};
function updateValues() {
  event.preventDefault();
  //UPDATE datarecord
  let idOfLastRecord = yourData[yourData.length-1].id;
  yourDataUpdated = {"id": idOfLastRecord, "column_1": "test-1-modified", "column_2": "test-2-modified"};
  updateYourData(yourDataUpdated);
};

// We don't need to retrieve token form Cookie. See my EDIT at the end

//GET value of Access Cookie wp-postpass_{hash}
//token = ("; "+document.cookie).split("; wp-postpass_675xxxxxx   =").pop().split(";").shift(); 
//console.log('TOKEN: ' + token);

// Here comes the REST API part:
// HTTP requests with fetch() promises
function getYourData() {
  let url="https://example.com/wp-json/your_private_page/v1/data";
  fetch(url, {
    method: 'GET',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    }
  }).then(res => res.json())
  .then(response => get_success(response))
  .catch(error => failure(error));
};

function insertYourData(data) {
  let url="https://example.com/wp-json/your_private_page/v1/data";
  fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => create_success(response))
  .catch(error => failure(error));
};

function updateYourData(data) {
  let url="https://example.com/wp-json/your_private_page/v1/data";
  fetch(url, {
    method: 'PUT',
    credentials: 'same-origin',
    headers:{
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        //'Authorization': 'Bearer ' + token  <-- not needed, see EDIT at end
    },
    body: JSON.stringify(data)
  }).then(res => res.json())
  .then(response => update_success(response))
  .catch(error => failure(error));
};

// fetch() promises success functions:
function get_success(json) {
  data = JSON.stringify(json);
  yourData = JSON.parse(data);
  console.log('GET');
  console.log(yourData);
};
function create_success(json) {
  let insertResponse = JSON.stringify(json);
  insertResponse = JSON.parse(insertResponse);
  console.log('CREATE');
  console.log(insertResponse);
};
function update_success(json) {
  let updateResponse = JSON.stringify(json);
  updateResponse = JSON.parse(updateResponse);
  console.log('UPDATE');
  console.log(updateResponse);
};
function failure(error) {
  console.log("Error: " + error);
};

</script>

Code (PHP code in function.php of my installed theme):

/**
 * Add here your custom CRUD functions
 */

/**
  * This is our callback function to return (GET) our data.
  *
  * @param WP_REST_Request $request This function accepts a rest request to process data.
  */
function get_your_data($request) {
    global $wpdb;
    $yourdata = $wpdb->get_results("SELECT * FROM your_custom_table");

    return rest_ensure_response( $yourdata );
};

/**
 * This is our callback function to insert (POST) new data record.
 *
 * @param WP_REST_Request $request This function accepts a rest request to process data.
 */
function insert_your_data($request) {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $newrecord = $wpdb->insert( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']));
    };
    if($newrecord){
        return rest_ensure_response($newrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };

    header("Content-Type: application/json; charset=UTF-8");
};
/**
 * This is our callback function to update (PUT) a data record.
 *
 * @param WP_REST_Request $request This function accepts a rest request to process data.
 */
function update_your_data($request) {
    global $wpdb;
    $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';

    if ($contentType === "application/json") {
        $content = trim(file_get_contents("php://input"));
        $decoded = json_decode($content, true);
        $updatedrecord = $wpdb->update( 'your_custom_table', array( 'column_1' => $decoded['column_1'], 'column_2' => $decoded['column_2']), array('id' => $decoded['id']), array( '%s' ));
    };

    if($updatedrecord){
        return rest_ensure_response($updatedrecord);
    }else{
        //something gone wrong
        return rest_ensure_response('failed');
    };

    header("Content-Type: application/json; charset=UTF-8");
};

// 'ypp' is the Prefix I chose (ypp = Your Private Page)
function ypp_get_private_data_permissions_check() {
    // Restrict endpoint to browsers that have the wp-postpass_ cookie.

    if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) {
        return new WP_Error( 'rest_forbidden', esc_html__( 'OMG you can not create or edit private data.', 'my-text-domain' ), array( 'status' => 401 ) );
    };
    // This is a black-listing approach. You could alternatively do this via white-listing, by returning false here and changing the permissions check.
    return true;
};

add_action('rest_api_init', function() {
    /**
    * Register here your custom routes for your CRUD functions
    */
    register_rest_route( 'your_private_page/v1', '/data', array(
        array(
           'methods'  => WP_REST_Server::READABLE,
           'callback' => 'get_your_data',
           // Always allow.
           'permission_callback' => '__return_true'
        ),
        array(
           'methods'  => WP_REST_Server::CREATABLE,
           'callback' => 'insert_your_data',
           // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
           'permission_callback' => 'ypp_get_private_data_permissions_check',
        ),
        array(
           'methods'  => WP_REST_Server::EDITABLE,
           'callback' => 'update_your_data',
           // Here we register our permissions callback. The callback is fired before the main callback to check if the current user can access the endpoint.
           'permission_callback' => 'ypp_get_private_data_permissions_check',
        ),
    ));
});

5.) Testing

Finally got my so desperately anticipated FAILURE  😊

Finally got my so desperately anticipated FAILURE 😊


Final Question, though:

Is 'Authorization': 'Bearer ' + token necessary in header of HTTP request?

After some testing, I realized that if ( !isset($_COOKIE['wp-postpass_'. COOKIEHASH] )) { within the Permission Callback not only checks if the Cookie is set on client browser, but it seems also to check its value (the JWT token).

Because I dobble checked as with my initial code, passing a false token, eliminating the cookie, or leaving session open but changing in the back-end the password of site (hence WordPress would create a new token, hence value of set wp_postpass_ cookie would change) and all test went correctly – REST API blocked, not only verifying presence of cookie, but also its value (which is good – thank you WordPress team).

My Questions here:

  • Can I leave out the 'Authorization': 'Bearer ' + token within my
    HTTP request header ?
  • Hence, do I have to retrieve at all the cookie value (token) or was it not necessary?

I am not sure whether I got it completely right now. Any comments and suggestions are so welcome.

Thank you.

EDIT:

I found the answer to my 2 questions above in the FAQ section:

Why is the REST API not verifying the incoming Origin header? Does this expose my site to CSRF attacks?

Because the WordPress REST API does not verify the Origin header of incoming requests, public REST API endpoints may therefore be accessed from any site.
This is an intentional design decision.

However, WordPress has an existing CSRF protection mechanism which uses nonces.

And according to my testing so far, the WP-way of authentication works perfectly well.

Thumbs up 👍 for the WordPress team

Leave a Comment