mirror of
https://github.com/wassname/Open-Assistant.git
synced 2026-08-15 12:05:19 +08:00
Merge pull request #932 from notmd/911_sigin_captcha
911 signin captcha
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { useColorMode } from "@chakra-ui/react";
|
||||
import { Turnstile, TurnstileInstance, TurnstileProps } from "@marsidev/react-turnstile";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export const CloudFlareCaptcha = forwardRef<TurnstileInstance, Omit<TurnstileProps, "siteKey">>((props, ref) => {
|
||||
const { colorMode } = useColorMode();
|
||||
return (
|
||||
<Turnstile
|
||||
ref={ref}
|
||||
{...props}
|
||||
siteKey={process.env.NEXT_PUBLIC_CLOUDFLARE_CAPTCHA_SITE_KEY}
|
||||
options={{
|
||||
theme: colorMode,
|
||||
...props.options,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CloudFlareCaptcha.displayName = "CloudFlareCaptcha";
|
||||
@@ -0,0 +1,57 @@
|
||||
type CaptchaErrorCode =
|
||||
| "missing-input-secret"
|
||||
| "invalid-input-secret"
|
||||
| "missing-input-response"
|
||||
| "invalid-input-response"
|
||||
| "bad-request"
|
||||
| "timeout-or-duplicate"
|
||||
| "internal-error";
|
||||
|
||||
type CheckCaptchaResponse = {
|
||||
success: boolean;
|
||||
challenge_ts?: string;
|
||||
hostname: string;
|
||||
"error-codes": CaptchaErrorCode[];
|
||||
action?: string;
|
||||
cdata?: string;
|
||||
};
|
||||
|
||||
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
|
||||
export const checkCaptcha = async (
|
||||
token: string,
|
||||
ipAdress: string,
|
||||
options?: { cdata?: string; action?: string }
|
||||
): Promise<CheckCaptchaResponse> => {
|
||||
const data = new FormData();
|
||||
|
||||
data.append("secret", process.env.CLOUDFLARE_CAPTCHA_SERCERT_KEY);
|
||||
data.append("response", token);
|
||||
data.append("remoteip", ipAdress);
|
||||
|
||||
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
body: data,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const res: CheckCaptchaResponse = await result.json();
|
||||
return {
|
||||
...res,
|
||||
success: getSuccess(res, options?.action, options?.cdata),
|
||||
};
|
||||
};
|
||||
|
||||
// This function hasn't been tested yet, Cloudflare doesn't send `action` and `cdata` with a demo key.
|
||||
const getSuccess = (response: CheckCaptchaResponse, action: string | undefined, cdata: string | undefined) => {
|
||||
if (action === undefined && cdata === undefined) {
|
||||
return response.success;
|
||||
}
|
||||
|
||||
if (action) {
|
||||
if (cdata) {
|
||||
return response.action === action && response.cdata === cdata;
|
||||
}
|
||||
return response.action === action;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { boolean } from "boolean";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { AuthOptions } from "next-auth";
|
||||
import NextAuth from "next-auth";
|
||||
import { Provider } from "next-auth/providers";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import DiscordProvider from "next-auth/providers/discord";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import { checkCaptcha } from "src/lib/captcha";
|
||||
import prisma from "src/lib/prismadb";
|
||||
import { generateUsername } from "unique-username-generator";
|
||||
|
||||
@@ -74,7 +76,7 @@ const adminUserMap = process.env.ADMIN_USERS.split(",").reduce((result, entry) =
|
||||
return result;
|
||||
}, new Map());
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
const authOptions: AuthOptions = {
|
||||
// Ensure we can store user data in a database.
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers,
|
||||
@@ -148,24 +150,41 @@ export const authOptions: AuthOptions = {
|
||||
}
|
||||
},
|
||||
},
|
||||
/*
|
||||
* We maybe need this, we maybe don't. Checking in this uncommented until
|
||||
* it's confirmed we can drop this.
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
path: "/",
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
export default function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
return NextAuth(req, res, {
|
||||
...authOptions,
|
||||
callbacks: {
|
||||
...authOptions.callbacks,
|
||||
async signIn({ account }) {
|
||||
if (account.provider !== "email" || !boolean(process.env.NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const captcha = req.body.captcha;
|
||||
|
||||
const res = await checkCaptcha(captcha, getIp(req));
|
||||
|
||||
if (res.success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return "/auth/signin?error=InvalidCaptcha";
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const getIp = (req: NextApiRequest) => {
|
||||
try {
|
||||
// https://stackoverflow.com/questions/66111742/get-the-client-ip-on-nextjs-and-use-ssr
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
return typeof forwarded === "string" ? forwarded.split(/, /)[0] : req.socket.remoteAddress;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Button, ButtonProps, Input, Stack, useColorModeValue } from "@chakra-ui/react";
|
||||
import { useColorMode } from "@chakra-ui/react";
|
||||
import { TurnstileInstance } from "@marsidev/react-turnstile";
|
||||
import { boolean } from "boolean";
|
||||
import { Bug, Github, Mail } from "lucide-react";
|
||||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ClientSafeProvider, getProviders, signIn } from "next-auth/react";
|
||||
import { getProviders, signIn } from "next-auth/react";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { AuthLayout } from "src/components/AuthLayout";
|
||||
import { CloudFlareCaptcha } from "src/components/CloudflareCaptcha";
|
||||
import { Footer } from "src/components/Footer";
|
||||
import { Header } from "src/components/Header";
|
||||
import { Discord } from "src/components/Icons/Discord";
|
||||
@@ -26,6 +29,7 @@ export type SignInErrorTypes =
|
||||
| "EmailSignin"
|
||||
| "CredentialsSignin"
|
||||
| "SessionRequired"
|
||||
| "InvalidCaptcha"
|
||||
| "default";
|
||||
|
||||
const errorMessages: Record<SignInErrorTypes, string> = {
|
||||
@@ -39,6 +43,7 @@ const errorMessages: Record<SignInErrorTypes, string> = {
|
||||
EmailSignin: "The e-mail could not be sent.",
|
||||
CredentialsSignin: "Sign in failed. Check the details you provided are correct.",
|
||||
SessionRequired: "Please sign in to access this page.",
|
||||
InvalidCaptcha: "Invalid captcha",
|
||||
default: "Unable to sign in.",
|
||||
};
|
||||
|
||||
@@ -62,14 +67,10 @@ function Signin({ providers }: SigninProps) {
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const signinWithEmail = (data: { email: string }) => {
|
||||
signIn(email.id, { callbackUrl: "/dashboard", email: data.email });
|
||||
};
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
const bgColorClass = colorMode === "light" ? "bg-gray-50" : "bg-chakra-gray-900";
|
||||
const buttonBgColor = colorMode === "light" ? "#2563eb" : "#2563eb";
|
||||
const { register, handleSubmit } = useForm<{ email: string }>();
|
||||
|
||||
return (
|
||||
<div className={bgColorClass}>
|
||||
<Head>
|
||||
@@ -78,24 +79,8 @@ function Signin({ providers }: SigninProps) {
|
||||
</Head>
|
||||
<AuthLayout>
|
||||
<Stack spacing="2">
|
||||
{credentials && <DebugSigninForm credentials={credentials} bgColorClass={bgColorClass} />}
|
||||
{email && (
|
||||
<form onSubmit={handleSubmit(signinWithEmail)}>
|
||||
<Stack>
|
||||
<Input
|
||||
type="email"
|
||||
data-cy="email-address"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
placeholder="Email Address"
|
||||
{...register("email")}
|
||||
/>
|
||||
<SigninButton data-cy="signin-email-button" leftIcon={<Mail />}>
|
||||
Continue with Email
|
||||
</SigninButton>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
{credentials && <DebugSigninForm providerId={credentials.id} bgColorClass={bgColorClass} />}
|
||||
{email && <EmailSignInForm providerId={email.id}></EmailSignInForm>}
|
||||
{discord && (
|
||||
<Button
|
||||
bg={buttonBgColor}
|
||||
@@ -160,6 +145,50 @@ Signin.getLayout = (page) => (
|
||||
|
||||
export default Signin;
|
||||
|
||||
const emailSigninCaptcha = boolean(process.env.NEXT_PUBLIC_ENABLE_EMAIL_SIGNIN_CAPTCHA);
|
||||
|
||||
const EmailSignInForm = ({ providerId }: { providerId: string }) => {
|
||||
const { register, handleSubmit } = useForm<{ email: string }>();
|
||||
const captcha = useRef<TurnstileInstance>();
|
||||
const [captchaSuccess, setCaptchaSuccess] = useState(false);
|
||||
const signinWithEmail = (data: { email: string }) => {
|
||||
signIn(providerId, {
|
||||
callbackUrl: "/dashboard",
|
||||
email: data.email,
|
||||
captcha: captcha.current?.getResponse(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit(signinWithEmail)}>
|
||||
<Stack>
|
||||
<Input
|
||||
type="email"
|
||||
data-cy="email-address"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
placeholder="Email Address"
|
||||
{...register("email")}
|
||||
/>
|
||||
{emailSigninCaptcha && (
|
||||
<CloudFlareCaptcha
|
||||
options={{ size: "invisible" }}
|
||||
ref={captcha}
|
||||
onSuccess={() => setCaptchaSuccess(true)}
|
||||
></CloudFlareCaptcha>
|
||||
)}
|
||||
<SigninButton
|
||||
data-cy="signin-email-button"
|
||||
leftIcon={<Mail />}
|
||||
mt="4"
|
||||
disabled={!captchaSuccess && emailSigninCaptcha}
|
||||
>
|
||||
Continue with Email
|
||||
</SigninButton>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const SigninButton = (props: ButtonProps) => {
|
||||
const buttonColorScheme = useColorModeValue("blue", "dark-blue-btn");
|
||||
|
||||
@@ -180,7 +209,7 @@ interface DebugSigninFormData {
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const DebugSigninForm = ({ credentials, bgColorClass }: { credentials: ClientSafeProvider; bgColorClass: string }) => {
|
||||
const DebugSigninForm = ({ providerId, bgColorClass }: { providerId: string; bgColorClass: string }) => {
|
||||
const { register, handleSubmit } = useForm<DebugSigninFormData>({
|
||||
defaultValues: {
|
||||
role: "general",
|
||||
@@ -189,7 +218,7 @@ const DebugSigninForm = ({ credentials, bgColorClass }: { credentials: ClientSaf
|
||||
});
|
||||
|
||||
function signinWithDebugCredentials(data: DebugSigninFormData) {
|
||||
signIn(credentials.id, {
|
||||
signIn(providerId, {
|
||||
callbackUrl: "/dashboard",
|
||||
...data,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user