mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(captcha): make ALTCHA the only captcha (#3035)
This commit is contained in:
@@ -1,87 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import {HcaptchaProvider} from '@pkgs/captcha/src/providers/HcaptchaProvider';
|
||||
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
|
||||
import type {RecaptchaProviderOptions} from '@pkgs/captcha/src/providers/RecaptchaProvider';
|
||||
import {RecaptchaProvider} from '@pkgs/captcha/src/providers/RecaptchaProvider';
|
||||
import {TestCaptchaProvider} from '@pkgs/captcha/src/providers/TestProvider';
|
||||
import {TurnstileProvider} from '@pkgs/captcha/src/providers/TurnstileProvider';
|
||||
import {UnavailableCaptchaProvider} from '@pkgs/captcha/src/providers/UnavailableCaptchaProvider';
|
||||
|
||||
interface BaseCaptchaProviderFactoryParams {
|
||||
logger?: LoggerInterface;
|
||||
}
|
||||
|
||||
interface CreateUnavailableCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'unavailable';
|
||||
}
|
||||
|
||||
interface CreateTestCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'test';
|
||||
}
|
||||
|
||||
interface CreateHcaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'hcaptcha';
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CreateTurnstileProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'turnstile';
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
interface CreateRecaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
|
||||
mode: 'recaptcha';
|
||||
secretKey: string;
|
||||
minimumScore?: number;
|
||||
timeoutMs?: number;
|
||||
userAgent?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
type CreateCaptchaProviderParams =
|
||||
| CreateUnavailableCaptchaProviderParams
|
||||
| CreateTestCaptchaProviderParams
|
||||
| CreateHcaptchaProviderParams
|
||||
| CreateTurnstileProviderParams
|
||||
| CreateRecaptchaProviderParams;
|
||||
|
||||
function buildHttpOptions(
|
||||
params: CreateHcaptchaProviderParams | CreateTurnstileProviderParams | CreateRecaptchaProviderParams,
|
||||
): HttpCaptchaProviderOptions {
|
||||
return {
|
||||
secretKey: params.secretKey,
|
||||
logger: params.logger,
|
||||
timeoutMs: params.timeoutMs,
|
||||
userAgent: params.userAgent,
|
||||
fetchFn: params.fetchFn,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCaptchaProvider(params: CreateCaptchaProviderParams): ICaptchaProvider {
|
||||
if (params.mode === 'test') {
|
||||
return new TestCaptchaProvider();
|
||||
}
|
||||
if (params.mode === 'hcaptcha') {
|
||||
return new HcaptchaProvider(buildHttpOptions(params));
|
||||
}
|
||||
if (params.mode === 'turnstile') {
|
||||
return new TurnstileProvider(buildHttpOptions(params));
|
||||
}
|
||||
if (params.mode === 'recaptcha') {
|
||||
const options: RecaptchaProviderOptions = {
|
||||
...buildHttpOptions(params),
|
||||
minimumScore: params.minimumScore,
|
||||
};
|
||||
return new RecaptchaProvider(options);
|
||||
}
|
||||
return new UnavailableCaptchaProvider();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface VerifyCaptchaParams {
|
||||
token: string;
|
||||
remoteIp?: string;
|
||||
}
|
||||
|
||||
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
|
||||
|
||||
export interface ICaptchaProvider {
|
||||
readonly type: CaptchaProviderType;
|
||||
verify(params: VerifyCaptchaParams): Promise<boolean>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -329,22 +329,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
breachedPasswordCheck: {
|
||||
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
hcaptcha: master.integrations.captcha.hcaptcha
|
||||
? {
|
||||
siteKey: master.integrations.captcha.hcaptcha.site_key,
|
||||
secretKey: master.integrations.captcha.hcaptcha.secret_key,
|
||||
}
|
||||
: undefined,
|
||||
turnstile: master.integrations.captcha.turnstile
|
||||
? {
|
||||
siteKey: master.integrations.captcha.turnstile.site_key,
|
||||
secretKey: master.integrations.captcha.turnstile.secret_key,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
contentModeration: {
|
||||
nsfwThreshold: master.services.api.content_moderation?.nsfw_threshold ?? 0.7,
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
@@ -67,7 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
altchaCaptcha,
|
||||
captcha,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -77,7 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -111,7 +110,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
altcha_captcha: altchaCaptcha,
|
||||
captcha,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -388,16 +387,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.altcha_captcha) {
|
||||
const patch = omitUndefinedFields(data.altcha_captcha);
|
||||
if (data.captcha) {
|
||||
const patch = omitUndefinedFields(data.captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
|
||||
AltchaCaptchaConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
await instanceConfigRepository.updateCaptchaConfig(patch);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
@@ -498,15 +491,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
api_key: readOptionalField(data.integrations.youtube, 'api_key'),
|
||||
})
|
||||
: undefined,
|
||||
captcha: data.integrations.captcha
|
||||
? omitUndefinedFields({
|
||||
provider: readOptionalField(data.integrations.captcha, 'provider'),
|
||||
hcaptcha_site_key: readOptionalField(data.integrations.captcha, 'hcaptcha_site_key'),
|
||||
hcaptcha_secret_key: readOptionalField(data.integrations.captcha, 'hcaptcha_secret_key'),
|
||||
turnstile_site_key: readOptionalField(data.integrations.captcha, 'turnstile_site_key'),
|
||||
turnstile_secret_key: readOptionalField(data.integrations.captcha, 'turnstile_secret_key'),
|
||||
})
|
||||
: undefined,
|
||||
email: data.integrations.email
|
||||
? {
|
||||
...omitUndefinedFields({
|
||||
|
||||
@@ -109,8 +109,8 @@ export function AuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/register',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
CaptchaMiddleware,
|
||||
Validator('json', RegisterRequest),
|
||||
OpenAPI({
|
||||
operationId: 'register_account',
|
||||
@@ -120,7 +120,7 @@ export function AuthController(app: HonoApp) {
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
|
||||
'Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').register({
|
||||
@@ -134,8 +134,8 @@ export function AuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/login',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
|
||||
CaptchaMiddleware,
|
||||
Validator('json', LoginRequest),
|
||||
OpenAPI({
|
||||
operationId: 'login_user',
|
||||
@@ -145,7 +145,7 @@ export function AuthController(app: HonoApp) {
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
|
||||
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const result = await ctx.get('authRequestService').login({
|
||||
@@ -240,8 +240,8 @@ export function AuthController(app: HonoApp) {
|
||||
app.post(
|
||||
'/auth/forgot',
|
||||
LocalAuthMiddleware,
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
|
||||
CaptchaMiddleware,
|
||||
Validator('json', ForgotPasswordRequest),
|
||||
OpenAPI({
|
||||
operationId: 'forgot_password',
|
||||
@@ -251,7 +251,7 @@ export function AuthController(app: HonoApp) {
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
|
||||
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires a solved captcha challenge (X-Captcha-Token).",
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').forgotPassword({
|
||||
|
||||
@@ -39,6 +39,8 @@ interface IssuedChallenge {
|
||||
interface SendPhoneVerificationOptions {
|
||||
clientIp: string;
|
||||
channel?: PhoneChannel;
|
||||
hasCaptchaToken: boolean;
|
||||
verifyCaptcha: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
function assertPhoneFormat(phone: string): void {
|
||||
@@ -74,18 +76,25 @@ export async function sendPhoneVerificationCode(
|
||||
): Promise<PhoneVerificationStartResult> {
|
||||
assertPhoneFormat(phone);
|
||||
const user = await loadRequestingUser(ctx, userId);
|
||||
const reply = await getPhoneVerificationClient().start(
|
||||
{
|
||||
user_id: user.id.toString(),
|
||||
user_flags: user.flags.toString(),
|
||||
has_verified_phone: user.hasVerifiedPhone,
|
||||
phone,
|
||||
requested_channel: options.channel ?? null,
|
||||
client_ip: options.clientIp,
|
||||
captcha_passed: false,
|
||||
},
|
||||
requestInfo(ctx, userId, phone),
|
||||
);
|
||||
const start = (captchaPassed: boolean) =>
|
||||
getPhoneVerificationClient().start(
|
||||
{
|
||||
user_id: user.id.toString(),
|
||||
user_flags: user.flags.toString(),
|
||||
has_verified_phone: user.hasVerifiedPhone,
|
||||
phone,
|
||||
requested_channel: options.channel ?? null,
|
||||
client_ip: options.clientIp,
|
||||
captcha_passed: captchaPassed,
|
||||
},
|
||||
requestInfo(ctx, userId, phone),
|
||||
);
|
||||
const captchaPassed = options.hasCaptchaToken && (await options.verifyCaptcha());
|
||||
const reply = await start(captchaPassed);
|
||||
if (reply.result === 'error' && reply.code === 'captcha_required' && !captchaPassed) {
|
||||
await options.verifyCaptcha();
|
||||
Logger.warn({userId: userId.toString()}, 'Phone verification asked for a captcha without a captcha check');
|
||||
}
|
||||
switch (reply.result) {
|
||||
case 'sms_sent':
|
||||
return {channel: 'sms'};
|
||||
|
||||
@@ -32,7 +32,6 @@ import {PhoneNumberNotInServiceError} from '@fluxer/errors/src/domains/auth/Phon
|
||||
import {PhoneNumberNotMobileError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotMobileError';
|
||||
import {PhoneVerificationNeedsReviewError} from '@fluxer/errors/src/domains/auth/PhoneVerificationNeedsReviewError';
|
||||
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
|
||||
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import {type NatsConnection, headers as natsHeaders, RequestError, TimeoutError} from '@nats-io/transport-node';
|
||||
@@ -204,8 +203,18 @@ export function errorForPhoneReply(error: PhoneError): FluxerError {
|
||||
return new PhoneInboundVerificationRequiredError();
|
||||
case 'already_used':
|
||||
return new PhoneAlreadyUsedError();
|
||||
case 'captcha_required':
|
||||
return new CaptchaVerificationRequiredError();
|
||||
case 'captcha_required': {
|
||||
const retryAfter = 24 * 60 * 60;
|
||||
return new RateLimitError({
|
||||
code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED,
|
||||
retryAfter,
|
||||
retryAfterDecimal: retryAfter,
|
||||
limit: 1,
|
||||
resetTime: new Date(Date.now() + retryAfter * 1000),
|
||||
resetAfterDecimal: retryAfter,
|
||||
scope: 'user',
|
||||
});
|
||||
}
|
||||
case 'invalid_code':
|
||||
return new InvalidPhoneVerificationCodeError();
|
||||
case 'rate_limited': {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
const FORGOT_PATH = '/auth/forgot';
|
||||
const FORGOT_BODY = {email: '[email protected]'};
|
||||
|
||||
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
cost: 1000,
|
||||
max_counter: 100,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function solve(challenge: Challenge): Promise<string> {
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('ALTCHA challenge was not solved');
|
||||
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
|
||||
}
|
||||
|
||||
describe('ALTCHA captcha experiment', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let previousCaptchaEnabled: boolean;
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
previousCaptchaEnabled = Config.captcha.enabled;
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.captcha.enabled = previousCaptchaEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the configured provider while the experiment is off', async () => {
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('captcha_provider');
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
|
||||
await setAltchaConfig({rollout_basis_points: 10000});
|
||||
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(body).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
|
||||
await forgot(harness)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const replayed = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge as Challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
|
||||
await setAltchaConfig({anonymous_enabled: true});
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solve(required.altcha_challenge as Challenge);
|
||||
await setAltchaConfig({anonymous_enabled: false});
|
||||
|
||||
const rejected = await rejectWith(
|
||||
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
|
||||
APIErrorCodes.INVALID_CAPTCHA,
|
||||
);
|
||||
expect(rejected).not.toHaveProperty('altcha_challenge');
|
||||
});
|
||||
|
||||
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
|
||||
Config.captcha.enabled = false;
|
||||
const included = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
Config.captcha.enabled = true;
|
||||
await setAltchaConfig({
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [included.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
const redeemPath = '/gifts/altcha-gift-code/redeem';
|
||||
|
||||
const excludedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
expect(excludedBody).not.toHaveProperty('altcha_challenge');
|
||||
|
||||
const includedBody = await rejectWith(
|
||||
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
|
||||
APIErrorCodes.CAPTCHA_REQUIRED,
|
||||
);
|
||||
const token = await solve(includedBody.altcha_challenge as Challenge);
|
||||
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', token)
|
||||
.header('X-Captcha-Type', 'altcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
|
||||
|
||||
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
|
||||
.post(redeemPath)
|
||||
.header('X-Captcha-Token', 'hcaptcha-token')
|
||||
.header('X-Captcha-Type', 'hcaptcha')
|
||||
.executeRaw();
|
||||
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,13 @@ import {
|
||||
createUniqueUsername,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAndFlag(
|
||||
harness: ApiTestHarness,
|
||||
flags: Array<string>,
|
||||
@@ -56,29 +43,28 @@ describe('Auth Captcha Bypass Flags', () => {
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
|
||||
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
|
||||
await withCaptchaEnabled(async () => {
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
|
||||
const account = await registerAndFlag(harness, []);
|
||||
await withCaptchaEnabled(async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {setPhoneRpcConnection} from '@app/api/auth/PhoneVerificationClient';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {
|
||||
CAPTCHA_TEST_HEADER,
|
||||
type CaptchaErrorBody,
|
||||
solveCaptchaChallenge,
|
||||
useCheapCaptcha,
|
||||
} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {CaptchaConfigResponse} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
|
||||
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
|
||||
expect(json.code).toBe(code);
|
||||
return json;
|
||||
}
|
||||
|
||||
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilderWithoutAuth<CaptchaErrorBody>(harness)
|
||||
.post('/auth/forgot')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: '[email protected]'});
|
||||
}
|
||||
|
||||
async function createPhoneRequiredAccount(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true, suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return account;
|
||||
}
|
||||
|
||||
function usePhoneServiceThatAsksForCaptcha(): Array<boolean> {
|
||||
const captchaPassed: Array<boolean> = [];
|
||||
const connection = {
|
||||
async request(_subject: string, payload: string) {
|
||||
const passed = (JSON.parse(payload) as {captcha_passed: boolean}).captcha_passed;
|
||||
captchaPassed.push(passed);
|
||||
const reply = passed
|
||||
? {result: 'sms_sent'}
|
||||
: {
|
||||
result: 'error',
|
||||
code: 'captcha_required',
|
||||
retry_after_s: null,
|
||||
rate_limit_scope: null,
|
||||
limit: null,
|
||||
message: null,
|
||||
};
|
||||
return {string: () => JSON.stringify(reply)};
|
||||
},
|
||||
} as unknown as NatsConnection;
|
||||
setPhoneRpcConnection(() => connection);
|
||||
return captchaPassed;
|
||||
}
|
||||
|
||||
function sendPhoneCode(harness: ApiTestHarness, account: TestAccount): TestRequestBuilder<CaptchaErrorBody> {
|
||||
return createBuilder<CaptchaErrorBody>(harness, account.token)
|
||||
.post('/users/@me/phone/send-verification')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({phone: '+15551230001'});
|
||||
}
|
||||
|
||||
async function turnCaptchaOff(harness: ApiTestHarness): Promise<void> {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({captcha: {enabled: false}})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Captcha challenge', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPhoneRpcConnection(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('issues an ALTCHA challenge to a request without a token', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.captcha_provider).toBe('altcha');
|
||||
expect(required.altcha_challenge?.parameters).toMatchObject({
|
||||
algorithm: 'PBKDF2/SHA-256',
|
||||
cost: 1000,
|
||||
keyLength: 32,
|
||||
});
|
||||
expect(required.altcha_challenge?.signature).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts a solved challenge once and answers a replay with a fresh challenge', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const token = await solveCaptchaChallenge(required);
|
||||
|
||||
await forgot(harness).header('X-Captcha-Token', token).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
|
||||
const replayed = await rejectWith(forgot(harness).header('X-Captcha-Token', token), APIErrorCodes.INVALID_CAPTCHA);
|
||||
expect(replayed.captcha_provider).toBe('altcha');
|
||||
expect(replayed.altcha_challenge?.signature).toBeTruthy();
|
||||
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
|
||||
});
|
||||
|
||||
it('rejects a payload whose derived key does not match the challenge', async () => {
|
||||
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
const challenge = required.altcha_challenge;
|
||||
const forged = Buffer.from(
|
||||
JSON.stringify({
|
||||
challenge: {parameters: challenge?.parameters, signature: challenge?.signature},
|
||||
solution: {counter: 1, derivedKey: '00'.repeat(32)},
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64');
|
||||
|
||||
const rejected = await rejectWith(forgot(harness).header('X-Captcha-Token', forged), APIErrorCodes.INVALID_CAPTCHA);
|
||||
expect(rejected.altcha_challenge?.signature).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips the check once an admin turns it off', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const updated = await createBuilder<{captcha: CaptchaConfigResponse}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({captcha: {enabled: false}})
|
||||
.execute();
|
||||
expect(updated.captcha).toEqual({enabled: false, cost: 1000, max_counter: 100});
|
||||
|
||||
await forgot(harness).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
});
|
||||
|
||||
it('challenges a phone send the phone service flags and lets it through once solved', async () => {
|
||||
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
|
||||
const account = await createPhoneRequiredAccount(harness);
|
||||
const required = await rejectWith(sendPhoneCode(harness, account), APIErrorCodes.CAPTCHA_REQUIRED);
|
||||
expect(required.altcha_challenge?.signature).toBeTruthy();
|
||||
expect(captchaPassed).toEqual([false]);
|
||||
const token = await solveCaptchaChallenge(required);
|
||||
|
||||
await sendPhoneCode(harness, account).header('X-Captcha-Token', token).expect(HTTP_STATUS.OK).execute();
|
||||
expect(captchaPassed).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('refuses a flagged phone send while the check is off', async () => {
|
||||
await turnCaptchaOff(harness);
|
||||
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
|
||||
const account = await createPhoneRequiredAccount(harness);
|
||||
|
||||
const {response, json} = await sendPhoneCode(harness, account).executeRaw();
|
||||
expect(response.status).toBe(429);
|
||||
expect(json).toMatchObject({code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED});
|
||||
expect(captchaPassed).toEqual([false]);
|
||||
});
|
||||
|
||||
it('skips the check in test mode unless the request opts in', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: '[email protected]'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,9 @@ describe('phone verification client', () => {
|
||||
for (const code of ['unavailable', 'deadline_exceeded', 'unsupported_contract'] as const) {
|
||||
expect(errorForPhoneReply({code, ...blank})).toBeInstanceOf(SmsVerificationUnavailableError);
|
||||
}
|
||||
const captchaRequired = errorForPhoneReply({code: 'captcha_required', ...blank});
|
||||
expect(captchaRequired).toBeInstanceOf(RateLimitError);
|
||||
expect((captchaRequired as RateLimitError).code).toBe(APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ describe('Auth registration', () => {
|
||||
expect(reg.user_id.length).toBeGreaterThan(0);
|
||||
});
|
||||
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
|
||||
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
||||
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
|
||||
const first = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadminone'));
|
||||
const second = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadmintwo'));
|
||||
|
||||
@@ -220,7 +220,7 @@ export function ChannelController(app: HonoApp) {
|
||||
operationId: 'add_group_dm_recipient',
|
||||
summary: 'Add recipient to group DM',
|
||||
description:
|
||||
'Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.',
|
||||
'Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires a solved captcha challenge (X-Captcha-Token).',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
type GroupDmChannelResponse,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {CAPTCHA_TEST_HEADER, issueSolvedCaptchaToken, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
@@ -15,22 +15,8 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const HTTP_TOO_MANY_REQUESTS = 429;
|
||||
const TEST_CAPTCHA_TOKEN = 'test-captcha-token';
|
||||
const RATE_LIMIT_TEST_HEADER = 'x-fluxer-test-enable-rate-limits';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroupDmWithCaptcha(
|
||||
harness: ApiTestHarness,
|
||||
owner: TestAccount,
|
||||
@@ -38,7 +24,8 @@ async function createGroupDmWithCaptcha(
|
||||
): Promise<GroupDmChannelResponse> {
|
||||
return await createBuilder<GroupDmChannelResponse>(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body({recipients: recipientIds})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
@@ -51,6 +38,7 @@ describe('Group DM captcha and rate limits', () => {
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
@@ -62,21 +50,20 @@ describe('Group DM captcha and rate limits', () => {
|
||||
const groupDmRecipient = await createTestAccount(harness);
|
||||
await createFriendship(harness, owner, dmRecipient);
|
||||
await createFriendship(harness, owner, groupDmRecipient);
|
||||
await withCaptchaEnabled(async () => {
|
||||
const dm = await createBuilder<{type: number}>(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: dmRecipient.userId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(dm.type).toBe(ChannelTypes.DM);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipients: [groupDmRecipient.userId]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
const groupDm = await createGroupDmWithCaptcha(harness, owner, [groupDmRecipient.userId]);
|
||||
expect(groupDm.type).toBe(ChannelTypes.GROUP_DM);
|
||||
});
|
||||
const dm = await createBuilder<{type: number}>(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.body({recipient_id: dmRecipient.userId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(dm.type).toBe(ChannelTypes.DM);
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({recipients: [groupDmRecipient.userId]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
const groupDm = await createGroupDmWithCaptcha(harness, owner, [groupDmRecipient.userId]);
|
||||
expect(groupDm.type).toBe(ChannelTypes.GROUP_DM);
|
||||
});
|
||||
|
||||
it('requires captcha when adding a recipient to an existing group DM', async () => {
|
||||
@@ -86,43 +73,43 @@ describe('Group DM captcha and rate limits', () => {
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, newMember);
|
||||
const groupDm = await createGroupDmChannel(harness, owner.token, [member.userId]);
|
||||
await withCaptchaEnabled(async () => {
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('limits group DM creation to 10 per user per hour', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const recipient = await createTestAccount(harness);
|
||||
await createFriendship(harness, owner, recipient);
|
||||
await withCaptchaEnabled(async () => {
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.body({recipients: [recipient.userId]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body({recipients: [recipient.userId]})
|
||||
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, owner.token)
|
||||
.post('/users/@me/channels')
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body({recipients: [recipient.userId]})
|
||||
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('limits group DM recipient additions to 10 per user per hour', async () => {
|
||||
@@ -136,23 +123,23 @@ describe('Group DM captcha and rate limits', () => {
|
||||
newMembers.push(member);
|
||||
}
|
||||
const groupDm = await createGroupDmChannel(harness, owner.token, [initialMember.userId]);
|
||||
await withCaptchaEnabled(async () => {
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMembers[index].userId}`)
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMembers[10].userId}`)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMembers[index].userId}`)
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body(null)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${groupDm.id}/recipients/${newMembers[10].userId}`)
|
||||
.header(RATE_LIMIT_TEST_HEADER, 'true')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body(null)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,18 +191,6 @@ export interface APIConfig {
|
||||
breachedPasswordCheck: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
hcaptcha?: {
|
||||
siteKey: string;
|
||||
secretKey: string;
|
||||
};
|
||||
turnstile?: {
|
||||
siteKey: string;
|
||||
secretKey: string;
|
||||
};
|
||||
};
|
||||
contentModeration: {
|
||||
nsfwThreshold: number;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
@@ -30,20 +29,18 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAltchaCaptchaConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, altchaCaptchaConfig]);
|
||||
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig]);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -11,11 +11,6 @@ interface TargetableExperimentConfig {
|
||||
|
||||
const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
|
||||
memberGuildIds: NO_GUILDS,
|
||||
premium: false,
|
||||
};
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
|
||||
@@ -9,10 +9,6 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
|
||||
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
@@ -58,7 +54,6 @@ describe('GET /experiments', () => {
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -109,62 +104,6 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
anonymous_enabled: true,
|
||||
included_user_ids: [targeted.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.execute();
|
||||
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
|
||||
.execute();
|
||||
expect(afterSecond.altcha_captcha).toMatchObject({
|
||||
config_version: 2,
|
||||
anonymous_enabled: true,
|
||||
cost: 2000,
|
||||
max_counter: 400,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
@@ -179,23 +118,16 @@ describe('GET /experiments', () => {
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setAltchaCaptchaConfig({
|
||||
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
@@ -227,19 +159,15 @@ describe('GET /experiments', () => {
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<'domain_migration' | 'altcha_captcha', {included_guild_ids: Array<string>; include_premium_users: boolean}>
|
||||
Record<'domain_migration', {included_guild_ids: Array<string>; include_premium_users: boolean}>
|
||||
>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
})
|
||||
.execute();
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
|
||||
for (const section of [body.domain_migration, body.altcha_captcha]) {
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
expect(body.domain_migration.include_premium_users).toBe(true);
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
|
||||
const LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
@@ -150,36 +151,54 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the effective captcha provider as none while the selected pair is incomplete', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
it('turns the captcha on at the default difficulty when no row is stored', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
|
||||
});
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
});
|
||||
it('ignores a legacy altcha captcha row that turned the experiment off', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
await repository.setConfig(
|
||||
LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY,
|
||||
JSON.stringify({enabled: false, config_version: 4, cost: 5000, max_counter: 10000}),
|
||||
);
|
||||
|
||||
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
|
||||
enabled: true,
|
||||
provider: 'turnstile',
|
||||
});
|
||||
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
|
||||
});
|
||||
|
||||
it('merges a partial captcha update onto the stored config', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.updateCaptchaConfig({cost: 2000});
|
||||
await repository.updateCaptchaConfig({enabled: false});
|
||||
|
||||
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: false, cost: 2000, max_counter: 1000});
|
||||
});
|
||||
|
||||
it('drops a legacy captcha integration, secrets included, on the next integrations write', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.setConfig(
|
||||
INSTANCE_INTEGRATIONS_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
captcha: {provider: 'legacy-provider', site_key: 'legacy-site-key', secret_key: 'legacy-secret-key'},
|
||||
youtube: {api_key: 'youtube-key'},
|
||||
}),
|
||||
);
|
||||
expect(await repository.getInstanceIntegrationsConfig()).not.toHaveProperty('captcha');
|
||||
|
||||
await repository.setInstanceIntegrationsConfig({gif: {klipy_api_key: 'klipy-key'}});
|
||||
|
||||
const stored = JSON.parse((await repository.getConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY)) ?? '{}');
|
||||
expect(stored).not.toHaveProperty('captcha');
|
||||
expect(stored.youtube.api_key).toBe('youtube-key');
|
||||
expect(stored.gif.klipy_api_key).toBe('klipy-key');
|
||||
});
|
||||
|
||||
it('keeps the stored setup state when a branding field is invalid', async () => {
|
||||
|
||||
@@ -34,9 +34,10 @@ import {
|
||||
type RegistrationUrlResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
type AltchaCaptchaConfig,
|
||||
AltchaCaptchaConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
type CaptchaConfig,
|
||||
CaptchaConfigSchema,
|
||||
type CaptchaConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
DomainMigrationConfigSchema,
|
||||
@@ -64,8 +65,6 @@ import {
|
||||
type InstanceAppPublic,
|
||||
InstanceAppPublicSchema,
|
||||
type InstanceBranding,
|
||||
type InstanceCaptchaProvider,
|
||||
InstanceCaptchaProviderSchema,
|
||||
type InstanceCommunity,
|
||||
type InstanceRegistration,
|
||||
InstanceRegistrationSchema,
|
||||
@@ -79,7 +78,7 @@ import {z} from 'zod';
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
|
||||
const CAPTCHA_CONFIG_KEY = 'captcha_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
@@ -189,14 +188,6 @@ interface InstanceYoutubeIntegrationConfig {
|
||||
api_key: string | null;
|
||||
}
|
||||
|
||||
interface InstanceCaptchaIntegrationConfig {
|
||||
provider: InstanceCaptchaProvider | null;
|
||||
hcaptcha_site_key: string | null;
|
||||
hcaptcha_secret_key: string | null;
|
||||
turnstile_site_key: string | null;
|
||||
turnstile_secret_key: string | null;
|
||||
}
|
||||
|
||||
interface InstanceEmailSmtpIntegrationConfig {
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
@@ -232,7 +223,6 @@ interface InstanceBlueskyIntegrationConfig {
|
||||
interface InstanceIntegrationsConfig {
|
||||
gif: InstanceGifIntegrationConfig;
|
||||
youtube: InstanceYoutubeIntegrationConfig;
|
||||
captcha: InstanceCaptchaIntegrationConfig;
|
||||
email: InstanceEmailIntegrationConfig;
|
||||
bluesky: InstanceBlueskyIntegrationConfig;
|
||||
}
|
||||
@@ -243,15 +233,6 @@ interface InstanceGifEffectiveConfig {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceCaptchaEffectiveConfig {
|
||||
enabled: boolean;
|
||||
provider: InstanceCaptchaProvider;
|
||||
hcaptcha_site_key: string | null;
|
||||
hcaptcha_secret_key: string | null;
|
||||
turnstile_site_key: string | null;
|
||||
turnstile_secret_key: string | null;
|
||||
}
|
||||
|
||||
interface InstanceIntegrationsAdminConfig {
|
||||
gif: {
|
||||
klipy_api_key_set: boolean;
|
||||
@@ -261,15 +242,6 @@ interface InstanceIntegrationsAdminConfig {
|
||||
api_key_set: boolean;
|
||||
effective_available: boolean;
|
||||
};
|
||||
captcha: {
|
||||
provider: InstanceCaptchaProvider | null;
|
||||
effective_provider: InstanceCaptchaProvider;
|
||||
hcaptcha_site_key: string | null;
|
||||
hcaptcha_secret_key_set: boolean;
|
||||
turnstile_site_key: string | null;
|
||||
turnstile_secret_key_set: boolean;
|
||||
effective_enabled: boolean;
|
||||
};
|
||||
email: {
|
||||
enabled: boolean | null;
|
||||
effective_enabled: boolean;
|
||||
@@ -336,7 +308,6 @@ interface InstanceMediaAdminConfig {
|
||||
interface InstanceIntegrationsConfigPatch {
|
||||
gif?: Partial<InstanceGifIntegrationConfig>;
|
||||
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
|
||||
captcha?: Partial<InstanceCaptchaIntegrationConfig>;
|
||||
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
|
||||
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
|
||||
};
|
||||
@@ -435,7 +406,7 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'altcha captcha'
|
||||
| 'captcha'
|
||||
| 'experiment delivery'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
@@ -596,8 +567,8 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
|
||||
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
|
||||
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
|
||||
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
@@ -718,15 +689,6 @@ const StoredBlueskyKeysSchema = z
|
||||
const StoredInstanceIntegrationsSchema = z.object({
|
||||
gif: z.object({klipy_api_key: StoredIntegrationStringSchema}).prefault({}),
|
||||
youtube: z.object({api_key: StoredIntegrationStringSchema}).prefault({}),
|
||||
captcha: z
|
||||
.object({
|
||||
provider: InstanceCaptchaProviderSchema.nullable().default(null),
|
||||
hcaptcha_site_key: StoredIntegrationStringSchema,
|
||||
hcaptcha_secret_key: StoredIntegrationStringSchema,
|
||||
turnstile_site_key: StoredIntegrationStringSchema,
|
||||
turnstile_secret_key: StoredIntegrationStringSchema,
|
||||
})
|
||||
.prefault({}),
|
||||
email: z
|
||||
.object({
|
||||
enabled: StoredNullableBooleanSchema,
|
||||
@@ -1270,7 +1232,7 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
@@ -1384,20 +1346,14 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
|
||||
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredAltchaCaptchaConfig(raw);
|
||||
async getCaptchaConfig(): Promise<CaptchaConfig> {
|
||||
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredCaptchaConfig(raw);
|
||||
}
|
||||
|
||||
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
|
||||
await this.updateAltchaCaptchaConfig(() => config);
|
||||
}
|
||||
|
||||
updateAltchaCaptchaConfig(
|
||||
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
|
||||
): Promise<AltchaCaptchaConfig> {
|
||||
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
|
||||
updateCaptchaConfig(patch: CaptchaConfigUpdateRequest): Promise<CaptchaConfig> {
|
||||
return this.updateStoredConfig(CAPTCHA_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(CaptchaConfigSchema, {...parseStoredCaptchaConfig(raw), ...patch}, 'captcha'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1545,10 +1501,6 @@ export class InstanceConfigRepository {
|
||||
...current.youtube,
|
||||
...(config.youtube ?? {}),
|
||||
},
|
||||
captcha: {
|
||||
...current.captcha,
|
||||
...(config.captcha ?? {}),
|
||||
},
|
||||
email: {
|
||||
...current.email,
|
||||
...(config.email ?? {}),
|
||||
@@ -1642,33 +1594,6 @@ export class InstanceConfigRepository {
|
||||
return integrations.youtube.api_key ?? normalizeOptionalString(Config.youtube.apiKey);
|
||||
}
|
||||
|
||||
async getEffectiveCaptchaConfig(): Promise<InstanceCaptchaEffectiveConfig> {
|
||||
const integrations = await this.getInstanceIntegrationsConfig();
|
||||
const provider = integrations.captcha.provider ?? (Config.captcha.enabled ? Config.captcha.provider : 'none');
|
||||
const hcaptchaSiteKey =
|
||||
integrations.captcha.hcaptcha_site_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.siteKey);
|
||||
const hcaptchaSecretKey =
|
||||
integrations.captcha.hcaptcha_secret_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.secretKey);
|
||||
const turnstileSiteKey =
|
||||
integrations.captcha.turnstile_site_key ?? normalizeOptionalString(Config.captcha.turnstile?.siteKey);
|
||||
const turnstileSecretKey =
|
||||
integrations.captcha.turnstile_secret_key ?? normalizeOptionalString(Config.captcha.turnstile?.secretKey);
|
||||
const providerReady =
|
||||
provider === 'hcaptcha'
|
||||
? Boolean(hcaptchaSiteKey && hcaptchaSecretKey)
|
||||
: provider === 'turnstile'
|
||||
? Boolean(turnstileSiteKey && turnstileSecretKey)
|
||||
: false;
|
||||
return {
|
||||
enabled: providerReady,
|
||||
provider: providerReady ? provider : 'none',
|
||||
hcaptcha_site_key: hcaptchaSiteKey,
|
||||
hcaptcha_secret_key: hcaptchaSecretKey,
|
||||
turnstile_site_key: turnstileSiteKey,
|
||||
turnstile_secret_key: turnstileSecretKey,
|
||||
};
|
||||
}
|
||||
|
||||
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
|
||||
const integrations = await this.getInstanceIntegrationsConfig();
|
||||
const provider = integrations.email.provider ?? Config.email.provider;
|
||||
@@ -1731,11 +1656,10 @@ export class InstanceConfigRepository {
|
||||
}
|
||||
|
||||
async getInstanceIntegrationsAdminConfig(): Promise<InstanceIntegrationsAdminConfig> {
|
||||
const [integrations, gif, youtubeApiKey, captcha, email, bluesky] = await Promise.all([
|
||||
const [integrations, gif, youtubeApiKey, email, bluesky] = await Promise.all([
|
||||
this.getInstanceIntegrationsConfig(),
|
||||
this.getEffectiveGifConfig(),
|
||||
this.getEffectiveYoutubeApiKey(),
|
||||
this.getEffectiveCaptchaConfig(),
|
||||
this.getEffectiveEmailConfig(),
|
||||
this.getEffectiveBlueskyConfig(),
|
||||
]);
|
||||
@@ -1748,17 +1672,6 @@ export class InstanceConfigRepository {
|
||||
api_key_set: secretIsSet(integrations.youtube.api_key) || secretIsSet(Config.youtube.apiKey),
|
||||
effective_available: Boolean(youtubeApiKey),
|
||||
},
|
||||
captcha: {
|
||||
provider: integrations.captcha.provider,
|
||||
effective_provider: captcha.provider,
|
||||
hcaptcha_site_key: captcha.hcaptcha_site_key,
|
||||
hcaptcha_secret_key_set:
|
||||
secretIsSet(integrations.captcha.hcaptcha_secret_key) || secretIsSet(Config.captcha.hcaptcha?.secretKey),
|
||||
turnstile_site_key: captcha.turnstile_site_key,
|
||||
turnstile_secret_key_set:
|
||||
secretIsSet(integrations.captcha.turnstile_secret_key) || secretIsSet(Config.captcha.turnstile?.secretKey),
|
||||
effective_enabled: captcha.enabled,
|
||||
},
|
||||
email: {
|
||||
enabled: integrations.email.enabled,
|
||||
effective_enabled: email.enabled,
|
||||
|
||||
@@ -14,8 +14,6 @@ import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface DiscoveryCaptcha {
|
||||
provider: string;
|
||||
hcaptcha_site_key: string | null;
|
||||
turnstile_site_key: string | null;
|
||||
}
|
||||
|
||||
describe('InstanceController discovery captcha', () => {
|
||||
@@ -62,40 +60,15 @@ describe('InstanceController discovery captcha', () => {
|
||||
return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha;
|
||||
}
|
||||
|
||||
it('advertises no provider and no site key while the selected pair is incomplete', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'none',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: null,
|
||||
});
|
||||
it('advertises altcha by default', async () => {
|
||||
await expect(readCaptcha(createRepository())).resolves.toEqual({provider: 'altcha'});
|
||||
});
|
||||
|
||||
it('advertises only the site key that matches the named provider', async () => {
|
||||
it('advertises no provider once an admin turns the captcha off', async () => {
|
||||
const repository = createRepository();
|
||||
await repository.setInstanceIntegrationsConfig({
|
||||
captcha: {
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
turnstile_secret_key: 'turnstile-secret-key',
|
||||
},
|
||||
});
|
||||
await repository.updateCaptchaConfig({enabled: false});
|
||||
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({
|
||||
provider: 'turnstile',
|
||||
hcaptcha_site_key: null,
|
||||
turnstile_site_key: 'turnstile-site-key',
|
||||
});
|
||||
await expect(readCaptcha(repository)).resolves.toEqual({provider: 'none'});
|
||||
});
|
||||
|
||||
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isDiscoveryNotModified,
|
||||
nextDiscoveryValidators,
|
||||
} from '@app/api/instance/DiscoveryValidators';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
@@ -17,6 +16,7 @@ import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
|
||||
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
|
||||
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
|
||||
import type {CaptchaConfig} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import type {Hono} from 'hono';
|
||||
@@ -27,7 +27,7 @@ function buildDiscoveryStaticInput(
|
||||
gifService: GifService | undefined,
|
||||
appPublic: InstanceAppPublic,
|
||||
runtime: {
|
||||
captcha: InstanceCaptchaEffectiveConfig;
|
||||
captcha: CaptchaConfig;
|
||||
emailEnabled: boolean;
|
||||
},
|
||||
): DiscoveryStaticInput {
|
||||
@@ -61,9 +61,7 @@ function buildDiscoveryStaticInput(
|
||||
webapp: Config.endpoints.webApp,
|
||||
},
|
||||
captcha: {
|
||||
provider: runtime.captcha.provider,
|
||||
hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null,
|
||||
turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null,
|
||||
provider: runtime.captcha.enabled ? 'altcha' : 'none',
|
||||
},
|
||||
features: {
|
||||
voice_enabled: Config.voice.enabled,
|
||||
@@ -111,7 +109,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
instanceConfigRepository.getInstanceCommunityPublicConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getEffectiveCaptchaConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getEffectiveEmailConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
]);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import {createHmac} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
|
||||
import {sharedListHas} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -13,22 +11,19 @@ import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
|
||||
import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
|
||||
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
|
||||
import type {CaptchaConfig} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
|
||||
const TEST_ENABLE_CAPTCHA_HEADER = 'x-fluxer-test-enable-captcha';
|
||||
|
||||
function deriveAltchaSecret(label: string): string {
|
||||
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
|
||||
}
|
||||
|
||||
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
|
||||
function createAltchaProvider(config: CaptchaConfig): AltchaProvider {
|
||||
return new AltchaProvider({
|
||||
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
|
||||
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
|
||||
@@ -40,85 +35,27 @@ function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
|
||||
});
|
||||
}
|
||||
|
||||
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
|
||||
if (!altcha) return undefined;
|
||||
async function altchaChallengeData(altcha: AltchaProvider): Promise<Record<string, unknown>> {
|
||||
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
|
||||
}
|
||||
|
||||
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
|
||||
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
|
||||
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
|
||||
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
|
||||
return createAltchaProvider(config);
|
||||
}
|
||||
|
||||
function resolveProviderSecret(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
provider: InstanceCaptchaProvider,
|
||||
): string | null {
|
||||
if (provider === 'hcaptcha') {
|
||||
return config.hcaptcha_secret_key;
|
||||
}
|
||||
if (provider === 'turnstile') {
|
||||
return config.turnstile_secret_key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCaptchaProvider(
|
||||
config: InstanceCaptchaEffectiveConfig,
|
||||
requestedType: string | undefined,
|
||||
): ICaptchaProvider {
|
||||
if (Config.dev.testModeEnabled) {
|
||||
return createCaptchaProvider({mode: 'test'});
|
||||
}
|
||||
const requestedProvider =
|
||||
requestedType === 'hcaptcha' || requestedType === 'turnstile'
|
||||
? requestedType
|
||||
: config.provider === 'hcaptcha' || config.provider === 'turnstile'
|
||||
? config.provider
|
||||
: null;
|
||||
if (!requestedProvider) {
|
||||
throw new Error('Captcha is enabled but no provider is configured');
|
||||
}
|
||||
const secretKey = resolveProviderSecret(config, requestedProvider);
|
||||
if (!secretKey) {
|
||||
throw new InvalidCaptchaError();
|
||||
}
|
||||
return createCaptchaProvider({mode: requestedProvider, secretKey});
|
||||
}
|
||||
|
||||
export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||
const captchaConfig = await ctx.get('instanceConfigRepository').getEffectiveCaptchaConfig();
|
||||
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
|
||||
export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<boolean> {
|
||||
if (Config.dev.testModeEnabled && ctx.req.header(TEST_ENABLE_CAPTCHA_HEADER) !== 'true') return false;
|
||||
const config = await ctx.get('instanceConfigRepository').getCaptchaConfig();
|
||||
if (!config.enabled) return false;
|
||||
const user = ctx.get('user') as User | undefined;
|
||||
if (sharedListHas('email_domain_exempt', extractEmailDomain(user?.email))) return;
|
||||
if (userHasCaptchaExemptFlag(user)) return;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
|
||||
const altcha = await resolveAltchaProvider(ctx, user);
|
||||
if (sharedListHas('email_domain_exempt', extractEmailDomain(user?.email))) return false;
|
||||
if (userHasCaptchaExemptFlag(user)) return false;
|
||||
if (await requestUserHasCaptchaExemptFlag(ctx)) return false;
|
||||
const altcha = createAltchaProvider(config);
|
||||
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
|
||||
if (!token) {
|
||||
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
|
||||
}
|
||||
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
|
||||
if (requestedType === 'altcha') {
|
||||
if (!altcha || !(await altcha.verify({token}))) {
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
|
||||
const isValid = await provider.verify({
|
||||
token,
|
||||
remoteIp:
|
||||
extractClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
}) ?? undefined,
|
||||
});
|
||||
if (!isValid) {
|
||||
if (!(await altcha.verify({token}))) {
|
||||
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function userHasCaptchaExemptFlag(user: User | null | undefined): boolean {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import {CAPTCHA_TEST_HEADER} from '@app/api/test/CaptchaTestUtils';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {type CaptchaConfig, DEFAULT_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CHEAP_CAPTCHA_CONFIG: CaptchaConfig = {...DEFAULT_CAPTCHA_CONFIG, cost: 1000, max_counter: 100};
|
||||
|
||||
function createHarness(captcha: CaptchaConfig): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getCaptchaConfig: async () => captcha,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
await next();
|
||||
});
|
||||
app.use(CaptchaMiddleware);
|
||||
app.post('/auth/register', (ctx) => ctx.text('ok'));
|
||||
app.onError(AppErrorHandler);
|
||||
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
|
||||
}
|
||||
|
||||
describe('CaptchaMiddleware', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
it('passes every request while the captcha is disabled', async () => {
|
||||
const response = await createHarness({...CHEAP_CAPTCHA_CONFIG, enabled: false})({});
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('passes a test-mode request that does not opt in', async () => {
|
||||
Config.dev.testModeEnabled = true;
|
||||
const request = createHarness(CHEAP_CAPTCHA_CONFIG);
|
||||
expect((await request({})).status).toBe(200);
|
||||
expect((await request({[CAPTCHA_TEST_HEADER]: 'true'})).status).toBe(400);
|
||||
});
|
||||
|
||||
it('answers a request with no token with CAPTCHA_REQUIRED and an ALTCHA challenge', async () => {
|
||||
const response = await createHarness(CHEAP_CAPTCHA_CONFIG)({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'CAPTCHA_REQUIRED',
|
||||
captcha_provider: 'altcha',
|
||||
altcha_challenge: {parameters: {algorithm: 'PBKDF2/SHA-256', cost: 1000}},
|
||||
});
|
||||
});
|
||||
|
||||
it('answers a token that is not an ALTCHA payload with INVALID_CAPTCHA and a fresh challenge', async () => {
|
||||
const response = await createHarness(CHEAP_CAPTCHA_CONFIG)({'x-captcha-token': 'legacy-provider-token'});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
code: 'INVALID_CAPTCHA',
|
||||
captcha_provider: 'altcha',
|
||||
altcha_challenge: {parameters: {algorithm: 'PBKDF2/SHA-256'}},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {
|
||||
InstanceCaptchaEffectiveConfig,
|
||||
InstanceConfigRepository,
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
|
||||
import {Hono} from 'hono';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const HCAPTCHA_ONLY: InstanceCaptchaEffectiveConfig = {
|
||||
enabled: true,
|
||||
provider: 'hcaptcha',
|
||||
hcaptcha_site_key: 'hcaptcha-site-key',
|
||||
hcaptcha_secret_key: 'hcaptcha-secret-key',
|
||||
turnstile_site_key: null,
|
||||
turnstile_secret_key: null,
|
||||
};
|
||||
|
||||
function createHarness(
|
||||
captcha: InstanceCaptchaEffectiveConfig,
|
||||
): (headers: Record<string, string>) => Promise<Response> {
|
||||
const repository = {
|
||||
getEffectiveCaptchaConfig: async () => captcha,
|
||||
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
|
||||
} as unknown as InstanceConfigRepository;
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.set('instanceConfigRepository', repository);
|
||||
await next();
|
||||
});
|
||||
app.use(CaptchaMiddleware);
|
||||
app.post('/auth/register', (ctx) => ctx.text('ok'));
|
||||
app.onError(AppErrorHandler);
|
||||
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
|
||||
}
|
||||
|
||||
describe('CaptchaMiddleware provider header', () => {
|
||||
let previousTestModeEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.dev.testModeEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
});
|
||||
|
||||
it('rejects a header naming a provider the instance holds no secret key for with 400 INVALID_CAPTCHA', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({'x-captcha-token': 'solution', 'x-captcha-type': 'turnstile'});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'INVALID_CAPTCHA'});
|
||||
});
|
||||
|
||||
it('rejects a request with no proof with 400 CAPTCHA_REQUIRED', async () => {
|
||||
const request = createHarness(HCAPTCHA_ONLY);
|
||||
const response = await request({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({code: 'CAPTCHA_REQUIRED'});
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export function OAuth2ApplicationsController(app: HonoApp) {
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['OAuth2'],
|
||||
description:
|
||||
'Creates a new bot-backed OAuth2 application (client). Requires CAPTCHA verification. Returns client credentials including ID and secret. Application can be used for authorization flows and API access.',
|
||||
'Creates a new bot-backed OAuth2 application (client). Requires a solved captcha challenge (X-Captcha-Token). Returns client credentials including ID and secret. Application can be used for authorization flows and API access.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {CAPTCHA_TEST_HEADER, issueSolvedCaptchaToken, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
@@ -18,19 +18,6 @@ interface ValidationErrorResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
describe('OAuth2 Application Create', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
@@ -94,24 +81,24 @@ describe('OAuth2 Application Create', () => {
|
||||
});
|
||||
test('requires captcha when creating a bot application', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilder(harness, account.token)
|
||||
.post('/oauth2/applications')
|
||||
.body({name: createUniqueApplicationName()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute(),
|
||||
);
|
||||
await useCheapCaptcha();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/oauth2/applications')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({name: createUniqueApplicationName()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
test('creates a bot application with a valid captcha token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilder(harness, account.token)
|
||||
.post('/oauth2/applications')
|
||||
.header('x-captcha-token', 'test-captcha-token')
|
||||
.body({name: createUniqueApplicationName()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute(),
|
||||
);
|
||||
await useCheapCaptcha();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/oauth2/applications')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
|
||||
.body({name: createUniqueApplicationName()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('rejects missing name', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
|
||||
"description": "Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires a solved captcha challenge (X-Captcha-Token).",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForgotPasswordRequest"}}}
|
||||
@@ -745,7 +745,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.",
|
||||
"description": "Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoginRequest"}}}
|
||||
@@ -1380,7 +1380,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.",
|
||||
"description": "Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.",
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RegisterRequest"}}}
|
||||
@@ -4683,7 +4683,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.",
|
||||
"description": "Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires a solved captcha challenge (X-Captcha-Token).",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -10238,7 +10238,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Creates a new bot-backed OAuth2 application (client). Requires CAPTCHA verification. Returns client credentials including ID and secret. Application can be used for authorization flows and API access.",
|
||||
"description": "Creates a new bot-backed OAuth2 application (client). Requires a solved captcha challenge (X-Captcha-Token). Returns client credentials including ID and secret. Application can be used for authorization flows and API access.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -13674,7 +13674,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires CAPTCHA verification. Returns the newly created channel object.",
|
||||
"description": "Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires a solved captcha challenge (X-Captcha-Token). Returns the newly created channel object.",
|
||||
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -18392,7 +18392,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel=\"sms\" to request SMS (only honoured for SMS-allowlisted destinations) or channel=\"inbound_challenge\" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge.",
|
||||
"description": "Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel=\"sms\" to request SMS (only honoured for SMS-allowlisted destinations) or channel=\"inbound_challenge\" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge. Requires a solved captcha challenge (X-Captcha-Token) when the phone verification service asks for one.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -27939,10 +27939,7 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50},
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
|
||||
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
|
||||
},
|
||||
"properties": {"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
@@ -29905,16 +29902,11 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"description": "Captcha provider name (hcaptcha, turnstile, none)",
|
||||
"description": "Captcha provider (altcha or none)",
|
||||
"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"
|
||||
},
|
||||
"hcaptcha_site_key": {"description": "hCaptcha site key if using hCaptcha", "type": ["string", "null"]},
|
||||
"turnstile_site_key": {
|
||||
"description": "Cloudflare Turnstile site key if using Turnstile",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
},
|
||||
"required": ["provider", "hcaptcha_site_key", "turnstile_site_key"],
|
||||
"required": ["provider"],
|
||||
"additionalProperties": false,
|
||||
"description": "Captcha configuration"
|
||||
},
|
||||
@@ -29949,7 +29941,7 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Endpoint URLs for various services"
|
||||
},
|
||||
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
|
||||
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["altcha", "none"]},
|
||||
"InstanceRegistrationModeSchema": {
|
||||
"description": "Registration mode",
|
||||
"x-enumNames": ["open", "approval", "closed"],
|
||||
@@ -31640,12 +31632,6 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
|
||||
"AltchaCaptchaAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DomainMigrationAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterEach, beforeEach, describe, test} from 'vitest';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Gift Code Redeem Captcha', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
await useCheapCaptcha();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('rejects an unauthenticated redeem before reading the captcha', async () => {
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilderWithoutAuth(harness)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.UNAUTHORIZED)
|
||||
.execute(),
|
||||
);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
test('requires captcha when redeeming with a credential', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await withCaptchaEnabled(async () =>
|
||||
createBuilder(harness, account.token)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute(),
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/gifts/test-gift-code/redeem')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {solveChallenge} from 'altcha-lib';
|
||||
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
|
||||
import type {Challenge} from 'altcha-lib/types';
|
||||
|
||||
export const CAPTCHA_TEST_HEADER = 'x-fluxer-test-enable-captcha';
|
||||
|
||||
export interface CaptchaErrorBody {
|
||||
code: string;
|
||||
captcha_provider?: string;
|
||||
altcha_challenge?: Challenge;
|
||||
}
|
||||
|
||||
export async function useCheapCaptcha(): Promise<void> {
|
||||
await getInstanceConfigRepository().updateCaptchaConfig({enabled: true, cost: 1000, max_counter: 100});
|
||||
}
|
||||
|
||||
export async function solveCaptchaChallenge(body: CaptchaErrorBody): Promise<string> {
|
||||
const challenge = body.altcha_challenge;
|
||||
if (!challenge) throw new Error('The response carried no ALTCHA challenge');
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('The ALTCHA challenge was not solved');
|
||||
const payload = {challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution};
|
||||
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
export async function issueSolvedCaptchaToken(harness: ApiTestHarness): Promise<string> {
|
||||
const {json} = await createBuilderWithoutAuth<CaptchaErrorBody>(harness)
|
||||
.post('/auth/forgot')
|
||||
.header(CAPTCHA_TEST_HEADER, 'true')
|
||||
.body({email: '[email protected]'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.executeWithResponse();
|
||||
return await solveCaptchaChallenge(json);
|
||||
}
|
||||
@@ -89,8 +89,6 @@ function setDefaultTestEnv(): void {
|
||||
FLUXER_SEARCH_ENGINE: 'elasticsearch',
|
||||
FLUXER_SEARCH_URL: 'http://127.0.0.1:9200',
|
||||
FLUXER_SEARCH_API_KEY: 'test',
|
||||
FLUXER_CAPTCHA_ENABLED: 'false',
|
||||
FLUXER_CAPTCHA_PROVIDER: 'none',
|
||||
FLUXER_DISCOVERY_ENABLED: 'true',
|
||||
FLUXER_RELAX_REGISTRATION_RATE_LIMITS: 'true',
|
||||
FLUXER_DISABLE_RATE_LIMITS: 'true',
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
|
||||
import {verifyCaptchaToken} from '@app/api/middleware/CaptchaMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {
|
||||
DisableTotpRequest,
|
||||
@@ -234,7 +236,7 @@ export function UserAuthController(app: HonoApp) {
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel="sms" to request SMS (only honoured for SMS-allowlisted destinations) or channel="inbound_challenge" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge.',
|
||||
'Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel="sms" to request SMS (only honoured for SMS-allowlisted destinations) or channel="inbound_challenge" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge. Requires a solved captcha challenge (X-Captcha-Token) when the phone verification service asks for one.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
@@ -245,6 +247,8 @@ export function UserAuthController(app: HonoApp) {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
}),
|
||||
hasCaptchaToken: ctx.req.header(Headers.X_CAPTCHA_TOKEN) !== undefined,
|
||||
verifyCaptcha: () => verifyCaptchaToken(ctx),
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ export function UserChannelController(app: HonoApp) {
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
'Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires CAPTCHA verification. Returns the newly created channel object.',
|
||||
'Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires a solved captcha challenge (X-Captcha-Token). Returns the newly created channel object.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -126,13 +126,19 @@ export class UserAuthRequestService {
|
||||
user,
|
||||
data,
|
||||
clientIp,
|
||||
hasCaptchaToken,
|
||||
verifyCaptcha,
|
||||
}: UserAuthRequest<PhoneSendVerificationRequest> & {
|
||||
clientIp: string;
|
||||
hasCaptchaToken: boolean;
|
||||
verifyCaptcha: () => Promise<boolean>;
|
||||
}): Promise<PhoneSendVerificationResponse> {
|
||||
await this.assertPhoneEligible(user);
|
||||
const result = await AuthPhone.sendPhoneVerificationCode(this.apiContext, data.phone, user.id, {
|
||||
clientIp,
|
||||
channel: data.channel,
|
||||
hasCaptchaToken,
|
||||
verifyCaptcha,
|
||||
});
|
||||
if (result.channel === 'inbound_challenge') {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user