Similar Articles

Building a Secure API - Part 5

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.

A quick review

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:

  1. The user will be provided with a token created via some administration functionality in the management part of the application.
  2. They can then use this and their password to call the /login endpoint of the API and get back a random token they can use on future requests
  3. This remote token is then used on the following requests in multiple ways: as a header value directly in X-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:

  • Implement the login
  • Generate the randomized token for use in future requests, save it to the database as a session, return it to the client
  • When a non-login request comes through, get the two headers, locate the matching session and rehash the body to ensure validity

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.

Enjoying the article? Consider contributing to help spread the message!

Verifying the user on login

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.

Create UserController

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.

Add New Routes

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.

Login Success/Fail

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:

  • If not all of the information was provided (username or key)
  • If the user doesn't exist
  • If they key isn't one that belongs to the user (no match)
  • If everything else matches but the key isn't active

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.

About Auditing

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.

Making requests with the key

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:

  • See if the session key is a valid key
  • Check to see if the current session related to that key is valid (and not expired)

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!');
}

And we're done!

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:

  • IP based rate limiting
  • Login brute force prevention
  • Per entity authorization methods
  • Return data filtering

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!

Resources

by Chris Cornutt

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.

Enjoying the article? Consider contributing to help spread the message!