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:
pekempy
2026-07-01 20:38:59 +02:00
committed by GitHub
co-authored by Jiralite Hampus
parent 4a518b8e68
commit 6fd7c027e3
10 changed files with 149 additions and 44 deletions
+8 -3
View File
@@ -12642,7 +12642,9 @@
"secure": {"nullable": true, "type": "boolean"}
},
"required": ["host", "port", "username", "password_set", "secure"]
}
},
"disable_new_ip_authorization": {"type": "boolean"},
"effective_disable_new_ip_authorization": {"type": "boolean"}
},
"required": [
"enabled",
@@ -12651,7 +12653,9 @@
"effective_provider",
"from_email",
"from_name",
"smtp"
"smtp",
"disable_new_ip_authorization",
"effective_disable_new_ip_authorization"
]
},
"bluesky": {
@@ -13014,7 +13018,8 @@
"password": {"nullable": true, "type": "string", "maxLength": 4096},
"secure": {"nullable": true, "type": "boolean"}
}
}
},
"disable_new_ip_authorization": {"nullable": true, "type": "boolean"}
}
},
"bluesky": {
@@ -140,6 +140,10 @@ pub struct InstanceEmailIntegrationResponse {
pub from_name: Option<String>,
#[serde(default)]
pub smtp: InstanceEmailSmtpIntegrationResponse,
#[serde(default)]
pub disable_new_ip_authorization: bool,
#[serde(default)]
pub effective_disable_new_ip_authorization: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -579,6 +583,8 @@ pub struct InstanceEmailIntegrationUpdateRequest {
pub from_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub smtp: Option<InstanceEmailSmtpIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_new_ip_authorization: Option<bool>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -627,6 +627,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
password: clean("integration_smtp_password"),
secure: Some(form.bool_value("integration_smtp_secure")),
}),
disable_new_ip_authorization: Some(
form.bool_value("integration_email_disable_new_ip_authorization"),
),
}),
bluesky: Some(InstanceBlueskyIntegrationUpdateRequest {
enabled: Some(form.bool_value("integration_bluesky_enabled")),
@@ -481,6 +481,11 @@ fn integrations_config_section(
} @else {
(badge("Effective: disabled", BadgeVariant::Default))
}
@if integrations.email.effective_disable_new_ip_authorization {
(badge("IP auth disabled", BadgeVariant::Warning))
} @else {
(badge("IP auth required", BadgeVariant::Default))
}
(secret_badge("SMTP password", integrations.email.smtp.password_set))
}
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
@@ -518,6 +523,7 @@ fn integrations_config_section(
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
@@ -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();
});
});
@@ -588,6 +588,8 @@ const InstanceIntegrationsResponse = z.object({
password_set: z.boolean(),
secure: z.boolean().nullable(),
}),
disable_new_ip_authorization: z.boolean(),
effective_disable_new_ip_authorization: z.boolean(),
}),
bluesky: z.object({
enabled: z.boolean().nullable(),
@@ -676,6 +678,7 @@ export const InstanceConfigUpdateRequest = z.object({
secure: z.boolean().nullish(),
})
.nullish(),
disable_new_ip_authorization: z.boolean().nullish(),
})
.nullish(),
bluesky: z