diff --git a/infra/controller.js b/infra/controller.js index c7896da..575db71 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -2,10 +2,13 @@ import { InternalServerError, MethodNotAllowedError, NotFoundError, - UnauthorizedErro, + UnauthorizedError, ValidationError, } from "infra/erros.js"; +import * as cookie from "cookie"; +import session from "models/session"; + function onNoMatchHandler(request, response) { const publicErrorObject = new MethodNotAllowedError(); response.status(publicErrorObject.statusCode).json(publicErrorObject); @@ -15,7 +18,7 @@ function onErrorHandler(error, request, response) { if ( error instanceof ValidationError || error instanceof NotFoundError || - error instanceof UnauthorizedErro + error instanceof UnauthorizedError ) { return response.status(error.statusCode).json(error); } @@ -29,11 +32,23 @@ function onErrorHandler(error, request, response) { response.status(publicErrorObject.statusCode).json(publicErrorObject); } +async function setSessionCookie(sessionToken, response) { + const setCookie = cookie.serialize("session_id", sessionToken, { + path: "/", + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + }); + + response.setHeader("Set-Cookie", setCookie); +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, onError: onErrorHandler, }, + setSessionCookie, }; export default controller; diff --git a/infra/erros.js b/infra/erros.js index 44808bb..3a47c3c 100644 --- a/infra/erros.js +++ b/infra/erros.js @@ -79,12 +79,12 @@ export class NotFoundError extends Error { } } -export class UnauthorizedErro extends Error { +export class UnauthorizedError extends Error { constructor({ cause, message, action }) { super(message || "Usuário não autenticado.", { cause, }); - this.name = "UnauthorizedErro"; + this.name = "UnauthorizedError"; this.action = action || "Faça novamente o login para continuar."; this.statusCode = 401; } diff --git a/models/authentication.js b/models/authentication.js index 48cc69d..559ffc4 100644 --- a/models/authentication.js +++ b/models/authentication.js @@ -1,6 +1,6 @@ import user from "models/user.js"; import password from "models/password.js"; -import { NotFoundError, UnauthorizedErro } from "infra/erros"; +import { NotFoundError, UnauthorizedError } from "infra/erros"; async function getAuthenticatedUser(providedEmail, providedPassword) { try { @@ -9,8 +9,8 @@ async function getAuthenticatedUser(providedEmail, providedPassword) { return storedUser; } catch (error) { - if (error instanceof UnauthorizedErro) { - throw new UnauthorizedErro({ + if (error instanceof UnauthorizedError) { + throw new UnauthorizedError({ message: "Dados de autenticação não conferem.", action: "Verifique se os dados enviados estão corretos.", }); @@ -26,7 +26,7 @@ async function getAuthenticatedUser(providedEmail, providedPassword) { storedUser = await user.findOneByEmail(providedEmail); } catch (error) { if (error instanceof NotFoundError) { - throw new UnauthorizedErro({ + throw new UnauthorizedError({ message: "Email não confere.", action: "Verifique se este dado está correto.", }); @@ -45,7 +45,7 @@ async function getAuthenticatedUser(providedEmail, providedPassword) { ); if (!correctPasswordMatch) { - throw new UnauthorizedErro({ + throw new UnauthorizedError({ message: "Senha não confere.", action: "Verifique se este dado está correto.", }); diff --git a/models/session.js b/models/session.js index 5e3ddb4..2e07094 100644 --- a/models/session.js +++ b/models/session.js @@ -1,8 +1,41 @@ import crypto from "node:crypto"; import database from "infra/database.js"; +import { UnauthorizedError } from "infra/erros"; const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 Days +async function findOneValidByToken(sessionToken) { + const sessionFound = await runSelectQuery(sessionToken); + + return sessionFound; + + async function runSelectQuery(sessionToken) { + const results = await database.query({ + text: ` + SELECT + * + FROM + sessions + WHERE + token = $1 + AND expires_at > NOW() + LIMIT + 1 + ;`, + values: [sessionToken], + }); + + if (results.rowCount === 0) { + throw new UnauthorizedError({ + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + }); + } + + return results.rows[0]; + } +} + async function create(userId) { const token = crypto.randomBytes(48).toString("hex"); const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); @@ -26,8 +59,35 @@ async function create(userId) { } } +async function renew(sessionId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + const renewedSessionObject = runUpdateQuery(sessionId, expiresAt); + return renewedSessionObject; + + async function runUpdateQuery(sessionId, expiresAt) { + const results = await database.query({ + text: ` + UPDATE + sessions + SET + expires_at = $2, + updated_at = NOW() + WHERE + id = $1 + RETURNING + * + ;`, + values: [sessionId, expiresAt], + }); + + return results.rows[0]; + } +} + const session = { create, + findOneValidByToken, + renew, EXPIRATION_IN_MILLISECONDS, }; diff --git a/models/user.js b/models/user.js index 817fe0a..4d70dec 100644 --- a/models/user.js +++ b/models/user.js @@ -2,6 +2,37 @@ import database from "infra/database.js"; import password from "models/password.js"; import { NotFoundError, ValidationError } from "infra/erros.js"; +async function findOneById(userId) { + const userFound = await runSelectQuery(userId); + + return userFound; + + async function runSelectQuery(userId) { + const results = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + id = $1 + LIMIT + 1 + ;`, + values: [userId], + }); + + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "O id informado não foi encontrado no sistema.", + action: "Verifique se o id está digitado corretamente.", + }); + } + + return results.rows[0]; + } +} + async function findOneByUsername(username) { const userFound = await runSelectQuery(username); @@ -189,6 +220,7 @@ async function hashPasswordInObject(userInputValues) { const user = { create, + findOneById, findOneByUsername, findOneByEmail, update, diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 94cc334..fcee24f 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,4 +1,3 @@ -import * as cookie from "cookie"; import { createRouter } from "next-connect"; import controller from "infra/controller.js"; @@ -21,13 +20,7 @@ async function postHandler(request, response) { 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); + controller.setSessionCookie(newSession.token, response); return response.status(201).json(newSession); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js new file mode 100644 index 0000000..8b25884 --- /dev/null +++ b/pages/api/v1/user/index.js @@ -0,0 +1,28 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.js"; + +import user from "models/user.js"; +import session from "models/session"; + +const router = createRouter({}); + +router.get(getHandler); + +export default router.handler(controller.errorHandlers); + +async function getHandler(request, response) { + const sessionToken = request.cookies.session_id; + + const sessionObject = await session.findOneValidByToken(sessionToken); + const renewedSessionObject = await session.renew(sessionObject.id); + controller.setSessionCookie(renewedSessionObject.token, response); + + const userFound = await user.findOneById(sessionObject.user_id); + + response.setHeader( + "Cache-Control", + "no-store, no-cache, max-age=0, must-revalidate", + ); + + return response.status(200).json(userFound); +} diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index b112732..90994ff 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -32,7 +32,7 @@ describe("POST /api/v1/sessions", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - name: "UnauthorizedErro", + name: "UnauthorizedError", message: "Dados de autenticação não conferem.", action: "Verifique se os dados enviados estão corretos.", status_code: 401, @@ -60,7 +60,7 @@ describe("POST /api/v1/sessions", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - name: "UnauthorizedErro", + name: "UnauthorizedError", message: "Dados de autenticação não conferem.", action: "Verifique se os dados enviados estão corretos.", status_code: 401, @@ -86,7 +86,7 @@ describe("POST /api/v1/sessions", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - name: "UnauthorizedErro", + name: "UnauthorizedError", message: "Dados de autenticação não conferem.", action: "Verifique se os dados enviados estão corretos.", status_code: 401, diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js new file mode 100644 index 0000000..7475d9d --- /dev/null +++ b/tests/integration/api/v1/user/get.test.js @@ -0,0 +1,125 @@ +import session from "models/session"; +import setCookieParser from "set-cookie-parser"; +import orchestrator from "tests/orchestrator"; +import { version as uuidVersion } from "uuid"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("GET /api/v1/user", () => { + describe("Default user", () => { + test("With valid session", async () => { + const createdUser = await orchestrator.createUser({ + username: "UserWithValidSession", + }); + + const sessionObject = await orchestrator.createSession(createdUser.id); + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const cacheControl = response.headers.get("Cache-Control"); + expect(cacheControl).toEqual( + "no-store, no-cache, max-age=0, must-revalidate", + ); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: createdUser.id, + username: "UserWithValidSession", + email: createdUser.email, + password: createdUser.password, + created_at: createdUser.created_at.toISOString(), + updated_at: createdUser.updated_at.toISOString(), + }); + + expect(uuidVersion(responseBody.id)).toEqual(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + // Session renew assertions + const renewedSessionObject = await session.findOneValidByToken( + sessionObject.token, + ); + + expect( + renewedSessionObject.expires_at > sessionObject.expires_at, + ).toEqual(true); + expect( + renewedSessionObject.updated_at > sessionObject.updated_at, + ).toEqual(true); + + // Set-Cookie assertions + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: sessionObject.token, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + path: "/", + httpOnly: true, + }); + }); + + test("With nonexistent session", async () => { + const nonexistentToken = + "faccbe1ecc51c225a60cf41376a9e1f8cbfd275fad77d8d7b1cc8caf7fccefece38050d57f2dd2ec3769b02ba73e71c8"; + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + Cookie: `session_id=${nonexistentToken}`, + }, + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + status_code: 401, + }); + }); + + test("With expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), + }); + + const createdUser = await orchestrator.createUser({ + username: "UserWithExpiredSession", + }); + + const sessionObject = await orchestrator.createSession(createdUser.id); + + jest.useRealTimers(); + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + status_code: 401, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 4602434..162d1e9 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker"; import database from "infra/database.js"; import migrator from "models/migrator.js"; import user from "models/user.js"; +import session from "models/session"; async function waitForAllServices() { await waitForWebServer(); @@ -41,11 +42,16 @@ async function createUser(userObject) { }); } +async function createSession(userId) { + return await session.create(userId); +} + const orcherstrator = { waitForAllServices, clearDatabase, runPendingMigrations, createUser, + createSession, }; export default orcherstrator;