244 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|