JWT, server modüler hale getirildi, Torrent durumu kalıcı hale getirildi.
This commit is contained in:
221
server/modules/auth.js
Normal file
221
server/modules/auth.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import express from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import jwt from "jsonwebtoken";
|
||||
import crypto from "crypto";
|
||||
|
||||
const DEFAULT_ACCESS_TTL = process.env.JWT_TTL || "15m";
|
||||
const DEFAULT_REFRESH_TTL = process.env.JWT_REFRESH_TTL || "30d";
|
||||
const ITERATIONS = 120000;
|
||||
const KEY_LEN = 64;
|
||||
const DIGEST = "sha512";
|
||||
|
||||
function ensureDir(target) {
|
||||
const dir = path.dirname(target);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonSafe(filePath, fallback = null) {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JSON okunamadı (${filePath}): ${err.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
ensureDir(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JSON yazılamadı (${filePath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPasswordHash(password, salt = crypto.randomBytes(16).toString("hex")) {
|
||||
const hash = crypto
|
||||
.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST)
|
||||
.toString("hex");
|
||||
return { hash, salt, iterations: ITERATIONS, keylen: KEY_LEN, digest: DIGEST };
|
||||
}
|
||||
|
||||
function verifyPassword(password, user) {
|
||||
if (!user?.password) return false;
|
||||
const { salt, iterations, keylen, digest, hash } = user.password;
|
||||
const candidate = crypto
|
||||
.pbkdf2Sync(password, salt, iterations, keylen, digest)
|
||||
.toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(hash));
|
||||
}
|
||||
|
||||
function loadSecret(secretPath) {
|
||||
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
|
||||
if (secretPath && fs.existsSync(secretPath)) {
|
||||
try {
|
||||
return fs.readFileSync(secretPath, "utf-8").trim();
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JWT secret okunamadı (${secretPath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
const generated = crypto.randomBytes(48).toString("hex");
|
||||
if (secretPath) {
|
||||
try {
|
||||
ensureDir(secretPath);
|
||||
fs.writeFileSync(secretPath, generated, "utf-8");
|
||||
console.log("🔑 Yeni JWT secret oluşturuldu (diskte saklandı).");
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ JWT secret yazılamadı (${secretPath}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
function loadUsers(usersPath, defaultUser) {
|
||||
let users = readJsonSafe(usersPath, []);
|
||||
if (!Array.isArray(users)) users = [];
|
||||
|
||||
if (defaultUser?.username && defaultUser?.password) {
|
||||
const exists = users.some((u) => u.username === defaultUser.username);
|
||||
if (!exists) {
|
||||
const password = buildPasswordHash(defaultUser.password);
|
||||
users.push({
|
||||
username: defaultUser.username,
|
||||
role: defaultUser.role || "admin",
|
||||
password
|
||||
});
|
||||
writeJsonSafe(usersPath, users);
|
||||
console.log(`👤 Varsayılan kullanıcı eklendi: ${defaultUser.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
export function createAuth({ usersPath, secretPath }) {
|
||||
const secret = loadSecret(secretPath);
|
||||
const users = loadUsers(usersPath, {
|
||||
username: process.env.USERNAME || "admin",
|
||||
password: process.env.PASSWORD || "dupe",
|
||||
role: "admin"
|
||||
});
|
||||
|
||||
function signToken(payload, opts = {}) {
|
||||
const jwtOpts = {
|
||||
issuer: "dupe",
|
||||
audience: "dupe-clients",
|
||||
expiresIn: opts.expiresIn || DEFAULT_ACCESS_TTL
|
||||
};
|
||||
// subject zaten payload.sub içinde ise tekrar opsiyonlara eklemeyelim
|
||||
if (!payload.sub && payload.username) {
|
||||
jwtOpts.subject = payload.username;
|
||||
}
|
||||
return jwt.sign(payload, secret, jwtOpts);
|
||||
}
|
||||
|
||||
function verifyToken(token, expectedType = "access") {
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret, {
|
||||
issuer: "dupe",
|
||||
audience: "dupe-clients"
|
||||
});
|
||||
if (expectedType && decoded.type !== expectedType) return null;
|
||||
return decoded;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function issueTokens(user) {
|
||||
const base = { sub: user.username, role: user.role || "user" };
|
||||
const accessToken = signToken({ ...base, type: "access" }, { expiresIn: DEFAULT_ACCESS_TTL });
|
||||
const refreshToken = signToken({ ...base, type: "refresh" }, { expiresIn: DEFAULT_REFRESH_TTL });
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || "";
|
||||
const bearer = header.startsWith("Bearer ") ? header.slice(7) : null;
|
||||
const token = bearer || req.query.token;
|
||||
if (!token) return res.status(401).json({ error: "Unauthorized" });
|
||||
const decoded = verifyToken(token, req.path.startsWith("/media/") ? null : "access");
|
||||
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
|
||||
req.user = decoded;
|
||||
next();
|
||||
}
|
||||
|
||||
function requireRole(role) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
const roles = Array.isArray(role) ? role : [role];
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function issueMediaToken(targetPath, ttlSeconds = 3600) {
|
||||
const expiresIn = Math.min(Math.max(Number(ttlSeconds) || 3600, 60), 72 * 3600);
|
||||
return signToken(
|
||||
{ type: "media", path: targetPath || "*", role: "media" },
|
||||
{ expiresIn }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyMediaToken(token, requestedPath) {
|
||||
const decoded = verifyToken(token, "media");
|
||||
if (!decoded) return null;
|
||||
if (decoded.path && decoded.path !== "*" && requestedPath) {
|
||||
if (!requestedPath.startsWith(decoded.path)) return null;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/api/login", (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: "username ve password gerekli" });
|
||||
}
|
||||
const user = users.find((u) => u.username === username);
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
return res.status(401).json({ error: "Invalid credentials" });
|
||||
}
|
||||
const { accessToken, refreshToken } = issueTokens(user);
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { username: user.username, role: user.role || "user" }
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/api/token/refresh", (req, res) => {
|
||||
const { refreshToken } = req.body || {};
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: "refreshToken gerekli" });
|
||||
}
|
||||
const decoded = verifyToken(refreshToken, "refresh");
|
||||
if (!decoded) return res.status(401).json({ error: "Unauthorized" });
|
||||
const accessToken = signToken(
|
||||
{ sub: decoded.sub, role: decoded.role, type: "access" },
|
||||
{ expiresIn: DEFAULT_ACCESS_TTL }
|
||||
);
|
||||
res.json({ accessToken });
|
||||
});
|
||||
|
||||
router.get("/api/auth/profile", requireAuth, (req, res) => {
|
||||
res.json({ user: { username: req.user.sub, role: req.user.role } });
|
||||
});
|
||||
|
||||
return {
|
||||
router,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
issueMediaToken,
|
||||
verifyMediaToken,
|
||||
verifyToken
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user