Like a lot of PHP and JavaScript developers, I started web development by learning enough PHP and then jQuery to customize a WordPress theme and then got carried away from there. Along the way, I learned what WordPress is a good solution for and what it is not a great fit for. This has led to learning Angular, Vue, React and Laravel.
Overtime, more and more of my work has been building WordPress-adjacent apps in Laravel and/ or Node. This has led me to a variety of solutions for sharing data and user authentication between the two. This article shows how to allow users of an existing WordPress site to login to a Laravel site with the same username and password. I did this without data duplication -- besides the cache -- or storing passwords anywhere.
If your WordPress and Laravel applications are able to access the same database, there is an alternative solution, that I will discuss later. The solution I will share in this post, uses the WordPress REST API to check credentials and issue JWT tokens for users.
Setting Up The WordPress Site
This solution requires a WordPress site that has the JWT authentication plugin. This is a free plugin you can install through wp-admin.
Once that plugin is activated, make sure to set a long, random string in the constant JWT_AUTH_SECRET_KEY
. Also, ensure that your server does not block the HTTP Authorization
header.
This plugin does use username and password to issue the token. They are not stored, so this process is as relatively secure as long as it is run on HTTPS.
When this plugin is installed, you can make a POST request to the "/jwt-auth/v1/token" endpoint with username and password in the body to get a JWT token. That token can then be set in the Authorization header to authorize requests to the WordPress REST API. If we supply that header when making a GET request to "/wp/v2/users/me" we will get details of the current user.
WordPress 5.6 will add an "application password" feature. This is a basic authentication system that uses a different password then the one for login. In the future, that might be a better solution then JWT tokens.
I followed instructions on how to create a customer user provider. It takes the supplied username and password and attempts to get a JWT token from the WordPress site. If that works, a User model is created -- in memory and cache. The password is not saved. A standard Laravel session is created. That session will be maintained as long as the user remains in the cache or the user logs out.
In this post, I will show you how it works. I also put the source for the app I built to test this on the Githubs. Feel free to fork it. It's a basic Laravel 8 site with Breeze.
Creating An API Client
The first step in the Laravel app will be to create an API client. This client, which will make use of Laravel's HTTP facade will need to be able to make requests with and without the authentication token.
This client will need methods for GET and POST requests, that optionally add the header. I also added a method for exchanging username and password for the token:
<?php
namespace App\Http\Clients;
use Illuminate\Support\Facades\Http;
/**
* API client for WordPress REST API
*
* Must have the JWT auth plugin
* https://wordpress.org/plugins/jwt-auth/
*/
class WordPressApiClient
{
public string $apiUrl;
protected $token;
protected array $options;
public function __construct(string $apiUrl, ?string $token = null)
{
$this->apiUrl = $apiUrl;
if ($token) {
$this->token = $token;
}
//In production require TLS on both ends.
//Else do not verify.
$this->options = [
'verify' => 'production' === app('ENV')
];
}
public function setToken(string $token) : WordPressApiClient
{
$this->token = $token;
return $this;
}
public function hasToken(): bool
{
return isset($this->token) && is_string($this->token);
}
public function get(string $endpoint, array $query = [])
{
$url = $this->apiUrl . $endpoint;
if( $this->hasToken() ){
$request = Http::withToken($this->token)
->withOptions($this->options);
}else{
$request = Http::withOptions($this->options);
}
return $request
->get(
$url,
$query
);
}
public function post(string $endpoint, array $body)
{
$url = $this->apiUrl . $endpoint;
if( $this->hasToken() ){
$request = Http::withToken($this->token)
->withOptions($this->options);
}else{
$request = Http::withOptions($this->options);
}
return $request
->post(
$url,
$body
);
}
public function getToken(string $username, string $password)
{
return $this->post('/jwt-auth/v1/token', [
'username' => $username,
'password' => $password
]);
}
}
Creating A User Provider
So far we have an HTTP client that could be used for a user provider. It could also be used to get or edit posts or other data from the WordPress site. For now, we'll look at the user provider.
The documentation for creating a custom user provider is pretty complete, and worth reading through.
The most important method here is retrieveById()
. Returning a user model here sets the current logged in user. I wanted to avoid having to check the token against the WordPress REST API on every request.
To avoid this, I created a factory for User models that caches the results:
{
protected function userFactory(array $data) : User
{
$user = new User;
Cache::put(
$this->cacheKey(
$user->getAuthIdentifier()
),
$user->toArray(),
9000
);
return $user;
}
}
Here is the method that finds users by id, which uses that factory:
public function retrieveById($identifier)
{
//Has user in cache?
$user = Cache::get(
$this->cacheKey($identifier)
);
//Yes? Make a user model and return it
if( $user ){
return (new User())
->forceFill($user);
}
//No user? Do a login.
//Could trigger a redirect to wp-logint that comes back with a JWT...
}
This function, may not return a user model. This happens when there is no no logged in user. IF that happens, thes enext two methods will be called, in the order you see here. If the token check throws an exception, which the API client will do if it's invalid, that will trigger a login error:
public function retrieveByCredentials(array $credentials)
{
$r = $this->wordpressClient
->getToken(
$credentials['email'], $credentials['password']
);
return $this
->userFactory($r);
}
public function validateCredentials(Authenticatable $user, array $credentials)
{
Auth::setUser($user);
return $this
->retrieveById(
$user->id
);
}
I did modify the User model to simplify it, and make it work for my needs. You don't have to use the User model, you can use any model. That might be useful if you wanted to have users in the Laravel database and users in the WordPress database. Here is my user model:
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'password',
'token'
];
}
I also deleted the user migration. In my test site I used sqlite for the session and cache table. If I deployed this with Vapor, I would use DynamoDB and/ or Redis instead. That means that the Laravel application may not need a MySQL database, which would be great for scaling and cost-control.
Here is the full user provider:
<?php
namespace App\Providers;
use App\DTO\UserResponse;
use App\Http\Clients\WordPressApiClient;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class WordPressUserProvider implements UserProvider
{
protected WordPressApiClient $wordpressClient;
public function __construct()
{
$this->wordpressClient = new WordPressApiClient(
env('WP_API_URL')
);
}
public function getModel()
{
return User::class;
}
protected function cacheKey($identifier):string {
return __CLASS__ . $identifier;
}
public function retrieveById($identifier)
{
//Has user in cache?
$user = Cache::get(
$this->cacheKey($identifier)
);
//Yes? Make a user model and return it
if( $user ){
return (new User())
->forceFill($user);
}
//If nothing is returned here, login redirect will be triggered
//That is normal for unauthorized users.
}
public function retrieveByToken($identifier, $token)
{
//Try to find by id
$user = $this
->retrieveById($identifier);
//Not found? Exchange token for user details again.
if( ! $user ){
$r = $this
->wordpressClient
->get('/wp-json/wp/v2/me');
return $this
->userFactory($r);
}
return $user;
}
public function updateRememberToken(Authenticatable $user, $token)
{
//Not actually needed
}
/**
* @param array $credentials
* @return User|Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
$r = $this->wordpressClient
->getToken(
$credentials['email'], $credentials['password']
);
return $this
->userFactory($r);
}
public function validateCredentials(Authenticatable $user, array $credentials)
{
//Recheck token?
//Auth::setUser($user);
return $this
->retrieveById(
$user->id
);
}
protected function userFactory(array $data) : User
{
$user = ( new UserResponse(
Arr::only($data,[
'token',
'ID',
'user_email'
])
) )
->toModel();
Cache::put(
$this->cacheKey(
$user->getAuthIdentifier()
),
$user->toArray(),
9000
);
return $user;
}
}
{
protected function userFactory(array $data) : User
{
$user = new User;
$user->forceFill(
[
'id' => $data['ID'],
'name' => $data['user_email'],
'token' => $data['token']
]
);
Cache::put(
$this->cacheKey(
$user->getAuthIdentifier()
),
$user->toArray(),
9000
);
return $user;
}
}
Configuring Everything
That's basically it. The last step is to wire things up. These steps are straight out of the documentation.
First in the AuthProvider
service provider, register the user provider:
Auth::provider('wordpress', function ($app, array $config) {
return new WordPressUserProvider();
});
Once that is done, you can tell Laravel to use that provider by modifying config/auth.php
.
'providers' => [
'users' => [
'driver' => 'wordpress',
],
],
In the user provider, I used the env varibale WP_API_URL
for the URL of the site's REST API. You would put something like WP_API_URL=https://hiroy.club/wp-json
in your .env file.
Conclusion
As an alternative to the approach I showed in this post, you may want to look at this package. That package requires access to WordPress' MySQL database. That's not required by the approach I showed here.
My organization's goal is to decouple our big WordPress site from our other internal applications, so co-location didn't make sense. Also, I want to use managed WordPress hosting providers for WordPress and Vapor for Laravel. Mixing them on the same server feels like a regression.
I hope you found this useful. Please feel free to copy the example application that is on Github. I am curious to see if anyone finds this useful and if you have better solutions. I know one thing I'm considering is using a redirect to the WordPress login screen.
Top comments (1)
hello i can't deploy the app. It is connected to JWT but gives the following error
Spatie\DataTransferObject\DataTransferObjectError
The following invalid types were encountered: expected
App\DTO\UserResponse::ID
to be of typeinteger
, instead got valuenull
, which is NULL. expectedApp\DTO\UserResponse::token
to be of typestring
, instead got valuenull
, which is NULL. expectedApp\DTO\UserResponse::user_email
to be of typestring
, instead got valuenull
, which is NULL.thanks
Some comments may only be visible to logged-in visitors. Sign in to view all comments.