Introducción
Cuando trabajamos creando aplicaciones SPA con React, el router por excelencia es React-Router-DOM, es fácil de usar, potente y versátil.
Uno de los escenarios que nos podemos encontrar en nuestra aplicación es el de manejar rutas de dos tipos:
Rutas públicas: es decir páginas en las que el usuario no tiene porque haber introducido su usuario y clave (por ejemplo la propia página de login).
Rutas privadas: para poder acceder a estas rutas el usuario ha tenido que hacer login con éxito previamente.
En esta serie de posts vamos a ver cómo manejar esto aplicando una solución simple que nos evite estar copiando y pegando código en cada página.
Nota: los ejemplos en este post están desarrollados usando TypeScript, en la sección de recursos encontrarás la versión ES6 del mismo.
Importante: si está trabajando con la versión 6 de React, hay cambios de calado, en está video puedes ver como implementar un interceptor para esa versión: https://www.lemoncode.tv/curso/react-router-v6/leccion/interceptores
TL;DR;
En este post resolvemos dos problemas:
Donde guardar la información de sesión usuario, para ello utilizaremos el contexto de React.
Cómo redirigir a la ventana de login si el usuario intenta acceder a una página que necesita previa autenticación, para ello crearemos un wrapper para nuestras rutas.
Aquí tienes un ejemplo que implementa la solución que vamos a detallar en este post:
TypeScript:
ES6:
Agenda
Manos a la obra
Punto de partida
Almacenando información de login.
Interceptando la navegación.
Siguientes pasos
Recursos
Manos a la obra
Punto de partida
Vamos a partir de una aplicación que tiene tres páginas:
Página de login: puedes acceder a ella sin estar autenticado.
Página de listado: tienes que estar autenticado para poder acceder a ella.
Página de detalle: tienes que estar autenticado para poder acceder a ella.
Este punto de partida lo tenéis disponible:
En este CodeSandbox: https://codesandbox.io/s/route-interceptor-start-ts-71w06
Almacenando la información de login
Cuando un usuario navega a la página de login, introduce su usuario y clave, la aplicación lanza una petición contra la API de servidor, y comprueba que todo es correcto y envía respuesta a nuestra aplicación web, hasta aquí todo bien, pero cuando navego a otra página ¿Cómo puede saber dicha página si el usuario se ha autenticado correctamente?
Lo ideal para almacenar esta información y que este disponible desde cualquier punto de la aplicación es usar el contexto de React, de esta manera:
Podemos elegir a qué nivel exponemos la información.
En el momento que hay algún cambio, automáticamente, se actualiza en los componentes que la estén consumiendo.
Vamos a definir un context en el que vamos a almacenar el nombre del usuario autenticado:
./src/core/authcontext.tsx
import React from "react"; interface Context { userInfo: string; setUserInfo: (user: string) => void; } export const AuthContext = React.createContext<Context>({ userInfo: "", setUserInfo: (user: string) => console.log("Did you forgot to add AuthContext on top of your app?"), }); export const AuthProvider: React.FunctionComponent = (props) => { const { children } = props; const [userInfo, setUserInfo] = React.useState<string>(""); return ( <AuthContext.Provider value={{ userInfo, setUserInfo }}> {children} </AuthContext.Provider> ); };
En nuestra aplicación supondremos que si el nombre del usuario es una cadena vacía no esta autenticado, en una aplicación real en vez de un string lo suyo sería crear un objeto de sesión en el que almacenáramos el id del usuario, su rol, etc...
Creamos un barrel en core para que sea más cómodo de importar desde otros puntos de la aplicación:
./src/core/index.ts
export * from "./authcontext";
Ese proveedor de contexto lo vamos a poner a nivel de raíz de nuestra aplicación (AuthProvider)
./src/app.tsx
import React from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import { LoginPage, ListPage, DetailPage } from "./pages"; import {AuthProvider} from "./core"; export const App = () => { return ( <AuthProvider> <Router> <Switch> <Route exact path="/"> <LoginPage /> </Route> <Route path="/list"> <ListPage /> </Route> <Route path="/detail"> <DetailPage /> </Route> </Switch> </Router> </AuthProvider> ); }; export default App;
En la página de login, vamos asignaremos el userId del contexto cuando el usuario se valida satisfactoriamente contra el servidor:
- Nos traemos el import de core.
./src/pages/login.tsx
import { AuthContext } from "../core";
- Con _useContext_ nos traemos del context la función para asignar el usuario a nuestro contexto:
./src/pages/login.tsx
export const LoginPage: React.FC = () => { const history = useHistory(); const { setUserInfo } = React.useContext(AuthContext);
- En la función en la que navegamos a la página de listado si el login es correcto, asignamos al contexto el nombre de usuario logado (setUserInfo):
./src/pages/login.tsx
const handleNavigation = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); // Just a mock / dummy password check if (username === "admin" && password === "test") { setUserInfo(username); // That's the important part history.push("/list");
Interceptando la navegación
Ya que tenemos el nombre del usuario autenticado accesible desde cualquier punto de la aplicación, vamos a hacer un wrapper sobre el componente Router de React-Router ¿ Qué narices es eso de un wrapper?
Tu tienes el componente Route de react-router, te va casí perfecto pero te hace falta añadir una comprobación: ¿Está el usuario autenticado?
Te creas otro componente, que puedes llamar AuthRouteComponent y en ese componente añades la comprobación de que existe usuario (si no rediriges a login).
En el return pintas el componente de Route de _react-router_.
Es decir estás "envolviendo el Router en papel de regalo" ;).
Veamos como queda esto en código:
/src/core/authroute.tsx
import React from "react"; import { Route, RouteProps, useHistory } from "react-router-dom"; import { AuthContext } from "../core"; export const AuthRouteComponent: React.FunctionComponent<RouteProps> = ( props ) => { const { userInfo } = React.useContext(AuthContext); const history = useHistory(); React.useEffect(() => { if (!userInfo) { history.push("/"); } }, [props?.location?.pathname]); return <Route {...props} />; };
Añadimos este componente al index de nuestra carpeta core:
./src/core/index.ts
export * from "./authcontext"; export * from "./authroute";
¿Qué hacemos aquí?
Este componente va a sustituir a Route en el switch de React Router en las rutas que necesiten que el usuario este autenticado.
Cuando se cambia de ruta y se vaya a activar esta página se ejecutara la función que vemos en el useEffect.
Comprobamos si el usuario se ha autenticado previamente, y si no redirigimos a la página de login.
Ya sólo tenemos que sustituir las rutas que necesitan que el usuario este autenticado, en vez de usar _Route_ usamos el wrapper que hemos creado previamente _AuthRouteComponent_
./src/app.tsx
import React from "react"; import "./App.css"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import { LoginPage, ListPage, DetailPage } from "./pages"; import { AuthProvider, AuthRouteComponent } from "./core"; export const App = () => { return ( <AuthProvider> <Router> <Switch> <Route exact path="/"> <LoginPage /> </Route> <AuthRouteComponent path="/list"> <ListPage /> </AuthRouteComponent> <AuthRouteComponent path="/detail"> <DetailPage /> </AuthRouteComponent> </Switch> </Router> </AuthProvider> ); }; export default App;
Para ver como funciona, puedes probar el "happy path":
Vas a la página de login.
Te logas satisfactoriamente.
Puedes navegar a la páginas protegidas sin problemas.
Vamos ahora por el caso "malo":
Voy a la página de login.
No me logo.
Pego una ruta protegida.
Me redirige a la página de login.
Siguientes Pasos
Ahora que hemos resuelto este caso, nos encontramos con nuevos desafíos:
¿Qué pasa si el usuario tiene su token de sesión en una cookie o en storage? Es decir que si pega el enlace en un navegador ya esta autenticado, aunque no haya pasado por el login, no deberíamos de redirigir a la página de login en ese caso.
¿Qué pasa si el usuario se autentica, está trabajando con la aplicación y le expira la sesión? Aquí tendríamos que detectar la respuesta de 401 y redirigir a la página de login.
¿Qué pasa si el usuario pega un enlace a una página privada y no está autenticado? ... Por un lado redirigirlo a la página de login, pero recordar la última página a la que fue para una vez validado su usuario y clave redirigirle a esa página de destino inicial.
¿Cómo podemos manejar roles?
Si os ha gustado este post iremos avanzando en la serie resolviendo estos problemas.
Recursos
El ejemplo básico implementado en TypeScript:
Codesandbox: https://codesandbox.io/s/route-interceptor-ts-6nwi1
GitHub: https://github.com/Lemoncode/router-auth-examples/tree/main/00-typescript
El ejemplo básico en ES6:
Codesandbox: https://codesandbox.io/s/route-interceptor-es6-0p88j
Github: https://github.com/Lemoncode/router-auth-examples/tree/main/01-es6
Si queréis ver el mismo proyecto pero con Material UI y más elaborado:
¿Front, Devops o Back?
Si tienes ganas de ponerte al día ¿Te apuntas a alguno de nuestros Másters Online o Bootcamps?
- Máster Front End Online Lemoncode
- Bootcamp Backend Online Lemoncode
- Bootcamp Devops Online Lemoncode