2
\$\begingroup\$

Edit: this is a base library for including in larger projects for the overall management of JSON Web Tokens (JWT) that should be compatible with other JWT libraries that share an encryption secret - also remote systems can reach out to the issuing system (with my library or a different JWT library) to verify the token works. The encryption "secret" thing is the crux of the matter: you can't encode, modify, or verify without it, regardless of the library.


This is my first time posting here. This is "reinventing the wheel", but somewhat an earnest thing I wanted to try, and a representative sample of where I am at in this skill. I would be grateful for any comments or suggestions that might make me a better developer tomorrow.

The code coverage is 100%, mostly as a good start for letting me know if I broke something when I modify the code - one of the things I would like to improve next year (tomorrow... as today is 2020-12-31) is to be able to expect a specific RuntimeException message, telling me that I am triggering the specific predicted runtime exception when I expect that I should get an exception in a particular place in the code.


There's a GitHub repo, and a 20-minute Youtube video of stepping through the tests.

(you shouldn't need a video to see how it works. ... And you don't. I just included it to be helpful as I believe more people have access to youtube than phpunit.)

If you wanted to see any particular chunk in action-- you can click to the area in the video you want to see happening. (Loading and running the code is still an option, but sometimes "I don't wanna" and maybe you don't either.)


The config file is essentially a php script that returns an array with the "secret" and database connection credentials.

I know revocation storage and handling is not part of JWT officially - but, I wanted it, so I baked it in, complete with an SQL table creation script.

I really appreciate you spending your time looking at my thing.


<?php
namespace BradChesney79;
use DateTime;
use DateTimeZone;
use Exception;
use LogicException;
use PDO;
use RuntimeException;
class EHJWT
{
    /*
      iss: issuer, the website that issued the token
      sub: subject, the id of the entity being granted the token
      aud: audience, the users of the token-- generally a url or string
      exp: expires, the UTC UNIX epoch time stamp of when the token is no longer valid
      nbf: not before, the UTC UNIX epoch time stamp of when the token becomes valid
      iat: issued at, the UTC UNIX epoch time stamp of when the token was issued
      jti: JSON web token ID, a unique identifier for the JWT that facilitates revocation

      DB/MySQL limits:
      int has an unsigned, numeric limit of 4294967295
      bigint has an unsigned, numeric limit of 18446744073709551615
      unix epoch as of "now" 1544897945
    */

    /**
     * Token Claims
     *
     * @var array
     */
    private array $tokenClaims = array();
    /**
     * @var string
     */
    private string $token = '';
    /**
     * The config data.
     *
     * @var array
     */
    protected $configurations = [];

    //    /**
    //     * Error Object
    //     *
    //     * @var object
    //     */
    //    public object $error;

    // methods
    public function __construct(string $secret = '', string $configFileNameWithPath = '', string $dsn = '', string $dbUser = '', string $dbPassword = '')
    {
        try {
            $this->setConfigurationsFromEnvVars();
            if (mb_strlen($configFileNameWithPath) > 0) {
                $this->setConfigurationsFromConfigFile($configFileNameWithPath);
            }
            $this->setConfigurationsFromArguments($secret, $dsn, $dbUser, $dbPassword);
        } catch (Exception $e) {
            throw new LogicException('Failure creating EHJWT object: ' . $e->getMessage(), 0);
        }
        return true;
    }

    private function setConfigurationsFromEnvVars()
    {
        $envVarNames = array(
            'EHJWT_JWT_SECRET',
            'EHJWT_DSN',
            'EHJWT_DB_USER',
            'EHJWT_DB_PASS'
        );
        $settingConfigurationName = array(
            'jwtSecret',
            'dsn',
            'dbUser',
            'dbPassword'
        );
        for ($i = 0; $i < count($envVarNames); $i++) {
            $retrievedEnvironmentVariableValue = getenv($envVarNames[$i]);
            if (mb_strlen($retrievedEnvironmentVariableValue) > 0) {
                $this->configurations[$settingConfigurationName[$i]] = $retrievedEnvironmentVariableValue;
            }
        }
    }

    private function setConfigurationsFromConfigFile(string $configFileWithPath)
    {
        if (file_exists($configFileWithPath)) {
            $configFileSettings = require $configFileWithPath;
            if (gettype($configFileSettings) !== 'array') {
                throw new RuntimeException('EHJWT config file does not return an array');
            }
            if (count($configFileSettings) == 0) {
                trigger_error('No valid configurations received from EHJWT config file', 8);
            }
            foreach (array(
                         'jwtSecret',
                         'dsn',
                         'dbUser',
                         'dbPassword'
                     ) as $settingName) {
                $retrievedConfigFileVariableValue = $configFileSettings[$settingName];
                if (mb_strlen($retrievedConfigFileVariableValue) > 0) {
                    $this->configurations[$settingName] = $retrievedConfigFileVariableValue;
                }
            }
        }
    }

    private function setConfigurationsFromArguments(string $jwtSecret = '', string $dsn = '', string $dbUser = '', string $dbPassword = '')
    {
        foreach (array(
                     'jwtSecret',
                     'dsn',
                     'dbUser',
                     'dbPassword'
                 ) as $settingName) {
            $argumentValue = $
            {
            "$settingName"
            };
            if (mb_strlen($argumentValue) > 0) {
                $this->configurations[$settingName] = $argumentValue;
            }
        }
    }

    public function addOrUpdateJwtClaim(string $key, $value, $requiredType = 'mixed')
    {
        // ToDo: Needs more validation or something ...added utf8
        if (gettype($value) == $requiredType || $requiredType === 'mixed') {
            if (mb_detect_encoding($value, 'UTF-8', true)) {
                $this->tokenClaims[$key] = $value;
                return true;
            }
            throw new RuntimeException('Specified JWT claim required encoding mismatch');
        }
        throw new RuntimeException('Specified JWT claim required type mismatch');
    }

    public function clearClaim(string $key)
    {
        if (isset($key)) {
            unset($this->tokenClaims[$key]);
        }
        return true;
    }

    private function jsonEncodeClaims()
    {
        return json_encode($this->tokenClaims, JSON_FORCE_OBJECT);
    }

    private function createSignature($base64UrlHeader, $base64UrlClaims)
    {
        $jsonSignature = $this->makeHmacHash($base64UrlHeader, $base64UrlClaims);
        return $this->base64UrlEncode($jsonSignature);
    }

    public function createToken()
    {
        // !!! ksort to maintain properties in repeatable order
        ksort($this->tokenClaims);
        $jsonClaims = $this->jsonEncodeClaims();
        // The hash is always the same... don't bother computing it.
        $base64UrlHeader = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
        $base64UrlClaims = $this->base64UrlEncode($jsonClaims);
        $jsonSignature = $this->createSignature($base64UrlHeader, $base64UrlClaims);
        $base64UrlSignature = $this->base64UrlEncode($jsonSignature);
        $tokenParts = array(
            $base64UrlHeader,
            $base64UrlClaims,
            $base64UrlSignature
        );
        $this->token = implode('.', $tokenParts);
        return true;
    }

    public function getUtcTime()
    {
        $date = new DateTime('now', new DateTimeZone('UTC'));
        return $date->getTimestamp();
    }

    public function loadToken(string $tokenString)
    {
        $this->clearClaims();
        $this->token = $tokenString;
        if ($this->validateToken()) {
            $tokenParts = explode('.', $tokenString);
            $this->tokenClaims = $this->decodeTokenPayload($tokenParts[1]);
            return true;
        }
        return false;
    }

    public function validateToken()
    {
        $tokenParts = $this->getTokenParts();
        $unpackedTokenPayload = $this->decodeTokenPayload($tokenParts[1]);
        $this->tokenClaims = $unpackedTokenPayload;
        if ($tokenParts[0] !== 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9') {
            throw new RuntimeException('Encryption algorithm tampered with', 0);
        }
        $utcTimeNow = $this->getUtcTime();
        if (!isset($unpackedTokenPayload['exp'])) {
            throw new RuntimeException("Expiration standard claim for JWT missing", 0);
        }
        $expiryTime = $unpackedTokenPayload['exp'];
        // a good JWT integration uses token expiration, I am forcing your hand
        if ($utcTimeNow > intval($expiryTime)) {
            // 'Expired (exp)'
            throw new RuntimeException('Token is expired', 0);
        }
        $notBeforeTime = $unpackedTokenPayload['nbf'];
        // if nbf is set
        if (null !== $notBeforeTime) {
            if (intval($notBeforeTime) > $utcTimeNow) {
                // 'Too early for not before(nbf) value'
                throw new RuntimeException('Token issued before nbf header allows', 0);
            }
        }
        if (mb_strlen($this->configurations['dbUser']) > 0 && mb_strlen($this->configurations['dbPassword']) > 0) {
            if (strpos($this->configurations['dsn'], ':') === false) {
                throw new RuntimeException('No valid DSN stored for connection to DB', 0);
            }
            try {
                $dbh = $this->makeRevocationTableDatabaseConnection();
            } catch (Exception $e) {
                throw new RuntimeException('Cannot connect to the DB to check for revoked tokens and banned users', 0);
            }
            $lastCharacterOfJti = substr(strval($this->tokenClaims['jti']), -1);
            // clean out revoked token records if the UTC unix time ends in '0'
            if (0 == (intval($lastCharacterOfJti))) {
                $this->revocationTableCleanup($utcTimeNow);
            }
            if (!isset($unpackedTokenPayload['sub'])) {
                throw new RuntimeException("Subject standard claim not set to check ban status");
            }
            // ToDo: fix bind statement
            $stmt = $dbh->prepare("SELECT * FROM revoked_ehjwt where sub = ?");
            $stmt->bindParam(1, $unpackedTokenPayload['sub']);
            // get records for this sub
            if ($stmt->execute()) {
                while ($row = $stmt->fetch()) {
                    if ($row['jti'] == 0 && $row['exp'] > $utcTimeNow) {
                        // user is under an unexpired ban condition
                        return false;
                    }
                    if ($row['jti'] == $unpackedTokenPayload['jti']) {
                        // token is revoked
                        return false;
                    }
                    // remove records for expired tokens to keep the table small and snappy
                    if ($row['exp'] < $utcTimeNow) {
                        // deleteRevocation record
                        $this->deleteRecordFromRevocationTable($row['id']);
                    }
                }
            }
        }
        $this->createToken();
        $recreatedToken = $this->getToken();
        $recreatedTokenParts = explode('.', $recreatedToken);
        $recreatedTokenSignature = $recreatedTokenParts[2];
        if ($recreatedTokenSignature !== $tokenParts['2']) {
            // 'signature invalid, potential tampering
            return false;
        }
        // the token checks out!
        return true;
    }

    public function revocationTableCleanup(int $utcTimeStamp)
    {
        $dbh = $this->makeRevocationTableDatabaseConnection();
        $stmt = $dbh->prepare("DELETE FROM revoked_ehjwt WHERE `exp` <= $utcTimeStamp");
        $stmt->execute();
    }

    private function getTokenParts()
    {
        $tokenParts = explode('.', $this->token);
        if ($this->verifyThreeMembers($tokenParts)) {
            return $tokenParts;
        }
        throw new RuntimeException('Token does not contain three delimited sections', 0);
    }

    private function verifyThreeMembers(array $array)
    {
        if (3 !== count($array)) {
            // 'Incorrect quantity of segments'
            return false;
        }
        return true;
    }

    private function makeRevocationTableDatabaseConnection()
    {
        return new PDO($this->configurations['dsn'], $this->configurations['dbUser'], $this->configurations['dbPassword'], array(
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_NAMED
        ));
    }

    private function deleteRecordFromRevocationTable(string $recordId)
    {
        $dbh = $this->makeRevocationTableDatabaseConnection();
        $stmt = $dbh->prepare("DELETE FROM revoked_ehjwt WHERE id = ?");
        $stmt->bindParam(1, $recordId);
        return $stmt->execute();
    }

    public function reissueToken(string $tokenString, int $newUtcTimestampExpiration)
    {
        if ($this->loadToken($tokenString)) {
            $this->addOrUpdateJwtClaim('exp', $newUtcTimestampExpiration);
            $this->createToken();
        }
        return;
    }

    public function getToken()
    {
        return $this->token;
    }

    private function decodeTokenPayload($jwtPayload)
    {
        $decodedPayload = json_decode($this->base64UrlDecode($jwtPayload), true);
        if (0 !== json_last_error()) {
            throw new RuntimeException("JWT payload json_decode() error: " . json_last_error_msg(), 0);
        }
        return $decodedPayload;
    }

    public function getTokenClaims()
    {
        return $this->tokenClaims;
    }

    private function base64UrlEncode(string $unencodedString)
    {
        return rtrim(strtr(base64_encode($unencodedString), '+/', '-_'), '=');
    }

    private function base64UrlDecode(string $base64UrlEncodedString)
    {
        return base64_decode(str_pad(strtr($base64UrlEncodedString, '-_', '+/'), mb_strlen($base64UrlEncodedString) % 4, '=', STR_PAD_RIGHT));
    }

    private function makeHmacHash(string $base64UrlHeader, string $base64UrlClaims)
    {
        // sha256 is the only algorithm. sorry, not sorry.
        return hash_hmac('sha256', $base64UrlHeader . '.' . $base64UrlClaims, $this->configurations['jwtSecret'], true);
    }

    public function clearClaims()
    {
        $this->tokenClaims = [];
    }

    public function revokeToken()
    {
        // only add if the token is valid-- don't let imposters kill otherwise valid tokens
        if ($this->validateToken()) {
            $revocationExpiration = (int)$this->tokenClaims['exp'] + 30;
            $this->writeRecordToRevocationTable($revocationExpiration);
        }
    }

    public function banUser(string $utcUnixTimestampBanExpiration)
    {
        $banExp = (int)$this->tokenClaims['exp'] + 60;
        // insert jti of 0, sub... the userId to ban, and UTC Unix epoch of ban end
        $this->writeRecordToRevocationTable($utcUnixTimestampBanExpiration, true);
    }

    public function permabanUser()
    {
        // insert jti of 0, sub... the userId to ban, and UTC Unix epoch of ban end-- Tuesday after never
        $this->writeRecordToRevocationTable(4294967295, true);
    }

    public function unbanUser()
    {
        $this->deleteRecordsFromRevocationTable();
    }

    private function writeRecordToRevocationTable(int $exp, bool $ban = false)
    {
        $userBanJtiPlaceholder = 0;
        $dbh = $this->makeRevocationTableDatabaseConnection();
        $stmt = $dbh->prepare("INSERT INTO revoked_ehjwt (jti, sub, exp) VALUES (?, ?, ?)");
        $stmt->bindParam(1, $this->tokenClaims['jti']);
        if ($ban) {
            $stmt->bindParam(1, $userBanJtiPlaceholder);
        }
        $stmt->bindParam(2, $this->tokenClaims['sub']);
        $stmt->bindParam(3, $exp);

        return $stmt->execute();
    }

    private function deleteRecordsFromRevocationTable()
    {
        $dbh = $this->makeRevocationTableDatabaseConnection();
        $stmt = $dbh->prepare("DELETE FROM revoked_ehjwt WHERE sub = ? AND jti = 0");
        $stmt->bindParam(1, $this->tokenClaims['sub']);
        return $stmt->execute();
    }
    // ToDo: Provide access to a list of banned users
    public function retrieveBannedUsers()
    {
        $bannedUsers = array();

        $dbh = $this->makeRevocationTableDatabaseConnection();

        $stmt = $dbh->query('SELECT * FROM revoked_ehjwt WHERE `jti` = 0');

        if ($stmt->execute()) {
            while ($row = $stmt->fetch()) {
                $bannedUsers[] = $row;
            }
            return $bannedUsers;
        }
    }}
\$\endgroup\$
2
  • \$\begingroup\$ You haven't said what your library is supposed to do (that should be the title). Maybe it's in your video, but not all of us have installed video players, and in any case, the question should be complete in itself, and not dependent on external resources to make sense. \$\endgroup\$ Commented Dec 31, 2020 at 19:08
  • \$\begingroup\$ @TobySpeight, I have edited the title as requested. I included the code and link to the github. The video link is just because I thought it would be helpful for people that didn't feel like installing my cruddy library and configuring phpunit. --I guess I figured more people would have access to youtube than phpunit. \$\endgroup\$ Commented Dec 31, 2020 at 19:28

1 Answer 1

3
\$\begingroup\$
  • The mb_strlen($configFileNameWithPath) > 0 checks don't need the > 0 part at the end. mb_strlen() will always return an integer-type value, so you only need to check if it is truthy. As I think about it, I don't see the value in mb_strlen() -- it will be potentially more accurate in non-zero cases, but you only intend to differentiate between zero and non-zero. For this reason, maybe just use strlen().

  • IMPORTANT: A constructor returns nothing. You should not be returning true. Please read Constructor returning value?.

  • $envVarNames and $settingConfigurationName are related arrays, but you have not declared them as such. I recommend that you logically construct a lookup array (as a class property so that only processing code is in the method) which is associative. This will set you up to employ a foreach() loop instead of counting the length of an array on every iteration.

    private $envConfigNames = [
        'EHJWT_JWT_SECRET' => 'jwtSecret',
        'EHJWT_DSN' => 'dsn',
        'EHJWT_DB_USER' => 'dbUser',
        'EHJWT_DB_PASS' => 'dbPassword',
    ];
    
    private function setConfigurationsFromEnvVars(): void
    {
        foreach ($this->$envConfigNames as $envName => $configName) {
            $value = getenv($envName);
            if (strlen($value)) {
                $this->configurations[$configName] = $value;
            }
        }
    

    Use this lookup array again in setConfigurationsFromConfigFile() and setConfigurationsFromArguments().

  • Instead of gettype($configFileSettings) !== 'array', it is more concise to use !is_array($configFileSettings).

  • I, generally, do not endorse the use of "variable variables", but I don't find your usage to be offensive or pulling your code into a disheveled state. The syntax can be as simple as $value = $$settingName;

  • You never need to check if a variable/element isset() before unset()ing it. Remove the condition and just unset() it.

  • I do not endorse the unconditional return of true in methods. It is not informative, it is just useless noise. If there is no chance of returning false, just don't return anything (:void).

  • I prefer to avoid single-use variables (unless there is a clear and valuable benefit in the declarative variable name). createToken() seems like the values should be directly fed into the implode() call.

  • I recommend declaring the return types on all methods (except the constructor). This will help with debugging and enforce strict/predictable types in your application. public function loadToken(string $tokenString): bool

  • Consistently use ALL-CAPS when writing sql keywords -- this will improve the readability of your code. (e.g. convert where to WHERE)

  • Even if variables fed to your sql are coming from a safe place, I recommend consistently using prepared statements with bound parameters. When you have a whole application written this way, you will be less likely to have other devs on your team "sneak in" unsafe values when they copy-pasta from your un-parameterized query.

  • I do not like that you are creating a new pdo connection for every query. You should modify makeRevocationTableDatabaseConnection() so that if there is already an established connection, then it is re-used.

  • Return the boolean evaluation for more concise code.

    private function verifyThreeMembers(array $array): bool
    {
        return count($array) === 3;
    }
    

    For methods that return a boolean value, I often start the method name with is or has (or a similar/suitable verb) so that all devs that read the code will infer from its name that it returns a boolean. When sorting my methods in my IDE (phpstorm), this serves to group the boolean-returning methods together.

  • With pdo, I prefer to pass the values directly into the execute() function to avoid the bindParam() calls.

  • Use fetchAll() when returning an entire unaltered result set instead of iteratively fetching the result set.

    return $stmt->fetchAll(PDO::FETCH_ASSOC);
    
\$\endgroup\$
1
  • \$\begingroup\$ That is a lot of good advice. Thanks. \$\endgroup\$ Commented Jan 4, 2021 at 15:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.