Merge pull request #106 from MAGICGrants/cloudflare-captcha

Cloudflare captcha
This commit is contained in:
Artur
2024-12-09 19:43:50 -03:00
committed by GitHub
12 changed files with 85 additions and 2 deletions

View File

@@ -11,6 +11,9 @@ NEXTAUTH_URL_INTERNAL="http://localhost:3000"
NEXTAUTH_SECRET=""
USER_SETTINGS_JWT_SECRET=""
NEXT_PUBLIC_TURNSTILE_SITEKEY=""
TURNSTILE_SECRET=""
SMTP_HOST="sandbox.smtp.mailtrap.io"
SMTP_PORT="2525"
SMTP_USER=""

View File

@@ -30,6 +30,7 @@ jobs:
DATABASE_URL=${{ secrets.DATABASE_URL }} \
NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }} \
USER_SETTINGS_JWT_SECRET=${{ secrets.USER_SETTINGS_JWT_SECRET }} \
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }} \
STRAPI_API_URL=${{ secrets.STRAPI_API_URL }} \
STRAPI_API_TOKEN=${{ secrets.STRAPI_API_TOKEN }} \
SMTP_USER=${{ secrets.SMTP_USER }} \

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { zodResolver } from '@hookform/resolvers/zod'
import { signIn, useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { Turnstile } from '@marsidev/react-turnstile'
import { z } from 'zod'
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
@@ -12,10 +13,12 @@ import { Button } from './ui/button'
import { useToast } from './ui/use-toast'
import { useFundSlug } from '../utils/use-fund-slug'
import Spinner from './Spinner'
import { env } from '../env.mjs'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
turnstileToken: z.string().min(1),
})
type LoginFormInputs = z.infer<typeof schema>
@@ -50,6 +53,7 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
redirect: false,
email: data.email,
password: data.password,
turnstileToken: data.turnstileToken,
})
if (result?.error) {
@@ -122,6 +126,13 @@ function LoginFormModal({ close, openPasswordResetModal, openRegisterModal }: Pr
</Button>
</div>
<Turnstile
siteKey={env.NEXT_PUBLIC_TURNSTILE_SITEKEY}
onError={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onExpire={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onSuccess={(token) => form.setValue('turnstileToken', token, { shouldValidate: true })}
/>
<div className="flex flex-row space-x-2">
<Button
className="grow basis-0"

View File

@@ -1,16 +1,19 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Turnstile } from '@marsidev/react-turnstile'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'
import { Input } from './ui/input'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form'
import { Button } from './ui/button'
import { useToast } from './ui/use-toast'
import { trpc } from '../utils/trpc'
import Spinner from './Spinner'
import { env } from '../env.mjs'
const schema = z.object({
turnstileToken: z.string().min(1),
email: z.string().email(),
})
@@ -60,6 +63,13 @@ function PasswordResetFormModal({ close }: Props) {
)}
/>
<Turnstile
siteKey={env.NEXT_PUBLIC_TURNSTILE_SITEKEY}
onError={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onExpire={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onSuccess={(token) => form.setValue('turnstileToken', token, { shouldValidate: true })}
/>
<Button type="submit" disabled={!form.formState.isValid || form.formState.isSubmitting}>
{form.formState.isSubmitting && <Spinner />} Reset Password
</Button>

View File

@@ -39,9 +39,12 @@ import {
} from './ui/command'
import { cn } from '../utils/cn'
import { Checkbox } from './ui/checkbox'
import { Turnstile } from '@marsidev/react-turnstile'
import { env } from '../env.mjs'
const schema = z
.object({
turnstileToken: z.string().min(1),
firstName: z
.string()
.trim()
@@ -525,6 +528,13 @@ function RegisterFormModal({ close, openLoginModal }: Props) {
</>
)}
<Turnstile
siteKey={env.NEXT_PUBLIC_TURNSTILE_SITEKEY}
onError={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onExpire={() => form.setValue('turnstileToken', '', { shouldValidate: true })}
onSuccess={(token) => form.setValue('turnstileToken', token, { shouldValidate: true })}
/>
<div className="flex flex-row space-x-2">
<Button
type="button"

View File

@@ -40,6 +40,8 @@ services:
NEXTAUTH_URL_INTERNAL: http://localhost:3000
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
USER_SETTINGS_JWT_SECRET: ${USER_SETTINGS_JWT_SECRET}
TURNSTILE_SECRET: ${TURNSTILE_SECRET}
NEXT_PUBLIC_TURNSTILE_SITEKEY: ${NEXT_PUBLIC_TURNSTILE_SITEKEY}
STRAPI_API_URL: ${STRAPI_API_URL}
STRAPI_API_TOKEN: ${STRAPI_API_TOKEN}

View File

@@ -13,6 +13,8 @@ export const env = createEnv({
NEXTAUTH_SECRET: z.string().min(32),
USER_SETTINGS_JWT_SECRET: z.string().min(32),
TURNSTILE_SECRET: z.string().min(1),
STRAPI_API_URL: z.string().url(),
STRAPI_API_TOKEN: z.string().length(256),
STRAPI_CDN_HOST: z.string().min(1).optional(),
@@ -64,6 +66,7 @@ export const env = createEnv({
NEXT_PUBLIC_FIRO_APPLICATION_RECIPIENT: z.string().email(),
NEXT_PUBLIC_PRIVACY_GUIDES_APPLICATION_RECIPIENT: z.string().email(),
NEXT_PUBLIC_GENERAL_APPLICATION_RECIPIENT: z.string().email(),
NEXT_PUBLIC_TURNSTILE_SITEKEY: z.string().min(1),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
@@ -77,6 +80,8 @@ export const env = createEnv({
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
USER_SETTINGS_JWT_SECRET: process.env.USER_SETTINGS_JWT_SECRET,
TURNSTILE_SECRET: process.env.TURNSTILE_SECRET,
NEXT_PUBLIC_STRAPI_URL: process.env.NEXT_PUBLIC_STRAPI_URL,
STRAPI_API_URL: process.env.STRAPI_API_URL,
STRAPI_API_TOKEN: process.env.STRAPI_API_TOKEN,
@@ -117,6 +122,8 @@ export const env = createEnv({
PRINTFUL_API_KEY: process.env.PRINTFUL_API_KEY,
NEXT_PUBLIC_TURNSTILE_SITEKEY: process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY,
NEXT_PUBLIC_MONERO_APPLICATION_RECIPIENT: process.env.NEXT_PUBLIC_MONERO_APPLICATION_RECIPIENT,
NEXT_PUBLIC_FIRO_APPLICATION_RECIPIENT: process.env.NEXT_PUBLIC_FIRO_APPLICATION_RECIPIENT,
NEXT_PUBLIC_PRIVACY_GUIDES_APPLICATION_RECIPIENT:

11
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@hookform/resolvers": "^3.6.0",
"@keycloak/keycloak-admin-client": "^24.0.5",
"@marsidev/react-turnstile": "^1.1.0",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",
@@ -2777,6 +2778,16 @@
"node": ">=18"
}
},
"node_modules/@marsidev/react-turnstile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.1.0.tgz",
"integrity": "sha512-X7bP9ZYutDd+E+klPYF+/BJHqEyyVkN4KKmZcNRr84zs3DcMoftlMAuoKqNSnqg0HE7NQ1844+TLFSJoztCdSA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",

View File

@@ -16,6 +16,7 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@hookform/resolvers": "^3.6.0",
"@keycloak/keycloak-admin-client": "^24.0.5",
"@marsidev/react-turnstile": "^1.1.0",
"@prisma/client": "^5.15.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.2",

View File

@@ -6,6 +6,7 @@ import axios from 'axios'
import { env } from '../../../env.mjs'
import { KeycloakJwtPayload } from '../../../server/types'
import { refreshToken } from '../../../server/utils/auth'
import { isTurnstileValid } from '../../../server/utils/turnstile'
export const authOptions: AuthOptions = {
callbacks: {
@@ -47,8 +48,13 @@ export const authOptions: AuthOptions = {
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
turnstileToken: { label: 'Turnstile token', type: 'password' },
},
authorize: async (credentials) => {
if (await isTurnstileValid(credentials?.turnstileToken || '')) {
throw new Error('INVALID_TURNSTILE_TOKEN')
}
try {
const { data: keycloakToken } = await axios.post(
`${env.KEYCLOAK_URL}/realms/${env.KEYCLOAK_REALM_NAME}/protocol/openid-connect/token`,

View File

@@ -8,12 +8,14 @@ import { keycloak, transporter } from '../services'
import { env } from '../../env.mjs'
import { fundSlugs } from '../../utils/funds'
import { UserSettingsJwtPayload } from '../types'
import { isTurnstileValid } from '../utils/turnstile'
export const authRouter = router({
register: publicProcedure
.input(
z
.object({
turnstileToken: z.string().min(1),
firstName: z
.string()
.trim()
@@ -91,6 +93,10 @@ export const authRouter = router({
})
)
.mutation(async ({ input }) => {
if (await isTurnstileValid(input.turnstileToken)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'INVALID_TURNSTILE_TOKEN' })
}
await authenticateKeycloakClient()
let user: { id: string }
@@ -202,8 +208,12 @@ export const authRouter = router({
}),
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.input(z.object({ turnstileToken: z.string().min(1), email: z.string().email() }))
.mutation(async ({ input }) => {
if (await isTurnstileValid(input.turnstileToken)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'INVALID_TURNSTILE_TOKEN' })
}
await authenticateKeycloakClient()
const users = await keycloak.users.find({ email: input.email })
const user = users[0]

11
server/utils/turnstile.ts Normal file
View File

@@ -0,0 +1,11 @@
import axios from 'axios'
import { env } from '../../env.mjs'
export async function isTurnstileValid(token: string) {
const { data: turnstileResult } = await axios.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{ response: token, secret: env.TURNSTILE_SECRET }
)
return turnstileResult.success
}