mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {ms} from 'itty-time';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
|
||||
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
|
||||
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/u;
|
||||
@@ -56,8 +55,7 @@ function decodePayload(token: string): AltchaPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
export class AltchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'altcha';
|
||||
export class AltchaProvider {
|
||||
private readonly options: AltchaProviderOptions;
|
||||
private readonly now: () => number;
|
||||
|
||||
@@ -79,7 +77,7 @@ export class AltchaProvider implements ICaptchaProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
|
||||
async verify({token}: {token: string}): Promise<boolean> {
|
||||
const payload = decodePayload(token);
|
||||
if (!payload) return false;
|
||||
try {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
export class HcaptchaProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'hcaptcha';
|
||||
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
|
||||
protected readonly providerName = 'hCaptcha';
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
|
||||
const DEFAULT_TIMEOUT = ms('10 seconds');
|
||||
|
||||
export interface HttpCaptchaProviderOptions {
|
||||
secretKey: string;
|
||||
logger?: LoggerInterface;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CaptchaVerifyResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: Array<string>;
|
||||
hostname?: string;
|
||||
challenge_ts?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
const errorCodes = data['error-codes'];
|
||||
return (
|
||||
typeof data.success === 'boolean' &&
|
||||
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
|
||||
(data.hostname === undefined || typeof data.hostname === 'string') &&
|
||||
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
|
||||
(data.score === undefined ||
|
||||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
|
||||
);
|
||||
}
|
||||
|
||||
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
|
||||
abstract readonly type: CaptchaProviderType;
|
||||
protected readonly secretKey: string;
|
||||
protected readonly logger: LoggerInterface | undefined;
|
||||
protected readonly timeoutMs: number;
|
||||
protected readonly userAgent: string;
|
||||
protected readonly fetchFn: typeof fetch;
|
||||
protected abstract readonly verifyUrl: string;
|
||||
protected abstract readonly providerName: string;
|
||||
|
||||
constructor(options: HttpCaptchaProviderOptions) {
|
||||
this.secretKey = options.secretKey;
|
||||
this.logger = options.logger;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
||||
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
||||
this.fetchFn = options.fetchFn ?? fetch;
|
||||
}
|
||||
|
||||
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append('secret', this.secretKey);
|
||||
body.append('response', token);
|
||||
if (remoteIp) {
|
||||
body.append('remoteip', remoteIp);
|
||||
}
|
||||
const response = await this.fetchFn(this.verifyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': this.userAgent,
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {
|
||||
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
|
||||
});
|
||||
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
|
||||
return false;
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (!isCaptchaVerifyResponse(data)) {
|
||||
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
|
||||
return false;
|
||||
}
|
||||
if (!data.success) {
|
||||
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
|
||||
return false;
|
||||
}
|
||||
return this.validateResponse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
|
||||
} else {
|
||||
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
const DEFAULT_MINIMUM_SCORE = 0.5;
|
||||
|
||||
interface RecaptchaVerifyResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: Array<string>;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
|
||||
minimumScore?: number;
|
||||
}
|
||||
|
||||
export class RecaptchaProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'recaptcha';
|
||||
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
protected readonly providerName = 'reCAPTCHA';
|
||||
private readonly minimumScore: number;
|
||||
|
||||
constructor(options: RecaptchaProviderOptions) {
|
||||
super(options);
|
||||
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
|
||||
}
|
||||
|
||||
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
|
||||
if (data.score !== undefined && data.score < this.minimumScore) {
|
||||
this.logger?.warn(
|
||||
{score: data.score, minimumScore: this.minimumScore},
|
||||
'reCAPTCHA score below minimum threshold',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
|
||||
export class TestCaptchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'test';
|
||||
|
||||
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
|
||||
export class TurnstileProvider extends HttpCaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'turnstile';
|
||||
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
protected readonly providerName = 'Turnstile';
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
|
||||
export class UnavailableCaptchaProvider implements ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType = 'unavailable';
|
||||
|
||||
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user