feat: login IT con allowlist (scrypt+HMAC) + middleware protege tablero/gestion

This commit is contained in:
Nurfog committed 2026-09-09 14:30:27 -03:00
1 parent 08bf7254a1
commit 57b64af1ba
13 files changed
+392 -14

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
// Genera hash scrypt para IT_USERS: node scripts/hash-password.mjs <clave>
import { scryptSync, randomBytes } from "node:crypto";
const pass = process.argv[2];
if (!pass) {
console.error("Uso: node scripts/hash-password.mjs <clave>");
process.exit(1);
}
const N = 16384;
const r = 8;
const p = 1;
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(pass, Buffer.from(salt, "hex"), 64, { N, r, p }).toString("hex");
console.log(`scrypt$${N}$${r}$${p}$${salt}$${hash}`);
+4 -2
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshModules, useModules } from "@/lib/store";
import { refreshModules, useModules, useMe } from "@/lib/store"
import {
displayName,
envOf,
@@ -13,6 +13,7 @@ import {
// Actualizar: redeploy manual + reinstalación de webhook por módulo.
export default function Actualizar() {
const { data, error } = useModules();
const me = useMe();
const modules = data?.modules ?? [];
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -70,7 +71,8 @@ export default function Actualizar() {
subtitle="Actualizar módulos"
menu={itMenu(modules)}
activeHref="/actualizar"
topUser="SAM"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE, isAllowed, parseUsers, signSession, verifyPassword, SESSION_MAX_AGE } from "@/lib/auth";
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
const mail = String(email || "").trim().toLowerCase();
if (!mail || !password) {
return NextResponse.json({ error: "Faltan credenciales" }, { status: 400 });
}
const users = parseUsers();
const hash = users.get(mail);
if (!hash || !isAllowed(mail) || !(await verifyPassword(String(password), hash))) {
// Mensaje genérico: no revela si el correo existe
await new Promise((r) => setTimeout(r, 600));
return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
}
const res = NextResponse.json({ ok: true, email: mail });
res.cookies.set(COOKIE, await signSession(mail), {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE,
});
return res;
} catch {
return NextResponse.json({ error: "Error interno" }, { status: 500 });
}
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE, "", { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 0 });
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE, readSession } from "@/lib/auth";
export async function GET(req: NextRequest) {
const sess = await readSession(req.cookies.get(COOKIE)?.value);
if (!sess) return NextResponse.json({ user: null });
return NextResponse.json({ user: { email: sess.email } });
}
+4 -2
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { refreshModules, useModules } from "@/lib/store";
import { refreshModules, useModules, useMe } from "@/lib/store"
import {
displayName,
envOf,
@@ -13,6 +13,7 @@ import {
// Eliminar: baja de módulos integrados, con o sin código (purge).
export default function Eliminar() {
const { data, error } = useModules();
const me = useMe();
const modules = data?.modules ?? [];
const [purge, setPurge] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
@@ -70,7 +71,8 @@ export default function Eliminar() {
subtitle="Eliminar módulos"
menu={itMenu(modules)}
activeHref="/eliminar"
topUser="SAM"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
function LoginForm() {
const router = useRouter();
const next = useSearchParams().get("next") || "/tablero";
const [rut, setRut] = useState("");
const [clave, setClave] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: rut, password: clave }),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.push(next.startsWith("/") ? next : "/tablero");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Error al ingresar");
} finally {
setLoading(false);
}
};
return (
<>
<div className="sidenav">
<div className="login-main-text text-light text-center">
<img src="/favicon.png" alt="SAM" />
<h1>SAM</h1>
<h3>Welcome</h3>
<h3>Bienvenido/a</h3>
<h3>Portal IT</h3>
</div>
</div>
<div className="main">
<div className="row">
<div className="col-md-12">
<div className="row">
<div className="col-md-12 text-center">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card alert alert-secondary">
<div className="card-body">
<form onSubmit={submit}>
<div className="row">
<div className="col-md-12 mb-3">
<div className="row">
<div className="col-md-3" />
<div className="col-md-6 text-center">
<img
src="/logo-sam.png"
alt="SAM"
className="img-fluid"
style={{ maxWidth: 220 }}
/>
</div>
<div className="col-md-3" />
</div>
</div>
{error && (
<div className="col-md-12 mb-3">
<div className="alert alert-danger py-2 mb-0">
{error}
</div>
</div>
)}
<div className="col-md-3 mt-2 text-start">
<label className="form-label fw-semibold">USUARIO</label>
</div>
<div className="col-md-6 mt-2">
<input
name="TextBoxRut"
type="text"
value={rut}
onChange={(e) => setRut(e.target.value)}
className="form-control shadow"
required
placeholder="Usuario"
autoComplete="username"
/>
</div>
<div className="col-md-3 mt-2 text-start">
<label className="form-label fw-semibold">CLAVE</label>
</div>
<div className="col-md-6 mt-2">
<input
name="TextBoxClave"
type="password"
value={clave}
onChange={(e) => setClave(e.target.value)}
className="form-control shadow"
required
placeholder="Clave"
autoComplete="current-password"
/>
</div>
<div className="col-md-12 mt-3">
<div className="d-grid">
<button
type="submit"
disabled={loading}
className="btn btn-success btn-sm"
>
{loading ? (
<span className="spinner-border spinner-border-sm" />
) : (
"Ingresar"
)}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}
export default function Login() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
+4 -2
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import SamShell from "@/components/SamShell";
import { useModules, useStats } from "@/lib/store";
import { useModules, useStats, useMe } from "@/lib/store"
import { useParams } from "next/navigation";
import {
displayName,
@@ -43,6 +43,7 @@ export default function ModuleBoard() {
const name = Array.isArray(params.name) ? params.name[0] : params.name ?? "";
const { data } = useModules();
const stats = useStats();
const me = useMe();
const loading = data === null;
const mod = data?.modules.find(
@@ -72,7 +73,8 @@ export default function ModuleBoard() {
subtitle="Tablero del módulo"
menu={menu}
activeHref="#estado"
topUser="SAM"
topUser={me?.email || "SAM"}
allowLogout
topSede={mod ? `${mod.subdomain}.devops.norteamericano.cl` : undefined}
backHref="/"
>
+4 -2
View File
@@ -2,7 +2,7 @@
import SamShell from "@/components/SamShell";
import TableroView from "@/components/TableroView";
import { useModules } from "@/lib/store";
import { useModules, useMe } from "@/lib/store"
import { itMenu } from "@/lib/sam";
// App IT: sidebar con todas sus opciones (resumen, aplicaciones,
@@ -10,6 +10,7 @@ import { itMenu } from "@/lib/sam";
// Se sirve en /tablero y en it.sam.devops.norteamericano.cl
export default function Tablero() {
const { data } = useModules();
const me = useMe();
return (
<SamShell
@@ -17,7 +18,8 @@ export default function Tablero() {
subtitle="Gestión de aplicaciones"
menu={itMenu(data?.modules ?? [])}
activeHref="/tablero"
topUser="SAM"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<TableroView />
+4 -2
View File
@@ -2,11 +2,12 @@
import { useState } from "react";
import SamShell from "@/components/SamShell";
import { useModules } from "@/lib/store";
import { useModules, useMe } from "@/lib/store"
import { itMenu } from "@/lib/sam";
export default function UploadPage() {
const { data } = useModules();
const me = useMe();
const [form, setForm] = useState({
name: "",
repoUrl: "",
@@ -81,7 +82,8 @@ export default function UploadPage() {
subtitle="Agregar módulo"
menu={itMenu(data?.modules ?? [])}
activeHref="/upload"
topUser="SAM"
topUser={me?.email || "SAM"}
allowLogout
backHref="/"
>
<div className="alert alert-light section-header mb-3 py-2 px-3">
+19
View File
@@ -12,6 +12,7 @@ interface SamShellProps {
topUser?: string;
topSede?: string;
backHref?: string;
allowLogout?: boolean;
children: React.ReactNode;
}
@@ -26,10 +27,19 @@ export default function SamShell({
topUser,
topSede,
backHref,
allowLogout,
children,
}: SamShellProps) {
const [open, setOpen] = useState(true);
const logout = async () => {
try {
await fetch("/api/auth", { method: "DELETE" });
} finally {
window.location.href = "/login";
}
};
return (
<div className={`sam-shell${open ? " open" : ""}`}>
<div className="sam-sidebar">
@@ -78,6 +88,15 @@ export default function SamShell({
<div className="name">{topUser || brand}</div>
<div className="job">{topSede || subtitle || "SAM"}</div>
</div>
{allowLogout && (
<i
className="bx bx-log-out ms-auto"
role="button"
title="Cerrar sesión"
onClick={logout}
style={{ fontSize: "1.4rem", cursor: "pointer" }}
/>
)}
</div>
</div>
</div>
+99
View File
@@ -0,0 +1,99 @@
// ============================================================
// SAM IT - Auth: allowlist de correos + sesión HMAC-SHA256.
// Sin dependencias: scrypt (node) para claves, WebCrypto para
// firmar/verificar (compatible Edge middleware).
// Env: IT_USERS="email:hash,email:hash" IT_SESSION_SECRET="..."
// Hash: node scripts/hash-password.mjs <clave>
// ============================================================
export const COOKIE = "it_session";
const MAX_AGE = 12 * 3600;
function b64url(s: string): string {
return Buffer.from(s, "utf8").toString("base64url");
}
function unb64url(s: string): string {
return Buffer.from(s, "base64url").toString("utf8");
}
async function hmac(secret: string, msg: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(msg));
return Buffer.from(sig).toString("hex");
}
export function parseUsers(): Map<string, string> {
const m = new Map<string, string>();
const raw = process.env.IT_USERS || "";
for (const part of raw.split(",")) {
const i = part.indexOf(":");
if (i > 0) m.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
return m;
}
export function isAllowed(email: string): boolean {
return parseUsers().has(email.trim().toLowerCase());
}
// scrypt$N$r$p$saltHex$hashHex (node:crypto, timing-safe)
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
try {
const [tag, N, r, p, salt, hash] = stored.split("$");
if (tag !== "scrypt" || !salt || !hash) return false;
const { scrypt, timingSafeEqual } = await import("node:crypto");
const derived: Buffer = await new Promise((resolve, reject) => {
scrypt(password, Buffer.from(salt, "hex"), 64, { N: +N, r: +r, p: +p }, (e, k) =>
e ? reject(e) : resolve(k as Buffer)
);
});
const expected = Buffer.from(hash, "hex");
return derived.length === expected.length && timingSafeEqual(derived, expected);
} catch {
return false;
}
}
export async function signSession(email: string): Promise<string> {
const secret = process.env.IT_SESSION_SECRET || "";
const exp = Math.floor(Date.now() / 1000) + MAX_AGE;
const payload = b64url(JSON.stringify({ email: email.toLowerCase(), exp }));
return `${payload}.${await hmac(secret, payload)}`;
}
export async function readSession(
cookieValue: string | undefined
): Promise<{ email: string; exp: number } | null> {
try {
if (!cookieValue) return null;
const secret = process.env.IT_SESSION_SECRET || "";
if (!secret) return null;
const [payload, sig] = cookieValue.split(".");
if (!payload || !sig) return null;
const good = await hmac(secret, payload);
if (sig.length !== good.length) return null;
let ok = true;
for (let i = 0; i < sig.length; i++) {
if (sig[i] !== good[i]) {
ok = false;
break;
}
}
if (!ok) return null;
const data = JSON.parse(unb64url(payload));
if (!data.email || !data.exp || data.exp < Date.now() / 1000) return null;
if (!isAllowed(data.email)) return null;
return data;
} catch {
return null;
}
}
export const SESSION_MAX_AGE = MAX_AGE;
+17
View File
@@ -107,6 +107,23 @@ export function refreshStats() {
statsStore.load();
}
async function fetchMe(): Promise<{ email: string } | null> {
try {
const res = await fetch("/api/me", { cache: "no-store" });
if (!res.ok) return null;
const j = await res.json();
return j.user || null;
} catch {
return null;
}
}
const meStore = createStore(fetchMe, null as { email: string } | null, 60000);
export function useMe() {
return useSyncExternalStore(meStore.subscribe, meStore.snapshot, () => null);
}
// Sección del sitio según hostname: it.sam.devops... -> "it".
// useSyncExternalStore evita mismatch de hidratación SSR/cliente.
function siteSnapshot(): string {
+33 -4
View File
@@ -1,11 +1,18 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { COOKIE, readSession } from "@/lib/auth";
const IT_BOARD = "https://it.sam.devops.norteamericano.cl/tablero";
// it.sam.devops... muestra el tablero IT manteniendo la URL.
// /m/portal ya no existe: redirige al tablero IT (Portal IT).
export function middleware(req: NextRequest) {
// Rutas IT (requieren sesión): tablero, gestión, tableros y APIs de acción.
// El índice (/) y el login quedan públicos.
const PROTECTED = ["/tablero", "/upload", "/actualizar", "/eliminar", "/m"];
function isProtected(path: string): boolean {
return PROTECTED.some((p) => path === p || path.startsWith(p + "/"));
}
export async function middleware(req: NextRequest) {
const host = (req.headers.get("host") || "").toLowerCase();
if (host.startsWith("it.sam.") && req.nextUrl.pathname === "/") {
const url = req.nextUrl.clone();
@@ -15,9 +22,31 @@ export function middleware(req: NextRequest) {
if (req.nextUrl.pathname === "/m/portal" || req.nextUrl.pathname.startsWith("/m/portal/")) {
return NextResponse.redirect(IT_BOARD);
}
if (isProtected(req.nextUrl.pathname)) {
const sess = await readSession(req.cookies.get(COOKIE)?.value);
if (!sess) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", req.nextUrl.pathname);
return NextResponse.redirect(url);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/", "/m/portal/:path*"],
matcher: [
"/",
"/m/portal/:path*",
"/tablero",
"/tablero/:path*",
"/upload",
"/upload/:path*",
"/actualizar",
"/actualizar/:path*",
"/eliminar",
"/eliminar/:path*",
"/m",
"/m/:path*",
],
};