Skip to content

feat(platform): Add captcha to login, signup and password reset pages #9847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
May 7, 2025
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7b65f63
Add captcha to login, signup and password reset pages
Bentlybro Apr 21, 2025
ecc9ba7
add secrets config
Bentlybro Apr 21, 2025
e79a103
fixes
Bentlybro Apr 21, 2025
31b0483
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro Apr 21, 2025
50c5ba4
Prettier
Bentlybro Apr 21, 2025
df3a579
format
Bentlybro Apr 21, 2025
1de0bcf
add missing parameters
Bentlybro Apr 21, 2025
b2f2873
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro Apr 23, 2025
ac102d1
Update autogpt_platform/backend/backend/server/v2/turnstile/models.py
Bentlybro Apr 28, 2025
4f98b5d
add turnstile_site_key to settings.py + rename secret_key to turnsti…
Bentlybro Apr 28, 2025
140df10
Update settings.py
Bentlybro Apr 28, 2025
797a383
add new line to ``.env.example``
Bentlybro Apr 28, 2025
2ca030e
make turnstileToken required
Bentlybro Apr 28, 2025
b8b9a9e
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro Apr 28, 2025
cca20e1
prettier
Bentlybro Apr 28, 2025
4ac6409
fix "Type 'undefined' is not assignable to type 'string'."
Bentlybro Apr 28, 2025
2cbed54
format
Bentlybro Apr 28, 2025
5c14e55
skip captcha if not BehaveAs = CLOUD
Bentlybro Apr 30, 2025
3467dd1
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro Apr 30, 2025
7a9d249
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 1, 2025
1b4451f
remove turnstile_site_key from settings.py
Bentlybro May 2, 2025
f018c02
add CLOUDFLARE_TURNSTILE_VERIFY_URL to backend ``.env``
Bentlybro May 2, 2025
b97a1d6
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 2, 2025
40029ba
updates
Bentlybro May 3, 2025
9dea8ab
Merge branch 'bently/secrt-1169-implement-captcha-on-sign-up' of http…
Bentlybro May 3, 2025
2da4bd5
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 3, 2025
29689d6
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 6, 2025
dd90903
Add system to reset turnstile token on failed login/register/password…
Bentlybro May 6, 2025
8449e28
prettier
Bentlybro May 6, 2025
1f4a72d
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 7, 2025
54a1a04
fix not peoperly refreshing on failed login ect
Bentlybro May 7, 2025
e08889e
Merge branch 'dev' into bently/secrt-1169-implement-captcha-on-sign-up
Bentlybro May 7, 2025
ab46474
prettier
Bentlybro May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions autogpt_platform/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ MEDIA_GCS_BUCKET_NAME=
## and tunnel it to your locally running backend.
PLATFORM_BASE_URL=http://localhost:3000

## Cloudflare Turnstile (CAPTCHA) Configuration
## Get these from the Cloudflare Turnstile dashboard: https://dash.cloudflare.com/?to=/:account/turnstile
## This is the backend secret key
TURNSTILE_SECRET_KEY=
## This is the verify URL
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify

## == INTEGRATION CREDENTIALS == ##
# Each set of server side credentials is required for the corresponding 3rd party
# integration to work.
Expand Down
4 changes: 4 additions & 0 deletions autogpt_platform/backend/backend/server/rest_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import backend.server.v2.otto.routes
import backend.server.v2.store.model
import backend.server.v2.store.routes
import backend.server.v2.turnstile.routes
import backend.util.service
import backend.util.settings
from backend.blocks.llm import LlmModel
Expand Down Expand Up @@ -119,6 +120,9 @@ def handler(request: fastapi.Request, exc: Exception):
app.include_router(
backend.server.v2.otto.routes.router, tags=["v2"], prefix="/api/otto"
)
app.include_router(
backend.server.v2.turnstile.routes.router, tags=["v2"], prefix="/api/turnstile"
)

