feat(auth): bearer token desteği ve çoklu origin ayarı ekle

- Authorization header ile Bearer token kimlik doğrulaması eklendi
- Token'ların localStorage'da saklanması desteği eklendi
- WEB_ALLOWED_ORIGINS ve WEB_ALLOWED_HOSTS konfigürasyonları eklendi
- Loop işlerinde profileId ve profileName alanları eklendi
- CORS ve Vite sunucusu için çoklu origin desteği sağlandı
This commit is contained in:
2026-01-04 15:20:12 +03:00
parent 45946e7c8e
commit 712af0c898
14 changed files with 85 additions and 18 deletions

View File

@@ -5,7 +5,6 @@ APP_USERNAME=qbuffer
APP_PASSWORD=changeme APP_PASSWORD=changeme
JWT_SECRET=replace_me JWT_SECRET=replace_me
APP_HOST=localhost APP_HOST=localhost
WEB_ORIGIN=http://localhost:5173
SERVER_PORT=3001 SERVER_PORT=3001
WEB_PORT=5173 WEB_PORT=5173
POLL_INTERVAL_MS=3000 POLL_INTERVAL_MS=3000
@@ -15,3 +14,4 @@ MAX_LOOP_LIMIT=1000
STALLED_RECOVERY_MS=300000 STALLED_RECOVERY_MS=300000
TIMER_POLL_MS=60000 TIMER_POLL_MS=60000
NODE_ENV=development NODE_ENV=development
WEB_ALLOWED_ORIGINS=http://192.168.1.205:5175,http://qbuffer.bee

View File

