Autenticación JWT con React JS y Django

The impostorThe impostor
13 min read

Los programadores conocen los beneficios de todo y las ventajas y desventajas de nada. Los arquitectos necesitan entender ambos.

-Rich Hickey, Creador de lenguaje de programación Clojure

JSON Web Token (JWT) es un estándar para la transmisión de información de manera segura entre dos partes: el cliente y el servidor. Un JWT es un token encriptado que contiene información relevante del usuario, como su identidad y permisos, y puede ser verificado y procesado sin necesidad de almacenar el estado del usuario en el servidor.

En un sistema de autenticación con React JS en el frontend y Django en el backend, el uso de JWT permite manejar sesiones de usuario de manera eficiente y segura. Cuando un usuario inicia sesión, Django genera un JWT que se envía al cliente React. Este token se incluye en cada solicitud posterior, permitiendo al backend autenticar al usuario y verificar permisos sin tener que consultar constantemente la base de datos, lo que mejora el rendimiento y la escalabilidad del sistema.


Backend — Django

Primero, configuraremos el proyecto.

Crear el entorno virtual:

python -m venv <Virtual Environment Name>

Activar el entorno virtual

source <Virtual Environment Name>/bin/activate

Después de activar el entorno virtual, instalamos los paquetes como Django, Django REST framework, Django REST framework simple JWT y Django CORS headers. Los paquetes DRF se utilizan para crear la API, mientras que DRF simple JWT brinda la capacidad de generar el token JWT y Django CORS headers se utiliza para evitar problemas relacionados con CORS.

pip install Django
pip install djangorestframework
pip install djangorestframework-simplejwt
pip install django-cors-headers

Una vez hecho esto, crearemos el Proyecto.

django-admin startproject backend

Luego, crearemos la primera aplicación dentro del proyecto.

python manage.py startapp <app name>

Ahora tenemos que cambiar la configuración del proyecto. Vaya al archivo settings.py y realice las siguientes modificaciones:

from datetime import timedelta
.....
INSTALLED_APPS = [
     ...........
     # Register your app
     '<app name>.apps.<className>',
     'corsheaders',
     'rest_framework',
     'rest_framework_simplejwt.token_blacklist'
]
.....
CORS_ORIGIN_ALLOW_ALL = True
.....
MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
    ...........
]
.....
REST_FRAMEWORK = {
     'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
      ],
}
.....
SIMPLE_JWT = {
     'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
     'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
     'ROTATE_REFRESH_TOKENS': True,
     'BLACKLIST_AFTER_ROTATION': True
}

Veamos lo que hemos hecho. Primero, hemos registrado el nombre de nuestra aplicación (escribirás el nombre de tu aplicación), DRF, DRF simple JWT y CORS en INSTALLED_APPS. En segundo lugar, hemos establecido CORS_ORIGIN_ALLOW_ALL = True y hemos agregado el middleware de encabezado CORS personalizado en MIDDLEWARE para evitar el problema relacionado con CORS. Luego, hemos agregado la clase JWT de autenticación en REST_FRAMEWORK y hemos agregado las configuraciones de token SIMPLE_JWT para implementar una lógica de acceso/actualización.

Después de agregar la configuración en el archivo settings.py, crearemos la URL para el token de acceso y el token de actualización en el archivo backend/urls.py.

