DEV Community

loading...
Cover image for Autenticação com JWT

Autenticação com JWT

Vitor Silva Delfino
・7 min read

Neste post, vamos escrever um middleware de autenticação e um módulo de login.

O serviço de login vai receber um payload com usuário e senha, após tudo ser validado na base, vamos gerar um token do tipo JWT e devolver pro client.

Para a geração do token, vamos utilizar a lib jsonwebtoken

Todas as outras requests, precisarão desse token no cabeçalho, assim garantimos que é uma request feita por um usuário previamente autenticado, e tudo que antes necessitava do ID do usuário, agora podemos pegar de dentro do token.

Instalações

yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Configurações

Após a instalação da lib, vamos criar uma variável de ambiente que vai servir como uma chave secreta. Ela vai ser utilizada no momento da geração do token

.env.dev

PORT=3000
DATABASE_MONGO_CONN=mongodb://localhost:27017/example
SECRET=0917B13A9091915D54B6336F45909539CCE452B3661B21F386418A257883B30A
Enter fullscreen mode Exit fullscreen mode

E agora vamos importar esse hash nas configs
src/config/index.ts

...
export const auth = {
  secret: String(process.env.SECRET),
  expires: '1h',
};
Enter fullscreen mode Exit fullscreen mode

Código

Vamos começar criando uma pasta Auth dentro de apps

Alt Text

E vamos criar um service.

Responsabilidade da service:

1 - Vamos buscar o usuário na base
2 - Caso o usuário não exista devolvemos um erro
3 - Caso o usuário exista, geramos um token e devolvemos
4 - Se acontecer algum outro erro, devolvemos uma falha interna

src/apps/Auth/AuthService.ts

/* eslint-disable no-underscore-dangle */
import { CustomError } from 'express-handler-errors';
import { sign } from 'jsonwebtoken';
import { MongoRepository, getConnection } from 'typeorm';

import { dbConnections, auth } from '@config/index';

import { Users } from '@apps/Users/Users.entity';

import logger from '@middlewares/logger';

class AuthService {
  private readonly repository: MongoRepository<Users>;

  constructor() {
    this.repository = getConnection(
      dbConnections.mongo.name
    ).getMongoRepository(Users);
  }

  async auth(data: {
    document: string;
    password: string;
  }): Promise<{ token: string }> {
    const { document, password } = data;
    logger.info(`AuthService::auth::`, data);

    try {
      // Buscando usuário
      const user = await this.repository.findOne({ document, password });

      // Validando existência
      if (!user) {
        throw new CustomError({
          code: 'USER_NOT_FOUND',
          message: 'Usuário não encontrado',
          status: 404,
        });
      }

      // Gerando token
      const token = await sign(
        {
          _id: user._id,
          document: user.document,
          name: user.name,
        },
        auth.secret,
        {
          expiresIn: auth.expires,
        }
      );

      return {
        token,
      };
    } catch (e) {
      if (e instanceof CustomError) throw e;
      logger.error(`AuthService::auth::${e.message}`);

      throw new CustomError({
        code: 'ERROR_AUTHENTICATE',
        message: 'Erro ao autenticar',
        status: 500,
      });
    }
  }
}

export default new AuthService();


Enter fullscreen mode Exit fullscreen mode

E então criamos o controller, um validator e a rota

src/apps/Auth/AuthController.ts

import { Request, Response } from 'express';

import AuthService from './AuthService';

export const auth = async (req: Request, res: Response): Promise<Response> => {
  const { document, password } = req.body;
  const response = await AuthService.auth({ document, password });
  return res.json(response);
};
Enter fullscreen mode Exit fullscreen mode

src/apps/Auth/validator.ts

import { NextFunction, Request, Response } from 'express';

import yup from '@config/yup';

export const validateAuthPayload = async (
  req: Request,
  _: Response,
  next: NextFunction
): Promise<void> => {
  await yup
    .object()
    .shape({
      document: yup.string().length(11).required(),
      password: yup.string().min(6).max(10).required(),
    })
    .validateSync(req.body, { abortEarly: false });

  return next();
};
Enter fullscreen mode Exit fullscreen mode

