0ddeaadeee
api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч
COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация
не доставала websocket-редактирование. Блокировать /collab-token для api_key
нельзя (это правки агентов). Решение fail-closed:
- JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId.
generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда
чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор
ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний
session-backed AI-агент остаётся 'session' и на no-check пути.
- /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId
(не тело) и прокидывает origin через getCollabToken → generateCollabToken.
- doAuthenticate: при principal='api_key' на connect прогоняет
ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра
пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется
только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его
начеканить не мог); после окна всякий валидный токен обязан нести
дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч).
CollaborationModule импортирует ApiKeyModule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
263 lines
8.2 KiB
TypeScript
263 lines
8.2 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,
|
|
@Req() req: FastifyRequest,
|
|
) {
|
|
// Thread the api-key origin (#501): when the requester authenticated with an
|
|
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
|
|
// token carries principal='api_key' + apiKeyId so a later revoke of the key
|
|
// rejects NEW collab connections. A normal session request mints a
|
|
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
|
|
// a client body) keeps it unspoofable.
|
|
const raw = req.raw as { authType?: string; apiKeyId?: string };
|
|
const apiKey =
|
|
raw.authType === 'api_key' && raw.apiKeyId
|
|
? { apiKeyId: raw.apiKeyId }
|
|
: undefined;
|
|
return this.authService.getCollabToken(user, workspace.id, apiKey);
|
|
}
|
|
|
|
@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(),
|
|
});
|
|
}
|
|
}
|