@@ -2,7 +2,10 @@ import { Request, Response, NextFunction } from "express";
import { verifyToken } from "./auth.service" import { verifyToken } from "./auth.service"
export const requireAuth = (req: Request, res: Response, next: NextFunction) => { export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
const token = req.cookies?.["qbuffer_token"]; const cookieToken = req.cookies?.["qbuffer_token"];
const authHeader = req.headers.authorization;
const bearer = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
const token = cookieToken || bearer;
if (!token) { if (!token) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }

View File

@@ -5,6 +5,13 @@ import { isDev } from "../config"
const router = Router(); const router = Router();
const getAuthToken = (req: any) => {
const cookieToken = req.cookies?.["qbuffer_token"];
const header = req.headers?.authorization as string | undefined;
const bearer = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
return cookieToken || bearer;
};
const loginLimiter = rateLimit({ const loginLimiter = rateLimit({
windowMs: 60_000, windowMs: 60_000,
max: 5, max: 5,
@@ -28,7 +35,7 @@ router.post("/login", loginLimiter, async (req, res) => {
secure: !isDev, secure: !isDev,
maxAge: 60 * 24 * 60 * 60 * 1000, maxAge: 60 * 24 * 60 * 60 * 1000,
}); });
return res.json({ username: user.username }); return res.json({ username: user.username, token });
}); });
router.post("/logout", (_req, res) => { router.post("/logout", (_req, res) => {
@@ -41,7 +48,7 @@ router.post("/logout", (_req, res) => {
}); });
router.get("/me", (req, res) => { router.get("/me", (req, res) => {
const token = req.cookies?.["qbuffer_token"]; const token = getAuthToken(req);
if (!token) { if (!token) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }
@@ -54,7 +61,7 @@ router.get("/me", (req, res) => {
}); });
router.get("/socket-token", (req, res) => { router.get("/socket-token", (req, res) => {
const token = req.cookies?.["qbuffer_token"]; const token = getAuthToken(req);
if (!token) { if (!token) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
} }

View File

@@ -22,6 +22,7 @@ export const config = {
timerPollMs: envNumber(process.env.TIMER_POLL_MS, 60_000), timerPollMs: envNumber(process.env.TIMER_POLL_MS, 60_000),
webPort: envNumber(process.env.WEB_PORT, 5173), webPort: envNumber(process.env.WEB_PORT, 5173),
webOrigin: process.env.WEB_ORIGIN ?? "", webOrigin: process.env.WEB_ORIGIN ?? "",
webAllowedOrigins: process.env.WEB_ALLOWED_ORIGINS ?? "",
dataDir: "/app/data", dataDir: "/app/data",
dbPath: "/app/data/db.json", dbPath: "/app/data/db.json",
logsPath: "/app/data/logs.json", logsPath: "/app/data/logs.json",

View File

@@ -45,7 +45,12 @@ const bootstrap = async () => {
if (isDev) { if (isDev) {
const fallbackOrigin = `http://localhost:${config.webPort}`; const fallbackOrigin = `http://localhost:${config.webPort}`;
const origins = [config.webOrigin || fallbackOrigin, fallbackOrigin]; const originList = [config.webOrigin || fallbackOrigin, fallbackOrigin];
const allowed = config.webAllowedOrigins
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const origins = [...originList, ...allowed];
app.use( app.use(
cors({ cors({
origin: origins, origin: origins,

View File

@@ -27,6 +27,8 @@ export const createLoopJob = async (
allowIp: string; allowIp: string;
targetLoops: number; targetLoops: number;
delayMs: number; delayMs: number;
profileName?: string;
profileId?: string;
} }
): Promise<LoopJob> => { ): Promise<LoopJob> => {
const now = nowIso(); const now = nowIso();
@@ -41,6 +43,8 @@ export const createLoopJob = async (
targetLoops: input.targetLoops, targetLoops: input.targetLoops,
doneLoops: 0, doneLoops: 0,
delayMs: input.delayMs, delayMs: input.delayMs,
profileName: input.profileName,
profileId: input.profileId,
deleteDataBetweenLoops: true, deleteDataBetweenLoops: true,
enforcementMode: "aggressive-soft", enforcementMode: "aggressive-soft",
status: "RUNNING", status: "RUNNING",

View File

@@ -18,7 +18,7 @@ router.post("/start", async (req, res) => {
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
const { hash, allowIp, targetLoops, delayMs } = parsed.data; const { hash, allowIp, targetLoops, delayMs, profileName, profileId } = parsed.data;
const db = await readDb(); const db = await readDb();
if (targetLoops > db.settings.maxLoopLimit) { if (targetLoops > db.settings.maxLoopLimit) {
return res.status(400).json({ error: "Target loops exceed max limit" }); return res.status(400).json({ error: "Target loops exceed max limit" });
@@ -64,6 +64,8 @@ router.post("/start", async (req, res) => {
allowIp, allowIp,
targetLoops, targetLoops,
delayMs, delayMs,
profileName,
profileId,
}); });
res.json(job); res.json(job);
}); });

View File

@@ -32,6 +32,8 @@ export interface LoopJob {
targetLoops: number; targetLoops: number;
doneLoops: number; doneLoops: number;
delayMs: number; delayMs: number;
profileName?: string;
profileId?: string;
deleteDataBetweenLoops: boolean; deleteDataBetweenLoops: boolean;
enforcementMode: EnforcementMode; enforcementMode: EnforcementMode;
status: LoopStatus; status: LoopStatus;
@@ -62,6 +64,8 @@ export interface Profile {
name: string; name: string;
allowIp: string; allowIp: string;
delayMs: number; delayMs: number;
profileName?: string;
profileId?: string;
targetLoops: number; targetLoops: number;
createdAt: string; createdAt: string;
} }

View File

@@ -6,3 +6,14 @@ export const api = axios.create({
baseURL: baseURL || undefined, baseURL: baseURL || undefined,
withCredentials: true, withCredentials: true,
}); });
api.interceptors.request.use((config) => {
const token = localStorage.getItem("qbuffer_token");
if (token) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});

View File

@@ -45,6 +45,9 @@ export const LoopSetupCard = () => {
if (!selectedHash) return false; if (!selectedHash) return false;
const job = jobs.find((j) => j.torrentHash === selectedHash && j.status === "RUNNING"); const job = jobs.find((j) => j.torrentHash === selectedHash && j.status === "RUNNING");
if (!job) return false; if (!job) return false;
if (job.profileId) {
return job.profileId === profile.id;
}
return ( return (
job.allowIp === profile.allowIp && job.allowIp === profile.allowIp &&
job.delayMs === profile.delayMs && job.delayMs === profile.delayMs &&
@@ -144,6 +147,8 @@ export const LoopSetupCard = () => {
allowIp: profile.allowIp, allowIp: profile.allowIp,
targetLoops: profile.targetLoops, targetLoops: profile.targetLoops,
delayMs: profile.delayMs, delayMs: profile.delayMs,
profileName: profile.name,
profileId: profile.id,
}); });
setLoopForm({ setLoopForm({
allowIp: profile.allowIp, allowIp: profile.allowIp,

View File

@@ -77,6 +77,15 @@ export const TorrentTable = () => {
const getProfileName = (hash: string) => { const getProfileName = (hash: string) => {
const job = jobs.find((j) => j.torrentHash === hash); const job = jobs.find((j) => j.torrentHash === hash);
if (!job) return null; if (!job) return null;
if (job.profileId) {
const profileById = profiles.find((p) => p.id === job.profileId);
if (profileById?.name) {
return profileById.name;
}
}
if (job.profileName) {
return job.profileName;
}
const profile = profiles.find((p) => const profile = profiles.find((p) =>
p.allowIp === job.allowIp && p.allowIp === job.allowIp &&
p.delayMs === job.delayMs && p.delayMs === job.delayMs &&

View File

@@ -25,6 +25,8 @@ export interface LoopJob {
targetLoops: number; targetLoops: number;
doneLoops: number; doneLoops: number;
delayMs: number; delayMs: number;
profileName?: string;
profileId?: string;
status: string; status: string;
bans: { bannedIps: string[] }; bans: { bannedIps: string[] };
nextRunAt?: string; nextRunAt?: string;

View File

@@ -18,6 +18,9 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await api.post("/api/auth/login", { username, password }); const response = await api.post("/api/auth/login", { username, password });
if (response.data?.token) {
localStorage.setItem("qbuffer_token", response.data.token);
}
set({ username: response.data.username, loading: false }); set({ username: response.data.username, loading: false });
return true; return true;
} catch (error) { } catch (error) {
@@ -27,6 +30,7 @@ export const useAuthStore = create<AuthState>((set) => ({
}, },
logout: async () => { logout: async () => {
await api.post("/api/auth/logout"); await api.post("/api/auth/logout");
localStorage.removeItem("qbuffer_token");
set({ username: null }); set({ username: null });
}, },
check: async () => { check: async () => {
@@ -34,6 +38,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const response = await api.get("/api/auth/me"); const response = await api.get("/api/auth/me");
set({ username: response.data.username ?? "session" }); set({ username: response.data.username ?? "session" });
} catch (error) { } catch (error) {
localStorage.removeItem("qbuffer_token");
set({ username: null }); set({ username: null });
} }
}, },

View File

@@ -1,8 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "node:path"; import path from "node:path";
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, "../.."), "");
const allowedHosts = (env.WEB_ALLOWED_HOSTS || "")
.split(",")
.map((host) => host.trim().replace(/^"|"$/g, ""))
.filter(Boolean);
return {
plugins: [react()], plugins: [react()],
build: { build: {
outDir: path.resolve(__dirname, "../server/public"), outDir: path.resolve(__dirname, "../server/public"),
@@ -10,6 +17,8 @@ export default defineConfig({
}, },
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: Number(process.env.WEB_PORT) || 5173, port: Number(env.WEB_PORT) || 5173,
allowedHosts: allowedHosts.length ? allowedHosts : true,
}, },
};
}); });