Autenticación, autorización y seguridad aplicada en React Native

Capítulo 8

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

Conceptos clave: autenticación vs autorización

Autenticación responde a “¿quién eres?” (login, sesión, tokens). Autorización responde a “¿qué puedes hacer?” (permisos, roles, acceso a pantallas/acciones). En una app React Native profesional, ambos se implementan de forma consistente: la UI consulta el estado de sesión y permisos, el dominio decide reglas, y los servicios hablan con el backend usando encabezados de autorización.

Modelo típico con tokens

  • Access token: corto (minutos). Se envía en cada request: Authorization: Bearer <token>.
  • Refresh token: más largo (días/semanas). Se usa para obtener un nuevo access token cuando expira.
  • Expiración: el backend devuelve 401 (no autenticado) o 403 (prohibido) según el caso.

Arquitectura recomendada: UI, dominio y servicios

Para mantener claridad y seguridad, separa responsabilidades:

  • UI (pantallas/componentes): muestra formularios, estados de carga/error, y navega. No conoce detalles de tokens.
  • Dominio (casos de uso): orquesta login/logout/refresh, decide qué hacer ante 401/403, y expone un estado de sesión.
  • Servicios (API + almacenamiento seguro): implementan llamadas HTTP, interceptores, y guardan/leen tokens de forma segura.

Una estructura posible:

src/  domain/    auth/      types.ts      authStore.ts      useCases.ts  services/    http/      client.ts      interceptors.ts    auth/      tokenStorage.ts      authApi.ts  ui/    navigation/      guards.tsx

Almacenamiento seguro de tokens y credenciales

Evita guardar tokens en AsyncStorage. Para tokens, usa almacenamiento seguro del sistema (Keychain en iOS, Keystore en Android). Opciones comunes: react-native-keychain o expo-secure-store (si usas Expo). El objetivo es reducir exposición ante backups, inspección de archivos o apps maliciosas.

TokenStorage (servicio)

Define una interfaz para no acoplarte a una librería concreta:

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

// src/domain/auth/types.ts
export type SessionTokens = {
  accessToken: string;
  refreshToken: string;
  expiresAt: number; // epoch ms
};

export interface TokenStorage {
  get(): Promise<SessionTokens | null>;
  set(tokens: SessionTokens): Promise<void>;
  clear(): Promise<void>;
}

Implementación ejemplo (pseudo) con un storage seguro:

// src/services/auth/tokenStorage.ts
import * as SecureStore from 'expo-secure-store';
import { SessionTokens, TokenStorage } from '../../domain/auth/types';

const KEY = 'session_tokens';

export const tokenStorage: TokenStorage = {
  async get() {
    const raw = await SecureStore.getItemAsync(KEY);
    return raw ? (JSON.parse(raw) as SessionTokens) : null;
  },
  async set(tokens) {
    await SecureStore.setItemAsync(KEY, JSON.stringify(tokens));
  },
  async clear() {
    await SecureStore.deleteItemAsync(KEY);
  },
};

Medidas adicionales para reducir exposición

  • No persistir datos sensibles innecesarios: evita guardar email, roles o perfil completo si no es imprescindible.
  • Minimiza logs: no imprimas tokens ni respuestas completas en consola; sanitiza errores.
  • Protege capturas en pantallas sensibles (según plataforma) y evita mostrar tokens en UI.
  • Evita “remember me” inseguro: si lo implementas, que sea mediante refresh token y revocación en backend, no guardando contraseña.

Servicio de autenticación: login, logout y refresh

API de autenticación (servicio)

// src/services/auth/authApi.ts
import { http } from '../http/client';
import { SessionTokens } from '../../domain/auth/types';

export type LoginDTO = { email: string; password: string };

export const authApi = {
  async login(dto: LoginDTO): Promise<SessionTokens> {
    const { data } = await http.post('/auth/login', dto);
    return data;
  },
  async refresh(refreshToken: string): Promise<SessionTokens> {
    const { data } = await http.post('/auth/refresh', { refreshToken });
    return data;
  },
  async logout(refreshToken: string): Promise<void> {
    await http.post('/auth/logout', { refreshToken });
  },
};

