Password protected posts redirects

TL;TR? Spoiler alert – hover over the next blockquote to expose it.

Building an archive that is easily filterable by the currently entered password is nothing that is easy and surely not elegant buildable. The solution involves a secondary query and a small plugin, but it works. All errors and wrong paths are shown as well to help you avoid making the same mistakes.

DB Structure

The password is part of the main $wpdb->posts table. It’s saved in plain text (just to make that clear. Don’t be afraid of that. It’s not connected to any actual user login, so you don’t have to be afraid that you are opening a security hole when someone finds it per accident.

Building/Altering the main query

To reduce the results to password protected posts, we’re intercepting the WHERE clause for:

  • A Page
  • Anything Public
  • All pages with a template that has a file name of password-protected.php

Here is the actual code as a plugin. Please note, that it will not work. Both is_page_template() and is_page() with a slug argument will retrieve their data from get_queried_object(). And the queried object is present when we already have retrieved the final query result.

<?php
/** Plugin Name: (#135443) Password Protected Posts Page Template Query */

add_action( 'pre_get_posts', 'wpse135443ExcludeProtectedAction' );
function wpse135443ExcludeProtectedAction( $query )
{
    if (
            is_page( 'slug-of-protected-page' )
            AND is_page_template( 'password-protected.php' )
            AND ! is_admin()
    )
        add_filter( 'posts_where', 'wpse135443ExcludeProtected' );
}
// Callback for the posts WHERE clause that only queries for password protected posts
function wpse135443ExcludeProtected( $where )
{
    return $where .= " AND NOT {$GLOBALS['wpdb']->posts}.post_password = '' ";
}

Page Template

There are several ways to go on this. One is using a filter:

add_filter( 'the_excerpt', 'wpse135443PasswordProtectedExcerpt' );
function wpse135443PasswordProtectedExcerpt( $excerpt )
{
    return post_password_required()
        ? get_the_password_form()
        : $excerpt;

}

Much easier would be to just list the title and then add the excerpt or content directly in the dedicated template. post_password_required() returns a boolean result so it’s easy to check against it. The function accepts one argument, which is the post ID. It’s already retrieved from the global inside the loop, but you can as well just use it outside the loop with throwing a password into it.

post_password_required() AND print get_the_password_form();

Retrieving the password and adding it to the query:

The following function adds the password to the (public) query string. As you can see this solution is quite unsecure: It exposes the password in the HTTP request.

add_action( 'login_form_postpass', 'wpse135443PasswordToQueryString' );
function wpse135443PasswordToQueryString()
{
    require_once ABSPATH . 'wp-includes/class-phpass.php';
    $hasher = new PasswordHash( 8, true );

    $expire = apply_filters( 'post_password_expires', time() + 10 * DAY_IN_SECONDS );
    setcookie( 'wp-postpass_' . COOKIEHASH, $hasher->HashPassword( wp_unslash( $_POST['post_password'] ) ), $expire, COOKIEPATH );

    exit( wp_safe_redirect( add_query_arg(
        'post_password',
        esc_attr( $_POST['post_password'] ),
        wp_get_referer()
    ) ) );
} );

While this might work, it’s really not recommended to do that. If you really want to go from there, you’ll hopefully know how to proceed. For the sake of sanity I’m not exposing more than this – snippet hunters might else grab and use it in themes for public distribution and I don’t want to deliver the root of a problem.

Nothing works? Other options?

The first big problem is, that WordPress per default has no “Protected Posts Archive” as you can see in the template hierarchy. And when we want to alter the main query in a page template, we will have to check the SQL string and compare against it. Plugins might intercept that (poorly) and there’s no reliable way to react with intercepted and altered SQL clauses in WP. An example of how the value of a filter callback/action might look, which is hooked to posts_clauses. $wpdb->posts is the name of your posts table.

array (size=7)
  'where' => string ' AND $wpdb->posts.ID = 1519 AND $wpdb->posts.post_type="page"' (length=63)
  'groupby' => string '' (length=0)
  'join' => string '' (length=0)
  'orderby' => string '$wpdb->posts.post_date DESC' (length=27)
  'distinct' => string '' (length=0)
  'fields' => string '$wpdb->posts.*' (length=14)
  'limits' => string '' (length=0)

Nothing to see here.

So the next thing we are trying is to add in another query. To make is easy to identify it, we will add an additional, custom query var.

$ppQuery = new WP_Query( array(
    'password_protected' => TRUE,
    'post_type'          => 'post',
    'posts_per_page'     => get_option( 'posts_per_page' ),
    'post_status'        => 'publish',
) );

