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:
181
apps/api/src/auth/auth.service.ts
Normal file
181
apps/api/src/auth/auth.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user