authentik now working with convex auth

This commit is contained in:
2026-01-12 10:10:03 -06:00
parent abcf9df6aa
commit 72f11f0b02
27 changed files with 1310 additions and 134 deletions

View File

@@ -1,11 +1,6 @@
import { withSentryConfig } from '@sentry/nextjs';
import { createJiti } from 'jiti';
import { withPlausibleProxy } from 'next-plausible';
const jiti = createJiti(import.meta.url);
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
await jiti.import('./src/env');
import { env } from './src/env.js';
/** @type {import("next").NextConfig} */
const config = withPlausibleProxy({

View File

@@ -38,7 +38,6 @@
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"eslint": "catalog:",
"jiti": "^2.5.1",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,460 @@
'use client';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import Link from 'next/link';
import { useAuthActions } from '@convex-dev/auth/react';
import { useRouter } from 'next/navigation';
import { ConvexError } from 'convex/values';
import { useState } from 'react';
import {
Card,
CardContent,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
Separator,
SubmitButton,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@gib/ui';
import { toast } from 'sonner';
import {
GibsAuthSignInButton,
} from '@/components/layout/auth/buttons';
import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@gib/backend/types';
const signInFormSchema = z.object({
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z.string().regex(PASSWORD_REGEX, {
message: 'Incorrect password. Does not meet requirements.',
}),
});
const signUpFormSchema = z
.object({
name: z.string().min(2, {
message: 'Name must be at least 2 characters.',
}),
email: z.email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
})
.max(PASSWORD_MAX, {
message: `Password must be no more than ${PASSWORD_MAX} characters.`,
})
.regex(/^\S+$/, {
message: 'Password must not contain whitespace.',
})
.regex(/[0-9]/, {
message: 'Password must contain at least one digit.',
})
.regex(/[a-z]/, {
message: 'Password must contain at least one lowercase letter.',
})
.regex(/[A-Z]/, {
message: 'Password must contain at least one uppercase letter.',
})
.regex(/[\p{P}\p{S}]/u, {
message: 'Password must contain at least one symbol.',
}),
confirmPassword: z.string().min(PASSWORD_MIN, {
message: `Password must be at least ${PASSWORD_MIN} characters.`,
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match!',
path: ['confirmPassword'],
});
const verifyEmailFormSchema = z.object({
code: z.string({ message: 'Invalid code.' }),
});
const SignIn = () => {
const { signIn } = useAuthActions();
const [flow, setFlow] = useState<'signIn' | 'signUp' | 'email-verification'>(
'signIn',
);
const [email, setEmail] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const signInForm = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: { email: '', password: '' },
});
const signUpForm = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
name: '',
email,
password: '',
confirmPassword: '',
},
});
const verifyEmailForm = useForm<z.infer<typeof verifyEmailFormSchema>>({
resolver: zodResolver(verifyEmailFormSchema),
defaultValues: { code },
});
const handleSignIn = async (values: z.infer<typeof signInFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error signing in:', error);
toast.error('Error signing in.');
} finally {
signInForm.reset();
setLoading(false);
}
};
const handleSignUp = async (values: z.infer<typeof signUpFormSchema>) => {
const formData = new FormData();
formData.append('email', values.email);
formData.append('password', values.password);
formData.append('flow', flow);
formData.append('name', values.name);
setLoading(true);
try {
if (values.confirmPassword !== values.password)
throw new ConvexError('Passwords do not match.');
await signIn('password', formData).then(() => {
setEmail(values.email);
setFlow('email-verification');
});
} catch (error) {
console.error('Error signing up:', error);
toast.error('Error signing up.');
} finally {
signUpForm.reset();
setLoading(false);
}
};
const handleVerifyEmail = async (
values: z.infer<typeof verifyEmailFormSchema>,
) => {
const formData = new FormData();
formData.append('code', code);
formData.append('flow', flow);
formData.append('email', email);
setLoading(true);
try {
await signIn('password', formData).then(() => router.push('/'));
} catch (error) {
console.error('Error verifying email:', error);
toast.error('Error verifying email.');
} finally {
verifyEmailForm.reset();
setLoading(false);
}
};
if (flow === 'email-verification') {
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<CardContent>
<div className='text-center mb-6'>
<h2 className='text-2xl font-bold'>Verify Your Email</h2>
<p className='text-muted-foreground'>We sent a code to {email}</p>
</div>
<Form {...verifyEmailForm}>
<form
onSubmit={verifyEmailForm.handleSubmit(handleVerifyEmail)}
className='flex flex-col space-y-8'
>
<FormField
control={verifyEmailForm.control}
name='code'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
value={code}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSeparator />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Please enter the one-time password sent to your email.
</FormDescription>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Verify Email
</SubmitButton>
</form>
</Form>
<div className='text-center mt-4'>
<button
onClick={() => setFlow('signUp')}
className='text-sm text-muted-foreground hover:underline'
>
Back to Sign Up
</button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className='flex flex-col items-center'>
<Card className='p-4 bg-card/25 min-h-[720px] w-md'>
<Tabs
defaultValue={flow}
onValueChange={(value) => setFlow(value as 'signIn' | 'signUp')}
className='items-center'
>
<TabsList className='py-6'>
<TabsTrigger
value='signIn'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign In
</TabsTrigger>
<TabsTrigger
value='signUp'
className='p-6 text-2xl font-bold cursor-pointer'
>
Sign Up
</TabsTrigger>
</TabsList>
<TabsContent value='signIn'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signInForm}>
<form
onSubmit={signInForm.handleSubmit(handleSignIn)}
className='flex flex-col space-y-8'
>
<FormField
control={signInForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signInForm.control}
name='password'
render={({ field }) => (
<FormItem>
<div className='flex justify-between'>
<FormLabel className='text-xl'>Password</FormLabel>
<Link href='/forgot-password'>
Forgot Password?
</Link>
</div>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing in...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Sign In
</SubmitButton>
</form>
</Form>
<div className='flex justify-center'>
<div
className='flex flex-row items-center
my-2.5 mx-auto justify-center w-1/4'
>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton />
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value='signUp'>
<Card className='min-w-xs sm:min-w-sm bg-card/50'>
<CardContent>
<Form {...signUpForm}>
<form
onSubmit={signUpForm.handleSubmit(handleSignUp)}
className='flex flex-col space-y-8'
>
<FormField
control={signUpForm.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Name</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Full Name'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='you@example.com'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<FormField
control={signUpForm.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel className='text-xl'>
Confirm Passsword
</FormLabel>
<FormControl>
<Input
type='password'
placeholder='Confirm your password'
{...field}
/>
</FormControl>
<div className='flex flex-col w-full items-center'>
<FormMessage className='w-5/6 text-center' />
</div>
</FormItem>
)}
/>
<SubmitButton
disabled={loading}
pendingText='Signing Up...'
className='text-xl font-semibold w-2/3 mx-auto'
>
Sign Up
</SubmitButton>
</form>
</Form>
<div className='flex my-auto justify-center w-2/3'>
<div className='flex flex-row w-1/3 items-center my-2.5'>
<Separator className='py-0.5 mr-3' />
<span className='font-semibold text-lg'>or</span>
<Separator className='py-0.5 ml-3' />
</div>
</div>
<div className='flex justify-center mt-3'>
<GibsAuthSignInButton type='signUp' />
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</Card>
</div>
);
};
export default SignIn;

