mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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:
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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user