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:
Adrian Acala
2026-01-20 19:55:32 -08:00
parent 260a898e3e
commit af07a73a07
13 changed files with 433 additions and 29 deletions
+5
View File
@@ -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");
+19
View File
@@ -50,6 +50,9 @@ model User {
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);
});
+5
View File
@@ -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);
+1
View File
@@ -68,6 +68,7 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
});
await prisma.user.deleteMany({});
await prisma.systemConfig.deleteMany({});
await prisma.loginAttempt.deleteMany({});
};
/**
+15 -2
View File
@@ -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,
};
};
+274 -5
View File
@@ -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,10 +2245,12 @@ 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 () => {
if (!isTestEnv || process.env.START_SERVER_IN_TEST === "true") {
httpServer.listen(PORT, async () => {
await initializeUploadDir();
await ensureTrashCollection();
await ensureSystemConfig();
@@ -1990,6 +2258,7 @@ httpServer.listen(PORT, async () => {
await ensureInitialAdminUser();
}
console.log(`Server running on port ${PORT}`);
});
});
}
export default app;
+7 -4
View File
@@ -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
View File
@@ -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 ""
+3 -1
View File
@@ -86,8 +86,10 @@ test.describe("Dashboard Workflows", () => {
await expect(cardLocator).toHaveCount(0);
await expect.poll(async () => {
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);
});
+14
View File
@@ -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,