initial commit
This commit is contained in:
139
fluxer_api/src/auth/services/AuthEmailRevertService.ts
Normal file
139
fluxer_api/src/auth/services/AuthEmailRevertService.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
137
fluxer_api/src/auth/services/AuthEmailService.ts
Normal file
137
fluxer_api/src/auth/services/AuthEmailService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
586
fluxer_api/src/auth/services/AuthLoginService.ts
Normal file
586
fluxer_api/src/auth/services/AuthLoginService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
721
fluxer_api/src/auth/services/AuthMfaService.ts
Normal file
721
fluxer_api/src/auth/services/AuthMfaService.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
203
fluxer_api/src/auth/services/AuthPasswordService.ts
Normal file
203
fluxer_api/src/auth/services/AuthPasswordService.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
212
fluxer_api/src/auth/services/AuthPhoneService.ts
Normal file
212
fluxer_api/src/auth/services/AuthPhoneService.ts
Normal 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!),
|
||||
});
|
||||
}
|
||||
}
|
||||
577
fluxer_api/src/auth/services/AuthRegistrationService.ts
Normal file
577
fluxer_api/src/auth/services/AuthRegistrationService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
158
fluxer_api/src/auth/services/AuthSessionService.ts
Normal file
158
fluxer_api/src/auth/services/AuthSessionService.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
214
fluxer_api/src/auth/services/AuthUtilityService.ts
Normal file
214
fluxer_api/src/auth/services/AuthUtilityService.ts
Normal 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!),
|
||||
});
|
||||
}
|
||||
}
|
||||
150
fluxer_api/src/auth/services/DesktopHandoffService.ts
Normal file
150
fluxer_api/src/auth/services/DesktopHandoffService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
76
fluxer_api/src/auth/services/SudoModeService.ts
Normal file
76
fluxer_api/src/auth/services/SudoModeService.ts
Normal 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;
|
||||
}
|
||||
157
fluxer_api/src/auth/services/SudoVerificationService.ts
Normal file
157
fluxer_api/src/auth/services/SudoVerificationService.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user