Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions apps/web/app/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? "")
Expand All @@ -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,
})
)
)
}
19 changes: 12 additions & 7 deletions apps/web/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeProviderProps>

function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
<Provider
attribute="class"
defaultTheme="system"
enableSystem
Expand All @@ -18,7 +23,7 @@ function ThemeProvider({ children, ...props }: ThemeProviderProps) {
>
<ThemeHotkey />
{children}
</NextThemesProvider>
</Provider>
)
}

Expand Down
135 changes: 135 additions & 0 deletions apps/web/lib/auth/login-protection.ts
Original file line number Diff line number Diff line change
@@ -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<LoginThrottleTarget> {
const headerStore = await headers()

return {
username: normalizeUsername(username),
ipAddress: normalizeIpAddress(getClientIp(headerStore)),
}
}

export async function checkLoginThrottle({
username,
ipAddress,
}: LoginThrottleTarget): Promise<LoginThrottleResult> {
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))
}
22 changes: 22 additions & 0 deletions prisma/migrations/20260512085000_add_login_throttle/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading