mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
refactor(api): tidy the api and shared packages (#2733)
This commit is contained in:
1 parent
580401d2dc
commit
5afbf67a70
333 files changed
+10180
-7910
No files matched your search
@@ -343,7 +343,8 @@ export function AuthController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId));
|
||||
const currentSessionIdHash = ctx.get('authSession')?.sessionIdHash;
|
||||
return ctx.json(await ctx.get('authRequestService').getAuthSessions(userId, currentSessionIdHash));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
|
||||
@@ -7,9 +7,7 @@ import type {VerifyEmailRequest} from '@fluxer/schema/src/domains/auth/AuthSchem
|
||||
import {ms} from 'itty-time';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createEmailVerificationToken} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import type {User} from '../models/User';
|
||||
import {getUserSearchService} from '../SearchFactory';
|
||||
import {mapUserToPrivateResponse} from '../user/UserMappers';
|
||||
import * as RandomUtils from '../utils/RandomUtils';
|
||||
|
||||
@@ -38,7 +36,11 @@ export async function verifyEmail(ctx: ApiContext, data: VerifyEmailRequest): Pr
|
||||
return false;
|
||||
}
|
||||
assertNonBotUser(user);
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
if (
|
||||
user.flags & UserFlags.DELETED ||
|
||||
!user.email ||
|
||||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const updates: {
|
||||
@@ -57,12 +59,6 @@ export async function verifyEmail(ctx: ApiContext, data: VerifyEmailRequest): Pr
|
||||
}
|
||||
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
|
||||
await users.deleteEmailVerificationToken(data.token);
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'updateUser' in userSearchService) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
await gateway.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
|
||||
@@ -5,9 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createEmailRevertToken} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import type {User} from '../models/User';
|
||||
import {getUserSearchService} from '../SearchFactory';
|
||||
import {mapUserToPrivateResponse} from '../user/UserMappers';
|
||||
import * as AuthPassword from './AuthPassword';
|
||||
import * as AuthSession from './AuthSession';
|
||||
@@ -75,6 +73,7 @@ export async function revertEmailChange(
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await users.deleteAllPasswordResetTokens(user.id);
|
||||
await users.deleteEmailRevertToken(token);
|
||||
await users.deleteAllMfaBackupCodes(user.id);
|
||||
await users.deleteAllWebAuthnCredentials(user.id);
|
||||
@@ -87,14 +86,6 @@ export async function revertEmailChange(
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
}),
|
||||
);
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser && 'updateUser' in userSearchService) {
|
||||
await userSearchService
|
||||
.updateUser(updatedUser)
|
||||
.catch((error) =>
|
||||
Logger.debug({error, userId: updatedUser.id}, 'Failed to update search index after email revert'),
|
||||
);
|
||||
}
|
||||
await gateway.dispatchPresence({
|
||||
userId: updatedUser.id,
|
||||
event: 'USER_UPDATE',
|
||||
|
||||
@@ -8,13 +8,11 @@ import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domain
|
||||
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
|
||||
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {LoginRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {formatGeoipLocation, UNKNOWN_LOCATION} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import type {AuthenticationResponseJSON} from '@simplewebauthn/server';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
@@ -33,6 +31,7 @@ import {createRequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {createRateLimitError} from '../utils/RateLimitUtils';
|
||||
import * as AuthMfa from './AuthMfa';
|
||||
import * as AuthPassword from './AuthPassword';
|
||||
import * as AuthSession from './AuthSession';
|
||||
@@ -79,18 +78,6 @@ interface LoginMfaResult {
|
||||
|
||||
type LoginResult = LoginTokenResult | LoginMfaResult;
|
||||
|
||||
function getRetryAfterSeconds(result: RateLimitResult): number {
|
||||
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function throwLoginRateLimit(result: RateLimitResult): never {
|
||||
throw new RateLimitError({
|
||||
retryAfter: getRetryAfterSeconds(result),
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
export interface IpAuthorizationTicketCache {
|
||||
userId: string;
|
||||
email: string;
|
||||
@@ -202,7 +189,7 @@ export async function login(
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!emailRateLimit.allowed && !skipRateLimits) {
|
||||
throwLoginRateLimit(emailRateLimit);
|
||||
throw createRateLimitError(emailRateLimit);
|
||||
}
|
||||
const clientIp = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
@@ -214,7 +201,7 @@ export async function login(
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
if (!ipRateLimit.allowed && !skipRateLimits) {
|
||||
throwLoginRateLimit(ipRateLimit);
|
||||
throw createRateLimitError(ipRateLimit);
|
||||
}
|
||||
const user = await users.findByEmail(data.email);
|
||||
if (!user) {
|
||||
@@ -254,22 +241,19 @@ export async function login(
|
||||
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
|
||||
}
|
||||
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
|
||||
if (currentUser.pendingDeletionAt) {
|
||||
await users.removePendingDeletion(currentUser.id, currentUser.pendingDeletionAt);
|
||||
const pendingDeletionAt = currentUser.pendingDeletionAt;
|
||||
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
|
||||
currentUser = await users.updateDeletionSchedule(currentUser, {
|
||||
flags: updatedFlags,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
});
|
||||
if (pendingDeletionAt) {
|
||||
await users.removePendingDeletion(currentUser.id, pendingDeletionAt);
|
||||
}
|
||||
await kvDeletionQueue.removeFromQueue(currentUser.id);
|
||||
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
|
||||
currentUser = await users.patchUpsert(
|
||||
currentUser.id,
|
||||
{
|
||||
flags: updatedFlags,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
},
|
||||
currentUser.toRow(),
|
||||
);
|
||||
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
|
||||
}
|
||||
if (currentUser.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import type {User} from '../models/User';
|
||||
import type {WebAuthnCredential} from '../models/WebAuthnCredential';
|
||||
import {getUserSearchService} from '../SearchFactory';
|
||||
import {mapUserToPrivateResponse} from '../user/UserMappers';
|
||||
import {TotpGenerator} from '../utils/TotpGenerator';
|
||||
import {deriveSudoMethods, userHasMfa} from './services/SudoMethods';
|
||||
@@ -217,12 +216,6 @@ export async function verifyWebAuthnRegistration(
|
||||
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'updateUser' in userSearchService) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
@@ -242,12 +235,6 @@ export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID,
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'updateUser' in userSearchService) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ async function resolveAuthSessionLocation(session: AuthSession): Promise<string
|
||||
|
||||
export async function mapAuthSessionsToResponse({
|
||||
authSessions,
|
||||
currentSessionId,
|
||||
currentSessionIdHash,
|
||||
}: {
|
||||
authSessions: Array<AuthSession>;
|
||||
currentSessionId?: Uint8Array;
|
||||
currentSessionIdHash?: Uint8Array;
|
||||
}): Promise<Array<AuthSessionResponse>> {
|
||||
const sortedSessions = authSessions.toSorted((a, b) => {
|
||||
const aTime = a.approximateLastUsedAt?.getTime() || 0;
|
||||
@@ -51,7 +51,7 @@ export async function mapAuthSessionsToResponse({
|
||||
productName: branding.product_name,
|
||||
});
|
||||
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
|
||||
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
|
||||
const isCurrent = currentSessionIdHash ? authSession.sessionIdHash.equals(currentSessionIdHash) : false;
|
||||
return {
|
||||
id_hash: idHash,
|
||||
client_info: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
|
||||
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
@@ -17,6 +16,7 @@ import type {User} from '../models/User';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '../utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '../utils/FetchUtils';
|
||||
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '../utils/PasswordUtils';
|
||||
import {createRateLimitError} from '../utils/RateLimitUtils';
|
||||
import * as AuthSession from './AuthSession';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
FetchUtils.discardResponseBody(response.body, response.status);
|
||||
Logger.warn(
|
||||
{
|
||||
status: response.status,
|
||||
@@ -183,29 +184,21 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const ipLimitConfig = {maxAttempts: 20, windowMs: ms('30 minutes')};
|
||||
const emailLimitConfig = {maxAttempts: 5, windowMs: ms('30 minutes')};
|
||||
const ipRateLimit = await rateLimit.checkLimit({
|
||||
identifier: `password_reset:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
|
||||
...ipLimitConfig,
|
||||
maxAttempts: 20,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
const emailRateLimit = await rateLimit.checkLimit({
|
||||
identifier: `password_reset:email:${data.email.toLowerCase()}`,
|
||||
...emailLimitConfig,
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('30 minutes'),
|
||||
});
|
||||
const exceeded = !ipRateLimit.allowed
|
||||
? {result: ipRateLimit, config: ipLimitConfig}
|
||||
: !emailRateLimit.allowed
|
||||
? {result: emailRateLimit, config: emailLimitConfig}
|
||||
: null;
|
||||
if (exceeded) {
|
||||
const retryAfter =
|
||||
exceeded.result.retryAfter ?? Math.max(0, Math.ceil((exceeded.result.resetTime.getTime() - Date.now()) / 1000));
|
||||
throw new RateLimitError({
|
||||
retryAfter,
|
||||
limit: exceeded.result.limit,
|
||||
resetTime: exceeded.result.resetTime,
|
||||
});
|
||||
if (!ipRateLimit.allowed) {
|
||||
throw createRateLimitError(ipRateLimit);
|
||||
}
|
||||
if (!emailRateLimit.allowed) {
|
||||
throw createRateLimitError(emailRateLimit);
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
|
||||
if (!hasValidDns) {
|
||||
@@ -235,7 +228,11 @@ export async function validateResetToken(ctx: ApiContext, token: string): Promis
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
if (
|
||||
user.flags & UserFlags.DELETED ||
|
||||
!user.email ||
|
||||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -255,7 +252,11 @@ export async function resetPassword(
|
||||
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
if (
|
||||
user.flags & UserFlags.DELETED ||
|
||||
!user.email ||
|
||||
user.email.trim().toLowerCase() !== tokenData.email.trim().toLowerCase()
|
||||
) {
|
||||
throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
|
||||
}
|
||||
await AuthUtility.handleBanStatus(ctx, user);
|
||||
|
||||
@@ -33,7 +33,6 @@ import type {ApiContext} from '../ApiContext';
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import type {User} from '../models/User';
|
||||
import {getUserSearchService} from '../SearchFactory';
|
||||
import {mapUserToPartialResponse, mapUserToPrivateResponse} from '../user/UserMappers';
|
||||
import {phonePrefixBanCache} from './PhonePrefixBanCache';
|
||||
import {requiresInboundPhoneVerification} from './PhoneVerificationPrefixPolicy';
|
||||
@@ -367,12 +366,6 @@ async function attachVerifiedPhoneToAccount(ctx: ApiContext, userId: UserID, pho
|
||||
reason: 'user_requested',
|
||||
actorUserId: userId,
|
||||
});
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && 'updateUser' in userSearchService) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search index');
|
||||
});
|
||||
}
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
|
||||
@@ -7,12 +7,10 @@ import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/Registrat
|
||||
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {RegisterRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {parseAcceptLanguage} from '@pkgs/locale/src/LocaleService';
|
||||
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {types} from 'cassandra-driver';
|
||||
import {ms} from 'itty-time';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
@@ -43,10 +41,10 @@ import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
|
||||
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
|
||||
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
|
||||
import {getUserSearchService} from '../SearchFactory';
|
||||
import * as AgeUtils from '../utils/AgeUtils';
|
||||
import {extractEmailDomain} from '../utils/EmailDomainUtils';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {createRateLimitError} from '../utils/RateLimitUtils';
|
||||
import {generateRandomUsername} from '../utils/UsernameGenerator';
|
||||
import {deriveUsernameFromDisplayName} from '../utils/UsernameSuggestionUtils';
|
||||
import * as AuthPassword from './AuthPassword';
|
||||
@@ -56,18 +54,6 @@ import type {IRegistrationRiskEvaluator} from './services/IRegistrationRiskEvalu
|
||||
|
||||
const DEFAULT_MINIMUM_AGE = 13;
|
||||
|
||||
function getRetryAfterSeconds(result: RateLimitResult): number {
|
||||
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function throwRegistrationRateLimit(result: RateLimitResult): never {
|
||||
throw new RateLimitError({
|
||||
retryAfter: getRetryAfterSeconds(result),
|
||||
limit: result.limit,
|
||||
resetTime: result.resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
function parseDobLocalDate(dateOfBirth: string): types.LocalDate {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth);
|
||||
if (!match) {
|
||||
@@ -149,6 +135,9 @@ export async function register(
|
||||
}
|
||||
const now = new Date();
|
||||
const registrationAccess = await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
|
||||
if (registrationAccess.pendingApproval) {
|
||||
await instanceConfigRepository.getPendingRegistrations();
|
||||
}
|
||||
const clientIp = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
@@ -410,7 +399,6 @@ export async function register(
|
||||
);
|
||||
}
|
||||
}
|
||||
await maybeIndexUser(user);
|
||||
if (rawEmail && emailEnabled) await maybeSendVerificationEmail(ctx, {user, email: rawEmail});
|
||||
await users.createAuthorizedIp(userId, clientIp);
|
||||
if (registrationAccess.registrationUrl) {
|
||||
@@ -506,18 +494,6 @@ async function resolveRegistrationAccess(
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeIndexUser(user: User): Promise<void> {
|
||||
const userSearchService = getUserSearchService();
|
||||
if (!userSearchService) return;
|
||||
if ('indexUser' in userSearchService) {
|
||||
try {
|
||||
await userSearchService.indexUser(user);
|
||||
} catch (error) {
|
||||
Logger.error({userId: user.id, error}, 'Failed to index user in search');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeSendVerificationEmail(ctx: ApiContext, params: {user: User; email: string}): Promise<void> {
|
||||
const {users, email: emailService} = ctx.services;
|
||||
const {user, email} = params;
|
||||
@@ -570,14 +546,14 @@ async function enforceRegistrationRateLimits(
|
||||
maxAttempts: 3,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!emailRateLimit.allowed) throwRegistrationRateLimit(emailRateLimit);
|
||||
if (!emailRateLimit.allowed) throw createRateLimitError(emailRateLimit);
|
||||
}
|
||||
const ipRateLimit = await rateLimit.checkLimit({
|
||||
identifier: `registration:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
|
||||
maxAttempts: 3,
|
||||
windowMs: ms('1 hour'),
|
||||
});
|
||||
if (!ipRateLimit.allowed) throwRegistrationRateLimit(ipRateLimit);
|
||||
if (!ipRateLimit.allowed) throw createRateLimitError(ipRateLimit);
|
||||
const subnet = getSubnet(clientIp);
|
||||
if (subnet) {
|
||||
const subnetRateLimit = await rateLimit.checkLimit({
|
||||
@@ -585,7 +561,7 @@ async function enforceRegistrationRateLimits(
|
||||
maxAttempts: 15,
|
||||
windowMs: ms('1 hour'),
|
||||
});
|
||||
if (!subnetRateLimit.allowed) throwRegistrationRateLimit(subnetRateLimit);
|
||||
if (!subnetRateLimit.allowed) throw createRateLimitError(subnetRateLimit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,8 +228,8 @@ export class AuthRequestService {
|
||||
return await this.toAuthLoginResponse(result);
|
||||
}
|
||||
|
||||
getAuthSessions(userId: UserID): Promise<AuthSessionsResponse> {
|
||||
return AuthSession.getAuthSessions(this.apiContext, userId);
|
||||
getAuthSessions(userId: UserID, currentSessionIdHash?: Uint8Array): Promise<AuthSessionsResponse> {
|
||||
return AuthSession.getAuthSessions(this.apiContext, userId, currentSessionIdHash);
|
||||
}
|
||||
|
||||
async logoutAuthSessions({user, data}: AuthLogoutAuthSessionsRequest): Promise<void> {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {mapAuthSessionsToResponse} from './AuthModel';
|
||||
import {revokeAllAuthSessions, revokeAuthSessions} from './AuthSessionRevocation';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
export interface SessionOrigin {
|
||||
@@ -134,10 +135,14 @@ export async function getAuthSessionByToken(ctx: ApiContext, token: string): Pro
|
||||
return users.getAuthSessionByToken(Buffer.from(AuthUtility.getTokenIdHash(ctx, token)));
|
||||
}
|
||||
|
||||
export async function getAuthSessions(ctx: ApiContext, userId: UserID): Promise<Array<AuthSessionResponse>> {
|
||||
export async function getAuthSessions(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
currentSessionIdHash?: Uint8Array,
|
||||
): Promise<Array<AuthSessionResponse>> {
|
||||
const {users} = ctx.services;
|
||||
const authSessions = await users.listAuthSessions(userId);
|
||||
return await mapAuthSessionsToResponse({authSessions});
|
||||
return await mapAuthSessionsToResponse({authSessions, currentSessionIdHash});
|
||||
}
|
||||
|
||||
export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint8Array): Promise<void> {
|
||||
@@ -145,52 +150,28 @@ export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint
|
||||
}
|
||||
|
||||
export async function revokeToken(ctx: ApiContext, token: string): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
const tokenHash = Buffer.from(AuthUtility.getTokenIdHash(ctx, token));
|
||||
const authSession = await users.getAuthSessionByToken(tokenHash);
|
||||
if (!authSession) return;
|
||||
const sessionIdHash = Buffer.from(authSession.sessionIdHash).toString('base64url');
|
||||
await users.deletePushSubscriptionsForAuthSessions(authSession.userId, [sessionIdHash], {
|
||||
deleteUnboundSubscriptions: true,
|
||||
});
|
||||
await gateway.invalidatePushSubscriptions({userId: authSession.userId});
|
||||
await users.revokeAuthSession(tokenHash);
|
||||
await gateway.terminateSession({
|
||||
userId: authSession.userId,
|
||||
sessionIdHashes: [sessionIdHash],
|
||||
});
|
||||
await revokeAuthSessions(ctx.services, authSession.userId, [
|
||||
{sessionIdHash: tokenHash, encodedIdHash: encodeSessionIdHash(authSession.sessionIdHash)},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function logoutAuthSessions(
|
||||
ctx: ApiContext,
|
||||
{user, sessionIdHashes}: LogoutAuthSessionsParams,
|
||||
): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
|
||||
await users.deletePushSubscriptionsForAuthSessions(user.id, sessionIdHashes, {
|
||||
deleteUnboundSubscriptions: true,
|
||||
});
|
||||
await gateway.invalidatePushSubscriptions({userId: user.id});
|
||||
await users.deleteAuthSessions(user.id, hashes);
|
||||
await gateway.terminateSession({
|
||||
userId: user.id,
|
||||
sessionIdHashes,
|
||||
});
|
||||
await revokeAuthSessions(
|
||||
ctx.services,
|
||||
user.id,
|
||||
sessionIdHashes.map((encodedIdHash) => ({sessionIdHash: Buffer.from(encodedIdHash, 'base64url'), encodedIdHash})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function terminateAllUserSessions(ctx: ApiContext, userId: UserID): Promise<number> {
|
||||
const {users, gateway} = ctx.services;
|
||||
const authSessions = await users.listAuthSessions(userId);
|
||||
await users.deleteAllPushSubscriptions(userId);
|
||||
await gateway.invalidatePushSubscriptions({userId});
|
||||
if (authSessions.length === 0) return 0;
|
||||
const hashes = authSessions.map((s) => s.sessionIdHash);
|
||||
await users.deleteAuthSessions(userId, hashes);
|
||||
await gateway.terminateSession({
|
||||
userId,
|
||||
sessionIdHashes: authSessions.map((s) => Buffer.from(s.sessionIdHash).toString('base64url')),
|
||||
});
|
||||
return authSessions.length;
|
||||
return await revokeAllAuthSessions(ctx.services, userId);
|
||||
}
|
||||
|
||||
export async function replaceCurrentAuthSession(
|
||||
@@ -229,20 +210,14 @@ async function deleteAndTerminateAuthSessions(
|
||||
if (authSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const {users, gateway} = ctx.services;
|
||||
const sessionIdHashes = authSessions.map((authSession) => encodeSessionIdHash(authSession.sessionIdHash));
|
||||
await users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {
|
||||
deleteUnboundSubscriptions: true,
|
||||
});
|
||||
await gateway.invalidatePushSubscriptions({userId});
|
||||
await users.deleteAuthSessions(
|
||||
await revokeAuthSessions(
|
||||
ctx.services,
|
||||
userId,
|
||||
authSessions.map((authSession) => authSession.sessionIdHash),
|
||||
authSessions.map((authSession) => ({
|
||||
sessionIdHash: authSession.sessionIdHash,
|
||||
encodedIdHash: encodeSessionIdHash(authSession.sessionIdHash),
|
||||
})),
|
||||
);
|
||||
await gateway.terminateSession({
|
||||
userId,
|
||||
sessionIdHashes,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeSessionIdHash(sessionIdHash: Uint8Array): string {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import type {IGatewayService} from '../infrastructure/IGatewayService';
|
||||
import type {IUserRepositoryAggregate} from '../user/repositories/IUserRepositoryAggregate';
|
||||
import {runAllInOrder} from '../utils/ConcurrencyUtils';
|
||||
|
||||
interface SessionRevocationDependencies {
|
||||
users: IUserRepositoryAggregate;
|
||||
gateway: IGatewayService;
|
||||
}
|
||||
|
||||
interface SessionRevocationTarget {
|
||||
sessionIdHash: Buffer;
|
||||
encodedIdHash: string;
|
||||
}
|
||||
|
||||
export async function revokeAuthSessions(
|
||||
deps: SessionRevocationDependencies,
|
||||
userId: UserID,
|
||||
targets: ReadonlyArray<SessionRevocationTarget>,
|
||||
): Promise<void> {
|
||||
await revokeSessionTargets(deps, userId, targets, 'selected');
|
||||
}
|
||||
|
||||
export async function revokeAllAuthSessions(deps: SessionRevocationDependencies, userId: UserID): Promise<number> {
|
||||
const sessions = await deps.users.listAuthSessions(userId);
|
||||
const targets = sessions.map((session) => ({
|
||||
sessionIdHash: session.sessionIdHash,
|
||||
encodedIdHash: session.sessionIdHash.toString('base64url'),
|
||||
}));
|
||||
await revokeSessionTargets(deps, userId, targets, 'all');
|
||||
return sessions.length;
|
||||
}
|
||||
|
||||
async function revokeSessionTargets(
|
||||
{users, gateway}: SessionRevocationDependencies,
|
||||
userId: UserID,
|
||||
targets: ReadonlyArray<SessionRevocationTarget>,
|
||||
scope: 'selected' | 'all',
|
||||
): Promise<void> {
|
||||
const sessionIdHashes = targets.map((target) => target.encodedIdHash);
|
||||
const steps: Array<() => Promise<unknown>> = [
|
||||
() =>
|
||||
scope === 'all'
|
||||
? users.deleteAllPushSubscriptions(userId)
|
||||
: users.deletePushSubscriptionsForAuthSessions(userId, sessionIdHashes, {deleteUnboundSubscriptions: true}),
|
||||
() => gateway.invalidatePushSubscriptions({userId}),
|
||||
];
|
||||
if (scope === 'selected' || targets.length > 0) {
|
||||
steps.push(
|
||||
() =>
|
||||
users.deleteAuthSessions(
|
||||
userId,
|
||||
targets.map((target) => target.sessionIdHash),
|
||||
),
|
||||
() => gateway.terminateSession({userId, sessionIdHashes}),
|
||||
);
|
||||
}
|
||||
await runAllInOrder(steps, 'Failed to revoke auth sessions');
|
||||
}
|
||||
@@ -104,6 +104,7 @@ function checkAccountBanStatus(_ctx: ApiContext, user: User): AccountBanStatus {
|
||||
|
||||
export async function handleBanStatus(ctx: ApiContext, user: User): Promise<User> {
|
||||
const {users} = ctx.services;
|
||||
if (user.deletionStartedAt) throw new AccountPermanentlySuspendedError();
|
||||
const banStatus = checkAccountBanStatus(ctx, user);
|
||||
if (banStatus.isPermanentlyBanned) {
|
||||
throw new AccountPermanentlySuspendedError();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {AdminRepository} from '../admin/AdminRepository';
|
||||
import {PHONE_PREFIX_BAN_REFRESH_CHANNEL} from '../constants/PhonePrefixBan';
|
||||
import {Logger} from '../Logger';
|
||||
import {RefreshSubscription} from '../utils/RefreshSubscription';
|
||||
|
||||
const BUILT_IN_BANNED_PHONE_PREFIXES: ReadonlyArray<string> = [
|
||||
'+93',
|
||||
@@ -29,55 +30,31 @@ const BUILT_IN_BANNED_PHONE_PREFIXES: ReadonlyArray<string> = [
|
||||
export class PhonePrefixBanCache {
|
||||
private prefixes: Set<string> = new Set();
|
||||
private prefixLengths: ReadonlyArray<number> = [];
|
||||
private isInitialized = false;
|
||||
private adminRepository = new AdminRepository();
|
||||
private kvClient: IKVProvider | null = null;
|
||||
private kvSubscription: IKVSubscription | null = null;
|
||||
private subscriberInitialized = false;
|
||||
private messageHandler: ((channel: string) => void) | null = null;
|
||||
private consecutiveFailures = 0;
|
||||
private readonly maxConsecutiveFailures = 5;
|
||||
private readonly refreshSubscription = new RefreshSubscription({
|
||||
name: 'phone-prefix ban cache',
|
||||
channels: [PHONE_PREFIX_BAN_REFRESH_CHANNEL],
|
||||
refresh: () => this.refresh(),
|
||||
onRefreshError: (err) => {
|
||||
this.consecutiveFailures++;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
||||
Logger.error({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
|
||||
} else {
|
||||
Logger.warn({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setRefreshSubscriber(kvClient: IKVProvider | null): void {
|
||||
this.kvClient = kvClient;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
await this.refresh();
|
||||
this.isInitialized = true;
|
||||
this.setupSubscriber();
|
||||
}
|
||||
|
||||
private setupSubscriber(): void {
|
||||
if (this.subscriberInitialized || !this.kvClient) return;
|
||||
const subscription = this.kvClient.duplicate();
|
||||
this.kvSubscription = subscription;
|
||||
this.messageHandler = (channel: string) => {
|
||||
if (channel === PHONE_PREFIX_BAN_REFRESH_CHANNEL) {
|
||||
this.refresh().catch((err) => {
|
||||
this.consecutiveFailures++;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
||||
Logger.error({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
|
||||
} else {
|
||||
Logger.warn({error: message}, 'Failed to refresh phone-prefix ban cache after notification');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
subscription
|
||||
.connect()
|
||||
.then(() => subscription.subscribe(PHONE_PREFIX_BAN_REFRESH_CHANNEL))
|
||||
.then(() => {
|
||||
if (this.messageHandler) {
|
||||
subscription.on('message', this.messageHandler);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error({error}, 'Failed to subscribe to phone-prefix ban refresh channel');
|
||||
});
|
||||
this.subscriberInitialized = true;
|
||||
initialize(): Promise<void> {
|
||||
return this.refreshSubscription.start(this.kvClient);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
@@ -132,9 +109,11 @@ export class PhonePrefixBanCache {
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
void this.shutdown().catch((error) => {
|
||||
Logger.error({error}, 'Failed to shut down phone-prefix ban cache');
|
||||
});
|
||||
this.prefixes = new Set();
|
||||
this.prefixLengths = [];
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
private computeSortedLengths(): ReadonlyArray<number> {
|
||||
@@ -143,15 +122,8 @@ export class PhonePrefixBanCache {
|
||||
return [...seen].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.kvSubscription && this.messageHandler) {
|
||||
this.kvSubscription.off('message', this.messageHandler);
|
||||
}
|
||||
if (this.kvSubscription) {
|
||||
this.kvSubscription.disconnect();
|
||||
this.kvSubscription = null;
|
||||
}
|
||||
this.messageHandler = null;
|
||||
shutdown(): Promise<void> {
|
||||
return this.refreshSubscription.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlo
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import {
|
||||
type CryptoKey,
|
||||
@@ -377,6 +378,9 @@ export class SsoService {
|
||||
throw new RegistrationClosedError();
|
||||
}
|
||||
const pendingApproval = registrationConfig.mode === 'approval';
|
||||
if (pendingApproval) {
|
||||
await this.instanceConfigRepository.getPendingRegistrations();
|
||||
}
|
||||
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
|
||||
if (pendingApproval) {
|
||||
await this.instanceConfigRepository.addPendingRegistration({
|
||||
@@ -675,6 +679,7 @@ export class SsoService {
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
FetchUtils.discardResponseBody(response.stream, response.status);
|
||||
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
|
||||
}
|
||||
const rawBody = await FetchUtils.streamToStringWithLimit(response.stream, {
|
||||
@@ -774,6 +779,7 @@ export class SsoService {
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO);
|
||||
}
|
||||
try {
|
||||
@@ -836,6 +842,7 @@ export class SsoService {
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE);
|
||||
}
|
||||
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
|
||||
@@ -926,7 +933,10 @@ export class SsoService {
|
||||
try {
|
||||
return await this.assertPublicOutboundUrl(rawUrl, fieldName);
|
||||
} catch (error) {
|
||||
getLogger().warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
|
||||
getLogger().warn(
|
||||
{fieldName, rawUrl: formatUrlForDiagnostics(rawUrl), error},
|
||||
'Ignoring SSO URL that failed outbound policy validation',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import {ms} from 'itty-time';
|
||||
import {Logger} from '../../Logger';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '../../utils/ExternalResponseLimits';
|
||||
@@ -52,7 +53,10 @@ export function parseTokenEndpointResponse(
|
||||
}
|
||||
|
||||
function buildDiscoveryUrl(issuer: string): URL {
|
||||
const issuerUrl = new URL(issuer);
|
||||
const issuerUrl = URL.parse(issuer);
|
||||
if (!issuerUrl) {
|
||||
throw new Error('Invalid OIDC issuer URL');
|
||||
}
|
||||
const normalized = issuerUrl.toString().replace(/\/$/, '');
|
||||
return new URL(`${normalized}/.well-known/openid-configuration`);
|
||||
}
|
||||
@@ -71,7 +75,10 @@ export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<D
|
||||
timeout: ms('10 seconds'),
|
||||
serviceName: 'sso_oidc_discovery',
|
||||
});
|
||||
if (resp.status < 200 || resp.status >= 300) return null;
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
FetchUtils.discardResponseBody(resp.stream, resp.status);
|
||||
return null;
|
||||
}
|
||||
const rawBody = await FetchUtils.streamToStringWithLimit(resp.stream, {
|
||||
maxBytes: EXTERNAL_RESPONSE_LIMITS.ssoDiscoveryBytes,
|
||||
headers: resp.headers,
|
||||
@@ -94,7 +101,7 @@ export async function tryDiscoverOidcProviderMetadata(issuer: string): Promise<D
|
||||
jwks_uri: typeof json['jwks_uri'] === 'string' ? json['jwks_uri'] : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.debug({issuer, error}, 'Failed to discover OIDC provider metadata');
|
||||
Logger.debug({issuer: formatUrlForDiagnostics(issuer), error}, 'Failed to discover OIDC provider metadata');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user