Consider this scenario. You have a WordPress site with a large user base set up on a domain. Lets call it iwishicouldcodebetter.com (because test.com and example.com are already taken). Over time you get sick of answering emails asking you how to use your site. You add a documentation site at documents.iwishicouldcodebetter.com and fill it with information for your site users. After a while, you realise that it would help to put some more sensitive data on your documentation site. While that data isn’t going to destroy your company if it gets out into the wild, it would probably be best if it wasn’t out on display for everyone to see like some kind of attention seeking Kardashian. You add user profiles to your documentation site and hide your sensitive content behind is_user_logged_in() tags. However, your users keep getting confused about which credentials are for what, and annoyed with having to login to two different sites. Nothing is more irritable than a confused user. So what next?

The first thing you might say would be “this is a really convoluted problem, it just wouldn’t happen in the real world”, to which I reply “erm… it happened to me”. You might follow that with “Justin Tadlock was installing two wordpress blogs with the same users eight years ago”. I would then be forced to admit that the idea of playing around with a large and important live database is the kind of thing that makes me go wobbly at the knees (and not in that good way like when you fall in love, or drink lots of good wine, or fall in love with lots of good wine). From the extensive research I undertook (went to page two of the google results), it was also clear that there could be issues with permissions with this method, and lets add at this juncture that iwishicouldcodebetter.com requires custom user roles and capabilities, and that at some point in the future, this may be a requirement on the documents site too (this late mention of a key point makes this even more like a real world scenario).

Hard work, persistence, and opening more than one related Stack Overflow post does pay off though. For starters, the “two sites, one cup of users” (sorry) technique made mention of cookie paths, and WordPress login cookies in general, which set my mind off at a nice tangent (as opposed to the usual tangent of “bugger this, its too hard”), and helped me establish some key points:

  1. We don’t actually need any users to login to the documents site, we just need to ascertain that they are logged into the main site.
  2. WordPress establishes if a user is logged in by weaving some sort of magic on a login cookie that it sets when the user logs in
  3. Our main site uses the REST API already (oh sorry, didn’t I mention that?)
  4. If the subdomain site could access the main site cookie it could ascertain if the user appears to be logged in, and if so, send the credentials to the main site for verification via the REST API
  5. This is going to require just the right ratio of coffees/hour or I’m going to end up down a coding blind alley tracking endless functions back through WordPress core only to realise that they’re just there to put Duke Ellington lyrics on the screen

Last things first, I made a coffee. Second last things second, I needed to allow the documents site access to the main site’s cookies. By default, WordPress sets its login cookie host to the domain of the site, and the path to ‘/’. This means no access to the cookie by subdomains. In a further effort to foil our plans, the cookie name is “wordpress_logged_in_SOME_HASH_OF_SOMETHING”. It turns out that the well known constant SOME_HASH_OF_SOMETHING pertains to an md5 hash of the site url, but is that with the https or without it? Trailing slash or no trailing slash? Will that change at some point? Luckily we can remove the guesswork and take care of our other initial issues by defining a few constants in the wp-config.php for the live site:


define('LOGGED_IN_COOKIE', 'special_logged_in_' . md5( 'secretpassword' ) );
define('COOKIE_DOMAIN', '.iwishicouldcodebetter.com');

Just like that, we now know the name of our logged in cookie, and we can access it from the documents site (or indeed any other site on the domain iwishicouldcodebetter.com). At this point, a warning. I am not a security expert, and I can feel a slight flushing of my left ear, which indicates that at least a handful of security experts are probably shaking their heads in despair at this point. When researching this, a lot of the material I read on setting cookies discouraged this kind of access. The more precisely you can lock down a cookie the better seems to be the general consensus, and clearly this goes in the opposite direction to this. Proceed with caution when tinkering with settings like these that potentially affect your site’s security.

Ok, on with the show. One last bit of work to complete on our live site, we need to add a REST API endpoint that can review the contents of a logged in cookie, and establish if the user is logged in or not. No need to re-invent the wheel here, we can simply expect to be passed the cookie contents, and make use of the wp_validate_auth_cookie() function for validating the cookie.

