Files
gitmost/apps/server/src/core/auth/auth.controller.ts
T
agent_coder d3209b5aab fix(#355 review E1=B + F1-F8): gate client telemetry OFF by default + throttler/lifecycle/overflow fixes
Maintainer resolved E1 as variant B: the public vitals sink + client collection
must be OFF by default (else client_metrics grows unbounded on a self-host deploy
with no external pruner, via an unauthenticated public endpoint).

- F1: new operator flag CLIENT_TELEMETRY_ENABLED (default OFF), SEPARATE from
  METRICS_PORT (Grafana reads the table directly, independent of the scrape port).
  ClientTelemetryModule.register() provides VitalsController ONLY when the flag is
  true (route absent otherwise); the flag reaches the client via window.CONFIG
  (config.ts isClientTelemetryEnabled), and initVitals() early-returns when off.
- F2/F3 [throttler]: this repo's ThrottlerGuard applies EVERY named throttler to
  every guarded route unless skipped. The new VITALS bucket therefore (a) newly
  bound collab-token → 429 behind shared/NAT IPs, and (b) the vitals route didn't
  skip the stricter public-share-ai (5/min) bucket → effective 5/min not 120.
  Fix (additive, global config unchanged): vitals.controller @SkipThrottle the
  other buckets + @Throttle VITALS 120/min; collab-token adds VITALS_THROTTLER to
  its existing @SkipThrottle (restoring its prior effectively-unthrottled state).
- F4: metrics node:http server is closed on shutdown (MetricsServerLifecycle
  OnModuleDestroy → closeMetricsServer(), fired by enableShutdownHooks).
- F5: docSize outside [0, int4-max] drops to null (keeping the event) instead of
  overflowing int4 and failing the WHOLE batch insert (+ 2 tests).
- F6: .env.example documents METRICS_PORT (no default — unset = subsystem OFF) +
  CLIENT_TELEMETRY_ENABLED; fixed the inaccurate "default 9464" wording.
- F7: disabled/non-sampled sessions install ZERO observers — isVitalsActive()
  (enabled && sampled) gates reportClientMetric AND the page-editor
  measurePageOpen + dispatchTransaction wrapping.
- F8: kept db.d.ts hand-added (wontfix) — this repo HAND-CURATES db.d.ts (verified
  across recent fork migrations a32fba63/8c5b57eb/fdeede00); codegen would be the
  deviation. The ClientMetrics interface maps the migration 1:1.

Gate: server tsc 0, client tsc 0, server metrics/vitals/telemetry/throttle 21
tests, client route-template 5. No new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:00:03 +03:00

251 lines
7.5 KiB
TypeScript

import {
Body,
Controller,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
UseGuards,
Logger,
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import {
AI_CHAT_THROTTLER,
AUTH_THROTTLER,
PAGE_TEMPLATE_THROTTLER,
PUBLIC_SHARE_AI_THROTTLER,
VITALS_THROTTLER,
} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { SessionService } from '../session/session.service';
import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
import { FastifyReply, FastifyRequest } from 'fastify';
import { validateSsoEnforcement } from './auth.util';
import { ModuleRef } from '@nestjs/core';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private authService: AuthService,
private sessionService: SessionService,
private environmentService: EnvironmentService,
private moduleRef: ModuleRef,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@Post('login')
async login(
@AuthWorkspace() workspace: Workspace,
@Res({ passthrough: true }) res: FastifyReply,
@Body() loginInput: LoginDto,
) {
validateSsoEnforcement(workspace);
let MfaModule: any;
let isMfaModuleReady = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
MfaModule = require('./../../ee/mfa/services/mfa.service');
isMfaModuleReady = true;
} catch (err) {
this.logger.debug(
'MFA module requested but EE module not bundled in this build',
);
isMfaModuleReady = false;
}
if (isMfaModuleReady) {
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
strict: false,
});
const mfaResult = await mfaService.checkMfaRequirements(
loginInput,
workspace,
res,
);
if (mfaResult) {
// If user has MFA enabled OR workspace enforces MFA, require MFA verification
if (mfaResult.userHasMfa || mfaResult.requiresMfaSetup) {
return {
userHasMfa: mfaResult.userHasMfa,
requiresMfaSetup: mfaResult.requiresMfaSetup,
isMfaEnforced: mfaResult.isMfaEnforced,
};
} else if (mfaResult.authToken) {
// User doesn't have MFA and workspace doesn't require it
this.setAuthCookie(res, mfaResult.authToken);
return;
}
}
}
const authToken = await this.authService.login(loginInput, workspace.id);
this.setAuthCookie(res, authToken);
}
@UseGuards(SetupGuard)
@HttpCode(HttpStatus.OK)
@Post('setup')
async setupWorkspace(
@Res({ passthrough: true }) res: FastifyReply,
@Body() createAdminUserDto: CreateAdminUserDto,
) {
const { workspace, authToken } =
await this.authService.setup(createAdminUserDto);
this.setAuthCookie(res, authToken);
return workspace;
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
async changePassword(
@Body() dto: ChangePasswordDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
const currentSessionId = (req.raw as any).sessionId;
return this.authService.changePassword(
dto,
user.id,
workspace.id,
currentSessionId,
);
}
@HttpCode(HttpStatus.OK)
@Post('forgot-password')
async forgotPassword(
@Body() forgotPasswordDto: ForgotPasswordDto,
@AuthWorkspace() workspace: Workspace,
) {
validateSsoEnforcement(workspace);
return this.authService.forgotPassword(forgotPasswordDto, workspace);
}
@HttpCode(HttpStatus.OK)
@Post('password-reset')
async passwordReset(
@Res({ passthrough: true }) res: FastifyReply,
@Body() passwordResetDto: PasswordResetDto,
@AuthWorkspace() workspace: Workspace,
) {
const result = await this.authService.passwordReset(
passwordResetDto,
workspace,
);
if (result.requiresLogin) {
return {
requiresLogin: true,
};
}
// Set auth cookie if no MFA is required
this.setAuthCookie(res, result.authToken);
return {
requiresLogin: false,
};
}
@HttpCode(HttpStatus.OK)
@Post('verify-token')
async verifyResetToken(
@Body() verifyUserTokenDto: VerifyUserTokenDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
// The global ThrottlerGuard applies ALL named throttlers to every route by
// default, so each non-AUTH bucket (AI chat, page template, public-share AI,
// client vitals) is explicitly skipped here. collab-token is auth-guarded
// (JwtAuthGuard), per-user and client-cached, so those feature buckets are
// irrelevant to it; skipping them avoids spurious 429s when a user opens many
// pages in a short window. The VITALS bucket must be skipped too: it is a
// process-wide named throttler, so without this skip its per-IP limit would
// silently cap collab-token (the one route that opts out of every other
// bucket) and break editing behind shared/NAT IPs. The AUTH bucket is skipped
// for the same per-user, cached reason.
@SkipThrottle({
[AUTH_THROTTLER]: true,
[AI_CHAT_THROTTLER]: true,
[PAGE_TEMPLATE_THROTTLER]: true,
[PUBLIC_SHARE_AI_THROTTLER]: true,
[VITALS_THROTTLER]: true,
})
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')
async collabToken(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
async logout(
@AuthUser() user: User,
@Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
const sessionId = (req.raw as any).sessionId;
if (sessionId) {
await this.sessionService.revokeSession(
sessionId,
user.id,
user.workspaceId,
);
}
res.clearCookie('authToken');
this.auditService.log({
event: AuditEvent.USER_LOGOUT,
resourceType: AuditResource.USER,
resourceId: user.id,
});
}
setAuthCookie(res: FastifyReply, token: string) {
res.setCookie('authToken', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
expires: this.environmentService.getCookieExpiresIn(),
secure: this.environmentService.isHttps(),
});
}
}