Skip to content

Commit

Permalink
feat(auth/v1.0): add cloudflare turnstile support for auth
Browse files Browse the repository at this point in the history
  • Loading branch information
IncognitoTGT committed Jul 21, 2024
1 parent 84e3b23 commit d797204
Show file tree
Hide file tree
Showing 10 changed files with 106 additions and 18 deletions.
14 changes: 14 additions & 0 deletions .config/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@
"secret": {
"description": "The JWT secret used to sign tokens.",
"type": "string"
},
"turnstile": {
"additionalProperties": false,
"description": "Cloudflare turnstile configuration. Leave `undefined` to disable turnstile.",
"properties": {
"secret": {
"type": "string"
},
"siteKey": {
"type": "string"
}
},
"required": ["secret", "siteKey"],
"type": "object"
}
},
"required": ["secret"],
Expand Down
4 changes: 2 additions & 2 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
// @ts-check
import { execSync } from "node:child_process";
import NextBundleAnalyzer from "@next/bundle-analyzer";
/** @type {import('next').NextConfig} */
const nextConfig = {
Expand All @@ -16,6 +16,7 @@ const nextConfig = {
env: {
GIT_COMMIT: process.env.NODE_ENV === "production" ? execSync("git rev-parse HEAD").toString().trim() : "DEVELOP",
BUILD_DATE: Date.now().toString(),
TURNSTILE_SITEKEY: JSON.parse(process.env.CONFIG || "").auth.turnstile?.siteKey,
},
experimental: {
ppr: true,
Expand Down Expand Up @@ -50,5 +51,4 @@ const nextConfig = {
return config;
},
};

export default NextBundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(nextConfig);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"config:generate": "ts-json-schema-generator --tsconfig ./tsconfig.json --path ./src/types/config.d.ts --type Config > ./.config/config-schema.json"
},
"dependencies": {
"@marsidev/react-turnstile": "^0.7.2",
"@next/bundle-analyzer": "^14.2.3",
"@novnc/novnc": "^1.4.0",
"@paralleldrive/cuid2": "^2.2.2",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions src/app/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StyledSubmit } from "@/components/submit-button";
import Turnstile from "@/components/turnstile";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription } from "@/components/ui/card";
Expand All @@ -7,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { auth, signIn } from "@/lib/auth";
import { providers } from "@/lib/auth.config";
import { getConfig } from "@/lib/config";
import turnstileCheck from "@/lib/turnstile";
import { AlertCircle, Info } from "lucide-react";
import type { CredentialsSignin } from "next-auth";
import Link from "next/link";
Expand Down Expand Up @@ -43,9 +45,15 @@ export default async function Login({
action={async (data) => {
"use server";
try {
await signIn("credentials", data);
if (await turnstileCheck(data)) {
await signIn("credentials", data);
} else {
throw new Error("Failed captcha");
}
} catch (error) {
redirect(`/auth/login?error=${(error as CredentialsSignin).cause?.err?.message}`);
redirect(
`/auth/login?error=${(error as CredentialsSignin).cause?.err?.message || (error as Error).message}`,
);
}
}}
>
Expand All @@ -69,6 +77,7 @@ export default async function Login({
required
className="w-full"
/>
<Turnstile />
<StyledSubmit className="w-full">Log in</StyledSubmit>
</form>
) : null}
Expand Down
43 changes: 30 additions & 13 deletions src/app/auth/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { StyledSubmit } from "@/components/submit-button";
import Turnstile from "@/components/turnstile";
import { Alert, AlertTitle } from "@/components/ui/alert";
import { CardContent, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { auth } from "@/lib/auth";
import { getConfig } from "@/lib/config";
import { db, user } from "@/lib/drizzle/db";
import turnstileCheck from "@/lib/turnstile";
import { createId } from "@paralleldrive/cuid2";
import { hash } from "argon2";
import { Info } from "lucide-react";
import { redirect } from "next/navigation";
import { redirect, unstable_rethrow } from "next/navigation";

export default async function Page({
searchParams,
Expand All @@ -19,7 +21,7 @@ export default async function Page({
const session = await auth();
if (session) redirect("/");
const config = getConfig();
const { message } = searchParams;
const { message, error } = searchParams;
if (!config.auth.credentials || !config.auth.credentials.signups)
redirect(`/auth/error?error=${encodeURIComponent("Signups are disabled for this instance.")}`);
return (
Expand All @@ -31,21 +33,35 @@ export default async function Page({
<AlertTitle>{message}</AlertTitle>
</Alert>
) : null}
{error ? (
<Alert className="w-full my-4" variant="destructive">
<Info className="h-4 w-4" />
<AlertTitle>{error}</AlertTitle>
</Alert>
) : null}
<form
className="mx-auto mb-4 flex w-full flex-col items-start justify-center gap-2"
action={async (data) => {
"use server";
const userCheck = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.email, data.get("email")?.toString() || ""),
});
if (userCheck) redirect("/auth/login?error=Email%20already%20in%20use");
await db.insert(user).values({
name: data.get("name")?.toString(),
email: data.get("email")?.toString() as string,
password: await hash(data.get("password")?.toString() as string),
id: createId(),
});
redirect("/auth/login?message=Account%20created%20successfully");
try {
if (!(await turnstileCheck(data))) {
redirect("/auth/signup?error=Failed%captcha");
}
const userCheck = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.email, data.get("email")?.toString() || ""),
});
if (userCheck) redirect("/auth/login?error=Email%20already%20in%20use");
await db.insert(user).values({
name: data.get("name")?.toString(),
email: data.get("email")?.toString() as string,
password: await hash(data.get("password")?.toString() as string),
id: createId(),
});
redirect("/auth/login?message=Account%20created%20successfully");
} catch (e) {
unstable_rethrow(e);
throw e;
}
}}
>
<Label htmlFor="name">Name</Label>
Expand All @@ -71,6 +87,7 @@ export default async function Page({
required
className="w-full"
/>
<Turnstile />
<StyledSubmit className="w-full">Sign up</StyledSubmit>
</form>
</CardContent>
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function generateMetadata(): Metadata {
: undefined,
};
}
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
Expand Down
4 changes: 4 additions & 0 deletions src/components/turnstile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Turnstile as BaseTurnstile } from "@marsidev/react-turnstile";
export default function Turnstile() {
return process.env.TURNSTILE_SITEKEY ? <BaseTurnstile siteKey={process.env.TURNSTILE_SITEKEY} /> : null;
}
22 changes: 22 additions & 0 deletions src/lib/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getConfig } from "./config";

export default async function turnstileCheck(data: FormData) {
const config = getConfig();
if (!config.auth.turnstile) return true;
const key = data.get("cf-turnstile-response")?.toString();
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
body: JSON.stringify({
secret: config.auth.turnstile.secret,
response: key,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const outcome = await result.json();
if (outcome.success) {
return true;
}
return false;
}
7 changes: 7 additions & 0 deletions src/types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface AuthConfig {
* The JWT secret used to sign tokens.
**/
secret: string;
/**
* Cloudflare turnstile configuration. Leave `undefined` to disable turnstile.
**/
turnstile?: {
secret: string;
siteKey: string;
};
/**
* Credentials configuration. Leave `undefined` to disable user/password signups.
**/
Expand Down

0 comments on commit d797204

Please sign in to comment.