From d98c50c3025f7d47990669165f7101895a5c1678 Mon Sep 17 00:00:00 2001 From: Kaito Date: Tue, 12 May 2026 15:48:18 +0700 Subject: [PATCH 1/3] feat(auth): add login throttling and audit events --- app/login/actions.ts | 79 +++++++++- lib/auth/login-protection.ts | 135 ++++++++++++++++++ .../migration.sql | 22 +++ prisma/schema.prisma | 15 ++ 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 lib/auth/login-protection.ts create mode 100644 prisma/migrations/20260512085000_add_login_throttle/migration.sql diff --git a/app/login/actions.ts b/app/login/actions.ts index 4e633f7..edefee9 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -3,9 +3,19 @@ import bcrypt from "bcryptjs" import { redirect } from "next/navigation" +import { createAuditLog } from "@/lib/api/audit-log" import { createSession } from "@/lib/auth" +import { + checkLoginThrottle, + clearLoginThrottle, + formatLoginThrottleMessage, + getLoginThrottleTarget, + recordFailedLoginAttempt, +} from "@/lib/auth/login-protection" import { prisma } from "@/lib/prisma" +const invalidLoginMessage = "Invalid username or password." + export async function login(_state: string | null, formData: FormData) { const username = String(formData.get("username") ?? "").trim() const password = String(formData.get("password") ?? "") @@ -14,14 +24,79 @@ export async function login(_state: string | null, formData: FormData) { return "Enter username and password." } + const throttleTarget = await getLoginThrottleTarget(username) + const throttle = await checkLoginThrottle(throttleTarget) + const throttleMessage = formatLoginThrottleMessage(throttle) + + if (throttleMessage) { + await createLoginAuditLog({ + username, + action: "auth.login.throttled", + details: "Rejected login attempt while throttle was active.", + }) + + return throttleMessage + } + const user = await prisma.user.findUnique({ where: { username }, }) + const isValidPassword = user + ? await bcrypt.compare(password, user.passwordHash) + : false + + if (!user || !isValidPassword) { + await recordFailedLoginAttempt(throttleTarget) + await createLoginAuditLog({ + username: user?.username ?? username, + userId: user?.id, + action: "auth.login.failed", + details: "Failed login attempt.", + }) - if (!user || !(await bcrypt.compare(password, user.passwordHash))) { - return "Invalid username or password." + return invalidLoginMessage } + await clearLoginThrottle(throttleTarget) + await createLoginAuditLog({ + username: user.username, + userId: user.id, + action: "auth.login.succeeded", + details: "Successful login.", + }) await createSession({ userId: user.id, username: user.username }) redirect("/dashboard") } + +type LoginAuditInput = { + username: string + userId?: string + action: string + details: string +} + +async function createLoginAuditLog({ username, userId, action, details }: LoginAuditInput) { + const companies = userId + ? await prisma.company.findMany({ + where: { userId }, + select: { id: true }, + }) + : [] + + if (companies.length === 0) { + console.info(`[auth] ${action}`, { username }) + return + } + + await Promise.all( + companies.map((company) => + createAuditLog({ + companyId: company.id, + action, + target: { type: "auth", id: null, name: username }, + actor: { type: "user", id: userId ?? null, name: username }, + details, + }) + ) + ) +} diff --git a/lib/auth/login-protection.ts b/lib/auth/login-protection.ts new file mode 100644 index 0000000..6b69a25 --- /dev/null +++ b/lib/auth/login-protection.ts @@ -0,0 +1,135 @@ +import { headers } from "next/headers" + +import { prisma } from "@/lib/prisma" + +const windowMs = 15 * 60 * 1000 +const maxFailedAttempts = 5 +const lockoutMs = 15 * 60 * 1000 +const usernameMaxLength = 191 +const ipMaxLength = 128 +const unknownIp = "unknown" + +type LoginThrottleTarget = { + username: string + ipAddress: string +} + +export type LoginThrottleResult = { + limited: boolean + retryAfterSeconds?: number +} + +export async function getLoginThrottleTarget(username: string): Promise { + const headerStore = await headers() + + return { + username: normalizeUsername(username), + ipAddress: normalizeIpAddress(getClientIp(headerStore)), + } +} + +export async function checkLoginThrottle({ + username, + ipAddress, +}: LoginThrottleTarget): Promise { + const now = new Date() + const windowStartedAt = new Date(now.getTime() - windowMs) + + const throttle = await prisma.loginThrottle.findUnique({ + where: { username_ipAddress: { username, ipAddress } }, + }) + + if (!throttle) return { limited: false } + + if (throttle.lockedUntil && throttle.lockedUntil > now) { + return { + limited: true, + retryAfterSeconds: secondsUntil(throttle.lockedUntil, now), + } + } + + if (throttle.lastFailedAt < windowStartedAt) { + return { limited: false } + } + + if (throttle.failedAttempts >= maxFailedAttempts) { + return { + limited: true, + retryAfterSeconds: Math.ceil(lockoutMs / 1000), + } + } + + return { limited: false } +} + +export async function recordFailedLoginAttempt({ username, ipAddress }: LoginThrottleTarget) { + const now = new Date() + const windowStartedAt = new Date(now.getTime() - windowMs) + + await prisma.$transaction(async (tx) => { + const throttle = await tx.loginThrottle.findUnique({ + where: { username_ipAddress: { username, ipAddress } }, + }) + + const failedAttempts = throttle && throttle.lastFailedAt >= windowStartedAt + ? throttle.failedAttempts + 1 + : 1 + const lockedUntil = failedAttempts >= maxFailedAttempts + ? new Date(now.getTime() + lockoutMs) + : null + + await tx.loginThrottle.upsert({ + where: { username_ipAddress: { username, ipAddress } }, + create: { + username, + ipAddress, + failedAttempts, + lastFailedAt: now, + lockedUntil, + }, + update: { + failedAttempts, + lastFailedAt: now, + lockedUntil, + }, + }) + }) +} + +export async function clearLoginThrottle({ username, ipAddress }: LoginThrottleTarget) { + await prisma.loginThrottle.deleteMany({ + where: { username, ipAddress }, + }) +} + +export function formatLoginThrottleMessage(result: LoginThrottleResult) { + if (!result.limited) return null + + const waitMinutes = Math.max(1, Math.ceil((result.retryAfterSeconds ?? 60) / 60)) + return `Too many sign-in attempts. Try again in ${waitMinutes} minute${waitMinutes === 1 ? "" : "s"}.` +} + +function getClientIp(headerStore: Headers) { + const forwardedFor = headerStore.get("x-forwarded-for")?.split(",")[0]?.trim() + if (forwardedFor) return forwardedFor + + const realIp = headerStore.get("x-real-ip")?.trim() + if (realIp) return realIp + + const vercelForwardedFor = headerStore.get("x-vercel-forwarded-for")?.split(",")[0]?.trim() + if (vercelForwardedFor) return vercelForwardedFor + + return unknownIp +} + +function normalizeUsername(username: string) { + return username.trim().toLowerCase().slice(0, usernameMaxLength) +} + +function normalizeIpAddress(ipAddress: string) { + return (ipAddress || unknownIp).slice(0, ipMaxLength) +} + +function secondsUntil(target: Date, now: Date) { + return Math.max(1, Math.ceil((target.getTime() - now.getTime()) / 1000)) +} diff --git a/prisma/migrations/20260512085000_add_login_throttle/migration.sql b/prisma/migrations/20260512085000_add_login_throttle/migration.sql new file mode 100644 index 0000000..74445db --- /dev/null +++ b/prisma/migrations/20260512085000_add_login_throttle/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "LoginThrottle" ( + "id" UUID NOT NULL, + "username" TEXT NOT NULL, + "ipAddress" TEXT NOT NULL, + "failedAttempts" INTEGER NOT NULL DEFAULT 0, + "lastFailedAt" TIMESTAMP(3) NOT NULL, + "lockedUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LoginThrottle_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LoginThrottle_username_ipAddress_key" ON "LoginThrottle"("username", "ipAddress"); + +-- CreateIndex +CREATE INDEX "LoginThrottle_ipAddress_lastFailedAt_idx" ON "LoginThrottle"("ipAddress", "lastFailedAt"); + +-- CreateIndex +CREATE INDEX "LoginThrottle_lockedUntil_idx" ON "LoginThrottle"("lockedUntil"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f25b04b..7da0873 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,21 @@ model User { companies Company[] } +model LoginThrottle { + id String @id @default(uuid()) @db.Uuid + username String + ipAddress String + failedAttempts Int @default(0) + lastFailedAt DateTime + lockedUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([username, ipAddress]) + @@index([ipAddress, lastFailedAt]) + @@index([lockedUntil]) +} + model Project { id String @id @default(uuid()) @db.Uuid name String From 02eaf855ce55ff06ded4b764efb34e9580cf06d5 Mon Sep 17 00:00:00 2001 From: Kaito Date: Tue, 12 May 2026 16:45:49 +0700 Subject: [PATCH 2/3] fix(auth): align login protection with web workspace --- apps/web/components/theme-provider.tsx | 19 ++++++++++++------- .../web/lib}/auth/login-protection.ts | 0 2 files changed, 12 insertions(+), 7 deletions(-) rename {lib => apps/web/lib}/auth/login-protection.ts (100%) diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx index 25f9f30..b59636a 100644 --- a/apps/web/components/theme-provider.tsx +++ b/apps/web/components/theme-provider.tsx @@ -1,15 +1,20 @@ "use client" import * as React from "react" -import { - ThemeProvider as NextThemesProvider, - type ThemeProviderProps, - useTheme, -} from "next-themes" +import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes" + +type ThemeProviderProps = React.PropsWithChildren<{ + attribute?: "class" | `data-${string}` | Array<"class" | `data-${string}`> + defaultTheme?: string + enableSystem?: boolean + disableTransitionOnChange?: boolean +}> + +const Provider = NextThemesProvider as React.ComponentType function ThemeProvider({ children, ...props }: ThemeProviderProps) { return ( - {children} - + ) } diff --git a/lib/auth/login-protection.ts b/apps/web/lib/auth/login-protection.ts similarity index 100% rename from lib/auth/login-protection.ts rename to apps/web/lib/auth/login-protection.ts From 270cdd1665649eb1691c06b7866b7f0764bd7766 Mon Sep 17 00:00:00 2001 From: Kaito Date: Tue, 12 May 2026 17:07:09 +0700 Subject: [PATCH 3/3] chore(ci): rerun vercel preview