Casos de uso (dominio)

El dominio decide cómo se guarda la sesión y qué ocurre al cerrar sesión o expirar tokens:

// src/domain/auth/useCases.ts
import { authApi } from '../../services/auth/authApi';
import { tokenStorage } from '../../services/auth/tokenStorage';
import { SessionTokens } from './types';

export async function login(email: string, password: string) {
  const tokens = await authApi.login({ email, password });
  await tokenStorage.set(tokens);
  return tokens;
}

export async function logout() {
  const current = await tokenStorage.get();
  if (current?.refreshToken) {
    try { await authApi.logout(current.refreshToken); } catch {}
  }
  await tokenStorage.clear();
}

export async function refreshSession(): Promise<SessionTokens | null> {
  const current = await tokenStorage.get();
  if (!current?.refreshToken) return null;
  const tokens = await authApi.refresh(current.refreshToken);
  await tokenStorage.set(tokens);
  return tokens;
}

Encabezados de autorización y cliente HTTP con interceptores

Centraliza la lógica en un cliente HTTP. Así evitas repetir Authorization en cada request y puedes manejar expiración de forma uniforme.

Cliente HTTP (Axios como ejemplo)

// src/services/http/client.ts
import axios from 'axios';

export const http = axios.create({
  baseURL: 'https://api.tuapp.com',
  timeout: 15000,
});

Interceptor de request: adjuntar access token

// src/services/http/interceptors.ts
import { http } from './client';
import { tokenStorage } from '../auth/tokenStorage';

export function attachAuthInterceptors() {
  http.interceptors.request.use(async (config) => {
    const session = await tokenStorage.get();
    if (session?.accessToken) {
      config.headers = config.headers ?? {};
      config.headers.Authorization = `Bearer ${session.accessToken}`;
    }
    return config;
  });
}

Interceptor de response: refresco automático ante 401

Patrón recomendado: si una request falla con 401 por expiración, intenta refrescar tokens una sola vez, reintenta la request original y, si falla el refresh, fuerza logout. También debes evitar múltiples refresh simultáneos (cola/lock).

// src/services/http/interceptors.ts
import { http } from './client';
import { tokenStorage } from '../auth/tokenStorage';
import { authApi } from '../auth/authApi';

let refreshPromise: Promise<string | null> | null = null;

async function getNewAccessToken(): Promise<string | null> {
  if (!refreshPromise) {
    refreshPromise = (async () => {
      const session = await tokenStorage.get();
      if (!session?.refreshToken) return null;
      const newTokens = await authApi.refresh(session.refreshToken);
      await tokenStorage.set(newTokens);
      return newTokens.accessToken;
    })().finally(() => {
      refreshPromise = null;
    });
  }
  return refreshPromise;
}

export function attachAuthInterceptors() {
  http.interceptors.response.use(
    (res) => res,
    async (error) => {
      const status = error?.response?.status;
      const original = error.config;

      if (status === 401 && original && !original._retry) {
        original._retry = true;
        const newAccess = await getNewAccessToken();
        if (newAccess) {
          original.headers = original.headers ?? {};
          original.headers.Authorization = `Bearer ${newAccess}`;
          return http.request(original);
        }
      }

      return Promise.reject(error);
    }
  );
}

Notas prácticas:

  • La marca _retry evita bucles infinitos.
  • El refreshPromise evita que 5 requests simultáneas disparen 5 refresh.
  • Si el refresh falla (refresh token revocado/expirado), el dominio debe limpiar sesión y redirigir a login.

Manejo global de errores 401/403

Conviene traducir errores HTTP a eventos de sesión y mensajes de UI consistentes. Reglas típicas:

  • 401: no autenticado o sesión expirada. Intenta refresh; si no se puede, logout y redirige a flujo público.
  • 403: autenticado pero sin permisos. No intentes refresh; muestra pantalla/alerta de “sin permisos” y registra el evento.

