diff --git a/infra/controller.js b/infra/controller.js index 5c2dd32..68adbf9 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -3,6 +3,7 @@ import { MethodNotAllowedError, ValidationError, NotFoundError, + UnauthorizedError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -11,12 +12,15 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { - if (error instanceof ValidationError || error instanceof NotFoundError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof UnauthorizedError + ) { return response.status(error.statusCode).json(error); } const publicErrorObject = new InternalServerError({ - statusCode: error.statusCode, cause: error, }); console.error(publicErrorObject); diff --git a/infra/errors.js b/infra/errors.js index ac95c2a..b5ba0eb 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -98,3 +98,23 @@ export class NotFoundError extends Error { }; } } + +export class UnauthorizedError extends Error { + constructor({ cause, message, action }) { + super(message || "Usuário não autenticado.", { + cause: cause, + }); + this.name = "UnauthorizedError"; + this.action = action || "Faça novamente o login para continuar."; + this.statusCode = 401; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} diff --git a/infra/migrations/1751376079838_create-sessions.js b/infra/migrations/1751376079838_create-sessions.js new file mode 100644 index 0000000..df7c763 --- /dev/null +++ b/infra/migrations/1751376079838_create-sessions.js @@ -0,0 +1,34 @@ +exports.up = (pgm) => { + pgm.createTable("sessions", { + id: { + type: "uuid", + primaryKey: true, + default: pgm.func("gen_random_uuid()"), + }, + token: { + type: "varchar(96)", + notNull: true, + unique: true, + }, + user_id: { + type: "uuid", + notNull: true, + }, + expires_at: { + type: "timestamptz", + notNull: true, + }, + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + updated_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + }); +}; + +exports.down = false; diff --git a/models/authentication.js b/models/authentication.js new file mode 100644 index 0000000..837dbf3 --- /dev/null +++ b/models/authentication.js @@ -0,0 +1,58 @@ +import user from "./user"; +import password from "./password"; +import { NotFoundError, UnauthorizedError } from "infra/errors"; + +async function getAuthenticatedUser(providedEmail, providedPassword) { + try { + const storedUser = await findUserByEmail(providedEmail); + await validatePassword(providedPassword, storedUser.password); + + return storedUser; + } catch (error) { + if (error instanceof UnauthorizedError) { + throw new UnauthorizedError({ + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + }); + } + + throw error; + } + + async function findUserByEmail(providedEmail) { + let storedUser; + + try { + storedUser = await user.findOneByEmail(providedEmail); + } catch (error) { + if (error instanceof NotFoundError) { + throw new UnauthorizedError({ + message: "Senha não confere.", + action: "Verifique se este dado esta correto.", + }); + } + throw error; + } + + return storedUser; + } + + async function validatePassword(providedPassword, storedPassword) { + const correctPasswordMatch = await password.compare( + providedPassword, + storedPassword, + ); + if (!correctPasswordMatch) { + throw new UnauthorizedError({ + message: "Senha não confere.", + action: "Verifique se este dado esta correto.", + }); + } + } +} + +const authentication = { + getAuthenticatedUser, +}; + +export default authentication; diff --git a/models/session.js b/models/session.js new file mode 100644 index 0000000..05006c8 --- /dev/null +++ b/models/session.js @@ -0,0 +1,35 @@ +import crypto from "node:crypto"; +import database from "infra/database.js"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 Days + +async function create(userId) { + const token = crypto.randomBytes(48).toString("hex"); + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newSession = await runInsertQuery(token, userId, expiresAt); + return newSession; + + async function runInsertQuery(token, userId, expiresAt) { + const results = await database.query({ + text: ` + INSERT INTO + sessions (token, user_id, expires_at) + VALUES + ($1, $2, $3) + RETURNING + * + ;`, + values: [token, userId, expiresAt], + }); + + return results.rows[0]; + } +} + +const session = { + create, + EXPIRATION_IN_MILLISECONDS, +}; + +export default session; diff --git a/models/user.js b/models/user.js index 6aa880c..9f102f1 100644 --- a/models/user.js +++ b/models/user.js @@ -31,6 +31,35 @@ async function findOneByUsername(username) { } } +async function findOneByEmail(email) { + const userFound = await runSelectQuery(email); + return userFound; + + async function runSelectQuery(email) { + const results = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + LOWER(email) = LOWER($1) + LIMIT + 1 + ;`, + values: [email], + }); + + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "O email informado não foi encontrado no sistema.", + action: "Verifique se o email está digitado corretamente.", + }); + } + return results.rows[0]; + } +} + async function create(userInputValues) { await validateUniqueUsername(userInputValues.username); await validateUniqueEmail(userInputValues.email); @@ -156,6 +185,7 @@ async function hashPasswordInObject(userInputValues) { const user = { create, findOneByUsername, + findOneByEmail, update, }; diff --git a/package-lock.json b/package-lock.json index e353a9e..1d995d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@faker-js/faker": "9.7.0", "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "1.0.2", "dotenv": "16.5.0", "dotenv-expand": "12.0.2", "next": "15.3.1", @@ -20,6 +21,7 @@ "pg": "8.15.6", "react": "19.1.0", "react-dom": "19.1.0", + "set-cookie-parser": "2.7.1", "swr": "2.3.3", "uuid": "11.1.0" }, @@ -4335,6 +4337,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -10737,6 +10748,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index bd5ac1e..ef27ae7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@faker-js/faker": "9.7.0", "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "1.0.2", "dotenv": "16.5.0", "dotenv-expand": "12.0.2", "next": "15.3.1", @@ -34,6 +35,7 @@ "pg": "8.15.6", "react": "19.1.0", "react-dom": "19.1.0", + "set-cookie-parser": "2.7.1", "swr": "2.3.3", "uuid": "11.1.0" }, diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js new file mode 100644 index 0000000..294bc4e --- /dev/null +++ b/pages/api/v1/sessions/index.js @@ -0,0 +1,32 @@ +import { createRouter } from "next-connect"; +import * as cookie from "cookie"; +import controller from "infra/controller"; +import authentication from "models/authentication"; +import session from "models/session"; + +const router = createRouter(); + +router.post(postHandler); + +export default router.handler(controller.errorHandlers); + +async function postHandler(request, response) { + const userInputValues = request.body; + + const authenticatedUser = await authentication.getAuthenticatedUser( + userInputValues.email, + userInputValues.password, + ); + + const newSession = await session.create(authenticatedUser.id); + + const setCookie = cookie.serialize("session_id", newSession.token, { + path: "/", + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + }); + response.setHeader("Set-Cookie", setCookie); + + return response.status(201).json(newSession); +} diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js new file mode 100644 index 0000000..86f23ff --- /dev/null +++ b/tests/integration/api/v1/sessions/post.test.js @@ -0,0 +1,144 @@ +import { version as uuidVersion } from "uuid"; +import setCookieParser from "set-cookie-parser"; +import orchestrator from "tests/orchestrator.js"; +import session from "models/session.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("POST api/v1/sessions", () => { + describe("Anonymous user", () => { + test("With incorrect `email` but correct `password`", async () => { + await orchestrator.createUser({ + password: "senha-correta", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "emailerrado@teste.com.br", + password: "senha-correta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With correct `email` but incorrect `password`", async () => { + await orchestrator.createUser({ + email: "emailcorreto@teste.com.br", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "emailcorreto@teste.com.br", + password: "senha-incorreta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With incorrect `email` and incorrect `password`", async () => { + await orchestrator.createUser(); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "emailincorreto@teste.com.br", + password: "senha-incorreta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With correct `email` and correct `password`", async () => { + const createdUser = await orchestrator.createUser({ + email: "tudocerto@teste.com.br", + password: "tudocerto", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "tudocerto@teste.com.br", + password: "tudocerto", + }), + }); + expect(response.status).toBe(201); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + token: responseBody.token, + user_id: createdUser.id, + expires_at: responseBody.expires_at, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.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(); + + const expiresAt = new Date(responseBody.expires_at); + const createdAt = new Date(responseBody.created_at); + + expiresAt.setMilliseconds(0); + createdAt.setMilliseconds(0); + + expect(expiresAt - createdAt).toBe(session.EXPIRATION_IN_MILLISECONDS); + + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: responseBody.token, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + path: "/", + httpOnly: true, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index ba9e829..9307d68 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -34,9 +34,9 @@ async function runPendingMigrations() { async function createUser(userObject) { return await user.create({ username: - userObject.username || faker.internet.username().replace(/[_.-]/g, ""), - email: userObject.email || faker.internet.email(), - password: userObject.password || "validpassword", + userObject?.username || faker.internet.username().replace(/[_.-]/g, ""), + email: userObject?.email || faker.internet.email(), + password: userObject?.password || "validpassword", }); }