from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt import views as jwt_views
urlpatterns = [
     .....
     path('token/', 
          jwt_views.TokenObtainPairView.as_view(), 
          name ='token_obtain_pair'),
     path('token/refresh/', 
          jwt_views.TokenRefreshView.as_view(), 
          name ='token_refresh')

Ahora estamos casi listos para probar si funciona o no. Antes de ejecutar el proyecto, debemos aplicar la migración y crear el usuario. Ejecute este comando para aplicar la migración:

python manage.py migrate

Una vez hecho esto, crearemos el usuario mediante este comando:

python manage.py createsuperuser

Llene todos los campos y ahora iniciaremos el servidor de desarrollo usando este comando:

python manage.py runserver

Luego de ejecutar el servidor, navegaremos a http://localhost:8000/token/, veremos esta página que contiene el formulario con el campo de nombre de usuario y contraseña.

Llene este formulario con su nombre de usuario y contraseña creados, debería ver el token de acceso y actualización.

Todos sabemos que el token de acceso deja de ser válido después de un tiempo. Por lo tanto, mediante el uso del token de actualización, generaremos el nuevo token de acceso y el token de actualización también. Para generar el nuevo token de acceso con la ayuda del token de actualización, navegaremos a esta URL http://localhost:8000/token/refresh/, veremos esta página que contiene el formulario con el campo Actualizar.

Complete este formulario con el token de actualización, debería ver el nuevo token de acceso y actualización.

Ahora, crearemos un Endpoint para las pruebas. Entonces, crearemos el paquete users y dentro al archivo views.py. Escribiremos el siguiente código.

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from django.contrib.auth.models import User
from rest_framework.response import Response
from rest_framework import status


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def by_id(_, user_id):
    """Get the user by id"""
    try:
        user = User.objects.get(id=user_id)
        return Response({'username': user.username, 'email': user.email, 
            'first_name': user.first_name, 'last_name': user.last_name})
    except User.DoesNotExist:
        return Response(status=status.HTTP_204_NO_CONTENT)

Veamos lo que hemos hecho. Primero, importamos los módulos y creamos la función by_id que recibe como parámetros un objeto request y user_id. Agregamos decoradores para especificar el método GET, y establecemos que el acceso a la función debe realizarse estando autentificado.

Ahora, crearemos la URL para el Endpoint. Para crear la URL primero, tenemos que crear el archivo urls.py en el directorio de nuestra aplicación.

from django.urls import path
from .users import views as user_views


urlpatterns = [
    path('users/<int:user_id>', user_views.by_id, name ='user'),
]

En segundo lugar, tenemos que registrar la ruta del archivo urls.py en el directorio de nuestro proyecto.

urlpatterns = [
    ......
    path('', include('<app name>.urls')),
]

Ahora, usaremos el plugin Rest Client en VSC para acceder a esta url http://localhost:8000/users/1, siguiendo el estándar RFC 2616 que incluye el método de la solicitud, encabezados y cuerpo. Crearemos un nuevo archivo users.http, y agregaremos:

### Get user without token
GET http://localhost:8000/users/1

Obtendremos un mensaje de credenciales de autenticación no proporcionadas porque en la función by_id hemos configurado la clase de permiso en IsAuthenticated, lo que significa que esta página es solo para usuarios autenticados.

Entonces, tenemos que proporcionar el token de acceso en la autorización para obtener el resultado. Ahora, nuevamente, crearemos la solicitud de obtención con el token de acceso.

Ahora, tenemos que agregar la funcionalidad de cerrar sesión ya que el token de acceso es válido por un tiempo.

Mediante el uso del token de actualización (refresh_token), generaremos el nuevo token de acceso. Pero, ¿qué sucede cuando el usuario desea cerrar sesión? El cliente puede olvidar ambos tokens. Por ejemplo, eliminamos tanto el token de acceso como el de actualización de localStorage. Pero, si el token de actualización es robado, otro cliente usa este token y lo usa. Para evitar esto, crearemos la API de cierre de sesión, al usarla podemos invalidar el token de actualización.

Para crear la API de cierre de sesión, agregaremos el modulo authentication y dentro el archivo views.py, agregaremos:

 from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework import status


class LogoutView(APIView):
     permission_classes = (IsAuthenticated,)

     def post(self, request):          
          try:
               refresh_token = request.data["refresh_token"]
               token = RefreshToken(refresh_token)
               token.blacklist()
               return Response(status=status.HTTP_205_RESET_CONTENT)
          except Exception as e:
               return Response(status=status.HTTP_400_BAD_REQUEST)

Después de crear LogoutView, tenemos que crear la URL para LogoutView en nuestro archivo urls.py en el directorio de la aplicación.

from .authentication import views as authn_views

urlpatterns = [
    ...
    path('logout/', authn_views.LogoutView.as_view(), name ='logout')
]

Al usar la URL http://localhost:8000/logout/, podemos invalidar el token de actualización y agregarlo a la lista negra. Usando Rest Client, podemos acceder a esta URL con una solicitud POST y pasar el token de acceso en la autorización y el token de actualización en el cuerpo.


Hemos llegado al final de la primera parte, la cual como habrás notado está basado principalmente en el escrito de Ronak Chitlangya.

Frontend — React JS

En esta segunda parte, para la conexión del frontend (ReactJS) con Django utilizaremos React Auth Kit. Ésta es una biblioteca de gestión de estado de autenticación liviana para proyectos basados ​​en React JS. Te facilitará la gestión de los datos de sesión como token de acceso, token de actualización y información de estado del usuario. Además ofrece funcionalidades de redireccionamiento en caso de no estar autenticado (redirigirse a la página de inicio de sesión).

Abre el terminal y ejecuta el comando siguiente para crear un nuevo proyecto React con Vite:

npm init vite@latest frontend -- --template react-ts

Instalar los siguientes paquetes

npm i --save bootstrap react-bootstrap axios \
    react-router-dom react-auth-kit @auth-kit/react-router jwt-decode
  • bootstrap. Un marco front-end elegante, intuitivo y potente para un desarrollo web más rápido y sencillo.

  • react-bootstrap. Compatible con versiones de bootstrap.

  • axios. Cliente HTTP basado en promesas para el navegador y node.js

  • react-router-dom. Enlaces para usar React Router en aplicaciones web.

  • react-auth-kit. Es una biblioteca de gestión de estado de autenticación liviana para proyectos basados ​​en React JS

  • @auth-kit/react-router. Contiene funciones útiles para administrar rutas privadas usando react-router* o react-router-dom.

  • jwt-decode. Biblioteca de navegador que ayuda a decodificar tokens JWT codificados en Base64Url

Después de configurar el proyecto, ejecute este comando para ejecutar la aplicación React:

npm run dev

Abra http://localhost:5173/ para ejecutar la aplicación React.

Después de ejecutar el servidor exitosamente, avanzamos para diseñar la aplicación.

Comenzaremos eliminando el contenido de los archivos src/App.css y index.css. Vamos a utilizar los estilos de bootstrap, para lo cual se agrega en el archivo main.tsx lo siguiente:

import 'bootstrap/dist/css/bootstrap.min.css';

Para la conexión con el backend (el cual está desplegado localmente en el puerto 8000) configuraremos el proxy en el archivo vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})