View File

@@ -1,34 +1,16 @@
import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { cn } from '@acme/ui';
import { ThemeProvider, ThemeToggle } from '@acme/ui/theme';
import { Toaster } from '@acme/ui/toast';
import { env } from '~/env';
import { TRPCReactProvider } from '~/trpc/react';
import '@/app/styles.css';
import '~/app/styles.css';
import { ConvexClientProvider } from '@/components/providers';
import { generateMetadata } from '@/lib/metadata';
import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server';
import PlausibleProvider from 'next-plausible';
export const metadata: Metadata = {
metadataBase: new URL(
env.VERCEL_ENV === 'production'
? 'https://turbo.t3.gg'
: 'http://localhost:3000',
),
title: 'Create T3 Turbo',
description: 'Simple monorepo with shared backend for web & mobile apps',
openGraph: {
title: 'Create T3 Turbo',
description: 'Simple monorepo with shared backend for web & mobile apps',
url: 'https://create-t3-turbo.vercel.app',
siteName: 'Create T3 Turbo',
},
twitter: {
card: 'summary_large_image',
site: '@jullerino',
creator: '@jullerino',
},
};
import { ThemeProvider, Toaster } from '@gib/ui';
export const metadata: Metadata = generateMetadata();
export const viewport: Viewport = {
themeColor: [
@@ -46,24 +28,36 @@ const geistMono = Geist_Mono({
variable: '--font-geist-mono',
});
export default function RootLayout(props: { children: React.ReactNode }) {
const RootLayout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return (
<html lang="en" suppressHydrationWarning>
<body
className={cn(
'bg-background text-foreground min-h-screen font-sans antialiased',
geistSans.variable,
geistMono.variable,
)}
<ConvexAuthNextjsServerProvider>
<PlausibleProvider
domain="convexmonorepo.gbrown.org"
customDomain="https://plausible.gbrown.org"
>
<ThemeProvider>
<TRPCReactProvider>{props.children}</TRPCReactProvider>
<div className="absolute right-4 bottom-4">
<ThemeToggle />
</div>
<Toaster />
</ThemeProvider>
</body>
</html>
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ConvexClientProvider>
{children}
<Toaster />
</ConvexClientProvider>
</ThemeProvider>
</body>
</html>
</PlausibleProvider>
</ConvexAuthNextjsServerProvider>
);
}
};
export default RootLayout;

View File

@@ -1,41 +1,11 @@
import { Suspense } from 'react';
import { HydrateClient, prefetch, trpc } from '~/trpc/server';
import { AuthShowcase } from './_components/auth-showcase';
import {
CreatePostForm,
PostCardSkeleton,
PostList,
} from './_components/posts';
export default function HomePage() {
prefetch(trpc.post.all.queryOptions());
'use client';
const Home = () => {
return (
<HydrateClient>
<main className="container h-screen py-16">
<div className="flex flex-col items-center justify-center gap-4">
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
Create <span className="text-primary">T3</span> Turbo
</h1>
<AuthShowcase />
<CreatePostForm />
<div className="w-full max-w-2xl overflow-y-scroll">
<Suspense
fallback={
<div className="flex w-full flex-col gap-4">
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</div>
}
>
<PostList />
</Suspense>
</div>
</div>
</main>
</HydrateClient>
<div className="flex min-h-screen items-center justify-center">
Hello!
</div>
);
}
};
export default Home;

View File

@@ -1,6 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@acme/tailwind-config/theme';
@import '@gib/tailwind-config/theme';
@source '../../../../packages/ui/src/*.{ts,tsx}';

View File

@@ -0,0 +1,46 @@
import Image from 'next/image';
import { useAuthActions } from '@convex-dev/auth/react';
import type { buttonVariants } from '@gib/ui';
import { Button } from '@gib/ui';
import type { ComponentProps } from 'react';
import type { VariantProps } from 'class-variance-authority';
interface Props {
buttonProps?: Omit<ComponentProps<'button'>, 'onClick'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
type?: 'signIn' | 'signUp';
};
export const GibsAuthSignInButton = ({
buttonProps,
type = 'signIn',
}: Props) => {
const { signIn } = useAuthActions();
return (
<Button
size='lg'
onClick={() => signIn('authentik')}
className='text-lg font-semibold'
{...buttonProps}
>
<div className='flex flex-col my-auto space-x-1'>
<p>
{
type === 'signIn'
? 'Sign In'
: 'Sign Up'
} with
</p>
<Image
src={'/misc/auth/gibs_auth_wide_header.png'}
className=''
alt="Gib's Auth"
width={100}
height={100}
/>
</div>
</Button>
);
};

View File

@@ -0,0 +1 @@
export { GibsAuthSignInButton } from './gibs-auth';

View File

@@ -0,0 +1,16 @@
"use client";
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { env } from '@/env';
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
}

