Auth completo (Argon2id + JWT/refresh rotativo), permissões, Companies e OpenAPI

- Auth (apps/api/src/auth/): Argon2id com parâmetros versionados
  (ADR-0005), access token JWT de 15min, refresh token opaco de 256 bits
  com rotação e detecção de replay (reuso de token já rotacionado revoga
  toda a cadeia de sessões do usuário). Endpoints /api/v1/auth/
  {login,refresh,logout,me}.
- Permissões resource+action+scope (ADR-0004) via PermissionGuard,
  resolvidas do banco a cada request (nunca cacheadas no JWT) — mudança
  de papel tem efeito imediato. super_admin com bypass hardcoded,
  espelhando a trava já existente no banco.
- Companies (migration + CRUD): leitura aberta a qualquer autenticado,
  escrita restrita a companies:manage; validação de CNPJ via
  packages/domain-shared (dígito verificador).
- SeedService: cria o primeiro super_admin a partir de
  EDEN_SUPERADMIN_EMAIL/PASSWORD só quando `users` está vazia.
- OpenAPI/Swagger em /api/docs; ValidationPipe global com
  whitelist+forbidNonWhitelisted (proteção contra mass-assignment).
- packages/database: repositórios (roles/users/sessions/audit/companies)
  reutilizáveis por api e worker.

Validado manualmente contra a stack real (não só typecheck): login com
senha errada → 401 genérico; refresh rotaciona e invalida o token
anterior; reuso do token antigo → replay detectado, cadeia revogada;
logout idempotente; audit log com a sequência completa de eventos;
CNPJ inválido rejeitado; mass-assignment rejeitado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:25:48 -03:00
parent 44510bd019
commit 8d81cf506b
42 changed files with 1804 additions and 33 deletions

View File

@@ -0,0 +1,47 @@
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { AuthService } from "./auth.service.js";
import { LoginDto } from "./dto/login.dto.js";
import { RefreshDto } from "./dto/refresh.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
function requestContext(req: Request) {
return {
ipAddress: req.ip ?? null,
userAgent: req.headers["user-agent"] ?? null,
};
}
@ApiTags("auth")
@Controller("v1/auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("login")
@HttpCode(200)
async login(@Body() dto: LoginDto, @Req() req: Request) {
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
}
@Post("refresh")
@HttpCode(200)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.refresh(dto.refreshToken, requestContext(req));
}
@Post("logout")
@HttpCode(204)
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
await this.auth.logout(dto.refreshToken, requestContext(req));
}
@Get("me")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: EdenAuthenticatedUser) {
return this.auth.getProfile(user.id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller.js";
import { AuthService } from "./auth.service.js";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { PermissionGuard } from "./permission.guard.js";
@Module({
controllers: [AuthController],
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,181 @@
import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { resolvePermissions } from "./permissions.js";
export interface RequestContext {
ipAddress?: string | null;
userAgent?: string | null;
}
export interface AuthResult {
accessToken: string;
expiresIn: number;
refreshToken: string;
user: PublicUser;
}
export interface PublicUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
}
function toPublicUser(user: User): PublicUser {
return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key };
}
const GENERIC_LOGIN_ERROR = "Credenciais inválidas";
@Injectable()
export class AuthService {
constructor(
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
) {}
async login(
email: string,
password: string,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
): Promise<AuthResult> {
const user = await usersRepo.findUserByEmail(email);
// Same generic error whether the account doesn't exist or the password
// is wrong — anti-enumeration, mirrors eden.md §4.1.
if (!user || !user.password_hash) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
const valid = await this.passwords.verify(user.password_hash, password);
if (!valid) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
if (this.passwords.needsRehash(user.password_algo_version)) {
const { hash, algoVersion } = await this.passwords.hash(password);
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
}
const grantedApps = await usersRepo.getUserApplications(user.id);
if (!grantedApps.includes(applicationKey)) {
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
}
const result = await this.issueTokens(user, applicationKey, ctx);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.login",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey },
ipAddress: ctx.ipAddress,
});
return result;
}
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!active) {
// Replay detection: a token that exists but is revoked/expired being
// reused is a signal the refresh token chain may be compromised —
// revoke every session of that user defensively (ADR-0003).
const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash);
if (anyState?.revoked_at) {
await sessionsRepo.revokeAllUserSessions(anyState.user_id);
await auditRepo.appendAuditEvent({
actorType: "system",
actorId: anyState.user_id,
action: "auth.refresh_replay_detected",
resourceType: "session",
resourceId: anyState.id,
ipAddress: ctx.ipAddress,
});
}
throw new UnauthorizedException("Sessão inválida ou expirada");
}
const user = await usersRepo.findUserById(active.user_id);
if (!user || !user.is_active) {
throw new UnauthorizedException("Sessão inválida ou expirada");
}
await sessionsRepo.touchSession(active.id);
const result = await this.issueTokens(user, active.application_key, ctx, active.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.refresh",
resourceType: "session",
resourceId: active.id,
ipAddress: ctx.ipAddress,
});
return result;
}
async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise<void> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!session) return; // idempotent — already logged out
await sessionsRepo.revokeSession(session.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: session.user_id,
action: "auth.logout",
resourceType: "session",
resourceId: session.id,
ipAddress: ctx.ipAddress,
});
}
async getProfile(userId: string) {
const user = await usersRepo.findUserById(userId);
if (!user || !user.is_active) throw new UnauthorizedException();
const permissions = await resolvePermissions(user.role_key);
const applications = await usersRepo.getUserApplications(user.id);
return {
user: toPublicUser(user),
permissions: permissions.superAdmin
? ("ALL" as const)
: permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })),
applications,
};
}
private async issueTokens(
user: User,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
replacesSessionId?: string,
): Promise<AuthResult> {
const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey });
const refresh = this.tokens.generateRefreshToken();
await sessionsRepo.createSession({
userId: user.id,
applicationKey,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
replacesSessionId,
});
return {
accessToken: access.token,
expiresIn: access.expiresIn,
refreshToken: refresh.plaintext,
user: toPublicUser(user),
};
}
}

