Files
fluxer/fluxer_api/src/auth/services/AuthLoginService.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

587 lines
18 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import 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,
};
}
}