How to implement JWT ¶
- The JWT Concept
- Scenarios
- User logs in for the first time, via the /auth/login endpoint:
- Token expired:
- My laptop got stolen:
- Why do we trust the JWT blindly?
- Implementation Steps
- Prerequisites
- Step-by-step setup
- Client-side examples
The JWT Concept ¶
JWT is short for JSON Web Token. It is used eg. instead of sessions to maintain a login in a browser that is talking to an API - since browser sessions are vulnerable to CSRF security issues. JWT is also less complicated than setting up an OAuth authentication mechanism.
The concept relies on two tokens:
- AccessToken - a short-lived JWT (eg. 5 minutes)
This token is generated using \sizeg\jwt\Jwt::class
It is not stored server side, and is sent on all subsequent API requests through the Authorization
header
How is the user identified then? Well, the JWT contents contain the user ID. We trust this value blindly.
- RefreshToken - a long-lived, stored in database
This token is generated upon login only, and is stored in the table user_refresh_token
.
A user may have several RefreshToken in the database.
Scenarios ¶
User logs in for the first time, via the /auth/login
endpoint: ¶
In our actionLogin()
method two things happens, if the credentials are correct:
- The JWT AccessToken is generated and sent back through JSON. It is not stored anywhere server-side, and contains the user ID (encoded).
- The RefreshToken is generated and stored in the database. It's not sent back as JSON, but rather as a
httpOnly
cookie, restricted to the/auth/refresh-token
path.
The JWT is stored in the browser's localStorage
, and have to be sent on all requests from now on.
The RefreshToken is in your cookies, but can't be read/accessed/tempered with through Javascript (since it is httpOnly
).
Token expired: ¶
After some time, the JWT will eventually expire. Your API have to return 401 - Unauthorized
in this case.
In your app's HTTP client (eg. Axios), add an interceptor, which detects the 401 status, stores the failing request in a queue,
and calls the /auth/refresh-token
endpoint.
When called, this endpoint will receive the RefreshToken via the cookie. You then have to check in your table if this is a valid RefreshToken, who is the associated user ID, generate a new JWT and send it back as JSON.
Your HTTP client must take this new JWT, replace it in localStorage
, and then cycle through the request queue and replay all failed requests.
My laptop got stolen: ¶
If you set up an /auth/sessions
endpoint, that returns all the current user's RefreshTokens, you can then display
a table of all connected devices.
You can then allow the user to remove a row (i.e. DELETE a particular RefreshToken from the table). When the compromised token expires (after eg. 5 min) and the renewal is attempted, it will fail. This is why we want the JWT to be really short lived.
Why do we trust the JWT blindly? ¶
This is by design the purpose of JWT. It is secure enough to be trustable. In big setups (eg. Google), the Authentication is handled by a separate authentication server. It's responsible for accepting a login/password in exchange for a token.
Later, in Gmail for example, no authentication is performed at all. Google reads your JWT and give you access to your email, provided your JWT is not dead. If it is, you're redirected to the authentication server.
This is why when Google authentication had a failure some time ago - some users were able to use Gmail without any problems, while others couldn't connect at all - JWT still valid versus an outdated JWT.
Implementation Steps ¶
Prerequisites ¶
- Yii2 installed
- An
https
enabled site is required for the HttpOnly cookie to work cross-site - A database table for storing RefreshTokens:
CREATE TABLE `user_refresh_tokens` (
`user_refresh_tokenID` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`urf_userID` INT(10) UNSIGNED NOT NULL,
`urf_token` VARCHAR(1000) NOT NULL,
`urf_ip` VARCHAR(50) NOT NULL,
`urf_user_agent` VARCHAR(1000) NOT NULL,
`urf_created` DATETIME NOT NULL COMMENT 'UTC',
PRIMARY KEY (`user_refresh_tokenID`)
)
COMMENT='For JWT authentication process';
- Install package:
composer require sizeg/yii2-jwt
- For the routes login/logout/refresh etc we'll use a controller called
AuthController.php
. You can name it what you want.
Step-by-step setup ¶
Create an ActiveRecord model for the table
user_refresh_tokens
. We'll use the class nameapp\models\UserRefreshToken
.Disable CSRF validation on all your controllers:
Add this property: public $enableCsrfValidation = false;
- Add JWT parameters in
/config/params.php
:
'jwt' => [
'issuer' => 'https://api.example.com', //name of your project (for information only)
'audience' => 'https://frontend.example.com', //description of the audience, eg. the website using the authentication (for info only)
'id' => 'UNIQUE-JWT-IDENTIFIER', //a unique identifier for the JWT, typically a random string
'expire' => 300, //the short-lived JWT token is here set to expire after 5 min.
],
- Add
JwtValidationData
class in/components
which uses the parameters we just set:
<?php
namespace app\components;
use Yii;
class JwtValidationData extends \sizeg\jwt\JwtValidationData {
/**
* @inheritdoc
*/
public function init() {
$jwtParams = Yii::$app->params['jwt'];
$this->validationData->setIssuer($jwtParams['issuer']);
$this->validationData->setAudience($jwtParams['audience']);
$this->validationData->setId($jwtParams['id']);
parent::init();
}
}
- Add component in configuration in
/config/web.php
for initializing JWT authentication:
$config = [
'components' => [
...
'jwt' => [
'class' => \sizeg\jwt\Jwt::class,
'key' => 'SECRET-KEY', //typically a long random string
'jwtValidationData' => \app\components\JwtValidationData::class,
],
...
],
];
- Add the authenticator behavior to your controllers
- For
AuthController.php
we must exclude actions that do not require being authenticated, likelogin
,refresh-token
,options
(when browser sends the cross-site OPTIONS request).
- For
public function behaviors() {
$behaviors = parent::behaviors();
$behaviors['authenticator'] = [
'class' => \sizeg\jwt\JwtHttpBearerAuth::class,
'except' => [
'login',
'refresh-token',
'options',
],
];
return $behaviors;
}
- Add the methods
generateJwt()
andgenerateRefreshToken()
toAuthController.php
. We'll be using them in the login/refresh-token actions. Adjust class name for your user model if different.
private function generateJwt(\app\models\User $user) {
$jwt = Yii::$app->jwt;
$signer = $jwt->getSigner('HS256');
$key = $jwt->getKey();
$time = time();
$jwtParams = Yii::$app->params['jwt'];
return $jwt->getBuilder()
->issuedBy($jwtParams['issuer'])
->permittedFor($jwtParams['audience'])
->identifiedBy($jwtParams['id'], true)
->issuedAt($time)
->expiresAt($time + $jwtParams['expire'])
->withClaim('uid', $user->userID)
->getToken($signer, $key);
}
/**
* @throws yii\base\Exception
*/
private function generateRefreshToken(\app\models\User $user, \app\models\User $impersonator = null): \app\models\UserRefreshToken {
$refreshToken = Yii::$app->security->generateRandomString(200);
// TODO: Don't always regenerate - you could reuse existing one if user already has one with same IP and user agent
$userRefreshToken = new \app\models\UserRefreshToken([
'urf_userID' => $user->id,
'urf_token' => $refreshToken,
'urf_ip' => Yii::$app->request->userIP,
'urf_user_agent' => Yii::$app->request->userAgent,
'urf_created' => gmdate('Y-m-d H:i:s'),
]);
if (!$userRefreshToken->save()) {
throw new \yii\web\ServerErrorHttpException('Failed to save the refresh token: '. $userRefreshToken->getErrorSummary(true));
}
// Send the refresh-token to the user in a HttpOnly cookie that Javascript can never read and that's limited by path
Yii::$app->response->cookies->add(new \yii\web\Cookie([
'name' => 'refresh-token',
'value' => $refreshToken,
'httpOnly' => true,
'sameSite' => 'none',
'secure' => true,
'path' => '/v1/auth/refresh-token', //endpoint URI for renewing the JWT token using this refresh-token, or deleting refresh-token
]));
return $userRefreshToken;
}
- Add the login action to
AuthController.php
:
public function actionLogin() {
$model = new \app\models\LoginForm();
if ($model->load(Yii::$app->request->getBodyParams()) && $model->login()) {
$user = Yii::$app->user->identity;
$token = $this->generateJwt($user);
$this->generateRefreshToken($user);
return [
'user' => $user,
'token' => (string) $token,
];
} else {
return $model->getFirstErrors();
}
}
- Add the refresh-token action to
AuthController.php
. CallPOST /auth/refresh-token
when JWT has expired, and callDELETE /auth/refresh-token
when user requests a logout (and then delete the JWT token from client'slocalStorage
).
public function actionRefreshToken() {
$refreshToken = Yii::$app->request->cookies->getValue('refresh-token', false);
if (!$refreshToken) {
return new \yii\web\UnauthorizedHttpException('No refresh token found.');
}
$userRefreshToken = \app\models\UserRefreshToken::findOne(['urf_token' => $refreshToken]);
if (Yii::$app->request->getMethod() == 'POST') {
// Getting new JWT after it has expired
if (!$userRefreshToken) {
return new \yii\web\UnauthorizedHttpException('The refresh token no longer exists.');
}
$user = \app\models\User::find() //adapt this to your needs
->where(['userID' => $userRefreshToken->urf_userID])
->andWhere(['not', ['usr_status' => 'inactive']])
->one();
if (!$user) {
$userRefreshToken->delete();
return new \yii\web\UnauthorizedHttpException('The user is inactive.');
}
$token = $this->generateJwt($user);
return [
'status' => 'ok',
'token' => (string) $token,
];
} elseif (Yii::$app->request->getMethod() == 'DELETE') {
// Logging out
if ($userRefreshToken && !$userRefreshToken->delete()) {
return new \yii\web\ServerErrorHttpException('Failed to delete the refresh token.');
}
return ['status' => 'ok'];
} else {
return new \yii\web\UnauthorizedHttpException('The user is inactive.');
}
}
- Adapt
findIdentityByAccessToken()
in your user model to find the authenticated user via the uid claim from the JWT:
public static function findIdentityByAccessToken($token, $type = null) {
return static::find()
->where(['userID' => (string) $token->getClaim('uid') ])
->andWhere(['<>', 'usr_status', 'inactive']) //adapt this to your needs
->one();
}
- Also remember to purge all RefreshTokens for the user when the password is changed, eg. in
afterSave()
in your user model:
public function afterSave($isInsert, $changedOldAttributes) {
// Purge the user tokens when the password is changed
if (array_key_exists('usr_password', $changedOldAttributes)) {
\app\models\UserRefreshToken::deleteAll(['urf_userID' => $this->userID]);
}
return parent::afterSave($isInsert, $changedOldAttributes);
}
- Make a page where user can delete his RefreshTokens. List the records from
user_refresh_tokens
that belongs to the given user and allow him to delete the ones he chooses.
Client-side examples ¶
The Axios interceptor (using React Redux???):
let isRefreshing = false;
let refreshSubscribers: QueuedApiCall[] = [];
const subscribeTokenRefresh = (cb: QueuedApiCall) =>
refreshSubscribers.push(cb);
const onRefreshed = (token: string) => {
console.log("refreshing ", refreshSubscribers.length, " subscribers");
refreshSubscribers.map(cb => cb(token));
refreshSubscribers = [];
};
api.interceptors.response.use(undefined,
error => {
const status = error.response ? error.response.status : false;
const originalRequest = error.config;
if (error.config.url === '/auth/refresh-token') {
console.log('REDIRECT TO LOGIN');
store.dispatch("logout").then(() => {
isRefreshing = false;
});
}
if (status === API_STATUS_UNAUTHORIZED) {
if (!isRefreshing) {
isRefreshing = true;
console.log('dispatching refresh');
store.dispatch("refreshToken").then(newToken => {
isRefreshing = false;
onRefreshed(newToken);
}).catch(() => {
isRefreshing = false;
});
}
return new Promise(resolve => {
subscribeTokenRefresh(token => {
// replace the expired token and retry
originalRequest.headers["Authorization"] = "Bearer " + token;
resolve(axios(originalRequest));
});
});
}
return Promise.reject(error);
}
);
Thanks to Mehdi Achour for helping with much of the material for this tutorial.
Here are some useful info:
176: return $token->verify($signer, $this->key);" to return "$token->verify($signer, $this->getKey());" This sounds fishy but it didn't seem to be a config issue on my end.
Just ask,
why refresh-token only
is stored in HttpOnly
Cookies,
then access-token is saved in browser's localStorage?
Why just save them all in same HttpOnly cookies paradigm ?
Please lightning us...
Great content btw...
@Dzil : a HttpOnly cookie cannot be read by javascript, and you need to be able to use
access_token
from within your application.There is no problems with having
access_token
in localStorage, as long as it is short lived (10 minutes or so)Hi Allan. First of all, congrats for this article. I find it really helpfull and well explained. I have a doubt about this sentence: "The RefreshToken is in your cookies, but can't be read/accessed/tempered with through Javascript (since it is httpOnly)."
If I explore my Chrome Dev Tools, at Network tab, at the headers of my "login" endpoint request, I can defenitely read the value of "refresh-token" param just created. Is it that ok? Dont can everybody see the value of the refresh token? Is that secure?
Thanks!
Great tutorial, although it needs a few updates. For example, generating JWT tokens now goes like this:
$jwt = Yii::$app->jwt; $signer = new $jwt->supportedAlgs['HS256'](); $key = $jwt->key; $time = time(); $jwtParams = Yii::$app->params['jwt']; return $jwt->getBuilder() ->setIssuer($jwtParams['issuer']) ->setAudience($jwtParams['audience']) ->setId($jwtParams['id'], true) ->setIssuedAt($time) ->setExpiration($time + $jwtParams['expire']) ->set('uid', $user->id) ->sign($signer, $key) ->getToken();
If you have any questions, please ask in the forum instead.
Signup or Login in order to comment.