diff --git a/backend/.env.example b/backend/.env.example index 57af6cb..4ad01c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,9 @@ POSTGRES_USER=sinapse POSTGRES_PASSWORD=sinapse_dev_password POSTGRES_DB=sinapse AI_SERVICE_URL=http://localhost:8000 + +# S1-01 — Autenticação e segurança +AUTH_MAX_LOGIN_ATTEMPTS=5 +AUTH_LOCKOUT_MINUTES=15 +AUTH_SESSION_IDLE_MINUTES=30 +AUTH_SESSION_MAX_HOURS=12 \ No newline at end of file diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 03e6693..956560b 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -16,6 +16,12 @@ const envSchema = z.object({ POSTGRES_HOST: z.string().default("localhost"), POSTGRES_PORT: z.coerce.number().default(5432), AI_SERVICE_URL: z.string().default("http://localhost:8000"), + + // S1-01 — Autenticação e segurança + AUTH_MAX_LOGIN_ATTEMPTS: z.coerce.number().int().min(1).default(5), + AUTH_LOCKOUT_MINUTES: z.coerce.number().int().min(1).default(15), + AUTH_SESSION_IDLE_MINUTES: z.coerce.number().int().min(1).default(30), + AUTH_SESSION_MAX_HOURS: z.coerce.number().int().min(1).default(12), }); -export const env = envSchema.parse(process.env); +export const env = envSchema.parse(process.env); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 6c7f46a..7a6e992 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,13 +3,17 @@ import cors from "cors"; import { env } from "./config/env.js"; import { checkDatabaseConnection } from "./database/db.js"; import { projectsRouter } from "./modules/projects/projects.routes.js"; +import { authRouter } from "./modules/auth/auth.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; +import { requireAuth } from "./middleware/requireAuth.js"; export const app = express(); app.use(cors()); app.use(express.json()); +app.use("/api/v1/auth", authRouter); + // Health Check Endpoint app.get("/health", async (_req: Request, res: Response) => { const dbHealthy = await checkDatabaseConnection(); @@ -27,8 +31,8 @@ app.get("/health", async (_req: Request, res: Response) => { }); // Projects API Endpoints (v1 e alias) -app.use("/api/v1/projects", projectsRouter); -app.use("/api/projects", projectsRouter); +app.use("/api/v1/projects", requireAuth, projectsRouter); +app.use("/api/projects", requireAuth, projectsRouter); // Root Information Endpoint app.get("/api/v1", (_req: Request, res: Response) => { diff --git a/backend/src/middleware/requireAuth.test.ts b/backend/src/middleware/requireAuth.test.ts new file mode 100644 index 0000000..682b24d --- /dev/null +++ b/backend/src/middleware/requireAuth.test.ts @@ -0,0 +1,151 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; + +import { createRequireAuth } from "./requireAuth.js"; +import { SessionService } from "../modules/auth/session.service.js"; +import { + SessionRecord, + SessionWithUser, +} from "../modules/auth/auth.types.js"; + +class MockSessionRepository { + public session: SessionWithUser | null = null; + + async createSession( + userId: string, + tokenHash: string, + ): Promise { + return { + id: "session-1", + usuario_id: userId, + token_hash: tokenHash, + created_at: new Date(), + ultima_atividade_em: new Date(), + revogada_em: null, + }; + } + + async findSessionByTokenHash(): Promise { + return this.session; + } + + async touchSession(): Promise {} + + async revokeSessionByTokenHash(): Promise {} +} + +test("requireAuth protege rotas privadas", async (t) => { + const repository = new MockSessionRepository(); + + const sessionService = new SessionService(repository); + + const app = express(); + + app.get( + "/private", + createRequireAuth(sessionService), + (req, res) => { + res.status(200).json({ + user: req.auth, + }); + }, + ); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = app.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + + baseUrl = + `http://127.0.0.1:${address.port}/private`; + + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + await t.test( + "deve retornar 401 sem cookie de sessão", + async () => { + const response = await fetch(baseUrl); + + assert.equal(response.status, 401); + + const body = await response.json() as { + code: string; + }; + + assert.equal(body.code, "UNAUTHORIZED"); + }, + ); + + await t.test( + "deve retornar 401 para sessão inválida", + async () => { + repository.session = null; + + const response = await fetch(baseUrl, { + headers: { + Cookie: "sinapse_session=token-invalido", + }, + }); + + assert.equal(response.status, 401); + }, + ); + + await t.test( + "deve permitir acesso com sessão válida", + async () => { + const now = new Date(); + + repository.session = { + id: "session-1", + usuario_id: "user-1", + token_hash: "hash", + created_at: now, + ultima_atividade_em: now, + revogada_em: null, + nome: "Usuário Teste", + email: "usuario@example.com", + role: "po", + ativo: true, + }; + + const response = await fetch(baseUrl, { + headers: { + Cookie: "sinapse_session=token-valido", + }, + }); + + assert.equal(response.status, 200); + + const body = await response.json() as { + user: { + id: string; + email: string; + role: string; + }; + }; + + assert.equal(body.user.id, "user-1"); + assert.equal( + body.user.email, + "usuario@example.com", + ); + assert.equal(body.user.role, "po"); + }, + ); +}); \ No newline at end of file diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts new file mode 100644 index 0000000..ce7bdce --- /dev/null +++ b/backend/src/middleware/requireAuth.ts @@ -0,0 +1,52 @@ +import { + NextFunction, + Request, + Response, +} from "express"; + +import { getSessionToken } from "../modules/auth/auth.cookies.js"; +import { + SessionService, + sessionService, +} from "../modules/auth/session.service.js"; + +export function createRequireAuth( + service: SessionService = sessionService, +) { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const token = getSessionToken(req); + + if (!token) { + res.status(401).json({ + error: "Autenticação necessária.", + code: "UNAUTHORIZED", + }); + return; + } + + const result = await service.validateSession(token); + + if (!result.valid) { + res.status(401).json({ + error: "Sessão inválida ou expirada.", + code: "UNAUTHORIZED", + }); + return; + } + + req.auth = result.user; + req.sessionToken = token; + + next(); + } catch (error) { + next(error); + } + }; +} + +export const requireAuth = createRequireAuth(); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.constants.ts b/backend/src/modules/auth/auth.constants.ts new file mode 100644 index 0000000..b7f38f9 --- /dev/null +++ b/backend/src/modules/auth/auth.constants.ts @@ -0,0 +1 @@ +export const SESSION_COOKIE_NAME = "sinapse_session"; \ No newline at end of file diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..ee3a8b8 --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -0,0 +1,124 @@ +import { + NextFunction, + Request, + Response, +} from "express"; + +import { env } from "../../config/env.js"; +import { getSessionToken } from "./auth.cookies.js"; +import { SESSION_COOKIE_NAME } from "./auth.constants.js"; +import { + authService, + AuthService, +} from "./auth.service.js"; +import { + sessionService, + SessionService, +} from "./session.service.js"; +import { loginSchema } from "./auth.types.js"; + +export class AuthController { + constructor( + private readonly service: AuthService = authService, + private readonly sessions: SessionService = sessionService, + ) {} + + login = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const parsed = loginSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ + error: "Dados de autenticação inválidos.", + code: "VALIDATION_ERROR", + details: parsed.error.flatten(), + }); + return; + } + + const result = await this.service.login(parsed.data); + + if (!result.success) { + if (result.reason === "inactive_user") { + res.status(403).json({ + error: + "Usuário inativo. Entre em contato com o administrador.", + code: "USER_INACTIVE", + }); + return; + } + + if (result.reason === "temporarily_locked") { + res.status(429).json({ + error: + "Muitas tentativas de autenticação. Tente novamente mais tarde.", + code: "LOGIN_TEMPORARILY_LOCKED", + }); + return; + } + + res.status(401).json({ + error: "Credenciais inválidas.", + code: "INVALID_CREDENTIALS", + }); + return; + } + + res.cookie(SESSION_COOKIE_NAME, result.token, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: + env.AUTH_SESSION_MAX_HOURS * 60 * 60 * 1000, + }); + + res.status(200).json({ + user: result.user, + }); + } catch (error) { + next(error); + } + }; + + logout = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const token = + req.sessionToken ?? getSessionToken(req); + + if (token) { + await this.sessions.revokeSession(token); + } + + res.clearCookie(SESSION_COOKIE_NAME, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + + res.status(204).send(); + } catch (error) { + next(error); + } + }; + + me = async ( + req: Request, + res: Response, + ): Promise => { + res.status(200).json({ + user: req.auth, + }); + }; +} + +export const authController = new AuthController(); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.cookies.ts b/backend/src/modules/auth/auth.cookies.ts new file mode 100644 index 0000000..fa31c00 --- /dev/null +++ b/backend/src/modules/auth/auth.cookies.ts @@ -0,0 +1,27 @@ +import { Request } from "express"; + +import { SESSION_COOKIE_NAME } from "./auth.constants.js"; + +export function getSessionToken( + req: Request, +): string | null { + const cookieHeader = req.headers.cookie; + + if (!cookieHeader) { + return null; + } + + const cookies = cookieHeader.split(";"); + + for (const cookie of cookies) { + const [rawName, ...rawValue] = cookie.trim().split("="); + + if (rawName === SESSION_COOKIE_NAME) { + const value = rawValue.join("="); + + return value ? decodeURIComponent(value) : null; + } + } + + return null; +} \ No newline at end of file diff --git a/backend/src/modules/auth/auth.repository.ts b/backend/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..68b91df --- /dev/null +++ b/backend/src/modules/auth/auth.repository.ts @@ -0,0 +1,168 @@ +import { Pool } from "pg"; + +import { pool } from "../../database/db.js"; +import { + SessionRecord, + SessionWithUser, + UserRecord, +} from "./auth.types.js"; + +export class AuthRepository { + private pool: Pool; + + constructor(customPool?: Pool) { + this.pool = customPool ?? pool; + } + + async findUserByEmail(email: string): Promise { + const result = await this.pool.query( + ` + SELECT + id, + nome, + email, + senha_hash, + role, + ativo, + tentativas_login, + bloqueado_ate, + created_at, + updated_at + FROM usuario + WHERE LOWER(email) = LOWER($1) + LIMIT 1 + `, + [email.trim()], + ); + + return result.rows[0] ?? null; + } + + async recordFailedLogin( + userId: string, + maxAttempts: number, + lockoutMinutes: number, + ): Promise { + const result = await this.pool.query( + ` + UPDATE usuario + SET + tentativas_login = tentativas_login + 1, + bloqueado_ate = CASE + WHEN tentativas_login + 1 >= $2 + THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 minute') + ELSE bloqueado_ate + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING + id, + nome, + email, + senha_hash, + role, + ativo, + tentativas_login, + bloqueado_ate, + created_at, + updated_at + `, + [userId, maxAttempts, lockoutMinutes], + ); + + return result.rows[0] ?? null; + } + + async resetLoginAttempts(userId: string): Promise { + await this.pool.query( + ` + UPDATE usuario + SET + tentativas_login = 0, + bloqueado_ate = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, + [userId], + ); + } + + async createSession( + userId: string, + tokenHash: string, + ): Promise { + const result = await this.pool.query( + ` + INSERT INTO sessao ( + usuario_id, + token_hash + ) + VALUES ($1, $2) + RETURNING + id, + usuario_id, + token_hash, + created_at, + ultima_atividade_em, + revogada_em + `, + [userId, tokenHash], + ); + + return result.rows[0]; + } + + async findSessionByTokenHash( + tokenHash: string, + ): Promise { + const result = await this.pool.query( + ` + SELECT + s.id, + s.usuario_id, + s.token_hash, + s.created_at, + s.ultima_atividade_em, + s.revogada_em, + u.nome, + u.email, + u.role, + u.ativo + FROM sessao s + INNER JOIN usuario u + ON u.id = s.usuario_id + WHERE s.token_hash = $1 + LIMIT 1 + `, + [tokenHash], + ); + + return result.rows[0] ?? null; + } + + async touchSession(sessionId: string): Promise { + await this.pool.query( + ` + UPDATE sessao + SET ultima_atividade_em = CURRENT_TIMESTAMP + WHERE id = $1 + AND revogada_em IS NULL + `, + [sessionId], + ); + } + + async revokeSessionByTokenHash(tokenHash: string): Promise { + await this.pool.query( + ` + UPDATE sessao + SET revogada_em = CURRENT_TIMESTAMP + WHERE token_hash = $1 + AND revogada_em IS NULL + `, + [tokenHash], + ); + } +} + +export const authRepository = new AuthRepository(); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.routes.test.ts b/backend/src/modules/auth/auth.routes.test.ts new file mode 100644 index 0000000..1ca4d2a --- /dev/null +++ b/backend/src/modules/auth/auth.routes.test.ts @@ -0,0 +1,461 @@ +import test, { after, before } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import { AddressInfo } from "node:net"; +import { Server } from "node:http"; + +import { AuthController } from "./auth.controller.js"; +import { AuthService } from "./auth.service.js"; +import { hashPassword } from "./pssword.service.js"; +import { SessionService } from "./session.service.js"; +import { createRequireAuth } from "../../middleware/requireAuth.js"; +import { + SessionRecord, + SessionWithUser, + UserRecord, +} from "./auth.types.js"; + +class MockAuthRepository { + public user: UserRecord | null = null; + public failedLoginCalls = 0; + public resetCalls = 0; + + async findUserByEmail( + _email: string, + ): Promise { + return this.user; + } + + async recordFailedLogin(): Promise { + this.failedLoginCalls += 1; + + if (this.user) { + this.user = { + ...this.user, + tentativas_login: + this.user.tentativas_login + 1, + }; + } + + return this.user; + } + + async resetLoginAttempts(): Promise { + this.resetCalls += 1; + + if (this.user) { + this.user = { + ...this.user, + tentativas_login: 0, + bloqueado_ate: null, + }; + } + } +} + +class MockSessionRepository { + public session: SessionRecord | null = null; + public user: UserRecord | null = null; + + async createSession( + userId: string, + tokenHash: string, + ): Promise { + this.session = { + id: "session-1", + usuario_id: userId, + token_hash: tokenHash, + created_at: new Date(), + ultima_atividade_em: new Date(), + revogada_em: null, + }; + + return this.session; + } + + async findSessionByTokenHash( + tokenHash: string, + ): Promise { + if ( + !this.session || + !this.user || + this.session.token_hash !== tokenHash + ) { + return null; + } + + return { + ...this.session, + nome: this.user.nome, + email: this.user.email, + role: this.user.role, + ativo: this.user.ativo, + }; + } + + async touchSession( + _sessionId: string, + ): Promise { + if (this.session) { + this.session = { + ...this.session, + ultima_atividade_em: new Date(), + }; + } + } + + async revokeSessionByTokenHash( + tokenHash: string, + ): Promise { + if ( + this.session && + this.session.token_hash === tokenHash + ) { + this.session = { + ...this.session, + revogada_em: new Date(), + }; + } + } +} + +async function createUser( + overrides: Partial = {}, +): Promise { + return { + id: "user-1", + nome: "Usuário Teste", + email: "usuario@example.com", + senha_hash: await hashPassword( + "SenhaCorreta123!", + ), + role: "po", + ativo: true, + tentativas_login: 0, + bloqueado_ate: null, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +test("Testes HTTP - Autenticação e sessão", async (t) => { + const authRepository = new MockAuthRepository(); + const sessionRepository = + new MockSessionRepository(); + + const sessions = new SessionService( + sessionRepository, + ); + + const authService = new AuthService( + authRepository, + sessions, + ); + + const controller = new AuthController( + authService, + sessions, + ); + + const requireAuth = + createRequireAuth(sessions); + + const app = express(); + + app.use(express.json()); + + app.post( + "/api/v1/auth/login", + controller.login, + ); + + app.get( + "/api/v1/auth/me", + requireAuth, + controller.me, + ); + + app.post( + "/api/v1/auth/logout", + requireAuth, + controller.logout, + ); + + let server: Server; + let baseUrl: string; + + before(async () => { + await new Promise((resolve) => { + server = app.listen( + 0, + "127.0.0.1", + () => { + const address = + server.address() as AddressInfo; + + baseUrl = + `http://127.0.0.1:${address.port}`; + + resolve(); + }, + ); + }); + }); + + after(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + await t.test( + "credenciais inexistentes e senha incorreta retornam a mesma resposta", + async () => { + authRepository.user = null; + + const nonexistent = await fetch( + `${baseUrl}/api/v1/auth/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "naoexiste@example.com", + password: "SenhaErrada123!", + }), + }, + ); + + authRepository.user = + await createUser(); + + const wrongPassword = await fetch( + `${baseUrl}/api/v1/auth/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "usuario@example.com", + password: "SenhaErrada123!", + }), + }, + ); + + assert.equal( + nonexistent.status, + 401, + ); + + assert.equal( + wrongPassword.status, + 401, + ); + + const nonexistentBody = + await nonexistent.json() as { + error: string; + code: string; + }; + + const wrongPasswordBody = + await wrongPassword.json() as { + error: string; + code: string; + }; + + assert.deepEqual( + nonexistentBody, + wrongPasswordBody, + ); + + assert.equal( + nonexistentBody.code, + "INVALID_CREDENTIALS", + ); + }, + ); + + await t.test( + "usuário inativo não consegue autenticar", + async () => { + authRepository.user = + await createUser({ + ativo: false, + }); + + const response = await fetch( + `${baseUrl}/api/v1/auth/login`, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + email: + "usuario@example.com", + password: + "SenhaCorreta123!", + }), + }, + ); + + assert.equal(response.status, 403); + + const body = + await response.json() as { + code: string; + }; + + assert.equal( + body.code, + "USER_INACTIVE", + ); + }, + ); + + await t.test( + "login cria cookie HttpOnly e permite consultar /me", + async () => { + const user = await createUser(); + + authRepository.user = user; + sessionRepository.user = user; + + const loginResponse = await fetch( + `${baseUrl}/api/v1/auth/login`, + { + method: "POST", + headers: { + "Content-Type": + "application/json", + }, + body: JSON.stringify({ + email: + "usuario@example.com", + password: + "SenhaCorreta123!", + }), + }, + ); + + assert.equal( + loginResponse.status, + 200, + ); + + const setCookie = + loginResponse.headers.get( + "set-cookie", + ); + + assert.ok(setCookie); + + assert.match( + setCookie, + /sinapse_session=/, + ); + + assert.match( + setCookie, + /HttpOnly/i, + ); + + const loginBody = + await loginResponse.json() as { + user: { + id: string; + email: string; + role: string; + }; + token?: string; + }; + + assert.equal( + loginBody.user.id, + "user-1", + ); + + assert.equal( + loginBody.token, + undefined, + ); + + const cookie = + setCookie.split(";")[0]; + + const meResponse = await fetch( + `${baseUrl}/api/v1/auth/me`, + { + headers: { + Cookie: cookie, + }, + }, + ); + + assert.equal( + meResponse.status, + 200, + ); + + const meBody = + await meResponse.json() as { + user: { + id: string; + email: string; + role: string; + }; + }; + + assert.equal( + meBody.user.id, + "user-1", + ); + + assert.equal( + meBody.user.email, + "usuario@example.com", + ); + + assert.equal( + meBody.user.role, + "po", + ); + + const logoutResponse = + await fetch( + `${baseUrl}/api/v1/auth/logout`, + { + method: "POST", + headers: { + Cookie: cookie, + }, + }, + ); + + assert.equal( + logoutResponse.status, + 204, + ); + + const afterLogout = + await fetch( + `${baseUrl}/api/v1/auth/me`, + { + headers: { + Cookie: cookie, + }, + }, + ); + + assert.equal( + afterLogout.status, + 401, + ); + }, + ); +}); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.routes.ts b/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..c83f18c --- /dev/null +++ b/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; + +import { requireAuth } from "../../middleware/requireAuth.js"; +import { authController } from "./auth.controller.js"; + +export const authRouter = Router(); + +authRouter.post("/login", authController.login); +authRouter.post("/logout", requireAuth, authController.logout); +authRouter.get("/me", requireAuth, authController.me); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.service.test.ts b/backend/src/modules/auth/auth.service.test.ts new file mode 100644 index 0000000..0c0e353 --- /dev/null +++ b/backend/src/modules/auth/auth.service.test.ts @@ -0,0 +1,292 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { AuthService } from "./auth.service.js"; +import { hashPassword } from "./pssword.service.js"; +import { + SessionRecord, + UserRecord, +} from "./auth.types.js"; + +class MockAuthRepository { + public user: UserRecord | null = null; + + public failedLoginCalls = 0; + public resetCalls = 0; + + public failedLoginResult: UserRecord | null = null; + + async findUserByEmail(): Promise { + return this.user; + } + + async recordFailedLogin(): Promise { + this.failedLoginCalls += 1; + + return this.failedLoginResult ?? this.user; + } + + async resetLoginAttempts(): Promise { + this.resetCalls += 1; + } +} + +class MockSessionService { + public createCalls = 0; + + async createSession( + userId: string, + ): Promise<{ + token: string; + session: SessionRecord; + }> { + this.createCalls += 1; + + return { + token: "token-seguro", + session: { + id: "session-1", + usuario_id: userId, + token_hash: "hash", + created_at: new Date(), + ultima_atividade_em: new Date(), + revogada_em: null, + }, + }; + } +} + +async function createUser( + overrides: Partial = {}, +): Promise { + return { + id: "user-1", + nome: "Usuário Teste", + email: "usuario@example.com", + senha_hash: await hashPassword("SenhaCorreta123!"), + role: "po", + ativo: true, + tentativas_login: 0, + bloqueado_ate: null, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +test("deve autenticar usuário ativo com credenciais válidas", async () => { + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser(); + + const service = new AuthService( + repository, + sessions, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaCorreta123!", + }); + + assert.equal(result.success, true); + assert.equal(repository.resetCalls, 1); + assert.equal(sessions.createCalls, 1); + + if (result.success) { + assert.equal(result.token, "token-seguro"); + assert.equal(result.user.id, "user-1"); + assert.equal(result.user.role, "po"); + } +}); + +test("deve retornar erro genérico para usuário inexistente", async () => { + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + const service = new AuthService( + repository, + sessions, + ); + + const result = await service.login({ + email: "naoexiste@example.com", + password: "SenhaQualquer123!", + }); + + assert.equal(result.success, false); + + if (!result.success) { + assert.equal( + result.reason, + "invalid_credentials", + ); + } + + assert.equal(sessions.createCalls, 0); +}); + +test("deve rejeitar senha incorreta com mensagem genérica", async () => { + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser(); + + const service = new AuthService( + repository, + sessions, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaErrada123!", + }); + + assert.equal(result.success, false); + assert.equal(repository.failedLoginCalls, 1); + assert.equal(sessions.createCalls, 0); + + if (!result.success) { + assert.equal( + result.reason, + "invalid_credentials", + ); + } +}); + +test("deve recusar usuário inativo mesmo com senha correta", async () => { + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser({ + ativo: false, + }); + + const service = new AuthService( + repository, + sessions, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaCorreta123!", + }); + + assert.equal(result.success, false); + assert.equal(sessions.createCalls, 0); + + if (!result.success) { + assert.equal( + result.reason, + "inactive_user", + ); + } +}); + +test("deve bloquear login durante período de bloqueio", async () => { + const now = new Date(); + + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser({ + tentativas_login: 5, + bloqueado_ate: new Date( + now.getTime() + 10 * 60 * 1000, + ), + }); + + const service = new AuthService( + repository, + sessions, + () => now, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaCorreta123!", + }); + + assert.equal(result.success, false); + assert.equal(sessions.createCalls, 0); + + if (!result.success) { + assert.equal( + result.reason, + "temporarily_locked", + ); + } +}); + +test("deve liberar nova tentativa após expiração do bloqueio", async () => { + const now = new Date(); + + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser({ + tentativas_login: 5, + bloqueado_ate: new Date( + now.getTime() - 60_000, + ), + }); + + const service = new AuthService( + repository, + sessions, + () => now, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaCorreta123!", + }); + + assert.equal(result.success, true); + + // Um reset quando detecta bloqueio expirado + // e outro após autenticação bem-sucedida. + assert.equal(repository.resetCalls, 2); + + assert.equal(sessions.createCalls, 1); +}); + +test("deve bloquear ao atingir o limite de tentativas inválidas", async () => { + const now = new Date(); + + const repository = new MockAuthRepository(); + const sessions = new MockSessionService(); + + repository.user = await createUser({ + tentativas_login: 4, + }); + + repository.failedLoginResult = { + ...repository.user, + tentativas_login: 5, + bloqueado_ate: new Date( + now.getTime() + 15 * 60 * 1000, + ), + }; + + const service = new AuthService( + repository, + sessions, + () => now, + ); + + const result = await service.login({ + email: "usuario@example.com", + password: "SenhaErrada123!", + }); + + assert.equal(result.success, false); + + if (!result.success) { + assert.equal( + result.reason, + "temporarily_locked", + ); + } +}); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..e0747c6 --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,171 @@ +import { env } from "../../config/env.js"; +import { authRepository } from "./auth.repository.js"; +import { + hashPassword, + verifyPassword, +} from "./pssword.service.js"; +import { sessionService } from "./session.service.js"; +import { + LoginDTO, + SessionRecord, + UserRecord, +} from "./auth.types.js"; + +interface AuthRepositoryPort { + findUserByEmail(email: string): Promise; + + recordFailedLogin( + userId: string, + maxAttempts: number, + lockoutMinutes: number, + ): Promise; + + resetLoginAttempts(userId: string): Promise; +} + +interface SessionServicePort { + createSession( + userId: string, + ): Promise<{ + token: string; + session: SessionRecord; + }>; +} + +export type LoginResult = + | { + success: true; + token: string; + user: { + id: string; + nome: string; + email: string; + role: UserRecord["role"]; + }; + } + | { + success: false; + reason: + | "invalid_credentials" + | "inactive_user" + | "temporarily_locked"; + blockedUntil?: Date | string; + }; + +let dummyHashPromise: Promise | null = null; + +function getDummyHash(): Promise { + if (!dummyHashPromise) { + dummyHashPromise = hashPassword( + "Sinapse-Dummy-Password-For-Timing-Protection", + ); + } + + return dummyHashPromise; +} + +export class AuthService { + constructor( + private readonly repository: AuthRepositoryPort = authRepository, + private readonly sessions: SessionServicePort = sessionService, + private readonly now: () => Date = () => new Date(), + ) {} + + async login(data: LoginDTO): Promise { + const email = data.email.trim().toLowerCase(); + + const user = await this.repository.findUserByEmail(email); + + // Faz uma verificação real mesmo quando o usuário não existe. + // Reduz diferença de tempo entre: + // "e-mail inexistente" e "senha incorreta". + if (!user) { + const dummyHash = await getDummyHash(); + + await verifyPassword(data.password, dummyHash); + + return { + success: false, + reason: "invalid_credentials", + }; + } + + const now = this.now(); + + if (user.bloqueado_ate) { + const blockedUntil = new Date(user.bloqueado_ate); + + if (blockedUntil.getTime() > now.getTime()) { + return { + success: false, + reason: "temporarily_locked", + blockedUntil: user.bloqueado_ate, + }; + } + + // O bloqueio anterior já terminou. + // Inicia uma nova janela de tentativas. + await this.repository.resetLoginAttempts(user.id); + + user.tentativas_login = 0; + user.bloqueado_ate = null; + } + + const passwordValid = await verifyPassword( + data.password, + user.senha_hash, + ); + + if (!passwordValid) { + const updatedUser = + await this.repository.recordFailedLogin( + user.id, + env.AUTH_MAX_LOGIN_ATTEMPTS, + env.AUTH_LOCKOUT_MINUTES, + ); + + if ( + updatedUser?.bloqueado_ate && + new Date(updatedUser.bloqueado_ate).getTime() > + now.getTime() + ) { + return { + success: false, + reason: "temporarily_locked", + blockedUntil: updatedUser.bloqueado_ate, + }; + } + + return { + success: false, + reason: "invalid_credentials", + }; + } + + if (!user.ativo) { + return { + success: false, + reason: "inactive_user", + }; + } + + await this.repository.resetLoginAttempts(user.id); + + const { token } = await this.sessions.createSession( + user.id, + ); + + return { + success: true, + token, + user: { + id: user.id, + nome: user.nome, + email: user.email, + role: user.role, + }, + }; + } +} + +export const authService = new AuthService(); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.types.ts b/backend/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..3f53ff5 --- /dev/null +++ b/backend/src/modules/auth/auth.types.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const USER_ROLES = ["admin", "po", "dev"] as const; + +export type UserRole = (typeof USER_ROLES)[number]; + +export const loginSchema = z.object({ + email: z + .string({ required_error: "O e-mail é obrigatório." }) + .trim() + .email("E-mail inválido.") + .transform((value) => value.toLowerCase()), + + password: z + .string({ required_error: "A senha é obrigatória." }) + .min(1, "A senha é obrigatória."), +}); + +export type LoginDTO = z.infer; + +export interface UserRecord { + id: string; + nome: string; + email: string; + senha_hash: string; + role: UserRole; + ativo: boolean; + tentativas_login: number; + bloqueado_ate: Date | string | null; + created_at: Date | string; + updated_at: Date | string; +} + +export interface AuthenticatedUser { + id: string; + nome: string; + email: string; + role: UserRole; +} + +export interface SessionRecord { + id: string; + usuario_id: string; + token_hash: string; + created_at: Date | string; + ultima_atividade_em: Date | string; + revogada_em: Date | string | null; +} + +export interface SessionWithUser extends SessionRecord { + nome: string; + email: string; + role: UserRole; + ativo: boolean; +} \ No newline at end of file diff --git a/backend/src/modules/auth/pssword.service.test.ts b/backend/src/modules/auth/pssword.service.test.ts new file mode 100644 index 0000000..86ba148 --- /dev/null +++ b/backend/src/modules/auth/pssword.service.test.ts @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + hashPassword, + verifyPassword, +} from "./pssword.service.js"; + +test("deve gerar hash sem armazenar a senha em texto puro", async () => { + const password = "SenhaSegura123!"; + + const hash = await hashPassword(password); + + assert.notEqual(hash, password); + assert.ok(hash.startsWith("scrypt$v1$")); + assert.equal(hash.includes(password), false); +}); + +test("deve validar a senha correta", async () => { + const password = "SenhaSegura123!"; + const hash = await hashPassword(password); + + const valid = await verifyPassword(password, hash); + + assert.equal(valid, true); +}); + +test("deve rejeitar senha incorreta", async () => { + const hash = await hashPassword("SenhaCorreta123!"); + + const valid = await verifyPassword("SenhaErrada123!", hash); + + assert.equal(valid, false); +}); + +test("deve gerar salts diferentes para a mesma senha", async () => { + const password = "MesmaSenha123!"; + + const firstHash = await hashPassword(password); + const secondHash = await hashPassword(password); + + assert.notEqual(firstHash, secondHash); + + assert.equal(await verifyPassword(password, firstHash), true); + assert.equal(await verifyPassword(password, secondHash), true); +}); + +test("deve rejeitar hash inválido sem lançar erro", async () => { + const valid = await verifyPassword( + "Senha123!", + "isso-nao-e-um-hash-valido", + ); + + assert.equal(valid, false); +}); \ No newline at end of file diff --git a/backend/src/modules/auth/pssword.service.ts b/backend/src/modules/auth/pssword.service.ts new file mode 100644 index 0000000..4d55891 --- /dev/null +++ b/backend/src/modules/auth/pssword.service.ts @@ -0,0 +1,77 @@ +import { + randomBytes, + scrypt, + timingSafeEqual, +} from "node:crypto"; + +const SALT_LENGTH = 16; +const KEY_LENGTH = 64; +const HASH_PREFIX = "scrypt"; +const HASH_VERSION = "v1"; + +function deriveKey(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, KEY_LENGTH, (error, derivedKey) => { + if (error) { + reject(error); + return; + } + + resolve(derivedKey); + }); + }); +} + +// Gera um hash irreversível para a senha. Formato persistido: scrypt$v1$$ + +export async function hashPassword(password: string): Promise { + if (!password) { + throw new Error("A senha não pode ser vazia."); + } + + const salt = randomBytes(SALT_LENGTH).toString("hex"); + const derivedKey = await deriveKey(password, salt); + + return [ + HASH_PREFIX, + HASH_VERSION, + salt, + derivedKey.toString("hex"), + ].join("$"); +} + +// Compara uma senha informada com o hash armazenado. + +export async function verifyPassword( + password: string, + storedHash: string, +): Promise { + try { + const [algorithm, version, salt, hashHex] = storedHash.split("$"); + + if ( + algorithm !== HASH_PREFIX || + version !== HASH_VERSION || + !salt || + !hashHex + ) { + return false; + } + + if (!/^[0-9a-f]+$/i.test(hashHex)) { + return false; + } + + const storedKey = Buffer.from(hashHex, "hex"); + + if (storedKey.length !== KEY_LENGTH) { + return false; + } + + const derivedKey = await deriveKey(password, salt); + + return timingSafeEqual(storedKey, derivedKey); + } catch { + return false; + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/session.service.test.ts b/backend/src/modules/auth/session.service.test.ts new file mode 100644 index 0000000..6938ab3 --- /dev/null +++ b/backend/src/modules/auth/session.service.test.ts @@ -0,0 +1,211 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { SessionService } from "./session.service.js"; +import { + SessionRecord, + SessionWithUser, +} from "./auth.types.js"; + +class MockSessionRepository { + public session: SessionWithUser | null = null; + public storedTokenHash: string | null = null; + public touched = false; + public revoked = false; + + async createSession( + userId: string, + tokenHash: string, + ): Promise { + this.storedTokenHash = tokenHash; + + return { + id: "session-1", + usuario_id: userId, + token_hash: tokenHash, + created_at: new Date(), + ultima_atividade_em: new Date(), + revogada_em: null, + }; + } + + async findSessionByTokenHash( + tokenHash: string, + ): Promise { + this.storedTokenHash = tokenHash; + return this.session; + } + + async touchSession(): Promise { + this.touched = true; + } + + async revokeSessionByTokenHash(): Promise { + this.revoked = true; + } +} + +function createValidSession( + now: Date, +): SessionWithUser { + return { + id: "session-1", + usuario_id: "user-1", + token_hash: "hash", + created_at: new Date(now.getTime() - 60_000), + ultima_atividade_em: new Date(now.getTime() - 30_000), + revogada_em: null, + nome: "Usuário Teste", + email: "teste@example.com", + role: "po", + ativo: true, + }; +} + +test("deve criar token e armazenar apenas seu hash", async () => { + const repository = new MockSessionRepository(); + const service = new SessionService(repository); + + const result = await service.createSession("user-1"); + + assert.ok(result.token.length > 0); + assert.ok(repository.storedTokenHash); + assert.notEqual(repository.storedTokenHash, result.token); + assert.equal(repository.storedTokenHash?.length, 64); +}); + +test("deve validar sessão ativa", async () => { + const now = new Date(); + const repository = new MockSessionRepository(); + + repository.session = createValidSession(now); + + const service = new SessionService( + repository, + () => now, + ); + + const result = await service.validateSession("token-valido"); + + assert.equal(result.valid, true); + assert.equal(repository.touched, true); + + if (result.valid) { + assert.equal(result.user.id, "user-1"); + assert.equal(result.user.role, "po"); + } +}); + +test("deve rejeitar sessão revogada", async () => { + const now = new Date(); + const repository = new MockSessionRepository(); + + repository.session = { + ...createValidSession(now), + revogada_em: new Date(), + }; + + const service = new SessionService( + repository, + () => now, + ); + + const result = await service.validateSession("token"); + + assert.equal(result.valid, false); + + if (!result.valid) { + assert.equal(result.reason, "revoked"); + } +}); + +test("deve rejeitar e revogar sessão de usuário inativo", async () => { + const now = new Date(); + const repository = new MockSessionRepository(); + + repository.session = { + ...createValidSession(now), + ativo: false, + }; + + const service = new SessionService( + repository, + () => now, + ); + + const result = await service.validateSession("token"); + + assert.equal(result.valid, false); + assert.equal(repository.revoked, true); + + if (!result.valid) { + assert.equal(result.reason, "inactive_user"); + } +}); + +test("deve revogar sessão expirada por inatividade", async () => { + const now = new Date(); + const repository = new MockSessionRepository(); + + repository.session = { + ...createValidSession(now), + ultima_atividade_em: new Date( + now.getTime() - 31 * 60 * 1000, + ), + }; + + const service = new SessionService( + repository, + () => now, + ); + + const result = await service.validateSession("token"); + + assert.equal(result.valid, false); + assert.equal(repository.revoked, true); + + if (!result.valid) { + assert.equal(result.reason, "idle_expired"); + } +}); + +test("deve permitir revogar uma sessão manualmente", async () => { + const repository = new MockSessionRepository(); + const service = new SessionService(repository); + + await service.revokeSession("token"); + + assert.equal(repository.revoked, true); +}); + +test("deve revogar sessão ao atingir o tempo máximo absoluto", async () => { + const now = new Date(); + const repository = new MockSessionRepository(); + + repository.session = { + ...createValidSession(now), + created_at: new Date( + now.getTime() - 13 * 60 * 60 * 1000, + ), + ultima_atividade_em: new Date( + now.getTime() - 60_000, + ), + }; + + const service = new SessionService( + repository, + () => now, + ); + + const result = await service.validateSession("token"); + + assert.equal(result.valid, false); + assert.equal(repository.revoked, true); + + if (!result.valid) { + assert.equal( + result.reason, + "absolute_expired", + ); + } +}); \ No newline at end of file diff --git a/backend/src/modules/auth/session.service.ts b/backend/src/modules/auth/session.service.ts new file mode 100644 index 0000000..018000d --- /dev/null +++ b/backend/src/modules/auth/session.service.ts @@ -0,0 +1,158 @@ +import { + createHash, + randomBytes, +} from "node:crypto"; + +import { env } from "../../config/env.js"; +import { authRepository } from "./auth.repository.js"; +import { + AuthenticatedUser, + SessionRecord, + SessionWithUser, +} from "./auth.types.js"; + +const SESSION_TOKEN_BYTES = 32; + +interface SessionRepository { + createSession( + userId: string, + tokenHash: string, + ): Promise; + + findSessionByTokenHash( + tokenHash: string, + ): Promise; + + touchSession(sessionId: string): Promise; + + revokeSessionByTokenHash(tokenHash: string): Promise; +} + +export type SessionValidationResult = + | { + valid: true; + user: AuthenticatedUser; + sessionId: string; + } + | { + valid: false; + reason: + | "not_found" + | "revoked" + | "inactive_user" + | "idle_expired" + | "absolute_expired"; + }; + +export class SessionService { + constructor( + private readonly repository: SessionRepository = authRepository, + private readonly now: () => Date = () => new Date(), + ) {} + + private hashToken(token: string): string { + return createHash("sha256") + .update(token) + .digest("hex"); + } + + async createSession( + userId: string, + ): Promise<{ token: string; session: SessionRecord }> { + const token = randomBytes(SESSION_TOKEN_BYTES).toString("hex"); + const tokenHash = this.hashToken(token); + + const session = await this.repository.createSession( + userId, + tokenHash, + ); + + return { + token, + session, + }; + } + + async validateSession( + token: string, + ): Promise { + const tokenHash = this.hashToken(token); + + const session = + await this.repository.findSessionByTokenHash(tokenHash); + + if (!session) { + return { + valid: false, + reason: "not_found", + }; + } + + if (session.revogada_em) { + return { + valid: false, + reason: "revoked", + }; + } + + if (!session.ativo) { + await this.repository.revokeSessionByTokenHash(tokenHash); + + return { + valid: false, + reason: "inactive_user", + }; + } + + const now = this.now().getTime(); + const createdAt = new Date(session.created_at).getTime(); + const lastActivity = new Date( + session.ultima_atividade_em, + ).getTime(); + + const idleLimit = + env.AUTH_SESSION_IDLE_MINUTES * 60 * 1000; + + const absoluteLimit = + env.AUTH_SESSION_MAX_HOURS * 60 * 60 * 1000; + + if (now - lastActivity > idleLimit) { + await this.repository.revokeSessionByTokenHash(tokenHash); + + return { + valid: false, + reason: "idle_expired", + }; + } + + if (now - createdAt > absoluteLimit) { + await this.repository.revokeSessionByTokenHash(tokenHash); + + return { + valid: false, + reason: "absolute_expired", + }; + } + + await this.repository.touchSession(session.id); + + return { + valid: true, + sessionId: session.id, + user: { + id: session.usuario_id, + nome: session.nome, + email: session.email, + role: session.role, + }, + }; + } + + async revokeSession(token: string): Promise { + const tokenHash = this.hashToken(token); + + await this.repository.revokeSessionByTokenHash(tokenHash); + } +} + +export const sessionService = new SessionService(); \ No newline at end of file diff --git a/backend/src/modules/projects/projects.controller.ts b/backend/src/modules/projects/projects.controller.ts index 17c98da..1022193 100644 --- a/backend/src/modules/projects/projects.controller.ts +++ b/backend/src/modules/projects/projects.controller.ts @@ -11,7 +11,7 @@ export class ProjectsController { create = async (req: Request, res: Response, next: NextFunction): Promise => { try { - const usuarioId = (req.headers["x-user-id"] as string) || null; + const usuarioId = req.auth?.id ?? null; const result = await this.service.create(req.body, usuarioId); res.status(201).json(result); } catch (error) { @@ -41,7 +41,7 @@ export class ProjectsController { update = async (req: Request, res: Response, next: NextFunction): Promise => { try { const id = getParamId(req.params.id); - const usuarioId = (req.headers["x-user-id"] as string) || null; + const usuarioId = req.auth?.id ?? null; const result = await this.service.update(id, req.body, usuarioId); res.status(200).json(result); } catch (error) { @@ -52,7 +52,7 @@ export class ProjectsController { archive = async (req: Request, res: Response, next: NextFunction): Promise => { try { const id = getParamId(req.params.id); - const usuarioId = (req.headers["x-user-id"] as string) || null; + const usuarioId = req.auth?.id ?? null; const justificativa = (req.body?.justificativa as string) || undefined; const result = await this.service.archive(id, usuarioId, justificativa); res.status(200).json(result); diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..301b976 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,12 @@ +import type { AuthenticatedUser } from "../modules/auth/auth.types.js"; + +declare global { + namespace Express { + interface Request { + auth?: AuthenticatedUser; + sessionToken?: string; + } + } +} + +export {}; \ No newline at end of file diff --git a/database/migrations/004_identity_domain.sql b/database/migrations/004_identity_domain.sql new file mode 100644 index 0000000..7ddf89c --- /dev/null +++ b/database/migrations/004_identity_domain.sql @@ -0,0 +1,59 @@ +UPDATE usuario +SET role = 'po' +WHERE role IS NULL; + +ALTER TABLE usuario + ALTER COLUMN role SET DEFAULT 'po', + ALTER COLUMN role SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_usuario_role' + ) THEN + ALTER TABLE usuario + ADD CONSTRAINT ck_usuario_role + CHECK (role IN ('admin', 'po', 'dev')); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_usuario_tentativas_login' + ) THEN + ALTER TABLE usuario + ADD CONSTRAINT ck_usuario_tentativas_login + CHECK (tentativas_login >= 0); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS sessao ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + usuario_id UUID NOT NULL + REFERENCES usuario(id) + ON DELETE CASCADE, + + token_hash CHAR(64) NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + ultima_atividade_em TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + revogada_em TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_sessao_usuario + ON sessao(usuario_id); + +CREATE INDEX IF NOT EXISTS idx_sessao_ultima_atividade + ON sessao(ultima_atividade_em); + +CREATE INDEX IF NOT EXISTS idx_sessao_usuario_ativa + ON sessao(usuario_id, ultima_atividade_em) + WHERE revogada_em IS NULL; \ No newline at end of file diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3484a4c..5dcba3a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,127 +1,264 @@ openapi: 3.0.3 + info: title: Sinapse Service Contracts version: 0.1.0 description: Contratos versionados entre backend, serviço de IA e automações n8n. + servers: - url: http://localhost:3001 description: Backend Node.js - url: http://localhost:8000 description: Serviço de IA local + paths: /health: get: summary: Verifica disponibilidade do serviço + security: [] responses: - '200': { description: Serviço saudável } - '503': { description: Dependência indisponível } + '200': + description: Serviço saudável + '503': + description: Dependência indisponível + + /api/v1/auth/login: + post: + summary: Autentica um usuário e inicia uma sessão + operationId: login + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Autenticação realizada com sucesso + headers: + Set-Cookie: + description: Cookie HttpOnly da sessão. Em produção também utiliza Secure. + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/AuthUserResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + description: Credenciais inválidas + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Usuário inativo + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Limite de tentativas de autenticação excedido temporariamente + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/v1/auth/logout: + post: + summary: Encerra e revoga a sessão atual + operationId: logout + security: + - cookieAuth: [] + responses: + '204': + description: Sessão encerrada com sucesso + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/auth/me: + get: + summary: Retorna o usuário autenticado + operationId: getAuthenticatedUser + security: + - cookieAuth: [] + responses: + '200': + description: Usuário da sessão atual + content: + application/json: + schema: + $ref: '#/components/schemas/AuthUserResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/projects: post: summary: Cadastra um novo projeto (PBI-01.1.1 / S1-03) operationId: createProject + security: + - cookieAuth: [] requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateProjectRequest' } + schema: + $ref: '#/components/schemas/CreateProjectRequest' responses: '201': description: Projeto criado com sucesso com status ativo e auditoria registrada content: application/json: - schema: { $ref: '#/components/schemas/ProjectResponse' } - '400': { $ref: '#/components/responses/ValidationError' } + schema: + $ref: '#/components/schemas/ProjectResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' '409': description: Conflito de nome duplicado entre projetos ativos content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' + get: summary: Lista projetos com paginação e busca (S1-03) operationId: listProjects + security: + - cookieAuth: [] parameters: - name: status in: query required: false - schema: { type: string, enum: [ativo, em_andamento, concluido, arquivado, todos] } + schema: + type: string + enum: [ativo, em_andamento, concluido, arquivado, todos] - name: busca in: query required: false - schema: { type: string } + schema: + type: string - name: limit in: query required: false - schema: { type: integer, default: 50, minimum: 1, maximum: 100 } + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 - name: offset in: query required: false - schema: { type: integer, default: 0, minimum: 0 } + schema: + type: integer + default: 0 + minimum: 0 - name: order in: query required: false - schema: { type: string, enum: [created_at_desc, created_at_asc, nome_asc, nome_desc], default: created_at_desc } + schema: + type: string + enum: [created_at_desc, created_at_asc, nome_asc, nome_desc] + default: created_at_desc responses: '200': description: Lista paginada de projetos content: application/json: - schema: { $ref: '#/components/schemas/PaginatedProjectsResponse' } + schema: + $ref: '#/components/schemas/PaginatedProjectsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/projects/{id}: get: summary: Obtém detalhes de um projeto por ID (S1-03) operationId: getProjectById + security: + - cookieAuth: [] parameters: - name: id in: path required: true - schema: { type: string, format: uuid } + schema: + type: string + format: uuid responses: '200': description: Detalhes do projeto com estatísticas content: application/json: - schema: { $ref: '#/components/schemas/ProjectResponse' } - '400': { $ref: '#/components/responses/ValidationError' } + schema: + $ref: '#/components/schemas/ProjectResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' '404': description: Projeto não encontrado content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' + put: summary: Atualiza dados de um projeto (S1-03 / S1-08) operationId: updateProject + security: + - cookieAuth: [] parameters: - name: id in: path required: true - schema: { type: string, format: uuid } + schema: + type: string + format: uuid requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateProjectRequest' } + schema: + $ref: '#/components/schemas/UpdateProjectRequest' responses: '200': description: Projeto atualizado content: application/json: - schema: { $ref: '#/components/schemas/ProjectResponse' } - '400': { $ref: '#/components/responses/ValidationError' } -'404': { description: Projeto não encontrado, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + schema: + $ref: '#/components/schemas/ProjectResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '409': description: Conflito de nome duplicado com outro projeto ativo content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' + /api/v1/projects/{id}/archive: patch: summary: Arquiva um projeto (S1-03 / S1-09) operationId: archiveProject + security: + - cookieAuth: [] parameters: - name: id in: path required: true - schema: { type: string, format: uuid } + schema: + type: string + format: uuid requestBody: required: false content: @@ -129,14 +266,24 @@ paths: schema: type: object properties: - justificativa: { type: string } + justificativa: + type: string responses: '200': description: Projeto arquivado content: application/json: - schema: { $ref: '#/components/schemas/ProjectResponse' } - '404': { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/ProjectResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: Projeto não encontrado + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ingest/document: post: summary: Solicita ingestão assíncrona de documento @@ -145,14 +292,18 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/IngestDocumentRequest' } + schema: + $ref: '#/components/schemas/IngestDocumentRequest' responses: '200': description: Documento fragmentado e embeddings calculados content: application/json: - schema: { $ref: '#/components/schemas/IngestDocumentResponse' } - '400': { $ref: '#/components/responses/ValidationError' } + schema: + $ref: '#/components/schemas/IngestDocumentResponse' + '400': + $ref: '#/components/responses/ValidationError' + /ingest/entity: post: summary: Indexa uma entidade estruturada @@ -161,10 +312,14 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/IngestEntityRequest' } + schema: + $ref: '#/components/schemas/IngestEntityRequest' responses: - '200': { description: Entidade processada } - '400': { $ref: '#/components/responses/ValidationError' } + '200': + description: Entidade processada + '400': + $ref: '#/components/responses/ValidationError' + /embeddings: post: summary: Gera embedding para um texto @@ -173,10 +328,14 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/EmbeddingRequest' } + schema: + $ref: '#/components/schemas/EmbeddingRequest' responses: - '200': { description: Vetor gerado } - '502': { description: Ollama indisponível } + '200': + description: Vetor gerado + '502': + description: Ollama indisponível + /rag/query: post: summary: Responde usando somente os trechos fornecidos @@ -185,111 +344,269 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/RagQueryRequest' } + schema: + $ref: '#/components/schemas/RagQueryRequest' responses: - '200': { description: Resposta fundamentada } - '502': { description: Ollama indisponível } + '200': + description: Resposta fundamentada + '502': + description: Ollama indisponível + components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: sinapse_session + schemas: + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + example: usuario@exemplo.com + password: + type: string + format: password + writeOnly: true + + AuthenticatedUser: + type: object + required: [id, nome, email, role] + properties: + id: + type: string + format: uuid + nome: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, po, dev] + + AuthUserResponse: + type: object + required: [user] + properties: + user: + $ref: '#/components/schemas/AuthenticatedUser' + CreateProjectRequest: type: object required: [nome, cliente] properties: - nome: { type: string, minLength: 1, maxLength: 255 } - cliente: { type: string, minLength: 1, maxLength: 255 } - descricao: { type: string, nullable: true } - status: { type: string, enum: [ativo, em_andamento, concluido, arquivado], default: ativo } - data_inicio: { type: string, format: date-time, nullable: true } + nome: + type: string + minLength: 1 + maxLength: 255 + cliente: + type: string + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + default: ativo + data_inicio: + type: string + format: date-time + nullable: true + UpdateProjectRequest: type: object properties: - nome: { type: string, minLength: 1, maxLength: 255 } - cliente: { type: string, minLength: 1, maxLength: 255 } - descricao: { type: string, nullable: true } - status: { type: string, enum: [ativo, em_andamento, concluido, arquivado] } - data_inicio: { type: string, format: date-time, nullable: true } - justificativa: { type: string } + nome: + type: string + minLength: 1 + maxLength: 255 + cliente: + type: string + minLength: 1 + maxLength: 255 + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + data_inicio: + type: string + format: date-time + nullable: true + justificativa: + type: string + ProjectResponse: type: object required: [id, nome, cliente, status, created_at, updated_at] properties: - id: { type: string, format: uuid } - nome: { type: string } - cliente: { type: string } - descricao: { type: string, nullable: true } - status: { type: string, enum: [ativo, em_andamento, concluido, arquivado] } - data_inicio: { type: string, nullable: true } - created_at: { type: string, format: date-time } - updated_at: { type: string, format: date-time } - epicos_count: { type: integer } - documentos_count: { type: integer } + id: + type: string + format: uuid + nome: + type: string + cliente: + type: string + descricao: + type: string + nullable: true + status: + type: string + enum: [ativo, em_andamento, concluido, arquivado] + data_inicio: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + epicos_count: + type: integer + documentos_count: + type: integer + PaginatedProjectsResponse: type: object required: [items, total, limit, offset] properties: items: type: array - items: { $ref: '#/components/schemas/ProjectResponse' } - total: { type: integer, minimum: 0 } - limit: { type: integer, minimum: 1 } - offset: { type: integer, minimum: 0 } + items: + $ref: '#/components/schemas/ProjectResponse' + total: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + IngestDocumentRequest: type: object required: [document_id, text_content] properties: - document_id: { type: string } - text_content: { type: string } - project_id: { type: string, format: uuid, nullable: true } - metadata: { type: object, additionalProperties: true } + document_id: + type: string + text_content: + type: string + project_id: + type: string + format: uuid + nullable: true + metadata: + type: object + additionalProperties: true + IngestDocumentResponse: type: object required: [document_id, total_chunks, status, chunks] properties: - document_id: { type: string } - total_chunks: { type: integer, minimum: 0 } - status: { type: string, enum: [chunked_and_indexed] } + document_id: + type: string + total_chunks: + type: integer + minimum: 0 + status: + type: string + enum: [chunked_and_indexed] chunks: type: array items: $ref: '#/components/schemas/ProcessedChunk' + ProcessedChunk: type: object required: [chunk_index, text, vector_dimension] properties: - chunk_index: { type: integer, minimum: 0 } - text: { type: string } - vector_dimension: { type: integer, minimum: 0 } - project_id: { type: string, format: uuid, nullable: true } + chunk_index: + type: integer + minimum: 0 + text: + type: string + vector_dimension: + type: integer + minimum: 0 + project_id: + type: string + format: uuid + nullable: true + IngestEntityRequest: type: object required: [entity_type, data] properties: - entity_type: { type: string, enum: [epic, feature, pbi, decision] } - data: { type: object, additionalProperties: true } + entity_type: + type: string + enum: [epic, feature, pbi, decision] + data: + type: object + additionalProperties: true + EmbeddingRequest: type: object required: [text] properties: - text: { type: string, minLength: 1 } - model: { type: string, nullable: true } + text: + type: string + minLength: 1 + model: + type: string + nullable: true + RagQueryRequest: type: object required: [query] properties: - query: { type: string, minLength: 1 } - project_id: { type: string, format: uuid, nullable: true } - context_chunks: { type: array, items: { type: string } } + query: + type: string + minLength: 1 + project_id: + type: string + format: uuid + nullable: true + context_chunks: + type: array + items: + type: string + Error: type: object required: [error] properties: - error: { type: string } - code: { type: string } - correlation_id: { type: string, format: uuid } - details: { type: object, additionalProperties: true } + error: + type: string + code: + type: string + correlation_id: + type: string + format: uuid + details: + type: object + additionalProperties: true + responses: ValidationError: description: Corpo inválido ou campo obrigatório ausente content: application/json: - schema: { $ref: '#/components/schemas/Error' } + schema: + $ref: '#/components/schemas/Error' + + Unauthorized: + description: Autenticação ausente, sessão inválida ou sessão expirada + content: + application/json: + schema: + $ref: '#/components/schemas/Error' \ No newline at end of file