View File

@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
return request.edenUser;
});

View File

@@ -0,0 +1,19 @@
import { SetMetadata } from "@nestjs/common";
import type { RolePermission } from "@eden/database";
export const REQUIRE_PERMISSION_KEY = "eden:requirePermission";
export interface RequiredPermission {
resource: string;
action: RolePermission["action"];
}
/**
* Declares that an endpoint needs (resource, action) per ADR-0004.
* super_admin always passes. Confirms the actor holds *some* scope for
* this resource/action — row-level filtering by the granted scope (own
* vs. all, etc.) is the responsibility of the service/query layer, which
* reads it off `request.edenUser.permissions` (see permissions.ts).
*/
export const RequirePermission = (resource: string, action: RequiredPermission["action"]) =>
SetMetadata(REQUIRE_PERMISSION_KEY, { resource, action } satisfies RequiredPermission);

View File

@@ -0,0 +1,13 @@
import { IsEmail, IsIn, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
@IsIn(["core", "reseller", "subscriber"])
applicationKey!: "core" | "reseller" | "subscriber";
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshDto {
@IsString()
@MinLength(32)
refreshToken!: string;
}

View File

@@ -0,0 +1,65 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { usersRepo } from "@eden/database";
import { TokenService } from "./token.service.js";
import { resolvePermissions, type ResolvedPermissions } from "./permissions.js";
export interface EdenAuthenticatedUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
applicationKey: "core" | "reseller" | "subscriber";
permissions: ResolvedPermissions;
}
declare module "express" {
interface Request {
edenUser?: EdenAuthenticatedUser;
}
}
/**
* Verifies the access token's signature/expiry, then re-resolves the user
* and their permissions from the database on every request — deliberately
* not cached in the JWT payload, so a role/permission change (or account
* deactivation) takes effect immediately rather than waiting out the
* token's 15-minute lifetime. Mirrors the legacy's LEFT JOIN-on-every-
* request philosophy (eden.md §1, §3.3).
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedException("Token de acesso ausente");
}
let payload;
try {
payload = this.tokens.verifyAccessToken(header.slice("Bearer ".length));
} catch {
throw new UnauthorizedException("Token de acesso inválido ou expirado");
}
const user = await usersRepo.findUserById(payload.sub);
if (!user || !user.is_active) {
throw new UnauthorizedException("Usuário inativo ou inexistente");
}
const permissions = await resolvePermissions(user.role_key);
request.edenUser = {
id: user.id,
email: user.email,
fullName: user.full_name,
roleKey: user.role_key,
applicationKey: payload.app,
permissions,
};
return true;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import * as argon2 from "argon2";
/**
* Argon2id password hashing with versioned parameters (ADR-0005). Bumping
* CURRENT_ALGO_VERSION and its entry in PARAMS_BY_VERSION lets us raise
* cost over time without invalidating existing hashes — verification
* always uses the parameters embedded in argon2's own encoded hash
* string, and a stored user is only re-hashed with the new parameters on
* their next successful login (see needsRehash()).
*/
const PARAMS_BY_VERSION: Record<number, argon2.Options> = {
1: {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB — OWASP minimum recommendation for argon2id
timeCost: 2,
parallelism: 1,
},
};
export const CURRENT_ALGO_VERSION = 1;
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<{ hash: string; algoVersion: number }> {
const params = PARAMS_BY_VERSION[CURRENT_ALGO_VERSION];
const hash = await argon2.hash(plain, params);
return { hash, algoVersion: CURRENT_ALGO_VERSION };
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
// Malformed/foreign hash — treat as verification failure, never throw
// into the caller (which would otherwise leak a 500 instead of 401).
return false;
}
}
needsRehash(algoVersion: number): boolean {
return algoVersion !== CURRENT_ALGO_VERSION;
}
}