The resulting SQL statement will look close to the following:

SELECT SQL_CALC_FOUND_ROWS  $wpdb->posts.*
FROM $wpdb->posts
WHERE 1=1
    AND $wpdb->posts.post_type="post" 
    AND ($wpdb->posts.post_status="publish")
    AND NOT ($wpdb->posts.post_password = '')
ORDER BY $wpdb->posts.post_date DESC LIMIT 0, 10

Then we can loop through it:

<?php
if ( $ppQuery->have_posts() )
{
    ?>
    <div class="entry-content">
        <?php echo get_the_password_form( $GLOBALS['post'] ); ?>
    </div>
    <?php
    while ( $ppQuery->have_posts() )
    {
        $ppQuery->the_post(); ?>

        <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
            <?php the_title( '<header class="entry-header"><h2>', '</h2></header>' ); ?>
            <div class="entry-content">
            <?php
            post_password_required()
                OR the_excerpt();
            ?>
            </div>
        </article>

    <?php
    }
} ?>

The password form is shown at the top of the template. It takes the needed data from the global $post object, which is the first WP_Post instance held by the global $wp_query object. It doesn’t really matter which post is used as this is only used to build the <label> for the form and the ID of the input field and nothing else. The form will still fire for name="post_password".

The excerpt is only shown if post_password_required() is TRUE. When firing the form, wp-login.php?action=postpass retrieves the $_POST data, sets the cookie and does a safe redirect. Therefore every post that shares the same password will be “unlocked” – WP is simply checking the Cookie Hash value against a hashed value of all the posts passwords. If it matches, it gets displayed.

Here is the plugin that aligns this template and that is needed to let it work.

<?php
/**
 * Plugin Name: (#135443) Password Protected Posts Page Template Query
 */

add_action( 'pre_get_posts', 'wpse135443ExcludeProtectedAction' );
function wpse135443ExcludeProtectedAction( $query )
{
    if (
        $query->get( 'password_protected' )
        AND ! is_admin()
    )
        add_filter( 'posts_where', 'wpse135443ExcludeProtected' );
}
// Callback for the posts WHERE clause that only queries for password protected posts
function wpse135443ExcludeProtected( $pieces )
{
    remove_filter( current_filter(), __FUNCTION__ );
    return $where .= " AND NOT ({$GLOBALS['wpdb']->posts}.post_password = '') ";
}

Filtering the posts

It would be nearly impossible (or only hardly achievable) to query against hashed values and to do hashing of post passwords during the query. So the easiest way is to filter the posts on runtime with a FilterIterator:

class PasswordProtectedPostsLoop extends \FilterIterator implements \Countable
{
    protected $wp_query;

    protected $allowed = FALSE;

    protected $total = 0;

    protected $counter = 0;

    /**
     * @param \Iterator $iterator
     * @param \WP_Query $wp_query
     */
    public function __construct( \Iterator $iterator, \WP_Query $wp_query )
    {
        # Setup properties
        // Global main query object
        NULL === $this->wp_query AND $this->wp_query = $wp_query;

        // Posts for this request
        $this->total = $this->wp_query->query_vars['posts_per_page'];

        // Internal counter
        0 !== $this->counter AND $this->counter = 0;

        $this->allowed = $this->wp_query->have_posts();

        parent::__construct( $iterator );
    }

    /**
     * @return bool
     */
    public function accept()
    {
        if (
            ! $this->allowed
            OR ! $this->current() instanceof \WP_Post
        )
            return FALSE;

        $this->wp_query->the_post();

        // Rewind posts for next loop
        $this->wp_query->current_post === $this->total -1
            AND $this->wp_query->rewind_posts();

        if ( ! post_password_required() )
            return FALSE;

        $this->counter++;
        return TRUE;
    }

    /**
     * Helper function to retrieve the ID of the currently looped post.
     * @return int
     */
    public function getID()
    {
        return $this->current()->ID;
    }

    /**
     * @return int
     */
    public function count()
    {
        return $this->counter;
    }
}

Looping would then be as simple as:

$ppArrayObj = new \ArrayObject( $ppQuery->->get_posts() );
$ppLoop = new PasswordProtectedPostsLoop( $ppArrayObj->getIterator(), $ppQuery );
$ppLoop->rewind();
foreach ( $ppyLoop as $post )
{
        ?>
        <article <?php post_class(); ?>>
            <?php the_title( '<header class="entry-header"><h2>', '</h2></header>' ); ?>
            <div class="entry-content">
                <?php the_excerpt(); >
            </div>
        </article>
        <?php
}

Leave a Comment