diff --git a/infra/controller.js b/infra/controller.js index 27a8336..da0019b 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,3 +1,5 @@ +import * as cookie from "cookie"; +import session from "models/session"; import { InternalServerError, MethodNotAllowedError, @@ -29,11 +31,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/models/session.js b/models/session.js new file mode 100644 index 0000000..db23a08 --- /dev/null +++ b/models/session.js @@ -0,0 +1,96 @@ +import crypto from "node:crypto"; +import database from "infra/database"; +import { UnauthorizedError } from "infra/errors"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; + +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: "Usuario 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 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]; + } +} + +async function renew(sessionId) { + const expires_at = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const renewedSessionObject = runUpdateQuery(sessionId, expires_at); + return renewedSessionObject; + + async function runUpdateQuery(sessionId, expires_at) { + const results = await database.query({ + text: ` + UPDATE + sessions + SET + expires_at = $1, + updated_at = NOW() + WHERE + id = $2 + RETURNING + * + ;`, + values: [expires_at, sessionId], + }); + + return results.rows[0]; + } +} + +const session = { + create, + renew, + findOneValidByToken, + EXPIRATION_IN_MILLISECONDS, +}; + +export default session; diff --git a/models/sessions.js b/models/sessions.js deleted file mode 100644 index 12ebb19..0000000 --- a/models/sessions.js +++ /dev/null @@ -1,35 +0,0 @@ -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 fd79710..5eb7cff 100644 --- a/models/user.js +++ b/models/user.js @@ -2,6 +2,37 @@ import database from "infra/database"; import password from "models/password"; import { ValidationError, NotFoundError } from "infra/errors"; +async function findOneById(id) { + const userFound = await runSelectQuery(id); + + return userFound; + + async function runSelectQuery(id) { + const result = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + id = $1 + LIMIT + 1 + ;`, + values: [id], + }); + + if (result.rowCount === 0) { + throw new NotFoundError({ + message: "O id informando não foi encotrado no sistema.", + action: "Verificque se o id está digitando corretamente.", + }); + } + + return result.rows[0]; + } +} + async function findOneByUsername(username) { const userFound = await runSelectQuery(username); @@ -188,6 +219,7 @@ async function hashPasswordInObject(userInputValue) { const user = { create, + findOneById, findOneByUsername, findOneByEmail, update, diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 04c8327..435994d 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,8 +1,8 @@ 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"; +import session from "models/session"; const router = createRouter(); @@ -20,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..6bf9a0c --- /dev/null +++ b/pages/api/v1/user/index.js @@ -0,0 +1,22 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import user from "models/user"; +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); + return response.status(200).json(userFound); +} diff --git a/teste/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js similarity index 100% rename from teste/integration/api/v1/migrations/get.test.js rename to tests/integration/api/v1/migrations/get.test.js diff --git a/teste/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js similarity index 100% rename from teste/integration/api/v1/migrations/post.test.js rename to tests/integration/api/v1/migrations/post.test.js diff --git a/teste/integration/api/v1/orchestrator.js b/tests/integration/api/v1/orchestrator.js similarity index 89% rename from teste/integration/api/v1/orchestrator.js rename to tests/integration/api/v1/orchestrator.js index 3d31804..fb885b2 100644 --- a/teste/integration/api/v1/orchestrator.js +++ b/tests/integration/api/v1/orchestrator.js @@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker"; import database from "infra/database"; import migrator from "models/migrator"; import user from "models/user"; +import session from "models/session"; async function waitForAllServices() { await waitForWebServer(); @@ -29,6 +30,10 @@ async function clearDatabase() { await database.query("drop schema public cascade; create schema public;"); } +async function createSession(userId) { + return await session.create(userId); +} + async function runPendingMigrations() { await migrator.runPendingMigrations(); } @@ -47,6 +52,7 @@ const orchestrator = { clearDatabase, runPendingMigrations, createUser, + createSession, }; export default orchestrator; diff --git a/teste/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js similarity index 99% rename from teste/integration/api/v1/sessions/post.test.js rename to tests/integration/api/v1/sessions/post.test.js index 5a40493..6984721 100644 --- a/teste/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -1,7 +1,7 @@ import { version as uuidVersion } from "uuid"; import setCookieParser from "set-cookie-parser"; import orchestrator from "../orchestrator"; -import session from "models/sessions"; +import session from "models/session"; beforeAll(async () => { await orchestrator.waitForAllServices(); diff --git a/teste/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js similarity index 100% rename from teste/integration/api/v1/status/get.test.js rename to tests/integration/api/v1/status/get.test.js diff --git a/teste/integration/api/v1/status/post.test.js b/tests/integration/api/v1/status/post.test.js similarity index 100% rename from teste/integration/api/v1/status/post.test.js rename to tests/integration/api/v1/status/post.test.js 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..3d260f2 --- /dev/null +++ b/tests/integration/api/v1/user/get.test.js @@ -0,0 +1,186 @@ +import { version as uuidVersion } from "uuid"; +import setCookieParser from "set-cookie-parser"; +import orchestrator from "../orchestrator"; +import session from "models/session"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("GET to /api/v1/user", () => { + describe("Default user", () => { + test("With valid session", async () => { + const createUser = await orchestrator.createUser({ + username: "UserWithValidSession", + }); + + const sessionObject = await orchestrator.createSession(createUser.id); + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: createUser.id, + username: "UserWithValidSession", + email: createUser.email, + password: createUser.password, + created_at: createUser.created_at.toISOString(), + update_at: createUser.update_at.toISOString(), + }); + + expect(uuidVersion(responseBody.id)).toEqual(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.update_at)).not.toBeNaN(); + + // Sessions renewal 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 = + "2d4f8f5c80a218d08cd8e4352a70e454e84204be7890ea68e5743bf2a76efd3ba2d1549694bc31d4a1767b8953ca0530"; + + 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: "Usuario não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + statusCode: 401, + }); + }); + + test("With session after 15 day", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - (session.EXPIRATION_IN_MILLISECONDS + 1000)), + }); + + const createUser = await orchestrator.createUser({ + username: "UserWithSession15Day", + }); + + const sessionObject = await orchestrator.createSession(createUser.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: "Usuario não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + statusCode: 401, + }); + }); + + test("With session 1 second before expiration", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - (session.EXPIRATION_IN_MILLISECONDS - 1000)), + }); + + const createUser = await orchestrator.createUser({ + username: "UserWithSession", + }); + + const sessionObject = await orchestrator.createSession(createUser.id); + + jest.useRealTimers(); + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: createUser.id, + username: "UserWithSession", + email: createUser.email, + password: createUser.password, + created_at: createUser.created_at.toISOString(), + update_at: createUser.update_at.toISOString(), + }); + }); + + test("With expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), + }); + + const createUser = await orchestrator.createUser({ + username: "UserWithExpiredSession", + }); + + const sessionObject = await orchestrator.createSession(createUser.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: "Usuario não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + statusCode: 401, + }); + }); + }); +}); diff --git a/teste/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js similarity index 100% rename from teste/integration/api/v1/users/[username]/get.test.js rename to tests/integration/api/v1/users/[username]/get.test.js diff --git a/teste/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js similarity index 100% rename from teste/integration/api/v1/users/[username]/patch.test.js rename to tests/integration/api/v1/users/[username]/patch.test.js diff --git a/teste/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js similarity index 100% rename from teste/integration/api/v1/users/post.test.js rename to tests/integration/api/v1/users/post.test.js