Files
2026-07-13 12:49:11 +08:00

384 lines
11 KiB
JavaScript

import "dotenv/config";
import jsonServer from "json-server";
import cors from "cors";
import cookieParser from 'cookie-parser';
import jwt from "jsonwebtoken";
import { nanoid } from "nanoid";
import { Low } from "lowdb";
import { JSONFile } from "lowdb/node";
// -------------------- Env & Defaults --------------------
const {
JWT_SECRET = process.env.JWT_SECRET,
ACCESS_TTL_SEC = process.env.ACCESS_TTL_SEC, // 15 minutes
REFRESH_TTL_SEC = process.env.REFRESH_TTL_SEC, // 7 days
CLIENT_ORIGIN = process.env.CLIENT_ORIGIN,
PORT = "4001", // match your NEXT_PUBLIC_API_URL host:port
} = process.env;
const ACCESS_TTL = parseInt(ACCESS_TTL_SEC, 10);
const REFRESH_TTL = parseInt(REFRESH_TTL_SEC, 10);
// -------------------- App & DB Setup --------------------
const app = jsonServer.create();
const router = jsonServer.router("db.json");
const middlewares = jsonServer.defaults({ noCors: true });
const defaultData = {
users: [],
revoked_tokens: [],
password_resets: [],
};
const adapter = new JSONFile("db.json");
const db = new Low(adapter, defaultData);
await db.read();
db.data.users ??= [];
db.data.revoked_tokens ??= [];
db.data.password_resets ??= [];
// -------------------- CORS (with credentials) --------------------
const corsOptions = {
origin: CLIENT_ORIGIN, // must be explicit when using credentials
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
};
app.use(cors(corsOptions));
app.use(
cors({
origin: CLIENT_ORIGIN, // must be the exact origin when using credentials
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type','Authorization'],
})
);
app.options('*', cors({ origin: CLIENT_ORIGIN, credentials: true }));
app.use(cookieParser());
// -------------------- Middleware --------------------
app.use(jsonServer.bodyParser);
app.use(middlewares);
// Optional request log to debug routes
app.use((req, _res, next) => {
console.log(req.method, req.url);
next();
});
// -------------------- Helpers --------------------
function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, typ: "access" },
JWT_SECRET,
{ expiresIn: ACCESS_TTL }
);
}
function signRefreshToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, typ: "refresh", jti: nanoid() },
JWT_SECRET,
{ expiresIn: REFRESH_TTL }
);
}
function setRefreshCookie(res, token, maxAgeMs) {
res.cookie('refresh_token', token, {
httpOnly: true,
sameSite: 'lax',
secure: false, // true in production if using HTTPS
maxAge: maxAgeMs,
path: '/', // adjust if needed
});
}
function sanitizeUser(u) {
const { password, ...safe } = u;
return safe;
}
function authz(req) {
const h = req.headers.authorization || "";
const token = h.startsWith("Bearer ") ? h.slice(7) : null;
if (!token) return null;
try {
const payload = jwt.verify(token, JWT_SECRET);
return payload;
} catch {
return null;
}
}
async function getUserByEmail(email) {
await db.read();
return db.data.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
}
async function getUserById(id) {
await db.read();
return db.data.users.find((u) => u.id === id);
}
async function createUser({
email,
password,
name,
provider = "email",
google_sub = null,
}) {
await db.read();
const nextId = db.data.users.length
? Math.max(...db.data.users.map((u) => u.id)) + 1
: 1;
const now = new Date().toISOString();
const newUser = {
id: nextId,
email,
password, // ⚠️ mock only (no hashing)
name,
provider,
google_sub,
created_at: now,
updated_at: now,
};
db.data.users.push(newUser);
await db.write();
return newUser;
}
async function upsertGoogleUser({ email, name, google_sub }) {
await db.read();
let user =
db.data.users.find((u) => u.google_sub === google_sub) ||
db.data.users.find((u) => u.email.toLowerCase() === email.toLowerCase());
if (user) {
user.google_sub = google_sub;
user.provider = "google";
user.name = user.name || name;
user.updated_at = new Date().toISOString();
await db.write();
return user;
}
return createUser({
email,
password: nanoid(),
name,
provider: "google",
google_sub,
});
}
async function revokeToken(token) {
await db.read();
db.data.revoked_tokens.push({
token,
revoked_at: new Date().toISOString(),
});
await db.write();
}
async function isRevoked(token) {
await db.read();
return !!db.data.revoked_tokens.find((t) => t.token === token);
}
// -------------------- AUTH Routes --------------------
// POST /api/v1/auth/register
app.post(["/api/v1/auth/register", "/register"], async (req, res) => {
const { email, password, name } = req.body || {};
if (!email || !password)
return res
.status(422)
.json({ message: "email și password sunt obligatorii" });
const existing = await getUserByEmail(email);
if (existing) return res.status(409).json({ message: "Email deja folosit" });
const user = await createUser({ email, password, name });
const access_token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
res.json({ user: sanitizeUser(user), access_token, refresh_token });
});
// POST /api/v1/auth/login
app.post(["/api/v1/auth/login", "/login"], async (req, res) => {
const { email, password } = req.body || {};
const user = await getUserByEmail(email || "");
if (!user || user.password !== password)
return res.status(401).json({ message: "Credențiale invalide" });
const access_token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
res.json({ user: sanitizeUser(user), access_token, refresh_token });
});
// POST /api/v1/auth/google (MOCK: sets refresh cookie like classic login)
app.post(["/api/v1/auth/google", "/google"], async (req, res) => {
const { token } = req.body || {};
if (!token) return res.status(422).json({ message: "token lipsă" });
let payload;
try {
payload = typeof token === "string" ? JSON.parse(token) : token;
} catch {
return res
.status(400)
.json({ message: "token invalid (mock așteaptă JSON cu {email,name,sub})" });
}
const { email, name, sub } = payload || {};
if (!email || !sub) {
return res
.status(400)
.json({ message: "token mock trebuie să conțină email și sub" });
}
// create/update Google user
const user = await upsertGoogleUser({
email,
name: name || email.split("@")[0],
google_sub: sub,
});
// issue tokens
const access_token = signAccessToken(user);
const refresh_token = signRefreshToken(user);
// ✅ set refresh cookie exactly like the classic login
setRefreshCookie(res, refresh_token, REFRESH_TTL * 1000);
// return same shape as login (keeps parity with Rails response)
return res.json({
user: sanitizeUser(user),
access_token,
refresh_token,
});
});
// POST /api/v1/auth/logout — clear refresh cookie & revoke tokens
app.post(["/api/v1/auth/logout", "/logout"], async (req, res) => {
const h = req.headers.authorization || "";
const at = h.startsWith("Bearer ") ? h.slice(7) : null;
if (at) await revokeToken(at);
const rt = req.cookies?.refresh_token;
if (rt) await revokeToken(rt);
res.clearCookie("refresh_token", { httpOnly: true, sameSite: "lax", secure: false, path: "/" });
return res.json({ success: true });
});
// POST /api/v1/auth/refresh — cookie-based (no body)
app.post(["/api/v1/auth/refresh", "/refresh"], async (req, res) => {
const refresh_token = req.cookies?.refresh_token;
if (!refresh_token) return res.status(401).json({ message: "No refresh token" });
try {
const payload = jwt.verify(refresh_token, JWT_SECRET);
if (payload.typ !== "refresh") return res.status(401).json({ message: "tip token greșit" });
if (await isRevoked(refresh_token)) return res.status(401).json({ message: "refresh token revocat" });
const user = await getUserById(payload.sub);
if (!user) return res.status(401).json({ message: "user inexistent" });
await revokeToken(refresh_token); // rotate
const new_access = signAccessToken(user);
const new_refresh = signRefreshToken(user);
setRefreshCookie(res, new_refresh, REFRESH_TTL * 1000);
return res.json({ access_token: new_access }); // only access in body
} catch {
return res.status(401).json({ message: "refresh token invalid sau expirat" });
}
});
// GET /api/v1/auth/me
app.get(["/api/v1/auth/me", "/me"], async (req, res) => {
const payload = authz(req);
if (!payload || payload.typ !== "access")
return res.status(401).json({ message: "Unauthorized" });
if (await isRevoked((req.headers.authorization || "").slice(7))) {
return res.status(401).json({ message: "Token revocat" });
}
const user = await getUserById(payload.sub);
if (!user) return res.status(404).json({ message: "User not found" });
res.json({ user: sanitizeUser(user) });
});
// -------------------- Password Reset (mock) --------------------
app.post(
["/api/v1/auth/forgot-password", "/forgot-password"],
async (req, res) => {
const { email } = req.body || {};
const user = email ? await getUserByEmail(email) : null;
if (!user)
return res.json({
message: "Dacă emailul există, vei primi un link de resetare",
});
const token = nanoid();
await db.read();
db.data.password_resets.push({
id: nanoid(),
user_id: user.id,
token,
created_at: new Date().toISOString(),
consumed_at: null,
});
await db.write();
// In mock, return token for testing the UI
res.json({ message: "Reset email sent (mock)", token });
}
);
app.post(
["/api/v1/auth/reset-password", "/reset-password"],
async (req, res) => {
const { token, password } = req.body || {};
if (!token || !password)
return res
.status(422)
.json({ message: "token și password sunt obligatorii" });
await db.read();
const prIndex = db.data.password_resets.findIndex(
(r) => r.token === token && !r.consumed_at
);
if (prIndex === -1)
return res.status(400).json({ message: "token invalid sau folosit" });
const pr = db.data.password_resets[prIndex];
const user = await getUserById(pr.user_id);
if (!user) return res.status(404).json({ message: "user inexistent" });
user.password = password;
user.updated_at = new Date().toISOString();
db.data.password_resets[prIndex].consumed_at = new Date().toISOString();
await db.write();
res.json({ message: "Parola a fost actualizată" });
}
);
// -------------------- Mount json-server router for other CRUD --------------------
app.use("/api", router);
// -------------------- Start --------------------
app.listen(Number(PORT), () => {
console.log(`Mock AUTH API on http://localhost:${PORT}`);
});