View File

@@ -0,0 +1 @@
export { ConvexClientProvider } from './ConvexClientProvider';

View File

@@ -0,0 +1,129 @@
import type { Metadata } from 'next';
import * as Sentry from '@sentry/nextjs';
export const generateMetadata = (): Metadata => {
return {
title: {
template: '%s | Convex Monorepo',
default: 'Convex Monorepo',
},
description: 'A Convex Monorepo with Next.js & Expo',
applicationName: 'Convex Monorepo',
keywords:
'Convex Monorepo,T3 Template, Nextjs, ' +
'Tailwind, TypeScript, React, T3, Gib',
authors: [{ name: 'Gib', url: 'https://gbrown.org' }],
creator: 'Gib Brown',
publisher: 'Gib Brown',
formatDetection: {
email: false,
address: false,
telephone: false,
},
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
icons: {
icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
{
url: '/favicon.ico',
type: 'image/x-icon',
sizes: 'any',
media: '(prefers-color-scheme: dark)',
},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
],
//shortcut: [
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: '/appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//apple: [
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//},
//{
//url: 'appicon/icon.png',
//type: 'image/png',
//sizes: '192x192',
//media: '(prefers-color-scheme: dark)',
//},
//],
//other: [
//{
//rel: 'apple-touch-icon-precomposed',
//url: '/appicon/icon-precomposed.png',
//type: 'image/png',
//sizes: '180x180',
//},
//],
},
other: {
...Sentry.getTraceData(),
},
//appleWebApp: {
//title: 'Convex Monorepo',
//statusBarStyle: 'black-translucent',
//startupImage: [
//'/icons/apple/splash-768x1004.png',
//{
//url: '/icons/apple/splash-1536x2008.png',
//media: '(device-width: 768px) and (device-height: 1024px)',
//},
//],
//},
verification: {
google: 'google',
yandex: 'yandex',
yahoo: 'yahoo',
},
category: 'technology',
/*
appLinks: {
ios: {
url: 'https://techtracker.gbrown.org/ios',
app_store_id: 'com.gbrown.techtracker',
},
android: {
package: 'https://techtracker.gbrown.org/android',
app_name: 'app_t3_template',
},
web: {
url: 'https://techtracker.gbrown.org',
should_fallback: true,
},
},
*/
};
};

