diff --git a/scripts/hash-password.mjs b/scripts/hash-password.mjs new file mode 100644 index 0000000..922b43d --- /dev/null +++ b/scripts/hash-password.mjs @@ -0,0 +1,14 @@ +// Genera hash scrypt para IT_USERS: node scripts/hash-password.mjs +import { scryptSync, randomBytes } from "node:crypto"; + +const pass = process.argv[2]; +if (!pass) { + console.error("Uso: node scripts/hash-password.mjs "); + 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}`); diff --git a/src/app/actualizar/page.tsx b/src/app/actualizar/page.tsx index 7bbda1d..e98033a 100644 --- a/src/app/actualizar/page.tsx +++ b/src/app/actualizar/page.tsx @@ -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(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="/" >
diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts new file mode 100644 index 0000000..4556625 --- /dev/null +++ b/src/app/api/auth/route.ts @@ -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; +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..18ea764 --- /dev/null +++ b/src/app/api/me/route.ts @@ -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 } }); +} diff --git a/src/app/eliminar/page.tsx b/src/app/eliminar/page.tsx index c0b501c..8ef61fd 100644 --- a/src/app/eliminar/page.tsx +++ b/src/app/eliminar/page.tsx @@ -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(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="/" >
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..b2c6457 --- /dev/null +++ b/src/app/login/page.tsx @@ -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(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 ( + <> +
+
+ SAM +

SAM

+

Welcome

+

Bienvenido/a

+

Portal IT

+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SAM +
+
+
+
+ {error && ( +
+
+ {error} +
+
+ )} +
+ +
+
+ setRut(e.target.value)} + className="form-control shadow" + required + placeholder="Usuario" + autoComplete="username" + /> +
+
+ +
+
+ setClave(e.target.value)} + className="form-control shadow" + required + placeholder="Clave" + autoComplete="current-password" + /> +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+ + ); +} + +export default function Login() { + return ( + + + + ); +} diff --git a/src/app/m/[name]/page.tsx b/src/app/m/[name]/page.tsx index 583614b..8b33344 100644 --- a/src/app/m/[name]/page.tsx +++ b/src/app/m/[name]/page.tsx @@ -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="/" > diff --git a/src/app/tablero/page.tsx b/src/app/tablero/page.tsx index a4d89f5..d63aed0 100644 --- a/src/app/tablero/page.tsx +++ b/src/app/tablero/page.tsx @@ -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 ( diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 6e97a5a..f1b73e3 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -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="/" >
diff --git a/src/components/SamShell.tsx b/src/components/SamShell.tsx index cc18378..d156020 100644 --- a/src/components/SamShell.tsx +++ b/src/components/SamShell.tsx @@ -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 (
@@ -78,6 +88,15 @@ export default function SamShell({
{topUser || brand}
{topSede || subtitle || "SAM"}
+ {allowLogout && ( + + )}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..3b1f474 --- /dev/null +++ b/src/lib/auth.ts @@ -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 +// ============================================================ + +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 { + 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 { + const m = new Map(); + 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 { + 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 { + 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; diff --git a/src/lib/store.ts b/src/lib/store.ts index 9e0a83b..711afe1 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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 { diff --git a/src/middleware.ts b/src/middleware.ts index 52a8f5c..4ba9be3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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*", + ], };