src/apps/Auth/routes.ts

import { Router } from 'express';

import * as controller from './AuthController';
import { validateAuthPayload } from './validator';

const routes = Router();

routes.post('/', validateAuthPayload, controller.auth);

export default routes;
Enter fullscreen mode Exit fullscreen mode

E vamos adicionar o path '/auth' no arquivo de rotas raiz.

src/routes.ts

import { Router } from 'express';

import * as controller from './AuthController';
import { validateAuthPayload } from './validator';

import 'express-async-errors';

const routes = Router();

routes.post('/', validateAuthPayload, controller.auth);

export default routes;
Enter fullscreen mode Exit fullscreen mode

Realizando Login

Criei um usuário com os requests que já existe

Alt Text

Agora vou atualizar o arquivo de requests com o endpoint de login

requests.http

...
POST http://localhost:3000/api/auth HTTP/1.1
Content-Type: application/json

{
  "document": "42780908890",
  "password": "123456"
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Podemos ver o token, na resposta da autenticação

Se colarmos esse token no site https://jwt.io, podemos ver a informação armazenada dentro dele, mas só com a secret conseguimos fazer a validação.

Então, nunca devemos gravar informações sensíveis dentro do token

Alt Text

Middleware

Antes de escrever o middleware, vamos modificar a interface do express.

No primeiro tutorial, adicionamos o campo id dentro da request.
Agora vamos adicionar o campo user com os tipos do payload do nosso token.

src/@types/express/index.d.ts

declare namespace Express {
  interface Request {
    id: string;
    user: {
      _id: string;
      document: string;
      name: string;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

Agora, vamos escrever um middleware que vai receber esse token e fazer a validação

src/middlewares/authorize

import { Request, Response, NextFunction } from 'express';
import { CustomError } from 'express-handler-errors';
import { verify } from 'jsonwebtoken';

import { auth } from '@config/index';

import logger from '@middlewares/logger';

export const authorize = (
  req: Request,
  _: Response,
  next: NextFunction
): void => {
  // coletamos o token do header da requisição
  const token = req.headers.authorization;
  logger.info(`Authorize::validate token::${token}`);

  // se não existir o token, devolvemos 401, que é o HTTP code para não autorizado
  if (!token)
    return next(
      new CustomError({
        code: 'UNAUTHORIZED',
        message: 'Token não enviado',
        status: 401,
      })
    );
  try {
    // Aqui fazemos a validação do token
    const decoded = verify(token, auth.secret) as any;
    req.user = decoded;
    logger.info(`Authorize::user authorized::`);
    // No sucesso da validação a request segue em frente ...
    return next();
  } catch (e) {
    // Se der erro na validação, devolvemos 401 novamente
    logger.error(`Authorize::error decode token::${e.message}`);
    return next(
      new CustomError({
        code: 'UNAUTHORIZED',
        message: 'Token inválido',
        status: 401,
      })
    );
  }
};

Enter fullscreen mode Exit fullscreen mode

Para utilizar o middleware, vamos alterar o método findOne do módulo User

src/config/index.ts

...
export type IUserRequest = {
  _id: string;
  document: string;
  name: string;
};
...
Enter fullscreen mode Exit fullscreen mode

src/apps/User/UserService.ts

...
async findOne(userAuthenticated: IUserRequest): Promise<Users> {
    const user = await this.repository.findOne(userAuthenticated._id);
    if (!user)
      throw new CustomError({
        code: 'USER_NOT_FOUND',
        message: 'Usuário não encontrado',
        status: 404,
      });

    return user;
  }
...

Enter fullscreen mode Exit fullscreen mode

E passar o userAuthenticated no controller

src/apps/User/UserController.ts

...
export const findOne = async (
  req: Request,
  res: Response
): Promise<Response> => {
  const response = await UserService.findOne(req.user);
  return res.json(response);
};
...
Enter fullscreen mode Exit fullscreen mode

Agora, passamos o middleware na rota e podemos realizar o teste

src/apps/User/routes.ts

import { Router } from 'express';

import * as controller from './UserController';
import { validateUserPayload } from './validator';

import 'express-async-errors';
import { authorize } from '@middlewares/authorize';

const route = Router();

route.post('/', validateUserPayload, controller.create);
route.get('/', authorize, controller.findOne);
route.put('/:id', controller.update);
route.delete('/:id', controller.deleteOne);

export default route;

Enter fullscreen mode Exit fullscreen mode

Para realizar o teste, vamos alterar a request dentro de requests.http

Alt Text

...
GET http://localhost:3000/api/users HTTP/1.1
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDY0YjU1NjBlMTJkZjBiOWVjY2JjZWUiLCJkb2N1bWVudCI6IjQyNzgwOTA4ODkwIiwibmFtZSI6IlZpdG9yIiwiaWF0IjoxNjE3MjE2NTE1LCJleHAiOjE2MTcyMjAxMTV9.oZSom3PhiuLp554A_R4VajBV67T1Sb3DbCEGkNwMCEE
...
Enter fullscreen mode Exit fullscreen mode

Alt Text

Estamos utilizando a informação de dentro do token, para resgatar um usuário da nossa base.

Tests

E como ficam os testes unitários que escrevemos ????

Como alteramos o serviço, os testes agora estão quebrando.

Alt Text

Vamos refatorar o teste existente.

Nós vamos precisar escrever um novo escopo em nossa switch de testes.

Como o token, tem uma expiração de 1h, fica inviável ter que gerar sempre um novo token para rodar os testes.

Nesses casos, vamos usar a função afterEach, para limpar o mock feito para o middleware de autenticação.

tests/User/user.test.ts

import { MockProxy } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { MongoRepository } from 'typeorm';
...

describe('## GET ##', () => {
    // Aqui estamos restaurando o mock
    afterEach(() => {
      jest.resetAllMocks();
    });
    test('should return error when user does not exists', async () => {
      /**
       * Vamos espionar a função verify,
       * a mesma utilizada no middleware e modificar o seu comportamento
       * é um outro jeito de mocar funções com jest
       *
       */
      const spy = jest.spyOn(jwt, 'verify');
      spy.mockReturnValue({
        _id: '6064b5560e12df0b9eccbcee',
        document: '42780908890',
        name: 'Vitor',
      } as any);
      repository.findOne.mockResolvedValue(null);
      await request(app)
        .get('/api/users')
        .set('Authorization', 'token')
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });

    test('should return an user', async () => {
      const spy = jest.spyOn(jwt, 'verify');
      spy.mockReturnValue({
        _id: '6064b5560e12df0b9eccbcee',
        document: '42780908890',
        name: 'Vitor',
      } as any);
      const user = {
        _id: '6064b5560e12df0b9eccbcee',
        name: 'Teste',
        password: '1234',
      };
      repository.findOne.mockResolvedValue(user);
      await request(app)
        .get('/api/users')
        .set('Authorization', 'token')
        .expect(200, user);
    });
  });
...
Enter fullscreen mode Exit fullscreen mode

Vamos escrever os testes do login

tests/Auth/auth.test.ts

import { MockProxy } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
import request from 'supertest';
import { MongoRepository } from 'typeorm';

jest.mock('typeorm');
jest.mock('../../src/middlewares/logger');
describe('## Auth Module ##', () => {
  const { app } = require('../../src/app').default;

  const repository = require('typeorm').mongoRepositoryMock as MockProxy<
    MongoRepository<any>
  >;

  describe('## Login ##', () => {
    afterEach(() => {
      jest.resetAllMocks();
    });
    test('should return error when user does not exists', async () => {
      repository.findOne.mockResolvedValue(null);
      await request(app)
        .post('/api/auth')
        .send({ document: '42780908890', password: '123456' })
        .expect(404, {
          errors: [
            {
              code: 'USER_NOT_FOUND',
              message: 'Usuário não encontrado',
              status: 404,
            },
          ],
        });
    });

    test('should return an token', async () => {
      repository.findOne.mockResolvedValue({
        _id: '6064b5560e12df0b9eccbcee',
        document: '42780908890',
        name: 'Vitor',
      });

      const spy = jest.spyOn(jwt, 'sign');
      const token =
        'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MDY0YjU1NjBlMTJkZjBiOWVjY2JjZWUiLCJkb2N1bWVudCI6IjQyNzgwOTA4ODkwIiwibmFtZSI6IlZpdG9yIiwiaWF0IjoxNjE3MjE2NTE1LCJleHAiOjE2MTcyMjAxMTV9.oZSom3PhiuLp554A_R4VajBV67T1Sb3DbCEGkNwMCEE';
      spy.mockReturnValue(token as any);

      await request(app)
        .post('/api/auth')
        .send({ document: '42780908890', password: '123456' })
        .expect(200, {
          token,
        });
    });

    test('should return error when generate token', async () => {
      repository.findOne.mockResolvedValue({
        _id: '6064b5560e12df0b9eccbcee',
        document: '42780908890',
        name: 'Vitor',
      });

      const spy = jest.spyOn(jwt, 'sign');

      spy.mockImplementation(() => {
        throw new Error('Error to generate token');
      });

      await request(app)
        .post('/api/auth')
        .send({ document: '42780908890', password: '123456' })
        .expect(500, {
          errors: [
            {
              code: 'ERROR_AUTHENTICATE',
              message: 'Erro ao autenticar',
              status: 500,
            },
          ],
        });
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

E o resultado do coverage fica assim

Alt Text

Considerações finais

Para finalizar, vamos atualizar o swagger

No get do usuário, vamos remover o parâmetro id

src/apps/User/swagger.ts

const paths = {
  '/users/{id}': {
    ...
  },
  '/users': {
    get: {
      tags: ['User'],
      summary: 'User',
      description: 'Get user by Id',
      security: [
        {
          Bearer: [],
        },
      ],
      parameters: [
        {
          in: 'path',
          name: 'id',
          required: true,
          schema: {
            type: 'string',
          },
          description: 'uuid',
        },
      ],
      responses: {
        200: {
          description: 'OK',
          schema: {
            $ref: '#/definitions/User',
          },
        },
        404: {
          description: 'Not Found',
          schema: {
            $ref: '#/definitions/ErrorResponse',
          },
        },
        500: {
          description: 'Internal Server Error',
          schema: {
            $ref: '#/definitions/ErrorResponse',
          },
        },
      },
    },
...
    },
  },
};

const definitions = {
  User: {
    type: 'object',
    properties: {
      _id: { type: 'string' },
      name: { type: 'string' },
      document: { type: 'string' },
      password: { type: 'string' },
      createdAt: { type: 'date' },
      updatedAt: { type: 'date' },
    },
  },
  UserPayload: {
    type: 'object',
    properties: {
      name: { type: 'string' },
      document: { type: 'string' },
      password: { type: 'string' },
    },
  },
};

export default {
  paths,
  definitions,
};

Enter fullscreen mode Exit fullscreen mode

E vamos escrever o swagger do módulo Auth

src/apps/Auth/swagger.ts

const paths = {
  '/auth': {
    post: {
      tags: ['Auth'],
      summary: 'Auth',
      description: 'Authenticate User',
      security: [
        {
          Bearer: [],
        },
      ],
      parameters: [
        {
          in: 'body',
          name: 'update',
          required: true,
          schema: {
            $ref: '#/definitions/AuthPayload',
          },
        },
      ],
      responses: {
        200: {
          description: 'OK',
          schema: {
            $ref: '#/definitions/AuthResponse',
          },
        },
        404: {
          description: 'Not Found',
          schema: {
            $ref: '#/definitions/ErrorResponse',
          },
        },
        500: {
          description: 'Internal Server Error',
          schema: {
            $ref: '#/definitions/ErrorResponse',
          },
        },
      },
    },
  },
};

const definitions = {
  AuthResponse: {
    type: 'object',
    properties: {
      token: { type: 'string' },
    },
  },
  AuthPayload: {
    type: 'object',
    properties: {
      document: { type: 'string' },
      password: { type: 'string' },
    },
  },
};

export default {
  paths,
  definitions,
};

Enter fullscreen mode Exit fullscreen mode

Alt Text

Discussion (0)