diff --git a/infra/controller.js b/infra/controller.js index c8ec44c..27a8336 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, }); diff --git a/infra/errors.js b/infra/errors.js index 93047a9..e9c32a9 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -81,6 +81,27 @@ export class NotFoundError extends Error { } } +export class UnauthorizedError extends Error { + constructor({ cause, message, action }) { + super(message || "Usuario não autenticado.", { + 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, + statusCode: this.statusCode, + }; + } +} + export class MethodNotAllowedError extends Error { constructor() { super("Metodo não permitido para este endpoint."); diff --git a/infra/migrations/1781379456775_create-sessions.js b/infra/migrations/1781379456775_create-sessions.js new file mode 100644 index 0000000..f28b89d --- /dev/null +++ b/infra/migrations/1781379456775_create-sessions.js @@ -0,0 +1,39 @@ +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..e0321dc --- /dev/null +++ b/models/authentication.js @@ -0,0 +1,60 @@ +import password from "models/password"; +import user from "models/user"; +import { NotFoundError, UnauthorizedError } from "infra/errors"; + +async function getAuthenticateUser(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 autonticaçã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 os dados está 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 os dados está correto.", + }); + } + } +} + +const authentication = { + getAuthenticateUser, +}; + +export default authentication; diff --git a/models/sessions.js b/models/sessions.js new file mode 100644 index 0000000..12ebb19 --- /dev/null +++ b/models/sessions.js @@ -0,0 +1,35 @@ +import crypto from "node:crypto"; +import database from "infra/database"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; + +async function create(userId) { + const token = crypto.randomBytes(48).toString("hex"); + const expires_at = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newSession = await runInsertQuery(token, userId, expires_at); + return newSession; + + async function runInsertQuery(token, userId, expires_at) { + const results = await database.query({ + text: ` + INSERT INTO + sessions (token, user_id, expires_at) + VALUES + ($1, $2, $3) + RETURNING + * + `, + values: [token, userId, expires_at], + }); + + return results.rows[0]; + } +} + +const session = { + create, + EXPIRATION_IN_MILLISECONDS, +}; + +export default session; diff --git a/models/user.js b/models/user.js index 8bcd39f..fd79710 100644 --- a/models/user.js +++ b/models/user.js @@ -33,6 +33,37 @@ async function findOneByUsername(username) { } } +async function findOneByEmail(email) { + const userFound = await runSelectQuery(email); + + return userFound; + + async function runSelectQuery(email) { + const result = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + LOWER(email) = LOWER($1) + LIMIT + 1 + ;`, + values: [email], + }); + + if (result.rowCount === 0) { + throw new NotFoundError({ + message: "O email informando não foi encotrado no sistema.", + action: "Verificque se o email está digitando corretamente.", + }); + } + + return result.rows[0]; + } +} + async function create(userInputValue) { await validateUniqueEmail(userInputValue.email); await validateUniqueUsername(userInputValue.username); @@ -158,6 +189,7 @@ async function hashPasswordInObject(userInputValue) { const user = { create, findOneByUsername, + findOneByEmail, update, }; diff --git a/package-lock.json b/package-lock.json index aa52d91..4784782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "1.0.2", "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "next": "16.2.6", @@ -38,7 +39,8 @@ "husky": "9.1.7", "jest": "30.4.2", "npm-run-all": "^4.1.5", - "prettier": "3.8.3" + "prettier": "3.8.3", + "set-cookie-parser": "2.7.1" } }, "node_modules/@babel/code-frame": { @@ -4398,6 +4400,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.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", @@ -10247,6 +10258,13 @@ "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==", + "dev": true, + "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 add4544..f78a9de 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "1.0.2", "dotenv": "17.4.2", "dotenv-expand": "13.0.0", "next": "16.2.6", @@ -54,7 +55,8 @@ "husky": "9.1.7", "jest": "30.4.2", "npm-run-all": "^4.1.5", - "prettier": "3.8.3" + "prettier": "3.8.3", + "set-cookie-parser": "2.7.1" }, "config": { "commitizen": { diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js new file mode 100644 index 0000000..04c8327 --- /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/sessions"; + +const router = createRouter(); + +router.post(postHandler); + +export default router.handler(controller.errorHandlers); + +async function postHandler(request, response) { + const userInputValue = request.body; + + const authenticatedUser = await authentication.getAuthenticateUser( + userInputValue.email, + userInputValue.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/teste/integration/api/v1/sessions/post.test.js b/teste/integration/api/v1/sessions/post.test.js new file mode 100644 index 0000000..5a40493 --- /dev/null +++ b/teste/integration/api/v1/sessions/post.test.js @@ -0,0 +1,147 @@ +import { version as uuidVersion } from "uuid"; +import setCookieParser from "set-cookie-parser"; +import orchestrator from "../orchestrator"; +import session from "models/sessions"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("POST to /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: "guimars@gmail.com", + password: "senha-correta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autonticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + statusCode: 401, + }); + }); + + test("With correct `email` but incorrect `password`", async () => { + await orchestrator.createUser({ + email: "email.correto@gmail.com", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email.correto@gmail.com", + password: "senha-incorreta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autonticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + statusCode: 401, + }); + }); + + test("With incorrect `email` and incorrect `password`", async () => { + await orchestrator.createUser({ + username: "teste", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email.incorreto@gmail.com", + password: "senha-incorreta", + }), + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autonticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + statusCode: 401, + }); + }); + + test("With correct `email` and correct `password`", async () => { + const createdUser = await orchestrator.createUser({ + email: "tudo.correto@gmail.com", + password: "tudocorreto", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "tudo.correto@gmail.com", + password: "tudocorreto", + }), + }); + + 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)).toEqual(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, + }); + }); + }); +});