En nuestro mundo conectado, la seguridad de las aplicaciones web es primordial. Para ello es fundamental cómo gestionamos y actualizamos los tokens de sesión. En este artículo, profundizaremos en la autenticación JWT, el dúo de tokens de acceso y tokens de actualización, y los matices de la rotación de tokens. Al final del viaje, habremos tocado las implementaciones de backend (NestJS) y frontend (Angular).

El imperativo de actualizar los tokens


Los tokens de acceso, con su corta vida útil, proporcionan a los usuarios las claves para acceder a partes específicas de nuestra aplicación. Pero, ¿qué ocurre si estas claves se pierden o, peor aún, son robadas? Aunque son de corta duración, el daño podría ser duradero.

Aquí es donde brillan los Refresh Tokens. Proporcionan un mecanismo para renovar el token de acceso, manteniendo nuestras sesiones de usuario seguras y sin problemas.

Recuerda que puedes aprender más acerca de esté tema en el siguiente video

Descifrando los tokens de acceso y actualización


Ficha de acceso: Un token con una vida corta, normalmente de 15 minutos a una hora, que permite operaciones de usuario específicas. Es como una tarjeta de identificación temporal.
Token de actualización: De vida más larga, su función principal es reemitir los tokens de acceso una vez caducados, garantizando el acceso ininterrumpido de los usuarios.
Utilizar únicamente Access Tokens es como caminar por la cuerda floja sin una red de seguridad. No hay que subestimar las posibles consecuencias de un compromiso de los tokens, por breve que sea.

Magia Backend con NestJS


Embarquémonos en nuestra aventura de backend con NestJS:

a. Configurar el módulo JWT:


En el módulo de tu aplicación o en un módulo de autenticación dedicado:

import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.register({
      secret: 'yourSecretKey',  // Note: In real applications, use something more secure and environment-specific
      signOptions: { expiresIn: '15m' },  // short-lived
    }),
  ],
})
export class AuthModule {}

b. Generación de tokens:


Generación racionalizada de tokens y validación de usuarios mediante JWT.

// authService.ts
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(private readonly jwtService: JwtService) {}

  async createAccessToken(userId: string) {
    return this.jwtService.sign({ id: userId }, { expiresIn: '15m' });
  }

  async createRefreshToken(userId: string) {
    const tokenId = uuid();
    return this.jwtService.sign({ id: userId, tokenId: tokenId }, { expiresIn: '7d' });
  }

  async validateUser(payload: any): Promise<any> {
    // Validate the user exists in your database, etc.
    return { id: payload.id };
  }
}

c. Endpoints para Login y Refresh:


El endpoint de login genera tokens de acceso y refresco, mientras que el endpoint de refresco los renueva, garantizando un acceso seguro continuo.

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('login')
  async login(@Res() res: Response, @Body() body: { userId: string }) {
    const accessToken = await this.authService.createAccessToken(body.userId);
    const refreshToken = await this.authService.createRefreshToken(body.userId);
    
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict'
    });
    
    return res.send({ accessToken });
  }

  @Post('refresh')
  async refresh(@Res() res: Response, @Req() req: Request) {
    const oldRefreshToken = req.cookies['refreshToken'];
    
    // Validate old refresh token, if invalid, throw an error.
    
    const userId = this.authService.decodeRefreshToken(oldRefreshToken).id;
    const newAccessToken = await this.authService.createAccessToken(userId);
    const newRefreshToken = await this.authService.createRefreshToken(userId);
    
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict'
    });
    
    return res.send({ accessToken: newAccessToken });
  }
}

Angular: ¿Cómo lo implementamos?


Angular, React, Vue - no importa el marco de frontend, el concepto sigue siendo coherente. Vamos a rodar con Angular para este ejemplo:

a. Almacenamiento del token de acceso:


Mantenerlo en memoria para una seguridad óptima

// authService.ts
private accessToken: string;

setAccessToken(token: string) {
  this.accessToken = token;
}

getAccessToken() {
  return this.accessToken;
}

b. Gestión de la caducidad y renovación de tokens en un interceptor:


Gestión de la renovación de autenticación con interceptores

