Files
fluxer/fluxer_api/src/api/instance/SetupToken.ts
T

111 lines
4.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {APIConfig} from '@app/api/config/APIConfig';
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {createRateLimitError} from '@app/api/utils/RateLimitUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import {ms} from 'itty-time';
const SETUP_TOKEN_FAILURE_MAX_ATTEMPTS = 10;
const SETUP_TOKEN_FAILURE_WINDOW_MS = ms('30 minutes');
export function isSetupTokenEnforced(config: APIConfig): boolean {
return config.instance.selfHosted && (config.nodeEnv === 'production' || config.instance.setup.token !== null);
}
async function releaseAdminBootstrapOfMissingAccount(
repository: InstanceConfigRepository,
userExists: (userId: UserID) => Promise<boolean>,
): Promise<void> {
const claimant = await repository.getUnconfirmedAdminBootstrapClaimant();
if (claimant === null || (await userExists(claimant))) return;
await repository.releaseAdminBootstrap();
Logger.warn(
{user_id: claimant.toString()},
'The account that claimed the admin bootstrap does not exist. The claim is released and the setup token applies again',
);
}
export async function ensureSetupToken(
repository: InstanceConfigRepository,
userExists: (userId: UserID) => Promise<boolean>,
): Promise<void> {
if (!isSetupTokenEnforced(Config)) return;
await releaseAdminBootstrapOfMissingAccount(repository, userExists);
if (!(await repository.isSetupTokenGateOpen())) return;
if (Config.instance.setup.token !== null) {
Logger.info('Setup token read from FLUXER_SETUP_TOKEN');
return;
}
const token = await repository.createSetupTokenIfAbsent();
if (token === null) {
Logger.info(
'A setup token from an earlier start is still unused. Set FLUXER_SETUP_TOKEN to replace it if it is lost',
);
return;
}
Logger.warn(
{setup_token: token},
'Setup token generated. This instance has not recorded an admin account yet. The registration that presents this token becomes an admin. It is shown once',
);
}
export async function clearUsedSetupToken(repository: InstanceConfigRepository): Promise<void> {
try {
await repository.burnSetupToken();
} catch (error) {
Logger.error({error}, 'Could not clear the stored setup token after the admin bootstrap was claimed');
}
}
export async function assertSetupToken(params: {
rateLimit: IRateLimitService;
config: APIConfig;
repository: InstanceConfigRepository;
presented: string | null | undefined;
clientIp: string;
}): Promise<boolean> {
const {rateLimit, config, repository, clientIp} = params;
if (!isSetupTokenEnforced(config)) return false;
const setupConfigured = (await repository.getAppPublicConfig()).setup.configured;
const adminBootstrapped = setupConfigured
? await repository.isAdminBootstrapped()
: await repository.confirmAdminBootstrapped();
if (adminBootstrapped) return false;
const token = params.presented?.trim();
if (!token) {
if (!setupConfigured) {
throw InputValidationError.fromCode('setup_token', ValidationErrorCodes.SETUP_TOKEN_REQUIRED);
}
return false;
}
const failureLimit = config.dev.relaxRegistrationRateLimits
? null
: {
identifier: `setup-token:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
maxAttempts: SETUP_TOKEN_FAILURE_MAX_ATTEMPTS,
windowMs: SETUP_TOKEN_FAILURE_WINDOW_MS,
};
if (failureLimit) {
const failureState = await rateLimit.peekLimit(failureLimit);
if (failureState.remaining === 0) {
throw createRateLimitError({
...failureState,
allowed: false,
retryAfter: Math.ceil(failureLimit.windowMs / failureLimit.maxAttempts / 1000),
});
}
}
if (!(await repository.verifySetupToken(token))) {
if (failureLimit) await rateLimit.checkLimit(failureLimit);
throw InputValidationError.fromCode('setup_token', ValidationErrorCodes.SETUP_TOKEN_INVALID);
}
return true;
}