feat(captcha): make ALTCHA the only captcha (#3035)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 17:00:15 +02:00
1 parent d433a039b5
commit 4f968bbc47
209 files changed
+4097 -8692

No files matched your search

@@ -1,16 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
}
.text {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--text-secondary);
}
@@ -1,66 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import styles from '@app/features/auth/components/AltchaVerification.module.css';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {Button} from '@app/features/ui/button/Button';
import {Spinner} from '@app/features/ui/components/Spinner';
import {Trans} from '@lingui/react/macro';
import {useCallback, useEffect, useRef, useState} from 'react';
const logger = new Logger('AltchaVerification');
interface AltchaVerificationProps {
challenge: AltchaChallenge;
onVerify: (token: string) => void;
}
export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) {
const onVerifyRef = useRef(onVerify);
const [attempt, setAttempt] = useState(0);
const [failed, setFailed] = useState(false);
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
useEffect(() => {
const controller = new AbortController();
setFailed(false);
solveAltchaChallenge(challenge, controller).then(
(token) => {
if (controller.signal.aborted) return;
if (token) {
onVerifyRef.current(token);
} else {
setFailed(true);
}
},
(error: unknown) => {
if (controller.signal.aborted) return;
logger.error('ALTCHA solve failed:', error);
setFailed(true);
},
);
return () => controller.abort();
}, [challenge, attempt]);
const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
if (failed) {
return (
<div className={styles.container} data-flx="auth.altcha-verification.failed">
<p className={styles.text} data-flx="auth.altcha-verification.failed-text">
<Trans>Your browser couldn't finish the check.</Trans>
</p>
<Button small variant="secondary" onClick={handleRetry} data-flx="auth.altcha-verification.retry-button">
<Trans>Try again</Trans>
</Button>
</div>
);
}
return (
<div className={styles.container} role="status" aria-live="polite" data-flx="auth.altcha-verification.solving">
<Spinner data-flx="auth.altcha-verification.spinner" />
<p className={styles.text} data-flx="auth.altcha-verification.solving-text">
<Trans>Checking your browser. This takes a few seconds.</Trans>
</p>
</div>
);
}
@@ -1,146 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal';
import {http} from '@app/features/platform/transport/RestTransport';
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
import {replyCode, replyMessage} from '@app/features/platform/utils/ResponseInspection';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {action, makeObservable, observable} from 'mobx';
import {observer} from 'mobx-react-lite';
const CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Captcha verification failed. Try again.',
comment: 'Toast error shown when captcha verification fails inside the auth captcha interceptor.',
});
export interface CaptchaResult {
token: string;
type: CaptchaType;
}
class CaptchaState {
error: string | null = null;
isVerifying = false;
constructor() {
makeObservable(this, {
error: observable,
isVerifying: observable,
setError: action,
setIsVerifying: action,
reset: action,
});
}
setError(error: string | null) {
this.error = error;
}
setIsVerifying(isVerifying: boolean) {
this.isVerifying = isVerifying;
}
reset() {
this.error = null;
this.isVerifying = false;
}
}
class CaptchaInterceptorState {
private state = new CaptchaState();
private pendingPromise: {resolve: (result: CaptchaResult) => void; reject: (error: Error) => void} | null = null;
private i18n: I18n | null = null;
setI18n(i18n: I18n) {
this.i18n = i18n;
}
constructor() {
http.installHooks({
intercept: this.intercept.bind(this),
});
}
private isCaptchaError(reply: RestResponse): boolean {
const code = replyCode(reply.body);
return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA';
}
private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise<CaptchaResult> {
if (this.pendingPromise) {
this.pendingPromise.reject(new Error('Captcha cancelled'));
this.pendingPromise = null;
}
this.state.reset();
return new Promise((resolve, reject) => {
this.pendingPromise = {resolve, reject};
const handleVerify = (token: string, captchaType: CaptchaType) => {
const result = {token, type: captchaType};
this.state.setIsVerifying(true);
if (this.pendingPromise) {
this.pendingPromise.resolve(result);
this.pendingPromise = null;
}
};
const handleCancel = () => {
this.state.reset();
if (this.pendingPromise) {
this.pendingPromise.reject(new Error('Captcha cancelled'));
this.pendingPromise = null;
}
ModalCommands.pop();
};
const CaptchaModalWrapper = observer(() => (
<CaptchaModal
altchaChallenge={altchaChallenge}
onVerify={handleVerify}
onCancel={handleCancel}
error={this.state.error}
isVerifying={this.state.isVerifying}
closeOnVerify={false}
data-flx="auth.captcha-interceptor.captcha-modal-wrapper.captcha-modal"
/>
));
ModalCommands.push(
modal(() => <CaptchaModalWrapper data-flx="auth.captcha-interceptor.captcha-modal-wrapper" />),
);
});
}
private intercept(
reply: RestResponse,
retry: (extraHeaders: Record<string, string>) => Promise<RestResponse>,
reject: (error: Error) => void,
): boolean | Promise<RestResponse> | undefined {
if (reply.status === 400 && this.isCaptchaError(reply)) {
const i18n = this.i18n!;
const errorMessage = replyMessage(reply.body) || i18n._(CAPTCHA_VERIFICATION_FAILED_PLEASE_TRY_AGAIN_DESCRIPTOR);
this.state.setError(errorMessage);
this.state.setIsVerifying(false);
const promise = this.showCaptchaModal(readAltchaChallenge(reply.body))
.then((captchaResult) => {
this.state.setError(null);
this.state.setIsVerifying(false);
ModalCommands.pop();
return retry({
'X-Captcha-Token': captchaResult.token,
'X-Captcha-Type': captchaResult.type,
});
})
.catch((error) => {
this.state.reset();
ModalCommands.pop();
reject(error);
throw error;
});
return promise;
}
return undefined;
}
}
export default new CaptchaInterceptorState();
@@ -1,29 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Turnstile} from '@marsidev/react-turnstile';
import {observer} from 'mobx-react-lite';
interface TurnstileWidgetProps {
sitekey: string;
onVerify: (token: string) => void;
onError?: (error: string) => void;
onExpire?: () => void;
theme?: 'light' | 'dark' | 'auto';
}
export const TurnstileWidget = observer(
({sitekey, onVerify, onError, onExpire, theme = 'dark'}: TurnstileWidgetProps) => {
return (
<Turnstile
siteKey={sitekey}
onSuccess={onVerify}
onError={() => onError?.('Turnstile error')}
onExpire={onExpire}
options={{
theme,
}}
data-flx="auth.turnstile-widget.turnstile"
/>
);
},
);
@@ -1,48 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
align-items: center;
}
.errorBox {
width: 100%;
padding: 0.75rem;
border-radius: 0.375rem;
border: 0.0625rem solid var(--status-danger);
background-color: rgb(from var(--status-danger) r g b / 0.1);
}
.errorText {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--status-danger);
}
.captchaContainer {
display: flex;
width: 100%;
justify-content: center;
margin-bottom: 1rem;
}
.switchContainer {
text-align: center;
}
.switchButton {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-link);
text-decoration: underline;
cursor: pointer;
}
.switchButton:hover {
color: var(--text-link);
}
.switchButton:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@@ -1,187 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver';
import {AltchaVerification} from '@app/features/auth/components/AltchaVerification';
import styles from '@app/features/auth/components/modals/CaptchaModal.module.css';
import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import HCaptcha from '@hcaptcha/react-hcaptcha';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef, useState} from 'react';
const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({
message: "Verify you're human",
comment: 'Short label in the authentication captcha modal. Keep the tone plain and specific.',
});
const logger = new Logger('CaptchaModal');
export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha';
interface HCaptchaComponentProps {
sitekey: string;
onVerify?: (token: string) => void;
onExpire?: () => void;
onError?: (error: string) => void;
theme?: 'light' | 'dark';
ref?: React.Ref<HCaptcha>;
}
const HCaptchaComponent = HCaptcha as React.ComponentType<HCaptchaComponentProps>;
interface CaptchaModalProps {
onVerify: (token: string, captchaType: CaptchaType) => void;
onCancel?: () => void;
preferredType?: CaptchaType;
altchaChallenge?: AltchaChallenge | null;
error?: string | null;
isVerifying?: boolean;
closeOnVerify?: boolean;
}
export const CaptchaModal = observer(
({
onVerify,
onCancel,
preferredType,
altchaChallenge,
error,
isVerifying,
closeOnVerify = true,
}: CaptchaModalProps) => {
const {i18n} = useLingui();
const hcaptchaRef = useRef<HCaptcha>(null);
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
if (altchaChallenge) return 'altcha';
if (preferredType) return preferredType;
if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) {
return 'turnstile';
}
if (RuntimeConfig.captchaProvider === 'hcaptcha' && RuntimeConfig.hcaptchaSiteKey) {
return 'hcaptcha';
}
return RuntimeConfig.turnstileSiteKey ? 'turnstile' : 'hcaptcha';
});
useEffect(() => {
if (captchaType === 'hcaptcha') {
const timer = setTimeout(() => {
hcaptchaRef.current?.resetCaptcha();
}, 100);
return () => clearTimeout(timer);
}
return;
}, [captchaType]);
useEffect(() => {
if (error) {
if (captchaType === 'hcaptcha') {
hcaptchaRef.current?.resetCaptcha();
}
}
}, [error, captchaType]);
const handleVerify = useCallback(
(token: string) => {
onVerify(token, captchaType);
if (closeOnVerify) {
ModalCommands.pop();
}
},
[onVerify, captchaType, closeOnVerify],
);
const handleCancel = useCallback(() => {
onCancel?.();
ModalCommands.pop();
}, [onCancel]);
const handleExpire = useCallback(() => {
if (captchaType === 'hcaptcha') {
hcaptchaRef.current?.resetCaptcha();
}
}, [captchaType]);
const handleError = useCallback(
(error: string) => {
logger.error(`${captchaType} error:`, error);
},
[captchaType],
);
const handleSwitchToHCaptcha = useCallback(() => {
setCaptchaType('hcaptcha');
}, []);
const handleSwitchToTurnstile = useCallback(() => {
setCaptchaType('turnstile');
}, []);
const showSwitchButton =
(captchaType === 'turnstile' && RuntimeConfig.hcaptchaSiteKey) ||
(captchaType === 'hcaptcha' && RuntimeConfig.turnstileSiteKey);
return (
<Modal.Root size="small" centered onClose={handleCancel} data-flx="auth.captcha-modal.modal-root">
<Modal.Header
title={i18n._(VERIFY_YOU_RE_HUMAN_DESCRIPTOR)}
onClose={handleCancel}
data-flx="auth.captcha-modal.modal-header"
/>
<Modal.Content data-flx="auth.captcha-modal.modal-content">
<Modal.ContentLayout className={styles.container} data-flx="auth.captcha-modal.container">
<Modal.Description data-flx="auth.captcha-modal.description">
<Trans>We need to make sure you're not a bot. Complete the verification below.</Trans>
</Modal.Description>
{error && (
<div className={styles.errorBox} data-flx="auth.captcha-modal.error-box">
<p className={styles.errorText} data-flx="auth.captcha-modal.error-text">
{error}
</p>
</div>
)}
<div className={styles.captchaContainer} data-flx="auth.captcha-modal.captcha-container">
{captchaType === 'altcha' && altchaChallenge ? (
<AltchaVerification
challenge={altchaChallenge}
onVerify={handleVerify}
data-flx="auth.captcha-modal.altcha-verification"
/>
) : captchaType === 'turnstile' ? (
<TurnstileWidget
sitekey={RuntimeConfig.turnstileSiteKey ?? ''}
onVerify={handleVerify}
onExpire={handleExpire}
onError={handleError}
theme="dark"
data-flx="auth.captcha-modal.turnstile-widget"
/>
) : (
<HCaptchaComponent
ref={hcaptchaRef}
sitekey={RuntimeConfig.hcaptchaSiteKey ?? ''}
onVerify={handleVerify}
onExpire={handleExpire}
onError={handleError}
theme="dark"
data-flx="auth.captcha-modal.h-captcha-component"
/>
)}
</div>
{showSwitchButton && (
<div className={styles.switchContainer} data-flx="auth.captcha-modal.switch-container">
<button
type="button"
onClick={captchaType === 'turnstile' ? handleSwitchToHCaptcha : handleSwitchToTurnstile}
className={styles.switchButton}
disabled={isVerifying}
data-flx="auth.captcha-modal.switch-button.switch-to-h-captcha"
>
{captchaType === 'turnstile' ? (
<Trans>Having issues? Try hCaptcha instead</Trans>
) : (
<Trans>Try Turnstile instead</Trans>
)}
</button>
</div>
)}
</Modal.ContentLayout>
</Modal.Content>
</Modal.Root>
);
},
);
@@ -573,10 +573,6 @@ export const PHONE_NEEDS_REVIEW_DESCRIPTOR = msg({
comment:
'Required-action form error when a phone attempt cannot be verified automatically and needs a support review. Keep it non-specific.',
});
export const CAPTCHA_REQUIRED_DESCRIPTOR = msg({
message: 'A browser check is required before phone verification. Try again from the sign-in page or contact support.',
comment: 'Required-action error when the API requires a captcha for phone verification.',
});
export const SOMETHING_WENT_WRONG_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Something went wrong. Try again.',
comment: 'Generic required-action error fallback.',
@@ -11,7 +11,6 @@ import {
ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR,
ALT_ROUTES_HUMAN_REVIEW_SELF_HOSTED_DESCRIPTOR,
ALT_ROUTES_TITLE_DESCRIPTOR,
CAPTCHA_REQUIRED_DESCRIPTOR,
CODE_DID_NOT_WORK_DESCRIPTOR,
CODE_EXPIRED_DESCRIPTOR,
CODE_NOT_REQUESTED_DESCRIPTOR,
@@ -192,9 +191,6 @@ export function resolveRequiredActionErrorMessage(
case APIErrorCodes.BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED:
case APIErrorCodes.PHONE_VERIFICATION_REQUIRED:
return i18n._(PHONE_NOT_ELIGIBLE_DESCRIPTOR);
case APIErrorCodes.CAPTCHA_REQUIRED:
case APIErrorCodes.INVALID_CAPTCHA:
return i18n._(CAPTCHA_REQUIRED_DESCRIPTOR);
case APIErrorCodes.ACCESS_DENIED:
case APIErrorCodes.FORBIDDEN:
return i18n._(EMAIL_CHANGE_UNAVAILABLE_DESCRIPTOR);