// token.interceptor.ts
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  // Attach access token to request headers
  const authorizedReq = req.clone({ headers: req.headers.set('Authorization', 'Bearer ' + this.authService.getAccessToken()) });
  
  return next.handle(authorizedReq).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        // Access token is expired, try refreshing
        return this.authService.refreshToken().pipe(
          switchMap((newToken: string) => {
            // Set the new token in authService for in-memory storage
            this.authService.setAccessToken(newToken);

            // Use the new token for the retry
            const retriedReq = req.clone({ headers: req.headers.set('Authorization', 'Bearer ' + newToken) });
            return next.handle(retriedReq);
          })
        );
      }
      return throwError(error);
    })
  );
}

Rotación de fichas: La seguridad


La seguridad, aunque intrínsecamente robusta con el uso de tokens de acceso y de actualización, obtiene una capa adicional de blindaje con la rotación de tokens de actualización.

Este concepto dicta que cada vez que se utiliza una ficha de actualización, se sustituye por una nueva.

Esto asegura que incluso si un Refresh Token se ve comprometido, su ventana de uso indebido es drásticamente limitada. Profundicemos en este avanzado concepto.

¿Por qué rotar los tokens de refresco?


Antes de sumergirnos en el código, averigüemos el "por qué" de esta estrategia. Si un atacante se hace con un token de actualización y el sistema no aplica la rotación, puede seguir renovando el token de acceso, manteniendo el acceso no autorizado.

Sin embargo, si se aplica la rotación, después de que el usuario legítimo (o el atacante) utilice el token de actualización, éste quedará invalidado y se reiniciará la sesión.

Esto puede alertar al sistema de un posible uso indebido si el usuario legítimo se encuentra inesperadamente desconectado.

Estrategia de rotación del backend con NestJS:


a. Ampliación de nuestro AuthService para la rotación de tokens:


En el AuthService actualizado, hemos añadido funcionalidad para decodificar y rotar de forma segura los tokens de actualización, mejorando la seguridad de los tokens.

// authService.ts
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  // ... previous code ...

  decodeRefreshToken(token: string) {
    try {
      return this.jwtService.verify(token);
    } catch (error) {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

  async replaceRefreshToken(userId: string, oldTokenId: string) {
    // Invalidate the old token by any means, e.g., storing the used token ID in a blacklist.
    // Here, you might also check against a list of previously issued tokens for this user.

    return this.createRefreshToken(userId);  // Generate a new token as shown previously
  }
}

b. Actualizando nuestro Refresh Endpoint:


Recuerde que el nuevo mecanismo de actualización ahora necesita manejar el token antiguo, validarlo y luego reemplazarlo:

// authController.ts

@Post('refresh')
async rotateRefreshToken(@Res() res: Response, @Req() req: Request) {
  const oldRefreshToken = req.cookies['refreshToken'];
  const decodedToken = this.authService.decodeRefreshToken(oldRefreshToken);

  // Invalidate old token and generate a new one
  const newRefreshToken = await this.authService.replaceRefreshToken(decodedToken.id, decodedToken.tokenId);

  const newAccessToken = await this.authService.createAccessToken(decodedToken.id);
  
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  });
  
  return res.send({ accessToken: newAccessToken });
}

c. Actualizar el token:


En nuestro servicio Angular, al refrescar el token, también actualizaremos el Access Token en memoria:

// authService.ts

refreshToken(): Observable<string> {
  return this.httpClient.post<{ accessToken: string }>('/auth/refresh', {}).pipe(
    tap((response) => {
      this.setAccessToken(response.accessToken);
    }),
    map(response => response.accessToken)
  );
}

d. Actualización del interceptor:
Nuestro interceptor se mantiene prácticamente sin cambios, ya que el proceso de refrescar y adjuntar el nuevo Access Token a los reintentos de solicitud sigue siendo el mismo.

Resumiendo 🎁


En seguridad web, los Access Tokens y los Refresh Tokens son pilares fundamentales. Utilizar sólo Access Tokens puede exponer nuestras aplicaciones a vulnerabilidades. Al incorporar Refresh Tokens, especialmente con un mecanismo de rotación, estamos reforzando nuestras defensas y mejorando la seguridad.

El mundo digital está en constante cambio, presentando tanto oportunidades como retos. Como desarrolladores, nuestra responsabilidad va más allá de la mera creación; también debemos garantizar una sólida protección frente a posibles amenazas.

Recuerde, una aplicación segura es una aplicación de confianza.

¡Feliz codificación! 🚀

Fuente

Plataforma de cursos gratis sobre programación