Skip to content

Bloqueando endpoints

Agora que o sistema de autorização está pronto, vamos começar a bloquear alguns endpoints

Bloqueando o endpoint /activations/[token_id]

Começando com o endpoint de fazer a ativação, vamos configurará-lo para permitir apenas usuários com a feature read:activation_token, que é a feature padrão que atribuímos ao usuário no momento que ele faz o cadastro. Isso é feito no método create() do model user.js:

./models/user.js
async function create(userInputValues) {
  await validateUniqueEmail(userInputValues.email);
  await validateUniqueUsername(userInputValues.username);
  await hashPasswordInObject(userInputValues);
  injectDefaultFeaturesInObject(userInputValues);

  const newUser = await runInsertQuery(userInputValues);
  return newUser;

  async function runInsertQuery(userInputValues) {
    const users = await database.query({
      text: `
        INSERT INTO 
          users (username, email, password, features)
        VALUES ($1, $2, $3, $4)
        RETURNING *
      `,
      values: [
        userInputValues.username,
        userInputValues.email,
        userInputValues.password,
        userInputValues.features,
      ],
    });
    return users.rows[0];
  }

  function injectDefaultFeaturesInObject(userInputValues) {
    userInputValues.features = ["read:activation_token"];
  }
}

E após a ativação, o usuário perde essa feature para ganhar as features create:session e read:session.

Então vamos bloquear o endpoint /activation/[token_id], injetando um usuário no contexto e validando se ele tem a feature read:activation_token.

./pages/api/v1/activations/[token_id]/index.js
import { createRouter } from "next-connect";
import controller from "infra/controller.js";
import activation from "models/activation";

const router = createRouter();
router.use(controller.injectAnonymousOrUser);
router.patch(controller.canRequest("read:activation_token"), patchHandler);

export default router.handler(controller.errorHandler);

async function patchHandler(request, response) {
  const activationTokenId = request.query.token_id;

  const validActivationToken =
    await activation.findOneValidById(activationTokenId);
  const usedActivationToken =
    await activation.markTokenAsUsed(activationTokenId);

  await activation.activateUserByUserId(validActivationToken.user_id);

  return response.status(200).json(usedActivationToken);
}

Só isso já basta para bloquearmos o endpoint. Mas vamos fazer algo além... veja que dessa forma estamos validando se o usuário injetado no contexto (que vai ser um usuário anônimo) possui a feature read:activation_token, mas não validamos se o usuário alvo (o usuário que queremos ativar) tem essa feature.

Podemos incluir essa validação dentro do método activateUserByUserId(), assim:

./models/activation.js
sync function activateUserByUserId(userId) {

  const userToActivate = await user.findOneById(userId);

  // Verifica se o usuário que está sendo ativado possui e feature read:activation_token
  if (!authorization.can(userToActivate, "read:activation_token")) {
    throw new ForbiddenError({
      message: "Você não pode mais utilizar tokens de ativação",
      action: "Entre em contato com o suporte.",
    });
  }

  const activatedUser = await user.setFeatures(userId, [
    "create:session",
    "read:session",
  ]);
  return activatedUser;
}

Note

Veja que agora ao invés desse método simplesmente fazer o setFeatures sem nenhuma validação, antes a gente verifica se o usuário que está querendo se ativar de fato possui essa feature, e se por acaso não é um usuário que já se ativou, por exemplo.

Mas para isso funcionar, lá no controller a gente tem que inverter a chamada dos métodos, fazendo o activateUserByUserId primeiro, e depois o markTokenAsUsed, que será chamado apenas se o usuário conseguir ser ativado:

./pages/api/v1/activations/[token_id]/index.js
async function patchHandler(request, response) {
  const activationTokenId = request.query.token_id;

  const validActivationToken =
    await activation.findOneValidById(activationTokenId);

  // Primeiro ativamos
  await activation.activateUserByUserId(validActivationToken.user_id);

  // E depois marcamos o token como usado
  const usedActivationToken =
    await activation.markTokenAsUsed(activationTokenId);

  return response.status(200).json(usedActivationToken);
}

Testando o endpoint activations

Atualmente o nosso endpoint de ativação só está sendo testado no fluxo do registration-flow. Vamos criar alguns testes para cobrir esse endpoint (nada diferente do que já fizemos até agora):

