leonardo 3 месяцев назад
Родитель
Сommit
ef8cd341d0

+ 3 - 3
chat/chatChain.js

@@ -1,6 +1,6 @@
-import { config } from "../config/index.js";
-import { chatCompletion } from "../services/ollamaClient.js";
-import { searchDocs } from "./searchChat.js";
+import { config } from "../src/config/index.js";
+import { chatCompletion } from "../src/services/ollamaClient.js";
+import { searchDocs } from "../chat/searchChat.js";
 import fs from "node:fs";
 
 function buildContextBlock(hits) {

+ 5 - 5
chat/ingest.js

@@ -1,8 +1,8 @@
-import { config } from "../config/index.js";
-import { chunkText } from "../services/textChunker.js";
-import { embedTexts, visionExtractFromImage } from "../services/ollamaClient.js";
-import { ensureCollection } from "../services/collectionService.js";
-import { qdrant } from "../services/qdrantClient.js";
+import { config } from "../src/config/index.js";
+import { chunkText } from "../src/services/textChunker.js";
+import { embedTexts, visionExtractFromImage } from "../src/services/ollamaClient.js";
+import { ensureCollection } from "../src/services/collectionService.js";
+import { qdrant } from "../src/services/qdrantClient.js";
 import { createHash } from "node:crypto";
 import { createRequire } from "node:module";
 import fs from "node:fs";

+ 3 - 3
chat/searchChat.js

@@ -1,6 +1,6 @@
-import { config } from "../config/index.js";
-import { embedTexts } from "../services/ollamaClient.js";
-import { qdrant } from "../services/qdrantClient.js";
+import { config } from "../src/config/index.js";
+import { embedTexts } from "../src/services/ollamaClient.js";
+import { qdrant } from "../src/services/qdrantClient.js";
 import fs from "node:fs";
 
 function isRefusalText(text) {

+ 19 - 0
db/migrations/20260609170200_create_refresh_tokens_table.cjs

@@ -0,0 +1,19 @@
+exports.up = function (knex) {
+  return knex.schema.createTable("refresh_tokens", (t) => {
+    t.increments("Id");
+    t.integer("UsuarioId").unsigned().notNullable();
+    t.string("TokenHash", 64).notNullable().unique();
+    t.dateTime("ExpiresAt").notNullable();
+    t.dateTime("RevokedAt").nullable();
+    t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+
+    t.foreign("UsuarioId").references("usuarios.Id").onDelete("CASCADE");
+    t.index(["UsuarioId"]);
+    t.index(["ExpiresAt"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("refresh_tokens");
+};
+

+ 6 - 0
src/config/index.js

@@ -6,6 +6,12 @@ export const config = {
   port: Number(process.env.PORT ?? 3001),
   corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
   apiKey: process.env.API_KEY ?? "",
+  jwt: {
+    secret: process.env.JWT_SECRET ?? "",
+    issuer: process.env.JWT_ISSUER ?? "oraculo-api",
+    accessTtlSeconds: Number(process.env.JWT_ACCESS_TTL_SECONDS ?? 900),
+    refreshTtlSeconds: Number(process.env.JWT_REFRESH_TTL_SECONDS ?? 60 * 60 * 24 * 30)
+  },
   qdrant: {
     url: process.env.QDRANT_URL ?? "http://localhost:6333",
     apiKey: process.env.QDRANT_API_KEY ?? "",

+ 16 - 0
src/db/knex.js

@@ -0,0 +1,16 @@
+import "dotenv/config";
+import knex from "knex";
+
+export const db = knex({
+  client: "mysql2",
+  connection: {
+    host: process.env.DB_HOST,
+    user: process.env.DB_USER,
+    port: process.env.DB_PORT ? Number(process.env.DB_PORT) : undefined,
+    password: process.env.DB_PASS,
+    database: process.env.DB_SCHEMA,
+    charset: "utf8mb4",
+    collation: "utf8mb4_bin"
+  }
+});
+

+ 32 - 6
src/middleware/auth.js

@@ -1,15 +1,41 @@
 import { config } from "../config/index.js";
+import { verifyAccessToken } from "../services/authTokens.js";
 
 export function authMiddleware(req, res, next) {
-  if (!config.apiKey) return next();
-
   const header = req.header("authorization") ?? "";
-  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
+  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
+
+  const isPublic = req.path === "/auth/login" || req.path === "/auth/refresh" || req.path === "/auth/logout";
+
+  const hasAuthConfigured = Boolean(config.apiKey || config.jwt.secret);
+  if (!hasAuthConfigured) return next();
+
+  if (token && config.apiKey && token === config.apiKey) {
+    req.user = { sub: "system", system: true };
+    next();
+    return;
+  }
+
+  if (token && config.jwt.secret) {
+    try {
+      const payload = verifyAccessToken(token);
+      if (payload) {
+        req.user = payload;
+        next();
+        return;
+      }
+    } catch (err) {
+      if (!isPublic) {
+        res.status(401).json({ error: "unauthorized" });
+        return;
+      }
+    }
+  }
 
-  if (token !== config.apiKey) {
-    res.status(401).json({ error: "unauthorized" });
+  if (isPublic) {
+    next();
     return;
   }
 
-  next();
+  res.status(401).json({ error: "unauthorized" });
 }

+ 179 - 0
src/routes/auth.js

@@ -0,0 +1,179 @@
+import { Router } from "express";
+import { z } from "zod";
+import bcrypt from "bcryptjs";
+import { db } from "../db/knex.js";
+import { config } from "../config/index.js";
+import { generateRefreshToken, hashRefreshToken, signAccessToken } from "../services/authTokens.js";
+
+export const authRouter = Router();
+
+const loginBodySchema = z.object({
+  login: z.string().min(1),
+  senha: z.string().min(1)
+});
+
+const refreshBodySchema = z.object({
+  refreshToken: z.string().min(1)
+});
+
+const logoutBodySchema = z.object({
+  refreshToken: z.string().min(1).optional()
+});
+
+function toPublicUser(u) {
+  return {
+    id: u.Id,
+    nome: u.Nome,
+    login: u.Login,
+    email: u.Email,
+    status: u.Status,
+    nivel: u.Nivel,
+    setor: u.Setor
+  };
+}
+
+function isUserBlocked(u) {
+  const status = String(u?.Status ?? "").trim();
+  return status === "0";
+}
+
+async function issueTokensForUser(trx, user) {
+  const now = Date.now();
+  const refreshToken = generateRefreshToken();
+  const refreshTokenHash = hashRefreshToken(refreshToken);
+  const expiresAt = new Date(now + config.jwt.refreshTtlSeconds * 1000);
+
+  await trx("refresh_tokens").insert({
+    UsuarioId: user.Id,
+    TokenHash: refreshTokenHash,
+    ExpiresAt: expiresAt
+  });
+
+  const accessToken = signAccessToken({
+    sub: String(user.Id),
+    login: user.Login,
+    nivel: user.Nivel,
+    setor: user.Setor
+  });
+
+  return { accessToken, refreshToken };
+}
+
+authRouter.post("/login", async (req, res, next) => {
+  try {
+    const body = loginBodySchema.parse(req.body);
+
+    const login = body.login.trim();
+    const isEmail = login.includes("@");
+
+    const user = await db("usuarios")
+      .where(isEmail ? { Email: login } : { Login: login })
+      .first();
+
+    if (!user) {
+      res.status(401).json({ error: "invalid_credentials" });
+      return;
+    }
+
+    if (isUserBlocked(user)) {
+      res.status(403).json({ error: "user_inactive" });
+      return;
+    }
+
+    const ok = await bcrypt.compare(body.senha, String(user.Senha ?? ""));
+    if (!ok) {
+      res.status(401).json({ error: "invalid_credentials" });
+      return;
+    }
+
+    const tokens = await db.transaction(async (trx) => issueTokensForUser(trx, user));
+    res.json({ ...tokens, user: toPublicUser(user) });
+  } catch (err) {
+    next(err);
+  }
+});
+
+authRouter.post("/refresh", async (req, res, next) => {
+  try {
+    const body = refreshBodySchema.parse(req.body);
+    const tokenHash = hashRefreshToken(body.refreshToken);
+
+    const now = new Date();
+
+    const result = await db.transaction(async (trx) => {
+      const tokenRow = await trx("refresh_tokens").where({ TokenHash: tokenHash }).first();
+
+      if (!tokenRow) return null;
+      if (tokenRow.RevokedAt) return null;
+      if (new Date(tokenRow.ExpiresAt) <= now) return null;
+
+      const user = await trx("usuarios").where({ Id: tokenRow.UsuarioId }).first();
+      if (!user) return null;
+      if (isUserBlocked(user)) return null;
+
+      await trx("refresh_tokens").where({ Id: tokenRow.Id }).update({ RevokedAt: now });
+
+      const tokens = await issueTokensForUser(trx, user);
+      return { tokens, user };
+    });
+
+    if (!result) {
+      res.status(401).json({ error: "invalid_refresh_token" });
+      return;
+    }
+
+    res.json({ ...result.tokens, user: toPublicUser(result.user) });
+  } catch (err) {
+    next(err);
+  }
+});
+
+authRouter.post("/logout", async (req, res, next) => {
+  try {
+    const body = logoutBodySchema.safeParse(req.body);
+    const refreshToken = body.success ? body.data.refreshToken : undefined;
+
+    const now = new Date();
+    const userId = req.user?.sub ? Number(req.user.sub) : null;
+
+    if (userId) {
+      await db("refresh_tokens")
+        .where({ UsuarioId: userId })
+        .whereNull("RevokedAt")
+        .update({ RevokedAt: now });
+      res.json({ ok: true });
+      return;
+    }
+
+    if (typeof refreshToken !== "string" || !refreshToken.trim()) {
+      res.status(400).json({ error: "refresh_token_required" });
+      return;
+    }
+
+    const tokenHash = hashRefreshToken(refreshToken.trim());
+    await db("refresh_tokens").where({ TokenHash: tokenHash }).update({ RevokedAt: now });
+    res.json({ ok: true });
+  } catch (err) {
+    next(err);
+  }
+});
+
+authRouter.get("/me", async (req, res, next) => {
+  try {
+    const userId = req.user?.sub ? Number(req.user.sub) : null;
+    if (!userId) {
+      res.status(401).json({ error: "unauthorized" });
+      return;
+    }
+
+    const user = await db("usuarios").where({ Id: userId }).first();
+    if (!user) {
+      res.status(404).json({ error: "user_not_found" });
+      return;
+    }
+
+    res.json({ user: toPublicUser(user) });
+  } catch (err) {
+    next(err);
+  }
+});

+ 2 - 0
src/routes/index.js

@@ -3,9 +3,11 @@ import { chatRouter } from "./chat.js";
 import { searchRouter } from "./search.js";
 import { ingestRouter } from "./ingest.js";
 import { documentsRouter } from "./documents.js";
+import { authRouter } from "./auth.js";
 
 export const apiRouter = Router();
 
+apiRouter.use("/auth", authRouter);
 apiRouter.use("/chat", chatRouter);
 apiRouter.use("/search", searchRouter);
 apiRouter.use("/ingest", ingestRouter);

+ 32 - 0
src/services/authTokens.js

@@ -0,0 +1,32 @@
+import crypto from "node:crypto";
+import jwt from "jsonwebtoken";
+import { config } from "../config/index.js";
+
+export function hashRefreshToken(token) {
+  return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+export function generateRefreshToken() {
+  return crypto.randomBytes(32).toString("base64url");
+}
+
+export function signAccessToken(payload) {
+  if (!config.jwt.secret) {
+    const err = new Error("jwt_not_configured");
+    err.statusCode = 500;
+    throw err;
+  }
+
+  return jwt.sign(payload, config.jwt.secret, {
+    issuer: config.jwt.issuer,
+    expiresIn: config.jwt.accessTtlSeconds
+  });
+}
+
+export function verifyAccessToken(token) {
+  if (!config.jwt.secret) return null;
+  return jwt.verify(token, config.jwt.secret, {
+    issuer: config.jwt.issuer
+  });
+}
+