View File

@@ -0,0 +1,199 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// In-memory stores for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const ip404Attempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
// Your existing patterns
/web-inf/i,
/\.jsp/i,
/\.php/i,
/puttest/i,
/WEB-INF/i,
/\.xml$/i,
/perl/i,
/xampp/i,
/phpwebgallery/i,
/FileManager/i,
/standalonemanager/i,
/h2console/i,
/WebAdmin/i,
/login_form\.php/i,
/%2e/i,
/%u002e/i,
/\.%00/i,
/\.\./,
/lcgi/i,
// New patterns from your logs
/\/appliance\//i,
/bomgar/i,
/netburner-logo/i,
/\/ui\/images\//i,
/logon_merge/i,
/logon_t\.gif/i,
/login_top\.gif/i,
/theme1\/images/i,
/\.well-known\/acme-challenge\/.*\.jpg$/i,
/\.well-known\/pki-validation\/.*\.jpg$/i,
// Path traversal and system file access patterns
/\/etc\/passwd/i,
/\/etc%2fpasswd/i,
/\/etc%5cpasswd/i,
/\/\/+etc/i,
/\\\\+.*etc/i,
/%2f%2f/i,
/%5c%5c/i,
/\/\/+/,
/\\\\+/,
/%00/i,
/%23/i,
// Encoded path traversal attempts
/%2e%2e/i,
/%252e/i,
/%c0%ae/i,
/%c1%9c/i,
];
// Suspicious HTTP methods
const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH'];
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_ATTEMPTS = 10; // Max suspicious requests per window
const BAN_DURATION = 30 * 60 * 1000; // 30 minutes
// 404 rate limiting settings
const RATE_404_WINDOW = 2 * 60 * 1000; // 2 minutes
const MAX_404_ATTEMPTS = 10; // Max 404s before ban
const getClientIP = (request: NextRequest): string => {
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
const cfConnectingIP = request.headers.get('cf-connecting-ip');
if (forwarded) return (forwarded.split(',')[0] ?? '').trim();
if (realIP) return realIP;
if (cfConnectingIP) return cfConnectingIP;
return request.headers.get('host') ?? 'unknown';
};
const isPathSuspicious = (pathname: string): boolean => {
return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname));
};
const isMethodSuspicious = (method: string): boolean => {
return SUSPICIOUS_METHODS.includes(method);
};
const updateIPAttempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ipAttempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) {
ipAttempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_ATTEMPTS) {
bannedIPs.add(ip);
ipAttempts.delete(ip);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
const update404Attempts = (ip: string): boolean => {
const now = Date.now();
const attempts = ip404Attempts.get(ip);
if (!attempts || now - attempts.lastAttempt > RATE_404_WINDOW) {
ip404Attempts.set(ip, { count: 1, lastAttempt: now });
return false;
}
attempts.count++;
attempts.lastAttempt = now;
if (attempts.count > MAX_404_ATTEMPTS) {
bannedIPs.add(ip);
ip404Attempts.delete(ip);
console.log(
`🔨 IP ${ip} banned for excessive 404 requests (${attempts.count} in ${RATE_404_WINDOW / 1000}s)`,
);
setTimeout(() => {
bannedIPs.delete(ip);
}, BAN_DURATION);
return true;
}
return false;
};
export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => {
const { pathname } = request.nextUrl;
const method = request.method;
const ip = getClientIP(request);
// Check if IP is already banned
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const isSuspiciousPath = isPathSuspicious(pathname);
const isSuspiciousMethod = isMethodSuspicious(method);
// Handle suspicious activity
if (isSuspiciousPath || isSuspiciousMethod) {
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned. Please fuck off.', {
status: 403,
});
}
return new NextResponse('Not Found', { status: 404 });
}
return null;
};
// Call this function when you detect a 404 response
export const handle404Response = (
request: NextRequest,
): NextResponse | null => {
const ip = getClientIP(request);
if (bannedIPs.has(ip)) {
return new NextResponse('Access denied.', { status: 403 });
}
const shouldBan = update404Attempts(ip);
if (shouldBan) {
return new NextResponse('Access denied - IP banned for excessive 404s.', {
status: 403,
});
}
return null;
};

View File

@@ -0,0 +1,34 @@
import {
convexAuthNextjsMiddleware,
createRouteMatcher,
nextjsMiddlewareRedirect,
} from '@convex-dev/auth/nextjs/server';
import { banSuspiciousIPs } from '@/lib/middleware/ban-sus-ips';
const isSignInPage = createRouteMatcher(['/sign-in']);
const isProtectedRoute = createRouteMatcher(['/', '/profile']);
export default convexAuthNextjsMiddleware(
async (request, { convexAuth }) => {
const banResponse = banSuspiciousIPs(request);
if (banResponse) return banResponse;
if (isSignInPage(request) && (await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/');
}
if (isProtectedRoute(request) && !(await convexAuth.isAuthenticated())) {
return nextjsMiddlewareRedirect(request, '/sign-in');
}
},
{ cookieConfig: { maxAge: 60 * 60 * 24 * 30 } },
);
export const config = {
// The following matcher runs middleware on all routes
// except static assets.
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/((?!.*\\..*|_next).*)',
'/',
'/(api|trpc)(.*)',
],
};