mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(captcha): make ALTCHA the only captcha (#3035)
This commit is contained in:
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
-4
@@ -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.',
|
||||
|
||||
-4
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user