Esto evitará errores de tipo Cross-Origin Resource Sharing cuando hagamos peticiones al backend usando axios.

Crearemos el archivo security/users.tsx en el cual llamaremos a los diferentes Endpoints para la creación del token de aceso, actualización del token y cierre de sesión.

import axios from "axios";

export function login(data: any): Promise<any> {
  return axios.post("/api/token/", data);
}

export function refreshToken(refresh: string): Promise<any> {
  return axios.post("/api/token/refresh/", { refresh: refresh }, {});
}

export function logout(access_token: string | null, refresh_token: string | null): Promise<any> {
  return axios.post("/api/logout/", { refresh_token: refresh_token },
    {
      headers: { Authorization: access_token },
    }
  );
}

Se ha agregado el archivo decode-token.tsx, y dentro de este la función para decodificar el token JWT, para obtener el campo user_id que viene encapsulado. Este puede ser usado para obtener más detalles del usuario.

import { jwtDecode } from 'jwt-decode';

export function getUserIdFromDecodeToken(access_token: string){
  const decoded = jwtDecode(access_token) as { [key: string]: string };
  return decoded.user_id;
}

Siguiendo, escribiremos el código para la barra de navegación en el archivo components/app-navbar.tsx.

import useAuthUser from 'react-auth-kit/hooks/useAuthUser';
import { Container, Navbar } from 'react-bootstrap'
// import AppNavLogout from './nav-logout';
import { IUserData } from '../authentication/models';

