diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9b881f2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +open_collective: leaf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f56cefe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +test +Experimental +vendor +composer.lock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2646e58 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ + +

+

+ +

Leaf Auth Module

+

+

+ +# Leaf PHP + +[![Latest Stable Version](https://poser.pugx.org/leafs/auth/v/stable)](https://packagist.org/packages/leafs/auth) +[![Total Downloads](https://poser.pugx.org/leafs/auth/downloads)](https://packagist.org/packages/leafs/auth) +[![License](https://poser.pugx.org/leafs/auth/license)](https://packagist.org/packages/leafs/auth) + +Leaf's auth helper packaged as a serve-yourself module. + +## Installation + +You can easily install Leaf using [Composer](https://getcomposer.org/). + +```bash +composer require leafs/auth +``` + +## View Leaf's docs [here](https://leafphp.netlify.app/#/) + +Built with ❤ by [**Mychi Darko**](https://mychi.netlify.app) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..37baaf4 --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "leafs/auth", + "description": "Leaf PHP auth helper", + "keywords": [ + "authentication", + "simple auth", + "login", + "register", + "leaf", + "php", + "framework" + ], + "homepage": "https://leafphp.netlify.app/#/", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Michael Darko", + "email": "mickdd22@gmail.com", + "homepage": "https://mychi.netlify.app", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Leaf\\": "src" + } + }, + "minimum-stability": "stable", + "require": { + "leafs/password": "^1.0", + "leafs/session": "^1.0", + "leafs/db": "^1.0", + "leafs/form": "^1.0", + "leafs/http": "^1.0" + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100755 index 0000000..3411b26 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,683 @@ + true, + "PASSWORD_ENCODE" => null, + "PASSWORD_VERIFY" => null, + "PASSWORD_KEY" => "password", + "HIDE_ID" => true, + "HIDE_PASSWORD" => true, + "LOGIN_PARAMS_ERROR" => "Incorrect credentials!", + "LOGIN_PASSWORD_ERROR" => "Password is incorrect!", + "USE_SESSION" => false, + "SESSION_ON_REGISTER" => false, + "GUARD_LOGIN" => "/auth/login", + "GUARD_REGISTER" => "/auth/register", + "GUARD_HOME" => "/home", + "SAVE_SESSION_JWT" => false, + ]; + + /** + * @var \Leaf\Db + */ + public static $db; + + /** + * @var \Leaf\Form + */ + public static $form; + + public function __construct($useSession = false) + { + static::$form = new Form; + static::$db = new Db; + + if ($useSession) { + static::$useSession(); + } + } + + /** + * Create a db connection + * + * @param string $host The db host name + * @param string $host The db user + * @param string $host The db password + * @param string $host The db name + */ + public static function connect(string $host, string $user, string $password, string $dbname): void + { + static::$form = new Form; + static::$db = new Db; + + static::$db->connect($host, $user, $password, $dbname); + } + + /** + * Create a database connection from env variables + */ + public static function autoConnect(): void + { + static::connect( + getenv("DB_HOST"), + getenv("DB_USERNAME"), + getenv("DB_PASSWORD"), + getenv("DB_DATABASE") + ); + } + + /** + * Get or set the default token lifetime value + * + * @param int $lifeTime The new lifetime value for token + * + * @return int|string|void + */ + public static function tokenLifetime($lifeTime = null) + { + if (!$lifeTime) return static::$lifeTime; + + static::$lifeTime = $lifeTime; + } + + /** + * Set token secret key for auth + * + * @param string $secretKey + */ + public static function setSecretKey(string $secretKey) + { + static::$secretKey = $secretKey; + } + + /** + * Get auth secret key + */ + public static function getSecretKey() + { + return static::$secretKey; + } + + /** + * Set auth config + */ + public static function config($config, $value = null) + { + if (is_array($config)) { + foreach ($config as $key => $configValue) { + static::config($key, $configValue); + } + } else { + if (!$value) return static::$settings[$config] ?? null; + static::$settings[$config] = $value; + } + } + + /** + * Exception for experimental features + */ + protected static function experimental($method) + { + if (!static::config("USE_SESSION")) { + trigger_error("Auth::$method is experimental. Turn on USE_SESSION to use this feature."); + } + } + + /** + * Manually start an auth session + */ + public static function useSession() + { + static::$session = new \Leaf\Http\Session(false); + static::config("USE_SESSION", true); + + session_start(); + + if (!static::$session->get("SESSION_STARTED_AT")) { + static::$session->set("SESSION_STARTED_AT", time()); + } + + static::$session->set("SESSION_LAST_ACTIVITY", time()); + } + + /** + * Session Length + */ + public static function sessionLength() + { + static::experimental("sessionLength"); + + return time() - static::$session->get("SESSION_STARTED_AT"); + } + + /** + * Session last active + */ + public static function sessionActive() + { + static::experimental("sessionActive"); + + return time() - static::$session->get("SESSION_LAST_ACTIVITY"); + } + + /** + * Refresh session + */ + public static function refresh($clearData = true) + { + static::experimental("refresh"); + + $success = static::$session->regenerate($clearData); + + static::$session->set("SESSION_STARTED_AT", time()); + static::$session->set("SESSION_LAST_ACTIVITY", time()); + static::$session->set("AUTH_SESISON", true); + + return $success; + } + + /** + * Define/Return session middleware + * + * **This method only works with session auth** + */ + public static function middleware(string $name, callable $handler = null) + { + static::experimental("middleware"); + + if (!$handler) return static::$middleware[$name]; + + static::$middleware[$name] = $handler; + } + + /** + * Check session status + */ + public static function session() + { + static::experimental("session"); + + return static::$session->get("AUTH_USER") ?? false; + } + + /** + * End a session + */ + public static function endSession($location = null) + { + static::experimental("endSession"); + + static::$session->destroy(); + + if ($location) { + $route = static::config($location) ?? $location; + (new Http\Response)->redirect($route); + } + } + + /** + * A simple auth guard: 'guest' pages can't be viewed when logged in, + * 'auth' pages can't be viewed without authentication + * + * @param array|string $type The type of guard/guard options + */ + public static function guard($type) + { + static::experimental("guard"); + + if (is_array($type)) { + if (isset($type["hasAuth"])) { + $type = $type["hasAuth"] ? 'auth' : 'guest'; + } + } + + if ($type === 'guest' && static::session()) { + exit(header("location: " . static::config("GUARD_HOME"), true, 302)); + } + + if ($type === 'auth' && !static::session()) { + exit(header("location: " . static::config("GUARD_LOGIN"), true, 302)); + } + } + + /** + * Save some data to auth session + */ + protected static function saveToSession($key, $data) + { + static::experimental("saveToSession"); + + static::$session->set($key, $data); + } + + /** + * Simple user login + * + * @param string table: Table to look for users + * @param array $credentials User credentials + * @param array $validate Validation for parameters + * + * @return array user: all user info + tokens + session data + */ + public static function login(string $table, array $credentials, array $validate = []) + { + $passKey = static::$settings["PASSWORD_KEY"]; + $password = $credentials[$passKey]; + + if (isset($credentials[$passKey])) { + unset($credentials[$passKey]); + } + + $user = static::$db->select($table)->where($credentials)->validate($validate)->fetchAssoc(); + if (!$user) { + static::$errorsArray["auth"] = static::$settings["LOGIN_PARAMS_ERROR"]; + return null; + } + + $passwordIsValid = true; + + if (static::$settings["PASSWORD_VERIFY"] !== false && isset($user[$passKey])) { + if (is_callable(static::$settings["PASSWORD_VERIFY"])) { + $passwordIsValid = call_user_func(static::$settings["PASSWORD_VERIFY"], $password, $user[$passKey]); + } else if (static::$settings["PASSWORD_VERIFY"] === Password::MD5) { + $passwordIsValid = (md5($password) === $user[$passKey]); + } else { + $passwordIsValid = Password::verify($password, $user[$passKey]); + } + } + + if (!$passwordIsValid) { + static::$errorsArray["password"] = static::$settings["LOGIN_PASSWORD_ERROR"]; + return null; + } + + $token = Authentication::generateSimpleToken($user["id"], static::$secretKey, static::$lifeTime); + + if (isset($user["id"])) { + $userId = $user["id"]; + } + + if (static::$settings["HIDE_ID"]) { + unset($user["id"]); + } + + if (static::$settings["HIDE_PASSWORD"] && (isset($user[$passKey]) || !$user[$passKey])) { + unset($user[$passKey]); + } + + if (!$token) { + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + return null; + } + + if (static::config("USE_SESSION")) { + if (isset($userId)) { + $user["id"] = $userId; + } + + static::saveToSession("AUTH_USER", $user); + static::saveToSession("HAS_SESSION", true); + + if (static::config("SAVE_SESSION_JWT")) { + static::saveToSession("AUTH_TOKEN", $token); + } + + exit(header("location: " . static::config("GUARD_HOME"))); + } + + $response["user"] = $user; + $response["token"] = $token; + + return $response; + } + + /** + * Simple user registration + * + * @param string $table: Table to store user in + * @param array $credentials Information for new user + * @param array $uniques Parameters which should be unique + * @param array $validate Validation for parameters + * + * @return array user: all user info + tokens + session data + */ + public static function register(string $table, array $credentials, array $uniques = [], array $validate = []) + { + $passKey = static::$settings["PASSWORD_KEY"]; + + if (static::$settings["PASSWORD_ENCODE"] !== false && isset($credentials[$passKey])) { + if (is_callable(static::$settings["PASSWORD_ENCODE"])) { + $credentials[$passKey] = call_user_func(static::$settings["PASSWORD_ENCODE"], $credentials[$passKey]); + } else if (static::$settings["PASSWORD_ENCODE"] === "md5") { + $credentials[$passKey] = md5($credentials[$passKey]); + } else { + $credentials[$passKey] = Password::hash($credentials[$passKey]); + } + } + + if (static::$settings["USE_TIMESTAMPS"]) { + $now = Date::now(); + $credentials["created_at"] = $now; + $credentials["updated_at"] = $now; + } + + try { + $query = static::$db->insert($table)->params($credentials)->unique($uniques)->validate($validate)->execute(); + } catch (\Throwable $th) { + static::$errorsArray["dev"] = $th->getMessage(); + return null; + } + + if (!$query) { + static::$errorsArray = array_merge(static::$errorsArray, static::$db->errors()); + return null; + } + + $user = static::$db->select($table)->where($credentials)->validate($validate)->fetchAssoc(); + + if (!$user) { + static::$errorsArray = array_merge(static::$errorsArray, static::$db->errors()); + return null; + } + + $token = Authentication::generateSimpleToken($user["id"], static::$secretKey, static::$lifeTime); + + if (isset($user["id"])) { + $userId = $user["id"]; + } + + if (static::$settings["HIDE_ID"]) { + unset($user["id"]); + } + + if (static::$settings["HIDE_PASSWORD"] && (isset($user[$passKey]) || !$user[$passKey])) { + unset($user[$passKey]); + } + + if (!$token) { + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + return null; + } + + if (static::config("USE_SESSION")) { + if (static::config("SESSION_ON_REGISTER")) { + if (isset($userId)) { + $user["id"] = $userId; + } + + static::saveToSession("AUTH_USER", $user); + static::saveToSession("HAS_SESSION", true); + + if (static::config("SAVE_SESSION_JWT")) { + static::saveToSession("AUTH_TOKEN", $token); + } + + exit(header("location: " . static::config("GUARD_HOME"))); + } else { + exit(header("location: " . static::config("GUARD_LOGIN"))); + } + } + + $response["user"] = $user; + $response["token"] = $token; + + return $response; + } + + /** + * Simple user update + * + * @param string $table: Table to store user in + * @param array $credentials New information for user + * @param array $where Information to find user by + * @param array $uniques Parameters which should be unique + * @param array $validate Validation for parameters + * + * @return array user: all user info + tokens + session data + */ + public static function update(string $table, array $credentials, array $where, array $uniques = [], array $validate = []) + { + $passKey = static::$settings["PASSWORD_KEY"]; + + if (static::$settings["PASSWORD_ENCODE"] !== false && isset($credentials[$passKey])) { + if (is_callable(static::$settings["PASSWORD_ENCODE"])) { + $credentials[$passKey] = call_user_func(static::$settings["PASSWORD_ENCODE"], $credentials[$passKey]); + } else if (static::$settings["PASSWORD_ENCODE"] === "md5") { + $credentials[$passKey] = md5($credentials[$passKey]); + } else { + $credentials[$passKey] = Password::hash($credentials[$passKey]); + } + } + + if (static::$settings["USE_TIMESTAMPS"]) { + $credentials["updated_at"] = Date::now(); + } + + if (count($uniques) > 0) { + foreach ($uniques as $unique) { + if (!isset($credentials[$unique])) { + (new Http\Response)->throwErr(["error" => "$unique not found in credentials."]); + } + + $data = static::$db->select($table)->where($unique, $credentials[$unique])->fetchAssoc(); + + $wKeys = array_keys($where); + $wValues = array_values($where); + + if (isset($data[$wKeys[0]]) && $data[$wKeys[0]] != $wValues[0]) { + static::$errorsArray[$unique] = "$unique already exists"; + } + } + + if (count(static::$errorsArray) > 0) return null; + } + + try { + $query = static::$db->update($table)->params($credentials)->where($where)->validate($validate)->execute(); + } catch (\Throwable $th) { + static::$errorsArray["dev"] = $th->getMessage(); + return null; + } + + if (!$query) { + static::$errorsArray = array_merge(static::$errorsArray, static::$db->errors()); + return null; + } + + if (isset($credentials["updated_at"])) { + unset($credentials["updated_at"]); + } + + $user = static::$db->select($table)->where($credentials)->validate($validate)->fetchAssoc(); + if (!$user) { + static::$errorsArray = array_merge(static::$errorsArray, static::$db->errors()); + return null; + } + + $token = Authentication::generateSimpleToken($user["id"], static::$secretKey, static::$lifeTime); + + if (isset($user["id"])) { + $userId = $user["id"]; + } + + if (static::$settings["HIDE_ID"] && isset($user["id"])) { + unset($user["id"]); + } + + if (static::$settings["HIDE_PASSWORD"] && (isset($user[$passKey]) || !$user[$passKey])) { + unset($user[$passKey]); + } + + if (!$token) { + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + return null; + } + + if (static::config("USE_SESSION")) { + if (isset($userId)) { + $user["id"] = $userId; + } + + static::saveToSession("AUTH_USER", $user); + static::saveToSession("HAS_SESSION", true); + + if (static::config("SAVE_SESSION_JWT")) { + static::saveToSession("AUTH_TOKEN", $token); + } + + return $user; + } + + $response["user"] = $user; + $response["token"] = $token; + + return $response; + } + + /** + * Validate Json Web Token + * + * @param string $token The token validate + * @param string $secretKey The secret key used to encode token + */ + public static function validate($token, $secretKey = null) + { + $payload = Authentication::validate($token, $secretKey ?? static::$secretKey); + if ($payload) return $payload; + + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + + return null; + } + + /** + * Validate Bearer Token + * + * @param string $secretKey The secret key used to encode token + */ + public static function validateToken($secretKey = null) + { + $payload = Authentication::validateToken($secretKey ?? static::$secretKey); + if ($payload) return $payload; + + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + + return null; + } + + /** + * Get Bearer token + */ + public static function getBearerToken() + { + $token = Authentication::getBearerToken(); + if ($token) return $token; + + static::$errorsArray = array_merge(static::$errorsArray, Authentication::errors()); + + return null; + } + + /** + * Get the current user data from token + * + * @param string $table The table to look for user + * @param array $hidden Fields to hide from user array + */ + public static function user($table = "users", $hidden = []) + { + if (!static::id()) { + if (static::config("USE_SESSION")) { + return static::$session->get("AUTH_USER"); + } + + return null; + } + + $user = static::$db->select($table)->where("id", static::id())->fetchAssoc(); + + if (count($hidden) > 0) { + foreach ($hidden as $item) { + if (isset($user[$item]) || !$user[$item]) { + unset($user[$item]); + } + } + } + + return $user; + } + + /** + * Return the user id encoded in token + */ + public static function id() + { + if (static::config("USE_SESSION")) { + return static::$session->get("AUTH_USER")["id"] ?? null; + } + + $payload = static::validateToken(static::getSecretKey()); + if (!$payload) return null; + return $payload->user_id; + } + + /** + * Return form field + */ + public static function get($param) + { + return static::$form->get($param); + } + + /** + * Get all authentication errors as associative array + */ + public static function errors(): array + { + return static::$errorsArray; + } +} diff --git a/src/Helpers/Authentication.php b/src/Helpers/Authentication.php new file mode 100755 index 0000000..91c5340 --- /dev/null +++ b/src/Helpers/Authentication.php @@ -0,0 +1,130 @@ + + * @since v1.2.0 + */ +class Authentication +{ + /** + * Any errors caught + */ + protected static $errorsArray = []; + + /** + * Quickly generate a JWT encoding a user id + * + * @param string $userId The user id to encode + * @param string $secretPhrase The user id to encode + * @param int $expiresAt Token lifetime + * + * @return string The generated token + */ + public static function generateSimpleToken(string $userId, string $secretPhrase, int $expiresAt = null) + { + $payload = [ + 'iat' => time(), + 'iss' => 'localhost', + 'exp' => time() + ($expiresAt ?? (60 * 60 * 24)), + 'user_id' => $userId + ]; + + return self::generateToken($payload, $secretPhrase); + } + + /** + * Create a JWT with your own payload + * + * @param string $payload The JWT payload + * @param string $secretPhrase The user id to encode + * + * @return string The generated token + */ + public static function generateToken(array $payload, string $secretPhrase) + { + return JWT::encode($payload, $secretPhrase); + } + + /** + * Get Authorization Headers + */ + public static function getAuthorizationHeader() + { + $headers = null; + + if (isset($_SERVER['Authorization'])) { + $headers = trim($_SERVER["Authorization"]); + } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $headers = trim($_SERVER["HTTP_AUTHORIZATION"]); + } else if (function_exists('apache_request_headers')) { + $requestHeaders = apache_request_headers(); + // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization) + $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); + + if (isset($requestHeaders['Authorization'])) { + $headers = trim($requestHeaders['Authorization']); + } + } + + return $headers; + } + + /** + * get access token from header + */ + public static function getBearerToken() + { + $headers = self::getAuthorizationHeader(); + + if (!empty($headers)) { + if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) { + return $matches[1]; + } + + self::$errorsArray["token"] = "Access token not found"; + return null; + } + + self::$errorsArray["token"] = "Access token not found"; + return null; + } + + /** + * Validate and decode access token in header + */ + public static function validateToken($secretPhrase) + { + $bearerToken = self::getBearerToken(); + if ($bearerToken === null) return null; + + return self::validate($bearerToken, $secretPhrase); + } + + /** + * Validate access token + * + * @param string $token Access token to validate and decode + */ + public static function validate($token, $secretPhrase) + { + $payload = JWT::decode($token, $secretPhrase, ['HS256']); + if ($payload !== null) return $payload; + + self::$errorsArray = array_merge(self::$errorsArray, JWT::errors()); + return null; + } + + /** + * Get all authentication errors as associative array + */ + public static function errors() + { + return self::$errorsArray; + } +} diff --git a/src/Helpers/JWT.php b/src/Helpers/JWT.php new file mode 100755 index 0000000..8fe1da5 --- /dev/null +++ b/src/Helpers/JWT.php @@ -0,0 +1,371 @@ + + * @author Anant Narayanan + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWT +{ + /** + * Errors caught + */ + protected static $errorsArray = []; + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + */ + public static $leeway = 0; + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * + * Will default to PHP time() value if null. + */ + public static $timestamp = null; + public static $supported_algs = array( + 'HS256' => array('hash_hmac', 'SHA256'), + 'HS512' => array('hash_hmac', 'SHA512'), + 'HS384' => array('hash_hmac', 'SHA384'), + 'RS256' => array('openssl', 'SHA256'), + 'RS384' => array('openssl', 'SHA384'), + 'RS512' => array('openssl', 'SHA512'), + ); + /** + * Decodes a JWT string into a PHP object. + * + * @param string $jwt The JWT + * @param string|array $key The key, or map of keys. + * If the algorithm used is asymmetric, this is the public key + * @param array $allowed_algs List of supported verification algorithms + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * + * @return object The JWT's payload as a PHP object + * + * + * @uses jsonDecode + * @uses urlsafeB64Decode + */ + public static function decode($jwt, $key, array $allowed_algs = array()) + { + $timestamp = is_null(static::$timestamp) ? time() : static::$timestamp; + if (empty($key)) { + return static::saveErr("Key may not be empty"); + } + $tks = explode('.', $jwt); + if (count($tks) != 3) { + return static::saveErr("Wrong number of segments"); + } + list($headb64, $bodyb64, $cryptob64) = $tks; + if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { + return static::saveErr("Invalid header encoding"); + } + if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { + return static::saveErr("Invalid claims encoding"); + } + if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { + return static::saveErr("Invalid signature encoding"); + } + if (empty($header->alg)) { + return static::saveErr("Empty algorithm"); + } + if (empty(static::$supported_algs[$header->alg])) { + return static::saveErr("Algorithm not supported"); + } + if (!in_array($header->alg, $allowed_algs)) { + return static::saveErr("Algorithm not allowed"); + } + if (is_array($key) || $key instanceof \ArrayAccess) { + if (isset($header->kid)) { + if (!isset($key[$header->kid])) { + return static::saveErr("'kid' invalid, unable to lookup correct key"); + } + $key = $key[$header->kid]; + } else { + return static::saveErr("'kid' empty, unable to lookup correct key"); + } + } + // Check the signature + if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { + return static::saveErr("Signature verification failed"); + } + // Check if the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { + return static::saveErr( + "Cannot handle token prior to " . date(\DateTime::ISO8601, $payload->nbf) + ); + } + // Check that this token has been created before "now". This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { + return static::saveErr( + "Cannot handle token prior to " . date(\DateTime::ISO8601, $payload->iat) + ); + } + // Check if this token has expired. + if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { + return static::saveErr("Expired token"); + } + return $payload; + } + + /** + * Converts and signs a PHP object or array into a JWT string. + * + * @param object|array $payload PHP object or array + * @param string $key The secret key. + * If the algorithm used is asymmetric, this is the private key + * @param string $alg The signing algorithm. + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * @param mixed $keyId + * @param array $head An array with header elements to attach + * + * @return string A signed JWT + * + * @uses jsonEncode + * @uses urlsafeB64Encode + */ + public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) + { + $header = array('typ' => 'JWT', 'alg' => $alg); + if ($keyId !== null) { + $header['kid'] = $keyId; + } + if (isset($head) && is_array($head)) { + $header = array_merge($head, $header); + } + $segments = array(); + $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); + $signing_input = implode('.', $segments); + $signature = static::sign($signing_input, $key, $alg); + $segments[] = static::urlsafeB64Encode($signature); + return implode('.', $segments); + } + + /** + * Sign a string with a given key and algorithm. + * + * @param string $msg The message to sign + * @param string|resource $key The secret key + * @param string $alg The signing algorithm. + * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' + * + * @return string An encrypted message + * + * @throws \DomainException Unsupported algorithm was specified + */ + public static function sign($msg, $key, $alg = 'HS256') + { + if (empty(static::$supported_algs[$alg])) { + throw new \DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'hash_hmac': + return hash_hmac($algorithm, $msg, $key, true); + case 'openssl': + $signature = ''; + $success = openssl_sign($msg, $signature, $key, $algorithm); + if (!$success) { + throw new \DomainException("OpenSSL unable to sign data"); + } else { + return $signature; + } + } + } + + /** + * Verify a signature with the message, key and method. Not all methods + * are symmetric, so we must have a separate verify and sign method. + * + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key + * @param string $alg The algorithm + * + * @return bool + * + * @throws \DomainException Invalid Algorithm or OpenSSL failure + */ + private static function verify($msg, $signature, $key, $alg) + { + if (empty(static::$supported_algs[$alg])) { + throw new \DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'openssl': + $success = openssl_verify($msg, $signature, $key, $algorithm); + if ($success === 1) { + return true; + } elseif ($success === 0) { + return false; + } + // returns 1 on success, 0 on failure, -1 on error. + throw new \DomainException( + 'OpenSSL error: ' . openssl_error_string() + ); + case 'hash_hmac': + default: + $hash = hash_hmac($algorithm, $msg, $key, true); + if (function_exists('hash_equals')) { + return hash_equals($signature, $hash); + } + $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (ord($signature[$i]) ^ ord($hash[$i])); + } + $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); + return ($status === 0); + } + } + + /** + * Decode a JSON string into a PHP object. + * + * @param string $input JSON string + * + * @return object Object representation of JSON string + * + * @throws \DomainException Provided string was invalid JSON + */ + public static function jsonDecode($input) + { + if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { + /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you + * to specify that large ints (like Steam Transaction IDs) should be treated as + * strings, rather than the PHP default behaviour of converting them to floats. + */ + $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); + } else { + /** Not all servers will support that, however, so for older versions we must + * manually detect large ints in the JSON string and quote them (thus converting + *them to strings) before decoding, hence the preg_replace() call. + */ + $max_int_length = strlen((string) PHP_INT_MAX) - 1; + $json_without_bigints = preg_replace('/:\s*(-?\d{' . $max_int_length . ',})/', ': "$1"', $input); + $obj = json_decode($json_without_bigints); + } + if (function_exists('json_last_error') && $errno = json_last_error()) { + static::handleJsonError($errno); + } elseif ($obj === null && $input !== 'null') { + throw new \DomainException('Null result with non-null input'); + } + return $obj; + } + + /** + * Encode a PHP object into a JSON string. + * + * @param object|array $input A PHP object or array + * + * @return string JSON representation of the PHP object or array + * + * @throws \DomainException Provided object could not be encoded to valid JSON + */ + public static function jsonEncode($input) + { + $json = json_encode($input); + if (function_exists('json_last_error') && $errno = json_last_error()) { + static::handleJsonError($errno); + } elseif ($json === 'null' && $input !== null) { + throw new \DomainException('Null result with non-null input'); + } + return $json; + } + + /** + * Decode a string with URL-safe Base64. + * + * @param string $input A Base64 encoded string + * + * @return string A decoded string + */ + public static function urlsafeB64Decode($input) + { + $remainder = strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + return base64_decode(strtr($input, '-_', '+/')); + } + + /** + * Encode a string with URL-safe Base64. + * + * @param string $input The string you want encoded + * + * @return string The base64 encode of what you passed in + */ + public static function urlsafeB64Encode($input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + /** + * Helper method to create a JSON error. + * + * @param int $errno An error number from json_last_error() + * + * @return void + */ + private static function handleJsonError($errno) + { + $messages = array( + JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', + JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', + JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', + JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', + JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 + ); + throw new \DomainException( + isset($messages[$errno]) + ? $messages[$errno] + : 'Unknown JSON error: ' . $errno + ); + } + + /** + * Get the number of bytes in cryptographic strings. + * + * @param string + * + * @return int + */ + private static function safeStrlen($str) + { + if (function_exists('mb_strlen')) { + return mb_strlen($str, '8bit'); + } + return strlen($str); + } + + protected static function saveErr($err, $key = "token") + { + self::$errorsArray[$key] = $err; + return null; + } + + /** + * Return all errors found + */ + public static function errors() + { + return static::$errorsArray; + } +}