./tests/integration/api/v1/activations/[token_id]/patch.test.js
import orchestrator from "tests/orchestrator";
import { version as uuidVersion } from "uuid";
import user from "models/user.js";
import activation from "models/activation.js";

beforeAll(async () => {
  await orchestrator.waitForAllServices();
  await orchestrator.clearDatabase();
  await orchestrator.runPendingMigrations();
});

describe("PATCH to /api/v1/activations/[token_id]", () => {
  describe("Anonymous user", () => {
    test("With non existent token", async () => {
      const response = await fetch(
        "http://localhost:3000/api/v1/activations/e12b6b5b-33ee-4ab2-aa18-53047cb254f8",
        {
          method: "PATCH",
        },
      );
      expect(response.status).toBe(404);

      const responseBody = await response.json();

      expect(responseBody).toEqual({
        name: "NotFoundError",
        message: "Token de ativação não encontrado.",
        action:
          "Verifique se este token de ativação não está expirado ou não foi utilizado.",
        status_code: 404,
      });
    });
    test("With expired token", async () => {
      jest.useFakeTimers({
        now: new Date(Date.now() - activation.EXPIRATION_IN_MILLISECONDS),
      });

      const createdUser = await orchestrator.createUser();
      const expiredActivationToken = await activation.create(createdUser.id);

      jest.useRealTimers();

      const response = await fetch(
        `http://localhost:3000/api/v1/activations/${expiredActivationToken.id}`,
        {
          method: "PATCH",
        },
      );
      expect(response.status).toBe(404);

      const responseBody = await response.json();

      expect(responseBody).toEqual({
        name: "NotFoundError",
        message: "Token de ativação não encontrado.",
        action:
          "Verifique se este token de ativação não está expirado ou não foi utilizado.",
        status_code: 404,
      });
    });
    test("With already used token", async () => {
      const createdUser = await orchestrator.createUser();
      const activationToken = await activation.create(createdUser.id);

      const response1 = await fetch(
        `http://localhost:3000/api/v1/activations/${activationToken.id}`,
        {
          method: "PATCH",
        },
      );
      expect(response1.status).toBe(200);

      const response2 = await fetch(
        `http://localhost:3000/api/v1/activations/${activationToken.id}`,
        {
          method: "PATCH",
        },
      );
      const responseBody = await response2.json();

      expect(responseBody).toEqual({
        name: "NotFoundError",
        message: "Token de ativação não encontrado.",
        action:
          "Verifique se este token de ativação não está expirado ou não foi utilizado.",
        status_code: 404,
      });
    });
    test("With valid token", async () => {
      const createdUser = await orchestrator.createUser();
      const activationToken = await activation.create(createdUser.id);

      const response = await fetch(
        `http://localhost:3000/api/v1/activations/${activationToken.id}`,
        {
          method: "PATCH",
        },
      );
      expect(response.status).toBe(200);
      const responseBody = await response.json();

      expect(responseBody).toEqual({
        id: activationToken.id,
        user_id: activationToken.user_id,
        used_at: responseBody.used_at,
        expires_at: activationToken.expires_at.toISOString(),
        created_at: activationToken.created_at.toISOString(),
        updated_at: responseBody.updated_at,
      });

      expect(uuidVersion(responseBody.id)).toBe(4);
      expect(uuidVersion(responseBody.user_id)).toBe(4);

      expect(Date.parse(responseBody.expires_at)).not.toBeNaN();
      expect(Date.parse(responseBody.created_at)).not.toBeNaN();
      expect(Date.parse(responseBody.updated_at)).not.toBeNaN();
      expect(responseBody.updated_at > responseBody.created_at).toBe(true);

      // Validando se a expiração é de 15 minutos
      const createdAt = new Date(responseBody.created_at);
      const expiresAt = new Date(responseBody.expires_at);
      expiresAt.setMilliseconds(0);
      createdAt.setMilliseconds(0);
      expect(expiresAt - createdAt).toBe(activation.EXPIRATION_IN_MILLISECONDS);

      // Validando se o usuário autenticado possui as features corretas
      const activatedUser = await user.findOneById(responseBody.user_id);
      expect(activatedUser.features).toEqual([
        "create:session",
        "read:session",
      ]);
    });
    test("With valid but already activated user", async () => {
      const createdUser = await orchestrator.createUser();
      await orchestrator.activateUser(createdUser);
      const activationToken = await activation.create(createdUser.id);

      const response = await fetch(
        `http://localhost:3000/api/v1/activations/${activationToken.id}`,
        {
          method: "PATCH",
        },
      );
      expect(response.status).toBe(403);
      const responseBody = await response.json();
      expect(responseBody).toEqual({
        name: "ForbiddenError",
        message: "Você não pode mais utilizar tokens de ativação",
        action: "Entre em contato com o suporte.",
        status_code: 403,
      });
    });
  });
  describe("Default user", () => {
    test("With valid token, but already logged in user", async () => {
      const user1 = await orchestrator.createUser();
      await orchestrator.activateUser(user1);
      const user1SessionObject = await orchestrator.createSession(user1.id);

      const user2 = await orchestrator.createUser();
      const user2ActivationToken = await activation.create(user2.id);

      const response = await fetch(
        `http://localhost:3000/api/v1/activations/${user2ActivationToken.id}`,
        {
          method: "PATCH",
          headers: {
            Cookie: `session_id=${user1SessionObject.token}`,
          },
        },
      );

      expect(response.status).toBe(403);
      const responseBody = await response.json();
      console.log(responseBody);
      expect(responseBody).toEqual({
        name: "ForbiddenError",
        message: "Você não possui permissão para executar esta ação.",
        action:
          'Verifique se o seu usuário possui a feature: "read:activation_token"',
        status_code: 403,
      });
    });
  });
});

