mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-10 06:55:47 +00:00
feat(auth): require a captcha on the endpoints that send mail (#32)
Signup had no bot protection. The account itself was never the prize — GoTrue refuses a session until the address is confirmed, and 0 of the 2 unconfirmed accounts on record ever signed in. The prize is our mail: /signup and /recover send to whatever address the caller types, so a stranger could make pezkuwichain.io mail thousands of people who never asked for it. Sending reputation takes months to rebuild and every product on the domain shares it. Rate limiting alone was not enough. GoTrue defaults to 30 emails/hour, which is 720 a day to attacker-chosen recipients; tightening it to 15 halves the number without changing what is possible. Verification runs inside GoTrue, not here. It POSTs to siteverify with the widget secret before /signup, /recover, /resend, /magiclink, /otp and /token with grant_type=password. Adding our own check in the frontend or a proxy would protect nothing, because /auth/v1/signup is reachable directly. One shared widget in execute mode rather than one per form: some of these are buttons with nowhere to put a checkbox, and interaction-only appearance keeps the challenge invisible unless Cloudflare asks for one. Tokens are single-use, so each call renders fresh and tears down the previous widget. Six call sites, matching the six protected endpoints — password login included, since /token with grant_type=password is enforced. Refresh-token grants are exempt, so open sessions are unaffected. Site key travels as a build arg like the WalletConnect id: it is public by construction, visible in the bundle. Only the secret is privileged and it lives in GoTrue's env on the auth host. Ships ahead of enforcement. Turning captcha on server-side before the frontend sends tokens would break every login and signup in the same instant.
This commit is contained in:
committed by
GitHub
parent
35884cb8c9
commit
c26eeee12c
@@ -87,6 +87,7 @@ jobs:
|
||||
VITE_ASSET_HUB_ENDPOINT: wss://asset-hub-rpc.pezkuwichain.io
|
||||
VITE_PEOPLE_CHAIN_ENDPOINT: wss://people-rpc.pezkuwichain.io
|
||||
VITE_WALLETCONNECT_PROJECT_ID: 8292a793b7640e8364c378e331e76d04
|
||||
VITE_TURNSTILE_SITE_KEY: 0x4AAAAAAEDkUkbsUgw2D9zN
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -208,6 +209,7 @@ jobs:
|
||||
VITE_ASSET_HUB_ENDPOINT=wss://asset-hub-rpc.pezkuwichain.io
|
||||
VITE_PEOPLE_CHAIN_ENDPOINT=wss://people-rpc.pezkuwichain.io
|
||||
VITE_WALLETCONNECT_PROJECT_ID=8292a793b7640e8364c378e331e76d04
|
||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAAEDkUkbsUgw2D9zN
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache
|
||||
|
||||
@@ -26,6 +26,9 @@ ARG VITE_WS_ENDPOINT_FALLBACK_1=wss://mainnet.pezkuwichain.io
|
||||
ARG VITE_ASSET_HUB_ENDPOINT=wss://asset-hub-rpc.pezkuwichain.io
|
||||
ARG VITE_PEOPLE_CHAIN_ENDPOINT=wss://people-rpc.pezkuwichain.io
|
||||
ARG VITE_WALLETCONNECT_PROJECT_ID=8292a793b7640e8364c378e331e76d04
|
||||
# Public by design — the site key is visible in the shipped bundle. The
|
||||
# matching secret lives only in GoTrue's env on the auth host.
|
||||
ARG VITE_TURNSTILE_SITE_KEY=0x4AAAAAAEDkUkbsUgw2D9zN
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
|
||||
@@ -35,6 +38,7 @@ ENV VITE_WS_ENDPOINT_FALLBACK_1=$VITE_WS_ENDPOINT_FALLBACK_1
|
||||
ENV VITE_ASSET_HUB_ENDPOINT=$VITE_ASSET_HUB_ENDPOINT
|
||||
ENV VITE_PEOPLE_CHAIN_ENDPOINT=$VITE_PEOPLE_CHAIN_ENDPOINT
|
||||
ENV VITE_WALLETCONNECT_PROJECT_ID=$VITE_WALLETCONNECT_PROJECT_ID
|
||||
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } fr
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { User } from '@supabase/supabase-js';
|
||||
import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge';
|
||||
import { getCaptchaToken } from '@/lib/captcha';
|
||||
|
||||
// Session timeout configuration
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -287,9 +288,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const signIn = async (email: string, password: string, rememberMe: boolean = false) => {
|
||||
try {
|
||||
// GoTrue enforces the captcha on /token with grant_type=password, so a
|
||||
// password login needs a token like signup does. Refresh-token calls are
|
||||
// exempt, which is why existing sessions keep working on their own.
|
||||
const captchaToken = await getCaptchaToken();
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
options: { captchaToken },
|
||||
});
|
||||
|
||||
if (!error && data.user) {
|
||||
@@ -314,10 +320,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const signUp = async (email: string, password: string, username: string, referralCode?: string) => {
|
||||
try {
|
||||
const captchaToken = await getCaptchaToken();
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
captchaToken,
|
||||
data: {
|
||||
username,
|
||||
referral_code: referralCode || null,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Cloudflare Turnstile token for Supabase Auth calls.
|
||||
*
|
||||
* GoTrue verifies these itself — it POSTs to challenges.cloudflare.com with the
|
||||
* widget secret before /signup, /recover, /resend, /magiclink, /otp and /token
|
||||
* (grant_type=password). There is no siteverify call in this codebase and there
|
||||
* should not be: the only way to protect those endpoints is inside the service
|
||||
* that serves them, since anyone can call /auth/v1/signup directly and skip
|
||||
* whatever the browser was asked to do.
|
||||
*
|
||||
* Why it exists: signup had no bot protection at all. An account is not the
|
||||
* prize — GoTrue refuses a session until the address is confirmed. The prize is
|
||||
* our mail: /signup and /recover send to whatever address the caller types, so
|
||||
* a stranger could make pezkuwichain.io mail thousands of people who never asked.
|
||||
* That burns the sending domain's reputation, and reputation takes months to
|
||||
* rebuild.
|
||||
*
|
||||
* One shared widget in `execution: "execute"` mode rather than one per form.
|
||||
* Some of these are buttons, not forms — "resend confirmation" on the dashboard
|
||||
* has nowhere to put a checkbox — and a challenge that only appears when
|
||||
* Cloudflare asks for one keeps the common case invisible.
|
||||
*/
|
||||
|
||||
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;
|
||||
const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
// A challenge that needs interaction still has to be visible and clickable, so
|
||||
// this is positioned rather than hidden.
|
||||
const CONTAINER_ID = 'pezkuwi-turnstile';
|
||||
|
||||
interface TurnstileApi {
|
||||
render(el: HTMLElement, opts: Record<string, unknown>): string;
|
||||
execute(el: HTMLElement | string, opts?: Record<string, unknown>): void;
|
||||
reset(id?: string): void;
|
||||
remove(id: string): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null;
|
||||
let widgetId: string | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (window.turnstile) return Promise.resolve();
|
||||
if (scriptPromise) return scriptPromise;
|
||||
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
// Let the next attempt retry instead of caching the failure forever.
|
||||
scriptPromise = null;
|
||||
reject(new Error('Turnstile script failed to load'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
function ensureContainer(): HTMLElement {
|
||||
if (container?.isConnected) return container;
|
||||
container = document.createElement('div');
|
||||
container.id = CONTAINER_ID;
|
||||
container.style.position = 'fixed';
|
||||
container.style.bottom = '1rem';
|
||||
container.style.right = '1rem';
|
||||
container.style.zIndex = '2147483647';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fresh Turnstile token, or undefined when no site key is configured.
|
||||
*
|
||||
* Undefined rather than throwing: without a key there is nothing to send, and a
|
||||
* local dev build with captcha disabled server-side should still be able to log
|
||||
* in. Once GoTrue has captcha enabled it rejects the tokenless request itself,
|
||||
* which is the check that matters.
|
||||
*
|
||||
* Tokens are single-use. Every call resets the widget first, so a retry after a
|
||||
* failed submit gets a new token instead of being refused as a duplicate.
|
||||
*/
|
||||
export async function getCaptchaToken(): Promise<string | undefined> {
|
||||
if (!SITE_KEY) return undefined;
|
||||
|
||||
await loadScript();
|
||||
const turnstile = window.turnstile;
|
||||
if (!turnstile) throw new Error('Turnstile unavailable');
|
||||
|
||||
const el = ensureContainer();
|
||||
|
||||
// Render fresh each time and tear the old one down. Calling render() twice on
|
||||
// one container would leave two widgets stacked on the page, and reusing a
|
||||
// widget means its callbacks still close over the previous call's promise.
|
||||
if (widgetId !== null) {
|
||||
try {
|
||||
turnstile.remove(widgetId);
|
||||
} catch {
|
||||
// Already gone (page navigation, script reload) — nothing to clean up.
|
||||
}
|
||||
widgetId = null;
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
widgetId = turnstile.render(el, {
|
||||
sitekey: SITE_KEY,
|
||||
execution: 'execute',
|
||||
appearance: 'interaction-only',
|
||||
action: 'turnstile-spin-v2',
|
||||
callback: (token: string) => resolve(token),
|
||||
'error-callback': () => reject(new Error('Captcha failed')),
|
||||
'timeout-callback': () => reject(new Error('Captcha timed out')),
|
||||
});
|
||||
|
||||
turnstile.execute(el);
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { getAllScores, getStakingScoreStatus, startScoreTracking, getPezRewards,
|
||||
import { getSigner } from '@/lib/get-signer';
|
||||
import { getKycStatus } from '@pezkuwi/lib/kyc';
|
||||
import { ReferralDashboard } from '@/components/referral/ReferralDashboard';
|
||||
import { getCaptchaToken } from '@/lib/captcha';
|
||||
// Commission proposals card removed - no longer using notary system for KYC approval
|
||||
// import { CommissionProposalsCard } from '@/components/dashboard/CommissionProposalsCard';
|
||||
|
||||
@@ -256,6 +257,7 @@ export default function Dashboard() {
|
||||
const { error: resendError } = await supabase.auth.resend({
|
||||
type: 'signup',
|
||||
email: user.email,
|
||||
options: { captchaToken: await getCaptchaToken() },
|
||||
});
|
||||
|
||||
if (resendError) {
|
||||
@@ -272,6 +274,7 @@ export default function Dashboard() {
|
||||
// This will send an email if the account exists
|
||||
const { error: resetError } = await supabase.auth.resetPasswordForEmail(user.email, {
|
||||
redirectTo: `${window.location.origin}/email-verification`,
|
||||
captchaToken: await getCaptchaToken(),
|
||||
});
|
||||
|
||||
if (resetError) throw resetError;
|
||||
@@ -1043,4 +1046,4 @@ export default function Dashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { CheckCircle, XCircle, Loader2, ArrowLeft, Mail, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCaptchaToken } from '@/lib/captcha';
|
||||
|
||||
export default function EmailVerification() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -42,6 +43,7 @@ export default function EmailVerification() {
|
||||
const { error } = await supabase.auth.resend({
|
||||
type: 'signup',
|
||||
email: email,
|
||||
options: { captchaToken: await getCaptchaToken() },
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { supabase } from '@/lib/supabase';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCaptchaToken } from '@/lib/captcha';
|
||||
|
||||
export default function PasswordReset() {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,7 +36,10 @@ export default function PasswordReset() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const redirectTo = `${window.location.origin}/reset-password`;
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { redirectTo });
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo,
|
||||
captchaToken: await getCaptchaToken(),
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
// Generic success message regardless of whether the email exists
|
||||
|
||||
@@ -9,6 +9,7 @@ import { supabase } from '@/lib/supabase';
|
||||
import { identityToUUID } from '@shared/lib/identity';
|
||||
import { Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getCaptchaToken } from '@/lib/captcha';
|
||||
|
||||
type Status = 'loading' | 'connecting' | 'success' | 'error';
|
||||
|
||||
@@ -111,6 +112,7 @@ export default function TelegramConnect() {
|
||||
const { error: signInError } = await supabase.auth.signInWithOtp({
|
||||
email: telegramEmail,
|
||||
options: {
|
||||
captchaToken: await getCaptchaToken(),
|
||||
shouldCreateUser: true,
|
||||
data: {
|
||||
telegram_id: parseInt(telegramId, 10),
|
||||
|
||||
Reference in New Issue
Block a user