feat(auth): enhance authentication system with login attempt tracking and configuration options
- Added a new `LoginAttempt` model to track login attempts, including rate limiting and lockout functionality. - Introduced environment variables for configuring login rate limits and maximum failures. - Updated the authentication middleware to handle login attempts and enforce rate limits. - Enhanced the user model with indexing for username and email for improved lookup performance. - Modified the `.env.example` file to include new optional authentication settings. - Updated integration tests to cover new login attempt features and authentication state management.
This commit is contained in:
@@ -3,6 +3,8 @@ PORT=8000
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=file:/app/prisma/dev.db
|
||||
FRONTEND_URL=http://localhost:6767
|
||||
# Optional auth settings
|
||||
AUTH_ENABLED=true
|
||||
# Optional auth cookie settings: lax | strict | none
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
# Optional auth bootstrap (creates initial admin)
|
||||
@@ -11,3 +13,6 @@ AUTH_EMAIL=admin@example.com
|
||||
# If not set, a random password is generated and logged
|
||||
AUTH_PASSWORD=
|
||||
AUTH_MIN_PASSWORD_LENGTH=7
|
||||
# Optional login throttling
|
||||
LOGIN_RATE_LIMIT_MAX=10
|
||||
LOGIN_MAX_FAILURES=5
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LoginAttempt" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"ip" TEXT NOT NULL,
|
||||
"count" INTEGER NOT NULL DEFAULT 0,
|
||||
"failures" INTEGER NOT NULL DEFAULT 0,
|
||||
"resetTime" DATETIME NOT NULL,
|
||||
"lockoutUntil" DATETIME,
|
||||
"lastAttempt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LoginAttempt_identifier_ip_key" ON "LoginAttempt"("identifier", "ip");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LoginAttempt_lastAttempt_idx" ON "LoginAttempt"("lastAttempt");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_username_idx" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
@@ -42,14 +42,17 @@ model Library {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String? @unique
|
||||
email String? @unique
|
||||
passwordHash String
|
||||
mustResetPassword Boolean @default(false)
|
||||
role String @default("USER")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
username String? @unique
|
||||
email String? @unique
|
||||
passwordHash String
|
||||
mustResetPassword Boolean @default(false)
|
||||
role String @default("USER")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([username])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
@@ -58,3 +61,19 @@ model SystemConfig {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model LoginAttempt {
|
||||
id String @id @default(uuid())
|
||||
identifier String
|
||||
ip String
|
||||
count Int @default(0)
|
||||
failures Int @default(0)
|
||||
resetTime DateTime
|
||||
lockoutUntil DateTime?
|
||||
lastAttempt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([identifier, ip])
|
||||
@@index([lastAttempt])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
cleanupTestDb,
|
||||
getTestDatabaseUrl,
|
||||
@@ -24,6 +25,11 @@ describe("Authentication flows", () => {
|
||||
app = appModule.default;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.LOGIN_RATE_LIMIT_MAX;
|
||||
delete process.env.LOGIN_MAX_FAILURES;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb(prisma);
|
||||
await initTestDb(prisma);
|
||||
@@ -109,4 +115,47 @@ describe("Authentication flows", () => {
|
||||
expect(register.status).toBe(201);
|
||||
expect(register.body.user.username).toBe("user1");
|
||||
});
|
||||
|
||||
it("locks out after repeated failed logins", async () => {
|
||||
process.env.LOGIN_RATE_LIMIT_MAX = "100";
|
||||
process.env.LOGIN_MAX_FAILURES = "2";
|
||||
|
||||
const token = await fetchCsrfToken();
|
||||
await request(app)
|
||||
.post("/auth/bootstrap")
|
||||
.set("x-csrf-token", token)
|
||||
.send({ username: "admin", password: "password123" });
|
||||
|
||||
let loginToken = await fetchCsrfToken();
|
||||
await request(app)
|
||||
.post("/auth/login")
|
||||
.set("x-csrf-token", loginToken)
|
||||
.send({ username: "admin", password: "wrong" });
|
||||
|
||||
loginToken = await fetchCsrfToken();
|
||||
const locked = await request(app)
|
||||
.post("/auth/login")
|
||||
.set("x-csrf-token", loginToken)
|
||||
.send({ username: "admin", password: "wrong" });
|
||||
|
||||
expect(locked.status).toBe(429);
|
||||
expect(locked.body.error).toBe("Account locked");
|
||||
});
|
||||
|
||||
it("blocks auth endpoints when disabled", async () => {
|
||||
process.env.AUTH_ENABLED = "false";
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DATABASE_URL = getTestDatabaseUrl();
|
||||
|
||||
// Reset module cache so the new env is read
|
||||
vi.resetModules();
|
||||
const appModule = (await import("../index")) as { default: unknown };
|
||||
const disabledApp = appModule.default;
|
||||
|
||||
const response = await request(disabledApp).post("/auth/login");
|
||||
expect(response.status).toBe(404);
|
||||
|
||||
process.env.AUTH_ENABLED = "true";
|
||||
vi.resetModules();
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,11 @@ describe("Auth utilities", () => {
|
||||
expect(config.minPasswordLength).toBe(7);
|
||||
});
|
||||
|
||||
it("supports disabling auth via env", () => {
|
||||
const config = buildAuthConfig({ AUTH_ENABLED: "false" });
|
||||
expect(config.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("hashes and verifies passwords", () => {
|
||||
const hashed = hashPassword("super-secret");
|
||||
expect(verifyPassword("super-secret", hashed)).toBe(true);
|
||||
|
||||
@@ -68,6 +68,7 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
|
||||
});
|
||||
await prisma.user.deleteMany({});
|
||||
await prisma.systemConfig.deleteMany({});
|
||||
await prisma.loginAttempt.deleteMany({});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+15
-2
@@ -62,6 +62,18 @@ const parseSameSite = (rawValue?: string): AuthSameSite => {
|
||||
return DEFAULT_COOKIE_SAMESITE;
|
||||
};
|
||||
|
||||
const parseAuthEnabled = (rawValue?: string): boolean => {
|
||||
if (!rawValue) return true;
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
if (["false", "0", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
|
||||
if (!enabled) return Buffer.alloc(0);
|
||||
|
||||
@@ -80,17 +92,18 @@ const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer =>
|
||||
};
|
||||
|
||||
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
|
||||
const enabled = parseAuthEnabled(env.AUTH_ENABLED);
|
||||
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
|
||||
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
|
||||
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
|
||||
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
enabled,
|
||||
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
|
||||
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
|
||||
cookieSameSite,
|
||||
secret: resolveAuthSecret(true, env),
|
||||
secret: resolveAuthSecret(enabled, env),
|
||||
minPasswordLength,
|
||||
};
|
||||
};
|
||||
|
||||
+281
-12
@@ -107,7 +107,7 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
||||
console.log("Allowed origins:", allowedOrigins);
|
||||
|
||||
const authConfig = buildAuthConfig();
|
||||
console.log("[auth] Auth middleware enabled.");
|
||||
console.log(`[auth] Auth middleware ${authConfig.enabled ? "enabled" : "disabled"}.`);
|
||||
|
||||
const uploadDir = path.resolve(__dirname, "../uploads");
|
||||
|
||||
@@ -499,6 +499,16 @@ const authExemptPaths = new Set([
|
||||
"/auth/password",
|
||||
]);
|
||||
|
||||
const authDisabledPaths = new Set([
|
||||
"/auth/login",
|
||||
"/auth/logout",
|
||||
"/auth/register",
|
||||
"/auth/bootstrap",
|
||||
"/auth/registration/toggle",
|
||||
"/auth/admins",
|
||||
"/auth/password",
|
||||
]);
|
||||
|
||||
const authNeedsSession = new Set([
|
||||
"/auth/logout",
|
||||
"/auth/registration/toggle",
|
||||
@@ -641,13 +651,180 @@ const csrfProtectionMiddleware = (
|
||||
|
||||
// Apply authentication and CSRF protection to all routes
|
||||
app.use(authMiddleware);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!authConfig.enabled && authDisabledPaths.has(req.path)) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use(csrfProtectionMiddleware);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (!authConfig.enabled) {
|
||||
res.locals.authUserId = "anonymous";
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
const authLoginSchema = z.object({
|
||||
username: z.string().trim().min(1).max(200),
|
||||
password: z.string().min(1).max(512),
|
||||
});
|
||||
|
||||
const LOGIN_RATE_LIMIT_WINDOW = 10 * 60 * 1000;
|
||||
const LOGIN_LOCKOUT_WINDOW = 15 * 60 * 1000;
|
||||
const getLoginRateLimitMax = () => {
|
||||
const parsed = Number(process.env.LOGIN_RATE_LIMIT_MAX);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 10;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const getLoginMaxFailures = () => {
|
||||
const parsed = Number(process.env.LOGIN_MAX_FAILURES);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 5;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
type LoginAttemptEntry = {
|
||||
id: string;
|
||||
identifier: string;
|
||||
ip: string;
|
||||
count: number;
|
||||
failures: number;
|
||||
resetTime: Date;
|
||||
lockoutUntil: Date | null;
|
||||
lastAttempt: Date;
|
||||
};
|
||||
|
||||
const LOGIN_ATTEMPT_RETENTION = 24 * 60 * 60 * 1000;
|
||||
const LOGIN_ATTEMPT_CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||||
|
||||
const normalizeLoginAttemptIdentifier = (identifier: string) => {
|
||||
const trimmed = identifier.trim();
|
||||
return trimmed.length > 0 ? trimmed.toLowerCase() : "unknown";
|
||||
};
|
||||
|
||||
const normalizeLoginAttemptIp = (ip: string) => {
|
||||
const trimmed = ip.trim();
|
||||
return trimmed.length > 0 ? trimmed.toLowerCase() : "unknown";
|
||||
};
|
||||
|
||||
const cleanupLoginAttempts = async () => {
|
||||
const cutoff = new Date(Date.now() - LOGIN_ATTEMPT_RETENTION);
|
||||
await prisma.loginAttempt.deleteMany({
|
||||
where: {
|
||||
lastAttempt: { lt: cutoff },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
void cleanupLoginAttempts().catch((error) => {
|
||||
console.error("Failed to cleanup login attempts:", error);
|
||||
});
|
||||
}, LOGIN_ATTEMPT_CLEANUP_INTERVAL).unref();
|
||||
|
||||
const resolveLoginAttempt = async (identifier: string, ip: string): Promise<LoginAttemptEntry> => {
|
||||
const now = new Date();
|
||||
const normalizedIdentifier = normalizeLoginAttemptIdentifier(identifier);
|
||||
const normalizedIp = normalizeLoginAttemptIp(ip);
|
||||
const resetTime = new Date(now.getTime() + LOGIN_RATE_LIMIT_WINDOW);
|
||||
const existing = await prisma.loginAttempt.findUnique({
|
||||
where: {
|
||||
identifier_ip: { identifier: normalizedIdentifier, ip: normalizedIp },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return prisma.loginAttempt.create({
|
||||
data: {
|
||||
identifier: normalizedIdentifier,
|
||||
ip: normalizedIp,
|
||||
resetTime,
|
||||
lastAttempt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateData: Prisma.LoginAttemptUpdateInput = {
|
||||
lastAttempt: now,
|
||||
};
|
||||
|
||||
if (existing.lockoutUntil && now >= existing.lockoutUntil) {
|
||||
updateData.lockoutUntil = null;
|
||||
updateData.failures = 0;
|
||||
updateData.count = 0;
|
||||
updateData.resetTime = resetTime;
|
||||
} else if (now > existing.resetTime && !existing.lockoutUntil) {
|
||||
updateData.count = 0;
|
||||
updateData.resetTime = resetTime;
|
||||
}
|
||||
|
||||
return prisma.loginAttempt.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData,
|
||||
});
|
||||
};
|
||||
|
||||
const incrementLoginAttemptCount = async (entryId: string): Promise<LoginAttemptEntry> =>
|
||||
prisma.loginAttempt.update({
|
||||
where: { id: entryId },
|
||||
data: {
|
||||
count: { increment: 1 },
|
||||
lastAttempt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const registerLoginFailure = async (entry: LoginAttemptEntry): Promise<LoginAttemptEntry> => {
|
||||
const now = new Date();
|
||||
const nextFailures = entry.failures + 1;
|
||||
if (nextFailures >= getLoginMaxFailures()) {
|
||||
return prisma.loginAttempt.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
failures: nextFailures,
|
||||
lockoutUntil: new Date(now.getTime() + LOGIN_LOCKOUT_WINDOW),
|
||||
count: 0,
|
||||
resetTime: new Date(now.getTime() + LOGIN_RATE_LIMIT_WINDOW),
|
||||
lastAttempt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.loginAttempt.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
failures: { increment: 1 },
|
||||
lastAttempt: now,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const clearLoginFailures = async (entryId: string) => {
|
||||
await prisma.loginAttempt.update({
|
||||
where: { id: entryId },
|
||||
data: {
|
||||
failures: 0,
|
||||
lockoutUntil: null,
|
||||
count: 0,
|
||||
resetTime: new Date(Date.now() + LOGIN_RATE_LIMIT_WINDOW),
|
||||
lastAttempt: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isLoginLockedOut = (entry: { lockoutUntil: Date | null; failures: number }): boolean => {
|
||||
if (!entry.lockoutUntil) return false;
|
||||
return Date.now() < entry.lockoutUntil.getTime();
|
||||
};
|
||||
|
||||
const authChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1).max(512),
|
||||
newPassword: z.string().min(1).max(512),
|
||||
@@ -669,6 +846,17 @@ const adminUpdateSchema = z.object({
|
||||
});
|
||||
|
||||
app.get("/auth/status", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.json({
|
||||
enabled: false,
|
||||
authenticated: true,
|
||||
registrationEnabled: false,
|
||||
bootstrapRequired: false,
|
||||
user: null,
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
const config = await getSystemConfig();
|
||||
const totalUsers = await prisma.user.count();
|
||||
@@ -692,6 +880,13 @@ app.get("/auth/status", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = authLoginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
@@ -702,6 +897,23 @@ app.post("/auth/login", async (req, res) => {
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
const identifier = username.trim().toLowerCase();
|
||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
let entry = await resolveLoginAttempt(identifier || "unknown", ip);
|
||||
if (isLoginLockedOut(entry)) {
|
||||
return res.status(429).json({
|
||||
error: "Account locked",
|
||||
message: "Too many failed attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
entry = await incrementLoginAttemptCount(entry.id);
|
||||
if (entry.count > getLoginRateLimitMax()) {
|
||||
return res.status(429).json({
|
||||
error: "Rate limit exceeded",
|
||||
message: "Too many login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -722,12 +934,20 @@ app.post("/auth/login", async (req, res) => {
|
||||
});
|
||||
|
||||
if (!user || !verifyPassword(password, user.passwordHash)) {
|
||||
entry = await registerLoginFailure(entry);
|
||||
if (isLoginLockedOut(entry)) {
|
||||
return res.status(429).json({
|
||||
error: "Account locked",
|
||||
message: "Too many failed attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username/email or password.",
|
||||
});
|
||||
}
|
||||
|
||||
await clearLoginFailures(entry.id);
|
||||
const token = createAuthSessionToken(authConfig, user.id);
|
||||
res.cookie(
|
||||
authConfig.cookieName,
|
||||
@@ -746,6 +966,10 @@ app.post("/auth/login", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/logout", (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
res.clearCookie(
|
||||
authConfig.cookieName,
|
||||
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
|
||||
@@ -755,6 +979,13 @@ app.post("/auth/logout", (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/password", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
@@ -805,7 +1036,14 @@ app.post("/auth/password", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/test/must-reset", async (req, res) => {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "test" && process.env.NODE_ENV !== "e2e") {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Endpoint is only available in test environments.",
|
||||
@@ -854,6 +1092,13 @@ app.post("/auth/test/must-reset", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/register", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getSystemConfig();
|
||||
const existingUsers = await prisma.user.count();
|
||||
|
||||
@@ -951,6 +1196,13 @@ app.post("/auth/register", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/bootstrap", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUsers = await prisma.user.count();
|
||||
if (existingUsers > 0) {
|
||||
@@ -1052,6 +1304,13 @@ app.post("/auth/bootstrap", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/registration/toggle", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
@@ -1090,6 +1349,13 @@ app.post("/auth/registration/toggle", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/auth/admins", async (req, res) => {
|
||||
if (!authConfig.enabled) {
|
||||
return res.status(404).json({
|
||||
error: "Not found",
|
||||
message: "Authentication is disabled.",
|
||||
});
|
||||
}
|
||||
|
||||
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||
if (!session) {
|
||||
return res.status(401).json({
|
||||
@@ -1979,17 +2245,20 @@ const ensureTrashCollection = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isTestEnv = process.env.NODE_ENV === "test";
|
||||
const shouldEnsureInitialAdmin =
|
||||
process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true";
|
||||
authConfig.enabled && !isTestEnv && process.env.SKIP_INITIAL_ADMIN !== "true";
|
||||
|
||||
httpServer.listen(PORT, async () => {
|
||||
await initializeUploadDir();
|
||||
await ensureTrashCollection();
|
||||
await ensureSystemConfig();
|
||||
if (shouldEnsureInitialAdmin) {
|
||||
await ensureInitialAdminUser();
|
||||
}
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
if (!isTestEnv || process.env.START_SERVER_IN_TEST === "true") {
|
||||
httpServer.listen(PORT, async () => {
|
||||
await initializeUploadDir();
|
||||
await ensureTrashCollection();
|
||||
await ensureSystemConfig();
|
||||
if (shouldEnsureInitialAdmin) {
|
||||
await ensureInitialAdminUser();
|
||||
}
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -3,8 +3,8 @@ import path from "path";
|
||||
import os from "os";
|
||||
|
||||
// Centralized test environment URLs
|
||||
const FRONTEND_PORT = 5173;
|
||||
const BACKEND_PORT = 8000;
|
||||
const FRONTEND_PORT = Number(process.env.FRONTEND_PORT || 5173);
|
||||
const BACKEND_PORT = Number(process.env.BACKEND_PORT || 8000);
|
||||
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
|
||||
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
|
||||
const API_URL = BACKEND_URL;
|
||||
@@ -110,7 +110,7 @@ export default defineConfig({
|
||||
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
|
||||
url: `${BACKEND_URL}/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
timeout: 240000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
@@ -126,18 +126,21 @@ export default defineConfig({
|
||||
RATE_LIMIT_MAX_REQUESTS: "20000",
|
||||
NODE_ENV: "e2e",
|
||||
TS_NODE_TRANSPILE_ONLY: "1",
|
||||
PORT: String(BACKEND_PORT),
|
||||
START_SERVER_IN_TEST: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "cd ../frontend && npm run dev -- --host",
|
||||
url: FRONTEND_URL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
timeout: 240000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
VITE_API_URL: "/api",
|
||||
API_URL,
|
||||
PORT: String(FRONTEND_PORT),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ elif [ "$CI" = "true" ]; then
|
||||
CI=true NO_SERVER=true npx playwright test
|
||||
else
|
||||
echo " Mode: Headless"
|
||||
NO_SERVER=${NO_SERVER:-false} npx playwright test
|
||||
PWDEBUG=${PWDEBUG:-false} NO_SERVER=${NO_SERVER:-false} npx playwright test
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -86,8 +86,10 @@ test.describe("Dashboard Workflows", () => {
|
||||
|
||||
await expect(cardLocator).toHaveCount(0);
|
||||
|
||||
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
|
||||
expect(response.status()).toBe(404);
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
|
||||
return response.status();
|
||||
}).toBe(404);
|
||||
createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,20 @@ api.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Reset auth state when auth is disabled
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (
|
||||
error.response?.status === 404 &&
|
||||
error.response?.data?.message?.includes("Authentication is disabled")
|
||||
) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor to handle CSRF token errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
|
||||
Reference in New Issue
Block a user