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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import EmailField from "@/components/custom/signup/EmailField"
import Turnstile from "@/components/custom/Turnstile"
declare global {
interface Window {
onTurnstileSuccess?: (token: string) => void
onloadTurnstileCallback?: () => void
}
}
import Altcha from "@/components/custom/Altcha"
export default function Signup() {
const router = useRouter()
@ -40,10 +33,11 @@ export default function Signup() {
const [isValid, setIsValid] = useState(false)
const [validationMessage, setValidationMessage] = useState("")
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 [errorAlert, setErrorAlert] = useState<string | null>(null)
const [forceRefresh, setForceRefresh] = useState(false)
const [altchaToken, setAltchaToken] = useState<string | null>(null)
const fadeInOut = {
initial: { opacity: 0, y: 20 },
@ -69,17 +63,15 @@ export default function Signup() {
}
}
const turnstileCallback = () => {
console.log("[i] Turnstile token received")
}
useEffect(() => {
window.onTurnstileSuccess = turnstileCallback
return () => {
delete window.onTurnstileSuccess
const handleAltchaStateChange = (e: Event | CustomEvent) => {
if ('detail' in e && e.detail?.payload) {
setAltchaToken(e.detail.payload)
setAltchaStatus("success")
} else {
setAltchaToken(null)
setAltchaStatus("required")
}
}, [])
}
useEffect(() => {
if (formType === "create") {
@ -110,7 +102,7 @@ export default function Signup() {
return
}
if (turnstileStatus !== "success") {
if (altchaStatus !== "success") {
setIsValid(false)
setValidationMessage("Please verify you are not a robot")
return
@ -146,7 +138,7 @@ export default function Signup() {
return
}
if (turnstileStatus !== "success") {
if (altchaStatus !== "success") {
setIsValid(false)
setValidationMessage("Please verify you are not a robot")
return
@ -155,7 +147,7 @@ export default function Signup() {
setIsValid(true)
setValidationMessage("Migrate Account")
}
}, [formData, formType, turnstileStatus])
}, [formData, formType, altchaStatus])
const getButtonIcon = () => {
if (isValid) return <CheckCircle2 size={30} />
@ -173,7 +165,7 @@ export default function Signup() {
setErrorAlert(null)
try {
if (turnstileStatus !== "success") {
if (altchaStatus !== "success") {
setValidationMessage("Please verify you are not a robot")
setIsSubmitting(false)
return
@ -181,10 +173,10 @@ export default function Signup() {
const email = `${formData.emailUsername}@${formData.emailDomain}`
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) {
setErrorAlert("Cloudflare Turnstile token is missing. Please refresh")
setErrorAlert("Altcha token is missing. Please refresh")
setIsSubmitting(false)
setForceRefresh(true)
return
@ -246,11 +238,11 @@ export default function Signup() {
<AnimatePresence mode="wait">
{formType === "initial" && (
<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" />
Create New Account
</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" />
Migrate p0ntus mail Account
</Button>
@ -319,10 +311,17 @@ export default function Signup() {
</Label>
</div>
{!forceRefresh && (
<Turnstile
setTurnstileStatus={setTurnstileStatus}
setValidationMessage={setValidationMessage}
/>
<input type="hidden" name="altcha-token" value={altchaToken ?? ""} />
)}
{!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>
)}
@ -389,10 +388,17 @@ export default function Signup() {
</Label>
</div>
{!forceRefresh && (
<Turnstile
setTurnstileStatus={setTurnstileStatus}
setValidationMessage={setValidationMessage}
/>
<input type="hidden" name="altcha-token" value={altchaToken ?? ""} />
)}
{!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>
)}
@ -405,7 +411,7 @@ export default function Signup() {
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
<Button
type="submit"
className="w-full mb-4"
className="w-full mb-4 cursor-pointer"
disabled={!isValid || isSubmitting}
onClick={handleSubmit}
>
@ -416,7 +422,7 @@ export default function Signup() {
)}
{isSubmitting ? "Submitting..." : validationMessage}
</Button>
<Button variant="outline" className="w-full" onClick={() => setFormType("initial")}>
<Button variant="outline" className="w-full cursor-pointer" onClick={() => setFormType("initial")}>
Back
</Button>
</motion.div>
@ -428,7 +434,7 @@ export default function Signup() {
)
) : (
<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} />
Back
</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)
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 })
}

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 { twMerge } from "tailwind-merge"
import { validateTurnstileToken } from "next-turnstile"
import { verifySolution } from "altcha-lib"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@ -9,29 +9,25 @@ export function cn(...inputs: ClassValue[]) {
export async function validateToken(token: string) {
try {
if (!token) {
console.error("Validation failed: No token provided")
console.error("Altcha error: No token provided")
return { success: false, error: "No token provided" }
}
if (!process.env.CF_SECRETKEY) {
console.error("Validation failed: Missing CF_SECRETKEY environment variable")
if (!process.env.ALTCHA_SECRETKEY) {
console.error("Altcha error: Missing ALTCHA_SECRETKEY environment variable")
return { success: false, error: "Server configuration error" }
}
const result = await validateTurnstileToken({
token,
secretKey: process.env.CF_SECRETKEY,
})
if (result.success) {
const ok = await verifySolution(token, process.env.ALTCHA_SECRETKEY)
if (ok) {
return { success: true }
} else {
console.error("Validation failed:", result)
console.error("Altcha error: Invalid token")
return { success: false, error: "Invalid token" }
}
} catch (error) {
console.error("Turnstile validation error:", error)
return { success: false, error: "Validation service error" }
console.error("Altcha error:", 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-tooltip": "^1.2.0",
"@web3icons/react": "^4.0.13",
"altcha": "^1.4.2",
"altcha-lib": "^1.2.0",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -38,7 +40,6 @@
"next": "^15.3.0",
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.4.6",
"next-turnstile": "^1.0.2",
"password-validator": "^5.3.0",
"prisma": "^6.6.0",
"react": "^19.1.0",