View File

@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { REQUIRE_PERMISSION_KEY, type RequiredPermission } from "./decorators/require-permission.decorator.js";
import { hasPermission } from "./permissions.js";
/**
* Runs after JwtAuthGuard (which populates request.edenUser). Endpoints
* without @RequirePermission are left alone — authentication only.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<RequiredPermission | undefined>(
REQUIRE_PERMISSION_KEY,
context.getHandler(),
);
if (!required) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request.edenUser;
if (!user) {
throw new ForbiddenException("Autenticação necessária");
}
if (!hasPermission(user.permissions, required.resource, required.action)) {
throw new ForbiddenException(
`Sem permissão para '${required.action}' em '${required.resource}'`,
);
}
return true;
}
}

View File

@@ -0,0 +1,55 @@
import { rolesRepo, type RolePermission } from "@eden/database";
export const SUPER_ADMIN_ROLE = "super_admin";
export type ResolvedPermissions =
| { superAdmin: true }
| { superAdmin: false; grants: RolePermission[] };
/**
* super_admin is never looked up in role_permissions — hardcoded bypass,
* mirrored from the legacy system and enforced again at the DB level
* (docs/adr/0004-permission-model.md, chk_role_permissions_no_super_admin).
*/
export async function resolvePermissions(roleKey: string): Promise<ResolvedPermissions> {
if (roleKey === SUPER_ADMIN_ROLE) return { superAdmin: true };
const grants = await rolesRepo.getRolePermissions(roleKey);
return { superAdmin: false, grants };
}
/**
* Returns the maximum scope the actor holds for (resource, action), or
* null if they hold none. Scope precedence — broader wins when a role
* somehow has more than one row for the same resource+action (shouldn't
* happen given the UNIQUE constraint per scope, but a role could have
* both e.g. 'own' and 'all' rows intentionally):
*/
const SCOPE_RANK: Record<RolePermission["scope"], number> = {
own: 1,
team: 2,
reseller: 3,
legal_entity: 4,
all: 5,
};
export function maxGrantedScope(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): RolePermission["scope"] | "all" | null {
if (permissions.superAdmin) return "all";
const matches = permissions.grants.filter((g) => g.resource === resource && g.action === action);
if (matches.length === 0) return null;
return matches.reduce<RolePermission["scope"]>(
(best, g) => (SCOPE_RANK[g.scope] > SCOPE_RANK[best] ? g.scope : best),
matches[0]!.scope,
);
}
export function hasPermission(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): boolean {
return maxGrantedScope(permissions, resource, action) !== null;
}

View File

@@ -0,0 +1,45 @@
import { randomBytes, createHash } from "node:crypto";
import { Inject, Injectable } from "@nestjs/common";
import jwt from "jsonwebtoken";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
export interface AccessTokenPayload {
sub: string; // user id
app: "core" | "reseller" | "subscriber";
}
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — ADR-0003
const REFRESH_TOKEN_TTL_DAYS = 30;
@Injectable()
export class TokenService {
private readonly secret: string;
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
this.secret = env.JWT_SIGNING_SECRET;
}
signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } {
const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, this.secret) as AccessTokenPayload;
}
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
* plaintext (shown once to the client) and its SHA-256 hash (the only
* thing persisted). */
generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } {
const plaintext = randomBytes(32).toString("hex"); // 256 bits
const hash = this.hashRefreshToken(plaintext);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
return { plaintext, hash, expiresAt };
}
hashRefreshToken(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
}