Criando Middleware de autenticação no CrazyStack Node.js

Dev Doido - Feb 27 '23 - - Dev Community

Este é um material auxiliar do bootcamp CrazyStack Node.js do DevDoido. Ele servirá como uma espécie de documentação de alguns códigos vistos durante as aulas apenas como material complementar, garanta já sua vaga no bootcamp clicando AQUI!.
Middleware é uma camada intermediária que permite a manipulação de requisições e respostas HTTP antes que cheguem ao seu destino final. Em uma aplicação web, o middleware é usado para adicionar recursos comuns a todas as requisições, como autenticação, validação de dados, tratamento de erros, entre outros.

Na aula de criação de Middleware de autenticação, você aprenderá a criar uma camada de segurança para sua aplicação, garantindo que somente usuários autenticados tenham acesso a determinadas rotas. Para isso, será implementado um middleware que verificará a presença de um token de autenticação na requisição HTTP, e caso ele não exista, retornará uma resposta de erro.

Além disso, você verá como incluir o middleware em suas rotas, permitindo que somente usuários autenticados possam acessá-las. Ao final da aula, você terá uma compreensão sólida do que é um middleware e como criar um para autenticação em sua aplicação.

import { Middleware } from "@/application/infra/contracts";
import { HttpRequest } from "@/application/helpers";
import { ServerError } from "@/application/errors";
export const adaptMiddleware = (middleware: Middleware) => {
  return async (request: any, reply: any) => {
    const httpRequest: HttpRequest = { headers: request.headers };
    const httpResponse = await middleware.handle(httpRequest);
    if (httpResponse.statusCode === 200) {
      request.requestContext.set("context", httpResponse?.data);
    } else if (httpResponse?.data) {
      reply.code(httpResponse.statusCode).send(httpResponse.data);
    } else {
      reply.code(500).send(new ServerError(new Error("Internal Server Error")));
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Este código define a função adaptMiddleware, que recebe como parâmetro um middleware de autenticação. A função serve como adaptador, transformando a interface do middleware para uma interface específica de uma biblioteca ou framework.

A função retorna uma nova função que é responsável por processar o objeto de requisição (request) e o objeto de resposta (reply). A função interna cria um objeto HttpRequest a partir do objeto de requisição, e então chama o método handle do middleware de autenticação passando esse objeto HttpRequest.

Se a resposta for um código de status 200 (sucesso), o objeto request é atualizado com o contexto retornado pela resposta do middleware. Se a resposta for outro código de status, o objeto reply é atualizado com esse código de status e a mensagem de erro retornada pela resposta do middleware. Se a resposta não tiver nenhuma mensagem de erro, o objeto reply é atualizado com o código de erro 500 e uma mensagem de erro padrão "Internal Server Error".

Se estivessemos usando o Express, a única coisa que precisaríamos mudar seria a chamada da variável "reply" para "response". Isso porque, no Express, a variável "response" é usada para responder ao cliente com as informações da requisição. Já a função "send" é utilizada para enviar a resposta para o cliente. Portanto, para fazer a mudança, bastaria mudar a seguinte linha:

reply.code(httpResponse.statusCode).send(httpResponse.data);

para:

response.status(httpResponse.statusCode).send(httpResponse.data);

import jwt from "jsonwebtoken";
import {
  forbidden,
  HttpRequest,
  HttpResponse,
  ok,
  serverError,
  unauthorized,
} from "@/application/helpers";
import { Middleware } from "@/application/infra/contracts";
import { LoadUser } from "@/slices/user/useCases/loadUser";
import { AccessDeniedError } from "@/application/errors";
import { env } from "@/application/infra/config";
import { ObjectId } from "mongodb";

export class AuthMiddleware implements Middleware {
  constructor(private readonly loadUser: LoadUser, private readonly roles: string[]) {}
  private async verifyToken(token: string, secret: string): Promise<any> {
    try {
      return jwt.verify(token, secret);
    } catch (error) {
      return null;
    }
  }
  async handle(httpRequest: HttpRequest<any>): Promise<HttpResponse<any>> {
    try {
      const authHeader = httpRequest?.headers?.["authorization"];
      if (authHeader) {
        const [, accessToken] = authHeader?.split?.(" ");
        if (accessToken) {
          const decoded = await this.verifyToken(accessToken, env.jwtSecret);
          if (!decoded) {
            return unauthorized();
          }
          const { _id } = decoded;
          const query = {
            fields: {
              _id: new ObjectId(_id),
              role: { $in: this.roles },
            },
            options: { projection: { password: 0 } },
          };
          const user = await this.loadUser(query);
          if (user) {
            return ok({ userId: user?._id, userLogged: user });
          }
        }
      }
      return forbidden(new AccessDeniedError());
    } catch (error) {
      return serverError(error);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Essa classe é uma implementação de um Middleware de autenticação. Ela é responsável por verificar se o token de acesso enviado na requisição é válido e se o usuário logado tem permissão para acessar a rota específica.

Ela usa a biblioteca jsonwebtoken para decodificar o token de acesso e verificar sua validade. O método verifyToken decodifica o token e retorna suas informações decodificadas ou null caso o token seja inválido.

O método handle faz o tratamento da requisição para verificar se o usuário está autenticado. Ele verifica se há um cabeçalho Authorization na requisição. Se houver, ele extrai o token de acesso da string e verifica sua validade. Caso o token seja válido, ele carrega as informações do usuário associado a ele e verifica se ele tem permissão para acessar a rota. Caso tudo esteja ok, ele retorna um objeto com os dados do usuário logado. Caso contrário, ele retorna uma resposta com o status 401 (não autorizado) ou 403 (proibido) dependendo do caso.

A classe recebe como parâmetros o caso de uso LoadUser e um array de strings com os roles que têm permissão para acessar a rota.

import { adaptMiddleware } from "@/application/adapters";
import { Middleware } from "@/application/infra/contracts";
import { makeLoadUserFactory } from "@/slices/user/useCases/loadUser/loadUserFactory";
import { AuthMiddleware } from "@/application/infra/middlewares";
export const makeAuthMiddleware = (roles: string[]): Middleware => {
  return new AuthMiddleware(makeLoadUserFactory(), roles);
};

//roles

export const authClient = () => adaptMiddleware(makeAuthMiddleware(["client", "admin"]));
export const authAdmin = () => adaptMiddleware(makeAuthMiddleware(["admin"]));
export const authOwner = () => adaptMiddleware(makeAuthMiddleware(["owner", "admin"]));
export const authProfessional = () =>
  adaptMiddleware(makeAuthMiddleware(["owner", "professional", "admin"]));
export const authVisitor = () =>
  adaptMiddleware(
    makeAuthMiddleware(["owner", "professional", "client", "visitor", "admin"])
  );
export const authLogged = () =>
  adaptMiddleware(makeAuthMiddleware(["owner", "professional", "client", "admin"]));
Enter fullscreen mode Exit fullscreen mode

Este código cria algumas funções que retornam middlewares de autenticação adaptados. O middleware é usado para verificar se o usuário que está fazendo uma solicitação possui a autorização adequada.

A função makeAuthMiddleware recebe uma lista de papéis e cria uma instância de AuthMiddleware passando a fábrica de carregamento de usuário makeLoadUserFactory e a lista de papéis como parâmetros.

As funções authClient, authAdmin, authOwner, authProfessional, authVisitor e authLogged retornam uma instância de middleware de autenticação adaptada para diferentes papéis, com base nas permissões necessárias para acessar determinadas rotas.

A função adaptMiddleware é importada de @/application/adapters e é responsável por adaptar o middleware para o formato esperado pelo framework de rotas que está sendo usado.

Classe RefreshTokenMiddleware

Esta é uma classe de middleware responsável por gerenciar o refresh de tokens de autenticação. Ela implementa a interface Middleware e recebe como parâmetros a função loadUser e um array de roles.

import jwt from "jsonwebtoken";
import { forbidden, HttpRequest, HttpResponse, ok, serverError, unauthorized } from "@/application/helpers";
import { Middleware } from "@/application/infra/contracts";
import { LoadUser } from "@/slices/user/useCases/loadUser";
import { AccessDeniedError } from "@/application/errors";
import { env } from "@/application/infra/config";
import { ObjectId } from "mongodb";

export class RefreshTokenMiddleware implements Middleware {
Enter fullscreen mode Exit fullscreen mode

Construtor

O construtor recebe dois parâmetros, a função loadUser e um array de roles. Estes são armazenados como propriedades de leitura privadas da classe.

constructor(private readonly loadUser: LoadUser, private readonly roles: string[]) {}
Enter fullscreen mode Exit fullscreen mode

Método verifyToken

Este é um método privado que é responsável por verificar se um token é válido. Ele recebe um token e uma chave secreta como parâmetros e retorna o payload decodificado se o token for válido, caso contrário retorna null.

 private async verifyToken(token: string, secret: string): Promise<any> {
    try {
      return jwt.verify(token, secret);
    } catch (error) {
      return null;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Método handle

Este é o método principal da classe que será executado quando a classe for usada como middleware. Ele recebe um objeto httpRequest como parâmetro e retorna um objeto HttpResponse.

async handle(httpRequest: HttpRequest<any>): Promise<HttpResponse<any>> {
Enter fullscreen mode Exit fullscreen mode

Verificação do cabeçalho de autorização

Primeiro, é verificado se o cabeçalho refreshtoken existe no objeto httpRequest. Se ele existir, é decodificado usando o método verifyToken.

try {
  const authHeader = httpRequest?.headers?.["refreshtoken"];
      if (authHeader) {
        const decoded = await this.verifyToken(authHeader, env.jwtSecret);
        if (!decoded) {
          return unauthorized();
        }` 
Enter fullscreen mode Exit fullscreen mode

Busca do usuário

Em seguida, é buscado o usuário usando a função loadUser passada no construtor. O id do usuário é extraído do payload decodificado e é passado como filtro para a busca. Além disso, é definido um construtor que inicializa o middleware com o uso do caso de uso LoadUser e as funções roles que serão usadas mais adiante.

Há também uma função verifyToken que irá verificar se o token enviado é válido, utilizando a biblioteca jsonwebtoken.

A função handle é onde ocorre a lógica principal do middleware. Primeiro é verificado se há um cabeçalho "refreshtoken" no httpRequest. Se houver, ele é decodificado com a chave secreta especificada em env.jwtSecret. Se o token não for válido, é retornado o status unauthorized.

Se o token for válido, é feita uma consulta no banco de dados com o id presente no token e com a condição de que a role esteja dentro das roles inicializadas no construtor. Se houver um usuário com esses critérios, é retornado um objeto com as informações do usuário e seu id.

Se não houver um cabeçalho "refreshtoken" no httpRequest ou se o usuário não foi encontrado, é retornado o status forbidden com a mensagem de erro AccessDeniedError.

Em caso de erro durante o processo, é retornado o status serverError com a mensagem de erro.

Código final

import jwt from "jsonwebtoken";
import {
  forbidden,
  HttpRequest,
  HttpResponse,
  ok,
  serverError,
  unauthorized,
} from "@/application/helpers";
import { Middleware } from "@/application/infra/contracts";
import { LoadUser } from "@/slices/user/useCases/loadUser";
import { AccessDeniedError } from "@/application/errors";
import { env } from "@/application/infra/config";
import { ObjectId } from "mongodb";

export class RefreshTokenMiddleware implements Middleware {
  constructor(private readonly loadUser: LoadUser, private readonly roles: string[]) {}
  private async verifyToken(token: string, secret: string): Promise<any> {
    try {
      return jwt.verify(token, secret);
    } catch (error) {
      return null;
    }
  }
  async handle(httpRequest: HttpRequest<any>): Promise<HttpResponse<any>> {
    try {
      const authHeader = httpRequest?.headers?.["refreshtoken"];
      if (authHeader) {
        const decoded = await this.verifyToken(authHeader, env.jwtSecret);
        if (!decoded) {
          return unauthorized();
        }
        const { _id } = decoded;
        const query = {
          fields: {
            _id: new ObjectId(_id),
            role: { $in: this.roles },
          },
          options: { projection: { password: 0 } },
        };
        const user = await this.loadUser(query);
        if (user) {
          return ok({ userId: user?._id, userLogged: user });
        }
      }
      return forbidden(new AccessDeniedError());
    } catch (error) {
      return serverError(error);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
import { adaptMiddleware } from "@/application/adapters";
import { Middleware } from "@/application/infra/contracts";
import { makeLoadUserFactory } from "@/slices/user/useCases/loadUser/loadUserFactory";
import { RefreshTokenMiddleware } from "@/application/infra/middlewares";
export const makeRefreshTokenMiddleware = (roles: string[]): Middleware => {
  return new RefreshTokenMiddleware(makeLoadUserFactory(), roles);
};

//roles

export const authClient = () =>
  adaptMiddleware(makeRefreshTokenMiddleware(["client", "admin"]));
export const authAdmin = () => adaptMiddleware(makeRefreshTokenMiddleware(["admin"]));
export const authOwner = () =>
  adaptMiddleware(makeRefreshTokenMiddleware(["owner", "admin"]));
export const authProfessional = () =>
  adaptMiddleware(makeRefreshTokenMiddleware(["owner", "professional", "admin"]));
export const authVisitor = () =>
  adaptMiddleware(
    makeRefreshTokenMiddleware(["owner", "professional", "client", "visitor", "admin"])
  );
export const authLogged = () =>
  adaptMiddleware(
    makeRefreshTokenMiddleware(["owner", "professional", "client", "admin"])
  );
Enter fullscreen mode Exit fullscreen mode
import { Authentication } from "@/application/helpers/contracts";
import { HashComparer, TokenGenerator } from "@/application/infra";
import { LoadUserRepository } from "@/slices/user/repositories";

export class DbAuthentication implements Authentication {
  constructor(
    private readonly loadUserRepository: LoadUserRepository,
    private readonly hashComparer: HashComparer,
    private readonly tokenGenerator: TokenGenerator,
    private readonly refreshTokenGenerator: TokenGenerator
  ) {}
  async auth(email: string, password: string): Promise<any> {
    const user = await this.loadUserRepository.loadUser({
      fields: { email },
      options: { projection: {} },
    });
    if (user?._id && user?.password) {
      const isValid = await this.hashComparer.compare(password, user.password);
      if (isValid) {
        const { accessToken, refreshToken } =
          (await this.authRefreshToken(user._id)) || {};
        return { accessToken, refreshToken };
      }
    }
    return null;
  }
  async authRefreshToken(userId: string): Promise<any> {
    const accessToken = await this.tokenGenerator.generate(userId);
    const refreshToken = await this.refreshTokenGenerator.generate(userId);
    return { accessToken, refreshToken };
  }
}
Enter fullscreen mode Exit fullscreen mode

Esse é um código em JavaScript que define a classe "DbAuthentication", responsável por implementar uma interface de autenticação. Essa classe possui as seguintes responsabilidades:

  • Verificar se as credenciais fornecidas (email e senha) correspondem a algum usuário registrado no banco de dados.
  • Gerar tokens de acesso e refresh (em caso de sucesso na autenticação).

A classe possui uma dependência de três objetos, que são inicializados através de seu construtor:

  • loadUserRepository: Um repositório responsável por carregar informações de usuários.
  • hashComparer: Um objeto que compara hashes de senhas.
  • tokenGenerator: Um gerador de tokens de acesso.
  • refreshTokenGenerator: Um gerador de tokens de refresh.

A classe possui dois métodos:

  • auth: Verifica as credenciais fornecidas e gera tokens de acesso e refresh em caso de sucesso.
  • authRefreshToken: Recebe o ID do usuário e gera tokens de acesso e refresh.
import { BcryptAdapter, env, JwtAdapter, MongoRepository } from "@/application/infra";
import { DbAuthentication, Authentication } from "@/application/helpers";
import { UserRepository } from "@/slices/user/repositories";
export const makeDbAuthentication = (): Authentication => {
  const salt = 12;
  const bcryptAdapter = new BcryptAdapter(salt);
  const jwtAdapter = new JwtAdapter(env.jwtSecret, "1d");
  const jwtRefreshTokenAdapter = new JwtAdapter(env.jwtRefreshSecret, "10d");
  const userMongoRepository = new MongoRepository("user");
  const userRepository = new UserRepository(userMongoRepository);
  return new DbAuthentication(
    userRepository,
    bcryptAdapter,
    jwtAdapter,
    jwtRefreshTokenAdapter
  );
};
Enter fullscreen mode Exit fullscreen mode

Este código define uma função chamada makeDbAuthentication que retorna uma instância da classe DbAuthentication.

A função começa declarando uma constante salt com o valor 12, que será usado para criar uma instância da classe BcryptAdapter. Em seguida, duas instâncias da classe JwtAdapter são criadas, uma para gerar tokens de acesso e outra para tokens de atualização de token.

Em seguida, uma instância da classe MongoRepository é criada com o nome da coleção "users". Uma instância da classe UserRepository é criada com base na instância de MongoRepository.

Finalmente, uma instância da classe DbAuthentication é retornada, usando as instâncias criadas anteriormente como argumentos.

A ideia é que makeDbAuthentication é uma factory que retorna uma instância de DbAuthentication pronta para ser usada.

Somente o Refresh Token e os dados de autenticação são armazenados em um banco de dados, normalmente MongoDB, com o objetivo de garantir a segurança dos dados do usuário. A biblioteca MongoRepository é usada para se conectar ao banco de dados e manipular as informações do usuário.

O Access Token é usado para autenticar as requisições do usuário e fornecer acesso aos recursos protegidos. É gerado pelo TokenGenerator e é válido por um curto período de tempo, como 120 dias. Se a sessão do usuário expirar antes desse período, ele precisará fazer login novamente.

O Refresh Token é usado para renovar o Access Token sem que o usuário precise fazer login novamente. Ele é gerado pelo RefreshTokenGenerator e é válido por um período de tempo mais longo do que o Access Token. Se o Access Token expirar, o usuário pode usar o Refresh Token para obter um novo Access Token sem precisar fazer login novamente.

Em resumo, o uso de Access Token e Refresh Token aumenta a segurança da aplicação, já que o usuário precisa fazer login apenas uma vez e pode continuar usando a aplicação sem precisar fazer login novamente, desde que seu Refresh Token esteja válido. Além disso, a biblioteca BcryptAdapter é usada para criptografar a senha do usuário antes de armazená-la no banco de dados, garantindo a segurança dos dados sensíveis do usuário.

import { MongoRepository } from "@/application/infra/database/mongodb";
import { UserRepository } from "@/slices/user/repositories";
import { loadUser, LoadUser } from "@/slices/user/useCases/loadUser";
export const makeLoadUserFactory = (): LoadUser => {
  const userMongoRepository = new MongoRepository("users");
  const userRepository = new UserRepository(userMongoRepository);
  return loadUser(userRepository);
};
Enter fullscreen mode Exit fullscreen mode

Este código exporta uma função factory makeLoadUserFactory que retorna uma instância de LoadUser. A função factory utiliza o módulo MongoRepository para criar uma instância do repositório de usuários, que é então usada para criar uma instância do caso de uso loadUser.

O caso de uso loadUser é importado do módulo @/slices/user/useCases/loadUser e é responsável por carregar um usuário a partir de seus dados de identificação, como o e-mail ou o ID do usuário. Esse caso de uso usa o repositório de usuários para realizar a tarefa de recuperar as informações do usuário.

Em resumo, a função factory makeLoadUserFactory retorna uma instância do caso de uso loadUser que foi inicializada com uma instância do repositório de usuários.

LINK DO REPOSITÓRIO

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .