feat: Toggle to "Disable new IP Login authorization" emails in /admin (#1030)

Co-authored-by: Jiralite <[email protected]>
Co-authored-by: Hampus <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-01 20:38:59 +02:00
1 parent 4a518b8e68
commit 6fd7c027e3
10 files changed
+149 -44

No files matched your search

@@ -314,6 +314,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
provider: readOptionalField(data.integrations.email, 'provider'),
from_email: readOptionalField(data.integrations.email, 'from_email'),
from_name: readOptionalField(data.integrations.email, 'from_name'),
disable_new_ip_authorization: readOptionalField(data.integrations.email, 'disable_new_ip_authorization'),
}),
smtp: data.integrations.email.smtp
? omitUndefinedFields({
+48 -38
View File
@@ -30,6 +30,7 @@ import {REGISTRATION_PENDING_APPROVAL_TRAIT, REGISTRATION_REJECTED_TRAIT} from '
import type {InviteService} from '../invite/InviteService';
import {Logger} from '../Logger';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import * as AuthMfa from './AuthMfa';
@@ -303,44 +304,53 @@ export async function login(
if (!hasMfa && !isAppStoreReviewer) {
const isIpAuthorized = await users.checkIpAuthorized(currentUser.id, clientIp);
if (!isIpAuthorized) {
const ticket = createIpAuthorizationTicket(await AuthUtility.generateSecureToken(ctx));
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
resendUsed: false,
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<{
ticket: string;
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
await users.createIpAuthorizationToken(currentUser.id, authToken, currentUser.email!);
await email.sendIpAuthorizationEmail(
currentUser.email!,
currentUser.username,
authToken,
clientIp,
clientLocation,
currentUser.locale,
);
throw new IpAuthorizationRequiredError({
ticket,
email: currentUser.email!,
resendAvailableIn: 30,
});
const instanceConfigRepository = getInstanceConfigRepository();
const [integrationsConfig, effectiveEmailConfig] = await Promise.all([
instanceConfigRepository.getInstanceIntegrationsConfig(),
instanceConfigRepository.getEffectiveEmailConfig(),
]);
if (integrationsConfig.email.disable_new_ip_authorization || !effectiveEmailConfig.enabled) {
await users.createAuthorizedIp(currentUser.id, clientIp);
} else {
const ticket = createIpAuthorizationTicket(await AuthUtility.generateSecureToken(ctx));
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
resendUsed: false,
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<{
ticket: string;
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
await users.createIpAuthorizationToken(currentUser.id, authToken, currentUser.email!);
await email.sendIpAuthorizationEmail(
currentUser.email!,
currentUser.username,
authToken,
clientIp,
clientLocation,
currentUser.locale,
);
throw new IpAuthorizationRequiredError({
ticket,
email: currentUser.email!,
resendAvailableIn: 30,
});
}
}
}
if (hasMfa) {
@@ -132,6 +132,7 @@ interface InstanceEmailIntegrationConfig {
from_email: string | null;
from_name: string | null;
smtp: InstanceEmailSmtpIntegrationConfig;
disable_new_ip_authorization: boolean | null;
}
interface InstanceBlueskyKeyIntegrationConfig {
@@ -204,6 +205,8 @@ interface InstanceIntegrationsAdminConfig {
password_set: boolean;
secure: boolean | null;
};
disable_new_ip_authorization: boolean;
effective_disable_new_ip_authorization: boolean;
};
bluesky: {
enabled: boolean | null;
@@ -454,6 +457,7 @@ const DEFAULT_INSTANCE_INTEGRATIONS_CONFIG: InstanceIntegrationsConfig = {
password: null,
secure: null,
},
disable_new_ip_authorization: null,
},
bluesky: {
enabled: null,
@@ -571,6 +575,7 @@ function normalizeInstanceIntegrationsConfig(value: unknown): InstanceIntegratio
password: normalizeSecretString(smtp.password),
secure: normalizeNullableBoolean(smtp.secure),
},
disable_new_ip_authorization: normalizeNullableBoolean(email.disable_new_ip_authorization),
},
bluesky: {
enabled: normalizeNullableBoolean(bluesky.enabled),
@@ -1334,6 +1339,8 @@ export class InstanceConfigRepository {
password_set: secretIsSet(integrations.email.smtp.password) || secretIsSet(Config.email.smtp?.password),
secure: email.smtp?.secure ?? null,
},
disable_new_ip_authorization: integrations.email.disable_new_ip_authorization ?? false,
effective_disable_new_ip_authorization: integrations.email.disable_new_ip_authorization || !email.enabled,
},
bluesky: {
enabled: integrations.bluesky.enabled,
+8 -2
View File
@@ -74,8 +74,14 @@ function setDefaultTestEnv(): void {
FLUXER_PASSKEY_RP_NAME: 'Fluxer Test',
FLUXER_PASSKEY_RP_ID: 'localhost',
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: 'http://localhost',
FLUXER_EMAIL_ENABLED: 'false',
FLUXER_EMAIL_PROVIDER: 'none',
FLUXER_EMAIL_ENABLED: 'true',
FLUXER_EMAIL_PROVIDER: 'smtp',
FLUXER_EMAIL_FROM_EMAIL: '[email protected]',
FLUXER_EMAIL_SMTP_HOST: 'localhost',
FLUXER_EMAIL_SMTP_PORT: '1025',
FLUXER_EMAIL_SMTP_USERNAME: 'test',
FLUXER_EMAIL_SMTP_PASSWORD: 'test',
FLUXER_EMAIL_SMTP_SECURE: 'false',
FLUXER_LIVEKIT_ENABLED: 'false',
FLUXER_STRIPE_ENABLED: 'true',
FLUXER_SEARCH_ENGINE: 'elasticsearch',
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, test} from 'vitest';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {clearTestEmails, createTestAccount, findLastTestEmail, listTestEmails} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {deleteOneOrMany, upsertOne} from '../../database/CassandraQueryExecution';
import {getInstanceConfigRepository, getUserRepository} from '../../middleware/ServiceSingletons';
import {AuthorizedIps} from '../../Tables';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
@@ -176,4 +177,61 @@ describe('User authorised IPs', () => {
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.IP_AUTHORIZATION_REQUIRED)
.execute();
});
test('disabling IP authorization via config allows login immediately from a new IP', async () => {
const account = await createTestAccount(harness);
const ip = '203.0.113.99';
const instanceConfigRepository = getInstanceConfigRepository();
await instanceConfigRepository.setInstanceIntegrationsConfig({
email: {
disable_new_ip_authorization: true,
},
});
try {
const login = await createBuilderWithoutAuth<LoginResponse>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
expect(login.token).toBeDefined();
const isAuthorized = await getUserRepository().checkIpAuthorized(createUserID(BigInt(account.userId)), ip);
expect(isAuthorized).toBe(true);
} finally {
await instanceConfigRepository.setInstanceIntegrationsConfig({
email: {
disable_new_ip_authorization: false,
},
});
}
});
test('disabling email provider allows login immediately from a new IP', async () => {
const account = await createTestAccount(harness);
const ip = '203.0.113.100';
await getInstanceConfigRepository().setInstanceIntegrationsConfig({
email: {
enabled: false,
provider: 'none',
disable_new_ip_authorization: false,
},
});
const login = await createBuilderWithoutAuth<LoginResponse>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
expect(login.token).toBeDefined();
const isAuthorized = await getUserRepository().checkIpAuthorized(createUserID(BigInt(account.userId)), ip);
expect(isAuthorized).toBe(true);
const emails = await listTestEmails(harness, {recipient: account.email});
expect(findLastTestEmail(emails, 'ip_authorization')).toBeNull();
});
});