mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(setup): add a setup token for first-run setup (#3437)
This commit is contained in:
1 parent
8e39380ee6
commit
78272521bb
135 files changed
+3045
-771
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config, getConfig} from '@app/api/Config';
|
||||
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {getLegalUrls, setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
|
||||
import {getInstanceProductName, setCachedProductName} from '@app/api/instance/ProductName';
|
||||
import {ensureSetupToken, isSetupTokenEnforced} from '@app/api/instance/SetupToken';
|
||||
import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceConfigWriteRaceExecutor';
|
||||
import {dockerAvailable, startPostgresKv, stopPostgresKv} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
@@ -38,6 +40,8 @@ const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
const POSTGRES_KV_TABLE = 'kv_instance_config_races';
|
||||
const SETUP_TOKEN_HASH_KEY = 'setup_token_hash';
|
||||
const ENV_SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
|
||||
describe('InstanceConfigRepository', () => {
|
||||
const repositories: Array<InstanceConfigRepository> = [];
|
||||
|
||||
@@ -945,6 +949,271 @@ function registrationUrlParams(maxUses: number | null) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('InstanceConfigRepository setup token', () => {
|
||||
const repositories: Array<InstanceConfigRepository> = [];
|
||||
const config = getConfig();
|
||||
let executor: InstanceConfigWriteRaceExecutor;
|
||||
let kvProvider: MockKVProvider;
|
||||
let originalSelfHosted: boolean;
|
||||
let originalNodeEnv: typeof config.nodeEnv;
|
||||
let originalSetupToken: typeof config.instance.setup.token;
|
||||
let originalSetupConfigured: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
executor = new InstanceConfigWriteRaceExecutor(new InMemoryCassandraQueryExecutor());
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
kvProvider = new MockKVProvider();
|
||||
originalSelfHosted = config.instance.selfHosted;
|
||||
originalNodeEnv = config.nodeEnv;
|
||||
originalSetupToken = config.instance.setup.token;
|
||||
originalSetupConfigured = config.instance.setup.configured;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
config.nodeEnv = originalNodeEnv;
|
||||
config.instance.setup.token = originalSetupToken;
|
||||
config.instance.setup.configured = originalSetupConfigured;
|
||||
for (const repository of repositories) {
|
||||
repository.shutdown();
|
||||
}
|
||||
repositories.length = 0;
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
});
|
||||
|
||||
function createPod(): InstanceConfigRepository {
|
||||
const repository = new InstanceConfigRepository(kvProvider);
|
||||
repositories.push(repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
function useSelfHostedProduction(): void {
|
||||
config.instance.selfHosted = true;
|
||||
config.nodeEnv = 'production';
|
||||
config.instance.setup.token = null;
|
||||
config.instance.setup.configured = false;
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
const accountExists = async () => true;
|
||||
const accountMissing = async () => false;
|
||||
|
||||
it('generates one setup token when two pods start together and stores only its hash', async () => {
|
||||
const tokens = await Promise.all([createPod().createSetupTokenIfAbsent(), createPod().createSetupTokenIfAbsent()]);
|
||||
const generated = tokens.filter((token) => token !== null);
|
||||
expect(generated).toHaveLength(1);
|
||||
expect(tokens.filter((token) => token === null)).toHaveLength(1);
|
||||
const stored = await executor.readDirectly(SETUP_TOKEN_HASH_KEY);
|
||||
expect(stored).toBe(sha256(generated[0]!));
|
||||
expect(stored).not.toBe(generated[0]);
|
||||
expect(await createPod().createSetupTokenIfAbsent()).toBeNull();
|
||||
});
|
||||
|
||||
it('verifies only the generated setup token', async () => {
|
||||
const pod = createPod();
|
||||
for (const presented of ['anything', '', null, undefined]) {
|
||||
expect(await pod.verifySetupToken(presented)).toBe(false);
|
||||
}
|
||||
const token = await pod.createSetupTokenIfAbsent();
|
||||
expect(await pod.verifySetupToken(token)).toBe(true);
|
||||
expect(await createPod().verifySetupToken(token)).toBe(true);
|
||||
for (const presented of [`${token}x`, sha256(token!), '', null, undefined]) {
|
||||
expect(await pod.verifySetupToken(presented)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('takes the environment setup token over a generated one', async () => {
|
||||
const pod = createPod();
|
||||
const generated = await pod.createSetupTokenIfAbsent();
|
||||
config.instance.setup.token = ENV_SETUP_TOKEN;
|
||||
expect(await pod.verifySetupToken(ENV_SETUP_TOKEN)).toBe(true);
|
||||
expect(await pod.verifySetupToken(generated)).toBe(false);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(sha256(generated!));
|
||||
});
|
||||
|
||||
it('burns the stored setup token and leaves it alone when the admin bootstrap is only claimed', async () => {
|
||||
useSelfHostedProduction();
|
||||
const pod = createPod();
|
||||
const token = await pod.createSetupTokenIfAbsent();
|
||||
await pod.claimAdminBootstrap();
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(sha256(token!));
|
||||
await pod.burnSetupToken();
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe('');
|
||||
expect(await pod.verifySetupToken(token)).toBe(false);
|
||||
expect(await pod.verifySetupToken('')).toBe(false);
|
||||
await ensureSetupToken(pod, accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe('');
|
||||
});
|
||||
|
||||
it('writes nothing when there is no stored setup token to burn', async () => {
|
||||
await createPod().burnSetupToken();
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('releases a claimed admin bootstrap so it can be claimed again', async () => {
|
||||
const pod = createPod();
|
||||
await pod.releaseAdminBootstrap();
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBeNull();
|
||||
expect(await pod.claimAdminBootstrap()).toBe(true);
|
||||
expect(await pod.isAdminBootstrapped()).toBe(true);
|
||||
await pod.releaseAdminBootstrap();
|
||||
expect(await pod.isAdminBootstrapped()).toBe(false);
|
||||
expect(await createPod().isAdminBootstrapped()).toBe(false);
|
||||
expect(await pod.claimAdminBootstrap()).toBe(true);
|
||||
expect(await pod.claimAdminBootstrap()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a won admin bootstrap claim when the refresh cannot be published', async () => {
|
||||
const pod = createPod();
|
||||
await pod.isAdminBootstrapped();
|
||||
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
|
||||
expect(await pod.claimAdminBootstrap()).toBe(true);
|
||||
expect(await pod.isAdminBootstrapped()).toBe(true);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
|
||||
});
|
||||
|
||||
it('enforces the setup token only on self-hosted instances that run in production or configure one', async () => {
|
||||
const pod = createPod();
|
||||
const gate = async () => ({
|
||||
enforced: isSetupTokenEnforced(config),
|
||||
open: await pod.isSetupTokenGateOpen(),
|
||||
required: await pod.isSetupTokenRequired(),
|
||||
});
|
||||
config.instance.setup.configured = false;
|
||||
config.instance.selfHosted = false;
|
||||
config.nodeEnv = 'production';
|
||||
config.instance.setup.token = ENV_SETUP_TOKEN;
|
||||
expect(await gate()).toEqual({enforced: false, open: false, required: false});
|
||||
config.instance.selfHosted = true;
|
||||
config.nodeEnv = 'development';
|
||||
config.instance.setup.token = null;
|
||||
expect(await gate()).toEqual({enforced: false, open: false, required: false});
|
||||
config.instance.setup.token = ENV_SETUP_TOKEN;
|
||||
expect(await gate()).toEqual({enforced: true, open: true, required: true});
|
||||
config.nodeEnv = 'production';
|
||||
config.instance.setup.token = null;
|
||||
expect(await gate()).toEqual({enforced: true, open: true, required: true});
|
||||
config.instance.setup.configured = true;
|
||||
expect(await gate()).toEqual({enforced: true, open: true, required: false});
|
||||
config.instance.setup.configured = false;
|
||||
expect(await gate()).toEqual({enforced: true, open: true, required: true});
|
||||
await pod.setAppPublicConfig({setup: {configured: true}});
|
||||
expect(await gate()).toEqual({enforced: true, open: true, required: false});
|
||||
await pod.claimAdminBootstrap();
|
||||
expect(await gate()).toEqual({enforced: true, open: false, required: false});
|
||||
});
|
||||
|
||||
it('stores a setup token on the first production start and keeps it on the next', async () => {
|
||||
useSelfHostedProduction();
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
const stored = await executor.readDirectly(SETUP_TOKEN_HASH_KEY);
|
||||
expect(stored).toMatch(/^[a-f0-9]{64}$/);
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBe(stored);
|
||||
});
|
||||
|
||||
it('stores a setup token when setup is configured but no admin exists', async () => {
|
||||
useSelfHostedProduction();
|
||||
config.instance.setup.configured = true;
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('stores no setup token when one is set in the environment', async () => {
|
||||
useSelfHostedProduction();
|
||||
config.instance.setup.token = ENV_SETUP_TOKEN;
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('stores no setup token on the hosted service or in self-hosted development', async () => {
|
||||
useSelfHostedProduction();
|
||||
config.instance.selfHosted = false;
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
config.instance.selfHosted = true;
|
||||
config.nodeEnv = 'development';
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('stores no setup token once the admin exists', async () => {
|
||||
useSelfHostedProduction();
|
||||
const pod = createPod();
|
||||
await pod.claimAdminBootstrap();
|
||||
await ensureSetupToken(pod, accountExists);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('releases an admin bootstrap claim at start when the claiming account was never created', async () => {
|
||||
useSelfHostedProduction();
|
||||
const pod = createPod();
|
||||
expect(await pod.claimAdminBootstrap(createUserID(4242n))).toBe(true);
|
||||
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBe('4242');
|
||||
await ensureSetupToken(createPod(), accountExists);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toBeNull();
|
||||
const restarted = createPod();
|
||||
await ensureSetupToken(restarted, accountMissing);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('false');
|
||||
expect(await restarted.isSetupTokenGateOpen()).toBe(true);
|
||||
expect(await executor.readDirectly(SETUP_TOKEN_HASH_KEY)).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('keeps an admin bootstrap claim at start when no claiming account is recorded', async () => {
|
||||
useSelfHostedProduction();
|
||||
const pod = createPod();
|
||||
await pod.claimAdminBootstrap();
|
||||
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBeNull();
|
||||
await ensureSetupToken(createPod(), accountMissing);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
|
||||
await pod.releaseAdminBootstrap();
|
||||
await pod.claimAdminBootstrap(createUserID(4242n));
|
||||
await pod.releaseAdminBootstrap();
|
||||
await pod.claimAdminBootstrap();
|
||||
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBe('');
|
||||
await ensureSetupToken(createPod(), accountMissing);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
|
||||
});
|
||||
|
||||
it('keeps a won admin bootstrap claim when the claiming account cannot be recorded', async () => {
|
||||
const pod = createPod();
|
||||
executor.watch('admin_bootstrap_user_id');
|
||||
executor.competeBeforeEachWrite(async () => {
|
||||
throw new Error('database down');
|
||||
});
|
||||
expect(await pod.claimAdminBootstrap(createUserID(4242n))).toBe(true);
|
||||
expect(await pod.isAdminBootstrapped()).toBe(true);
|
||||
expect(await executor.readDirectly('admin_bootstrapped')).toBe('true');
|
||||
expect(await executor.readDirectly('admin_bootstrap_user_id')).toBeNull();
|
||||
});
|
||||
|
||||
it('confirms the admin bootstrap against the database and repairs a stale snapshot', async () => {
|
||||
const stale = createPod();
|
||||
const writer = createPod();
|
||||
expect(await stale.isAdminBootstrapped()).toBe(false);
|
||||
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
|
||||
await writer.claimAdminBootstrap();
|
||||
expect(await stale.isAdminBootstrapped()).toBe(false);
|
||||
expect(await stale.confirmAdminBootstrapped()).toBe(true);
|
||||
expect(await stale.isAdminBootstrapped()).toBe(true);
|
||||
vi.spyOn(kvProvider, 'publish').mockRejectedValueOnce(new Error('kv down'));
|
||||
await writer.releaseAdminBootstrap();
|
||||
expect(await stale.isAdminBootstrapped()).toBe(true);
|
||||
expect(await stale.confirmAdminBootstrapped()).toBe(false);
|
||||
expect(await stale.isAdminBootstrapped()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to start when the setup token cannot be stored', async () => {
|
||||
useSelfHostedProduction();
|
||||
const pod = createPod();
|
||||
vi.spyOn(pod, 'createSetupTokenIfAbsent').mockRejectedValueOnce(new Error('database down'));
|
||||
await expect(ensureSetupToken(pod, accountExists)).rejects.toThrow('database down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceConfigRepository concurrent writes', () => {
|
||||
describe('in memory', () => {
|
||||
describeConcurrentInstanceConfigWrites(async () => new InMemoryCassandraQueryExecutor());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
|
||||
import type {StoredLimitConfig} from '@app/api/constants/LimitConfig';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import {InstanceConfigCache} from '@app/api/instance/InstanceConfigCache';
|
||||
import {setCachedConfiguredLegalUrls} from '@app/api/instance/LegalUrls';
|
||||
import {getDefaultProductName, setCachedProductName} from '@app/api/instance/ProductName';
|
||||
import {isSetupTokenEnforced} from '@app/api/instance/SetupToken';
|
||||
import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValidation';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {isStoredLimitConfig} from '@app/api/limits/LimitConfigValidation';
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
import {InstanceConfiguration, Users} from '@app/api/Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
|
||||
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
import {randomBase64Url, secretMatchesHash, sha256Hex} from '@app/api/utils/SecretHash';
|
||||
import {
|
||||
type AccountIdentityMode,
|
||||
AccountIdentityModes,
|
||||
@@ -120,6 +123,8 @@ const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const ADMIN_BOOTSTRAP_KEY = 'admin_bootstrapped';
|
||||
const ADMIN_BOOTSTRAP_USER_ID_KEY = 'admin_bootstrap_user_id';
|
||||
const SETUP_TOKEN_HASH_KEY = 'setup_token_hash';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
const LIMIT_CONFIG_KEY = 'limit_config';
|
||||
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
|
||||
@@ -1484,8 +1489,95 @@ export class InstanceConfigRepository {
|
||||
return (await this.getConfig(ADMIN_BOOTSTRAP_KEY)) === 'true';
|
||||
}
|
||||
|
||||
async markAdminBootstrapped(): Promise<void> {
|
||||
await this.setConfig(ADMIN_BOOTSTRAP_KEY, 'true');
|
||||
async confirmAdminBootstrapped(): Promise<boolean> {
|
||||
const cache = this.configCache;
|
||||
const stored = (await this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_KEY)) === 'true';
|
||||
if (stored !== (await this.isAdminBootstrapped())) cache.update(ADMIN_BOOTSTRAP_KEY, stored ? 'true' : 'false');
|
||||
return stored;
|
||||
}
|
||||
|
||||
async claimAdminBootstrap(userId: UserID | null = null): Promise<boolean> {
|
||||
const cache = this.configCache;
|
||||
const {written} = await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_KEY, (raw) => ({
|
||||
value: raw === 'true' ? null : 'true',
|
||||
result: undefined,
|
||||
}));
|
||||
if (!written) return false;
|
||||
await this.recordAdminBootstrapClaimant(cache, userId);
|
||||
await this.publishAdminBootstrapRefresh(cache.sourceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async recordAdminBootstrapClaimant(cache: InstanceConfigCache, userId: UserID | null): Promise<void> {
|
||||
const claimant = userId?.toString() ?? '';
|
||||
try {
|
||||
await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_USER_ID_KEY, (raw) => ({
|
||||
value: (raw ?? '') === claimant ? null : claimant,
|
||||
result: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Could not record which account claimed the admin bootstrap');
|
||||
}
|
||||
}
|
||||
|
||||
async getUnconfirmedAdminBootstrapClaimant(): Promise<UserID | null> {
|
||||
const [bootstrapped, claimant] = await Promise.all([
|
||||
this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_KEY),
|
||||
this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_USER_ID_KEY),
|
||||
]);
|
||||
if (bootstrapped !== 'true' || !claimant || !/^\d+$/.test(claimant)) return null;
|
||||
return createUserID(BigInt(claimant));
|
||||
}
|
||||
|
||||
async releaseAdminBootstrap(): Promise<void> {
|
||||
const cache = this.configCache;
|
||||
const {written} = await this.compareAndSetStoredValue(cache, ADMIN_BOOTSTRAP_KEY, (raw) => ({
|
||||
value: raw === 'true' ? 'false' : null,
|
||||
result: undefined,
|
||||
}));
|
||||
if (written) await this.publishAdminBootstrapRefresh(cache.sourceId);
|
||||
}
|
||||
|
||||
private async publishAdminBootstrapRefresh(sourceId: string): Promise<void> {
|
||||
try {
|
||||
await this.publishRefresh(sourceId);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error},
|
||||
'Could not publish the admin bootstrap change. Other API processes see it after the next instance config change or a restart',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async isSetupTokenGateOpen(): Promise<boolean> {
|
||||
return isSetupTokenEnforced(Config) && !(await this.isAdminBootstrapped());
|
||||
}
|
||||
|
||||
async isSetupTokenRequired(): Promise<boolean> {
|
||||
return (await this.isSetupTokenGateOpen()) && !(await this.getAppPublicConfig()).setup.configured;
|
||||
}
|
||||
|
||||
async createSetupTokenIfAbsent(): Promise<string | null> {
|
||||
const token = randomBase64Url(32);
|
||||
const {written} = await this.compareAndSetStoredValue(this.configCache, SETUP_TOKEN_HASH_KEY, (raw) => ({
|
||||
value: raw ? null : sha256Hex(token),
|
||||
result: undefined,
|
||||
}));
|
||||
return written ? token : null;
|
||||
}
|
||||
|
||||
async verifySetupToken(presented: string | null | undefined): Promise<boolean> {
|
||||
const expectedHash = Config.instance.setup.token
|
||||
? sha256Hex(Config.instance.setup.token)
|
||||
: await this.fetchConfigFromDatabase(SETUP_TOKEN_HASH_KEY);
|
||||
return secretMatchesHash(presented, expectedHash);
|
||||
}
|
||||
|
||||
async burnSetupToken(): Promise<void> {
|
||||
await this.compareAndSetStoredValue(this.configCache, SETUP_TOKEN_HASH_KEY, (raw) => ({
|
||||
value: raw ? '' : null,
|
||||
result: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAccountIdentity(): Promise<AccountIdentity> {
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
isDiscoveryNotModified,
|
||||
nextDiscoveryValidators,
|
||||
} from '@app/api/instance/DiscoveryValidators';
|
||||
import {assertSetupToken} from '@app/api/instance/SetupToken';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {isBillingActive, isPremiumTieringActive, isStripeServiceable} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {requireConfiguredClientIp} from '@app/api/utils/RequestClientIp';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
|
||||
@@ -130,10 +132,13 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getAccountIdentity(),
|
||||
]);
|
||||
const accountIdentityLocked =
|
||||
const openSetup =
|
||||
Config.instance.selfHosted && !appPublicConfig.setup.configured
|
||||
? await instanceConfigRepository.isAccountIdentityLocked()
|
||||
: undefined;
|
||||
? {
|
||||
account_identity_locked: await instanceConfigRepository.isAccountIdentityLocked(),
|
||||
setup_token_required: await instanceConfigRepository.isSetupTokenRequired(),
|
||||
}
|
||||
: {};
|
||||
const discovery = buildDiscoveryResponse(
|
||||
buildDiscoveryStaticInput(
|
||||
gifService,
|
||||
@@ -142,7 +147,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
setup: {
|
||||
...appPublicConfig.setup,
|
||||
admin_url: Config.endpoints.admin || null,
|
||||
...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}),
|
||||
...openSetup,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,15 +192,23 @@ export function InstanceController(app: Hono<HonoEnv>) {
|
||||
security: [],
|
||||
tags: ['Instance'],
|
||||
description:
|
||||
'Sets how people sign in on a new self-hosted instance, and for email sign-in whether usernames are unique with no tag. Username sign-in always uses unique usernames. It works only before setup is finished and before the first account exists. After that it fails with ACCOUNT_IDENTITY_LOCKED.',
|
||||
'Sets how people sign in on a new self-hosted instance, and for email sign-in whether usernames are unique with no tag. Username sign-in always uses unique usernames. It works only before setup is finished and before the first account exists. After that it fails with ACCOUNT_IDENTITY_LOCKED. While the instance requires a setup token, the request must send it as setup_token.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {mode, tag_style} = ctx.req.valid('json');
|
||||
const {mode, tag_style, setup_token} = ctx.req.valid('json');
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const {config, rateLimit, cache} = ctx.get('apiContext').services;
|
||||
await assertSetupToken({
|
||||
rateLimit,
|
||||
config,
|
||||
repository: instanceConfigRepository,
|
||||
presented: setup_token,
|
||||
clientIp: requireConfiguredClientIp(ctx.req.raw, config.proxy),
|
||||
});
|
||||
if (mode === AccountIdentityModes.USERNAME && tag_style === TagStyles.RANDOM) {
|
||||
throw InputValidationError.fromCode('tag_style', ValidationErrorCodes.TAG_STYLE_REQUIRES_EMAIL_SIGN_IN);
|
||||
}
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const identity = await withAccountIdentitySetupLock(ctx.get('apiContext').services.cache, async () => {
|
||||
const identity = await withAccountIdentitySetupLock(cache, async () => {
|
||||
if (await instanceConfigRepository.isAccountIdentityLocked()) {
|
||||
throw new AccountIdentityLockedError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createAuthHarness, createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCachedAccountIdentity, getCachedAccountIdentityMode} from '@app/api/instance/AccountIdentityModeCache';
|
||||
@@ -14,10 +15,12 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const ACCOUNT_IDENTITY_CONFIG_KEY = 'account_identity_config';
|
||||
const SETUP_TOKEN = 'setup-token-for-tests-0123456789-abcdefghijklmnopqrstuvwxyz';
|
||||
const WRONG_SETUP_TOKEN = 'wrong-setup-token-0123456789-abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
interface DiscoveryResponse {
|
||||
features: {account_identity: string; emails_enabled: boolean; tag_style: string};
|
||||
app_public: {setup: {configured: boolean; account_identity_locked?: boolean}};
|
||||
app_public: {setup: {configured: boolean; account_identity_locked?: boolean; setup_token_required?: boolean}};
|
||||
}
|
||||
|
||||
interface AdminInstanceConfigResponse {
|
||||
@@ -33,6 +36,7 @@ describe('Account identity mode', () => {
|
||||
let originalSetupConfigured: boolean;
|
||||
let originalAccountIdentity: typeof config.instance.accountIdentity;
|
||||
let originalTagStyle: typeof config.instance.tagStyle;
|
||||
let originalSetupToken: typeof config.instance.setup.token;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
@@ -45,6 +49,7 @@ describe('Account identity mode', () => {
|
||||
originalSetupConfigured = config.instance.setup.configured;
|
||||
originalAccountIdentity = config.instance.accountIdentity;
|
||||
originalTagStyle = config.instance.tagStyle;
|
||||
originalSetupToken = config.instance.setup.token;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -53,6 +58,7 @@ describe('Account identity mode', () => {
|
||||
config.instance.setup.configured = originalSetupConfigured;
|
||||
config.instance.accountIdentity = originalAccountIdentity;
|
||||
config.instance.tagStyle = originalTagStyle;
|
||||
config.instance.setup.token = originalSetupToken;
|
||||
repository.clearCacheForTesting();
|
||||
});
|
||||
afterAll(async () => {
|
||||
@@ -239,7 +245,7 @@ describe('Account identity mode', () => {
|
||||
|
||||
it('stamps email when the admin was bootstrapped', async () => {
|
||||
useFreshSelfHostedInstance();
|
||||
await repository.markAdminBootstrapped();
|
||||
await repository.claimAdminBootstrap();
|
||||
await repository.ensureAccountIdentityMode();
|
||||
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
||||
});
|
||||
@@ -321,7 +327,7 @@ describe('Account identity mode', () => {
|
||||
it('skips the users probe when a cheaper signal already decides', async () => {
|
||||
useFreshSelfHostedInstance();
|
||||
const probe = vi.spyOn(repository as unknown as {hasAnyUser: () => Promise<boolean>}, 'hasAnyUser');
|
||||
await repository.markAdminBootstrapped();
|
||||
await repository.claimAdminBootstrap();
|
||||
await repository.ensureAccountIdentityMode();
|
||||
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'existing_instance'});
|
||||
expect(await repository.isAccountIdentityLocked()).toBe(true);
|
||||
@@ -540,6 +546,77 @@ describe('Account identity mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup token', () => {
|
||||
async function putModeWithToken(setupToken?: string) {
|
||||
return await createBuilderWithoutAuth<{
|
||||
mode?: string;
|
||||
tag_style?: string;
|
||||
errors?: Array<{path: string; code: string}>;
|
||||
}>(harness)
|
||||
.put('/instance/setup/account-identity')
|
||||
.body(
|
||||
setupToken === undefined
|
||||
? {mode: AccountIdentityModes.EMAIL}
|
||||
: {mode: AccountIdentityModes.EMAIL, setup_token: setupToken},
|
||||
)
|
||||
.executeRaw();
|
||||
}
|
||||
|
||||
it('reports in discovery whether registration needs the setup token', async () => {
|
||||
useFreshSelfHostedInstance();
|
||||
await repository.ensureAccountIdentityMode();
|
||||
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
|
||||
config.instance.setup.token = SETUP_TOKEN;
|
||||
const {text, json} = await createBuilderWithoutAuth<DiscoveryResponse>(harness)
|
||||
.get('/.well-known/fluxer')
|
||||
.executeRaw();
|
||||
expect(json.app_public.setup.setup_token_required).toBe(true);
|
||||
expect(text).not.toContain(SETUP_TOKEN);
|
||||
expect(text).not.toContain(createHash('sha256').update(SETUP_TOKEN).digest('hex'));
|
||||
await repository.claimAdminBootstrap();
|
||||
expect((await fetchDiscovery()).app_public.setup.setup_token_required).toBe(false);
|
||||
await repository.setAppPublicConfig({setup: {configured: true}});
|
||||
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
|
||||
});
|
||||
|
||||
it('leaves the setup token out of discovery on the hosted service', async () => {
|
||||
config.instance.selfHosted = false;
|
||||
config.instance.setup.token = SETUP_TOKEN;
|
||||
await repository.setAppPublicConfig({setup: {configured: false}});
|
||||
expect((await fetchDiscovery()).app_public.setup).not.toHaveProperty('setup_token_required');
|
||||
});
|
||||
|
||||
it('needs the setup token to choose the sign-in method while one is required', async () => {
|
||||
useFreshSelfHostedInstance();
|
||||
await repository.ensureAccountIdentityMode();
|
||||
config.instance.setup.token = SETUP_TOKEN;
|
||||
const missing = await putModeWithToken();
|
||||
expect(missing.response.status).toBe(400);
|
||||
expect(missing.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
|
||||
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_REQUIRED},
|
||||
]);
|
||||
const wrong = await putModeWithToken(WRONG_SETUP_TOKEN);
|
||||
expect(wrong.response.status).toBe(400);
|
||||
expect(wrong.json.errors?.map(({path, code}) => ({path, code}))).toEqual([
|
||||
{path: 'setup_token', code: ValidationErrorCodes.SETUP_TOKEN_INVALID},
|
||||
]);
|
||||
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.USERNAME});
|
||||
const correct = await putModeWithToken(SETUP_TOKEN);
|
||||
expect(correct.response.status).toBe(200);
|
||||
expect(correct.json).toEqual({mode: AccountIdentityModes.EMAIL, tag_style: 'none'});
|
||||
expect(await readStoredRow()).toMatchObject({mode: AccountIdentityModes.EMAIL, source: 'setup'});
|
||||
});
|
||||
|
||||
it('chooses the sign-in method without a setup token when none is required', async () => {
|
||||
useFreshSelfHostedInstance();
|
||||
await repository.ensureAccountIdentityMode();
|
||||
const tokenless = await putModeWithToken();
|
||||
expect(tokenless.response.status).toBe(200);
|
||||
const ignored = await putModeWithToken(WRONG_SETUP_TOKEN);
|
||||
expect(ignored.response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin instance config', () => {
|
||||
async function fetchAdminConfig(token: string): Promise<AdminInstanceConfigResponse> {
|
||||
return await createBuilder<AdminInstanceConfigResponse>(harness, token).get('/admin/instance/config').execute();
|
||||
|
||||
Reference in new issue
Block a user