Normalización de errores (servicio)

// src/services/http/errors.ts
export type AppError = {
  kind: 'Network' | 'Unauthorized' | 'Forbidden' | 'Server' | 'Unknown';
  message: string;
  status?: number;
};

export function mapHttpError(err: any): AppError {
  const status = err?.response?.status;
  if (!status) return { kind: 'Network', message: 'Error de red o timeout' };
  if (status === 401) return { kind: 'Unauthorized', message: 'Sesión no válida', status };
  if (status === 403) return { kind: 'Forbidden', message: 'No tienes permisos', status };
  if (status >= 500) return { kind: 'Server', message: 'Error del servidor', status };
  return { kind: 'Unknown', message: 'Error inesperado', status };
}

Reacción desde el dominio

En vez de que cada pantalla decida qué hacer con un 401, centraliza: cuando el interceptor no logra refrescar, dispara un evento de “sesión expirada” (por ejemplo, mediante un store global o un event emitter) y ejecuta logout().

// idea: un canal simple de eventos
export const authEvents = {
  onSessionExpired: (cb: () => void) => { /* subscribe */ },
  emitSessionExpired: () => { /* emit */ },
};

En el interceptor, si no hay refresh posible:

import { authEvents } from '../../domain/auth/authEvents';

// ... dentro del response interceptor
if (status === 401 && !newAccess) {
  authEvents.emitSessionExpired();
}

Protección de rutas (route guards) en navegación

La protección de rutas consiste en impedir que el usuario llegue a pantallas privadas si no está autenticado, y en algunos casos, si no tiene el rol adecuado. La forma más limpia es tener un “árbol” de navegación público y otro privado, y elegir cuál renderizar según el estado de sesión.

Estado de sesión mínimo

En el store de auth, guarda solo lo necesario para decidir navegación:

  • isAuthenticated
  • user (id, roles) si aplica
  • status: idle | loading | authenticated | anonymous

Guard de autenticación

// src/ui/navigation/guards.tsx
import React from 'react';

type Props = {
  isAuthenticated: boolean;
  PublicNavigator: React.ReactElement;
  PrivateNavigator: React.ReactElement;
};

export function AuthGate({ isAuthenticated, PublicNavigator, PrivateNavigator }: Props) {
  return isAuthenticated ? PrivateNavigator : PublicNavigator;
}

En el arranque de la app, intenta restaurar sesión leyendo tokens del storage seguro y, si corresponde, refrescando:

  • Lee tokens.
  • Si expiresAt está cerca o expiró, intenta refresh.
  • Si falla, limpia y queda en flujo público.

Control por roles y permisos

Roles (por ejemplo: admin, manager, user) deben venir del backend firmados dentro del token (JWT) o en un endpoint de perfil. En el cliente, úsalos para:

  • Ocultar/mostrar opciones de UI (conveniencia).
  • Bloquear navegación a pantallas restringidas (seguridad de UX).
  • Evitar ejecutar acciones no permitidas (validación previa).

Importante: la seguridad real se aplica en backend; el cliente solo reduce intentos y mejora experiencia.

Helpers de autorización (dominio)

// src/domain/auth/permissions.ts
export type Role = 'admin' | 'manager' | 'user';

export function hasRole(userRoles: Role[] | undefined, required: Role[]) {
  if (!userRoles) return false;
  return required.some((r) => userRoles.includes(r));
}

Guard por rol para pantallas

// src/ui/navigation/RoleGate.tsx
import React from 'react';
import { hasRole, Role } from '../../domain/auth/permissions';

type Props = {
  roles?: Role[];
  required: Role[];
  children: React.ReactNode;
  fallback?: React.ReactNode;
};

export function RoleGate({ roles, required, children, fallback = null }: Props) {
  if (!hasRole(roles, required)) return <>{fallback}</>;
  return <>{children}</>;
}