export default function AppNavBar() {
  const auth = useAuthUser<IUserData>();
  return (
    <Navbar className="bg-body-tertiary">
      <Container>
        <Navbar.Brand href="/">Autenticación con JWT</Navbar.Brand>
        <Navbar.Toggle />
        <Navbar.Collapse className="justify-content-end">
          <Navbar.Text>
            Inició sesión como: 
            <b>{auth?.username} - {auth?.id}</b>
          </Navbar.Text>
        </Navbar.Collapse>
        {/* <AppNavLogout /> */}
      </Container>
  </Navbar>
  )
}

Se usa useAuthUser para obtener información de estado del usuario. Se tiene una barra de navegación en donde se muestra el nombre y el id del usuario que ha iniciado sesión. La estructura de información del usuario se ha definido por la interfaz IUserData en el archivo authentication/models.tsx.

export interface IUserData {
    username: string;
    id: string;
    refreshToken: string;
  };

Se agrega también el campo refreshToken, el cual se usará para dar de baja el token.

Crearemos la página Home en el archivo pages/home.tsx:

import AppNavBar from '../components/app-navbar'

export default function Home() {
  return (
    <AppNavBar />
  )
}

Así también crearemos una página para el Inicio de sesión en el archivo pages/login.tsx

import { useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { login } from "../security/users";
import useSignIn from "react-auth-kit/hooks/useSignIn";
import { getUserIdFromDecodeToken } from "../utils/decode-token";

export default function Login() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const signIn = useSignIn();

  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = { username: username, password: password };

    login(data).then((res) => {
      const user_id = getUserIdFromDecodeToken(res.data.access);

      if (res.status === 200) {
        if (
          signIn({
            auth: { token: res.data.access, type: "Bearer"},
            refresh: res.data.refresh,
            userState: { username: username, id: user_id, refreshToken: res.data.refresh},
          })
        ) {
          window.location.href = "/";
        } else {
          //Throw error
        }
      }
    });
  };
  return (
    <>
      <Container>
        <h1 className="text-center">Iniciar sesión</h1>
        <Form onSubmit={onSubmit}>
          <Form.Group className="mb-3" controlId="exampleForm.ControlInput1">
            <Form.Label>Username</Form.Label>
            <Form.Control name="username" type="username" placeholder="usuario" value={username} onChange={(e) => setUsername(e.target.value)} />
          </Form.Group>
          <Form.Group className="mb-3" controlId="exampleForm.ControlInput1">
            <Form.Label>Contraseña</Form.Label>
            <Form.Control name="password" type="password" placeholder="********" value={password} onChange={(e) => setPassword(e.target.value)} />
          </Form.Group>
          <Button variant="primary" type="submit">
            Iniciar sesión
          </Button>
        </Form>
      </Container>
    </>
  );
}

El código anterior agrega el formulario de inicio de sesión. Usa la función signIn para facilitar la implementación de procedimientos de inicio de sesión. En el parámetro userState se agrega información del estado del usuario como nombre, id. Este userState es el mismo objeto que obtuvimos usando useAuthUser para mostrar la información del usuario en el navbar.

Ahora tendremos que agregar las rutas en el archivo App.tsx

import { BrowserRouter, Route, Routes } from "react-router-dom"
import Login from "./pages/login"
import Home from "./pages/home"
import AuthOutlet from "@auth-kit/react-router/AuthOutlet"
import AuthProvider from "react-auth-kit"
import { store } from "./authentication/store"


function App() {
  return (
    <>
      <AuthProvider store={store}>
        <BrowserRouter>
          <Routes>
            <Route path="/login" element={<Login />} />
            <Route element={<AuthOutlet fallbackPath='/login' />}>
              <Route path="/" element={<Home />} index />
            </Route>
          </Routes>
        </BrowserRouter>
      </AuthProvider>
    </>
  )
}

export default App

Para gestionar el estado de autenticación y de rutas usaremos la librería React Auth Kit. Para lo cual necesitamos definir AuthProvider y establecer el almacenamiento que contendrá los datos de autenticación de nuestra aplicación. Creamos el archivo authentication/store.tsx. Dentro del proveedor también definimos AuthOutlet que nos permite identificar dentro las rutas que serán privadas, usando fallbackPath identificamos la ruta a la cual redireccionarse en caso de no estar autenticado. La ruta /login es pública, por lo que la hemos dejado fuera.

