mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(api): remove Flutter client auth gates (#1513)
This commit is contained in:
1 parent
6a23ec30c6
commit
ec1182d34d
9 files changed
+4
-117
No files matched your search
@@ -37,7 +37,6 @@ import * as AuthMfa from './AuthMfa';
|
|||||||
import * as AuthPassword from './AuthPassword';
|
import * as AuthPassword from './AuthPassword';
|
||||||
import * as AuthSession from './AuthSession';
|
import * as AuthSession from './AuthSession';
|
||||||
import * as AuthUtility from './AuthUtility';
|
import * as AuthUtility from './AuthUtility';
|
||||||
import {assertFlutterClientLoginAllowed, type FlutterClientGateMemberRepository} from './FlutterClientGate';
|
|
||||||
|
|
||||||
function createRequestCache(): RequestCache {
|
function createRequestCache(): RequestCache {
|
||||||
const userPartials = new Map();
|
const userPartials = new Map();
|
||||||
@@ -73,7 +72,6 @@ interface LoginMfaWebAuthnParams {
|
|||||||
export interface LoginDependencies {
|
export interface LoginDependencies {
|
||||||
inviteService: InviteService | null;
|
inviteService: InviteService | null;
|
||||||
kvDeletionQueue: KVAccountDeletionQueueService;
|
kvDeletionQueue: KVAccountDeletionQueueService;
|
||||||
flutterClientGateMemberRepository: FlutterClientGateMemberRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginTokenResult {
|
interface LoginTokenResult {
|
||||||
@@ -219,7 +217,7 @@ export async function login(
|
|||||||
{data, request}: LoginParams,
|
{data, request}: LoginParams,
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
const {users, cache, rateLimit, email, config} = ctx.services;
|
const {users, cache, rateLimit, email, config} = ctx.services;
|
||||||
const {inviteService, kvDeletionQueue, flutterClientGateMemberRepository} = deps;
|
const {inviteService, kvDeletionQueue} = deps;
|
||||||
const skipRateLimits = config.dev.testModeEnabled || config.dev.disableRateLimits;
|
const skipRateLimits = config.dev.testModeEnabled || config.dev.disableRateLimits;
|
||||||
const emailRateLimit = await rateLimit.checkLimit({
|
const emailRateLimit = await rateLimit.checkLimit({
|
||||||
identifier: `login:email:${data.email}`,
|
identifier: `login:email:${data.email}`,
|
||||||
@@ -259,7 +257,6 @@ export async function login(
|
|||||||
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
await assertFlutterClientLoginAllowed(request, user, flutterClientGateMemberRepository);
|
|
||||||
let currentUser = await AuthUtility.handleBanStatus(ctx, user);
|
let currentUser = await AuthUtility.handleBanStatus(ctx, user);
|
||||||
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
|
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
|
||||||
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
|
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
|
||||||
@@ -380,7 +377,6 @@ const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
|
|||||||
|
|
||||||
export async function loginMfaTotp(
|
export async function loginMfaTotp(
|
||||||
ctx: ApiContext,
|
ctx: ApiContext,
|
||||||
deps: Pick<LoginDependencies, 'flutterClientGateMemberRepository'>,
|
|
||||||
{code, ticket, request}: LoginMfaTotpParams,
|
{code, ticket, request}: LoginMfaTotpParams,
|
||||||
): Promise<LoginTokenResult> {
|
): Promise<LoginTokenResult> {
|
||||||
const {users, cache} = ctx.services;
|
const {users, cache} = ctx.services;
|
||||||
@@ -393,7 +389,6 @@ export async function loginMfaTotp(
|
|||||||
throw new UnknownUserError();
|
throw new UnknownUserError();
|
||||||
}
|
}
|
||||||
AuthUtility.assertNonBotUser(ctx, user);
|
AuthUtility.assertNonBotUser(ctx, user);
|
||||||
await assertFlutterClientLoginAllowed(request, user, deps.flutterClientGateMemberRepository);
|
|
||||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||||
}
|
}
|
||||||
@@ -429,7 +424,6 @@ export async function loginMfaTotp(
|
|||||||
|
|
||||||
export async function loginMfaWebAuthn(
|
export async function loginMfaWebAuthn(
|
||||||
ctx: ApiContext,
|
ctx: ApiContext,
|
||||||
deps: Pick<LoginDependencies, 'flutterClientGateMemberRepository'>,
|
|
||||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||||
): Promise<LoginTokenResult> {
|
): Promise<LoginTokenResult> {
|
||||||
const {users, cache} = ctx.services;
|
const {users, cache} = ctx.services;
|
||||||
@@ -442,7 +436,6 @@ export async function loginMfaWebAuthn(
|
|||||||
throw new UnknownUserError();
|
throw new UnknownUserError();
|
||||||
}
|
}
|
||||||
AuthUtility.assertNonBotUser(ctx, user);
|
AuthUtility.assertNonBotUser(ctx, user);
|
||||||
await assertFlutterClientLoginAllowed(request, user, deps.flutterClientGateMemberRepository);
|
|
||||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||||
await cache.delete(`mfa-ticket:${ticket}`);
|
await cache.delete(`mfa-ticket:${ticket}`);
|
||||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {deriveUsernameFromDisplayName} from '../utils/UsernameSuggestionUtils';
|
|||||||
import * as AuthPassword from './AuthPassword';
|
import * as AuthPassword from './AuthPassword';
|
||||||
import * as AuthSession from './AuthSession';
|
import * as AuthSession from './AuthSession';
|
||||||
import * as AuthUtility from './AuthUtility';
|
import * as AuthUtility from './AuthUtility';
|
||||||
import {assertFlutterClientRegistrationAllowed} from './FlutterClientGate';
|
|
||||||
import type {IRegistrationRiskEvaluator} from './services/IRegistrationRiskEvaluator';
|
import type {IRegistrationRiskEvaluator} from './services/IRegistrationRiskEvaluator';
|
||||||
|
|
||||||
const DEFAULT_MINIMUM_AGE = 13;
|
const DEFAULT_MINIMUM_AGE = 13;
|
||||||
@@ -133,7 +132,6 @@ export async function register(
|
|||||||
riskAssessmentRepository,
|
riskAssessmentRepository,
|
||||||
riskHistoryRepository,
|
riskHistoryRepository,
|
||||||
} = deps;
|
} = deps;
|
||||||
assertFlutterClientRegistrationAllowed(request, data.email ?? null);
|
|
||||||
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
|
const appPublicConfig = await instanceConfigRepository.getAppPublicConfig();
|
||||||
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
|
const emailEnabled = await instanceConfigRepository.isEmailEnabled();
|
||||||
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
|
const requiresTermsConsent = shouldRequireHostedLegalConsent(config) || appPublicConfig.legal.terms_url !== null;
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export class AuthRequestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginMfaTotp({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
async loginMfaTotp({code, ticket, request}: AuthLoginMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||||
const result = await AuthLogin.loginMfaTotp(this.apiContext, this.loginDependencies, {code, ticket, request});
|
const result = await AuthLogin.loginMfaTotp(this.apiContext, {code, ticket, request});
|
||||||
return await this.toAuthTokenResponse(result);
|
return await this.toAuthTokenResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ export class AuthRequestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
async loginMfaWebAuthn({data, request}: AuthWebAuthnMfaRequest): Promise<AuthTokenWithUserIdResponse> {
|
||||||
const result = await AuthLogin.loginMfaWebAuthn(this.apiContext, this.loginDependencies, {
|
const result = await AuthLogin.loginMfaWebAuthn(this.apiContext, {
|
||||||
response: data.response,
|
response: data.response,
|
||||||
challenge: data.challenge,
|
challenge: data.challenge,
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
||||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
|
||||||
import {createGuildID} from '../BrandedTypes';
|
|
||||||
import {Config} from '../Config';
|
|
||||||
import type {IGuildMemberRepository} from '../guild/repositories/IGuildMemberRepository';
|
|
||||||
import type {User} from '../models/User';
|
|
||||||
import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService';
|
|
||||||
import {checkHasActivePaidPremium} from '../user/UserHelpers';
|
|
||||||
import {isFluxerFlutterAndroidClient, isFluxerFlutterClient, isFluxerFlutterIosClient} from '../utils/UserAgentUtils';
|
|
||||||
|
|
||||||
const FLUTTER_CLIENT_ALLOWED_GUILD_ID = createGuildID(1489322182823577203n);
|
|
||||||
const ANDROID_FLUTTER_OPEN_ACCESS_AT_MS = Date.parse('2026-06-15T21:00:00.000Z');
|
|
||||||
|
|
||||||
export type FlutterClientGateMemberRepository = Pick<IGuildMemberRepository, 'getMember'>;
|
|
||||||
|
|
||||||
export async function assertFlutterClientLoginAllowed(
|
|
||||||
request: Request,
|
|
||||||
user: User,
|
|
||||||
memberRepository: FlutterClientGateMemberRepository,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!isFluxerFlutterClient(request)) return;
|
|
||||||
if (Config.instance.selfHosted) return;
|
|
||||||
if (accountPolicyContactHasCapability(user.email, 'client_gate_exempt')) return;
|
|
||||||
if (isFluxerFlutterAndroidClient(request) && isAndroidOpenAccessActive()) return;
|
|
||||||
if (isFluxerFlutterIosClient(request) && checkHasActivePaidPremium(user)) return;
|
|
||||||
const member = await memberRepository.getMember(FLUTTER_CLIENT_ALLOWED_GUILD_ID, user.id);
|
|
||||||
if (member) return;
|
|
||||||
throw InputValidationError.fromCodes([
|
|
||||||
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
||||||
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertFlutterClientRegistrationAllowed(request: Request, email: string | null | undefined): void {
|
|
||||||
if (!isFluxerFlutterClient(request)) return;
|
|
||||||
if (Config.instance.selfHosted) return;
|
|
||||||
if (accountPolicyContactHasCapability(email, 'client_gate_exempt')) return;
|
|
||||||
if (isFluxerFlutterAndroidClient(request) && isAndroidOpenAccessActive()) return;
|
|
||||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAndroidOpenAccessActive(): boolean {
|
|
||||||
return Date.now() >= ANDROID_FLUTTER_OPEN_ACCESS_AT_MS;
|
|
||||||
}
|
|
||||||
@@ -482,7 +482,6 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
|||||||
{
|
{
|
||||||
inviteService,
|
inviteService,
|
||||||
kvDeletionQueue: getKVAccountDeletionQueue(),
|
kvDeletionQueue: getKVAccountDeletionQueue(),
|
||||||
flutterClientGateMemberRepository: guildRepository,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const reportService = getReportServiceInstance();
|
const reportService = getReportServiceInstance();
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type AccountPolicyAuditLevel = 'debug' | 'info' | 'warn' | 'error';
|
|||||||
type AccountPolicyRiskHistoryOutcome = 'challenged';
|
type AccountPolicyRiskHistoryOutcome = 'challenged';
|
||||||
export type AccountPolicyContactCapability =
|
export type AccountPolicyContactCapability =
|
||||||
| 'captcha_exempt'
|
| 'captcha_exempt'
|
||||||
| 'client_gate_exempt'
|
|
||||||
| 'followup_risk_exempt'
|
| 'followup_risk_exempt'
|
||||||
| 'reputation_checks_exempt'
|
| 'reputation_checks_exempt'
|
||||||
| 'required_actions_exempt';
|
| 'required_actions_exempt';
|
||||||
@@ -48,7 +47,6 @@ const CONTACT_CAPABILITY_FACT_PATHS = new Set([
|
|||||||
]);
|
]);
|
||||||
const CONTACT_CAPABILITIES = new Set<AccountPolicyContactCapability>([
|
const CONTACT_CAPABILITIES = new Set<AccountPolicyContactCapability>([
|
||||||
'captcha_exempt',
|
'captcha_exempt',
|
||||||
'client_gate_exempt',
|
|
||||||
'followup_risk_exempt',
|
'followup_risk_exempt',
|
||||||
'reputation_checks_exempt',
|
'reputation_checks_exempt',
|
||||||
'required_actions_exempt',
|
'required_actions_exempt',
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ describe('AccountPolicyEvaluator', () => {
|
|||||||
const evaluator = createCurrentBehaviorTestAccountPolicyEvaluator();
|
const evaluator = createCurrentBehaviorTestAccountPolicyEvaluator();
|
||||||
const decision = evaluator.evaluateContact(createAccountPolicyContactContext(`test@${TEST_POLICY_CONTACT_DOMAIN}`));
|
const decision = evaluator.evaluateContact(createAccountPolicyContactContext(`test@${TEST_POLICY_CONTACT_DOMAIN}`));
|
||||||
expect(decision.hasCapability('captcha_exempt')).toBe(true);
|
expect(decision.hasCapability('captcha_exempt')).toBe(true);
|
||||||
expect(decision.hasCapability('client_gate_exempt')).toBe(true);
|
|
||||||
expect(decision.hasCapability('followup_risk_exempt')).toBe(true);
|
expect(decision.hasCapability('followup_risk_exempt')).toBe(true);
|
||||||
expect(decision.hasCapability('required_actions_exempt')).toBe(true);
|
expect(decision.hasCapability('required_actions_exempt')).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const CURRENT_BEHAVIOR_TEST_POLICY_DSL = {
|
|||||||
fact: 'contact.domain',
|
fact: 'contact.domain',
|
||||||
in_set: 'contact_domain_exclusions',
|
in_set: 'contact_domain_exclusions',
|
||||||
},
|
},
|
||||||
capabilities: ['captcha_exempt', 'client_gate_exempt', 'followup_risk_exempt', 'required_actions_exempt'],
|
capabilities: ['captcha_exempt', 'followup_risk_exempt', 'required_actions_exempt'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'contact_domain_reputation_profile',
|
id: 'contact_domain_reputation_profile',
|
||||||
|
|||||||
@@ -2,15 +2,12 @@
|
|||||||
|
|
||||||
import Bowser from 'bowser';
|
import Bowser from 'bowser';
|
||||||
import {Logger} from '../Logger';
|
import {Logger} from '../Logger';
|
||||||
import {parseJsonRecord} from './JsonBoundaryUtils';
|
|
||||||
|
|
||||||
interface UserAgentInfo {
|
interface UserAgentInfo {
|
||||||
clientOs: string;
|
clientOs: string;
|
||||||
detectedPlatform: string;
|
detectedPlatform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FluxerClientProperties = Record<string, unknown>;
|
|
||||||
|
|
||||||
const UNKNOWN_LABEL = 'Unknown';
|
const UNKNOWN_LABEL = 'Unknown';
|
||||||
|
|
||||||
function formatName(name?: string | null): string {
|
function formatName(name?: string | null): string {
|
||||||
@@ -33,57 +30,6 @@ function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFluxerFlutterClient(request: Request): boolean {
|
|
||||||
const parsed = parseFluxerClientProperties(request);
|
|
||||||
return parsed?.client_runtime === 'flutter';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFluxerFlutterAndroidClient(request: Request): boolean {
|
|
||||||
return isFluxerFlutterPlatformClient(request, 'android');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFluxerFlutterIosClient(request: Request): boolean {
|
|
||||||
return (
|
|
||||||
isFluxerFlutterPlatformClient(request, 'ios') ||
|
|
||||||
isFluxerFlutterPlatformClient(request, 'iphone') ||
|
|
||||||
isFluxerFlutterPlatformClient(request, 'ipad') ||
|
|
||||||
isFluxerFlutterPlatformClient(request, 'ipod')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFluxerFlutterPlatformClient(request: Request, platform: string): boolean {
|
|
||||||
const platformCandidates = getFluxerFlutterPlatformCandidates(request);
|
|
||||||
if (!platformCandidates) return false;
|
|
||||||
const needle = platform.toLowerCase();
|
|
||||||
return platformCandidates.some((value) => typeof value === 'string' && value.toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFluxerFlutterPlatformCandidates(request: Request): Array<unknown> | null {
|
|
||||||
const parsed = parseFluxerClientProperties(request);
|
|
||||||
if (parsed?.client_runtime !== 'flutter') return null;
|
|
||||||
return [
|
|
||||||
parsed.client_os,
|
|
||||||
parsed.os,
|
|
||||||
parsed.operating_system,
|
|
||||||
parsed.platform,
|
|
||||||
parsed.device_platform,
|
|
||||||
parsed.client_platform,
|
|
||||||
parsed.user_agent,
|
|
||||||
request.headers.get('user-agent'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFluxerClientProperties(request: Request): FluxerClientProperties | null {
|
|
||||||
const encoded = request.headers.get('x-fluxer-client-properties');
|
|
||||||
if (!encoded) return null;
|
|
||||||
try {
|
|
||||||
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
|
|
||||||
return parseJsonRecord(decoded);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||||
clientOs: string;
|
clientOs: string;
|
||||||
clientPlatform: string;
|
clientPlatform: string;
|
||||||
|
|||||||
Reference in new issue
Block a user