mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
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:
co-authored by
Jiralite
Hampus
parent
4a518b8e68
commit
6fd7c027e3
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user