Have you ever needed to log authentication attempts (successful and failed) somewhere for your WordPress website? Perhaps you have considered installing a plugin which logs this information to the database, but didn’t like this approach because one of the following:
You don’t want to bloat your database with log data
You need to access your authentication logs from another service (e.g. Fail2Ban, Rsyslog, etc.)
You just want authentication logging and nothing else
Well, by rolling a Must-Use (MU) Plugin you can add lightweight and flexibile authentication logging to your WordPress website. A ready-to-use example of this would be the `tripoint-security` plugin that I wrote:
You can just drop this plugin into your wp-content/mu-plugins/ directory and adjust the logging directory by setting the `TRIPOINT_LOG_PATH` constant. The default log path is ABSPATH . ‘../logs/authentication_log’.
If that's all you wanted to know, you can flesh out those functions to your need. If you would like to log this data to a local file, I will cover this in the next section.
Logging attempts to a log file
We only need to decide a couple of questions for our log file. Where will we locate it and what will the format be?
For the location, I suggest locating this file outside the public path of your WordPress installation. The most likely location would be an existing directory for your log files. On my servers this is at /var/www/vhosts/mywebsite.com/logs which can be defined as:
if (defined('AUTHENTICATION_LOG_PATH') == false){
define('AUTHENTICATION_LOG_PATH', ABSPATH . '../logs/authentication_log');
}
For the format, I chose this format. Each entry will be a newline.
If we go back to our stubbed functions, we can finish building them.
function login_succeeded( string $username, WP_User $user ) {
// get ip address
$ip_address = $_SERVER['REMOTE_ADDR'];
// sanitize username
$username = sanitize_user($username);
// message
$log_message = addslashes("WordPress successful login for {$username} from {$ip_address}");
// line
// format: application ip_address status username message
$log_text = "\"wordpress\" \"{$ip_address}\" \"success\" \"{$username }\" \"{$log_message}\"" . PHP_EOL;
file_put_contents(TRIPOINT_LOG_PATH, $log_text, FILE_APPEND);
}
function login_failed( string $username , WP_Error $error ) {
// get ip address
$ip_address = $_SERVER['REMOTE_ADDR'];
// sanitize username
$username = sanitize_user($username);
// message
$log_message = addslashes("WordPress failed login for {$username} from {$ip_address}");
// line
// format: application ip_address status username message
$log_text = "\"wordpress\" \"{$ip_address}\" \"fail\" \"{$username }\" \"{$log_message}\"" . PHP_EOL;
file_put_contents(TRIPOINT_LOG_PATH, $log_text, FILE_APPEND);
}
For the most part, you are done. Just drop your file into the `wp-contents/my-plugins` directory and test the log path by making a few login attempts.
Which IP Address to use?
The simplest way to get a user’s IP address with PHP is to use the $_SERVER[‘REMOTE_ADDR’] global variable. This is what’s used in the functions we defined. However, depending on your server configuration, this isn’t always the correct way to get the your user’s IP address. If you are using a loadbalancer or reverse proxy (e.g. CloudFlare) then REMOTE_ADDR may just contain the IP address of your loadbalancer or reverse proxy.
If this is your case then most likely the user’s IP address was forwarded in either the HTTP_X_FORWARDED_FOR or HTTP_X_REAL_IP header. You will need to modify your functions for your scenario. You can also use this function below and replace using REMOTE_ADDR with this function.
function get_ip_address() {
// Grab the real IP address
if ( isset($_SERVER['HTTP_X_REAL_IP'])
&& empty($_SERVER['HTTP_X_REAL_IP']) === false ) {
$ip_address = $_SERVER['HTTP_X_REAL_IP'];
} elseif( isset($_SERVER['HTTP_X_FORWARDED_FOR'])
&& empty($_SERVER['HTTP_X_FORWARDED_FOR']) === false ) {
$ip_address = $_SERVER['HTTP_X_FORWARDED_FOR'];
}else {
$ip_address = $_SERVER['REMOTE_ADDR'];
}
// If multiple IP addresses, extract the first one
$ip_addresses = explode(',', $ip_address);
if( is_array($ip_addresses) ){
$ip_address = $ip_addresses[0];
}
return $ip_address;
}
Today, I had a need for a simple tooltip in my application. After looking at tooltip options, I decided I really didn’t want to include several libraries to accomplish this simple need. Since I was already using AlpineJS and TailwindCSS, I decided to just roll a simple one.
If you have stored encrypted data in Laravel than you are familiar with the built-in encryption services. Laravel’s encryption services abstracts PHP’s OpenSSL functions and support AES 128 and AES 256 bit keys. This makes for a simple encryption interface that adheres to recommendations such as utilizing MAC. To make it even easier for you to start using encryption, Laravel will both generate an encryption key (`php artisan key:generate`) and store that key in your .env file as APP_KEY.
The problem
If you have specific compliance goals, you may run into some pitfalls with either the key generation or key storage process used by Laravel. For instance, consider this particular OWASP recommendation about key storage:
“Where available, the secure storage mechanisms provided by the operating system, framework or cloud service provider should be used. These include: … Key vaults such as Amazon KMS … “
It’s safe to say that Laravel’s default key storage process doesn’t meet this recommendation by OWASP (or other security recommendations). The key is stored encoded with base64 in the local .env file. By not using a purpose-built key storage option as recommended, your risk increases around management of encryption keys. For example, consider this additional recommendation regarding key storage:
“Where possible, encryption keys should be stored in a separate location from encrypted data. For example, if the data is stored in a database, the keys should be stored in the filesystem. “
Yes, the .env file is stored on your local filesystem and yes, the encrypted values would be limited to the database if that’s where you are storing them. However, you may risk cross-contamination depending on your hosting setup:
Have you separated your web and database servers?
Do you occasionally backup your database to where the application is hosted?
Do you store your file backups and database backups in the same location?
Did a developer copy the system to a staging or development environment to troubleshoot?
Things get more complex the more we consider additional OWASP recommendations.
“Where possible, encryption keys should themselves be stored in an encrypted form. At least two separate keys are required for this.”
Okay, so now we should encrypt APP_KEY using another encryption key, something not supported out of box by Laravel. Some additional considerations might include:
Is the key generation process provided by Artisan sufficient?
Will you need to eventually rotate keys?
How will you share and control key access?
Do you really want to spend time managing these considerations?
The solution
In comes AWS’ Key Management Services (KMS). This service is designed to generate and store encryption keys in a highly durable and available service that’s neither our filesystem or our database. With a click of a button we can securely generate a key inside a Hardware Security Module (HSM) which is stored on AWS. We can then interface with that key using AWS’ API and grant access using IAM accounts or roles. This solves the following problems we outlined earlier:
The key is generated securely using FIPS hardware security modules
The key is stored securely in a purpose-built vault
The key can’t accidently leak between the filesystem and database
We can control access to the key using IAM
Should we encrypt data directly with AWS KMS?
The biggest decision we will make with KMS is what to encrypt with it.
AWS KMS provides an encrypt method and that method can take arbitrary data. Using this method we can pass small amounts of arbitrary data directly to KMS and it will encrypt that data for us. If we want to have AWS KMS directly encrypt our data, we should consider these limitations:
We are limited to a payload size of 4096 bytes.
We must transfer all of our encrypted data into KMS
We must transfer all of our unencrypted data from KMS
The 4096 byte limitation either works for our application or it doesn’t. If we need to store only secrets then this limitation isn’t likely a concern. If we intend to store large amounts of encrypted data such as payloads, then this may be an instant rule out for whether we can even encrypt data directly with KMS.
Another concern is that all of our data must be transferred into and out of KMS as required by the application. If we have larger amounts of encrypted data or many pieces of encrypted data, the application will need to make larger and more frequent calls to the KMS service which could impact the application’s performance.
Before starting to encrypt data directly with KMS, we should consider if this is the best method. For many applications with simpler requirements, this may be perfectly fine and a step above using Laravel’s built-in encryption service. However, there is a better way.
This approach follows the OWASP recommendations, is well supported by KMS and doesn’t come with the same concerns as encrypting/decrypting data directly with KMS. Using this method we only encrypt and decrypt the application’s encryption keys with KMS.
KMS provides API endpoints that will generate data keys, which we then store in our application. Data keys generated by KMS are compatible with Laravel Encrypter. We just need to decrypt the data keys before use so that we can encrypt/decrypt data locally.
Setting up your Customer Master Key (CMK) on KMS
To start with KMS, we need to create a Customer Master Key. Login to the AWS Console and visit the Key Management Service Page. The wizard will walk you through all the steps needed to setup your new CMK and attach a policy to an IAM user. If you need more details on setting up the CMK I would refer you to the AWS Docs for detailed instructions
Once your key is setup, record the ARN for the new CMK. This is the value for AWS_KMS_CMK in the .env file.
The CMK will be used to generate and decrypt Data Encryption Keys (DEKs), which are the keys actually used by our application to encrypt and decrypt our data.
Laravel’s APP_KEY as a Data Encryption Key (DEK)
The simplest approach to adding envelope encryption with KMS to our application is to replace the .env APP_KEY with a DEK generated by KMS. Then on boot, decrypt APP_KEY using KMS and replace the running applications config app.key with the decrypted key. Using this approach we are meeting all the OWASP recommendations for key generation and storage while retaining the simplicity of Laravel’s Encryption service.
“The encrypted DEK can be stored with the data, but will only be usable if an attacker is able to also obtain the KEK, which is stored on another system.”
That’s it, Laravel will work as documented with a basic envelope encryption setup.
APP_KEY in the .env is encrypted
The application needs to authorize with AWS KMS to decrypt the encrypted APP_KEY
APP_KEY is decrypted once on boot
All encryption and decryption is local to the application
How does this work?
After installing `aws/aws-php-sdk`, we need to register KMSClient, which is done in the KmsEncryption/KmsEncryptionServiceProvider. This will allow us to resolve the KMSClient with our applications’s config.
<?php
namespace KmsEncryption;
use Aws\Kms\Exception\KmsException;
use Aws\Kms\KmsClient;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use KmsEncryption\Console\Commands\KmsGenerateDataKey;
use KmsEncryption\Console\Commands\KmsTest;
class KmsEncryptionServiceProvider extends ServiceProvider
{
public function register()
{
// register client
$this->app->bind(KmsClient::class, function(Application $app, $params = []) {
return new KmsClient([
'profile' => $params['profile'] ?? env('AWS_ROLE'),
'version' => '2014-11-01',
'region' => $params['region'] ?? env('AWS_DEFAULT_REGION'),
'credentials' => [
'key' => $params['credentials']['key'] ?? env('AWS_ACCESS_KEY_ID') ?? null,
'secret' => $params['credentials']['secret'] ?? env('AWS_SECRET_ACCESS_KEY') ?? null,
],
]);
});
// register commands
if ($this->app->runningInConsole()) {
$this->commands([
KmsGenerateDataKey::class,
KmsTest::class,
]);
}
}
}
Next, we need a way to generate a data key using the KMS API. Specifically, we want to call the GenerateDataKeyWithoutPlaintext endpoint. This endpoint will return a DEK encrypted by the CMK, which will become our new APP_KEY. To facilitate this there is an Artisan command at KmsEncryption/Console/Commands/KmsGenerateDataKey we can run. I made the –save-as-app-key an optional flag since it’s potentially dangerous. We format the key with base64 just like the the default Laravel key.
class KmsGenerateDataKey extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'kms:generate-data-key
{--save-as-app-key : Save as APP_KEY in .env}
{--cmk= : Specify the CMK}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate a DEK using AWS';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(KmsClient $kmsClient)
{
$saveAsAppKey = $this->option('save-as-app-key');
$cmk = $this->option('cmk') ?? env('AWS_KMS_CMK');
if(empty($cmk)){
echo 'No CMK specified. Specify with --cmk or AWS_KMS_CMK environmental variable' . PHP_EOL;
return 1;
}
$dataKey = $kmsClient->generateDataKeyWithoutPlaintext([
'KeyId' => $cmk,
'KeySpec' => 'AES_256',
])->get('CiphertextBlob');
$dataKeyEncoded = 'base64:' . base64_encode($dataKey);
if($saveAsAppKey){
$escaped = preg_quote('='.$this->laravel['config']['app.key'] ?? '', '/');
file_put_contents($this->laravel->environmentFilePath(), preg_replace(
"/^APP_KEY{$escaped}/m",
'APP_KEY=' . $dataKeyEncoded,
file_get_contents($this->laravel->environmentFilePath())
));
}
echo $dataKeyEncoded . PHP_EOL;
return 0;
}
}
Next, we need to decrypt the new APP_KEY using KMS when the application boots and use the decrypted key in place of it. Back to KmsEncryption/KmsEncryptionServiceProvider on the boot() method.
<?php
// namespace...
class KmsEncryptionServiceProvider extends ServiceProvider
{
//public function register() {} ...
public function boot()
{
// skip if no cmk configured
if(!env('AWS_KMS_CMK')){
return;
}
// grab the (kms) encrypted APP_KEY
$key = config('app.key');
if (Str::startsWith($key, $prefix = 'base64:')) {
$key = base64_decode(Str::after($key, $prefix));
}
try {
// instantiate the kms client with default settings
/** @var KmsClient $client */
$client = $this->app->make(KmsClient::class);
// try to decrypt the key using kms
$decrypted = $client->decrypt([
'CiphertextBlob' => $key,
'KeyId' => env('AWS_KMS_CMK'),
])->get('Plaintext');
}catch (KmsException $kmsException){
return;
}
$encoded = 'base64:' . base64_encode($decrypted);
// override app key with decrypted key
Config::set('app.key', $encoded);
}
}
After that, you are all set!
Next Steps
For applications that are storing data for multiple users, we should consider separating our encryption keys per-user / organizational unit. Look for my next post where I extend the above principles and use multiple data encryption keys throughout the application.