Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
8 changes: 6 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
151 changes: 151 additions & 0 deletions backend/src/middleware/requireAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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<SessionRecord> {
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<SessionWithUser | null> {
return this.session;
}

async touchSession(): Promise<void> {}

async revokeSessionByTokenHash(): Promise<void> {}
}

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<void>((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<void>((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");
},
);
});
52 changes: 52 additions & 0 deletions backend/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
1 change: 1 addition & 0 deletions backend/src/modules/auth/auth.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SESSION_COOKIE_NAME = "sinapse_session";
124 changes: 124 additions & 0 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
res.status(200).json({
user: req.auth,
});
};
}

export const authController = new AuthController();
Loading
Loading