feat: implement authentication and RBAC

- packages/auth: Argon2id password hashing, JWT access tokens (jose),
  opaque refresh tokens with rotation, generic error messages (no
  user-enumeration via timing or message differences)
- roles/permissions/role_permissions/user_roles/sessions/audit_logs schema
  (agente.md secoes 142-150); RBAC scope PLATFORM vs TENANT
- withUserContext(): narrow RLS exception so a user can discover their own
  tenant_memberships before a tenant is chosen (login flow)
- userHasPermission()/isPlatformUser(): explicit service-layer RBAC checks
  (roles/permissions tables are not RLS-protected — documented why in
  docs/AUTHENTICATION.md)
- seed: permission catalog, 4 system roles, initial Platform Super Admin
  (password written once to FIRST_LOGIN.txt, 600, outside Git)
- automated end-to-end test: login, RBAC check, refresh rotation, logout
This commit is contained in:
2026-08-28 05:58:49 -03:00
parent d66170c795
commit 70c5586595
16 changed files with 1172 additions and 10 deletions

View File

@@ -0,0 +1,187 @@
import { getPrismaClient, withUserContext } from "@b2bcall/database";
import { recordAuditEvent } from "./audit";
import { verifyPassword } from "./password";
import {
REFRESH_TOKEN_TTL_MS,
generateRefreshToken,
hashRefreshToken,
signAccessToken,
} from "./tokens";
// Hash Argon2id de uma senha aleatória fixa, usado só para manter o tempo de
// resposta do login constante quando o e-mail não existe — evita
// user-enumeration via timing attack. Nunca corresponde a uma senha real.
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=19456,t=2,p=1$ue+qGHKnsPX6ducjF4h61Q$wRqupd7dHkad21UhvTxLuv8bDqNr63dZmuydRkjohr0";
export class InvalidCredentialsError extends Error {
constructor() {
super("E-mail ou senha invalidos");
}
}
export class InvalidRefreshTokenError extends Error {
constructor() {
super("Refresh token invalido, expirado ou revogado");
}
}
export class NotATenantMemberError extends Error {
constructor() {
super("Usuario nao pertence a este tenant");
}
}
export interface LoginParams {
email: string;
password: string;
ipAddress?: string;
userAgent?: string;
}
export interface AuthResult {
accessToken: string;
refreshToken: string;
sessionId: string;
mustChangePassword: boolean;
}
/**
* Login por e-mail/senha (agente.md secao 148). Sempre roda verify() de
* Argon2id mesmo quando o e-mail não existe (contra timing attack), e nunca
* revela via mensagem se foi o e-mail ou a senha que estava errada.
*/
export async function login(params: LoginParams): Promise<AuthResult> {
const prisma = getPrismaClient();
const email = params.email.trim().toLowerCase();
const user = await prisma.user.findUnique({ where: { email } });
const passwordOk = await verifyPassword(user?.passwordHash ?? DUMMY_PASSWORD_HASH, params.password);
const isUsable = Boolean(user) && !user!.deletedAt && user!.status === "ACTIVE";
if (!user || !passwordOk || !isUsable) {
await recordAuditEvent(prisma, {
action: "LOGIN_FAILED",
userId: user?.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
after: { email },
});
throw new InvalidCredentialsError();
}
const refreshToken = generateRefreshToken();
const session = await prisma.session.create({
data: {
userId: user.id,
refreshTokenHash: hashRefreshToken(refreshToken),
userAgent: params.userAgent,
ipAddress: params.ipAddress,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({ sub: user.id, sessionId: session.id });
await recordAuditEvent(prisma, {
action: "LOGIN",
userId: user.id,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
});
return {
accessToken,
refreshToken,
sessionId: session.id,
mustChangePassword: user.mustChangePassword,
};
}
/**
* Rotaciona o refresh token (agente.md secao 148: "refresh rotation"). O
* token antigo para de funcionar assim que um novo é emitido — a linha de
* `sessions` é a mesma, só o hash e a expiração mudam.
*/
export async function refreshSession(
refreshToken: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const prisma = getPrismaClient();
const session = await prisma.session.findUnique({
where: { refreshTokenHash: hashRefreshToken(refreshToken) },
});
if (!session || session.revokedAt || session.expiresAt.getTime() < Date.now()) {
throw new InvalidRefreshTokenError();
}
const newRefreshToken = generateRefreshToken();
await prisma.session.update({
where: { id: session.id },
data: {
refreshTokenHash: hashRefreshToken(newRefreshToken),
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
const accessToken = await signAccessToken({
sub: session.userId,
sessionId: session.id,
tenantId: session.activeTenantId ?? undefined,
});
return { accessToken, refreshToken: newRefreshToken };
}
/** Revoga a sessão (logout / revogação administrativa). Idempotente. */
export async function logout(sessionId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Lista os tenants aos quais o usuário pertence — precisa do contexto
* especial de RLS "olhar as próprias memberships" (withUserContext), já que
* ainda não existe um tenant escolhido nesse ponto do fluxo (ver
* docs/AUTHENTICATION.md e docs/TENANT_ISOLATION.md).
*/
export async function listUserTenants(userId: string) {
const prisma = getPrismaClient();
return withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findMany({
where: { userId },
include: { tenant: true },
}),
);
}
/**
* Troca o tenant ativo de uma sessão já autenticada, validando a membership
* antes de emitir um novo access token com `tenantId` nas claims.
*/
export async function setActiveTenant(
sessionId: string,
userId: string,
tenantId: string,
): Promise<string> {
const prisma = getPrismaClient();
const membership = await withUserContext(prisma, userId, (tx) =>
tx.tenantMembership.findUnique({
where: { tenantId_userId: { tenantId, userId } },
}),
);
if (!membership) {
throw new NotATenantMemberError();
}
const session = await prisma.session.update({
where: { id: sessionId },
data: { activeTenantId: tenantId },
});
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
}