fluxer/fluxer_api/src/auth/services/AuthUtilityService.ts
2026-01-02 19:27:51 +00:00

244 lines
7.3 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {promisify} from 'node:util';
import {createInviteCode, type UserID} from '~/BrandedTypes';
import {APIErrorCodes, UserFlags} from '~/Constants';
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
import type {InviteService} from '~/invite/InviteService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import * as AgeUtils from '~/utils/AgeUtils';
import * as RandomUtils from '~/utils/RandomUtils';
const randomBytesAsync = promisify(crypto.randomBytes);
const ALPHANUMERIC_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const base62Encode = (buffer: Uint8Array): string => {
let num = BigInt(`0x${Buffer.from(buffer).toString('hex')}`);
const base = BigInt(ALPHANUMERIC_CHARS.length);
let encoded = '';
while (num > 0) {
const remainder = num % base;
encoded = ALPHANUMERIC_CHARS[Number(remainder)] + encoded;
num = num / base;
}
return encoded;
};
interface ValidateAgeParams {
dateOfBirth: string;
minAge: number;
}
interface CheckEmailChangeRateLimitParams {
userId: UserID;
}
export class AuthUtilityService {
constructor(
private repository: IUserRepository,
private rateLimitService: IRateLimitService,
private gatewayService: IGatewayService,
private inviteService: InviteService,
private pendingJoinInviteStore: PendingJoinInviteStore,
) {}
async generateSecureToken(length = 64): Promise<string> {
return RandomUtils.randomString(length);
}
async generateAuthToken(): Promise<string> {
const bytes = await randomBytesAsync(27);
let token = base62Encode(new Uint8Array(bytes));
while (token.length < 36) {
const extraBytes = await randomBytesAsync(1);
token += ALPHANUMERIC_CHARS[extraBytes[0] % ALPHANUMERIC_CHARS.length];
}
if (token.length > 36) {
token = token.slice(0, 36);
}
return `flx_${token}`;
}
generateBackupCodes(): Array<string> {
return Array.from({length: 10}, () => {
return `${RandomUtils.randomString(4).toLowerCase()}-${RandomUtils.randomString(4).toLowerCase()}`;
});
}
getTokenIdHash(token: string): Uint8Array {
return new Uint8Array(crypto.createHash('sha256').update(token).digest());
}
async checkEmailChangeRateLimit({
userId,
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
const rateLimit = await this.rateLimitService.checkLimit({
identifier: `email_change:${userId}`,
maxAttempts: 3,
windowMs: 60 * 60 * 1000,
});
return {
allowed: rateLimit.allowed,
retryAfter: rateLimit.retryAfter,
};
}
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
const birthDate = new Date(dateOfBirth);
const age = AgeUtils.calculateAge({
year: birthDate.getFullYear(),
month: birthDate.getMonth() + 1,
day: birthDate.getDate(),
});
return age >= minAge;
}
assertNonBotUser(user: User): void {
if (user.isBot) {
throw new AccessDeniedError('Bot users cannot use auth endpoints');
}
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.repository.authorizeIpByToken(token);
}
checkAccountBanStatus(user: User): {
isPermanentlyBanned: boolean;
isTempBanned: boolean;
tempBanExpired: boolean;
} {
const isPermanentlyBanned = !!(user.flags & UserFlags.DELETED);
const hasTempBan = !!(user.flags & UserFlags.DISABLED && user.tempBannedUntil);
const tempBanExpired = hasTempBan && user.tempBannedUntil! <= new Date();
return {
isPermanentlyBanned,
isTempBanned: hasTempBan && !tempBanExpired,
tempBanExpired,
};
}
async handleBanStatus(user: User): Promise<User> {
const banStatus = this.checkAccountBanStatus(user);
if (banStatus.isPermanentlyBanned) {
throw new FluxerAPIError({
code: APIErrorCodes.ACCOUNT_DISABLED,
message: 'Your account has been permanently suspended',
status: 403,
});
}
if (banStatus.isTempBanned) {
throw new FluxerAPIError({
code: APIErrorCodes.ACCOUNT_DISABLED,
message: 'Your account has been temporarily suspended',
status: 403,
});
}
if (banStatus.tempBanExpired) {
const updatedUser = await this.repository.patchUpsert(user.id, {
flags: user.flags & ~UserFlags.DISABLED,
temp_banned_until: null,
});
if (!updatedUser) {
throw new UnauthorizedError();
}
return updatedUser;
}
return user;
}
async redeemBetaCode(userId: UserID, betaCode: string): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
throw InputValidationError.create('beta_code', 'Your account is already verified');
}
const code = await this.repository.getBetaCode(betaCode);
if (!code || code.redeemerId) {
throw InputValidationError.create('beta_code', 'Invalid or already used beta code');
}
await this.repository.updateBetaCodeRedeemed(betaCode, userId, new Date());
await this.repository.deletePendingVerification(userId);
const updatedUser = await this.repository.patchUpsert(userId, {
flags: user.flags & ~UserFlags.PENDING_MANUAL_VERIFICATION,
});
const userSearchService = getUserSearchService();
if (userSearchService && updatedUser) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId, error}, 'Failed to update user in search');
});
}
await this.gatewayService.dispatchPresence({
userId,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser!),
});
await this.autoJoinPendingInvite(userId);
}
private async autoJoinPendingInvite(userId: UserID): Promise<void> {
const pendingInviteCode = await this.pendingJoinInviteStore.getPendingInvite(userId);
if (!pendingInviteCode) {
return;
}
try {
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(pendingInviteCode),
requestCache: createRequestCache(),
});
} catch (error) {
Logger.warn(
{userId, inviteCode: pendingInviteCode, error},
'Failed to auto-join invite after redeeming beta code',
);
} finally {
await this.pendingJoinInviteStore.deletePendingInvite(userId);
}
}
}