import createStore from 'react-auth-kit/createStore';
import { refresh } from './refresh';

export const store = createStore({
  authName:'_auth',
  authType:'cookie',
  cookieDomain: window.location.hostname,
  cookieSecure: window.location.protocol === 'https:',
  refresh: refresh
});

Mas información de los parámetros establecidos en createStore.

Ya que los token JWT son temporales es necesario gestionar la actualización del token. Para lo cual crearemos el archivo refresh.tsx

import createRefresh from 'react-auth-kit/createRefresh';
import { refreshToken } from '../security/users';
import { getUserIdFromDecodeToken } from '../utils/decode-token';
import { IUserData } from './models';

export const refresh = createRefresh({
  interval: 10,
  refreshApiCallback: refreshApiCallback
})

async function refreshApiCallback(param: {authToken?: string; refreshToken?: string; authUserState: IUserData}): Promise<any> {
  try {
    console.log('refreshing token', param)
    if (!param.refreshToken) {
      throw new Error('Refresh token is missing');
    }
    const response = await refreshToken(param.refreshToken)
    const user_id = getUserIdFromDecodeToken(response.data.access);
    return {
      isSuccess: true,
      newAuthToken: response.data.access,
      newAuthTokenType: "Bearer",
      newRefreshToken: response.data.refresh,
      newAuthUserState: {
        username: param.authUserState.username,
        id: user_id,
        refreshToken: response.data.refresh
      }
    }
  }catch(error){
    console.error(error)
    return {
      isSuccess: false
    }
  }
}

Repasemos lo que hemos realizado, se ha declarado un intervalo y la función callback que será llamada para la actualización del token. Esta función recibe parámetros como refreshToken, el cual usa para generar nuevas credenciales de acceso. En el objeto de retorno se declaran los datos de la sesión actualizados como el nuevo token de acceso, el tipo del token (Bearer), el token usado para actualizar, y un objeto de estado del usuario que puede contener información extra.

Para gestionar el cierre de sesión crearemos el archivo components/nav-logout.tsx. No olvides descomentar este componente en el archivo.components/app-navbar.tsx.

import useAuthUser from 'react-auth-kit/hooks/useAuthUser';
import { Nav } from 'react-bootstrap'
import { IUserData } from '../authentication/models';
import useAuthHeader from 'react-auth-kit/hooks/useAuthHeader';
import { logout } from '../security/users';
import useSignOut from 'react-auth-kit/hooks/useSignOut';

export default function AppNavLogout() {

  const auth = useAuthUser<IUserData>();
  const authHeader = useAuthHeader()

  const signOut = useSignOut()
  const handleSignOut = async () => {
    if(auth){
      await logout(authHeader, auth.refreshToken)
    }
    signOut()
    window.location.href = '/login'
  }

  return (
    <Nav className="me-auto">
        <Nav.Link onClick={handleSignOut} className='primary'>| Cerrar sesión</Nav.Link>
    </Nav>
  )
}

Hemos definido un componente el cual permitirá cerrar la sesión. Al hacer clic este llamará a la función handleSignOut, en donde se usa logout para dar de baja al refresh_token. Se llama a la función signOut de react-auth-kit para borrar de la memoria el estado de la sesión. Finalmente redirecciona a la página /login.

Estamos en la recta final, probemos el flujo. En el navegador vayamos a http://localhost:5173/login e ingresemos el usuario y contraseña.

Al iniciar sesión se iniciará a la página Home, y se mostrará el nombre de usuario y su id.

Al cerrar sesión sesión dará de baja el token y borrará la sesión de la memoria local.

Intentemos redirigir hacia Home http://localhost:5173/, y notará como automáticamente se redirigue hacia http://localhost:5173/login.

Referencias

0
Subscribe to my newsletter

Read articles from The impostor directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

The impostor
The impostor