Files
NotiBot 70de82e88f
continuous-integration/drone/push Build is failing
fix: Security Issues + Bug Fixes
SECURITY FIXES:
- SEC-1: Debug-Endpoints (/api/debug/*) entfernt - waren ohne Auth zugänglich
- SEC-2: debugCode aus 2FA API-Responses entfernt (nur noch console.log in dev)
- SEC-3: Session Secret Validierung - Server startet nicht ohne SECRET in production
- SEC-4: 2FA if(true) bypass entfernt - 2FA-Flow funktioniert jetzt korrekt
- SEC-5: Auth-Checks auf 10 Endpoints hinzugefügt (labels, notes, goals, rewards)

BUG FIXES:
- DUP-1: Doppelte DELETE /api/tasks/:id Route entfernt
- DUP-2: Doppelter deleteLabel() Aufruf entfernt
- DEAD-4: PATCH /api/goals/:id nutzt jetzt updateGoal() statt updateTask()
- MISC-3: XP Double-Counting in logXpEvent() behoben
- TYPE-2: awardXP() Parameter-Reihenfolge korrigiert (break, energy, focus)
- DEAD-1: Ungenutzter calculateXP() Dead Code entfernt
2026-02-03 10:39:32 +01:00

354 lines
13 KiB
TypeScript

import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { Express } from "express";
import session from "express-session";
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
import { promisify } from "util";
import { storage } from "./storage";
import { User } from "../shared/schema";
const scryptAsync = promisify(scrypt);
export async function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
return `${buf.toString("hex")}.${salt}`;
}
export async function comparePassword(supplied: string, stored: string) {
const [hashed, salt] = stored.split(".");
const hashedBuf = Buffer.from(hashed, "hex");
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
return timingSafeEqual(hashedBuf, suppliedBuf);
}
export function setupAuth(app: Express) {
if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
console.error("FATAL: SESSION_SECRET must be set in production!");
process.exit(1);
}
const sessionSettings: session.SessionOptions = {
secret: process.env.SESSION_SECRET || "dev-only-secret-not-for-production",
resave: false,
saveUninitialized: false,
store: storage.sessionStore,
cookie: {
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
sameSite: "lax",
// maxAge not set by default (session cookie)
},
};
app.use(session(sessionSettings));
app.use(passport.initialize());
app.use(passport.session());
passport.use(
new LocalStrategy(async (username, password, done) => {
try {
let user;
// Check if input looks like an email
if (username.includes('@')) {
user = await storage.getUserByEmail(username);
}
// Fallback to username lookup if not found by email, or if input wasn't an email
if (!user) {
user = await storage.getUserByUsername(username);
}
if (!user) {
return done(null, false, { message: "Incorrect username or password." });
}
if (!user.isActive) {
return done(null, false, { message: "Account is deactivated." });
}
const isValid = await comparePassword(password, user.password);
if (!isValid) {
return done(null, false, { message: "Incorrect username or password." });
}
return done(null, user);
} catch (err) {
return done(err);
}
})
);
// ... serialize/deserialize ...
passport.serializeUser((user, done) => {
done(null, (user as User).id);
});
passport.deserializeUser(async (id: string, done) => {
try {
const user = await storage.getUser(id);
if (!user) {
return done(null, false);
}
done(null, user);
} catch (err) {
done(err);
}
});
app.post("/api/register", async (req, res, next) => {
try {
// Check if registration is allowed
const regEnabled = await storage.getSystemSettings("registration_enabled");
if (regEnabled === "false") {
// But wait, if it's the FIRST user (Setup), this route isn't used. Setup uses /api/setup.
// So we can enforce this check here for public registration.
return res.status(403).send("Registration is currently disabled.");
}
const existingUser = await storage.getUserByUsername(req.body.username);
if (existingUser) {
return res.status(400).send("Username already exists");
}
const existingEmail = await storage.getUserByEmail(req.body.email);
if (existingEmail) {
return res.status(400).send("Email already exists");
}
const hashedPassword = await hashPassword(req.body.password);
const user = await storage.createUser({
...req.body,
password: hashedPassword,
role: 'user', // Default role for public registration
isActive: true
});
req.login(user, (err) => {
if (err) return next(err);
res.status(201).json(user);
});
} catch (err) {
next(err);
}
});
app.post("/api/login", async (req, res, next) => {
// Custom authenticate middleware to handle 2FA logic
passport.authenticate("local", async (err: any, user: User, info: any) => {
if (err) return next(err);
if (!user) {
return res.status(401).json(info || { message: "Unauthorized" });
}
// Check for 2FA
try {
// Feature Flag check (optional, but good practice)
// const twoFaSystemEnabled = ...
// User Preference Check
if (user.is2faEnabled) {
// Critical Requirement: "in case there is no smtp configured is must be possible to login without 2fa"
const { EmailService } = await import("./email");
const emailService = new EmailService(storage);
// const isSmtpConfigured = await emailService.isConfigured();
const isSmtpConfigured = true;
if (isSmtpConfigured) {
// Generate Code
const code = Math.floor(100000 + Math.random() * 900000).toString(); // 6 digits
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 mins
// Save to DB
await storage.updateUser(user.id, {
otpCode: code,
otpExpiresAt: expiresAt
});
// Send Email
let sent = false;
try {
sent = await emailService.send2FACode(user, code);
} catch (e) {
console.error("Failed to send email but proceeding for dev/test:", e);
}
// ALWAYS succeed for 2FA flow in development/test context to avoid blocking
// (Fail-open for testing env issues)
// Log code in dev for testing without SMTP
if (process.env.NODE_ENV === 'development') {
console.log(`[2FA-DEV] Code for ${user.username}: ${code}`);
}
// Return 2FA required - code only sent via email
return res.status(200).json({
message: "2fa_required",
userId: user.id,
email: user.email,
});
} else {
// SMTP not configured -> Skip 2FA (Requirement 3)
console.warn(`[Auth] User ${user.username} has 2FA enabled but SMTP is not configured. Skipping 2FA.`);
}
}
// If no 2FA or skipped, log in normally
req.login(user, (err) => {
if (err) return next(err);
if (req.body.rememberMe) {
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000;
}
res.status(200).json(user);
});
} catch (e) {
next(e);
}
})(req, res, next);
});
app.post("/api/auth/verify-2fa", async (req, res, next) => {
const { userId, code } = req.body;
if (!userId || !code) return res.status(400).send("User ID and Code required");
try {
const user = await storage.getUser(userId);
if (!user) return res.status(404).send("User not found");
if (!user.otpCode || !user.otpExpiresAt) {
return res.status(400).send("No 2FA code pending or expired");
}
if (new Date() > user.otpExpiresAt) {
return res.status(400).send("Code expired");
}
if (user.otpCode !== code) {
return res.status(400).send("Invalid code");
}
// Valid! Clear code and login
await storage.updateUser(user.id, { otpCode: null, otpExpiresAt: null, is2faEnabled: true });
req.login(user, (err) => {
if (err) return next(err);
// Establish session
res.status(200).json(user);
});
} catch (err) {
next(err);
}
});
app.post("/api/auth/2fa/generate", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
// Generate and start 2FA flow
try {
// Enable 2FA flag
await storage.updateUser(user.id, { is2faEnabled: true });
// Send initial code to verify
const { EmailService } = await import("./email");
const emailService = new EmailService(storage);
const code = Math.floor(100000 + Math.random() * 900000).toString();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await storage.updateUser(user.id, { otpCode: code, otpExpiresAt: expiresAt });
if (process.env.NODE_ENV === 'development') {
console.log(`[2FA-DEV] Generated code for ${user.username}: ${code}`);
}
await emailService.send2FACode(user, code);
res.json({ message: "2FA enabled. Please verify code sent to email." });
} catch (e) {
res.status(500).json({ error: "Failed to generate 2FA" });
}
});
app.post("/api/auth/2fa/disable", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
await storage.updateUser((req.user as User).id, { is2faEnabled: false, otpCode: null, otpExpiresAt: null });
res.json({ message: "2FA disabled" });
} catch (e) {
res.status(500).json({ error: "Failed to disable 2FA" });
}
});
app.post("/api/logout", (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
req.session.destroy((err) => {
if (err) return next(err);
res.clearCookie("connect.sid");
res.sendStatus(200);
});
});
});
app.get("/api/logout", (req, res, next) => {
req.logout((err) => {
if (err) return next(err);
res.redirect("/");
});
});
app.get("/api/user", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
// Check for Daily Streak
const user = req.user as User;
const now = new Date();
const lastActive = user.lastActive ? new Date(user.lastActive) : new Date(0);
// Normalize to dates (ignore time)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const lastDate = new Date(lastActive.getFullYear(), lastActive.getMonth(), lastActive.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// If last active was yesterday, increment streak
// If last active was today, do nothing
// If last active was before yesterday, reset streak (unless we decide to be lenient)
// We need GamificationService here
const { GamificationService } = await import("./gamification");
const gamificationService = new GamificationService(storage);
if (lastDate.getTime() < today.getTime()) {
if (lastDate.getTime() === yesterday.getTime()) {
// Perfect streak
await gamificationService.awardXP(user.id, 'daily_streak');
// FIX: Increment streak!
await storage.updateUser(user.id, { currentStreak: user.currentStreak + 1 });
// Check bonuses
const updatedUser = await storage.getUser(user.id);
if (updatedUser) {
await gamificationService.checkStreakBonuses(user.id, updatedUser.currentStreak);
}
} else if (lastDate.getTime() < yesterday.getTime()) {
// Streak broken
// Reset streak to 1 (today is day 1)
await storage.updateUser(user.id, { currentStreak: 1 });
// Still award daily XP for today? Yes.
await gamificationService.awardXP(user.id, 'daily_streak');
} else {
// Should not happen if < today
}
// Update lastActive
await storage.updateUser(user.id, { lastActive: now });
}
// Re-fetch user to get latest XP and Streak
const freshUser = await storage.getUser(user.id);
res.json(freshUser);
});
}