feat: login IT con allowlist (scrypt+HMAC) + middleware protege tablero/gestion
This commit is contained in:
1 parent
08bf7254a1
commit
57b64af1ba
13 files changed
+392
-14
No files matched your search
@@ -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}`);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { refreshModules, useModules } from "@/lib/store";
|
import { refreshModules, useModules, useMe } from "@/lib/store"
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
envOf,
|
envOf,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
// Actualizar: redeploy manual + reinstalación de webhook por módulo.
|
// Actualizar: redeploy manual + reinstalación de webhook por módulo.
|
||||||
export default function Actualizar() {
|
export default function Actualizar() {
|
||||||
const { data, error } = useModules();
|
const { data, error } = useModules();
|
||||||
|
const me = useMe();
|
||||||
const modules = data?.modules ?? [];
|
const modules = data?.modules ?? [];
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [msg, setMsg] = useState<{ ok: boolean; text: 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"
|
subtitle="Actualizar módulos"
|
||||||
menu={itMenu(modules)}
|
menu={itMenu(modules)}
|
||||||
activeHref="/actualizar"
|
activeHref="/actualizar"
|
||||||
topUser="SAM"
|
topUser={me?.email || "SAM"}
|
||||||
|
allowLogout
|
||||||
backHref="/"
|
backHref="/"
|
||||||
>
|
>
|
||||||
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { refreshModules, useModules } from "@/lib/store";
|
import { refreshModules, useModules, useMe } from "@/lib/store"
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
envOf,
|
envOf,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
// Eliminar: baja de módulos integrados, con o sin código (purge).
|
// Eliminar: baja de módulos integrados, con o sin código (purge).
|
||||||
export default function Eliminar() {
|
export default function Eliminar() {
|
||||||
const { data, error } = useModules();
|
const { data, error } = useModules();
|
||||||
|
const me = useMe();
|
||||||
const modules = data?.modules ?? [];
|
const modules = data?.modules ?? [];
|
||||||
const [purge, setPurge] = useState(true);
|
const [purge, setPurge] = useState(true);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
@@ -70,7 +71,8 @@ export default function Eliminar() {
|
|||||||
subtitle="Eliminar módulos"
|
subtitle="Eliminar módulos"
|
||||||
menu={itMenu(modules)}
|
menu={itMenu(modules)}
|
||||||
activeHref="/eliminar"
|
activeHref="/eliminar"
|
||||||
topUser="SAM"
|
topUser={me?.email || "SAM"}
|
||||||
|
allowLogout
|
||||||
backHref="/"
|
backHref="/"
|
||||||
>
|
>
|
||||||
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { useModules, useStats } from "@/lib/store";
|
import { useModules, useStats, useMe } from "@/lib/store"
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
@@ -43,6 +43,7 @@ export default function ModuleBoard() {
|
|||||||
const name = Array.isArray(params.name) ? params.name[0] : params.name ?? "";
|
const name = Array.isArray(params.name) ? params.name[0] : params.name ?? "";
|
||||||
const { data } = useModules();
|
const { data } = useModules();
|
||||||
const stats = useStats();
|
const stats = useStats();
|
||||||
|
const me = useMe();
|
||||||
const loading = data === null;
|
const loading = data === null;
|
||||||
|
|
||||||
const mod = data?.modules.find(
|
const mod = data?.modules.find(
|
||||||
@@ -72,7 +73,8 @@ export default function ModuleBoard() {
|
|||||||
subtitle="Tablero del módulo"
|
subtitle="Tablero del módulo"
|
||||||
menu={menu}
|
menu={menu}
|
||||||
activeHref="#estado"
|
activeHref="#estado"
|
||||||
topUser="SAM"
|
topUser={me?.email || "SAM"}
|
||||||
|
allowLogout
|
||||||
topSede={mod ? `${mod.subdomain}.devops.norteamericano.cl` : undefined}
|
topSede={mod ? `${mod.subdomain}.devops.norteamericano.cl` : undefined}
|
||||||
backHref="/"
|
backHref="/"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import TableroView from "@/components/TableroView";
|
import TableroView from "@/components/TableroView";
|
||||||
import { useModules } from "@/lib/store";
|
import { useModules, useMe } from "@/lib/store"
|
||||||
import { itMenu } from "@/lib/sam";
|
import { itMenu } from "@/lib/sam";
|
||||||
|
|
||||||
// App IT: sidebar con todas sus opciones (resumen, aplicaciones,
|
// 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
|
// Se sirve en /tablero y en it.sam.devops.norteamericano.cl
|
||||||
export default function Tablero() {
|
export default function Tablero() {
|
||||||
const { data } = useModules();
|
const { data } = useModules();
|
||||||
|
const me = useMe();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SamShell
|
<SamShell
|
||||||
@@ -17,7 +18,8 @@ export default function Tablero() {
|
|||||||
subtitle="Gestión de aplicaciones"
|
subtitle="Gestión de aplicaciones"
|
||||||
menu={itMenu(data?.modules ?? [])}
|
menu={itMenu(data?.modules ?? [])}
|
||||||
activeHref="/tablero"
|
activeHref="/tablero"
|
||||||
topUser="SAM"
|
topUser={me?.email || "SAM"}
|
||||||
|
allowLogout
|
||||||
backHref="/"
|
backHref="/"
|
||||||
>
|
>
|
||||||
<TableroView />
|
<TableroView />
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import SamShell from "@/components/SamShell";
|
import SamShell from "@/components/SamShell";
|
||||||
import { useModules } from "@/lib/store";
|
import { useModules, useMe } from "@/lib/store"
|
||||||
import { itMenu } from "@/lib/sam";
|
import { itMenu } from "@/lib/sam";
|
||||||
|
|
||||||
export default function UploadPage() {
|
export default function UploadPage() {
|
||||||
const { data } = useModules();
|
const { data } = useModules();
|
||||||
|
const me = useMe();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
repoUrl: "",
|
repoUrl: "",
|
||||||
@@ -81,7 +82,8 @@ export default function UploadPage() {
|
|||||||
subtitle="Agregar módulo"
|
subtitle="Agregar módulo"
|
||||||
menu={itMenu(data?.modules ?? [])}
|
menu={itMenu(data?.modules ?? [])}
|
||||||
activeHref="/upload"
|
activeHref="/upload"
|
||||||
topUser="SAM"
|
topUser={me?.email || "SAM"}
|
||||||
|
allowLogout
|
||||||
backHref="/"
|
backHref="/"
|
||||||
>
|
>
|
||||||
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
<div className="alert alert-light section-header mb-3 py-2 px-3">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface SamShellProps {
|
|||||||
topUser?: string;
|
topUser?: string;
|
||||||
topSede?: string;
|
topSede?: string;
|
||||||
backHref?: string;
|
backHref?: string;
|
||||||
|
allowLogout?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +27,19 @@ export default function SamShell({
|
|||||||
topUser,
|
topUser,
|
||||||
topSede,
|
topSede,
|
||||||
backHref,
|
backHref,
|
||||||
|
allowLogout,
|
||||||
children,
|
children,
|
||||||
}: SamShellProps) {
|
}: SamShellProps) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth", { method: "DELETE" });
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sam-shell${open ? " open" : ""}`}>
|
<div className={`sam-shell${open ? " open" : ""}`}>
|
||||||
<div className="sam-sidebar">
|
<div className="sam-sidebar">
|
||||||
@@ -78,6 +88,15 @@ export default function SamShell({
|
|||||||
<div className="name">{topUser || brand}</div>
|
<div className="name">{topUser || brand}</div>
|
||||||
<div className="job">{topSede || subtitle || "SAM"}</div>
|
<div className="job">{topSede || subtitle || "SAM"}</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -107,6 +107,23 @@ export function refreshStats() {
|
|||||||
statsStore.load();
|
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".
|
// Sección del sitio según hostname: it.sam.devops... -> "it".
|
||||||
// useSyncExternalStore evita mismatch de hidratación SSR/cliente.
|
// useSyncExternalStore evita mismatch de hidratación SSR/cliente.
|
||||||
function siteSnapshot(): string {
|
function siteSnapshot(): string {
|
||||||
|
|||||||
+33
-4
@@ -1,11 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } 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";
|
const IT_BOARD = "https://it.sam.devops.norteamericano.cl/tablero";
|
||||||
|
|
||||||
// it.sam.devops... muestra el tablero IT manteniendo la URL.
|
// Rutas IT (requieren sesión): tablero, gestión, tableros y APIs de acción.
|
||||||
// /m/portal ya no existe: redirige al tablero IT (Portal IT).
|
// El índice (/) y el login quedan públicos.
|
||||||
export function middleware(req: NextRequest) {
|
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();
|
const host = (req.headers.get("host") || "").toLowerCase();
|
||||||
if (host.startsWith("it.sam.") && req.nextUrl.pathname === "/") {
|
if (host.startsWith("it.sam.") && req.nextUrl.pathname === "/") {
|
||||||
const url = req.nextUrl.clone();
|
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/")) {
|
if (req.nextUrl.pathname === "/m/portal" || req.nextUrl.pathname.startsWith("/m/portal/")) {
|
||||||
return NextResponse.redirect(IT_BOARD);
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
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*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user