app.include_router(
backend.server.routers.postmark.postmark.router,
Expand Down
Empty file.
30 changes: 30 additions & 0 deletions autogpt_platform/backend/backend/server/v2/turnstile/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional

from pydantic import BaseModel, Field


class TurnstileVerifyRequest(BaseModel):
"""Request model for verifying a Turnstile token."""

token: str = Field(description="The Turnstile token to verify")
action: Optional[str] = Field(
default=None, description="The action that the user is attempting to perform"
)


class TurnstileVerifyResponse(BaseModel):
"""Response model for the Turnstile verification endpoint."""

success: bool = Field(description="Whether the token verification was successful")
error: Optional[str] = Field(
default=None, description="Error message if verification failed"
)
challenge_timestamp: Optional[str] = Field(
default=None, description="Timestamp of the challenge (ISO format)"
)
hostname: Optional[str] = Field(
default=None, description="Hostname of the site where the challenge was solved"
)
action: Optional[str] = Field(
default=None, description="The action associated with this verification"
)
108 changes: 108 additions & 0 deletions autogpt_platform/backend/backend/server/v2/turnstile/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import logging

import aiohttp
from fastapi import APIRouter

from backend.util.settings import Settings

from .models import TurnstileVerifyRequest, TurnstileVerifyResponse

logger = logging.getLogger(__name__)

router = APIRouter()
settings = Settings()


@router.post("/verify", response_model=TurnstileVerifyResponse)
async def verify_turnstile_token(
request: TurnstileVerifyRequest,
) -> TurnstileVerifyResponse:
"""
Verify a Cloudflare Turnstile token.
This endpoint verifies a token returned by the Cloudflare Turnstile challenge
on the client side. It returns whether the verification was successful.
"""
logger.info(f"Verifying Turnstile token for action: {request.action}")
return await verify_token(request)


async def verify_token(request: TurnstileVerifyRequest) -> TurnstileVerifyResponse:
"""
Verify a Cloudflare Turnstile token by making a request to the Cloudflare API.
"""
# Get the secret key from settings
turnstile_secret_key = settings.secrets.turnstile_secret_key
turnstile_verify_url = settings.secrets.turnstile_verify_url

if not turnstile_secret_key:
logger.error("Turnstile secret key is not configured")
return TurnstileVerifyResponse(
success=False,
error="CONFIGURATION_ERROR",
challenge_timestamp=None,
hostname=None,
action=None,
)

try:
async with aiohttp.ClientSession() as session:
payload = {
"secret": turnstile_secret_key,
"response": request.token,
}

if request.action:
payload["action"] = request.action

logger.debug(f"Verifying Turnstile token with action: {request.action}")

async with session.post(
turnstile_verify_url,
data=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"Turnstile API error: {error_text}")
return TurnstileVerifyResponse(
success=False,
error=f"API_ERROR: {response.status}",
challenge_timestamp=None,
hostname=None,
action=None,
)

data = await response.json()
logger.debug(f"Turnstile API response: {data}")

# Parse the response and return a structured object
return TurnstileVerifyResponse(
success=data.get("success", False),
error=(
data.get("error-codes", None)[0]
if data.get("error-codes")
else None
),
challenge_timestamp=data.get("challenge_timestamp"),
hostname=data.get("hostname"),
action=data.get("action"),
)

except aiohttp.ClientError as e:
logger.error(f"Connection error to Turnstile API: {str(e)}")
return TurnstileVerifyResponse(
success=False,
error=f"CONNECTION_ERROR: {str(e)}",
challenge_timestamp=None,
hostname=None,
action=None,
)
except Exception as e:
logger.error(f"Unexpected error in Turnstile verification: {str(e)}")
return TurnstileVerifyResponse(
success=False,
error=f"UNEXPECTED_ERROR: {str(e)}",
challenge_timestamp=None,
hostname=None,
action=None,
)
10 changes: 10 additions & 0 deletions autogpt_platform/backend/backend/util/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
description="The secret key to use for the unsubscribe user by token",
)

# Cloudflare Turnstile credentials
turnstile_secret_key: str = Field(
default="",
description="Cloudflare Turnstile backend secret key",
)
turnstile_verify_url: str = Field(
default="https://challenges.cloudflare.com/turnstile/v0/siteverify",
description="Cloudflare Turnstile verify URL",
)

# OAuth server credentials for integrations
# --8<-- [start:OAuthServerCredentialsExample]
github_client_id: str = Field(default="", description="GitHub OAuth client ID")
Expand Down
5 changes: 5 additions & 0 deletions autogpt_platform/frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ GA_MEASUREMENT_ID=G-FH2XK2W4GN
# When running locally, set NEXT_PUBLIC_BEHAVE_AS=CLOUD to use the a locally hosted marketplace (as is typical in development, and the cloud deployment), otherwise set it to LOCAL to have the marketplace open in a new tab
NEXT_PUBLIC_BEHAVE_AS=LOCAL
NEXT_PUBLIC_SHOW_BILLING_PAGE=false

