diff --git a/.changeset/less-aggressive-password-rate-limits.md b/.changeset/less-aggressive-password-rate-limits.md new file mode 100644 index 000000000..35b750d5e --- /dev/null +++ b/.changeset/less-aggressive-password-rate-limits.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/plugin-auth': patch +--- + +Reduce password rate limit aggressiveness (15 attempts/hour for IP, 10 consecutive fails/hour), reset login rate limits after successful password reset, and improve error messages to suggest password reset when rate limited diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx index bdc6bd812..485998f7f 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx @@ -1,10 +1,11 @@ +import type React from 'react'; import type { ReactElement } from 'react'; import { Outlet } from '@tanstack/react-router'; import { AsyncBoundary } from '../ui/async-boundary'; import { Separator } from '../ui/separator'; -import { SidebarProvider, SidebarTrigger } from '../ui/sidebar'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '../ui/sidebar'; import { AppBreadcrumbs } from './app-breadcrumbs'; import { AppSidebar } from './app-sidebar'; @@ -14,23 +15,37 @@ interface Props { export function AdminLayout({ className }: Props): ReactElement { return ( - + -
-
- - - + +
+
+ + + +
-
- - - -
-
+
+
+
+ + + +
+
+
+
); } diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx index 29daa8a2c..f2659069d 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx @@ -29,7 +29,7 @@ function HomePage(): ReactElement { } return ( -
+

Welcome {data.viewer?.email ?? 'an anonymous user'}!

); diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/forgot-password.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/forgot-password.tsx index dbc0ea960..edecb48e8 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/forgot-password.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/forgot-password.tsx @@ -31,7 +31,7 @@ export const Route = createFileRoute('/auth_/forgot-password')({ const formSchema = z.object({ email: z - .email() + .email('Please enter a valid email address') .max(PASSWORD_MAX_LENGTH) .transform((value) => value.toLowerCase()), }); diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/login.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/login.tsx index e976ab749..f8388a592 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/login.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/login.tsx @@ -43,8 +43,16 @@ export const Route = createFileRoute('/auth_/login')({ }); const formSchema = z.object({ - email: z.email().transform((value) => value.toLowerCase()), - password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + email: z + .email('Please enter a valid email address') + .transform((value) => value.toLowerCase()), + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), }); type FormData = z.infer; @@ -96,6 +104,8 @@ function LoginPage(): React.JSX.Element { .catch((err: unknown) => { const errorCode = getApolloErrorCode(err, [ 'invalid-credentials', + 'login-ip-rate-limited', + 'login-consecutive-fails-blocked', ] as const); switch (errorCode) { case 'invalid-credentials': { @@ -107,6 +117,15 @@ function LoginPage(): React.JSX.Element { ); break; } + case 'login-ip-rate-limited': + case 'login-consecutive-fails-blocked': { + resetField('password'); + setFormError('password', { + message: + 'Too many failed login attempts. Please reset your password or try again later.', + }); + break; + } default: { toast.error( logAndFormatError(err, 'Sorry, we could not log you in.'), diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/register.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/register.tsx index 33e9d81a9..78b879d77 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/register.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/register.tsx @@ -43,9 +43,17 @@ export const Route = createFileRoute('/auth_/register')({ }); const formSchema = /* TPL_REGISTER_SCHEMA:START */ z.object({ - email: z.email().transform((value) => value.toLowerCase()), - name: z.string().min(1).max(100), - password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + email: z + .email('Please enter a valid email address') + .transform((value) => value.toLowerCase()), + name: z.string().min(1, 'Please enter your name').max(100), + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), }); /* TPL_REGISTER_SCHEMA:END */ type FormData = z.infer; diff --git a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/reset-password.tsx b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/reset-password.tsx index b76a6b0fd..549ad0e68 100644 --- a/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/reset-password.tsx +++ b/examples/blog-with-auth/apps/admin/baseplate/generated/src/routes/auth_/reset-password.tsx @@ -44,10 +44,19 @@ export const Route = createFileRoute('/auth_/reset-password')({ const formSchema = z .object({ - newPassword: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + newPassword: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), confirmPassword: z .string() - .min(PASSWORD_MIN_LENGTH) + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) .max(PASSWORD_MAX_LENGTH), }) .refine((data) => data.newPassword === data.confirmPassword, { diff --git a/examples/blog-with-auth/apps/admin/src/components/layouts/admin-layout.tsx b/examples/blog-with-auth/apps/admin/src/components/layouts/admin-layout.tsx index bdc6bd812..485998f7f 100644 --- a/examples/blog-with-auth/apps/admin/src/components/layouts/admin-layout.tsx +++ b/examples/blog-with-auth/apps/admin/src/components/layouts/admin-layout.tsx @@ -1,10 +1,11 @@ +import type React from 'react'; import type { ReactElement } from 'react'; import { Outlet } from '@tanstack/react-router'; import { AsyncBoundary } from '../ui/async-boundary'; import { Separator } from '../ui/separator'; -import { SidebarProvider, SidebarTrigger } from '../ui/sidebar'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '../ui/sidebar'; import { AppBreadcrumbs } from './app-breadcrumbs'; import { AppSidebar } from './app-sidebar'; @@ -14,23 +15,37 @@ interface Props { export function AdminLayout({ className }: Props): ReactElement { return ( - + -
-
- - - + +
+
+ + + +
-
- - - -
-
+
+
+
+ + + +
+
+
+
); } diff --git a/examples/blog-with-auth/apps/admin/src/routes/admin/index.tsx b/examples/blog-with-auth/apps/admin/src/routes/admin/index.tsx index 29daa8a2c..f2659069d 100644 --- a/examples/blog-with-auth/apps/admin/src/routes/admin/index.tsx +++ b/examples/blog-with-auth/apps/admin/src/routes/admin/index.tsx @@ -29,7 +29,7 @@ function HomePage(): ReactElement { } return ( -
+

Welcome {data.viewer?.email ?? 'an anonymous user'}!

); diff --git a/examples/blog-with-auth/apps/admin/src/routes/auth_/forgot-password.tsx b/examples/blog-with-auth/apps/admin/src/routes/auth_/forgot-password.tsx index dbc0ea960..edecb48e8 100644 --- a/examples/blog-with-auth/apps/admin/src/routes/auth_/forgot-password.tsx +++ b/examples/blog-with-auth/apps/admin/src/routes/auth_/forgot-password.tsx @@ -31,7 +31,7 @@ export const Route = createFileRoute('/auth_/forgot-password')({ const formSchema = z.object({ email: z - .email() + .email('Please enter a valid email address') .max(PASSWORD_MAX_LENGTH) .transform((value) => value.toLowerCase()), }); diff --git a/examples/blog-with-auth/apps/admin/src/routes/auth_/login.tsx b/examples/blog-with-auth/apps/admin/src/routes/auth_/login.tsx index e976ab749..f8388a592 100644 --- a/examples/blog-with-auth/apps/admin/src/routes/auth_/login.tsx +++ b/examples/blog-with-auth/apps/admin/src/routes/auth_/login.tsx @@ -43,8 +43,16 @@ export const Route = createFileRoute('/auth_/login')({ }); const formSchema = z.object({ - email: z.email().transform((value) => value.toLowerCase()), - password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + email: z + .email('Please enter a valid email address') + .transform((value) => value.toLowerCase()), + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), }); type FormData = z.infer; @@ -96,6 +104,8 @@ function LoginPage(): React.JSX.Element { .catch((err: unknown) => { const errorCode = getApolloErrorCode(err, [ 'invalid-credentials', + 'login-ip-rate-limited', + 'login-consecutive-fails-blocked', ] as const); switch (errorCode) { case 'invalid-credentials': { @@ -107,6 +117,15 @@ function LoginPage(): React.JSX.Element { ); break; } + case 'login-ip-rate-limited': + case 'login-consecutive-fails-blocked': { + resetField('password'); + setFormError('password', { + message: + 'Too many failed login attempts. Please reset your password or try again later.', + }); + break; + } default: { toast.error( logAndFormatError(err, 'Sorry, we could not log you in.'), diff --git a/examples/blog-with-auth/apps/admin/src/routes/auth_/register.tsx b/examples/blog-with-auth/apps/admin/src/routes/auth_/register.tsx index 33e9d81a9..78b879d77 100644 --- a/examples/blog-with-auth/apps/admin/src/routes/auth_/register.tsx +++ b/examples/blog-with-auth/apps/admin/src/routes/auth_/register.tsx @@ -43,9 +43,17 @@ export const Route = createFileRoute('/auth_/register')({ }); const formSchema = /* TPL_REGISTER_SCHEMA:START */ z.object({ - email: z.email().transform((value) => value.toLowerCase()), - name: z.string().min(1).max(100), - password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + email: z + .email('Please enter a valid email address') + .transform((value) => value.toLowerCase()), + name: z.string().min(1, 'Please enter your name').max(100), + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), }); /* TPL_REGISTER_SCHEMA:END */ type FormData = z.infer; diff --git a/examples/blog-with-auth/apps/admin/src/routes/auth_/reset-password.tsx b/examples/blog-with-auth/apps/admin/src/routes/auth_/reset-password.tsx index b76a6b0fd..549ad0e68 100644 --- a/examples/blog-with-auth/apps/admin/src/routes/auth_/reset-password.tsx +++ b/examples/blog-with-auth/apps/admin/src/routes/auth_/reset-password.tsx @@ -44,10 +44,19 @@ export const Route = createFileRoute('/auth_/reset-password')({ const formSchema = z .object({ - newPassword: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + newPassword: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), confirmPassword: z .string() - .min(PASSWORD_MIN_LENGTH) + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) .max(PASSWORD_MAX_LENGTH), }) .refine((data) => data.newPassword === data.confirmPassword, { diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/schema/password-reset.mutations.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/schema/password-reset.mutations.ts index 4cd343ce4..63e040c92 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/schema/password-reset.mutations.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/schema/password-reset.mutations.ts @@ -44,10 +44,11 @@ builder.mutationField('resetPasswordWithToken', (t) => token: t.input.field({ required: true, type: 'String' }), newPassword: t.input.field({ required: true, type: 'String' }), }, - resolve: async (_root, { input }) => + resolve: async (_root, { input }, context) => completePasswordReset({ token: input.token, newPassword: input.newPassword, + context, }), }), ); diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/password-reset.service.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/password-reset.service.ts index 32a2930ef..6adac8114 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/password-reset.service.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/password-reset.service.ts @@ -23,6 +23,7 @@ import { PASSWORD_RESET_TOKEN_EXPIRY_SEC, } from '../constants/password.constants.js'; import { createPasswordHash } from './password-hasher.service.js'; +import { resetLoginRateLimits } from './user-password.service.js'; const PROVIDER_ID = 'email-password'; const PASSWORD_RESET_TYPE = 'password-reset'; @@ -172,9 +173,11 @@ const completePasswordResetSchema = z.object({ export async function completePasswordReset({ token: rawToken, newPassword: rawNewPassword, + context, }: { token: string; newPassword: string; + context: RequestServiceContext; }): Promise<{ success: true }> { const { token, newPassword } = await completePasswordResetSchema .parseAsync({ @@ -230,6 +233,9 @@ export async function completePasswordReset({ }), ]); + // Reset login rate limits so the user can log in with their new password + await resetLoginRateLimits({ email: user.email, ip: context.reqInfo.ip }); + // Send password changed confirmation email await sendEmail( /* TPL_PASSWORD_CHANGED_EMAIL:START */ PasswordChangedEmail /* TPL_PASSWORD_CHANGED_EMAIL:END */, diff --git a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/user-password.service.ts b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/user-password.service.ts index 59582fa45..18ede1f36 100644 --- a/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/user-password.service.ts +++ b/examples/blog-with-auth/apps/backend/baseplate/generated/src/modules/accounts/auth/password/services/user-password.service.ts @@ -48,20 +48,38 @@ const getRegistrationLimiter = memoizeRateLimiter('registration', { }); const getLoginIpLimiter = memoizeRateLimiter('login-ip', { - points: 10, - duration: 60 * 60 * 24, // 24 hours + points: 15, + duration: 60 * 60, // 1 hour blockDuration: 60 * 60, // Block for 1 hour if exceeded }); const getLoginConsecutiveFailsLimiter = memoizeRateLimiter( 'login-consecutive-fails', { - points: 5, - duration: 60 * 60 * 24, // 24 hours (duration for tracking) - blockDuration: 60 * 15, // Block for 15 minutes after 5 consecutive fails + points: 10, + duration: 60 * 60, // 1 hour (duration for tracking) + blockDuration: 60 * 15, // Block for 15 minutes after consecutive fails }, ); +/** + * Resets login rate limits for the given email+IP combination. + * Intentionally scoped to the requesting IP only — a password reset should not + * clear rate limits accumulated from other IPs (which may be attacker traffic). + */ +export async function resetLoginRateLimits({ + email, + ip, +}: { + email: string; + ip: string; +}): Promise { + await Promise.all([ + getLoginConsecutiveFailsLimiter().delete(`${email}_${ip}`), + getLoginIpLimiter().delete(ip), + ]); +} + export async function createUserWithEmailAndPassword({ input, }: { @@ -157,11 +175,14 @@ export async function authenticateUserWithEmailAndPassword({ const emailIpKey = `${email}_${clientIp}`; // Check IP-based rate limit (slow brute force protection) - await getLoginIpLimiter().consumeOrThrow( - clientIp, - 'Too many login attempts. Please try again later.', - 'login-ip-rate-limited', - ); + const ipResult = await getLoginIpLimiter().consume(clientIp); + if (!ipResult.allowed) { + throw new TooManyRequestsError( + 'Too many login attempts. Please try again later or reset your password.', + 'login-ip-rate-limited', + { retryAfterMs: ipResult.msBeforeNext }, + ); + } // Check consecutive failures rate limit (fast brute force protection) const consecutiveFailsResult = @@ -169,7 +190,7 @@ export async function authenticateUserWithEmailAndPassword({ if (consecutiveFailsResult && !consecutiveFailsResult.allowed) { throw new TooManyRequestsError( - 'Account temporarily locked due to too many failed attempts. Please try again later.', + 'Account temporarily locked due to too many failed attempts. Please reset your password or try again later.', 'login-consecutive-fails-blocked', { retryAfterMs: consecutiveFailsResult.msBeforeNext }, ); diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/schema/password-reset.mutations.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/schema/password-reset.mutations.ts index 4cd343ce4..63e040c92 100644 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/schema/password-reset.mutations.ts +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/schema/password-reset.mutations.ts @@ -44,10 +44,11 @@ builder.mutationField('resetPasswordWithToken', (t) => token: t.input.field({ required: true, type: 'String' }), newPassword: t.input.field({ required: true, type: 'String' }), }, - resolve: async (_root, { input }) => + resolve: async (_root, { input }, context) => completePasswordReset({ token: input.token, newPassword: input.newPassword, + context, }), }), ); diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/password-reset.service.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/password-reset.service.ts index 32a2930ef..6adac8114 100644 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/password-reset.service.ts +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/password-reset.service.ts @@ -23,6 +23,7 @@ import { PASSWORD_RESET_TOKEN_EXPIRY_SEC, } from '../constants/password.constants.js'; import { createPasswordHash } from './password-hasher.service.js'; +import { resetLoginRateLimits } from './user-password.service.js'; const PROVIDER_ID = 'email-password'; const PASSWORD_RESET_TYPE = 'password-reset'; @@ -172,9 +173,11 @@ const completePasswordResetSchema = z.object({ export async function completePasswordReset({ token: rawToken, newPassword: rawNewPassword, + context, }: { token: string; newPassword: string; + context: RequestServiceContext; }): Promise<{ success: true }> { const { token, newPassword } = await completePasswordResetSchema .parseAsync({ @@ -230,6 +233,9 @@ export async function completePasswordReset({ }), ]); + // Reset login rate limits so the user can log in with their new password + await resetLoginRateLimits({ email: user.email, ip: context.reqInfo.ip }); + // Send password changed confirmation email await sendEmail( /* TPL_PASSWORD_CHANGED_EMAIL:START */ PasswordChangedEmail /* TPL_PASSWORD_CHANGED_EMAIL:END */, diff --git a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/user-password.service.ts b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/user-password.service.ts index 59582fa45..18ede1f36 100644 --- a/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/user-password.service.ts +++ b/examples/blog-with-auth/apps/backend/src/modules/accounts/auth/password/services/user-password.service.ts @@ -48,20 +48,38 @@ const getRegistrationLimiter = memoizeRateLimiter('registration', { }); const getLoginIpLimiter = memoizeRateLimiter('login-ip', { - points: 10, - duration: 60 * 60 * 24, // 24 hours + points: 15, + duration: 60 * 60, // 1 hour blockDuration: 60 * 60, // Block for 1 hour if exceeded }); const getLoginConsecutiveFailsLimiter = memoizeRateLimiter( 'login-consecutive-fails', { - points: 5, - duration: 60 * 60 * 24, // 24 hours (duration for tracking) - blockDuration: 60 * 15, // Block for 15 minutes after 5 consecutive fails + points: 10, + duration: 60 * 60, // 1 hour (duration for tracking) + blockDuration: 60 * 15, // Block for 15 minutes after consecutive fails }, ); +/** + * Resets login rate limits for the given email+IP combination. + * Intentionally scoped to the requesting IP only — a password reset should not + * clear rate limits accumulated from other IPs (which may be attacker traffic). + */ +export async function resetLoginRateLimits({ + email, + ip, +}: { + email: string; + ip: string; +}): Promise { + await Promise.all([ + getLoginConsecutiveFailsLimiter().delete(`${email}_${ip}`), + getLoginIpLimiter().delete(ip), + ]); +} + export async function createUserWithEmailAndPassword({ input, }: { @@ -157,11 +175,14 @@ export async function authenticateUserWithEmailAndPassword({ const emailIpKey = `${email}_${clientIp}`; // Check IP-based rate limit (slow brute force protection) - await getLoginIpLimiter().consumeOrThrow( - clientIp, - 'Too many login attempts. Please try again later.', - 'login-ip-rate-limited', - ); + const ipResult = await getLoginIpLimiter().consume(clientIp); + if (!ipResult.allowed) { + throw new TooManyRequestsError( + 'Too many login attempts. Please try again later or reset your password.', + 'login-ip-rate-limited', + { retryAfterMs: ipResult.msBeforeNext }, + ); + } // Check consecutive failures rate limit (fast brute force protection) const consecutiveFailsResult = @@ -169,7 +190,7 @@ export async function authenticateUserWithEmailAndPassword({ if (consecutiveFailsResult && !consecutiveFailsResult.allowed) { throw new TooManyRequestsError( - 'Account temporarily locked due to too many failed attempts. Please try again later.', + 'Account temporarily locked due to too many failed attempts. Please reset your password or try again later.', 'login-consecutive-fails-blocked', { retryAfterMs: consecutiveFailsResult.msBeforeNext }, ); diff --git a/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx b/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx index bdc6bd812..485998f7f 100644 --- a/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx +++ b/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/components/layouts/admin-layout.tsx @@ -1,10 +1,11 @@ +import type React from 'react'; import type { ReactElement } from 'react'; import { Outlet } from '@tanstack/react-router'; import { AsyncBoundary } from '../ui/async-boundary'; import { Separator } from '../ui/separator'; -import { SidebarProvider, SidebarTrigger } from '../ui/sidebar'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '../ui/sidebar'; import { AppBreadcrumbs } from './app-breadcrumbs'; import { AppSidebar } from './app-sidebar'; @@ -14,23 +15,37 @@ interface Props { export function AdminLayout({ className }: Props): ReactElement { return ( - + -
-
- - - + +
+
+ + + +
-
- - - -
-
+
+
+
+ + + +
+
+
+
); } diff --git a/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx b/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx index 29daa8a2c..f2659069d 100644 --- a/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx +++ b/examples/todo-with-better-auth/apps/admin/baseplate/generated/src/routes/admin/index.tsx @@ -29,7 +29,7 @@ function HomePage(): ReactElement { } return ( -
+

Welcome {data.viewer?.email ?? 'an anonymous user'}!

); diff --git a/examples/todo-with-better-auth/apps/admin/src/components/layouts/admin-layout.tsx b/examples/todo-with-better-auth/apps/admin/src/components/layouts/admin-layout.tsx index bdc6bd812..485998f7f 100644 --- a/examples/todo-with-better-auth/apps/admin/src/components/layouts/admin-layout.tsx +++ b/examples/todo-with-better-auth/apps/admin/src/components/layouts/admin-layout.tsx @@ -1,10 +1,11 @@ +import type React from 'react'; import type { ReactElement } from 'react'; import { Outlet } from '@tanstack/react-router'; import { AsyncBoundary } from '../ui/async-boundary'; import { Separator } from '../ui/separator'; -import { SidebarProvider, SidebarTrigger } from '../ui/sidebar'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '../ui/sidebar'; import { AppBreadcrumbs } from './app-breadcrumbs'; import { AppSidebar } from './app-sidebar'; @@ -14,23 +15,37 @@ interface Props { export function AdminLayout({ className }: Props): ReactElement { return ( - + -
-
- - - + +
+
+ + + +
-
- - - -
-
+
+
+
+ + + +
+
+
+
); } diff --git a/examples/todo-with-better-auth/apps/admin/src/routes/admin/index.tsx b/examples/todo-with-better-auth/apps/admin/src/routes/admin/index.tsx index 29daa8a2c..f2659069d 100644 --- a/examples/todo-with-better-auth/apps/admin/src/routes/admin/index.tsx +++ b/examples/todo-with-better-auth/apps/admin/src/routes/admin/index.tsx @@ -29,7 +29,7 @@ function HomePage(): ReactElement { } return ( -
+

Welcome {data.viewer?.email ?? 'an anonymous user'}!

); diff --git a/packages/react-generators/src/generators/admin/admin-home/templates/routes/index.tsx b/packages/react-generators/src/generators/admin/admin-home/templates/routes/index.tsx index 6134c8542..6bd69dbd7 100644 --- a/packages/react-generators/src/generators/admin/admin-home/templates/routes/index.tsx +++ b/packages/react-generators/src/generators/admin/admin-home/templates/routes/index.tsx @@ -28,7 +28,7 @@ function HomePage(): ReactElement { } return ( -
+

Welcome {data.viewer?.email ?? 'an anonymous user'}!

); diff --git a/packages/react-generators/src/generators/admin/admin-layout/templates/components/layouts/admin-layout.tsx b/packages/react-generators/src/generators/admin/admin-layout/templates/components/layouts/admin-layout.tsx index 31b71e653..e33aa76fb 100644 --- a/packages/react-generators/src/generators/admin/admin-layout/templates/components/layouts/admin-layout.tsx +++ b/packages/react-generators/src/generators/admin/admin-layout/templates/components/layouts/admin-layout.tsx @@ -1,11 +1,13 @@ // @ts-nocheck +import type React from 'react'; import type { ReactElement } from 'react'; import { AppBreadcrumbs } from '$appBreadcrumbs'; import { AppSidebar } from '$appSidebar'; import { Separator, + SidebarInset, SidebarProvider, SidebarTrigger, } from '%reactComponentsImports'; @@ -18,23 +20,37 @@ interface Props { export function AdminLayout({ className }: Props): ReactElement { return ( - + -
-
- - - + +
+
+ + + +
-
- - - -
-
+
+
+
+ + + +
+
+
+
); } diff --git a/packages/react-generators/src/generators/core/react-tailwind/extractor.json b/packages/react-generators/src/generators/core/react-tailwind/extractor.json index 5a3f3cdf0..5af13a68a 100644 --- a/packages/react-generators/src/generators/core/react-tailwind/extractor.json +++ b/packages/react-generators/src/generators/core/react-tailwind/extractor.json @@ -8,14 +8,12 @@ "pathRootRelativePath": "{src-root}/styles.css", "sourceFile": "src/styles.css", "variables": { + "TPL_DARK_COLORS": { "description": "Dark colors to apply to the app" }, "TPL_GLOBAL_STYLES": { "description": "Global styles to apply to the app" }, "TPL_LIGHT_COLORS": { "description": "Light colors to apply to the app" - }, - "TPL_DARK_COLORS": { - "description": "Dark colors to apply to the app" } } } diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/schema/password-reset.mutations.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/schema/password-reset.mutations.ts index 858d7a585..c90a4f810 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/schema/password-reset.mutations.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/schema/password-reset.mutations.ts @@ -45,10 +45,11 @@ builder.mutationField('resetPasswordWithToken', (t) => token: t.input.field({ required: true, type: 'String' }), newPassword: t.input.field({ required: true, type: 'String' }), }, - resolve: async (_root, { input }) => + resolve: async (_root, { input }, context) => completePasswordReset({ token: input.token, newPassword: input.newPassword, + context, }), }), ); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/password-reset.service.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/password-reset.service.ts index f48296e2e..a5952e7ac 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/password-reset.service.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/password-reset.service.ts @@ -7,6 +7,7 @@ import { PASSWORD_MIN_LENGTH, PASSWORD_RESET_TOKEN_EXPIRY_SEC, } from '$constantsPassword'; +import { resetLoginRateLimits } from '$servicesUserPassword'; import { createAuthVerification, validateAuthVerification, @@ -167,9 +168,11 @@ const completePasswordResetSchema = z.object({ export async function completePasswordReset({ token: rawToken, newPassword: rawNewPassword, + context, }: { token: string; newPassword: string; + context: RequestServiceContext; }): Promise<{ success: true }> { const { token, newPassword } = await completePasswordResetSchema .parseAsync({ @@ -225,6 +228,9 @@ export async function completePasswordReset({ }), ]); + // Reset login rate limits so the user can log in with their new password + await resetLoginRateLimits({ email: user.email, ip: context.reqInfo.ip }); + // Send password changed confirmation email await sendEmail(TPL_PASSWORD_CHANGED_EMAIL, { to: user.email, diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/user-password.service.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/user-password.service.ts index 9a7aca154..761df835b 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/user-password.service.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-email-password/templates/module/services/user-password.service.ts @@ -44,20 +44,38 @@ const getRegistrationLimiter = memoizeRateLimiter('registration', { }); const getLoginIpLimiter = memoizeRateLimiter('login-ip', { - points: 10, - duration: 60 * 60 * 24, // 24 hours + points: 15, + duration: 60 * 60, // 1 hour blockDuration: 60 * 60, // Block for 1 hour if exceeded }); const getLoginConsecutiveFailsLimiter = memoizeRateLimiter( 'login-consecutive-fails', { - points: 5, - duration: 60 * 60 * 24, // 24 hours (duration for tracking) - blockDuration: 60 * 15, // Block for 15 minutes after 5 consecutive fails + points: 10, + duration: 60 * 60, // 1 hour (duration for tracking) + blockDuration: 60 * 15, // Block for 15 minutes after consecutive fails }, ); +/** + * Resets login rate limits for the given email+IP combination. + * Intentionally scoped to the requesting IP only — a password reset should not + * clear rate limits accumulated from other IPs (which may be attacker traffic). + */ +export async function resetLoginRateLimits({ + email, + ip, +}: { + email: string; + ip: string; +}): Promise { + await Promise.all([ + getLoginConsecutiveFailsLimiter().delete(`${email}_${ip}`), + getLoginIpLimiter().delete(ip), + ]); +} + export async function createUserWithEmailAndPassword({ input, }: { @@ -149,11 +167,14 @@ export async function authenticateUserWithEmailAndPassword({ const emailIpKey = `${email}_${clientIp}`; // Check IP-based rate limit (slow brute force protection) - await getLoginIpLimiter().consumeOrThrow( - clientIp, - 'Too many login attempts. Please try again later.', - 'login-ip-rate-limited', - ); + const ipResult = await getLoginIpLimiter().consume(clientIp); + if (!ipResult.allowed) { + throw new TooManyRequestsError( + 'Too many login attempts. Please try again later or reset your password.', + 'login-ip-rate-limited', + { retryAfterMs: ipResult.msBeforeNext }, + ); + } // Check consecutive failures rate limit (fast brute force protection) const consecutiveFailsResult = @@ -161,7 +182,7 @@ export async function authenticateUserWithEmailAndPassword({ if (consecutiveFailsResult && !consecutiveFailsResult.allowed) { throw new TooManyRequestsError( - 'Account temporarily locked due to too many failed attempts. Please try again later.', + 'Account temporarily locked due to too many failed attempts. Please reset your password or try again later.', 'login-consecutive-fails-blocked', { retryAfterMs: consecutiveFailsResult.msBeforeNext }, ); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/auth-routes.generator.ts b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/auth-routes.generator.ts index 8475b98c1..6c301f749 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/auth-routes.generator.ts +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/auth-routes.generator.ts @@ -29,11 +29,11 @@ export const authRoutesGenerator = createGenerator({ }, run({ renderers, paths }) { const schemaFields: Record = { - email: 'z.email().transform((value) => value.toLowerCase())', + email: `z.email('Please enter a valid email address').transform((value) => value.toLowerCase())`, ...(requireNameOnRegistration - ? { name: 'z.string().min(1).max(100)' } + ? { name: `z.string().min(1, 'Please enter your name').max(100)` } : {}), - password: `z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH)`, + password: `z.string().min(PASSWORD_MIN_LENGTH, \`Password must be at least \${PASSWORD_MIN_LENGTH} characters\`).max(PASSWORD_MAX_LENGTH)`, }; const registerSchema = tsTemplateWithImports( diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/forgot-password.tsx b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/forgot-password.tsx index dbca6fe8d..83277ba16 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/forgot-password.tsx +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/forgot-password.tsx @@ -31,7 +31,7 @@ export const Route = createFileRoute('/auth_/forgot-password')({ const formSchema = z.object({ email: z - .email() + .email('Please enter a valid email address') .max(PASSWORD_MAX_LENGTH) .transform((value) => value.toLowerCase()), }); diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/login.tsx b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/login.tsx index 7afb96429..f016dbe28 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/login.tsx +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/login.tsx @@ -42,8 +42,16 @@ export const Route = createFileRoute('/auth_/login')({ }); const formSchema = z.object({ - email: z.email().transform((value) => value.toLowerCase()), - password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + email: z + .email('Please enter a valid email address') + .transform((value) => value.toLowerCase()), + password: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), }); type FormData = z.infer; @@ -95,6 +103,8 @@ function LoginPage(): React.JSX.Element { .catch((err: unknown) => { const errorCode = getApolloErrorCode(err, [ 'invalid-credentials', + 'login-ip-rate-limited', + 'login-consecutive-fails-blocked', ] as const); switch (errorCode) { case 'invalid-credentials': { @@ -106,6 +116,15 @@ function LoginPage(): React.JSX.Element { ); break; } + case 'login-ip-rate-limited': + case 'login-consecutive-fails-blocked': { + resetField('password'); + setFormError('password', { + message: + 'Too many failed login attempts. Please reset your password or try again later.', + }); + break; + } default: { toast.error( logAndFormatError(err, 'Sorry, we could not log you in.'), diff --git a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/reset-password.tsx b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/reset-password.tsx index b260bdfbc..24dc7104b 100644 --- a/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/reset-password.tsx +++ b/plugins/plugin-auth/src/local-auth/core/generators/auth-routes/templates/routes/auth_/reset-password.tsx @@ -43,10 +43,19 @@ export const Route = createFileRoute('/auth_/reset-password')({ const formSchema = z .object({ - newPassword: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH), + newPassword: z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) + .max(PASSWORD_MAX_LENGTH), confirmPassword: z .string() - .min(PASSWORD_MIN_LENGTH) + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ) .max(PASSWORD_MAX_LENGTH), }) .refine((data) => data.newPassword === data.confirmPassword, {