3
\$\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;
 }
 }}
Toby Speight
87.3k14 gold badges104 silver badges322 bronze badges
asked Dec 31, 2020 at 18:54
\$\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

4
\$\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);
    
answered Jan 1, 2021 at 9:08
\$\endgroup\$
0

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.