The title says it all, but it's long winded. But does it do what it says? Yes, and here is what I'll demonstrate for budding React web developers:
This blog post is a follow up from my earlier post about protecting nested routes in React. If you are new to React, it's better to walk yourself through that post first. In fact, you'll need to because I am not going to duplicate a few things from that post, for this one. Assume we are working with the structure found in the App.js
file therefore. Bar a very slight change (shown below), it's the same nonetheless.
I've created a number of React hooks to help in the authentication and they are: useAuth
and useLogout
and several utility scripts.
// ./Hooks/useAuth.js
import { useContext } from "react";
import AuthContext from "../Utils/AuthProvider";
const useAuth = () => {
return useContext(AuthContext);
}
export default useAuth;
// ./Hooks/useLogout.js
import authInstance from "../Utils/Axios/AuthInstance";
import useAuth from "./useAuth";
const useLogout = () => {
const { auth, setAuth } = useAuth();
const logout = async () => {
const token = auth?.token;
setAuth({});
try {
// attempt to blacklist the jwt token too
const response = await authInstance.post('/authenticate/logout', {
headers: {
"Authorization": "Bearer " + token
}
});
} catch(error) {
console.error(error);
}
};
return logout;
};
export default useLogout;
Those utility scripts are next, in no particular order. Note that we need two different instances of Axios to work with ― a global instance for endpoints requiring no token authentication (such as logging in or registering) and an authentication instance that carries the JWT token, for those endpoints that must be authenticated. This solution uses Axios interceptors, and the interceptors use their own instance of Axios too.
// ./Utils/Axios/GlobalInstance.js
import axios from "axios";
const globalInstance = axios.create({
baseURL: "http://laravel.com/api/v1",
timeout: 0,
headers: {
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json; charset=UTF-8",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cache-Control": "no-cache",
},
withCredentials: true,
responseType: "json",
responseEncoding: "utf8",
});
export default globalInstance;
// ./Utils/Axios/AuthInstance.js
import axios from "axios";
const authInstance = axios.create({
baseURL: "http://laravel.com/api/v1",
timeout: 0,
headers: {
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json; charset=UTF-8",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cache-Control": "no-cache"
},
withCredentials: true,
responseType: "json",
responseEncoding: "utf8",
});
export default authInstance;
I guess the headers and so on could be put into the one configuration but for this blog post you can look past the duplication.
// ./Utils/AuthProvider.js
import { createContext, useState } from "react";
/* create the global state, from context */
const AuthContext = createContext({});
export const AuthProvider = ({children}) => {
const [auth, setAuth] = useState({});
return (
<AuthContext.Provider value={{auth, setAuth}}>{children}</AuthContext.Provider>
);
};
export default AuthContext;
// ./Utils/AxiosProvider.js
import { useEffect } from "react";
import authInstance from "./Axios/AuthInstance";
import useAuth from "../Hooks/useAuth";
import axios from "axios";
const AxiosProvider = ({ children }) => {
const { auth, setAuth } = useAuth();
useEffect(() => {
const requestInterceptor = authInstance.interceptors.request.use(
config => {
// setting this prevents a 401 on response
config.headers["Authorization"] = "Bearer " + auth?.token;
return config;
}, (error) => {
return Promise.reject(error);
}
);
const responseInterceptor = authInstance.interceptors.response.use(
async (response) => {
return response;
}, async (error) => {
if(error?.response?.status !== 401) {
// nothing to do here, so ignore the error
return Promise.reject(error);
}
const prevRequest = error.config;
if(!prevRequest.sent) {
prevRequest.sent = true;
// do not use this instance of axios, but create a separate
// instance, instead preventing an infinite loop
const refreshTokenInstance = axios.create({
baseURL: "http://laravel.com/api/v1",
timeout: 0,
headers: {
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json; charset=UTF-8",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cache-Control": "no-cache",
"Authorization": "Bearer " + auth?.token
},
withCredentials: true,
responseType: "json",
responseEncoding: "utf8",
});
try {
// get a new token
const refreshResponse = await refreshTokenInstance.post("/authenticate/refresh");
if(refreshResponse?.status === 200) {
const refreshToken = refreshResponse?.data?.token;
setAuth(prev => {
return { ...prev, token: refreshToken }
});
// set up the header accordingly, for any subsequent request
prevRequest.headers["Authorization"] = "Bearer " + refreshToken;
// exit, carrying the config forward for the next request around
return authInstance(prevRequest);
}
} catch(error) {
// the refresh token has now expired, so destroy the state, which causes the login screen to appear
setAuth({
token: null
});
}
return Promise.reject(error);
}
return Promise.reject(error);
}
);
return () => {
authInstance.interceptors.response.eject(responseInterceptor);
authInstance.interceptors.request.eject(requestInterceptor);
};
}, [auth, setAuth]);
return children;
};
export default AxiosProvider;
It's the AxiosProvider
that does the bulk of the heavy lifting for you. It facilitates the use of the AuthProvider
context, forgoing the need to use local storage. It also encapsulates the Axios interceptors that ensure the JWT token reaches the Laravel back end. The interceptors also keep the access token fresh, using the refresh endpoint once the access token expires. When the refresh token eventually expires too, the state is nullified and thus you must log back in.
With this solution there is no need to calculate when a JWT token is going to expire, which increases the complexity of the application but also adds to the maintenance cost. Because we are using a second context hook (the AxiosProvider
) we need to adjust the route structure accordingly, from this:
// ./App.js
// ... etc ...
<>
<BrowserRouter>
<AuthProvider>
<Routes> ... etc ... </Routes>
</AuthProvider>
</BrowserRouter>
</> // ... etc ...
To this below. Just tab indent the inner structure and include <AxiosProvider>
... </AxiosProivder>
to enclose the internals. It's this second hook that negates the need for local storage and thus increasing the security of the application in question. And because most applications developed with React use a third-party API, on an entirely separate server, you can't depend on cookies either.
<>
<BrowserRouter>
<AuthProvider>
<AxiosProvider>
<Routes> ... etc ...
</Routes>
</AxiosProvider>
</AuthProvider>
</BrowserRouter>
</>
I'll include the login and logout components as well because maybe of a few changes from the earlier post.
// ./Components/Login.js
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import useAuth from "../Hooks/useAuth";
import globalInstance from "../Utils/Axios/GlobalInstance";
function Login() {
const navigate = useNavigate();
const {setAuth} = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const validateSubmit = async (e) => {
e.preventDefault();
try {
const response = await globalInstance.post("/authenticate/login",
JSON.stringify({
email: email,
password: password
}),
{
headers: {
"Content-Type": "application/json; charset=UTF-8",
"Accept": "application/json; charset=UTF-8",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Cache-Control": "no-cache"
},
withCredentials: true
}
);
const token = response?.data?.token;
setAuth({
token: token
});
navigate("/dashboard", { replace: true });
} catch (err) {
console.log(err);
}
};
return (
<>
<h1>Login</h1>
<form onSubmit={validateSubmit}>
... etc ...
<button>Log In</button>
</form>
</>
);
};
export default Login;
// ./Components/Logout.js
import { useNavigate } from "react-router-dom";
import useLogout from "../Hooks/useLogout";
function Logout() {
const navigate = useNavigate();
const logout = useLogout();
const logOut = async () => {
await logout();
navigate("/");
};
return (
<>
<h1>Log Out</h1>
<br />
<button onClick={logOut}>Log Out</button>
</>
);
};
export default Logout;
That's it for the React front end. Next, let's look at the Laravel back end. I'm still developing using Laravel 8 but that shouldn't be a problem if you're working with version 5.x or a newer Laravel. But the assumption is you have Tymon's package installed and Laravel all set up for JWT. I'm not going to go through the installation and setup for you.
For the purpose of this blog post there are two endpoints that are secure: ./posts
and ./topics
as seen below in Laravel's ./routes/api.php
file.
use App\Http\Controllers\Api\v1\AuthenticateController;
/**
* @note can either specify the API version here in the route group, per group level, or at the application
* level, in the route service provider, ie
*
* - ... 'prefix'=>'v1' ... etc ..., in the route group, or
* - ... from Route::prefix('api') to Route::prefix('api/v1') ... etc ...
*
* but there is no need to append the "api" in front of the version number (on the prefix), that's already
* done in the provider
*/
Route::group(['prefix' => 'v1', 'namespace' => 'Api\v1'], function()
{
Route::post('/authenticate/login', [AuthenticateController::class, 'login']);
Route::post('/authenticate/register', [AuthenticateController::class, 'register']);
});
Route::group(['prefix' => 'v1', 'namespace' => 'Api\v1', 'middleware' => ['jwt.refresh']], function()
{
/**
* @note ensure the access token is expired after specified time, and a new access token
* is sent back in response, and also the refresh token expires after specified
* time, requiring authentication once more
*/
Route::post('/authenticate/refresh', [AuthenticateController::class, 'refresh']);
});
Route::group(['prefix' => 'v1', 'namespace' => 'Api\v1', 'middleware'=>['jwt.verify']], function()
{
Route::post('/posts', function()
{
return response()->json([
'status' => true
, 'posts' => 'This is the posts data, a message'
], 200)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
});
Route::post('/topics', function()
{
return response()->json([
'status' => true
, 'topics' => 'This is the topics data, a message'
], 200)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
});
Route::post('/authenticate/profile', [AuthenticateController::class, 'profile']);
Route::post('/authenticate/logout', [AuthenticateController::class, 'logout']);
});
There are no controllers or models obviously. Simply a message returned will suffice. The jwt.refresh
middleware comes with the Tymon installation but not the jwt.verify
middleware, so that is below for you.
class JwtVerifyMiddleware extends BaseMiddleware
{
public function handle(Request $request, Closure $next)
{
/**
* @note there is a problem with middleware during unit tests, so need to ignore this
* middleware altogether, otherwise losing the token resulting in failing tests,
* and not bother yourself with the WithoutMiddleware trait
*/
if(app()->runningUnitTests())
{
return $next($request);
}
try
{
// attempt to refresh the token, within the lifespan of the JWT_REFRESH_TTL setting
$user=JWTAuth::setRequest($request)->parseToken()->authenticate();
}
catch(Exception $e)
{
if($e instanceof \Tymon\JWTAuth\Exceptions\TokenInvalidException)
{
return response()->json([
'status'=>'Invalid token'
], 401)->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);;
}
elseif($e instanceof \Tymon\JWTAuth\Exceptions\TokenExpiredException)
{
return response()->json([
'status'=>'Token has expired'
], 401)->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);;
}
else
{
return response()->json([
'status'=>'Authorization token not found'
], 401)->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);;
}
}
return $next($request);
}
}
Note we keep the token refresh endpoint on a separate route, away from the other middleware? Just in case any issues arise in the event of a conflict on one middleware or the other. Next, I use the default configuration:
// ./config/jwt.php
// ... etc ...
'ttl' => env('JWT_TTL', 60),
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), // ... etc ...
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 30),
// ... etc ...
The solution works with you blacklisting old worthless tokens and the 30 seconds gives your token breathing space with asynchronous API calls. To finish up there is the controller for authentication.
use App\Models\User;
use Log;
use JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
use App\Services\UserService;
use App\Exceptions\ServiceFaultException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* @note can use the following to access your token, and the user based on a token, once
* authenticated
*
* - $token=JWTAuth::fromUser(auth()->guard('api')->user()) returns the token
* - $user=JWTAuth::toUser($token) and
* - auth()->guard('api')->user() both return the user
*
* can set a token also, using JWTAuth::setToken($token);
*/
class AuthenticateController extends Controller
{
protected UserService $userService;
public function __construct(UserService $userService)
{
$this->userService=$userService;
}
protected function getService() : UserService
{
return $this->userService;
}
public function profile(Request $request) : JsonResponse
{
$user=auth()->guard('api')->user();
if(is_null($user))
{
return response()->json([
'status'=>false
, 'message'=>'Unauthorized'
], 401)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
$token=JWTAuth::fromUser($user);
return $this->respondWithToken($token, 200);
}
public function refresh() : JsonResponse
{
try
{
/**
* @note pass true for first parameter, this forces the token to be blacklisted,
* and true for the second parameter, to reset any claims for the new token
*
* - keep blacklisted enabled, it's recommended
* - keep blacklisted grace period to 30 seconds
*
* @see https://github.com/tymondesigns/jwt-auth/blob/2.x/docs/auth-guard.md
*/
return $this->respondWithToken(auth()->guard('api')->refresh(), 200);
}
catch(JWTException $e)
{
return response()->json([
'status'=>false
, 'message'=>'Unauthorized'
, 'errors'=>$e->getMessage()
], 401)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
}
public function logout(Request $request) : JsonResponse
{
auth()->guard('api')->logout(true);
return response()->json([
'status'=>true
, 'message'=>'Successfully logged out'
], 200)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
public function login(Request $request) : JsonResponse
{
try
{
$result=$this->getService()->login($request->only([
'email'
, 'password'
])
);
}
catch(ServiceFaultException $e)
{
return response()->json([
'status'=>false
, 'message'=>'This service is not available'
, 'errors'=>$e->getMessage()
], 500)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
if(is_array($result))
{
return response()->json([
'status'=>false
, 'message'=>'Please correct form validation errors'
, 'errors'=>$result
], 409)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
if(!$token=auth()->guard('api')->attempt(['email'=>$request->email, 'password'=>$request->password]))
{
return response()->json([
'status'=>false
, 'message'=>'Unauthorized'
], 401)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
return $this->respondWithToken($token, 200);
}
public function register(Request $request) : JsonResponse
{
try
{
$result=$this->getService()->register($request->only([
'fullname'
, 'email'
, 'password'
])
);
}
catch(ServiceFaultException $e)
{
return response()->json([
'status'=>false
, 'message'=>'This service is not available'
, 'errors'=>$e->getMessage()
], 500)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
if(is_array($result))
{
return response()->json([
'status'=>false
, 'message'=>'Please correct form validation errors'
, 'errors'=>$result
], 409)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
$token = JWTAuth::fromUser($result);
return response()->json([
'status'=>true
, 'message'=>'User successfully registered'
, 'token'=>$token
, 'token_type'=>'bearer'
, 'user'=>$result
], 201)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
protected function respondWithToken(string $token, int $code)
{
/**
* @note this expiry, use with a cookie on the client side, and to send back
* details of authenticated user in response, use
*
* - auth()->guard('api')->user()
*
* @note send with the response no cache headers, preventing any endpoint being cached
* by the browser, @see https://github.com/tymondesigns/jwt-auth/issues/983
*/
$expiry=auth()->guard('api')->factory()->getTTL() * 60;
return response()->json([
'status'=>true
, 'token'=>$token
, 'token_type'=>'bearer'
, 'expiry'=>$expiry
, 'user'=>auth()->guard('api')->user()
], $code)
->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
}
}
I use a Repository pattern which I feel doesn't need an explanation (for the purposes of this blog post). So now you can use React and Laravel together to authenticate JWT tokens for an application. Because a lot of the online content found in searches isn't, let's say, complete when it comes to using JWT tokens with React. The majority of the content is actually unhelpful, contributing very little to your learning experience. So, I decided to do something about that and give back.
Content on this site is licensed under a Creative Commons Attribution 4.0 International License. You are encouraged to link to, and share but with attribution.
Copyright ©2024 Leslie Quinn.