API RESTful. CI4 & JWT Auth
Instal·lar PHP JWT Library
composer update
composer require firebase/php-jwt
La llibreria firebase/php-jwt codifica i descodifica tokens JWT en PHP segons les especificacions de la norma RFC 7519
Crear JWT API-CI Components
Arxiu configuració
php spark make:config Apijwt
php spark key:generate --show
Cal crear un arxiu de configuració on definirem el comportament bàsic de la llibreria que desenvoluparem per emprar JWT i ens mostrarem per pantalla una clau generada pel propi codeigniter que servirà per signar els tokens que generem amb aquesta llibreria.
En aquest fitxer de configuració podrem tenir la possibilitat de crear diferents perfils de JWT, tindrem una política que s'utilitzarà per defecte.
App\Config\Apijwt.php
/**
* tokenSecret
* Defines key to sign digitally the token JWT.
* To generate in hex2bin use:
* php spark key:generate --show
* To generate in base64 use:
* php spark key:generate --show --prefix base64
*
* You must store only the key, without algorithm prefix
*
* @var string
***********************************************************
*
* hash
* Defines hash used to sign JWT token. The signing algorithm (alg field)
* Supported algorithms are 'ES384','ES256', 'HS256', 'HS384',
* 'HS512', 'RS256', 'RS384', and 'RS512'
*
* @var string
***********************************************************
*
* authTimeout
* Defines timeout for token JWT, exp field into token JWT
* Set null to disable timeout
*
* @var int
***********************************************************
*
* issuer
* Issuer of the JWT
* Set null to ignore iss field from JWT token
*
* @var string
***********************************************************
*
* audience
* Audience of the JWT
* Set null to ignore aud field from JWT token
*
* @var string
***********************************************************
*
* subject
* Subject of the JWT
* Set null to ignore sub field from JWT token
*
* @var string
***********************************************************
*
* autoRenew
* Renew automatically JWT token every request
*
* @var bool
***********************************************************
*
* oneTimeToken
* JWT Token can be used only one time, toherwise is revoked
*
* @var bool
***********************************************************
*/
public $policyName = "default"; // Defines default policy name, to use by Library/Filter
public $default = [
'tokenSecret' => 'b952674c72eff0e5e482b7525cde57a1fcdddc486a89658fd8985169cda341e9', //hex2bin.
'hash' => "HS256",
'authTimeout' => 30 * MINUTE,
'issuer' => "daw-company",
'audience' => "daw-company.user-db",
'subject' => "secure.jwt.v1.daw",
'autoRenew' => true,
'oneTimeToken' => true,
'renewTokenField' => 'refreshToken',
'includePolicy' => true,
];
public $test = [
'tokenSecret' => 'b952674c72eff0e5e482b7525cde57a1fcdddc486a89658fd8985169cda341e9', //hex2bin.
'hash' => "HS256",
'authTimeout' => 24 * HOUR,
'issuer' => "test-company",
'audience' => "test-company.user-db",
'subject' => "secure.jwt.v1.test",
'autoRenew' => false,
'oneTimeToken' => false,
'renewTokenField' => 'refreshToken',
'includePolicy' => true,
];
public function __construct($policy = null)
{
parent::__construct();
if ($policy != null)
$this->policyName = $policy;
}
/**
* Returns deafult configuration or configuration group
*
* @param mixed $groupName Config groupname to get
* @return object
*/
public function config($policy = null)
{
if ($policy == null) $policy = $this->policyName;
$props = get_object_vars($this);
if (isset($props[$policy]) && is_array($props[$policy])) {
$props[$policy]["policy"] = $policy;
return (object) $props[$policy];
} else {
$props[$this->policyName]["policy"] = $this->policyName;
return (object) $props[$this->policyName];
}
}
Taula Revoked
Aquest desenvolupament permet treballar amb tokens d'un sol ús, per aquest motiu cal disposar d'una taula per emmagatzemar els tokens que ja s'han fet servir fins que aquests caduquin i ja es puguin eliminar. Per això cal desenvolupar una migració.
php spark make:migration AddRevokeTokensTable
php spark migrate
App\Database\Migrations\AddRevokeTokensTable.php
public function up()
{
$this->forge->addField([
'tokenid' => [
'type' => 'VARCHAR',
'constraint' => '36',
'null' => false,
],
'subject' => [
'type' => 'VARCHAR',
'constraint' => '128',
'null' => false,
],
'expiration' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
],
]);
$this->forge->addPrimaryKey(['tokenid', 'subject']);
$this->forge->createTable('tokens');
}
public function down()
{
$this->forge->dropTable('tokens');
}
Model JWT
Per interactuar amb la taula creada anteriorment serà necessari disposar d'un model que ens permeti, obtenir si un token està revocat, buidar la taula de tokens revocats, etc.
php spark make:model Tokens --suffix
App\Models\TokensModel.php
protected $DBGroup = 'default';
protected $table = 'tokens';
protected $primaryKey = ['tokenid', 'subject'];
protected $useAutoIncrement = true;
protected $insertID = 0;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $protectFields = true;
protected $allowedFields = ['tokenid', 'subject', 'expiration'];
// Dates
protected $useTimestamps = false;
protected $dateFormat = 'datetime';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $deletedField = 'deleted_at';
// Validation
protected $validationRules = [];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
// Callbacks
protected $allowCallbacks = true;
protected $beforeInsert = [];
protected $afterInsert = [];
protected $beforeUpdate = [];
protected $afterUpdate = [];
protected $beforeFind = [];
protected $afterFind = [];
protected $beforeDelete = [];
protected $afterDelete = [];
public function get($token_data)
{
$data = array(
'tokenid' => $token_data->jti,
'subject' => $token_data->sub??'subject.not.defined'
);
return $this->where($data)->first();
}
public function revoked($token_data)
{
return $this->get($token_data) != null;
}
public function revoke($token_data)
{
$data = array(
'tokenid' => $token_data->jti,
'subject' => $token_data->sub??'subject.not.defined',
'expiration' => $token_data->exp
);
return $this->insert($data);
}
public function purge($time=null)
{
if ($time===null) $time=time();
$data = array('expiration <=' => $time);
$query = $this->where($data)->delete();
return $this->affectedRows();
}
Helper JWT
Per treballar amb els tokens JWT ens ajudarem de diverses funcions que desenvoluparem a manera de Helper
App\Helpers\jwt_helper.php
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
/**
* getToken
* This function returns data from request JWT
* it examines request header to obtain Autorization: Bearer
* and decodes JWT to get payload.
*
* If an error occurs on decoding, it throws a JWT Exception
*
* @param mixed $cfgAPI Config object
* @param mixed $request Request object
* @return object ['encoded' => string, "data" => object]
*/
if (!function_exists('getToken')) {
function getToken($cfgAPI, $request)
{
$header = $request->header("Authorization");
$token = null;
if (!empty($header)) {
if (preg_match('/Bearer\s(\S+)/', $header, $matches)) {
$token = $matches[1];
}
}
$token_data = JWT::decode($token, new Key($cfgAPI->tokenSecret, $cfgAPI->hash));
$data = [
"encoded" => $token,
"data" => $token_data
];
return $data;
}
}
/**
* renewTokenJWT
* This function renews JWT token, with payload from a previous JWT. The
* function renews jti (json token id), iat (issued at) and nbf (not before)
*
* If an error occurs on encoding, it throws a JWT Exception
*
* @param mixed $cfgAPI Config policy object
* @param mixed $token_raw Token payload object
* @return string (token)
*/
if (!function_exists('renewTokenJWT')) {
function renewTokenJWT($cfgAPI, $token_raw)
{
$iat = time(); // current timestamp value
if (isset($cfgAPI->authTimeout) && $cfgAPI->authTimeout != null)
$token_raw->exp = $iat + $cfgAPI->authTimeout;
$token_raw->iat = $iat;
$token_raw->nbf = $iat;
$token_raw->jti = App\Libraries\UUID::v4();
$newtoken = JWT::encode((array)$token_raw, $cfgAPI->tokenSecret, $cfgAPI->hash);
return $newtoken;
}
}
/**
* newTokenJWT
* This function generates a new JWT token, it takes info from token policy
* defined on config file. Adds payload and config items
*
* @param mixed $cfgAPI Config policy object
* @param mixed $data Payload data, to add to JWT
* @return string Token generated
*/
if (!function_exists('newTokenJWT')) {
function newTokenJWT($cfgAPI, $data)
{
$iat = time(); // current timestamp value
$payload = array();
if (isset($cfgAPI->authTimeout) && $cfgAPI->authTimeout != null)
$payload["exp"] = $iat + $cfgAPI->authTimeout;
if (isset($cfgAPI->issuer) && $cfgAPI->issuer != null)
$payload["iss"] = $cfgAPI->issuer;
if (isset($cfgAPI->audience) && $cfgAPI->audience != null)
$payload["aud"] = $cfgAPI->audience;
if (isset($cfgAPI->subject) && $cfgAPI->subject != null)
$payload["sub"] = $cfgAPI->subject;
$payload = array_merge(
$payload,
array(
"nbf" => $iat,
"iat" => $iat, // Issued at
"jti" => App\Libraries\UUID::v4(), // Json Token Id
),
(array)$data
);
$token = JWT::encode($payload, $cfgAPI->tokenSecret, $cfgAPI->hash);
return $token;
}
}
Filter JWT
Finalment, perquè els tokens JWT funcionin de forma automatitzada en les nostres API, crearem un filtre que s'encarregarà de revisar les peticions rebudes per validar el token JWT rebut quant a format, caducitats, etc. La qüestió de la seguretat o privilegis anirà a càrrec del controlador. Així mateix, aquest filtre s'encarregarà d'afegir en el retorn de la resposta, el nou token, si es tracta d'una API amb tokens d'un sol amb generació automàtica (segons arxiu de configuració).
php spark make:filter JWT --suffix
App\Filters\JWTFilter.php
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use \Firebase\JWT\Key;
use \Firebase\JWT\JWT;
class JWTFilter implements FilterInterface
{
/**
* Do whatever processing this filter needs to do.
* By default it should not return anything during
* normal execution. However, when an abnormal state
* is found, it should return an instance of
* CodeIgniter\HTTP\Response. If it does, script
* execution will end and that Response will be
* sent back to the client, allowing for error pages,
* redirects, etc.
*
* @param RequestInterface $request
* @param array|null $arguments
*
* @return mixed
*/
public function before(RequestInterface $request, $arguments = null)
{
helper("jwt");
$model = new \App\Models\TokensModel();
if (isset($arguments))
$cfgAPI = new \Config\APIJwt($arguments[0]);
else
$cfgAPI = new \Config\APIJwt();
$header = $request->header("Authorization");
$token = null;
// extract the token from the header
if (!empty($header)) {
if (preg_match('/Bearer\s(\S+)/', $header, $matches)) {
$token = $matches[1];
}
}
// check if token is null or empty
if (is_null($token) || empty($token)) {
$response = service('response');
$response->setBody('Access denied. Token required');
$response->setStatusCode(401);
return $response;
}
try {
$token_data = JWT::decode($token, new Key($cfgAPI->config()->tokenSecret, $cfgAPI->config()->hash));
// check if token is defined with another policy and is not a valid token
if (($token_data->sub ?? 'undefined') != ($cfgAPI->config()->subject ?? 'undefined') ||
($token_data->aud ?? 'undefined') != ($cfgAPI->config()->audience ?? 'undefined') ||
($token_data->iss ?? 'undefined') != ($cfgAPI->config()->issuer ?? 'undefined')
) {
$response = service('response');
$response->setBody('Access denied. Wrong token params');
$response->setStatusCode(401);
return $response;
}
// check if token is revoked
if ($model->revoked($token_data)) {
$response = service('response');
$response->setBody('Access denied. Token revoked');
$response->setStatusCode(401);
return $response;
}
// if oneTimeToken is enabled, revoke current token
if ($cfgAPI->config()->oneTimeToken) {
$model->revoke($token_data);
}
// store token data into request header to controller access
$request->setHeader("token-data", json_encode($token_data));
$request->setHeader("token-config", json_encode($cfgAPI->config()));
$request->setHeader("jwt-policy", $cfgAPI->policyName);
} catch (\Exception $ex) {
$response = service('response');
$response->setBody('Access denied. ' . $ex->getMessage());
$response->setStatusCode(401);
return $response;
} finally {
// clear expired tokens in revoked tokens table
$model->purge();
}
}
/**
* Allows After filters to inspect and modify the response
* object as needed. This method does not allow any way
* to stop execution of other after filters, short of
* throwing an Exception or Error.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @param array|null $arguments
*
* @return mixed
*
* @link https://docs.microsoft.com/en-us/machine-learning-server/operationalize/how-to-manage-access-tokens
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// ADD fields to api response, ONLY for StatusCode OK-200/CREATED-201/ACCEPTED-202
if (
$response->getStatusCode() == \CodeIgniter\HTTP\Response::HTTP_OK ||
$response->getStatusCode() == \CodeIgniter\HTTP\Response::HTTP_CREATED ||
$response->getStatusCode() == \CodeIgniter\HTTP\Response::HTTP_ACCEPTED
) {
helper("jwt");
if (isset($arguments))
$cfgAPI = new \Config\APIJwt($arguments[0]);
else
$cfgAPI = new \Config\APIJwt();
try {
$values = json_decode($response->getBody());
//check if $response->getBody() is a json string
if (json_last_error() !== JSON_ERROR_NONE) {
if ($cfgAPI->config()->oneTimeToken && $cfgAPI->config()->autoRenew) {
$token_data = json_decode($request->header("token-data")->getValue());
$newToken = renewTokenJWT($cfgAPI->config(), $token_data);
$renewTokenField = $cfgAPI->config()->renewTokenField;
$values->$renewTokenField = $newToken;
}
if ($cfgAPI->config()->includePolicy)
$values->policy = $cfgAPI->policyName;
}
} catch (\Exception $ex) {
$response = service('response');
$response->setBody('Access denied. After. ' . $ex->getMessage());
$response->setStatusCode(401);
} finally {
if ($values !== null)
$response->setBody(json_encode($values));
}
}
}
}
Routes
Un cop tenim JWT configurat, actiu el filtre podrem utilitzar-lo com un filtre qualsevol a l'arxiu de routes
/**
* Call with default JWT policy
* $routes->get("test", "ApiController::test",['filter'=>'jwt']);
*
* Call with custom JWT policy defined in APIJwt config file
* $routes->get("test", "ApiController::test",['filter'=>'jwt:CONFIG_POLICY']);
* $routes->get("test", "ApiController::test",['filter'=>'jwt:test']);
*
*/
Exemple: Utilització API amb JWT
Disable CSRF
Cal desactivar la necessitat dels codis CSRF que requeríem en els formularis, ja que la nostra API rebrà informació via POST/PUT/PATCH... per fer-ho caldrà modificar l'afectació del filtre CSRF i configurar les rutes a les quals no haurà d'afectar.
API Securitzada
php spark make:controller Api --suffix --restful
App\Controllers\ApiController.php
<?php
namespace App\Controllers;
use CodeIgniter\RESTful\ResourceController;
use App\Models\UsuarisDemoModel;
use Firebase\JWT\JWT;
class ApiController extends ResourceController
{
public function login() {}
public function test(){}
}
Funció login
Login API. Genera JWT Token
/**
* Login API to generate JWT token
*
*/
public function login()
{
helper("form");
$rules = [
'email' => 'required',
'password' => 'required|min_length[4]'
];
if (!$this->validate($rules)) return $this->fail($this->validator->getErrors());
$model = new UsuarisDemoModel();
$user = $model->getUserByMailOrUsername($this->request->getVar('email'));
if (!$user) return $this->failNotFound('Email Not Found');
$verify = password_verify($this->request->getVar('password'), $user['password']);
if (!$verify) return $this->fail('Wrong Password');
/****************** GENERATE TOKEN ********************/
helper("jwt");
$APIGroupConfig = "default";
$cfgAPI = new \Config\APIJwt($APIGroupConfig);
$data = array(
"uid" => $user['id'],
"name" => $user['name'],
"email" => $user['email']
);
$token = newTokenJWT($cfgAPI->config(), $data);
/****************** END TOKEN GENERATION **************/
$response = [
'status' => 200,
'error' => false,
'messages' => 'User logged In successfully',
'token' => $token
];
return $this->respondCreated($response);
}
Funció test
Test JWT API. Funció de test JWT
/**
* API Sample call
*
*/
public function test()
{ // Get current token payload as object
$token_data = json_decode($this->request->header("token-data")->getValue());
// Get current config for this controller request as object
// $token_config = json_decode($this->request->header("token-config")->getValue());
// Get JWT policy config
// $policy_name = $this->request->header("jwt-policy")->getValue();
// check if user has permission or token policy is ok
// if user no authorized
// $this->fail("User no valid")
$response = [
'status' => 200,
'error' => false,
'messages' => 'Test function ok',
'data' => [
"data" => time(),
"token-username" => $token_data->name,
"token-email" => $token_data->email,
'config'=>"la configuració del pacman"
]
];
return $this->respond($response);
}
Arxiu routes
// All API functions with the same filter JWT policy
// $routes->group('api', ['filter' => 'jwt'], static function ($routes)
// Every route with their filter JWT policy
$routes->group("api", function ($routes) {
$routes->post("login", "ApiController::login");
/**
* Call with default JWT policy
* $routes->get("test", "ApiController::test",['filter'=>'jwt']);
*
* Call with custom JWT policy defined in APIJwt config file
* $routes->get("test", "ApiController::test",['filter'=>'jwt:CONFIG_POLICY']);
* $routes->get("test", "ApiController::test",['filter'=>'jwt:test']);
*
*/
$routes->get("test", "ApiController::test", ['filter' => 'jwt']);
});
Exemple Filtre CORS
En cas de requerir un filtre de CORS, aquest filtre està implementat dins de Codeigniter des de la versió 4.5 del Framework.
Si es vol ajustar la seva implementació es tractaria de crear un nou filtre amb un contingut similar al següent:
public function before(RequestInterface $request, $arguments = null)
{
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: X-API-KEY, Origin,X-Requested-With, Content-Type, Accept, Access-Control-Requested-Method, Authorization");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS, PATCH, PUT, DELETE");
$method = $_SERVER['REQUEST_METHOD'];
if ($method == "OPTIONS") {
die();
}
}
i un cop creat el filtre ajustant-ne el contingut a les necessitats del projecte, s'hauria d'activar com a filtre dins l'arxiu App\Config\Filters.php