Uso típico: envolver botones o secciones sensibles, y en navegación redirigir a una pantalla “Sin permisos” si el rol no cumple.

Guía práctica paso a paso: implementar login/logout, refresh y protección

Paso 1: definir contrato de sesión y storage seguro

  • Crea SessionTokens con accessToken, refreshToken, expiresAt.
  • Implementa TokenStorage con SecureStore/Keychain.

Paso 2: crear Auth API y casos de uso

  • authApi.login, authApi.refresh, authApi.logout.
  • Casos de uso: login(), refreshSession(), logout().

Paso 3: configurar cliente HTTP e interceptores

  • Request interceptor: adjunta Authorization.
  • Response interceptor: si 401, intenta refresh con lock; reintenta request; si no, emite “session expired”.

Paso 4: restauración de sesión al iniciar

  • En el bootstrap de la app, lee tokens.
  • Si no hay tokens: estado anónimo.
  • Si hay tokens y están vigentes: estado autenticado.
  • Si expiraron: intenta refresh; si falla: limpia y anónimo.

Paso 5: navegación protegida

  • Renderiza árbol público o privado con AuthGate.
  • Para pantallas con rol: usa RoleGate o un guard en la definición de rutas.

Paso 6: manejo consistente de 403

  • Cuando recibas 403, muestra fallback “Sin permisos” y evita reintentos.
  • Registra el evento (sin datos sensibles) para diagnóstico.

Manejo de expiración: estrategias prácticas

Refresh proactivo vs reactivo

  • Reactivo: refrescar cuando llega un 401 (vía interceptor). Simple y efectivo.
  • Proactivo: refrescar antes de expirar (por ejemplo, si faltan < 60s). Reduce fallos visibles en UI.

Ejemplo de chequeo proactivo:

const SKEW_MS = 60_000;
const session = await tokenStorage.get();
const needsRefresh = session && Date.now() + SKEW_MS >= session.expiresAt;

Evitar condiciones de carrera

  • Usa un lock/cola (refreshPromise) para que solo haya un refresh activo.
  • Marca requests reintentadas con _retry.
  • Si el refresh falla, limpia tokens una sola vez y notifica al resto.

Buenas prácticas de seguridad aplicadas

Reducir superficie de ataque en el cliente

  • Principio de mínimo privilegio: no asumas permisos; consulta roles/permisos y limita UI.
  • Sanitiza errores: muestra mensajes genéricos; guarda detalles solo en herramientas internas y sin tokens.
  • Evita exponer PII: en pantallas de perfil, muestra lo necesario; en cachés, cifra o evita persistencia.
  • Pinning/SSL (si aplica): considera certificate pinning en apps de alto riesgo (banca/health). Evalúa impacto en mantenimiento.
  • Protección ante debugging: en builds de producción, deshabilita logs y herramientas de debug; revisa configuración de minificación.

Checklist rápido de implementación

ÁreaQué verificar
TokensAccess corto, refresh largo, rotación y revocación soportadas por backend
StorageTokens en SecureStore/Keychain/Keystore, no en AsyncStorage
HTTPAuthorization centralizado, refresh con lock, reintentos controlados
Errores401 refresca o expira sesión; 403 bloquea por permisos
NavegaciónÁrbol público/privado, guards por rol
Datos sensiblesNo logs de tokens, no persistir PII innecesaria

Ahora responde el ejercicio sobre el contenido:

Una app recibe un 401 al llamar a la API porque el access token expiró. ¿Cuál es el flujo recomendado para manejarlo de forma segura y consistente?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Un 401 suele indicar sesión no válida o expiración. La práctica recomendada es refrescar el token una sola vez (con lock para evitar múltiples refresh), reintentar la request y, si no se puede refrescar, limpiar tokens y forzar logout.

Siguiente capítulo

Manejo de multimedia, permisos y funcionalidades del dispositivo en React Native

Arrow Right Icon
Portada de libro electrónico gratuitaReact Native desde Cero a App Profesional
67%

React Native desde Cero a App Profesional

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.