We've come a long way in this series about building a secure API using the Slim framework as a base, the Laravel Eloquent package for database interaction and Phinx to handle our migrations. In Part One I introduced the series, APIs and provided a basic flow of how the API will work. Part Two saw the introduction of the Slim framework and helped you get the initial routes set up and working. In Part Three we expanded on this and moved away from a basic single-file implementation of our API over to a Model/View/Controller (MVC) structure to make the end result easier to maintain and split things up into functional pieces. Then, in Part Four we worked through the database setup including the Eloquent models, Phinx migrations and some of the logic that applies to them. That brings us up to date and to this installment in the series - Part Five.
This series so far has been a lot about setup and configuration of the underlying structure of the API. We've created the routes to handle our requests and the database to store information about our users and other related entities. Now we're going to get to the "fun part" (you've been having fun all along, right?) of creating the code for the authentication system and plugging that into our current application.
Before we get started, I just wanted to give a quick review of what we're shooting for as a end result of this part of the series. If you'll remember back in Part One] I outlined the basic flow but here's the short version to refresh your memory:
/login
endpoint of the API and get back a random token they can use on future requestsX-Token
and as a part of another header where it was used to HMAC hash the body of the request.So now we have a few steps that we'll need to accomplish in our application in order to make this whole login and request flow work:
In this case, the hash itself becomes the piece of information we use to identify the user (by their session, which can be removed easily) and use it to verify the message wasn't tampered with via the HMAC hash validation.
The first step is getting the user authenticated. At this point we assume that the user already has their API token pulled from their user profile page or an "API Keys" page. They can then use these two pieces of information to make a POST
request to the API's /user/login
endpoint to start up a new session and return the key they should use on the following requests.
Here's an example of what a curl
call might look like to make the POST
request to the endpoint. Obviously the keys will be different on your system so just fill them in where appropriate:
> curl -X POST \
-d "username=user1&key=[key goes here]" \
http://localhost:8080/user/login
{
"success":true,
"message":{
"session":"[....key here....]"
}
}
The second section of the example above is what our response should look like if the login is successful. If not, the user will be presented with an error message.
Now, we already have our BaseController
and its jsonSuccess/jsonFail
methods to handle the JSON response but we need to make a new controller to handle this new login logic. In a new file App\Controller\UserController.php
we'll have this code:
<?php
namespace App\Controller;
use \App\Model\User;
use \App\Model\ApiSession;
class UserController extends \App\Controller\BaseController
{
public function login($request, $response)
{
$username = $request->getParam('username');
$key = $request->getParam('key');
if ($username == null || $key == null) {
return $this->jsonFail('Username and key are required');
}
// Find the user's keys and see if the one we have is in it
$user = User::where(['username' => $username])->first();
if ($user == null) {
return $this->jsonFail('Invalid credentials');
}
$keys = $user->keys;
$found = $keys->pluck('key')->search($key);
if ($found === false) {
return $this->jsonFail('Invalid credentials');
}
if ($keys[$found]->status !== 'active') {
return $this->jsonFail('Invalid credentials');
}
return $this->jsonSuccess([
'session' => \App\Lib\Session::start($user, $keys[$found])
]);
}
}
?>
This creates a whole new controller class, the UserController
, with a single method: login
. In this method the first thing we do is grab the username
and key
values from the request. This grabs them from the post data so we can use them later on in the logic. If either of these items are missing, the == null
checks in the if
will trigger the first of our possible errors. In accordance with the "Fail Fast" mentality, we return the error message immediately rather than trying to carry a "state" value down to the end of the method.
Next up we take that username
value and try to locate the user in our system with the User::where
function making use of our User
model. We grab the first
record off the return result and check to see if it is null
. If it is, that means the user wasn't found by username so we fail.
If the user exists, we get to move on to the next check on the key
value. In this check we're using some of the Laravel collection "magic" to extract just the key
values (with pluck
) and see if the key we were provided is in the results (the search
method). If the key isn't found in the set - the value of $found
is null
- then another error is returned to the user. This is essentially like a password failing.
Finally, if this all passes we get to the success call and return the session
key value to the user. As the logic around creating the session isn't really something that the controller should be responsible for, I've split it off into a service class that will handle it form us: \App\Lib\Session
. This file is created in App\Lib\Session.php
with the \App\Lib
directory being new for this file.
Here's the contents of that service class:
<?php
namespace App\Lib;
use \App\Model\User;
use \App\Model\ApiKey;
use \App\Model\ApiSession;
use Doctrine\DBAL\Exception\DatabaseObjectExistsException;
class Session
{
const TIMEOUT = '+1 hour';
/**
* Start up the API session and generate the randomized token
*
* @param User $user User instance
* @param ApiKey $key API Key instance
* @param boolean $expire Expire older API keys [optional]
* @return string Randomly generated Session ID
*/
public static function start(User $user, ApiKey $key, $expire = true)
{
// The key is found, generate them a new one
$sessionId = hash('sha512', random_bytes(128));
// Make the new session record
$session = ApiSession::create([
'key_id' => $key->id,
'session_id' => $sessionId,
'expiration' => date('Y-m-d H:i:s', strtotime(self::TIMEOUT)),
'user_id' => $user->id
]);
if ($expire === true) {
self::expire($session);
}
return $sessionId;
}
/**
* Expire previous API sessions
*
* @param ApiSession $session API Session instance
*/
protected static function expire(ApiSession $session)
{
$now = date('Y-m-d H:i:s');
// Expire sessions older than the one provided
$sessions = ApiSession::where('user_id', $session->user_id)
->where('key_id', $session->key_id)
->where('created_at', '<=', $now)
->where('id', '!=', $session->id)
->get();
foreach ($sessions as $expire) {
$expire->delete();
}
}
}
?>
In this class we've defined a start
static method that takes in the User
and ApiKey
instances and uses the data from each to create the API session hash and save it to the database. The one hour timeout I mentioned in previous parts of this series is defined in the class constant TIMEOUT
and is used when calculating the expiration timeout for the session record.
The next thing to notice is that we have the optional expire
variable on the start
method. By default this is set to true
meaning that each time the user logs in a new session will be created but the older sessions will be removed from their active sessions list, only allowing one session at a time. This makes sure that, for any given username and key combination that there's only ever one valid session open at a time. If an attacker somehow manages to get a hold of a current session ID, all the user needs to do is log in again and that old session is revoked.
You might be asking, "what if the attacker has their API key and username? Wouldn't they just be able to keep logging in too?" Well, that's where the fact that the keys are revokable comes into play. If any suspicious activity shows up related to one of their keys they can just revoke it and replace it with a new one on their side. Hopefully they've been created with specific purposes and aren't used globally...
Since this value is optional and can be switched off, you have the option of setting it to false and allowing multiple logins with the same session. Be aware, however, that with an hour timeout and attacker with the current user credentials would be able to create thousands (or more) valid sessions with some easy brute forcing. Its best to stick with the "one session at a time" rule unless you have a very good reason not to.
Finally we have the portion of the start
method that creates the new session identifier (a sha512
hash based on random data) and makes a new record in the session tracking table for it.
We need to be able to reach this endpoint from the outside world, so it will need to be added to the current set of allowed routes. Go and update the bootstrap/routes.php
file to look like this:
<?php
$app->get('/', '\App\Controller\IndexController:index');
$app->post('/login', '\App\Controller\UserController:login');
?>
This adds in the new route for /user/login
that points to our newly created controller's login
method. Be sure that you're using post()
and not get()
here. These login requests should never be allowed via GET
as that could lead to the credentials being accidentally stored in a log somewhere as part of the URL.
Now that we have everything in place we can try the login. I've created a simple script that uses Guzzle to make the request for convenience but you can do the same thing with a normal curl request.
<?php
require_once 'vendor/autoload.php';
$client = new GuzzleHttp\Client();
$username = 'user1';
$apiKey = '[... key goes here ...]';
$res = $client->post('http://localhost:8080/user/login', [
'form_params' => [
'username' => $username,
'key' => $apiKey
]
]);
$result = json_decode($res->getBody());
// On success, the key is in $result->session
?>
In this example we're creating the $client
and making the POST
request to the /user/login
endpoint. The payload of the request contains the values for username
and key
. If the key and the username are a match, you'll receive a response like:
{
"success": true,
"session": "[... session hash here ...]"
}
However, if something fails, you'll get back an error:
{
"success": false,
"message": "Invalid credentials"
}
In the code above there's a few different ways it could fail:
username
or key
)You'll note that most of these failures return the same "Invalid credentials" message. There's a debate between security and user experience about what the contents of these messages should be. On one hand, it's nice to tell the user exactly what failed (ex: "Invalid username") but on the security side that's providing the potential attacker with more information than necessary. It's a tricky decision and one you'll have to make on your own for your system.
I want to take a second and look at a subject that comes up a lot when I talk with people about session considerations and general authentication/authorization issues: auditing. As anyone that's worked with any kind of codebase that has some kind of governmental or regulatory requirements around it can tell you, you need audit trails in your system to provide to the correct people just in case something happens. This is also useful in non-regulatory situations as it can help you better visualize what's happening in your application.
There's a very real temptation to use something like our API session table as an audit source and change up the code so that only the most recent API session is valid. While this seems like a sane choice, I advise against it. When creating your security controls, allow them to live in their own context and not have to worry about whatever other tools might interact with them. Create a logging system that can be used to track actions in your system more generally and use that for auditing purposes. It can even be something as simple as a data structure like:
{
"url": "/foo/bar/1",
"message": "Failed login for user - bad API key",
"context": "API"
"additional": {
"user_id": 1, "account_id": 2
}
}
This single message, along with the timestamp it was entered, provides you with a wealth of information that you otherwise wouldn't have had. You can then use whatever tool you have to start parsing those logs and look for patterns or set thresholds to notify you when things start going sideways.
If you're not including logging as a part of your system of security controls you're flying blind and would probably be very surprised to find out exactly what your users are doing in your system.
One last word of advice here before moving on - be very careful what you're logging and don't just log random data or error messages. As I've mentioned in other places you should "log on purpose" and know exactly what information you're putting into your logs. Trust me, finding out that you've accidentally been storing two years of API token hashes in logs is not a fun situation to be in, especially if they're in backups you'll need to purge.
So, now we get to the fun part! We have all of the code in place to start the session and return the key back to the user, now we need to have that session token and the matching hashes in the header checked when they make a request to the API for other endpoints. I've seen some APIs add something like this into each method that's called (a sort of "verify" method) but I'd like to suggest a better way to handle it that Slim supports out of the box: middleware.
Back in part one I talked about middleware and the role that it would play later on in the series to handle the authorization logic. Well, the time has come so lets create that middleware now. We're going to make a new file in our MVC structure to keep things clean and separate and make the "SessionValidate" middleware inside of App\Middleware\SessionValidate.php
:
<?php
namespace App\Middleware;
class SessionValidate
{
public function __invoke($request, $response, $next)
{
$result = \App\Lib\Session::validate($request);
if ($result == false) {
$message = [
'success' => false,
'message' => 'Not allowed'
];
$response = $response->withHeader('Content-type', 'application/json');
$response = $response->withStatus(403);
$body = $response->getBody();
$body->write(json_encode($message));
return $response;
}
// Allowed, continue with the execution
$response = $next($request, $response);
return $response;
}
}
?>
The above code, formatted to work with Slim's PSR-7 middleware implementation, puts the logic in an __invoke
method to check the provided session key for validity. I've decided to encapsulate the logic for the validation inside of the \App\Lib\Session
class just to keep things all in one place related to API session handling. So, we'll need to add another method to that class:
public static function validate($request)
{
// Get the headers
$xToken = $request->getHeader('X-Token')[0];
$xTokenHash = $request->getHeader('X-Token-Hash')[0];
// Get the key first
$key = ApiKey::where(['key' => $xToken])->first();
if ($key == null) {
return false;
}
// Be sure the key matches a session and isn't expired
$session = ApiSession::where(['key_id' => $key->id])
->where('expiration', '>=', date('m-d-Y H:i:s'))
->first();
if ($session == null) {
return false;
}
// Get the body and rebuild the hash
$body = (string)$request->getBody()->getContents();
$messageHash = hash_hmac('SHA512', $body, $session->session_id.time());
if ($messageHash !== $xTokenHash) {
return false;
}
return true;
}
In this method we're provided with the Request
instance that comes from the middleware. The two headers, X-Token
and X-Token-Hash
are then pulled from the request and used to:
The final step in the process is to take the session key off of the ApiSession
record and use it to reconstruct the hash and then compare it to the one we were provided. This session key is never sent over the wire and is only known to the server and the client. In the security world this is called a "secret" (makes sense, eh?). This hash is a SHA512
HMAC hash of the contents of the body using the session key and the current time in seconds as the key for the hash.
This method works on two levels - it ensures that the session that is currently available is the one that they used to for making the request and the other is the integrity of the body contents. If the contents of the body were somehow changed in transit (like via a Man-in-the-Middle attack) the hash would no longer validate assuming the attacker didn't know what the secret - session key - was for their current session.
If this returns false
the middleware then builds out a JSON response message in a similar format to our other responses in the controllers, complete with a success
value of false
, a message
stating that they're not allowed and a HTTP response code of 401
(or "Not Authorized"). If everything passes and the hash validates the user is happily rewarded by being directed to the requested resource.
Here's an example of building out a request to the API, again with Guzzle:
// Assuming we've already gotten our session key in $sessionKey from the login
$body = '';
$messageHash = hash_hmac('SHA512', $body, $sessionKey.time());
$res = $client->get('http://localhost:8080/test', [
'headers' => [
'X-Token' => $apiKey,
'X-Token-Hash' => $messageHash
]
]);
var_export((string)$res->getBody());
?>
Seems like we're done here, right? Well, there's one last thing that we haven't done and it involves the middleware. In the Slim framework middleware doesn't just automatically execute as a part of every request. You have to define which middleware you want to run when. You can do this for whole sets of routes by adding it to a group()
section in the route definitions or you can just add it to one route. In our example we want the latter. It's an easy task thanks to the add()
method Slim provides.
We just have the one /test
route that we want to protect so we're going to add the route for it and attach the middleware. So in bootstrap/routes.php
add:
$app->get('/test', '\App\Controller\IndexController:test')
->add(new \App\Middleware\SessionValidate());
This tells Slim to attach and execute that middleware when the /test
route is requested. Finally we'll add the code for that request to hit into the IndexController
:
public function test()
{
return $this->jsonSuccess('You did it!');
}
By putting these last pieces of the puzzle in place we've wrapped up the main functionality of the API including examples of making a request with the correct security information. Where you go from here is up to you, I've just laid the foundation for you to work from. Don't forget that if things aren't working as expected or you just want to see the code examples all in one place, check out our matching repository for the full code.
While this brings the main part of the series to a close, I have ideas about the continuance of this series to add in additional security features that would help harden the API against potential attackers. These features could include:
All of these are characteristics of secure, well-architected APIs and add even more layers to the "Defense in Depth" making it that much harder for attackers to breach your system. I hope you've enjoyed this series and learned at least a few things along the way!
With over 12 years of experience in development and a focus on application security Chris is on a quest to bring his knowledge to the masses, making application security accessible to everyone. He also is an avodcate for security in the PHP community and provides application security training and consulting services.