register_rest_route( 'iwishicouldcodebetter/v1', '/logged_in', [
	'methods'	=> 'GET',
	'args'		=> [
		'logged_in_cookie' => [
			'required' => true,
			'sanitize_callback' => function( $param, $request, $key ) {
				return strip_tags( $param );
			}
		],
	],
	'callback'	=> function( $request ) {

		// Get the cookie data.
		$param = $request->get_param( 'logged_in_cookie' );

		// Decode.
		$cookie_data = urldecode( $param );

		// Validate and return response.
		return wp_validate_auth_cookie( $cookie_data, 'logged_in' );
	}
] );

Next, lets head over to our documents site, and throw together a plugin (or add to your theme’s functions.php file – your choice). The general flow of what we are trying to do is:

  1. Establish if the user is logged in by looking for a logged in cookie from the main site
  2. If so, pass the contents to the main site via the REST API
  3. Receive an indication back from the main site as to whether the login cookie is valid
  4. If so, allow the user access

In addition to this, we also don’t want to make an API call every time a user requests a resource, so lets store a cookie on establishing that the user is logged in, and look that up in future checks. The need to potentially set a cookie tells us we need a hook that runs pre headers, so lets hook into the ‘wp’ action, with something like the following:

class Externally_Logged_In {

	private $cookie;

	function __construct() {
		add_action( 'wp', [ $this, 'logged_in_cookie' ] );
	}

	/**
	 * Called by the wp action to review cookies.
	 * Runs logged in checks if required.
	 */
	function logged_in_cookie() {

		// Establish whether we are using SSL or not.
		$https = isset( $_SERVER["HTTPS"] );

		// First look for a logged in cookie for the main site.
		// If none, delete any remnants and exit.
		if ( false === $this->master_cookie() ) {
			setcookie ( 'docs_logged_in', '', time() - 3600, COOKIEPATH, COOKIE_DOMAIN, $https, true );
			return;
		}

		// Look for our local cookie. If we have one, leave.
		if ( isset( $_COOKIE['docs_logged_in'] ) ) {
			return;
		}

		// Build the request. If we get a false response, exit.
		if ( false === $this->request() ) {
			return;
		}

		// Create a cookie.
		setcookie ( 'docs_logged_in', 'logged in', time() + 60 * 60 * 24, COOKIEPATH, COOKIE_DOMAIN, $https, true );
		// Do this so that when the page finishes rendering, the cookie is present.
		// Otherwise, it will take another page load for cookies to take effect.
		$_COOKIE['docs_logged_in'] = 'logged in';

		return;
	}

	/**
	 * Checks for a logged in cookie for the main site.
	 * If there is one, saves content to our $cookie var.
	 */
	function master_cookie() {

		// This must match our 'LOGGED_IN_COOKIE' constant on the main site.
		$cookie_name = 'special_logged_in_' . md5( 'secretpassword' );

		// No cookie, no access.
		if ( ! isset( $_COOKIE[ $cookie_name ] ) ) {
			return false;
		}

		// Otherwise, store contents.
		$this->cookie = $_COOKIE[ $cookie_name ];

		return true;
	}

	/**
	 * Hits our custom endpoint on the main site with the cookie content.
	 * Responds with a boolean value reflecting if we were successful.
	 */
	function request() {

		$request_url = add_query_arg( 'logged_in_cookie', urlencode( $this->cookie ), 'https://iwishicouldcodebetter/v1/logged_in' );

		$req = wp_remote_get( $request_url );

		if ( 'OK' !== wp_remote_retrieve_response_message( $req ) || 200 !== wp_remote_retrieve_response_code( $req ) ) {
			return false;
		}

		$resp = wp_remote_retrieve_body( $req );

		if ( false === (bool) $resp ) {
			return false;
		}

		return true;
	}
}

Finally, to make live easy for whoever is building/editing the theme, lets create a function for is_externally_logged_in() so that we can replace is_user_logged_in() calls with our external login checks. In this case, we simply check for the presence of the ‘docs_logged_in’ cookie.

function is_externally_logged_in() {
	return isset( $_COOKIE['docs_logged_in'] ) );
}

Key Points:
As mentioned earlier, tread carefully with cookies. I also would highly recommend that you do not use this method for securing highly sensitive data. Bear in mind that although this example validates the logged in cookie of the main site using in-built WP core functions, you are ultimately going on whether or not the ‘docs_logged_in’ cookie is set. As such, you may wish to look at other means of storing a flag to indicate that the user is logged in to the live site. I would also highly recommend the use of SSL for all sites you use this technique on and ensuring that all cookies set have https set to true.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s