initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
/*
* 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 type {UserID} from '~/BrandedTypes';
import {createEmailRevertToken} from '~/BrandedTypes';
import {InputValidationError, UnauthorizedError} from '~/Errors';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import {getUserSearchService} from '~/Meilisearch';
import type {AuthSession, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import * as IpUtils from '~/utils/IpUtils';
interface IssueTokenParams {
user: User;
previousEmail: string;
newEmail: string;
}
interface RevertParams {
token: string;
password: string;
request: Request;
}
export class AuthEmailRevertService {
constructor(
private readonly repository: IUserRepository,
private readonly emailService: IEmailService,
private readonly gatewayService: IGatewayService,
private readonly hashPassword: (password: string) => Promise<string>,
private readonly isPasswordPwned: (password: string) => Promise<boolean>,
private readonly handleBanStatus: (user: User) => Promise<User>,
private readonly assertNonBotUser: (user: User) => void,
private readonly generateSecureToken: () => Promise<string>,
private readonly createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
private readonly terminateAllUserSessions: (userId: UserID) => Promise<void>,
private readonly contactChangeLogService: UserContactChangeLogService,
) {}
async issueRevertToken(params: IssueTokenParams): Promise<void> {
const {user, previousEmail, newEmail} = params;
const trimmed = previousEmail.trim();
if (!trimmed) return;
const token = createEmailRevertToken(await this.generateSecureToken());
await this.repository.createEmailRevertToken({
token_: token,
user_id: user.id,
email: trimmed,
});
await this.emailService.sendEmailChangeRevert(trimmed, user.username, newEmail, token, user.locale);
}
async revertEmailChange(params: RevertParams): Promise<{user_id: string; token: string}> {
const {token, password, request} = params;
const tokenData = await this.repository.getEmailRevertToken(token);
if (!tokenData) {
throw InputValidationError.create('token', 'Invalid or expired revert token');
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
throw InputValidationError.create('token', 'Invalid or expired revert token');
}
this.assertNonBotUser(user);
await this.handleBanStatus(user);
if (await this.isPasswordPwned(password)) {
throw InputValidationError.create('password', 'Password is too common');
}
const passwordHash = await this.hashPassword(password);
const now = new Date();
const updatedUser = await this.repository.patchUpsert(user.id, {
email: tokenData.email,
email_verified: true,
phone: null,
totp_secret: null,
authenticator_types: null,
password_hash: passwordHash,
password_last_changed_at: now,
});
if (!updatedUser) {
throw new UnauthorizedError();
}
await this.repository.deleteEmailRevertToken(token);
await this.repository.deleteAllMfaBackupCodes(user.id);
await this.repository.deleteAllWebAuthnCredentials(user.id);
await this.repository.deleteAllAuthorizedIps(user.id);
await this.terminateAllUserSessions(user.id);
await this.repository.createAuthorizedIp(user.id, IpUtils.requireClientIp(request));
const userSearchService = getUserSearchService();
if (userSearchService && updatedUser) {
await userSearchService.updateUser(updatedUser).catch(() => {});
}
await this.gatewayService.dispatchPresence({
userId: updatedUser.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
const [authToken] = await this.createAuthSession({user: updatedUser, request});
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: user.id,
});
return {user_id: updatedUser.id.toString(), token: authToken};
}
}

View File

@@ -0,0 +1,137 @@
/*
* 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 type {VerifyEmailRequest} from '~/auth/AuthModel';
import {createEmailVerificationToken} from '~/BrandedTypes';
import {SuspiciousActivityFlags, UserFlags} from '~/Constants';
import {RateLimitError} from '~/Errors';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserModel';
const EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
export class AuthEmailService {
constructor(
private repository: IUserRepository,
private emailService: IEmailService,
private gatewayService: IGatewayService,
private rateLimitService: IRateLimitService,
private assertNonBotUser: (user: User) => void,
private generateSecureToken: () => Promise<string>,
) {}
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
const tokenData = await this.repository.getEmailVerificationToken(data.token);
if (!tokenData) {
return false;
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
return false;
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
return false;
}
const updates: {email_verified: boolean; suspicious_activity_flags?: number} = {
email_verified: true,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await this.repository.patchUpsert(user.id, updates);
await this.repository.deleteEmailVerificationToken(data.token);
const userSearchService = getUserSearchService();
if (userSearchService && updatedUser) {
await userSearchService.updateUser(updatedUser).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to update user in search');
});
}
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser!),
});
return true;
}
async resendVerificationEmail(user: User): Promise<void> {
this.assertNonBotUser(user);
const allowReverification =
user.suspiciousActivityFlags !== null &&
((user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0 ||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0);
if (user.emailVerified && !allowReverification) {
return;
}
const rateLimit = await this.rateLimitService.checkLimit({
identifier: `email_verification:${user.email!}`,
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
if (!rateLimit.allowed) {
const resetTime = new Date(Date.now() + (rateLimit.retryAfter || 0) * 1000);
throw new RateLimitError({
message: 'Too many verification email requests. Please try again later.',
retryAfter: rateLimit.retryAfter || 0,
limit: 3,
resetTime,
});
}
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken());
await this.repository.createEmailVerificationToken({
token_: emailVerifyToken,
user_id: user.id,
email: user.email!,
});
await this.emailService.sendEmailVerification(user.email!, user.username, emailVerifyToken, user.locale);
}
}

View File

@@ -0,0 +1,586 @@
/*
* 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 type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import type {LoginRequest} from '~/auth/AuthModel';
import type {UserID} from '~/BrandedTypes';
import {
createInviteCode,
createIpAuthorizationTicket,
createIpAuthorizationToken,
createMfaTicket,
createUserID,
} from '~/BrandedTypes';
import {Config} from '~/Config';
import {APIErrorCodes, UserAuthenticatorTypes, UserFlags} from '~/Constants';
import {FluxerAPIError, InputValidationError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
import type {InviteService} from '~/invite/InviteService';
import {Logger} from '~/Logger';
import type {AuthSession, User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import * as IpUtils from '~/utils/IpUtils';
import * as RandomUtils from '~/utils/RandomUtils';
function createRequestCache(): RequestCache {
return {
userPartials: new Map(),
clear: () => {},
};
}
interface LoginParams {
data: LoginRequest;
request: Request;
}
interface LoginMfaTotpParams {
code: string;
ticket: string;
request: Request;
}
export interface IpAuthorizationTicketCache {
userId: string;
email: string;
username: string;
clientIp: string;
userAgent: string;
platform: string | null;
authToken: string;
clientLocation: string;
inviteCode?: string | null;
resendUsed?: boolean;
createdAt: number;
}
export class AuthLoginService {
constructor(
private repository: IUserRepository,
private inviteService: InviteService,
private cacheService: ICacheService,
private rateLimitService: IRateLimitService,
private emailService: IEmailService,
private redisDeletionQueue: RedisAccountDeletionQueueService,
private verifyPassword: (params: {password: string; passwordHash: string}) => Promise<boolean>,
private handleBanStatus: (user: User) => Promise<User>,
private assertNonBotUser: (user: User) => void,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
private generateSecureToken: () => Promise<string>,
private verifyMfaCode: (params: {
userId: UserID;
mfaSecret: string;
code: string;
allowBackup?: boolean;
}) => Promise<boolean>,
private verifySmsMfaCode: (userId: UserID, code: string) => Promise<boolean>,
private verifyWebAuthnAuthentication: (
userId: UserID,
response: AuthenticationResponseJSON,
expectedChallenge: string,
context?: 'registration' | 'discoverable' | 'mfa' | 'sudo',
ticket?: string,
) => Promise<void>,
) {}
private getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket:${ticket}`;
}
private getTokenCacheKey(token: string): string {
return `ip-auth-token:${token}`;
}
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
const cacheKey = this.getTicketCacheKey(ticket);
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
if (!payload) {
throw InputValidationError.create('ticket', 'Invalid or expired authorization ticket');
}
const now = Date.now();
const secondsSinceCreation = Math.floor((now - payload.createdAt) / 1000);
if (payload.resendUsed) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'You can only resend this email once.',
status: 429,
});
}
const minDelay = 30;
if (secondsSinceCreation < minDelay) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Please wait before resending the email.',
status: 429,
data: {resend_available_in: minDelay - secondsSinceCreation},
});
}
await this.emailService.sendIpAuthorizationEmail(
payload.email,
payload.username,
payload.authToken,
payload.clientIp,
payload.clientLocation,
null,
);
const ttl = await this.cacheService.ttl(cacheKey);
await this.cacheService.set(
cacheKey,
{
...payload,
resendUsed: true,
},
ttl > 0 ? ttl : undefined,
);
return {};
}
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
const tokenMapping = await this.cacheService.get<{ticket: string}>(this.getTokenCacheKey(token));
if (!tokenMapping?.ticket) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const cacheKey = this.getTicketCacheKey(tokenMapping.ticket);
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
if (!payload) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const repoResult = await this.repository.authorizeIpByToken(token);
if (!repoResult || repoResult.userId.toString() !== payload.userId) {
throw InputValidationError.create('token', 'Invalid or expired authorization token');
}
const user = await this.repository.findUnique(createUserID(BigInt(payload.userId)));
if (!user) {
throw new FluxerAPIError({
code: APIErrorCodes.UNKNOWN_USER,
message: 'User not found',
status: 404,
});
}
this.assertNonBotUser(user);
await this.repository.createAuthorizedIp(user.id, payload.clientIp);
const headers: Record<string, string> = {
'X-Forwarded-For': payload.clientIp,
'user-agent': payload.userAgent,
};
if (payload.platform) {
headers['x-fluxer-platform'] = payload.platform;
}
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
headers,
method: 'POST',
});
const [sessionToken] = await this.createAuthSession({user, request: syntheticRequest});
await this.cacheService.delete(cacheKey);
await this.cacheService.delete(this.getTokenCacheKey(token));
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'ip_authorization'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
}
async login({
data,
request,
}: LoginParams): Promise<
| {mfa: false; user_id: string; token: string; pending_verification?: boolean}
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
> {
const inTests = Config.dev.testModeEnabled || process.env.CI === 'true';
const skipRateLimits = inTests || Config.dev.disableRateLimits;
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `login:email:${data.email}`,
maxAttempts: 5,
windowMs: 15 * 60 * 1000,
});
if (!emailRateLimit.allowed && !skipRateLimits) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Too many login attempts. Please try again later.',
status: 429,
});
}
const clientIp = IpUtils.requireClientIp(request);
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `login:ip:${clientIp}`,
maxAttempts: 10,
windowMs: 30 * 60 * 1000,
});
if (!ipRateLimit.allowed && !skipRateLimits) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Too many login attempts from this IP. Please try again later.',
status: 429,
});
}
const user = await this.repository.findByEmail(data.email);
if (!user) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'invalid_credentials'},
});
throw InputValidationError.createMultiple([
{field: 'email', message: 'Invalid email or password'},
{field: 'password', message: 'Invalid email or password'},
]);
}
this.assertNonBotUser(user);
const isMatch = await this.verifyPassword({
password: data.password,
passwordHash: user.passwordHash!,
});
if (!isMatch) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'invalid_credentials'},
});
throw InputValidationError.createMultiple([
{field: 'email', message: 'Invalid email or password'},
{field: 'password', message: 'Invalid email or password'},
]);
}
let currentUser = await this.handleBanStatus(user);
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
const updatedUser = await this.repository.patchUpsert(currentUser.id, {
flags: updatedFlags,
});
if (updatedUser) {
currentUser = updatedUser;
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
}
}
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
if (currentUser.pendingDeletionAt) {
await this.repository.removePendingDeletion(currentUser.id, currentUser.pendingDeletionAt);
}
await this.redisDeletionQueue.removeFromQueue(currentUser.id);
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
const updatedUser = await this.repository.patchUpsert(currentUser.id, {
flags: updatedFlags,
pending_deletion_at: null,
});
if (updatedUser) {
currentUser = updatedUser;
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
} else {
Logger.error({userId: currentUser.id}, 'Failed to cancel deletion during login');
throw new Error('Failed to cancel account deletion during login');
}
}
const hasMfa = (currentUser.authenticatorTypes?.size ?? 0) > 0;
const isAppStoreReviewer = (currentUser.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
if (!hasMfa && !isAppStoreReviewer) {
const isIpAuthorized = await this.repository.checkIpAuthorized(currentUser.id, clientIp);
if (!isIpAuthorized) {
const ticket = createIpAuthorizationTicket(await this.generateSecureToken());
const authToken = createIpAuthorizationToken(await this.generateSecureToken());
const geoipResult = await IpUtils.getCountryCodeDetailed(clientIp);
const clientLocation = IpUtils.formatGeoipLocation(geoipResult);
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 = 15 * 60;
await this.cacheService.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await this.cacheService.set<{ticket: string}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
await this.repository.createIpAuthorizationToken(currentUser.id, authToken);
await this.emailService.sendIpAuthorizationEmail(
currentUser.email!,
currentUser.username,
authToken,
clientIp,
clientLocation,
currentUser.locale,
);
throw new FluxerAPIError({
code: APIErrorCodes.IP_AUTHORIZATION_REQUIRED,
message: 'New login location detected. Check your inbox for a link to authorize this device.',
status: 403,
data: {
ip_authorization_required: true,
ticket,
email: currentUser.email,
resend_available_in: 30,
},
});
}
}
if (hasMfa) {
return await this.createMfaTicketResponse(currentUser);
}
if (data.invite_code && this.inviteService) {
try {
await this.inviteService.acceptInvite({
userId: currentUser.id,
inviteCode: createInviteCode(data.invite_code),
requestCache: createRequestCache(),
});
} catch (error) {
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
}
}
const [token] = await this.createAuthSession({user: currentUser, request});
const isPendingVerification = (currentUser.flags & UserFlags.PENDING_MANUAL_VERIFICATION) !== 0n;
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'none'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {
mfa: false,
user_id: currentUser.id.toString(),
token,
pending_verification: isPendingVerification ? true : undefined,
};
}
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new Error('User not found');
}
this.assertNonBotUser(user);
if (!user.totpSecret) {
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'totp'},
});
return {user_id: user.id.toString(), token};
}
const isValid = await this.verifyMfaCode({
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
if (!isValid) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw InputValidationError.create('code', 'Invalid code');
}
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'totp'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
async loginMfaSms({
code,
ticket,
request,
}: {
code: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new Error('User not found');
}
this.assertNonBotUser(user);
const isValid = await this.verifySmsMfaCode(user.id, code);
if (!isValid) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw InputValidationError.create('code', 'Invalid code');
}
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'sms'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
async loginMfaWebAuthn({
response,
challenge,
ticket,
request,
}: {
response: AuthenticationResponseJSON;
challenge: string;
ticket: string;
request: Request;
}): Promise<{user_id: string; token: string}> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_ticket_expired'},
});
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
if (!user) {
throw new Error('User not found');
}
this.assertNonBotUser(user);
await this.verifyWebAuthnAuthentication(user.id, response, challenge, 'mfa', ticket);
await this.cacheService.delete(`mfa-ticket:${ticket}`);
const [token] = await this.createAuthSession({user, request});
getMetricsService().counter({
name: 'user.login',
dimensions: {mfa_type: 'webauthn'},
});
getMetricsService().counter({
name: 'auth.login.success',
});
return {user_id: user.id.toString(), token};
}
private async createMfaTicketResponse(user: User): Promise<{
mfa: true;
ticket: string;
sms: boolean;
totp: boolean;
webauthn: boolean;
}> {
const ticket = createMfaTicket(RandomUtils.randomString(64));
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), 60 * 5);
const credentials = await this.repository.listWebAuthnCredentials(user.id);
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
const hasWebauthn = credentials.length > 0;
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
return {
mfa: true,
ticket: ticket,
sms: hasSms,
totp: hasTotp,
webauthn: hasWebauthn,
};
}
}

View File

@@ -0,0 +1,721 @@
/*
* 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 type {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
type VerifiedAuthenticationResponse,
type VerifiedRegistrationResponse,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {createUserID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {APIErrorCodes, UserAuthenticatorTypes} from '~/Constants';
import {
FluxerAPIError,
InputValidationError,
PhoneRequiredForSmsMfaError,
SmsMfaNotEnabledError,
SmsMfaRequiresTotpError,
} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ISMSService} from '~/infrastructure/ISMSService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
import type {IUserRepository} from '~/user/IUserRepository';
import {mapUserToPrivateResponse} from '~/user/UserModel';
import {TotpGenerator} from '~/utils/TotpGenerator';
const WEBAUTHN_CHALLENGE_TTL_SECONDS = 60 * 5;
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
interface SudoMfaVerificationParams {
userId: UserID;
method: 'totp' | 'sms' | 'webauthn';
code?: string;
webauthnResponse?: AuthenticationResponseJSON;
webauthnChallenge?: string;
}
interface SudoMfaVerificationResult {
success: boolean;
error?: string;
}
interface VerifyMfaCodeParams {
userId: UserID;
mfaSecret: string;
code: string;
allowBackup?: boolean;
}
export class AuthMfaService {
constructor(
private repository: IUserRepository,
private cacheService: ICacheService,
private smsService: ISMSService,
private gatewayService: IGatewayService,
private botMfaMirrorService?: BotMfaMirrorService,
) {}
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
try {
const totp = new TotpGenerator(mfaSecret);
const isValidTotp = await totp.validateTotp(code);
if (isValidTotp) {
if (Config.dev.testModeEnabled) {
return true;
}
const reuseKey = `mfa-totp:${userId}:${code}`;
const isCodeUsed = await this.cacheService.get<number>(reuseKey);
if (!isCodeUsed) {
await this.cacheService.set(reuseKey, 1, 30);
return true;
}
}
} catch {}
if (allowBackup) {
const backupCodes = await this.repository.listMfaBackupCodes(userId);
const backupCode = backupCodes.find((bc) => bc.code === code && !bc.consumed);
if (backupCode) {
await this.repository.consumeMfaBackupCode(userId, code);
return true;
}
}
return false;
}
async enableSmsMfa(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
if (!user.totpSecret) {
throw new SmsMfaRequiresTotpError();
}
if (!user.phone) {
throw new PhoneRequiredForSmsMfaError();
}
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.add(UserAuthenticatorTypes.SMS);
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
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!),
});
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
async disableSmsMfa(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
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!),
});
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
async sendSmsMfaCode(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
throw new SmsMfaNotEnabledError();
}
if (!user.phone) {
throw new PhoneRequiredForSmsMfaError();
}
await this.smsService.startVerification(user.phone);
}
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
await this.sendSmsMfaCode(createUserID(BigInt(userId)));
}
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
const user = await this.repository.findUnique(userId);
if (!user || !user.phone) {
return false;
}
return await this.smsService.checkVerification(user.phone, code);
}
async generateWebAuthnRegistrationOptions(userId: UserID) {
const user = await this.repository.findUniqueAssert(userId);
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
if (existingCredentials.length >= 10) {
throw new FluxerAPIError({
code: APIErrorCodes.WEBAUTHN_CREDENTIAL_LIMIT_REACHED,
message: 'You have reached the maximum number of passkeys (10)',
status: 400,
});
}
const rpName = Config.auth.passkeys.rpName;
const rpID = Config.auth.passkeys.rpId;
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(user.id.toString()),
userName: user.username!,
userDisplayName: user.username!,
attestationType: 'none',
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'registration', userId});
return options;
}
async verifyWebAuthnRegistration(
userId: UserID,
response: RegistrationResponseJSON,
expectedChallenge: string,
name: string,
): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
await this.consumeWebAuthnChallenge(expectedChallenge, 'registration', {userId});
if (existingCredentials.length >= 10) {
throw new FluxerAPIError({
code: APIErrorCodes.WEBAUTHN_CREDENTIAL_LIMIT_REACHED,
message: 'You have reached the maximum number of passkeys (10)',
status: 400,
});
}
const rpID = Config.auth.passkeys.rpId;
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
});
} catch (_error) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'Failed to verify WebAuthn credential',
status: 400,
});
}
if (!verification.verified || !verification.registrationInfo) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'Failed to verify WebAuthn credential',
status: 400,
});
}
const {credential} = verification.registrationInfo;
let publicKeyBuffer: Buffer;
let counterBigInt: bigint;
try {
publicKeyBuffer = Buffer.from(credential.publicKey);
} catch (_error) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'Invalid credential public key format during registration',
status: 400,
});
}
try {
if (credential.counter === undefined || credential.counter === null) {
throw new Error('Counter value is undefined or null');
}
counterBigInt = BigInt(credential.counter);
} catch (_error) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'Invalid credential counter value during registration',
status: 400,
});
}
const responseObj = response as {response?: {transports?: Array<string>}};
await this.repository.createWebAuthnCredential(
userId,
credential.id,
publicKeyBuffer,
counterBigInt,
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
name,
);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
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!),
});
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
throw new FluxerAPIError({
code: APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL,
message: 'Credential not found',
status: 404,
});
}
await this.repository.deleteWebAuthnCredential(userId, credentialId);
const remainingCredentials = await this.repository.listWebAuthnCredentials(userId);
if (remainingCredentials.length === 0) {
const user = await this.repository.findUniqueAssert(userId);
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
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!),
});
if (updatedUser) {
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
}
}
}
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
throw new FluxerAPIError({
code: APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL,
message: 'Credential not found',
status: 404,
});
}
await this.repository.updateWebAuthnCredentialName(userId, credentialId, name);
}
async generateWebAuthnAuthenticationOptionsDiscoverable() {
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'discoverable'});
return options;
}
async verifyWebAuthnAuthenticationDiscoverable(
response: AuthenticationResponseJSON,
expectedChallenge: string,
): Promise<User> {
const responseObj = response as {id: string};
const credentialId = responseObj.id;
const userId = await this.repository.getUserIdByCredentialId(credentialId);
if (!userId) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new FluxerAPIError({
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
message: 'Passkey authentication failed',
status: 401,
});
}
await this.verifyWebAuthnAuthentication(userId, response, expectedChallenge, 'discoverable');
const user = await this.repository.findUniqueAssert(userId);
return user;
}
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
}
const credentials = await this.repository.listWebAuthnCredentials(createUserID(BigInt(userId)));
if (credentials.length === 0) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'No passkeys registered',
status: 400,
});
}
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'mfa', userId: createUserID(BigInt(userId)), ticket});
return options;
}
async verifyWebAuthnAuthentication(
userId: UserID,
response: AuthenticationResponseJSON,
expectedChallenge: string,
context: WebAuthnChallengeContext = 'mfa',
ticket?: string,
): Promise<void> {
await this.consumeWebAuthnChallenge(expectedChallenge, context, {userId, ticket});
const responseObj = response as {id: string};
const credentialId = responseObj.id;
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
if (!credential) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new FluxerAPIError({
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
message: 'Passkey authentication failed',
status: 401,
});
}
const rpID = Config.auth.passkeys.rpId;
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
let verification: VerifiedAuthenticationResponse;
try {
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
try {
const buffer = Buffer.from(credential.publicKey);
const arrayBuffer: ArrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
publicKeyUint8Array = new Uint8Array(arrayBuffer);
} catch (_error) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'Invalid credential public key format',
status: 400,
});
}
verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: publicKeyUint8Array,
counter: Number(credential.counter),
transports: credential.transports
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
},
});
} catch (_error) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new FluxerAPIError({
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
message: 'Passkey authentication failed',
status: 401,
});
}
if (!verification.verified) {
getMetricsService().counter({
name: 'auth.login.failure',
dimensions: {reason: 'mfa_invalid'},
});
throw new FluxerAPIError({
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
message: 'Passkey authentication failed',
status: 401,
});
}
let newCounter: bigint;
try {
if (
verification.authenticationInfo.newCounter === undefined ||
verification.authenticationInfo.newCounter === null
) {
throw new Error('Counter value is undefined or null');
}
newCounter = BigInt(verification.authenticationInfo.newCounter);
} catch (_error) {
throw new FluxerAPIError({
code: APIErrorCodes.GENERAL_ERROR,
message: 'Invalid authentication counter value',
status: 500,
});
}
await this.repository.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
await this.repository.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async generateWebAuthnOptionsForSudo(userId: UserID) {
const credentials = await this.repository.listWebAuthnCredentials(userId);
if (credentials.length === 0) {
throw new FluxerAPIError({
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
message: 'No passkeys registered',
status: 400,
});
}
const rpID = Config.auth.passkeys.rpId;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
: undefined,
})),
userVerification: 'required',
});
await this.saveWebAuthnChallenge(options.challenge, {context: 'sudo', userId});
return options;
}
async verifySudoMfa(params: SudoMfaVerificationParams): Promise<SudoMfaVerificationResult> {
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
const user = await this.repository.findUnique(userId);
const hasMfa = (user?.authenticatorTypes?.size ?? 0) > 0;
if (!user || !hasMfa) {
return {success: false, error: 'MFA not enabled'};
}
switch (method) {
case 'totp': {
if (!code) {
return {success: false, error: 'TOTP code is required'};
}
if (!user.totpSecret) {
return {success: false, error: 'TOTP is not enabled'};
}
const isValid = await this.verifyMfaCode({
userId,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'sms': {
if (!code) {
return {success: false, error: 'SMS code is required'};
}
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
return {success: false, error: 'SMS MFA is not enabled'};
}
const isValid = await this.verifySmsMfaCode(userId, code);
return {success: isValid, error: isValid ? undefined : 'Invalid SMS code'};
}
case 'webauthn': {
if (!webauthnResponse || !webauthnChallenge) {
return {success: false, error: 'WebAuthn response and challenge are required'};
}
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
return {success: false, error: 'WebAuthn is not enabled'};
}
try {
await this.verifyWebAuthnAuthentication(userId, webauthnResponse, webauthnChallenge, 'sudo');
return {success: true};
} catch {
return {success: false, error: 'WebAuthn verification failed'};
}
}
default:
return {success: false, error: 'Invalid MFA method'};
}
}
async getAvailableMfaMethods(
userId: UserID,
): Promise<{totp: boolean; sms: boolean; webauthn: boolean; has_mfa: boolean}> {
const user = await this.repository.findUnique(userId);
if (!user) {
return {totp: false, sms: false, webauthn: false, has_mfa: false};
}
const hasMfa = (user.authenticatorTypes?.size ?? 0) > 0;
return {
totp: user.totpSecret !== null,
sms: user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS) ?? false,
webauthn: user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
has_mfa: hasMfa,
};
}
private getWebAuthnChallengeCacheKey(challenge: string): string {
return `webauthn:challenge:${challenge}`;
}
private async saveWebAuthnChallenge(
challenge: string,
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
): Promise<void> {
const key = this.getWebAuthnChallengeCacheKey(challenge);
await this.cacheService.set(
key,
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
WEBAUTHN_CHALLENGE_TTL_SECONDS,
);
}
private async consumeWebAuthnChallenge(
challenge: string,
expectedContext: WebAuthnChallengeContext,
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
): Promise<void> {
const key = this.getWebAuthnChallengeCacheKey(challenge);
const cached = await this.cacheService.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(
key,
);
const challengeMatches =
cached &&
cached.context === expectedContext &&
(userId === undefined || cached.userId === undefined || cached.userId === userId.toString()) &&
(ticket === undefined || cached.ticket === undefined || cached.ticket === ticket);
if (!challengeMatches) {
throw this.createChallengeError(expectedContext);
}
await this.cacheService.delete(key);
}
private createChallengeError(context: WebAuthnChallengeContext): FluxerAPIError {
const isRegistration = context === 'registration';
return new FluxerAPIError({
code: isRegistration ? APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL : APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
message: isRegistration ? 'Failed to verify WebAuthn credential' : 'Passkey authentication failed',
status: isRegistration ? 400 : 401,
});
}
}

View File

@@ -0,0 +1,203 @@
/*
* 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 type {ForgotPasswordRequest, ResetPasswordRequest} from '~/auth/AuthModel';
import {createPasswordResetToken} from '~/BrandedTypes';
import {FLUXER_USER_AGENT, UserFlags} from '~/Constants';
import {InputValidationError, RateLimitError, UnauthorizedError} from '~/Errors';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {Logger} from '~/Logger';
import type {AuthSession, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import * as IpUtils from '~/utils/IpUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '~/utils/PasswordUtils';
interface ForgotPasswordParams {
data: ForgotPasswordRequest;
request: Request;
}
interface ResetPasswordParams {
data: ResetPasswordRequest;
request: Request;
}
interface VerifyPasswordParams {
password: string;
passwordHash: string;
}
export class AuthPasswordService {
constructor(
private repository: IUserRepository,
private emailService: IEmailService,
private rateLimitService: IRateLimitService,
private generateSecureToken: () => Promise<string>,
private handleBanStatus: (user: User) => Promise<User>,
private assertNonBotUser: (user: User) => void,
private createMfaTicketResponse: (
user: User,
) => Promise<{mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}>,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
) {}
async hashPassword(password: string): Promise<string> {
return hashPasswordUtil(password);
}
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
return verifyPasswordUtil({password, passwordHash});
}
async isPasswordPwned(password: string): Promise<boolean> {
try {
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = hashed.slice(0, 5);
const hashSuffix = hashed.slice(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
headers: {
'User-Agent': FLUXER_USER_AGENT,
'Add-Padding': 'true',
},
});
if (!response.ok) {
return false;
}
const body = await response.text();
const lines = body.split('\n');
for (const line of lines) {
const [hashSuffixLine, count] = line.split(':', 2);
if (hashSuffixLine === hashSuffix && Number.parseInt(count, 10) > 0) {
return true;
}
}
return false;
} catch (error) {
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
return false;
}
}
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
const clientIp = IpUtils.requireClientIp(request);
const ipLimitConfig = {maxAttempts: 20, windowMs: 30 * 60 * 1000};
const emailLimitConfig = {maxAttempts: 5, windowMs: 30 * 60 * 1000};
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `password_reset:ip:${clientIp}`,
...ipLimitConfig,
});
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `password_reset:email:${data.email.toLowerCase()}`,
...emailLimitConfig,
});
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({
message: 'Too many password reset attempts. Please try again later.',
retryAfter,
limit: exceeded.config.maxAttempts,
resetTime: exceeded.result.resetTime,
});
}
const user = await this.repository.findByEmail(data.email);
if (!user) {
return;
}
this.assertNonBotUser(user);
const token = createPasswordResetToken(await this.generateSecureToken());
await this.repository.createPasswordResetToken({
token_: token,
user_id: user.id,
email: user.email!,
});
await this.emailService.sendPasswordResetEmail(user.email!, user.username, token, user.locale);
}
async resetPassword({
data,
request,
}: ResetPasswordParams): Promise<
| {mfa: false; user_id: string; token: string}
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
> {
const tokenData = await this.repository.getPasswordResetToken(data.token);
if (!tokenData) {
throw InputValidationError.create('token', 'Invalid or expired reset token');
}
const user = await this.repository.findUnique(tokenData.userId);
if (!user) {
throw InputValidationError.create('token', 'Invalid or expired reset token');
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
throw InputValidationError.create('token', 'Invalid or expired reset token');
}
await this.handleBanStatus(user);
if (await this.isPasswordPwned(data.password)) {
throw InputValidationError.create('password', 'Password is too common');
}
const newPasswordHash = await this.hashPassword(data.password);
const updatedUser = await this.repository.patchUpsert(user.id, {
password_hash: newPasswordHash,
password_last_changed_at: new Date(),
});
if (!updatedUser) {
throw new UnauthorizedError();
}
await this.repository.deleteAllAuthSessions(user.id);
await this.repository.deletePasswordResetToken(data.token);
const hasMfa = (updatedUser.authenticatorTypes?.size ?? 0) > 0;
if (hasMfa) {
return await this.createMfaTicketResponse(updatedUser);
}
const [token] = await this.createAuthSession({user: updatedUser, request});
return {mfa: false, user_id: updatedUser.id.toString(), token};
}
}

View File

@@ -0,0 +1,212 @@
/*
* 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 {createPhoneVerificationToken, type UserID} from '~/BrandedTypes';
import {SuspiciousActivityFlags, UserAuthenticatorTypes, UserFlags} from '~/Constants';
import {
InvalidPhoneNumberError,
InvalidPhoneVerificationCodeError,
PhoneAlreadyUsedError,
PhoneVerificationRequiredError,
SmsMfaNotEnabledError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ISMSService} from '~/infrastructure/ISMSService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
import {PHONE_E164_REGEX} from '~/Schema';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
import {mapUserToPrivateResponse} from '~/user/UserModel';
const PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
export class AuthPhoneService {
constructor(
private repository: IUserRepository,
private smsService: ISMSService,
private gatewayService: IGatewayService,
private assertNonBotUser: (user: User) => void,
private generateSecureToken: () => Promise<string>,
private contactChangeLogService: UserContactChangeLogService,
) {}
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
if (!PHONE_E164_REGEX.test(phone)) {
throw new InvalidPhoneNumberError();
}
const existingUser = await this.repository.findByPhone(phone);
if (userId) {
const requestingUser = await this.repository.findUnique(userId);
if (requestingUser) {
this.assertNonBotUser(requestingUser);
}
}
const allowReverification =
existingUser &&
userId &&
existingUser.id === userId &&
existingUser.suspiciousActivityFlags !== null &&
((existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE) !== 0 ||
(existingUser.suspiciousActivityFlags &
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
0 ||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
0 ||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !==
0);
if (existingUser) {
this.assertNonBotUser(existingUser);
}
if (existingUser && (!userId || existingUser.id !== userId) && !allowReverification) {
throw new PhoneAlreadyUsedError();
}
await this.smsService.startVerification(phone);
}
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
if (!PHONE_E164_REGEX.test(phone)) {
throw new InvalidPhoneNumberError();
}
const isValid = await this.smsService.checkVerification(phone, code);
if (!isValid) {
throw new InvalidPhoneVerificationCodeError();
}
const phoneToken = await this.generateSecureToken();
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
await this.repository.createPhoneToken(phoneVerificationToken, phone, userId);
return phoneToken;
}
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
const tokenData = await this.repository.getPhoneToken(phoneVerificationToken);
if (!tokenData) {
throw new PhoneVerificationRequiredError();
}
if (tokenData.user_id && tokenData.user_id !== userId) {
throw new PhoneVerificationRequiredError();
}
const existingUser = await this.repository.findByPhone(tokenData.phone);
if (existingUser && existingUser.id !== userId) {
throw new PhoneAlreadyUsedError();
}
const user = await this.repository.findUnique(userId);
if (!user) {
throw new PhoneVerificationRequiredError();
}
this.assertNonBotUser(user);
if (user.flags & UserFlags.DELETED) {
throw new PhoneVerificationRequiredError();
}
const updates: {phone: string; suspicious_activity_flags?: number} = {
phone: tokenData.phone,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await this.repository.patchUpsert(userId, updates);
await this.repository.deletePhoneToken(phoneVerificationToken);
if (updatedUser) {
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: userId,
});
}
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!),
});
}
async removePhoneFromAccount(userId: UserID): Promise<void> {
const user = await this.repository.findUniqueAssert(userId);
this.assertNonBotUser(user);
if (user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
throw new SmsMfaNotEnabledError();
}
const updatedUser = await this.repository.patchUpsert(userId, {phone: null});
if (updatedUser) {
await this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: userId,
});
}
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!),
});
}
}

View File

@@ -0,0 +1,577 @@
/*
* 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 {types} from 'cassandra-driver';
import {UAParser} from 'ua-parser-js';
import type {RegisterRequest} from '~/auth/AuthModel';
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {APIErrorCodes, UserFlags} from '~/Constants';
import {FluxerAPIError, InputValidationError} from '~/Errors';
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
import {getMetricsService} from '~/infrastructure/MetricsService';
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
import type {RedisActivityTracker} from '~/infrastructure/RedisActivityTracker';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository';
import type {InviteService} from '~/invite/InviteService';
import {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {AuthSession, User} from '~/Models';
import {UserSettings} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import * as AgeUtils from '~/utils/AgeUtils';
import * as IpUtils from '~/utils/IpUtils';
import {parseAcceptLanguage} from '~/utils/LocaleUtils';
import {generateRandomUsername} from '~/utils/UsernameGenerator';
const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
KR: 14,
VN: 15,
AW: 16,
BQ: 16,
CW: 16,
SX: 16,
AT: 14,
BG: 14,
HR: 16,
CY: 14,
CZ: 15,
FR: 15,
DE: 16,
GR: 15,
HU: 16,
IE: 16,
IT: 14,
LT: 14,
LU: 16,
NL: 16,
PL: 16,
RO: 16,
SM: 16,
RS: 15,
SK: 16,
SI: 16,
ES: 14,
CL: 14,
CO: 14,
PE: 14,
VE: 14,
};
const DEFAULT_MINIMUM_AGE = 13;
const USER_AGENT_TRUNCATE_LENGTH = 512;
interface RegistrationMetadataContext {
metadata: Map<string, string>;
clientIp: string;
countryCode: string;
location: string;
city: string | null;
region: string | null;
osInfo: string;
browserInfo: string;
deviceInfo: string;
truncatedUserAgent: string;
fluxerTag: string;
displayName: string;
email: string;
ipAddressReverse: string | null;
}
const AGE_BUCKETS: Array<{label: string; min: number; max: number}> = [
{label: '0-12', min: 0, max: 12},
{label: '13-17', min: 13, max: 17},
{label: '18-24', min: 18, max: 24},
{label: '25-34', min: 25, max: 34},
{label: '35-44', min: 35, max: 44},
{label: '45-54', min: 45, max: 54},
{label: '55-64', min: 55, max: 64},
];
function determineAgeGroup(age: number | null): string {
if (age === null || age < 0) {
return 'unknown';
}
for (const bucket of AGE_BUCKETS) {
if (age >= bucket.min && age <= bucket.max) {
return bucket.label;
}
}
return '65+';
}
interface RegisterParams {
data: RegisterRequest;
request: Request;
requestCache: RequestCache;
}
export class AuthRegistrationService {
constructor(
private repository: IUserRepository,
private inviteService: InviteService,
private rateLimitService: IRateLimitService,
private emailService: IEmailService,
private snowflakeService: SnowflakeService,
private discriminatorService: IDiscriminatorService,
private redisActivityTracker: RedisActivityTracker,
private pendingJoinInviteStore: PendingJoinInviteStore,
private cacheService: ICacheService,
private hashPassword: (password: string) => Promise<string>,
private isPasswordPwned: (password: string) => Promise<boolean>,
private validateAge: (params: {dateOfBirth: string; minAge: number}) => boolean,
private generateSecureToken: () => Promise<string>,
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
) {}
async register({
data,
request,
requestCache,
}: RegisterParams): Promise<{user_id: string; token: string; pending_verification?: boolean}> {
if (!data.consent) {
throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy');
}
const countryCode = await IpUtils.getCountryCodeFromReq(request);
const clientIp = IpUtils.requireClientIp(request);
const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp);
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
throw InputValidationError.create(
'date_of_birth',
`You must be at least ${minAge} years old to create an account`,
);
}
if (data.password && (await this.isPasswordPwned(data.password))) {
throw InputValidationError.create('password', 'Password is too common');
}
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
if (enforceRateLimits && data.email) {
const emailRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:email:${data.email}`,
maxAttempts: 3,
windowMs: 15 * 60 * 1000,
});
if (!emailRateLimit.allowed) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Too many registration attempts. Please try again later.',
status: 429,
});
}
}
if (enforceRateLimits) {
const ipRateLimit = await this.rateLimitService.checkLimit({
identifier: `registration:ip:${clientIp}`,
maxAttempts: 5,
windowMs: 30 * 60 * 1000,
});
if (!ipRateLimit.allowed) {
throw new FluxerAPIError({
code: APIErrorCodes.RATE_LIMITED,
message: 'Too many registration attempts from this IP. Please try again later.',
status: 429,
});
}
}
let betaCode = null;
let hasValidBetaCode = false;
if (data.beta_code) {
if (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY') {
hasValidBetaCode = false;
} else {
betaCode = await this.repository.getBetaCode(data.beta_code);
if (betaCode && !betaCode.redeemerId) {
hasValidBetaCode = true;
}
}
}
const rawEmail = data.email?.trim() || null;
const normalizedEmail = rawEmail?.toLowerCase() || null;
if (normalizedEmail) {
const emailTaken = await this.repository.findByEmail(normalizedEmail);
if (emailTaken) {
throw InputValidationError.create('email', 'Email already in use');
}
}
const username = data.username || generateRandomUsername();
const discriminatorResult = await this.discriminatorService.generateDiscriminator({
username,
isPremium: false,
});
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
throw InputValidationError.create('username', 'Too many users with this username');
}
const discriminator = discriminatorResult.discriminator;
let userId: UserID;
if (normalizedEmail && process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) {
const mapping = JSON.parse(process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) as Record<string, string>;
const emailHash = crypto.createHash('sha256').update(normalizedEmail).digest('hex');
const mappedUserId = mapping[emailHash];
userId = mappedUserId ? createUserID(BigInt(mappedUserId)) : createUserID(this.snowflakeService.generate());
} else {
userId = createUserID(this.snowflakeService.generate());
}
const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await this.hashPassword(data.password) : null;
const instanceConfigRepository = new InstanceConfigRepository();
const instanceConfig = await instanceConfigRepository.getInstanceConfig();
const isManualReviewActive = instanceConfigRepository.isManualReviewActiveNow(instanceConfig);
const shouldRequireVerification =
(isManualReviewActive && Config.nodeEnv === 'production') ||
(Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY');
const isPendingVerification = shouldRequireVerification && !hasValidBetaCode;
let baseFlags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
if (isPendingVerification) {
baseFlags |= UserFlags.PENDING_MANUAL_VERIFICATION;
}
const now = new Date();
const user = await this.repository.create({
user_id: userId,
username,
discriminator: discriminator,
global_name: data.global_name || null,
bot: false,
system: false,
email: rawEmail,
email_verified: false,
email_bounced: false,
phone: null,
password_hash: passwordHash,
password_last_changed_at: passwordHash ? new Date() : null,
totp_secret: null,
authenticator_types: new Set(),
avatar_hash: null,
avatar_color: null,
banner_hash: null,
banner_color: null,
bio: null,
pronouns: null,
accent_color: null,
date_of_birth: types.LocalDate.fromString(data.date_of_birth),
locale: userLocale,
flags: baseFlags,
premium_type: null,
premium_since: null,
premium_until: null,
premium_will_cancel: null,
premium_billing_cycle: null,
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
suspicious_activity_flags: null,
terms_agreed_at: new Date(),
privacy_agreed_at: new Date(),
last_active_at: now,
last_active_ip: clientIp,
temp_banned_until: null,
pending_deletion_at: null,
pending_bulk_message_deletion_at: null,
pending_bulk_message_deletion_channel_count: null,
pending_bulk_message_deletion_message_count: null,
deletion_reason_code: null,
deletion_public_reason: null,
deletion_audit_log_reason: null,
acls: null,
first_refund_at: null,
beta_code_allowance: 0,
beta_code_last_reset_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_onboarding_dismissed_at: null,
version: 1,
});
await this.redisActivityTracker.updateActivity(user.id, now);
getMetricsService().counter({
name: 'user.registration',
dimensions: {
country: countryCode ?? 'unknown',
state: countryResultDetailed.region ?? 'unknown',
ip_version: clientIp.includes(':') ? 'v6' : 'v4',
},
});
const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null;
getMetricsService().counter({
name: 'user.age',
dimensions: {
country: countryCode ?? 'unknown',
state: countryResultDetailed.region ?? 'unknown',
age: age !== null ? age.toString() : 'unknown',
age_group: determineAgeGroup(age),
},
});
await this.repository.upsertSettings(
UserSettings.getDefaultUserSettings({
userId,
locale: userLocale,
isAdult: AgeUtils.isUserAdult(data.date_of_birth),
}),
);
const userSearchService = getUserSearchService();
if (userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
if (rawEmail) {
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken());
await this.repository.createEmailVerificationToken({
token_: emailVerifyToken,
user_id: userId,
email: rawEmail,
});
await this.emailService.sendEmailVerification(rawEmail, user.username, emailVerifyToken, user.locale);
}
if (betaCode) {
await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, new Date());
}
const registrationMetadata = await this.buildRegistrationMetadataContext(user, clientIp, request);
if (isPendingVerification) {
await this.repository.createPendingVerification(userId, new Date(), registrationMetadata.metadata);
}
await this.repository.createAuthorizedIp(userId, clientIp);
const inviteCodeToJoin = data.invite_code || Config.instance.autoJoinInviteCode;
if (inviteCodeToJoin != null) {
if (isPendingVerification) {
await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCodeToJoin);
} else if (this.inviteService) {
try {
await this.inviteService.acceptInvite({
userId,
inviteCode: createInviteCode(inviteCodeToJoin),
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: inviteCodeToJoin, error}, 'Failed to auto-join invite on registration');
}
}
}
const [token] = await this.createAuthSession({user, request});
this.sendRegistrationWebhook(user, registrationMetadata, instanceConfig.registrationAlertsWebhookUrl).catch(
(error) => {
Logger.warn({error, userId: user.id.toString()}, 'Failed to send registration webhook');
},
);
return {
user_id: user.id.toString(),
token,
pending_verification: isPendingVerification ? true : undefined,
};
}
private async buildRegistrationMetadataContext(
user: User,
clientIp: string,
request: Request,
): Promise<RegistrationMetadataContext> {
const countryResult = await IpUtils.getCountryCodeDetailed(clientIp);
const userAgentHeader = request.headers.get('user-agent') ?? '';
const trimmedUserAgent = userAgentHeader.trim();
const parsedUserAgent = new UAParser(trimmedUserAgent).getResult();
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
const displayName = user.globalName || user.username;
const emailDisplay = user.email || 'Not provided';
const normalizedUserAgent = trimmedUserAgent.length > 0 ? trimmedUserAgent : 'Not provided';
const truncatedUserAgent = this.truncateUserAgent(normalizedUserAgent);
const normalizedIp = countryResult.normalizedIp ?? clientIp;
const geoipReason = countryResult.reason ?? 'none';
const osInfo = parsedUserAgent.os.name
? `${parsedUserAgent.os.name}${parsedUserAgent.os.version ? ` ${parsedUserAgent.os.version}` : ''}`
: 'Unknown';
const browserInfo = parsedUserAgent.browser.name
? `${parsedUserAgent.browser.name}${parsedUserAgent.browser.version ? ` ${parsedUserAgent.browser.version}` : ''}`
: 'Unknown';
const deviceInfo = parsedUserAgent.device.vendor
? `${parsedUserAgent.device.vendor} ${parsedUserAgent.device.model || ''}`.trim()
: 'Desktop/Unknown';
const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService);
const locationLabel = IpUtils.formatGeoipLocation(countryResult);
const metadataEntries: Array<[string, string]> = [
['fluxer_tag', fluxerTag],
['display_name', displayName],
['email', emailDisplay],
['ip_address', clientIp],
['normalized_ip', normalizedIp],
['country_code', countryResult.countryCode],
['location', locationLabel],
['geoip_reason', geoipReason],
['os', osInfo],
['browser', browserInfo],
['device', deviceInfo],
['user_agent', truncatedUserAgent],
];
if (countryResult.city) {
metadataEntries.push(['city', countryResult.city]);
}
if (countryResult.region) {
metadataEntries.push(['region', countryResult.region]);
}
if (countryResult.countryName) {
metadataEntries.push(['country_name', countryResult.countryName]);
}
if (ipAddressReverse) {
metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
}
return {
metadata: new Map(metadataEntries),
clientIp,
countryCode: countryResult.countryCode,
location: locationLabel,
city: countryResult.city,
region: countryResult.region,
osInfo,
browserInfo,
deviceInfo,
truncatedUserAgent,
fluxerTag,
displayName,
email: emailDisplay,
ipAddressReverse,
};
}
private truncateUserAgent(userAgent: string): string {
if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) {
return userAgent;
}
return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`;
}
private async sendRegistrationWebhook(
user: User,
context: RegistrationMetadataContext,
webhookUrl: string | null,
): Promise<void> {
if (!webhookUrl) return;
const {
clientIp,
countryCode,
location,
city,
osInfo,
browserInfo,
deviceInfo,
truncatedUserAgent,
fluxerTag,
displayName,
email,
ipAddressReverse,
} = context;
const locationDisplay = city ? location : countryCode;
const embedFields = [
{name: 'User ID', value: user.id.toString(), inline: true},
{name: 'FluxerTag', value: fluxerTag, inline: true},
{name: 'Display Name', value: displayName, inline: true},
{name: 'Email', value: email, inline: true},
{name: 'IP Address', value: clientIp, inline: true},
{name: 'Location', value: locationDisplay, inline: true},
{name: 'OS', value: osInfo, inline: true},
{name: 'Browser', value: browserInfo, inline: true},
{name: 'Device', value: deviceInfo, inline: true},
{name: 'User Agent', value: truncatedUserAgent, inline: false},
];
if (ipAddressReverse) {
embedFields.splice(6, 0, {name: 'Reverse DNS', value: ipAddressReverse, inline: true});
}
const payload = {
username: 'Registration Monitor',
embeds: [
{
title: 'New Account Registered',
color: 0x10b981,
fields: embedFields,
timestamp: new Date().toISOString(),
},
],
};
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text();
Logger.warn({status: response.status, body}, 'Failed to send registration webhook');
}
} catch (error) {
Logger.warn({error}, 'Failed to send registration webhook');
}
}
}

View File

@@ -0,0 +1,158 @@
/*
* 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 {UAParser} from 'ua-parser-js';
import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel';
import type {UserID} from '~/BrandedTypes';
import {AccessDeniedError} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {AuthSession, User} from '~/Models';
import type {IUserRepository} from '~/user/IUserRepository';
import * as IpUtils from '~/utils/IpUtils';
interface CreateAuthSessionParams {
user: User;
request: Request;
}
interface LogoutAuthSessionsParams {
user: User;
sessionIdHashes: Array<string>;
}
interface UpdateUserActivityParams {
userId: UserID;
clientIp: string;
}
export class AuthSessionService {
constructor(
private repository: IUserRepository,
private gatewayService: IGatewayService,
private generateAuthToken: () => Promise<string>,
private getTokenIdHash: (token: string) => Uint8Array,
) {}
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
if (user.isBot) {
throw new AccessDeniedError('Bot users cannot create auth sessions');
}
const token = await this.generateAuthToken();
const ip = IpUtils.requireClientIp(request);
const userAgent = request.headers.get('user-agent') || '';
const platformHeader = request.headers.get('x-fluxer-platform')?.toLowerCase() ?? null;
const parsedUserAgent = new UAParser(userAgent).getResult();
const geoipResult = await IpUtils.getCountryCodeDetailed(ip);
const clientLocationLabel = IpUtils.formatGeoipLocation(geoipResult);
const detectedPlatform = parsedUserAgent.browser.name ?? 'Unknown';
const clientPlatform = platformHeader === 'desktop' ? 'Fluxer Desktop' : detectedPlatform;
const authSession = await this.repository.createAuthSession({
user_id: user.id,
session_id_hash: Buffer.from(this.getTokenIdHash(token)),
created_at: new Date(),
approx_last_used_at: new Date(),
client_ip: ip,
client_os: parsedUserAgent.os.name ?? 'Unknown',
client_platform: clientPlatform,
client_country:
(geoipResult.countryName ?? geoipResult.countryCode) === IpUtils.UNKNOWN_LOCATION
? null
: (geoipResult.countryName ?? geoipResult.countryCode),
client_location: clientLocationLabel === IpUtils.UNKNOWN_LOCATION ? null : clientLocationLabel,
version: 1,
});
return [token, authSession];
}
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
return this.repository.getAuthSessionByToken(Buffer.from(this.getTokenIdHash(token)));
}
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
const authSessions = await this.repository.listAuthSessions(userId);
return mapAuthSessionsToResponse({authSessions});
}
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
await this.repository.updateAuthSessionLastUsed(Buffer.from(tokenHash));
}
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
await this.repository.updateUserActivity(userId, clientIp);
}
async revokeToken(token: string): Promise<void> {
const tokenHash = this.getTokenIdHash(token);
const authSession = await this.repository.getAuthSessionByToken(Buffer.from(tokenHash));
if (authSession) {
await this.repository.revokeAuthSession(Buffer.from(tokenHash));
await this.gatewayService.terminateSession({
userId: authSession.userId,
sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')],
});
}
}
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
await this.repository.deleteAuthSessions(user.id, hashes);
await this.gatewayService.terminateSession({
userId: user.id,
sessionIdHashes: sessionIdHashes,
});
}
async terminateAllUserSessions(userId: UserID): Promise<void> {
const authSessions = await this.repository.listAuthSessions(userId);
await this.repository.deleteAuthSessions(
userId,
authSessions.map((session) => session.sessionIdHash),
);
await this.gatewayService.terminateSession({
userId,
sessionIdHashes: authSessions.map((session) => Buffer.from(session.sessionIdHash).toString('base64url')),
});
}
async dispatchAuthSessionChange({
userId,
oldAuthSessionIdHash,
newAuthSessionIdHash,
newToken,
}: {
userId: UserID;
oldAuthSessionIdHash: string;
newAuthSessionIdHash: string;
newToken: string;
}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'AUTH_SESSION_CHANGE',
data: {
old_auth_session_id_hash: oldAuthSessionIdHash,
new_auth_session_id_hash: newAuthSessionIdHash,
new_token: newToken,
},
});
}
}

View File

@@ -0,0 +1,214 @@
/*
* 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 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 {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
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,
) {}
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!),
});
}
}

View File

@@ -0,0 +1,150 @@
/*
* 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 {randomBytes} from 'node:crypto';
import {APIErrorCodes} from '~/constants/API';
import {BadRequestError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
const HANDOFF_CODE_EXPIRY_SECONDS = 5 * 60;
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 8;
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$/;
interface HandoffData {
createdAt: number;
userAgent?: string;
}
interface HandoffTokenData {
token: string;
userId: string;
}
function generateHandoffCode(): string {
const bytes = randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
}
return `${code.slice(0, 4)}-${code.slice(4, 8)}`;
}
function normalizeHandoffCode(code: string): string {
return code.replace(/[-\s]/g, '').toUpperCase();
}
function assertValidHandoffCode(code: string): void {
if (!NORMALIZED_CODE_REGEX.test(code)) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_HANDOFF_CODE,
message: 'Invalid handoff code format',
});
}
}
export class DesktopHandoffService {
constructor(private readonly cacheService: ICacheService) {}
async initiateHandoff(userAgent?: string): Promise<{code: string; expiresAt: Date}> {
const code = generateHandoffCode();
const normalizedCode = normalizeHandoffCode(code);
const handoffData: HandoffData = {
createdAt: Date.now(),
userAgent,
};
await this.cacheService.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, HANDOFF_CODE_EXPIRY_SECONDS);
const expiresAt = new Date(Date.now() + HANDOFF_CODE_EXPIRY_SECONDS * 1000);
return {code, expiresAt};
}
async completeHandoff(code: string, token: string, userId: string): Promise<void> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
throw new BadRequestError({
code: APIErrorCodes.INVALID_HANDOFF_CODE,
message: 'Invalid or expired handoff code',
});
}
const tokenData: HandoffTokenData = {
token,
userId,
};
const remainingSeconds = Math.max(
0,
HANDOFF_CODE_EXPIRY_SECONDS - Math.floor((Date.now() - handoffData.createdAt) / 1000),
);
if (remainingSeconds <= 0) {
throw new BadRequestError({
code: APIErrorCodes.HANDOFF_CODE_EXPIRED,
message: 'Handoff code has expired',
});
}
await this.cacheService.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
}
async getHandoffStatus(
code: string,
): Promise<{status: 'pending' | 'completed' | 'expired'; token?: string; userId?: string}> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const tokenData = await this.cacheService.getAndDelete<HandoffTokenData>(
`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`,
);
if (tokenData) {
return {
status: 'completed',
token: tokenData.token,
userId: tokenData.userId,
};
}
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
}
return {status: 'expired'};
}
async cancelHandoff(code: string): Promise<void> {
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await this.cacheService.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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 {jwtVerify, SignJWT} from 'jose';
import type {UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
const SUDO_TOKEN_EXPIRY_SECONDS = 5 * 60;
export class SudoModeService {
private readonly secret: Uint8Array;
constructor() {
this.secret = new TextEncoder().encode(Config.auth.sudoModeSecret);
}
async generateSudoToken(userId: UserID): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({
type: 'sudo',
})
.setProtectedHeader({alg: 'HS256'})
.setSubject(userId.toString())
.setIssuedAt(now)
.setExpirationTime(now + SUDO_TOKEN_EXPIRY_SECONDS)
.sign(this.secret);
return jwt;
}
async verifySudoToken(token: string, userId: UserID): Promise<boolean> {
try {
const {payload} = await jwtVerify(token, this.secret, {
algorithms: ['HS256'],
});
if (payload.type !== 'sudo') {
return false;
}
if (payload.sub !== userId.toString()) {
return false;
}
return true;
} catch {
return false;
}
}
}
let sudoModeServiceInstance: SudoModeService | null = null;
export function getSudoModeService(): SudoModeService {
if (!sudoModeServiceInstance) {
sudoModeServiceInstance = new SudoModeService();
}
return sudoModeServiceInstance;
}

View File

@@ -0,0 +1,157 @@
/*
* 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 type {AuthenticationResponseJSON} from '@simplewebauthn/server';
import type {Context} from 'hono';
import type {HonoEnv} from '~/App';
import type {AuthService} from '~/auth/AuthService';
import type {AuthMfaService} from '~/auth/services/AuthMfaService';
import {getSudoModeService} from '~/auth/services/SudoModeService';
import {InputValidationError} from '~/Errors';
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
import type {User} from '~/Models';
import {SUDO_MODE_HEADER} from '~/middleware/SudoModeMiddleware';
import {setSudoCookie} from '~/utils/SudoCookieUtils';
interface SudoVerificationBody {
password?: string;
mfa_method?: 'totp' | 'sms' | 'webauthn';
mfa_code?: string;
webauthn_response?: AuthenticationResponseJSON;
webauthn_challenge?: string;
}
type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
return (user.authenticatorTypes?.size ?? 0) > 0;
}
export interface SudoVerificationResult {
verified: boolean;
method: SudoVerificationMethod;
sudoToken?: string;
}
export interface SudoVerificationOptions {
issueSudoToken?: boolean;
}
async function verifySudoMode(
ctx: Context<HonoEnv>,
user: User,
body: SudoVerificationBody,
authService: AuthService,
mfaService: AuthMfaService,
options: SudoVerificationOptions = {},
): Promise<SudoVerificationResult> {
if (user.isBot) {
return {verified: true, method: 'sudo_token'};
}
const hasMfa = userHasMfa(user);
const issueSudoToken = options.issueSudoToken ?? hasMfa;
if (hasMfa && ctx.get('sudoModeValid')) {
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
}
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
}
if (hasMfa && body.mfa_method) {
const result = await mfaService.verifySudoMfa({
userId: user.id,
method: body.mfa_method,
code: body.mfa_code,
webauthnResponse: body.webauthn_response,
webauthnChallenge: body.webauthn_challenge,
});
if (!result.success) {
throw InputValidationError.create('mfa_code', result.error ?? 'Invalid MFA code');
}
const sudoModeService = getSudoModeService();
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
const isUnclaimedAccount = !user.passwordHash;
if (isUnclaimedAccount && !hasMfa) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
if (!user.passwordHash) {
throw InputValidationError.create('password', 'Password not set');
}
const passwordValid = await authService.verifyPassword({
password: body.password,
passwordHash: user.passwordHash,
});
if (!passwordValid) {
throw InputValidationError.create('password', 'Invalid password');
}
return {verified: true, method: 'password'};
}
throw new SudoModeRequiredError(hasMfa);
}
function setSudoTokenHeader(
ctx: Context<HonoEnv>,
result: SudoVerificationResult,
options: SudoVerificationOptions = {},
): void {
const issueSudoToken = options.issueSudoToken ?? true;
if (!issueSudoToken) {
return;
}
const tokenToSet = result.sudoToken ?? ctx.req.header(SUDO_MODE_HEADER);
if (tokenToSet) {
ctx.header(SUDO_MODE_HEADER, tokenToSet);
const user = ctx.get('user');
if (user) {
setSudoCookie(ctx, tokenToSet, user.id.toString());
}
}
}
export async function requireSudoMode(
ctx: Context<HonoEnv>,
user: User,
body: SudoVerificationBody,
authService: AuthService,
mfaService: AuthMfaService,
options: SudoVerificationOptions = {},
): Promise<SudoVerificationResult> {
const sudoResult = await verifySudoMode(ctx, user, body, authService, mfaService, options);
setSudoTokenHeader(ctx, sudoResult, options);
return sudoResult;
}