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
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
FRONTEND_URL=http://localhost:6767
|
||||||
|
# Optional auth settings
|
||||||
|
AUTH_ENABLED=true
|
||||||
# Optional auth cookie settings: lax | strict | none
|
# Optional auth cookie settings: lax | strict | none
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
# Optional auth bootstrap (creates initial admin)
|
# 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
|
# If not set, a random password is generated and logged
|
||||||
AUTH_PASSWORD=
|
AUTH_PASSWORD=
|
||||||
AUTH_MIN_PASSWORD_LENGTH=7
|
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");
|
||||||
@@ -50,6 +50,9 @@ model User {
|
|||||||
role String @default("USER")
|
role String @default("USER")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([username])
|
||||||
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
@@ -58,3 +61,19 @@ model SystemConfig {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import { vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
cleanupTestDb,
|
cleanupTestDb,
|
||||||
getTestDatabaseUrl,
|
getTestDatabaseUrl,
|
||||||
@@ -24,6 +25,11 @@ describe("Authentication flows", () => {
|
|||||||
app = appModule.default;
|
app = appModule.default;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.LOGIN_RATE_LIMIT_MAX;
|
||||||
|
delete process.env.LOGIN_MAX_FAILURES;
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await cleanupTestDb(prisma);
|
await cleanupTestDb(prisma);
|
||||||
await initTestDb(prisma);
|
await initTestDb(prisma);
|
||||||
@@ -109,4 +115,47 @@ describe("Authentication flows", () => {
|
|||||||
expect(register.status).toBe(201);
|
expect(register.status).toBe(201);
|
||||||
expect(register.body.user.username).toBe("user1");
|
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);
|
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", () => {
|
it("hashes and verifies passwords", () => {
|
||||||
const hashed = hashPassword("super-secret");
|
const hashed = hashPassword("super-secret");
|
||||||
expect(verifyPassword("super-secret", hashed)).toBe(true);
|
expect(verifyPassword("super-secret", hashed)).toBe(true);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
|
|||||||
});
|
});
|
||||||
await prisma.user.deleteMany({});
|
await prisma.user.deleteMany({});
|
||||||
await prisma.systemConfig.deleteMany({});
|
await prisma.systemConfig.deleteMany({});
|
||||||
|
await prisma.loginAttempt.deleteMany({});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+15
-2
@@ -62,6 +62,18 @@ const parseSameSite = (rawValue?: string): AuthSameSite => {
|
|||||||
return DEFAULT_COOKIE_SAMESITE;
|
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 => {
|
const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer => {
|
||||||
if (!enabled) return Buffer.alloc(0);
|
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 => {
|
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
|
||||||
|
const enabled = parseAuthEnabled(env.AUTH_ENABLED);
|
||||||
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
|
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
|
||||||
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
|
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
|
||||||
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
|
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
|
||||||
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
|
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled,
|
||||||
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
|
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
|
||||||
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
|
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
|
||||||
cookieSameSite,
|
cookieSameSite,
|
||||||
secret: resolveAuthSecret(true, env),
|
secret: resolveAuthSecret(enabled, env),
|
||||||
minPasswordLength,
|
minPasswordLength,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+272
-3
@@ -107,7 +107,7 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
|
|||||||
console.log("Allowed origins:", allowedOrigins);
|
console.log("Allowed origins:", allowedOrigins);
|
||||||
|
|
||||||
const authConfig = buildAuthConfig();
|
const authConfig = buildAuthConfig();
|
||||||
console.log("[auth] Auth middleware enabled.");
|
console.log(`[auth] Auth middleware ${authConfig.enabled ? "enabled" : "disabled"}.`);
|
||||||
|
|
||||||
const uploadDir = path.resolve(__dirname, "../uploads");
|
const uploadDir = path.resolve(__dirname, "../uploads");
|
||||||
|
|
||||||
@@ -499,6 +499,16 @@ const authExemptPaths = new Set([
|
|||||||
"/auth/password",
|
"/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([
|
const authNeedsSession = new Set([
|
||||||
"/auth/logout",
|
"/auth/logout",
|
||||||
"/auth/registration/toggle",
|
"/auth/registration/toggle",
|
||||||
@@ -641,13 +651,180 @@ const csrfProtectionMiddleware = (
|
|||||||
|
|
||||||
// Apply authentication and CSRF protection to all routes
|
// Apply authentication and CSRF protection to all routes
|
||||||
app.use(authMiddleware);
|
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(csrfProtectionMiddleware);
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!authConfig.enabled) {
|
||||||
|
res.locals.authUserId = "anonymous";
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const authLoginSchema = z.object({
|
const authLoginSchema = z.object({
|
||||||
username: z.string().trim().min(1).max(200),
|
username: z.string().trim().min(1).max(200),
|
||||||
password: z.string().min(1).max(512),
|
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({
|
const authChangePasswordSchema = z.object({
|
||||||
currentPassword: z.string().min(1).max(512),
|
currentPassword: z.string().min(1).max(512),
|
||||||
newPassword: 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) => {
|
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 session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||||
const config = await getSystemConfig();
|
const config = await getSystemConfig();
|
||||||
const totalUsers = await prisma.user.count();
|
const totalUsers = await prisma.user.count();
|
||||||
@@ -692,6 +880,13 @@ app.get("/auth/status", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/login", 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);
|
const parsed = authLoginSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -702,6 +897,23 @@ app.post("/auth/login", async (req, res) => {
|
|||||||
|
|
||||||
const { username, password } = parsed.data;
|
const { username, password } = parsed.data;
|
||||||
const identifier = username.trim().toLowerCase();
|
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({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -722,12 +934,20 @@ app.post("/auth/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !verifyPassword(password, user.passwordHash)) {
|
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({
|
return res.status(401).json({
|
||||||
error: "Unauthorized",
|
error: "Unauthorized",
|
||||||
message: "Invalid username/email or password.",
|
message: "Invalid username/email or password.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clearLoginFailures(entry.id);
|
||||||
const token = createAuthSessionToken(authConfig, user.id);
|
const token = createAuthSessionToken(authConfig, user.id);
|
||||||
res.cookie(
|
res.cookie(
|
||||||
authConfig.cookieName,
|
authConfig.cookieName,
|
||||||
@@ -746,6 +966,10 @@ app.post("/auth/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/logout", (req, res) => {
|
app.post("/auth/logout", (req, res) => {
|
||||||
|
if (!authConfig.enabled) {
|
||||||
|
return res.status(204).end();
|
||||||
|
}
|
||||||
|
|
||||||
res.clearCookie(
|
res.clearCookie(
|
||||||
authConfig.cookieName,
|
authConfig.cookieName,
|
||||||
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
|
buildAuthCookieOptions(isRequestSecure(req), authConfig.cookieSameSite)
|
||||||
@@ -755,6 +979,13 @@ app.post("/auth/logout", (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/password", async (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);
|
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({
|
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) => {
|
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({
|
return res.status(404).json({
|
||||||
error: "Not found",
|
error: "Not found",
|
||||||
message: "Endpoint is only available in test environments.",
|
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) => {
|
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 config = await getSystemConfig();
|
||||||
const existingUsers = await prisma.user.count();
|
const existingUsers = await prisma.user.count();
|
||||||
|
|
||||||
@@ -951,6 +1196,13 @@ app.post("/auth/register", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/bootstrap", 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 {
|
try {
|
||||||
const existingUsers = await prisma.user.count();
|
const existingUsers = await prisma.user.count();
|
||||||
if (existingUsers > 0) {
|
if (existingUsers > 0) {
|
||||||
@@ -1052,6 +1304,13 @@ app.post("/auth/bootstrap", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/registration/toggle", 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);
|
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -1090,6 +1349,13 @@ app.post("/auth/registration/toggle", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/admins", 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);
|
const session = getAuthSessionFromCookie(req.headers.cookie, authConfig);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -1979,9 +2245,11 @@ const ensureTrashCollection = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isTestEnv = process.env.NODE_ENV === "test";
|
||||||
const shouldEnsureInitialAdmin =
|
const shouldEnsureInitialAdmin =
|
||||||
process.env.NODE_ENV !== "test" && process.env.SKIP_INITIAL_ADMIN !== "true";
|
authConfig.enabled && !isTestEnv && process.env.SKIP_INITIAL_ADMIN !== "true";
|
||||||
|
|
||||||
|
if (!isTestEnv || process.env.START_SERVER_IN_TEST === "true") {
|
||||||
httpServer.listen(PORT, async () => {
|
httpServer.listen(PORT, async () => {
|
||||||
await initializeUploadDir();
|
await initializeUploadDir();
|
||||||
await ensureTrashCollection();
|
await ensureTrashCollection();
|
||||||
@@ -1991,5 +2259,6 @@ httpServer.listen(PORT, async () => {
|
|||||||
}
|
}
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import path from "path";
|
|||||||
import os from "os";
|
import os from "os";
|
||||||
|
|
||||||
// Centralized test environment URLs
|
// Centralized test environment URLs
|
||||||
const FRONTEND_PORT = 5173;
|
const FRONTEND_PORT = Number(process.env.FRONTEND_PORT || 5173);
|
||||||
const BACKEND_PORT = 8000;
|
const BACKEND_PORT = Number(process.env.BACKEND_PORT || 8000);
|
||||||
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
|
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
|
||||||
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
|
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
|
||||||
const API_URL = BACKEND_URL;
|
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",
|
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
|
||||||
url: `${BACKEND_URL}/health`,
|
url: `${BACKEND_URL}/health`,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120000,
|
timeout: 240000,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
env: {
|
env: {
|
||||||
@@ -126,18 +126,21 @@ export default defineConfig({
|
|||||||
RATE_LIMIT_MAX_REQUESTS: "20000",
|
RATE_LIMIT_MAX_REQUESTS: "20000",
|
||||||
NODE_ENV: "e2e",
|
NODE_ENV: "e2e",
|
||||||
TS_NODE_TRANSPILE_ONLY: "1",
|
TS_NODE_TRANSPILE_ONLY: "1",
|
||||||
|
PORT: String(BACKEND_PORT),
|
||||||
|
START_SERVER_IN_TEST: "true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "cd ../frontend && npm run dev -- --host",
|
command: "cd ../frontend && npm run dev -- --host",
|
||||||
url: FRONTEND_URL,
|
url: FRONTEND_URL,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 120000,
|
timeout: 240000,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
env: {
|
env: {
|
||||||
VITE_API_URL: "/api",
|
VITE_API_URL: "/api",
|
||||||
API_URL,
|
API_URL,
|
||||||
|
PORT: String(FRONTEND_PORT),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ elif [ "$CI" = "true" ]; then
|
|||||||
CI=true NO_SERVER=true npx playwright test
|
CI=true NO_SERVER=true npx playwright test
|
||||||
else
|
else
|
||||||
echo " Mode: Headless"
|
echo " Mode: Headless"
|
||||||
NO_SERVER=${NO_SERVER:-false} npx playwright test
|
PWDEBUG=${PWDEBUG:-false} NO_SERVER=${NO_SERVER:-false} npx playwright test
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ test.describe("Dashboard Workflows", () => {
|
|||||||
|
|
||||||
await expect(cardLocator).toHaveCount(0);
|
await expect(cardLocator).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
|
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
|
||||||
expect(response.status()).toBe(404);
|
return response.status();
|
||||||
|
}).toBe(404);
|
||||||
createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id);
|
createdDrawingIds = createdDrawingIds.filter((id) => id !== createdDrawing.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ api.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error)
|
(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
|
// Add response interceptor to handle CSRF token errors
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
|
|||||||
Reference in New Issue
Block a user