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:
187
packages/auth/src/session.ts
Normal file
187
packages/auth/src/session.ts
Normal 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! });
|
||||
}
|
||||
Reference in New Issue
Block a user