## Cloudflare Turnstile (CAPTCHA) Configuration
## Get these from the Cloudflare Turnstile dashboard: https://dash.cloudflare.com/?to=/:account/turnstile
## This is the frontend site key
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=
12 changes: 11 additions & 1 deletion autogpt_platform/frontend/src/app/(platform)/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Sentry from "@sentry/nextjs";
import getServerSupabase from "@/lib/supabase/getServerSupabase";
import BackendAPI from "@/lib/autogpt-server-api";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { verifyTurnstileToken } from "@/lib/turnstile";

export async function logout() {
return await Sentry.withServerActionInstrumentation(
Expand Down Expand Up @@ -39,7 +40,10 @@ async function shouldShowOnboarding() {
);
}

export async function login(values: z.infer<typeof loginFormSchema>) {
export async function login(
values: z.infer<typeof loginFormSchema>,
turnstileToken: string,
) {
return await Sentry.withServerActionInstrumentation("login", {}, async () => {
const supabase = getServerSupabase();
const api = new BackendAPI();
Expand All @@ -48,6 +52,12 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
redirect("/error");
}

// Verify Turnstile token if provided
const success = await verifyTurnstileToken(turnstileToken, "login");
if (!success) {
return "CAPTCHA verification failed. Please try again.";
}

// We are sure that the values are of the correct type because zod validates the form
const { data, error } = await supabase.auth.signInWithPassword(values);

Expand Down
30 changes: 28 additions & 2 deletions autogpt_platform/frontend/src/app/(platform)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,23 @@ import {
AuthFeedback,
AuthBottomText,
PasswordInput,
Turnstile,
} from "@/components/auth";
import { loginFormSchema } from "@/types/auth";
import { getBehaveAs } from "@/lib/utils";
import { useTurnstile } from "@/hooks/useTurnstile";

export default function LoginPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);

const turnstile = useTurnstile({
action: "login",
autoVerify: false,
});

const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
Expand Down Expand Up @@ -65,15 +72,23 @@ export default function LoginPage() {
return;
}

const error = await login(data);
if (!turnstile.verified) {
setFeedback("Please complete the CAPTCHA challenge.");
setIsLoading(false);
return;
}

const error = await login(data, turnstile.token as string);
setIsLoading(false);
if (error) {
setFeedback(error);
// Reset Turnstile if there was an error to get a fresh token
turnstile.reset();
return;
}
setFeedback(null);
},
[form],
[form, turnstile],
);

if (user) {
Expand Down Expand Up @@ -140,6 +155,17 @@ export default function LoginPage() {
</FormItem>
)}
/>

{/* Turnstile CAPTCHA Component */}
<Turnstile
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
action="login"
shouldRender={turnstile.shouldRender}
/>

<AuthButton
onClick={() => onLogin(form.getValues())}
isLoading={isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import getServerSupabase from "@/lib/supabase/getServerSupabase";
import { redirect } from "next/navigation";
import * as Sentry from "@sentry/nextjs";
import { headers } from "next/headers";
import { verifyTurnstileToken } from "@/lib/turnstile";

export async function sendResetEmail(email: string) {
export async function sendResetEmail(email: string, turnstileToken: string) {
return await Sentry.withServerActionInstrumentation(
"sendResetEmail",
{},
Expand All @@ -20,6 +21,15 @@ export async function sendResetEmail(email: string) {
redirect("/error");
}

// Verify Turnstile token if provided
const success = await verifyTurnstileToken(
turnstileToken,
"reset_password",
);
if (!success) {
return "CAPTCHA verification failed. Please try again.";
}

const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/reset_password`,
});
Expand All @@ -34,7 +44,7 @@ export async function sendResetEmail(email: string) {
);
}

export async function changePassword(password: string) {
export async function changePassword(password: string, turnstileToken: string) {
return await Sentry.withServerActionInstrumentation(
"changePassword",
{},
Expand All @@ -45,6 +55,15 @@ export async function changePassword(password: string) {
redirect("/error");
}

// Verify Turnstile token if provided
const success = await verifyTurnstileToken(
turnstileToken,
"change_password",
);
if (!success) {
return "CAPTCHA verification failed. Please try again.";
}

const { error } = await supabase.auth.updateUser({ password });

if (error) {
Expand Down
Loading
Loading