feat: replace turnstile with altcha

This commit is contained in:
Aidan 2025-04-16 16:54:52 -04:00
parent b38347f6d9
commit 17acf5085d
7 changed files with 135 additions and 89 deletions

View File

@ -15,14 +15,7 @@ import { useRouter } from "next/navigation"
import { validateEmail, validatePassword } from "@/lib/utils" import { validateEmail, validatePassword } from "@/lib/utils"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import EmailField from "@/components/custom/signup/EmailField" import EmailField from "@/components/custom/signup/EmailField"
import Turnstile from "@/components/custom/Turnstile" import Altcha from "@/components/custom/Altcha"
declare global {
interface Window {
onTurnstileSuccess?: (token: string) => void
onloadTurnstileCallback?: () => void
}
}
export default function Signup() { export default function Signup() {
const router = useRouter() const router = useRouter()
@ -40,10 +33,11 @@ export default function Signup() {
const [isValid, setIsValid] = useState(false) const [isValid, setIsValid] = useState(false)
const [validationMessage, setValidationMessage] = useState("") const [validationMessage, setValidationMessage] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [turnstileStatus, setTurnstileStatus] = useState<"success" | "error" | "expired" | "required">("required") const [altchaStatus, setAltchaStatus] = useState<"success" | "error" | "expired" | "required">("required")
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [errorAlert, setErrorAlert] = useState<string | null>(null) const [errorAlert, setErrorAlert] = useState<string | null>(null)
const [forceRefresh, setForceRefresh] = useState(false) const [forceRefresh, setForceRefresh] = useState(false)
const [altchaToken, setAltchaToken] = useState<string | null>(null)
const fadeInOut = { const fadeInOut = {
initial: { opacity: 0, y: 20 }, initial: { opacity: 0, y: 20 },
@ -69,17 +63,15 @@ export default function Signup() {
} }
} }
const turnstileCallback = () => { const handleAltchaStateChange = (e: Event | CustomEvent) => {
console.log("[i] Turnstile token received") if ('detail' in e && e.detail?.payload) {
} setAltchaToken(e.detail.payload)
setAltchaStatus("success")
useEffect(() => { } else {
window.onTurnstileSuccess = turnstileCallback setAltchaToken(null)
setAltchaStatus("required")
return () => {
delete window.onTurnstileSuccess
} }
}, []) }
useEffect(() => { useEffect(() => {
if (formType === "create") { if (formType === "create") {
@ -110,7 +102,7 @@ export default function Signup() {
return return
} }
if (turnstileStatus !== "success") { if (altchaStatus !== "success") {
setIsValid(false) setIsValid(false)
setValidationMessage("Please verify you are not a robot") setValidationMessage("Please verify you are not a robot")
return return
@ -146,7 +138,7 @@ export default function Signup() {
return return
} }
if (turnstileStatus !== "success") { if (altchaStatus !== "success") {
setIsValid(false) setIsValid(false)
setValidationMessage("Please verify you are not a robot") setValidationMessage("Please verify you are not a robot")
return return
@ -155,7 +147,7 @@ export default function Signup() {
setIsValid(true) setIsValid(true)
setValidationMessage("Migrate Account") setValidationMessage("Migrate Account")
} }
}, [formData, formType, turnstileStatus]) }, [formData, formType, altchaStatus])
const getButtonIcon = () => { const getButtonIcon = () => {
if (isValid) return <CheckCircle2 size={30} /> if (isValid) return <CheckCircle2 size={30} />
@ -173,7 +165,7 @@ export default function Signup() {
setErrorAlert(null) setErrorAlert(null)
try { try {
if (turnstileStatus !== "success") { if (altchaStatus !== "success") {
setValidationMessage("Please verify you are not a robot") setValidationMessage("Please verify you are not a robot")
setIsSubmitting(false) setIsSubmitting(false)
return return
@ -181,10 +173,10 @@ export default function Signup() {
const email = `${formData.emailUsername}@${formData.emailDomain}` const email = `${formData.emailUsername}@${formData.emailDomain}`
const formDataObj = new FormData(formRef.current as HTMLFormElement) const formDataObj = new FormData(formRef.current as HTMLFormElement)
const token = formDataObj.get("cf-turnstile-response") as string const token = formDataObj.get("altcha-token") as string
if (!token) { if (!token) {
setErrorAlert("Cloudflare Turnstile token is missing. Please refresh") setErrorAlert("Altcha token is missing. Please refresh")
setIsSubmitting(false) setIsSubmitting(false)
setForceRefresh(true) setForceRefresh(true)
return return
@ -246,11 +238,11 @@ export default function Signup() {
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{formType === "initial" && ( {formType === "initial" && (
<motion.div key="initial" {...fadeInOut} className="space-y-4"> <motion.div key="initial" {...fadeInOut} className="space-y-4">
<Button onClick={() => setFormType("create")} className="w-full h-16 text-lg"> <Button onClick={() => setFormType("create")} className="w-full h-16 text-lg cursor-pointer">
<UserPlus className="mr-2" /> <UserPlus className="mr-2" />
Create New Account Create New Account
</Button> </Button>
<Button onClick={() => setFormType("migrate")} className="w-full h-16 text-lg"> <Button onClick={() => setFormType("migrate")} className="w-full h-16 text-lg cursor-pointer">
<UserCog className="mr-2" /> <UserCog className="mr-2" />
Migrate p0ntus mail Account Migrate p0ntus mail Account
</Button> </Button>
@ -319,10 +311,17 @@ export default function Signup() {
</Label> </Label>
</div> </div>
{!forceRefresh && ( {!forceRefresh && (
<Turnstile <input type="hidden" name="altcha-token" value={altchaToken ?? ""} />
setTurnstileStatus={setTurnstileStatus} )}
setValidationMessage={setValidationMessage} {!forceRefresh && (
/> <>
<div id="altcha-description" className="sr-only">
A CAPTCHA box. You must solve the challenge to make an account.
</div>
<Altcha
onStateChange={handleAltchaStateChange}
/>
</>
)} )}
</motion.form> </motion.form>
)} )}
@ -389,10 +388,17 @@ export default function Signup() {
</Label> </Label>
</div> </div>
{!forceRefresh && ( {!forceRefresh && (
<Turnstile <input type="hidden" name="altcha-token" value={altchaToken ?? ""} />
setTurnstileStatus={setTurnstileStatus} )}
setValidationMessage={setValidationMessage} {!forceRefresh && (
/> <>
<div id="altcha-description" className="sr-only">
A CAPTCHA box. You must solve the challenge to make an account.
</div>
<Altcha
onStateChange={handleAltchaStateChange}
/>
</>
)} )}
</motion.form> </motion.form>
)} )}
@ -405,7 +411,7 @@ export default function Signup() {
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2"> <motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
<Button <Button
type="submit" type="submit"
className="w-full mb-4" className="w-full mb-4 cursor-pointer"
disabled={!isValid || isSubmitting} disabled={!isValid || isSubmitting}
onClick={handleSubmit} onClick={handleSubmit}
> >
@ -416,7 +422,7 @@ export default function Signup() {
)} )}
{isSubmitting ? "Submitting..." : validationMessage} {isSubmitting ? "Submitting..." : validationMessage}
</Button> </Button>
<Button variant="outline" className="w-full" onClick={() => setFormType("initial")}> <Button variant="outline" className="w-full cursor-pointer" onClick={() => setFormType("initial")}>
Back Back
</Button> </Button>
</motion.div> </motion.div>
@ -428,7 +434,7 @@ export default function Signup() {
) )
) : ( ) : (
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2"> <motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
<Button className="w-full" onClick={() => { setFormType("initial"); setForceRefresh(false); setErrorAlert(null) }}> <Button className="w-full cursor-pointer" onClick={() => { setFormType("initial"); setForceRefresh(false); setErrorAlert(null) }}>
<ArrowLeft size={30} /> <ArrowLeft size={30} />
Back Back
</Button> </Button>

View File

@ -0,0 +1,22 @@
import { createChallenge } from 'altcha-lib'
import { NextResponse } from 'next/server'
const hmacKey = process.env.ALTCHA_SECRETKEY
async function getChallenge() {
if (!hmacKey) {
console.error("ALTCHA_SECRETKEY is not set")
return NextResponse.json({ error: "ALTCHA_SECRETKEY is not set" }, { status: 500 })
}
const challenge = await createChallenge({
hmacKey,
maxNumber: 1200000, // Max random number
})
return NextResponse.json(challenge)
}
export async function GET(request: Request) {
return getChallenge()
}

View File

@ -61,7 +61,7 @@ export async function POST(request: Request) {
const tokenValidation = await validateToken(token) const tokenValidation = await validateToken(token)
if (!tokenValidation.success) { if (!tokenValidation.success) {
console.error("Turnstile validation failed:", tokenValidation.error) console.error("Altcha validation failed:", tokenValidation.error)
return NextResponse.json({ success: false, message: "Robot check failed, try refreshing" }, { status: 400 }) return NextResponse.json({ success: false, message: "Robot check failed, try refreshing" }, { status: 400 })
} }

View File

@ -0,0 +1,57 @@
"use client"
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
interface AltchaProps {
onStateChange?: (ev: Event | CustomEvent) => void
}
const Altcha = forwardRef<{ value: string | null }, AltchaProps>(({ onStateChange }, ref) => {
const widgetRef = useRef<AltchaWidget & AltchaWidgetMethods & HTMLElement>(null)
const [value, setValue] = useState<string | null>(null)
useEffect(() => {
import('altcha')
}, [])
useImperativeHandle(ref, () => {
return {
get value() {
return value
}
}
}, [value])
useEffect(() => {
const handleStateChange = (ev: Event | CustomEvent) => {
if ('detail' in ev) {
setValue(ev.detail.payload || null)
onStateChange?.(ev)
}
}
const { current } = widgetRef
if (current) {
current.addEventListener('statechange', handleStateChange)
return () => current.removeEventListener('statechange', handleStateChange)
}
}, [onStateChange])
return (
<altcha-widget
challengeurl="/api/captcha/create"
ref={widgetRef}
style={{
'--altcha-max-width': '100%',
}}
debug={process.env.NODE_ENV === "development"}
aria-label="Security verification"
aria-describedby="altcha-description"
></altcha-widget>
)
})
Altcha.displayName = 'Altcha'
export default Altcha

View File

@ -1,36 +0,0 @@
import type React from "react"
import { Turnstile as CloudflareTS } from "next-turnstile"
interface TurnstileProps {
setTurnstileStatus: React.Dispatch<React.SetStateAction<"error" | "expired" | "required" | "success">>
setValidationMessage: (message: string) => void
}
export default function Turnstile({ setTurnstileStatus, setValidationMessage }: TurnstileProps) {
return (
<CloudflareTS
siteKey={process.env.NEXT_PUBLIC_CF_SITEKEY!}
retry="auto"
refreshExpired="auto"
onError={() => {
setTurnstileStatus("error")
setValidationMessage("Security check failed. Please try again.")
console.error("[!] Turnstile error occurred")
}}
onExpire={() => {
setTurnstileStatus("expired")
setValidationMessage("Security check expired. Please verify again.")
console.warn("[!] Turnstile token expired")
}}
onLoad={() => {
setTurnstileStatus("required")
}}
onVerify={() => {
setTurnstileStatus("success")
console.log("[S] Turnstile verification successful")
}}
className="flex justify-center"
/>
)
}

View File

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { validateTurnstileToken } from "next-turnstile" import { verifySolution } from "altcha-lib"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -9,29 +9,25 @@ export function cn(...inputs: ClassValue[]) {
export async function validateToken(token: string) { export async function validateToken(token: string) {
try { try {
if (!token) { if (!token) {
console.error("Validation failed: No token provided") console.error("Altcha error: No token provided")
return { success: false, error: "No token provided" } return { success: false, error: "No token provided" }
} }
if (!process.env.CF_SECRETKEY) { if (!process.env.ALTCHA_SECRETKEY) {
console.error("Validation failed: Missing CF_SECRETKEY environment variable") console.error("Altcha error: Missing ALTCHA_SECRETKEY environment variable")
return { success: false, error: "Server configuration error" } return { success: false, error: "Server configuration error" }
} }
const result = await validateTurnstileToken({ const ok = await verifySolution(token, process.env.ALTCHA_SECRETKEY)
token, if (ok) {
secretKey: process.env.CF_SECRETKEY,
})
if (result.success) {
return { success: true } return { success: true }
} else { } else {
console.error("Validation failed:", result) console.error("Altcha error: Invalid token")
return { success: false, error: "Invalid token" } return { success: false, error: "Invalid token" }
} }
} catch (error) { } catch (error) {
console.error("Turnstile validation error:", error) console.error("Altcha error:", error)
return { success: false, error: "Validation service error" } return { success: false, error: "An error occurred with Altcha" }
} }
} }

View File

@ -25,6 +25,8 @@
"@radix-ui/react-tabs": "^1.1.4", "@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@web3icons/react": "^4.0.13", "@web3icons/react": "^4.0.13",
"altcha": "^1.4.2",
"altcha-lib": "^1.2.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -38,7 +40,6 @@
"next": "^15.3.0", "next": "^15.3.0",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"next-turnstile": "^1.0.2",
"password-validator": "^5.3.0", "password-validator": "^5.3.0",
"prisma": "^6.6.0", "prisma": "^6.6.0",
"react": "^19.1.0", "react": "^19.1.0",