Bloqueando o endpoint /users

Para bloquear o endpoint de criação de usuários, limitando apenas para quem tiver a feature create:user, não tem segredo nenhum:

./pages/api/v1/users/index.js
import { createRouter } from "next-connect";
import controller from "infra/controller.js";
import user from "models/user.js";
import activation from "models/activation";

const router = createRouter();
router.use(controller.injectAnonymousOrUser);
router.post(controller.canRequest("create:user"), postHandler);

export default router.handler(controller.errorHandler);

async function postHandler(request, response) {
  const userInputValues = request.body;
  const newUser = await user.create(userInputValues);

  const activationToken = await activation.create(newUser.id);
  await activation.sendEmailToUser(newUser, activationToken);
  return response.status(201).json(newUser);
}

Só isso já basta, porque o nosso código já estava atribuindo essa permissão para os usuários anônimos:

./infra/controller.js
async function injectAnonymousUser(request) {
  const anonymousUserObject = {
    features: ["read:activation_token", "create:session", "create:user"],
  };

  request.context = {
    ...request.context,
    user: anonymousUserObject,
  };
}

Mas depois que o usuário faz a ativação, ele perde essa feature:

./models/activation.js
async function activateUserByUserId(userId) {
  const userToActivate = await user.findOneById(userId);

  if (!authorization.can(userToActivate, "read:activation_token")) {
    throw new ForbiddenError({
      message: "Você não pode mais utilizar tokens de ativação",
      action: "Entre em contato com o suporte.",
    });
  }

  const activatedUser = await user.setFeatures(userId, [
    "create:session",
    "read:session",
  ]);
  return activatedUser;
}

Portanto, podemos criar mais um test no users/post.test.js cobrindo esse caso de um usuário logado tentando criar um outro usuário, situação essa que ele deveria receber um 403 Forbidden:

./tests/integration/api/v1/users/post.test.js
...
  describe("Default user", () => {
    test("With unique and valid data", async () => {
      const user1 = await orchestrator.createUser();
      await orchestrator.activateUser(user1);
      const user1SessionObject = await orchestrator.createSession(user1.id);

      const user2Response = await fetch("http://localhost:3000/api/v1/users", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Cookie: `session_id=${user1SessionObject.token}`,
        },
        body: JSON.stringify({
          username: "usuariologado",
          password: "senha123",
        }),
      });
      expect(user2Response.status).toBe(403);
      const response2Body = await user2Response.json();
      expect(response2Body).toEqual({
        name: "ForbiddenError",
        message: "Você não possui permissão para executar esta ação.",
        action:
          'Verifique se o seu usuário possui a feature: "create:user"',
        status_code: 403,
      });
    });
  });

Success

Pronto, agora os nossos endpoints de /activations e /users já estão sendo bloqueados para quem não possui o devido acesso!