feat: integrate Cloudflare Turnstile for enhanced security in sign-up and email verification processes. Implement rate limiting for sign-up attempts and add Turnstile validation to prevent abuse. Update environment configuration to support Turnstile keys and enhance user experience with improved error handling and feedback.
Browse files- .gitignore +1 -0
- apps/server/.env.example +2 -0
- apps/server/.gitignore +2 -0
- apps/server/src/index.ts +16 -0
- apps/web/.env.example +1 -1
- apps/web/package.json +1 -0
- apps/web/src/components/TurnstileField.tsx +30 -0
- apps/web/src/components/sign-up-form.tsx +22 -2
- apps/web/src/routes/verify-email.tsx +46 -12
- bun.lock +3 -0
- packages/api/src/__tests__/test-db.ts +1 -1
- packages/api/src/__tests__/test-setup.ts +3 -1
- packages/api/src/__tests__/verification.integration.test.ts +277 -0
- packages/api/src/context.ts +2 -0
- packages/api/src/lib/client-ip.ts +15 -0
- packages/api/src/lib/rate-limit.ts +31 -0
- packages/api/src/lib/turnstile.ts +34 -0
- packages/api/src/routers/verification.ts +22 -21
- packages/env/src/server.ts +1 -0
- packages/env/src/web.ts +1 -0
.gitignore
CHANGED
|
@@ -9,6 +9,7 @@ references
|
|
| 9 |
# Build outputs
|
| 10 |
dist
|
| 11 |
build
|
|
|
|
| 12 |
*.tsbuildinfo
|
| 13 |
|
| 14 |
# Environment variables
|
|
|
|
| 9 |
# Build outputs
|
| 10 |
dist
|
| 11 |
build
|
| 12 |
+
blog
|
| 13 |
*.tsbuildinfo
|
| 14 |
|
| 15 |
# Environment variables
|
apps/server/.env.example
CHANGED
|
@@ -31,3 +31,5 @@ SMTP_FROM=
|
|
| 31 |
# PLATFORM_AI_API_KEY=
|
| 32 |
# PLATFORM_AI_BASE_URL=
|
| 33 |
# PLATFORM_AI_MODEL=
|
|
|
|
|
|
|
|
|
| 31 |
# PLATFORM_AI_API_KEY=
|
| 32 |
# PLATFORM_AI_BASE_URL=
|
| 33 |
# PLATFORM_AI_MODEL=
|
| 34 |
+
|
| 35 |
+
CLOUDFLARE_TURNSTILE_SECRET_KEY=
|
apps/server/.gitignore
CHANGED
|
@@ -53,3 +53,5 @@ lerna-debug.log*
|
|
| 53 |
# typescript
|
| 54 |
*.tsbuildinfo
|
| 55 |
next-env.d.ts
|
|
|
|
|
|
|
|
|
| 53 |
# typescript
|
| 54 |
*.tsbuildinfo
|
| 55 |
next-env.d.ts
|
| 56 |
+
|
| 57 |
+
audio-cache/
|
apps/server/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { trpcServer } from "@hono/trpc-server";
|
| 2 |
import { createContext } from "@labas/api/context";
|
| 3 |
import { appRouter } from "@labas/api/routers/index";
|
|
|
|
| 4 |
import { auth } from "@labas/auth";
|
| 5 |
import { env } from "@labas/env/server";
|
| 6 |
import { withRequestId } from "@labas/api/logger";
|
|
@@ -44,6 +45,21 @@ app.use(
|
|
| 44 |
}),
|
| 45 |
);
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
| 48 |
|
| 49 |
app.use(
|
|
|
|
| 1 |
import { trpcServer } from "@hono/trpc-server";
|
| 2 |
import { createContext } from "@labas/api/context";
|
| 3 |
import { appRouter } from "@labas/api/routers/index";
|
| 4 |
+
import { checkRateLimitAllowed } from "@labas/api/lib/rate-limit";
|
| 5 |
import { auth } from "@labas/auth";
|
| 6 |
import { env } from "@labas/env/server";
|
| 7 |
import { withRequestId } from "@labas/api/logger";
|
|
|
|
| 45 |
}),
|
| 46 |
);
|
| 47 |
|
| 48 |
+
// Rate limit sign-up attempts per IP to mitigate mass account creation
|
| 49 |
+
app.use("/api/auth/sign-up/email", async (c, next) => {
|
| 50 |
+
if (c.req.method !== "POST") return next();
|
| 51 |
+
const cfIp = c.req.header("cf-connecting-ip");
|
| 52 |
+
const forwarded = c.req.header("x-forwarded-for");
|
| 53 |
+
const realIp = c.req.header("x-real-ip");
|
| 54 |
+
const firstForwardedIp = forwarded ? forwarded.split(",")[0]?.trim() : undefined;
|
| 55 |
+
const ip = cfIp ?? firstForwardedIp ?? realIp ?? "unknown";
|
| 56 |
+
const allowed = await checkRateLimitAllowed({ key: `signup:ip:${ip}`, limit: 5, windowMs: 3_600_000 });
|
| 57 |
+
if (!allowed) {
|
| 58 |
+
return c.json({ error: "Too many sign-up attempts. Please try again later." }, 429);
|
| 59 |
+
}
|
| 60 |
+
return next();
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
| 64 |
|
| 65 |
app.use(
|
apps/web/.env.example
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
VITE_SERVER_URL=http://localhost:3000
|
| 2 |
VITE_SITE_URL=http://localhost:5173
|
| 3 |
-
|
|
|
|
| 1 |
VITE_SERVER_URL=http://localhost:3000
|
| 2 |
VITE_SITE_URL=http://localhost:5173
|
| 3 |
+
VITE_CLOUDFLARE_TURNSTILE_SITE_KEY=
|
apps/web/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
| 22 |
"@labas/auth": "workspace:*",
|
| 23 |
"@labas/env": "workspace:*",
|
| 24 |
"@labas/ui": "workspace:*",
|
|
|
|
| 25 |
"@tailwindcss/vite": "^4.2.2",
|
| 26 |
"@tanstack/react-form": "^1.28.0",
|
| 27 |
"@tanstack/react-query": "^5.90.12",
|
|
|
|
| 22 |
"@labas/auth": "workspace:*",
|
| 23 |
"@labas/env": "workspace:*",
|
| 24 |
"@labas/ui": "workspace:*",
|
| 25 |
+
"@marsidev/react-turnstile": "^1.5.2",
|
| 26 |
"@tailwindcss/vite": "^4.2.2",
|
| 27 |
"@tanstack/react-form": "^1.28.0",
|
| 28 |
"@tanstack/react-query": "^5.90.12",
|
apps/web/src/components/TurnstileField.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Turnstile, type TurnstileInstance, type TurnstileProps } from "@marsidev/react-turnstile";
|
| 2 |
+
import { forwardRef } from "react";
|
| 3 |
+
import { env } from "@labas/env/web";
|
| 4 |
+
|
| 5 |
+
export const TURNSTILE_SITE_KEY = env.VITE_CLOUDFLARE_TURNSTILE_SITE_KEY;
|
| 6 |
+
|
| 7 |
+
type TurnstileFieldProps = Omit<TurnstileProps, "siteKey"> & {
|
| 8 |
+
siteKey?: string;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export const TurnstileField = forwardRef<TurnstileInstance, TurnstileFieldProps>(
|
| 12 |
+
function TurnstileField({ options, className, siteKey, ...props }, ref) {
|
| 13 |
+
const resolvedSiteKey = siteKey ?? TURNSTILE_SITE_KEY;
|
| 14 |
+
if (!resolvedSiteKey) return null;
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="w-full">
|
| 18 |
+
<div className="flex w-full items-center justify-center overflow-hidden rounded-[var(--radius-lg)] border border-[var(--oat-border)] bg-muted/40 px-2 py-2.5">
|
| 19 |
+
<Turnstile
|
| 20 |
+
ref={ref}
|
| 21 |
+
siteKey={resolvedSiteKey}
|
| 22 |
+
className={className ?? "w-full min-h-[65px] [&>iframe]:mx-auto"}
|
| 23 |
+
options={{ theme: "auto", size: "flexible", ...options }}
|
| 24 |
+
{...props}
|
| 25 |
+
/>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
},
|
| 30 |
+
);
|
apps/web/src/components/sign-up-form.tsx
CHANGED
|
@@ -7,11 +7,14 @@ import { useMutation } from "@tanstack/react-query";
|
|
| 7 |
import { useNavigate } from "@tanstack/react-router";
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import z from "zod";
|
|
|
|
|
|
|
| 10 |
|
| 11 |
import { authClient } from "@/lib/auth-client";
|
| 12 |
import { trpc } from "@/utils/trpc";
|
| 13 |
import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
|
| 14 |
import { PasswordInput } from "@/components/ui/PasswordInput";
|
|
|
|
| 15 |
|
| 16 |
import Loader from "./loader";
|
| 17 |
|
|
@@ -40,6 +43,8 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 40 |
});
|
| 41 |
const { isPending } = authClient.useSession();
|
| 42 |
const sendOtpMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
|
|
|
|
|
|
| 43 |
|
| 44 |
const form = useForm({
|
| 45 |
defaultValues: {
|
|
@@ -59,8 +64,10 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 59 |
onSuccess: async () => {
|
| 60 |
trackUmamiEvent(AnalyticsEvent.SIGN_UP);
|
| 61 |
try {
|
| 62 |
-
await sendOtpMutation.mutateAsync({ email: value.email });
|
| 63 |
} catch {}
|
|
|
|
|
|
|
| 64 |
navigate({
|
| 65 |
to: "/verify-email",
|
| 66 |
search: { email: value.email },
|
|
@@ -68,6 +75,8 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 68 |
toast.success("Akun berhasil dibuat! Cek email Anda untuk kode verifikasi.");
|
| 69 |
},
|
| 70 |
onError: (error) => {
|
|
|
|
|
|
|
| 71 |
toast.error(error.error.message || error.error.statusText);
|
| 72 |
},
|
| 73 |
},
|
|
@@ -190,11 +199,22 @@ export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: ()
|
|
| 190 |
</form.Field>
|
| 191 |
</div>
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
<form.Subscribe
|
| 194 |
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
| 195 |
>
|
| 196 |
{({ canSubmit, isSubmitting }) => (
|
| 197 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
{isSubmitting ? "Submitting..." : "Sign Up"}
|
| 199 |
</Button>
|
| 200 |
)}
|
|
|
|
| 7 |
import { useNavigate } from "@tanstack/react-router";
|
| 8 |
import { toast } from "sonner";
|
| 9 |
import z from "zod";
|
| 10 |
+
import type { TurnstileInstance } from "@marsidev/react-turnstile";
|
| 11 |
+
import { useRef, useState } from "react";
|
| 12 |
|
| 13 |
import { authClient } from "@/lib/auth-client";
|
| 14 |
import { trpc } from "@/utils/trpc";
|
| 15 |
import { trackUmamiEvent, AnalyticsEvent } from "@/lib/umami";
|
| 16 |
import { PasswordInput } from "@/components/ui/PasswordInput";
|
| 17 |
+
import { TurnstileField, TURNSTILE_SITE_KEY } from "@/components/TurnstileField";
|
| 18 |
|
| 19 |
import Loader from "./loader";
|
| 20 |
|
|
|
|
| 43 |
});
|
| 44 |
const { isPending } = authClient.useSession();
|
| 45 |
const sendOtpMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
| 46 |
+
const [turnstileToken, setTurnstileToken] = useState<string | undefined>(undefined);
|
| 47 |
+
const turnstileRef = useRef<TurnstileInstance>(null);
|
| 48 |
|
| 49 |
const form = useForm({
|
| 50 |
defaultValues: {
|
|
|
|
| 64 |
onSuccess: async () => {
|
| 65 |
trackUmamiEvent(AnalyticsEvent.SIGN_UP);
|
| 66 |
try {
|
| 67 |
+
await sendOtpMutation.mutateAsync({ email: value.email, turnstileToken });
|
| 68 |
} catch {}
|
| 69 |
+
turnstileRef.current?.reset();
|
| 70 |
+
setTurnstileToken(undefined);
|
| 71 |
navigate({
|
| 72 |
to: "/verify-email",
|
| 73 |
search: { email: value.email },
|
|
|
|
| 75 |
toast.success("Akun berhasil dibuat! Cek email Anda untuk kode verifikasi.");
|
| 76 |
},
|
| 77 |
onError: (error) => {
|
| 78 |
+
turnstileRef.current?.reset();
|
| 79 |
+
setTurnstileToken(undefined);
|
| 80 |
toast.error(error.error.message || error.error.statusText);
|
| 81 |
},
|
| 82 |
},
|
|
|
|
| 199 |
</form.Field>
|
| 200 |
</div>
|
| 201 |
|
| 202 |
+
<TurnstileField
|
| 203 |
+
ref={turnstileRef}
|
| 204 |
+
onSuccess={setTurnstileToken}
|
| 205 |
+
onExpire={() => setTurnstileToken(undefined)}
|
| 206 |
+
onError={() => setTurnstileToken(undefined)}
|
| 207 |
+
/>
|
| 208 |
+
|
| 209 |
<form.Subscribe
|
| 210 |
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
| 211 |
>
|
| 212 |
{({ canSubmit, isSubmitting }) => (
|
| 213 |
+
<Button
|
| 214 |
+
type="submit"
|
| 215 |
+
className="w-full rounded-[var(--radius-lg)]"
|
| 216 |
+
disabled={!canSubmit || isSubmitting || (!!TURNSTILE_SITE_KEY && !turnstileToken)}
|
| 217 |
+
>
|
| 218 |
{isSubmitting ? "Submitting..." : "Sign Up"}
|
| 219 |
</Button>
|
| 220 |
)}
|
apps/web/src/routes/verify-email.tsx
CHANGED
|
@@ -5,10 +5,12 @@ import { Button } from "@labas/ui/components/button";
|
|
| 5 |
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
| 7 |
import { toast } from "sonner";
|
|
|
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { authClient } from "@/lib/auth-client";
|
| 10 |
import { trpc } from "@/utils/trpc";
|
| 11 |
import { routeShell } from "@/lib/route-shell";
|
|
|
|
| 12 |
import { z } from "zod";
|
| 13 |
|
| 14 |
const searchSchema = z.object({
|
|
@@ -40,6 +42,8 @@ function RouteComponent() {
|
|
| 40 |
|
| 41 |
const sendMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
| 42 |
const verifyMutation = useMutation(trpc.verification.verifyEmailOtp.mutationOptions());
|
|
|
|
|
|
|
| 43 |
|
| 44 |
useEffect(() => {
|
| 45 |
if (!searchEmail) {
|
|
@@ -66,13 +70,17 @@ function RouteComponent() {
|
|
| 66 |
inputRefs.current[0]?.focus();
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
-
const handleSendOtp = async () => {
|
| 70 |
if (!email) return;
|
| 71 |
try {
|
| 72 |
-
await sendMutation.mutateAsync({ email });
|
| 73 |
toast.success("Kode OTP telah dikirim ke email Anda");
|
| 74 |
setCountdown(60);
|
|
|
|
|
|
|
| 75 |
} catch (err: unknown) {
|
|
|
|
|
|
|
| 76 |
toast.error(getErrorMessage(err) || "Gagal mengirim OTP");
|
| 77 |
}
|
| 78 |
};
|
|
@@ -173,16 +181,42 @@ function RouteComponent() {
|
|
| 173 |
{verifyMutation.isPending ? "Memverifikasi..." : "Verifikasi Email"}
|
| 174 |
</Button>
|
| 175 |
|
| 176 |
-
<div className="
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
|
| 188 |
<div className="text-center">
|
|
|
|
| 5 |
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 6 |
import { Input } from "@labas/ui/components/input";
|
| 7 |
import { toast } from "sonner";
|
| 8 |
+
import type { TurnstileInstance } from "@marsidev/react-turnstile";
|
| 9 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 10 |
import { authClient } from "@/lib/auth-client";
|
| 11 |
import { trpc } from "@/utils/trpc";
|
| 12 |
import { routeShell } from "@/lib/route-shell";
|
| 13 |
+
import { TurnstileField, TURNSTILE_SITE_KEY } from "@/components/TurnstileField";
|
| 14 |
import { z } from "zod";
|
| 15 |
|
| 16 |
const searchSchema = z.object({
|
|
|
|
| 42 |
|
| 43 |
const sendMutation = useMutation(trpc.verification.sendVerificationOtp.mutationOptions());
|
| 44 |
const verifyMutation = useMutation(trpc.verification.verifyEmailOtp.mutationOptions());
|
| 45 |
+
const [resendTurnstileToken, setResendTurnstileToken] = useState<string | undefined>(undefined);
|
| 46 |
+
const resendTurnstileRef = useRef<TurnstileInstance>(null);
|
| 47 |
|
| 48 |
useEffect(() => {
|
| 49 |
if (!searchEmail) {
|
|
|
|
| 70 |
inputRefs.current[0]?.focus();
|
| 71 |
}, []);
|
| 72 |
|
| 73 |
+
const handleSendOtp = async (token?: string) => {
|
| 74 |
if (!email) return;
|
| 75 |
try {
|
| 76 |
+
await sendMutation.mutateAsync({ email, turnstileToken: token });
|
| 77 |
toast.success("Kode OTP telah dikirim ke email Anda");
|
| 78 |
setCountdown(60);
|
| 79 |
+
resendTurnstileRef.current?.reset();
|
| 80 |
+
setResendTurnstileToken(undefined);
|
| 81 |
} catch (err: unknown) {
|
| 82 |
+
resendTurnstileRef.current?.reset();
|
| 83 |
+
setResendTurnstileToken(undefined);
|
| 84 |
toast.error(getErrorMessage(err) || "Gagal mengirim OTP");
|
| 85 |
}
|
| 86 |
};
|
|
|
|
| 181 |
{verifyMutation.isPending ? "Memverifikasi..." : "Verifikasi Email"}
|
| 182 |
</Button>
|
| 183 |
|
| 184 |
+
<div className="space-y-3">
|
| 185 |
+
{countdown === 0 && TURNSTILE_SITE_KEY ? (
|
| 186 |
+
<div className="space-y-2">
|
| 187 |
+
<p className="text-center text-xs text-muted-foreground">
|
| 188 |
+
Verifikasi keamanan untuk kirim ulang kode
|
| 189 |
+
</p>
|
| 190 |
+
<TurnstileField
|
| 191 |
+
ref={resendTurnstileRef}
|
| 192 |
+
onSuccess={(token) => {
|
| 193 |
+
setResendTurnstileToken(token);
|
| 194 |
+
handleSendOtp(token);
|
| 195 |
+
}}
|
| 196 |
+
onExpire={() => setResendTurnstileToken(undefined)}
|
| 197 |
+
onError={() => setResendTurnstileToken(undefined)}
|
| 198 |
+
/>
|
| 199 |
+
</div>
|
| 200 |
+
) : null}
|
| 201 |
+
<p className="text-center text-sm text-muted-foreground">
|
| 202 |
+
Tidak menerima kode?{" "}
|
| 203 |
+
{countdown > 0 ? (
|
| 204 |
+
<span className="font-semibold text-foreground">Kirim ulang ({countdown}s)</span>
|
| 205 |
+
) : TURNSTILE_SITE_KEY ? (
|
| 206 |
+
<span className="font-semibold text-foreground">
|
| 207 |
+
{sendMutation.isPending ? "Mengirim..." : "Selesaikan verifikasi di atas"}
|
| 208 |
+
</span>
|
| 209 |
+
) : (
|
| 210 |
+
<Button
|
| 211 |
+
variant="link"
|
| 212 |
+
className="p-0 h-auto font-semibold"
|
| 213 |
+
disabled={sendMutation.isPending}
|
| 214 |
+
onClick={() => handleSendOtp()}
|
| 215 |
+
>
|
| 216 |
+
{sendMutation.isPending ? "Mengirim..." : "Kirim Ulang"}
|
| 217 |
+
</Button>
|
| 218 |
+
)}
|
| 219 |
+
</p>
|
| 220 |
</div>
|
| 221 |
|
| 222 |
<div className="text-center">
|
bun.lock
CHANGED
|
@@ -50,6 +50,7 @@
|
|
| 50 |
"@labas/auth": "workspace:*",
|
| 51 |
"@labas/env": "workspace:*",
|
| 52 |
"@labas/ui": "workspace:*",
|
|
|
|
| 53 |
"@tailwindcss/vite": "^4.2.2",
|
| 54 |
"@tanstack/react-form": "^1.28.0",
|
| 55 |
"@tanstack/react-query": "^5.90.12",
|
|
@@ -630,6 +631,8 @@
|
|
| 630 |
|
| 631 |
"@labas/ui": ["@labas/ui@workspace:packages/ui"],
|
| 632 |
|
|
|
|
|
|
|
| 633 |
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
| 634 |
|
| 635 |
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
|
|
|
|
| 50 |
"@labas/auth": "workspace:*",
|
| 51 |
"@labas/env": "workspace:*",
|
| 52 |
"@labas/ui": "workspace:*",
|
| 53 |
+
"@marsidev/react-turnstile": "^1.5.2",
|
| 54 |
"@tailwindcss/vite": "^4.2.2",
|
| 55 |
"@tanstack/react-form": "^1.28.0",
|
| 56 |
"@tanstack/react-query": "^5.90.12",
|
|
|
|
| 631 |
|
| 632 |
"@labas/ui": ["@labas/ui@workspace:packages/ui"],
|
| 633 |
|
| 634 |
+
"@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-+3aBPxp86JzSC0ZmgyonoGoUEENcUkH3LGahXSpkV87ArvD2DzRCmPgh0FyQk6PQRmJwQJDAfwNavFsxUxMQWA=="],
|
| 635 |
+
|
| 636 |
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
| 637 |
|
| 638 |
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.9", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-RXSxsokhAF/4nWys8An8npsqOI33Ex1Hlzqjw2pZOO+GKtMAR2noGnUdsFiGwsaO/xXI+56mtjTmDA3JXJsvmA=="],
|
packages/api/src/__tests__/test-db.ts
CHANGED
|
@@ -19,7 +19,7 @@ export async function createTestDb() {
|
|
| 19 |
const tables = [
|
| 20 |
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 21 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 22 |
-
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 23 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 24 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
|
|
|
| 19 |
const tables = [
|
| 20 |
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 21 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 22 |
+
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 23 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 24 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
packages/api/src/__tests__/test-setup.ts
CHANGED
|
@@ -23,7 +23,7 @@ async function initSchema(pg: PGlite) {
|
|
| 23 |
const tables = [
|
| 24 |
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, role text DEFAULT 'user' NOT NULL, suspended boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 26 |
-
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL)`,
|
| 27 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 28 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 29 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
|
@@ -53,10 +53,12 @@ export async function createTestUserData() {
|
|
| 53 |
const [user1] = await db.insert(schema.user).values({
|
| 54 |
id: "user-1", name: "Test User", email: "test@test.com", role: "admin",
|
| 55 |
}).returning();
|
|
|
|
| 56 |
|
| 57 |
const [user2] = await db.insert(schema.user).values({
|
| 58 |
id: "user-2", name: "Other User", email: "other@test.com",
|
| 59 |
}).returning();
|
|
|
|
| 60 |
|
| 61 |
await db.insert(schema.examType).values([
|
| 62 |
{ id: "IELTS", name: "IELTS", language: "English" },
|
|
|
|
| 23 |
const tables = [
|
| 24 |
`CREATE TABLE IF NOT EXISTS "user" (id text PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, email_verified boolean DEFAULT false NOT NULL, image text, role text DEFAULT 'user' NOT NULL, suspended boolean DEFAULT false NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 25 |
`CREATE TABLE IF NOT EXISTS "session" (id text PRIMARY KEY, expires_at timestamp NOT NULL, token text NOT NULL UNIQUE, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL, ip_address text, user_agent text, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE)`,
|
| 26 |
+
`CREATE TABLE IF NOT EXISTS "account" (id text PRIMARY KEY, account_id text NOT NULL, provider_id text NOT NULL, user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, access_token text, refresh_token text, id_token text, access_token_expires_at timestamp, refresh_token_expires_at timestamp, scope text, password text, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 27 |
`CREATE TABLE IF NOT EXISTS "verification" (id text PRIMARY KEY, identifier text NOT NULL, value text NOT NULL, expires_at timestamp NOT NULL, created_at timestamp DEFAULT now() NOT NULL, updated_at timestamp DEFAULT now() NOT NULL)`,
|
| 28 |
`CREATE TABLE IF NOT EXISTS "exam_type" (id text PRIMARY KEY, name text NOT NULL, language text NOT NULL, description text)`,
|
| 29 |
`CREATE TABLE IF NOT EXISTS "section_type" (id text PRIMARY KEY, name text NOT NULL)`,
|
|
|
|
| 53 |
const [user1] = await db.insert(schema.user).values({
|
| 54 |
id: "user-1", name: "Test User", email: "test@test.com", role: "admin",
|
| 55 |
}).returning();
|
| 56 |
+
if (!user1) throw new Error("Failed to seed test user 1");
|
| 57 |
|
| 58 |
const [user2] = await db.insert(schema.user).values({
|
| 59 |
id: "user-2", name: "Other User", email: "other@test.com",
|
| 60 |
}).returning();
|
| 61 |
+
if (!user2) throw new Error("Failed to seed test user 2");
|
| 62 |
|
| 63 |
await db.insert(schema.examType).values([
|
| 64 |
{ id: "IELTS", name: "IELTS", language: "English" },
|
packages/api/src/__tests__/verification.integration.test.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test";
|
| 2 |
+
import { drizzle } from "drizzle-orm/pglite";
|
| 3 |
+
import { eq } from "drizzle-orm";
|
| 4 |
+
import * as schema from "../../../db/src/schema";
|
| 5 |
+
import { getTestPGlite, closeTestPGlite } from "./test-setup";
|
| 6 |
+
|
| 7 |
+
// ── Mocks (must be declared before dynamic imports) ──────────────────────────
|
| 8 |
+
|
| 9 |
+
mock.module("@labas/env/server", () => ({
|
| 10 |
+
env: {
|
| 11 |
+
DATABASE_URL: "postgres://localhost:5432/test",
|
| 12 |
+
BETTER_AUTH_SECRET: "a".repeat(32),
|
| 13 |
+
BETTER_AUTH_URL: "http://localhost:3000",
|
| 14 |
+
CORS_ORIGIN: "http://localhost:5173",
|
| 15 |
+
API_KEY_ENCRYPTION_KEY: "z".repeat(32),
|
| 16 |
+
REDIS_URL: "redis://localhost:6379",
|
| 17 |
+
SMTP_HOST: "localhost",
|
| 18 |
+
SMTP_USER: "test",
|
| 19 |
+
SMTP_PASS: "test",
|
| 20 |
+
SMTP_FROM: "test@test.com",
|
| 21 |
+
FREE_CREDITS_ENABLED: false,
|
| 22 |
+
DEFAULT_SIGNUP_CREDIT_TOKENS: 50_000,
|
| 23 |
+
// CLOUDFLARE_TURNSTILE_SECRET_KEY intentionally omitted — Turnstile disabled
|
| 24 |
+
},
|
| 25 |
+
}));
|
| 26 |
+
|
| 27 |
+
const mockCheckRateLimit = mock(async () => {});
|
| 28 |
+
mock.module("../lib/rate-limit", () => ({
|
| 29 |
+
checkRateLimit: mockCheckRateLimit,
|
| 30 |
+
checkRateLimitAllowed: mock(async () => true),
|
| 31 |
+
}));
|
| 32 |
+
|
| 33 |
+
const mockSendOtpEmail = mock(async (_opts: unknown) => {});
|
| 34 |
+
mock.module("../lib/email", () => ({
|
| 35 |
+
sendOtpEmail: mockSendOtpEmail,
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
mock.module("../lib/turnstile", () => ({
|
| 39 |
+
validateTurnstileToken: mock(async () => {}),
|
| 40 |
+
}));
|
| 41 |
+
|
| 42 |
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
const TEST_IP = "203.0.113.42";
|
| 45 |
+
const EMAIL_UNVERIFIED = "unverified@example.com";
|
| 46 |
+
const EMAIL_VERIFIED = "verified@example.com";
|
| 47 |
+
const EMAIL_NONEXISTENT = "ghost@example.com";
|
| 48 |
+
|
| 49 |
+
describe("verification router", () => {
|
| 50 |
+
let db: ReturnType<typeof drizzle>;
|
| 51 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 52 |
+
let verificationRouter: any;
|
| 53 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 54 |
+
let caller: any;
|
| 55 |
+
|
| 56 |
+
beforeAll(async () => {
|
| 57 |
+
const pg = await getTestPGlite();
|
| 58 |
+
db = drizzle(pg, { schema });
|
| 59 |
+
|
| 60 |
+
mock.module("@labas/db", () => ({
|
| 61 |
+
__esModule: true,
|
| 62 |
+
...schema,
|
| 63 |
+
db,
|
| 64 |
+
}));
|
| 65 |
+
|
| 66 |
+
const mod = await import("../routers/verification");
|
| 67 |
+
verificationRouter = mod.verificationRouter;
|
| 68 |
+
caller = verificationRouter.createCaller({ session: null, auth: null, ip: TEST_IP });
|
| 69 |
+
|
| 70 |
+
await db.insert(schema.user).values([
|
| 71 |
+
{ id: "v-user-1", name: "Unverified User", email: EMAIL_UNVERIFIED, emailVerified: false },
|
| 72 |
+
{ id: "v-user-2", name: "Verified User", email: EMAIL_VERIFIED, emailVerified: true },
|
| 73 |
+
]);
|
| 74 |
+
|
| 75 |
+
// Seed account row so resetPassword can update the password hash
|
| 76 |
+
await db.insert(schema.account).values({
|
| 77 |
+
id: "v-account-1",
|
| 78 |
+
accountId: "v-user-1",
|
| 79 |
+
providerId: "credential",
|
| 80 |
+
userId: "v-user-1",
|
| 81 |
+
password: "hashed_old_password",
|
| 82 |
+
});
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
afterAll(async () => {
|
| 86 |
+
await closeTestPGlite();
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
// ── sendVerificationOtp ───────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
describe("sendVerificationOtp", () => {
|
| 92 |
+
it("sends email and returns generic success for unverified user", async () => {
|
| 93 |
+
mockSendOtpEmail.mockClear();
|
| 94 |
+
const result = await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
|
| 95 |
+
expect(result.success).toBe(true);
|
| 96 |
+
expect(mockSendOtpEmail).toHaveBeenCalledTimes(1);
|
| 97 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 98 |
+
const callArg = (mockSendOtpEmail.mock.calls[0] as any[])[0] as { to: string; type: string; otp: string };
|
| 99 |
+
expect(callArg.to).toBe(EMAIL_UNVERIFIED);
|
| 100 |
+
expect(callArg.type).toBe("email-verification");
|
| 101 |
+
expect(callArg.otp).toMatch(/^\d{6}$/);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
it("silently returns generic response for non-existent email (no enumeration)", async () => {
|
| 105 |
+
mockSendOtpEmail.mockClear();
|
| 106 |
+
const result = await caller.sendVerificationOtp({ email: EMAIL_NONEXISTENT });
|
| 107 |
+
expect(result.success).toBe(true);
|
| 108 |
+
expect(mockSendOtpEmail).not.toHaveBeenCalled();
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it("silently returns generic response for already-verified email", async () => {
|
| 112 |
+
mockSendOtpEmail.mockClear();
|
| 113 |
+
const result = await caller.sendVerificationOtp({ email: EMAIL_VERIFIED });
|
| 114 |
+
expect(result.success).toBe(true);
|
| 115 |
+
expect(mockSendOtpEmail).not.toHaveBeenCalled();
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("checks per-email and per-IP rate limits with correct keys", async () => {
|
| 119 |
+
mockCheckRateLimit.mockClear();
|
| 120 |
+
await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
|
| 121 |
+
const keys = mockCheckRateLimit.mock.calls.map((c: unknown[]) => (c[0] as { key: string }).key);
|
| 122 |
+
expect(keys).toContain(`otp-send:email:${EMAIL_UNVERIFIED}`);
|
| 123 |
+
expect(keys).toContain(`otp-send:ip:${TEST_IP}:email`);
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
it("passes strict:true to checkRateLimit", async () => {
|
| 127 |
+
mockCheckRateLimit.mockClear();
|
| 128 |
+
await caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED });
|
| 129 |
+
const allStrict = mockCheckRateLimit.mock.calls.every(
|
| 130 |
+
(c: unknown[]) => (c[0] as { strict?: boolean }).strict === true,
|
| 131 |
+
);
|
| 132 |
+
expect(allStrict).toBe(true);
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// ── verifyEmailOtp ────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
describe("verifyEmailOtp", () => {
|
| 139 |
+
it("verifies email and returns success with correct OTP", async () => {
|
| 140 |
+
// Seed a fresh OTP directly into the verification table
|
| 141 |
+
const otp = "123456";
|
| 142 |
+
await db.delete(schema.verification).where(
|
| 143 |
+
eq(schema.verification.identifier, `email-verification:${EMAIL_UNVERIFIED}`),
|
| 144 |
+
);
|
| 145 |
+
await db.insert(schema.verification).values({
|
| 146 |
+
id: "v-otp-1",
|
| 147 |
+
identifier: `email-verification:${EMAIL_UNVERIFIED}`,
|
| 148 |
+
value: otp,
|
| 149 |
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
const result = await caller.verifyEmailOtp({ email: EMAIL_UNVERIFIED, otp });
|
| 153 |
+
expect(result.success).toBe(true);
|
| 154 |
+
|
| 155 |
+
// emailVerified should now be true in the DB
|
| 156 |
+
const [updated] = await db
|
| 157 |
+
.select({ emailVerified: schema.user.emailVerified })
|
| 158 |
+
.from(schema.user)
|
| 159 |
+
.where(eq(schema.user.email, EMAIL_UNVERIFIED));
|
| 160 |
+
expect(updated?.emailVerified).toBe(true);
|
| 161 |
+
|
| 162 |
+
// OTP record should be deleted after use
|
| 163 |
+
const remaining = await db
|
| 164 |
+
.select()
|
| 165 |
+
.from(schema.verification)
|
| 166 |
+
.where(eq(schema.verification.id, "v-otp-1"));
|
| 167 |
+
expect(remaining).toHaveLength(0);
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
it("throws BAD_REQUEST for wrong OTP", async () => {
|
| 171 |
+
await expect(
|
| 172 |
+
caller.verifyEmailOtp({ email: EMAIL_UNVERIFIED, otp: "000000" }),
|
| 173 |
+
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
it("throws BAD_REQUEST for expired OTP", async () => {
|
| 177 |
+
await db.delete(schema.verification).where(
|
| 178 |
+
eq(schema.verification.identifier, `email-verification:${EMAIL_VERIFIED}`),
|
| 179 |
+
);
|
| 180 |
+
await db.insert(schema.verification).values({
|
| 181 |
+
id: "v-otp-expired",
|
| 182 |
+
identifier: `email-verification:${EMAIL_VERIFIED}`,
|
| 183 |
+
value: "654321",
|
| 184 |
+
expiresAt: new Date(Date.now() - 1000), // already expired
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
await expect(
|
| 188 |
+
caller.verifyEmailOtp({ email: EMAIL_VERIFIED, otp: "654321" }),
|
| 189 |
+
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
| 190 |
+
});
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
// ── sendPasswordResetOtp ──────────────────────────────────────────────────
|
| 194 |
+
|
| 195 |
+
describe("sendPasswordResetOtp", () => {
|
| 196 |
+
it("sends reset email for registered user", async () => {
|
| 197 |
+
mockSendOtpEmail.mockClear();
|
| 198 |
+
const result = await caller.sendPasswordResetOtp({ email: EMAIL_UNVERIFIED });
|
| 199 |
+
expect(result.success).toBe(true);
|
| 200 |
+
expect(mockSendOtpEmail).toHaveBeenCalledTimes(1);
|
| 201 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 202 |
+
const callArg = (mockSendOtpEmail.mock.calls[0] as any[])[0] as { to: string; type: string };
|
| 203 |
+
expect(callArg.to).toBe(EMAIL_UNVERIFIED);
|
| 204 |
+
expect(callArg.type).toBe("forget-password");
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
it("silently returns generic response for non-existent email", async () => {
|
| 208 |
+
mockSendOtpEmail.mockClear();
|
| 209 |
+
const result = await caller.sendPasswordResetOtp({ email: EMAIL_NONEXISTENT });
|
| 210 |
+
expect(result.success).toBe(true);
|
| 211 |
+
expect(mockSendOtpEmail).not.toHaveBeenCalled();
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
it("checks per-email and per-IP rate limits for reset", async () => {
|
| 215 |
+
mockCheckRateLimit.mockClear();
|
| 216 |
+
await caller.sendPasswordResetOtp({ email: EMAIL_UNVERIFIED });
|
| 217 |
+
const keys = mockCheckRateLimit.mock.calls.map((c: unknown[]) => (c[0] as { key: string }).key);
|
| 218 |
+
expect(keys).toContain(`otp-send:email:${EMAIL_UNVERIFIED}`);
|
| 219 |
+
expect(keys).toContain(`otp-send:ip:${TEST_IP}:reset`);
|
| 220 |
+
});
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
// ── resetPassword ─────────────────────────────────────────────────────────
|
| 224 |
+
|
| 225 |
+
describe("resetPassword", () => {
|
| 226 |
+
it("resets password with valid OTP", async () => {
|
| 227 |
+
const otp = "789012";
|
| 228 |
+
await db.delete(schema.verification).where(
|
| 229 |
+
eq(schema.verification.identifier, `forget-password:${EMAIL_UNVERIFIED}`),
|
| 230 |
+
);
|
| 231 |
+
await db.insert(schema.verification).values({
|
| 232 |
+
id: "v-reset-1",
|
| 233 |
+
identifier: `forget-password:${EMAIL_UNVERIFIED}`,
|
| 234 |
+
value: otp,
|
| 235 |
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
const result = await caller.resetPassword({
|
| 239 |
+
email: EMAIL_UNVERIFIED,
|
| 240 |
+
otp,
|
| 241 |
+
newPassword: "NewPass123",
|
| 242 |
+
});
|
| 243 |
+
expect(result.success).toBe(true);
|
| 244 |
+
|
| 245 |
+
// OTP record should be deleted
|
| 246 |
+
const remaining = await db
|
| 247 |
+
.select()
|
| 248 |
+
.from(schema.verification)
|
| 249 |
+
.where(eq(schema.verification.id, "v-reset-1"));
|
| 250 |
+
expect(remaining).toHaveLength(0);
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it("throws BAD_REQUEST for invalid OTP", async () => {
|
| 254 |
+
await expect(
|
| 255 |
+
caller.resetPassword({ email: EMAIL_UNVERIFIED, otp: "000000", newPassword: "NewPass123" }),
|
| 256 |
+
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
| 257 |
+
});
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
// ── rate limit propagation ─────────────────────────────────────────────────
|
| 261 |
+
|
| 262 |
+
describe("rate limit enforcement", () => {
|
| 263 |
+
it("throws TOO_MANY_REQUESTS when checkRateLimit throws", async () => {
|
| 264 |
+
const { TRPCError } = await import("@trpc/server");
|
| 265 |
+
mockCheckRateLimit.mockImplementation(async () => {
|
| 266 |
+
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many requests. Please try again later." });
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
await expect(
|
| 270 |
+
caller.sendVerificationOtp({ email: EMAIL_UNVERIFIED }),
|
| 271 |
+
).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
| 272 |
+
|
| 273 |
+
// Restore no-op
|
| 274 |
+
mockCheckRateLimit.mockImplementation(async () => {});
|
| 275 |
+
});
|
| 276 |
+
});
|
| 277 |
+
});
|
packages/api/src/context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { auth } from "@labas/auth";
|
| 2 |
import type { Context as HonoContext } from "hono";
|
|
|
|
| 3 |
|
| 4 |
export type CreateContextOptions = {
|
| 5 |
context: HonoContext;
|
|
@@ -12,6 +13,7 @@ export async function createContext({ context }: CreateContextOptions) {
|
|
| 12 |
return {
|
| 13 |
auth: null,
|
| 14 |
session,
|
|
|
|
| 15 |
};
|
| 16 |
}
|
| 17 |
|
|
|
|
| 1 |
import { auth } from "@labas/auth";
|
| 2 |
import type { Context as HonoContext } from "hono";
|
| 3 |
+
import { getClientIp } from "./lib/client-ip";
|
| 4 |
|
| 5 |
export type CreateContextOptions = {
|
| 6 |
context: HonoContext;
|
|
|
|
| 13 |
return {
|
| 14 |
auth: null,
|
| 15 |
session,
|
| 16 |
+
ip: getClientIp(context),
|
| 17 |
};
|
| 18 |
}
|
| 19 |
|
packages/api/src/lib/client-ip.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Context as HonoContext } from "hono";
|
| 2 |
+
|
| 3 |
+
export function getClientIp(context: HonoContext): string | undefined {
|
| 4 |
+
const headers = context.req.raw.headers;
|
| 5 |
+
const cfIp = headers.get("cf-connecting-ip");
|
| 6 |
+
if (cfIp) return cfIp.trim();
|
| 7 |
+
const forwarded = headers.get("x-forwarded-for");
|
| 8 |
+
if (forwarded) {
|
| 9 |
+
const firstIp = forwarded.split(",")[0]?.trim();
|
| 10 |
+
if (firstIp) return firstIp;
|
| 11 |
+
}
|
| 12 |
+
const realIp = headers.get("x-real-ip");
|
| 13 |
+
if (realIp) return realIp.trim();
|
| 14 |
+
return undefined;
|
| 15 |
+
}
|
packages/api/src/lib/rate-limit.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface RateLimitConfig {
|
|
| 39 |
key: string;
|
| 40 |
limit: number;
|
| 41 |
windowMs: number;
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
|
|
@@ -56,10 +58,39 @@ export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
|
|
| 56 |
}
|
| 57 |
} catch (err) {
|
| 58 |
if (err instanceof TRPCError) throw err;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
|
| 60 |
}
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
export async function checkDailyBudget(key: string, limit: number): Promise<boolean> {
|
| 64 |
const r = getRedis();
|
| 65 |
const now = new Date();
|
|
|
|
| 39 |
key: string;
|
| 40 |
limit: number;
|
| 41 |
windowMs: number;
|
| 42 |
+
/** When true, throw a 503 if Redis is unavailable instead of silently skipping. */
|
| 43 |
+
strict?: boolean;
|
| 44 |
}
|
| 45 |
|
| 46 |
export async function checkRateLimit(config: RateLimitConfig): Promise<void> {
|
|
|
|
| 58 |
}
|
| 59 |
} catch (err) {
|
| 60 |
if (err instanceof TRPCError) throw err;
|
| 61 |
+
if (config.strict) {
|
| 62 |
+
logger.error("[RATELIMIT] Redis unavailable, rejecting request (strict mode)", { error: (err as Error).message });
|
| 63 |
+
throw new TRPCError({
|
| 64 |
+
code: "INTERNAL_SERVER_ERROR",
|
| 65 |
+
message: "Service temporarily unavailable. Please try again.",
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
|
| 69 |
}
|
| 70 |
}
|
| 71 |
|
| 72 |
+
/**
|
| 73 |
+
* Returns true if the request is allowed, false if rate-limited.
|
| 74 |
+
* Used by non-tRPC contexts (e.g. Hono middleware) where throwing TRPCError is not appropriate.
|
| 75 |
+
* Fails open (returns true) when Redis is unavailable unless `strict` is set.
|
| 76 |
+
*/
|
| 77 |
+
export async function checkRateLimitAllowed(config: RateLimitConfig): Promise<boolean> {
|
| 78 |
+
const r = getRedis();
|
| 79 |
+
const key = `ratelimit:${config.key}`;
|
| 80 |
+
const now = Date.now();
|
| 81 |
+
try {
|
| 82 |
+
const allowed = await r.eval(SCRIPT, 1, key, config.limit.toString(), config.windowMs.toString(), now.toString());
|
| 83 |
+
return allowed !== 0;
|
| 84 |
+
} catch (err) {
|
| 85 |
+
if (config.strict) {
|
| 86 |
+
logger.error("[RATELIMIT] Redis unavailable (strict mode)", { error: (err as Error).message });
|
| 87 |
+
return false;
|
| 88 |
+
}
|
| 89 |
+
logger.warn("[RATELIMIT] Redis unavailable, rate limit skipped", { error: (err as Error).message });
|
| 90 |
+
return true;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
export async function checkDailyBudget(key: string, limit: number): Promise<boolean> {
|
| 95 |
const r = getRedis();
|
| 96 |
const now = new Date();
|
packages/api/src/lib/turnstile.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { TRPCError } from "@trpc/server";
|
| 2 |
+
import { env } from "@labas/env/server";
|
| 3 |
+
import { logger } from "@labas/api/logger";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Validates a Cloudflare Turnstile token server-side.
|
| 7 |
+
* No-ops when CLOUDFLARE_TURNSTILE_SECRET_KEY is not configured.
|
| 8 |
+
* Throws BAD_REQUEST if the token is missing or invalid when the key is set.
|
| 9 |
+
*/
|
| 10 |
+
export async function validateTurnstileToken(token: string | undefined): Promise<void> {
|
| 11 |
+
const secretKey = env.CLOUDFLARE_TURNSTILE_SECRET_KEY;
|
| 12 |
+
if (!secretKey) return;
|
| 13 |
+
|
| 14 |
+
if (!token) {
|
| 15 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "CAPTCHA verification is required." });
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
const res = await fetch("https://challenges.cloudflare.com/turnstile/v1/siteverify", {
|
| 20 |
+
method: "POST",
|
| 21 |
+
headers: { "Content-Type": "application/json" },
|
| 22 |
+
body: JSON.stringify({ secret: secretKey, response: token }),
|
| 23 |
+
});
|
| 24 |
+
const data = (await res.json()) as { success: boolean; "error-codes"?: string[] };
|
| 25 |
+
if (!data.success) {
|
| 26 |
+
logger.warn("[TURNSTILE] Token verification failed", { errorCodes: data["error-codes"] });
|
| 27 |
+
throw new TRPCError({ code: "BAD_REQUEST", message: "CAPTCHA verification failed. Please try again." });
|
| 28 |
+
}
|
| 29 |
+
} catch (err) {
|
| 30 |
+
if (err instanceof TRPCError) throw err;
|
| 31 |
+
// Fail open if the Cloudflare API is unreachable — don't block legitimate users
|
| 32 |
+
logger.error("[TURNSTILE] Siteverify request failed, skipping", { error: (err as Error).message });
|
| 33 |
+
}
|
| 34 |
+
}
|
packages/api/src/routers/verification.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import { verification, user, account } from "@labas/db/schema/auth";
|
| 3 |
import { TRPCError } from "@trpc/server";
|
| 4 |
import bcrypt from "bcryptjs";
|
|
@@ -10,6 +10,7 @@ import { sendOtpEmail } from "../lib/email";
|
|
| 10 |
import { autoRefillIfEligible } from "../lib/credit";
|
| 11 |
import { publicProcedure, router } from "../index";
|
| 12 |
import { checkRateLimit } from "../lib/rate-limit";
|
|
|
|
| 13 |
|
| 14 |
const OTP_LENGTH = 6;
|
| 15 |
const OTP_EXPIRY_MS = 5 * 60 * 1000;
|
|
@@ -24,12 +25,12 @@ function genericOtpResponse() {
|
|
| 24 |
|
| 25 |
export const verificationRouter = router({
|
| 26 |
sendVerificationOtp: publicProcedure
|
| 27 |
-
.input(z.object({ email: z.string().email() }))
|
| 28 |
-
.mutation(async ({ input }) => {
|
| 29 |
-
|
| 30 |
-
await checkRateLimit({ key: `otp-send:
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
const [existingUser] = await db
|
| 35 |
.select({ id: user.id, emailVerified: user.emailVerified })
|
|
@@ -60,10 +61,10 @@ export const verificationRouter = router({
|
|
| 60 |
|
| 61 |
verifyEmailOtp: publicProcedure
|
| 62 |
.input(z.object({ email: z.string().email(), otp: z.string().length(6) }))
|
| 63 |
-
.mutation(async ({ input }) => {
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
|
| 68 |
const identifier = `email-verification:${input.email}`;
|
| 69 |
const [record] = await db
|
|
@@ -103,12 +104,12 @@ export const verificationRouter = router({
|
|
| 103 |
}),
|
| 104 |
|
| 105 |
sendPasswordResetOtp: publicProcedure
|
| 106 |
-
.input(z.object({ email: z.string().email() }))
|
| 107 |
-
.mutation(async ({ input }) => {
|
| 108 |
-
|
| 109 |
-
await checkRateLimit({ key: `otp-send:
|
| 110 |
-
|
| 111 |
-
|
| 112 |
|
| 113 |
const [existingUser] = await db
|
| 114 |
.select({ id: user.id })
|
|
@@ -145,10 +146,10 @@ export const verificationRouter = router({
|
|
| 145 |
newPassword: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"),
|
| 146 |
}),
|
| 147 |
)
|
| 148 |
-
.mutation(async ({ input }) => {
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
|
| 153 |
const identifier = `forget-password:${input.email}`;
|
| 154 |
const [record] = await db
|
|
|
|
| 1 |
+
import { db } from "@labas/db";
|
| 2 |
import { verification, user, account } from "@labas/db/schema/auth";
|
| 3 |
import { TRPCError } from "@trpc/server";
|
| 4 |
import bcrypt from "bcryptjs";
|
|
|
|
| 10 |
import { autoRefillIfEligible } from "../lib/credit";
|
| 11 |
import { publicProcedure, router } from "../index";
|
| 12 |
import { checkRateLimit } from "../lib/rate-limit";
|
| 13 |
+
import { validateTurnstileToken } from "../lib/turnstile";
|
| 14 |
|
| 15 |
const OTP_LENGTH = 6;
|
| 16 |
const OTP_EXPIRY_MS = 5 * 60 * 1000;
|
|
|
|
| 25 |
|
| 26 |
export const verificationRouter = router({
|
| 27 |
sendVerificationOtp: publicProcedure
|
| 28 |
+
.input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() }))
|
| 29 |
+
.mutation(async ({ input, ctx }) => {
|
| 30 |
+
const ip = ctx.ip ?? "unknown";
|
| 31 |
+
await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true });
|
| 32 |
+
await checkRateLimit({ key: `otp-send:ip:${ip}:email`, limit: 20, windowMs: 900_000, strict: true });
|
| 33 |
+
await validateTurnstileToken(input.turnstileToken);
|
| 34 |
|
| 35 |
const [existingUser] = await db
|
| 36 |
.select({ id: user.id, emailVerified: user.emailVerified })
|
|
|
|
| 61 |
|
| 62 |
verifyEmailOtp: publicProcedure
|
| 63 |
.input(z.object({ email: z.string().email(), otp: z.string().length(6) }))
|
| 64 |
+
.mutation(async ({ input, ctx }) => {
|
| 65 |
+
const ip = ctx.ip ?? "unknown";
|
| 66 |
+
await checkRateLimit({ key: `otp-verify:email:${input.email}`, limit: 10, windowMs: 300_000, strict: true });
|
| 67 |
+
await checkRateLimit({ key: `otp-verify:ip:${ip}`, limit: 30, windowMs: 300_000, strict: true });
|
| 68 |
|
| 69 |
const identifier = `email-verification:${input.email}`;
|
| 70 |
const [record] = await db
|
|
|
|
| 104 |
}),
|
| 105 |
|
| 106 |
sendPasswordResetOtp: publicProcedure
|
| 107 |
+
.input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() }))
|
| 108 |
+
.mutation(async ({ input, ctx }) => {
|
| 109 |
+
const ip = ctx.ip ?? "unknown";
|
| 110 |
+
await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true });
|
| 111 |
+
await checkRateLimit({ key: `otp-send:ip:${ip}:reset`, limit: 20, windowMs: 900_000, strict: true });
|
| 112 |
+
await validateTurnstileToken(input.turnstileToken);
|
| 113 |
|
| 114 |
const [existingUser] = await db
|
| 115 |
.select({ id: user.id })
|
|
|
|
| 146 |
newPassword: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"),
|
| 147 |
}),
|
| 148 |
)
|
| 149 |
+
.mutation(async ({ input, ctx }) => {
|
| 150 |
+
const ip = ctx.ip ?? "unknown";
|
| 151 |
+
await checkRateLimit({ key: `otp-reset:email:${input.email}`, limit: 5, windowMs: 300_000, strict: true });
|
| 152 |
+
await checkRateLimit({ key: `otp-reset:ip:${ip}`, limit: 15, windowMs: 300_000, strict: true });
|
| 153 |
|
| 154 |
const identifier = `forget-password:${input.email}`;
|
| 155 |
const [record] = await db
|
packages/env/src/server.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const env = createEnv({
|
|
| 22 |
FREE_CREDITS_ENABLED: z.coerce.boolean().default(false),
|
| 23 |
FREE_CREDITS_MAX_POOL: z.coerce.number().positive().default(1_000_000),
|
| 24 |
DEFAULT_SIGNUP_CREDIT_TOKENS: z.coerce.number().positive().default(50_000),
|
|
|
|
| 25 |
},
|
| 26 |
runtimeEnv: process.env,
|
| 27 |
emptyStringAsUndefined: true,
|
|
|
|
| 22 |
FREE_CREDITS_ENABLED: z.coerce.boolean().default(false),
|
| 23 |
FREE_CREDITS_MAX_POOL: z.coerce.number().positive().default(1_000_000),
|
| 24 |
DEFAULT_SIGNUP_CREDIT_TOKENS: z.coerce.number().positive().default(50_000),
|
| 25 |
+
CLOUDFLARE_TURNSTILE_SECRET_KEY: z.string().optional(),
|
| 26 |
},
|
| 27 |
runtimeEnv: process.env,
|
| 28 |
emptyStringAsUndefined: true,
|
packages/env/src/web.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const env = createEnv({
|
|
| 6 |
client: {
|
| 7 |
VITE_SERVER_URL: z.url(),
|
| 8 |
VITE_SITE_URL: z.url().default("http://localhost:5173"),
|
|
|
|
| 9 |
},
|
| 10 |
runtimeEnv: (import.meta as any).env,
|
| 11 |
emptyStringAsUndefined: true,
|
|
|
|
| 6 |
client: {
|
| 7 |
VITE_SERVER_URL: z.url(),
|
| 8 |
VITE_SITE_URL: z.url().default("http://localhost:5173"),
|
| 9 |
+
VITE_CLOUDFLARE_TURNSTILE_SITE_KEY: z.string().optional(),
|
| 10 |
},
|
| 11 |
runtimeEnv: (import.meta as any).env,
|
| 12 |
emptyStringAsUndefined: true,
|