# O que mudou 1 Schema: failedAttempts + lockedUntil em User; migration auth_v0_2_lockout aplicada; crypto.ts com hashSecret/verifySecret (Node scrypt nativo, zero deps) 2 packages/api/src/auth.ts — authenticateCredential com lockout de 5 tentativas 3 Seed reescrito: admin hashed admin1234, operadores hashed 1111/2222/3333 4 Porta das traseiras fechada: AUTH_DEV_AUTOLOGIN ignorado quando NODE_ENV=production, em ambas as apps 5 operator-pwa: Credentials provider usa PIN + allowedRoles:['OPERATOR']; cookies fieldops-op.* 6 Picker em 2 estados: lista → teclado PIN (botões grandes, dots de progresso, mensagem de erro sem dar pistas) 7 admin-web: Auth.js completo (auth.config, auth.ts, route handler, middleware, /login page, AUTH_SECRET no env) com cookies fieldops-admin.* 8 scripts/auth-smoke.ts (11/11 ✓); .env.example e README atualizados
20 KiB
Plano — Auth real v0.2 (pré-piloto)
Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet. Pré-requisito: MAI CALL v0.1 completo (ver
mai-call-v0.1.md). Estado do código no momento do design verificado contra o repo.
Objetivo numa frase
Fechar a porta das traseiras (AUTH_DEV_AUTOLOGIN) e dar a cada tipo de utilizador o login certo:
admin/supervisor → email + password; operador → escolha na lista + PIN de 4–6 dígitos.
Tudo o resto do produto (MAI CALL) continua a funcionar igual.
Decisões fixadas (não revisitar sem motivo forte)
- Um único segredo por utilizador, guardado em
User.passwordHash(já existe, nullable). Password (admin) e PIN (operador) são ambos "um segredo que o utilizador prova" — mesmo campo, mesma função de hash. Oroledecide qual UI/validação se aplica. - Hashing com
crypto.scryptnativo do Node 22 — zero dependências, sem build nativo (evita atrito no Windows + pnpm 11 que bloqueia postinstall). NÃO instalar bcrypt/argon nativos. AUTH_DEV_AUTOLOGINnão desaparece — passa a ser ignorado quandoNODE_ENV === 'production'. Em dev/test continua a funcionar (conveniência local + E2E). A porta das traseiras fica fechada no código, não só por honestidade do.env.- Operador entra por lista + PIN (não password). O picker que já existe ganha um segundo passo (teclado de PIN). Isto mantém o caminho para o RFID limpo: trocar o teclado por um leitor de cartão, sem mexer no resto.
- Admin-web ganha Auth.js a sério (hoje não tem nenhum). Mesmo padrão da operator-pwa: Credentials provider + JWT + middleware + página de login.
- Lockout mínimo (5 tentativas falhadas → bloqueio de 5 min) porque um PIN numérico exposto na rede sem qualquer travão a força-bruta é irresponsável para um piloto. É a única adição de schema.
- Continua single-tenant na prática. O login procura o utilizador por email com
findFirst(o email só é único por tenant,@@unique([tenantId, email])). Para o piloto (uma fábrica = um tenant) é suficiente. Multi-tenant login (selector de tenant ou email globalmente único) fica para quando entrar o 2º cliente.
1. Modelo de dados
Sem modelos novos. Só dois campos em User, para o lockout:
model User {
// ... campos existentes (id, tenantId, email, passwordHash, role, createdAt, relations) ...
failedAttempts Int @default(0)
lockedUntil DateTime?
}
passwordHash já existe e é String? — passa a ser efetivamente usado. Não mudar o tipo nem a nulidade (um utilizador sem segredo definido simplesmente não consegue entrar — verifySecret devolve false).
tenant-extension.ts não precisa de alteração (User já está em TENANT_SCOPED_MODELS), mas atenção: o login corre antes de haver tenant, por isso usa o prisma não-scoped (ver §3).
Migration: após editar o schema, correr pnpm db:migrate (nome sugerido: auth_v0_2_lockout).
2. Função de hashing — @repo/db
Cria-se packages/db/src/crypto.ts e exporta-se de packages/db/src/index.ts.
Fica em @repo/db (e não em @repo/api) de propósito: o seed.ts vive em packages/db e precisa de hashSecret; como api depende de db (e não o contrário), só db pode ser importado por todos sem ciclo.
// packages/db/src/crypto.ts
import { randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(_scrypt);
const KEYLEN = 64;
/** Devolve "scrypt$<saltHex>$<hashHex>". Serve para password (admin) e PIN (operador). */
export async function hashSecret(plain: string): Promise<string> {
const salt = randomBytes(16);
const derived = (await scrypt(plain, salt, KEYLEN)) as Buffer;
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`;
}
/** Verificação em tempo constante. `stored` null/malformado → false (nunca lança). */
export async function verifySecret(plain: string, stored: string | null): Promise<boolean> {
if (!stored) return false;
const [scheme, saltHex, hashHex] = stored.split('$');
if (scheme !== 'scrypt' || !saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const derived = (await scrypt(plain, salt, expected.length)) as Buffer;
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
Export em packages/db/src/index.ts: export { hashSecret, verifySecret } from './crypto';
3. Verificação de credenciais — @repo/api
Lógica única partilhada pelas duas apps (lookup + verify + lockout). Vai em @repo/api porque toca Prisma + regra de negócio, e ambas as apps já dependem de @repo/api.
// packages/api/src/auth.ts
import { prisma, verifySecret } from '@repo/db';
import type { SessionUser } from './context';
const MAX_ATTEMPTS = 5;
const LOCK_MS = 5 * 60_000;
/**
* Autentica por email + segredo (PIN ou password), restringindo a roles permitidos.
* Usa o prisma NÃO-scoped: o login acontece antes de existir tenant.
* Devolve o SessionUser em sucesso, null em qualquer falha (credenciais, role, lockout).
*/
export async function authenticateCredential(opts: {
email: string;
secret: string;
allowedRoles: SessionUser['role'][];
}): Promise<SessionUser | null> {
const user = await prisma.user.findFirst({ where: { email: opts.email } });
if (!user) return null;
if (!opts.allowedRoles.includes(user.role)) return null;
if (user.lockedUntil && user.lockedUntil > new Date()) return null;
const ok = await verifySecret(opts.secret, user.passwordHash);
if (!ok) {
const attempts = user.failedAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: attempts,
lockedUntil: attempts >= MAX_ATTEMPTS ? new Date(Date.now() + LOCK_MS) : null,
},
});
return null;
}
if (user.failedAttempts !== 0 || user.lockedUntil) {
await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockedUntil: null },
});
}
return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId };
}
Export em packages/api/src/index.ts: export { authenticateCredential } from './auth';
(SessionUser já é exportado de @repo/api — confirmado em context.ts.)
4. Fechar a porta das traseiras (NODE_ENV gating)
A regra única: autologin só é honrado se AUTH_DEV_AUTOLOGIN E NODE_ENV !== 'production'.
operator-pwa — apps/operator-pwa/lib/auth.ts, em resolveUser():
const autologinAllowed = env.AUTH_DEV_AUTOLOGIN && process.env.NODE_ENV !== 'production';
if (autologinAllowed) {
// ... fallback admin@demo.local (igual ao atual)
}
operator-pwa — apps/operator-pwa/middleware.ts:
const isAutologin =
process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production';
O redirect passa a apontar para /select-operator na operator-pwa (já aponta) — sem mudança de destino.
admin-web — passa a ter resolveUser() real (ver §6), com o mesmo gating no fallback.
Porque é que isto não parte o E2E: o Playwright corre
next dev(logoNODE_ENV === 'development') e injectaAUTH_DEV_AUTOLOGIN=truenos webServers. O gating só desliga o autologin emproduction. O happy-path continua a entrar por autologin sem mexer no teste.
5. operator-pwa — login do operador (lista + PIN)
5a. Credentials provider (apps/operator-pwa/lib/auth.ts)
Substituir o provider placeholder (que aceitava email sem password) por:
Credentials({
name: 'Operador (PIN)',
credentials: { email: { type: 'text' }, pin: { type: 'password' } },
async authorize(credentials) {
const email = credentials?.email;
const pin = credentials?.pin;
if (typeof email !== 'string' || typeof pin !== 'string') return null;
const u = await authenticateCredential({ email, secret: pin, allowedRoles: ['OPERATOR'] });
if (!u) return null;
return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId };
},
}),
allowedRoles: ['OPERATOR'] garante que admins não entram pela app do operador.
5b. Cookie name distinto (evita colisão com admin-web)
Em apps/operator-pwa/lib/auth.config.ts, dentro do objeto authConfig, acrescentar:
cookies: {
sessionToken: { name: 'fieldops-op.session-token' },
callbackUrl: { name: 'fieldops-op.callback-url' },
csrfToken: { name: 'fieldops-op.csrf-token' },
},
Porquê: cookies não são isolados por porto. Sem nomes distintos, abrir admin-web (:3001) e operator-pwa (:3000) na mesma máquina sobrepõe a sessão. Cada app tem de ter o seu prefixo.
5c. Ecrã de PIN
/select-operator mantém-se como entrada, mas o OperatorPicker ganha dois estados:
- Lista (como hoje): mostra operadores; tap selecciona em vez de fazer
signInimediato. - Teclado de PIN: depois de escolher, mostra o nome do operador + um teclado numérico (botões 0–9, apagar) + indicador de dígitos. Ao atingir o comprimento (aceitar 4 a 6 dígitos; submeter no botão "Entrar") chama:
const result = await signIn('credentials', { email, pin, redirect: false });- sucesso (
!result?.error):router.push('/'); router.refresh(); - erro: limpar dígitos e mostrar "PIN incorreto ou conta bloqueada. Tente novamente." (não distinguir os dois casos — não dar pistas a quem ataca).
- botão "Voltar" regressa à lista.
- sucesso (
Mobile-first, botões grandes (target dedo com luva). Reaproveitar as classes Tailwind já usadas no picker atual.
apps/operator-pwa/app/select-operator/page.tsx não muda a query (continua a listar operadores via prisma direto). Só passa a precisar do id+email (já passa).
sign-out-button.tsx (botão "Trocar") não muda — signOut({ callbackUrl: '/select-operator' }) continua correto.
6. admin-web — login do admin (email + password)
A admin-web não tem Auth.js. Adiciona-se o stack completo, espelhando a operator-pwa.
6a. Dependência
Em apps/admin-web/package.json, acrescentar a dependencies (mesma versão exata da operator-pwa, para não haver drift):
"next-auth": "5.0.0-beta.25"
Correr pnpm install.
6b. Ficheiros novos
-
apps/admin-web/lib/auth.config.ts— cópia do edge-safe config da operator-pwa, com:pages: { signIn: '/login' }- os mesmos callbacks
jwt/session(copiar tal e qual). cookiescom prefixofieldops-admin.(sessionToken / callbackUrl / csrfToken) — ver §5b.
-
apps/admin-web/lib/auth.ts— substitui oresolveUserplaceholder atual. Estrutura igual à operator-pwa:export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, secret: env.AUTH_SECRET, providers: [ Credentials({ name: 'Email + password', credentials: { email: { type: 'email' }, password: { type: 'password' } }, async authorize(c) { if (typeof c?.email !== 'string' || typeof c?.password !== 'string') return null; const u = await authenticateCredential({ email: c.email, secret: c.password, allowedRoles: ['ADMIN', 'SUPERVISOR'], }); if (!u) return null; return { id: u.id, email: u.email, name: u.email, role: u.role, tenantId: u.tenantId }; }, }), ], }); export async function resolveUser(): Promise<SessionUser | null> { const session = await auth(); const u = session?.user as any; if (u?.id && u?.tenantId) return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId }; const autologinAllowed = process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; if (autologinAllowed) { const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); if (admin) return { id: admin.id, email: admin.email, role: admin.role as any, tenantId: admin.tenantId }; } return null; } -
apps/admin-web/app/api/auth/[...nextauth]/route.ts— idêntico ao da operator-pwa:import { handlers } from '@/lib/auth'; export const { GET, POST } = handlers; export const runtime = 'nodejs'; -
apps/admin-web/middleware.ts— espelha o da operator-pwa, mas redireciona para/login:import NextAuth from 'next-auth'; import { authConfig } from './lib/auth.config'; const { auth } = NextAuth(authConfig); export default auth((req) => { const isLoggedIn = !!req.auth?.user; const isAutologin = process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; const { pathname } = req.nextUrl; if (pathname === '/login') { if (isLoggedIn) return Response.redirect(new URL('/maintenance', req.url)); return; } if (!isLoggedIn && !isAutologin) return Response.redirect(new URL('/login', req.url)); }); export const config = { matcher: ['/((?!api/auth|api/trpc|_next/static|_next/image|favicon.ico).*)'], }; -
apps/admin-web/app/login/page.tsx+login-form.tsx(client component):- form com email + password, botão "Entrar".
const result = await signIn('credentials', { email, password, redirect: false });- sucesso →
router.push('/maintenance'); erro → "Email ou password incorretos. Tente novamente." - Estilo: reaproveitar componentes
@repo/ui(Card/Button) já usados na queue.
signIn/signOutdenext-auth/reactfuncionam semSessionProvider, por issoapps/admin-web/app/providers.tsxnão precisa de mudar.
6c. env da admin-web
apps/admin-web/env.ts — acrescentar ao bloco server (hoje só tem DATABASE_URL, AUTH_DEV_AUTOLOGIN, LOG_LEVEL):
AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'),
e no runtimeEnv: AUTH_SECRET: process.env.AUTH_SECRET,
Não adicionar AUTH_URL: o .env partilhado tem AUTH_URL=http://localhost:3000 (da operator-pwa) e isso seria errado para a admin. Com trustHost: true (já no authConfig), o Auth.js infere o host a partir do request — não precisa de AUTH_URL em dev.
7. Seed — definir PINs e password demo
packages/db/prisma/seed.ts:
- importar
hashSecretde@repo/db(ou diretamente de./crypto— mas o seed já importa@prisma/clientdinamicamente; usar import estático de../src/crypto.js/@repo/dbconforme o que o build resolver; preferir@repo/db). - admin:
passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD). - cada operador:
passwordHash: await hashSecret(pin)com PINs distintos. ComocreateManynão permite valores assíncronos por linha de forma limpa, trocar ocreateManydos operadores por um loopfor ... create. - constantes no topo:
const DEMO_ADMIN_PASSWORD = 'admin1234'; const OPERATORS = [ { email: 'op1@demo.local', pin: '1111' }, { email: 'op2@demo.local', pin: '2222' }, { email: 'op3@demo.local', pin: '3333' }, ]; - imprimir as credenciais demo no final (
console.warn) para Pedro saber o que digitar:Seed: admin=admin@demo.local / admin1234 | operadores: op1=1111 op2=2222 op3=3333
failedAttempts/lockedUntil ficam nos defaults (0/null), não precisam de set no seed.
8. .env.example + README
.env.example: na nota doAUTH_DEV_AUTOLOGIN, acrescentar que mesmo atrueé ignorado em produção (NODE_ENV=production). Manter default"false".- README — secção "Known limitations": remover/atualizar a linha "No real authentication" (agora há), e a linha "Operator picker, not TAG/card" mantém-se mas passa a "picker + PIN". Acrescentar as credenciais demo ao "Demo flow" (admin:
admin@demo.local/admin1234; operadores: PINs). Atualizar o aviso da porta das traseiras para refletir o gating porNODE_ENV.
9. Verificação — smoke script (segue o padrão do repo)
Criar scripts/auth-smoke.ts (como os outros scripts/*-smoke.ts):
- corre contra a BD seeded.
authenticateCredential({ email:'op1@demo.local', secret:'1111', allowedRoles:['OPERATOR'] })→ devolve user. ✓- PIN errado 5× → 6ª chamada com PIN certo devolve
null(lockout ativo). ✓ - role errado:
authenticateCredential({ email:'admin@demo.local', secret:'admin1234', allowedRoles:['OPERATOR'] })→null. ✓ - admin com password certa e
allowedRoles:['ADMIN','SUPERVISOR']→ user. ✓ - no fim, repor
failedAttempts=0, lockedUntil=nulldo op1 para não deixar a BD num estado bloqueado (ou re-seed).
Adicionar ao README a linha pnpm tsx scripts/auth-smoke.ts.
O E2E mai-call.spec.ts não muda (continua via autologin em dev, §4).
10. Cortes propositados — o que NÃO entra na v0.2
| Cortado | Porquê | Quando volta |
|---|---|---|
| RFID/cartão do operador | PIN é a ponte; arquitetura já preparada | quando MY QUALITY entrar |
| Reset de password / "esqueci o PIN" self-service | Sem email/SMS infra; admin re-seed ou futura UI | v0.3 |
| UI de gestão de utilizadores (criar/editar/role) | Cria-se via seed/SQL no piloto | com onboarding multi-tenant |
| Selector de tenant no login | Single-tenant no piloto; email findFirst chega |
2º cliente |
| SSO / MFA | Não pedido pelo piloto | se cliente exigir |
| Rate-limit por IP (além do lockout por conta) | Lockout por conta cobre o PIN; IP precisa de infra | se necessário |
Rotação de AUTH_SECRET / política de sessão curta |
Default JWT chega para piloto | endurecimento pós-piloto |
| Expiração/rotação de PIN | Sem valor demonstrado no piloto | quando pedirem |
11. Plano de implementação (passos pequenos, ordenados)
Cada passo é mergeable e tem critério de aceitação testável.
Passo 1 — Schema + crypto util
Faz: §1 (campos failedAttempts/lockedUntil + migration) e §2 (packages/db/src/crypto.ts + export).
AC: pnpm db:migrate corre sem erro; um script ad-hoc consegue hashSecret('1234') e verifySecret('1234', hash) === true, verifySecret('9999', hash) === false, verifySecret('x', null) === false.
Passo 2 — authenticateCredential em @repo/api
Faz: §3 (packages/api/src/auth.ts + export).
AC: importável de @repo/api; pnpm typecheck verde. (Comportamento real testado no Passo 7.)
Passo 3 — Seed com PINs/password
Faz: §7.
AC: pnpm db:seed corre e imprime as credenciais; em Prisma Studio, passwordHash dos 4 utilizadores está preenchido (formato scrypt$...).
Passo 4 — Fechar a porta das traseiras
Faz: §4 (gating NODE_ENV na operator-pwa: resolveUser + middleware).
AC: com AUTH_DEV_AUTOLOGIN=true e next dev, a operator-pwa continua a entrar (dev). Simular produção (NODE_ENV=production num build) → abrir / redireciona para /select-operator em vez de auto-entrar.
Passo 5 — operator-pwa: provider PIN + cookie name
Faz: §5a + §5b (authorize com PIN + allowedRoles:['OPERATOR']; cookies fieldops-op.*).
AC: signIn('credentials',{email:'op1@demo.local',pin:'1111'}) cria sessão; pin:'0000' falha. Cookie no browser chama-se fieldops-op.session-token.
Passo 6 — operator-pwa: ecrã de PIN
Faz: §5c (picker em 2 estados: lista → teclado PIN).
AC: Demo: limpar cookies → / redireciona ao picker → escolher op1 → teclar 1111 → "Entrar" → home mostra op1@demo.local. PIN errado mostra a mensagem e limpa os dígitos.
Passo 7 — admin-web: Auth.js completo + login
Faz: §6 inteiro (dep, auth.config, auth.ts, route handler, middleware, /login, env AUTH_SECRET, cookies fieldops-admin.*).
AC: com autologin off (ou prod), abrir :3001 redireciona a /login; entrar com admin@demo.local/admin1234 chega à fila /maintenance; password errada falha; um OPERATOR não consegue entrar na admin-web (role bloqueado).
Passo 8 — Smoke + docs
Faz: §9 (scripts/auth-smoke.ts) + §8 (.env.example + README).
AC: pnpm tsx scripts/auth-smoke.ts passa todos os casos (incl. lockout às 5 tentativas); pnpm test:e2e continua verde; outro dev segue o README e consegue fazer login real nas duas apps.
Sequência crítica: Passos 1→3 são fundação (schema, crypto, credenciais). Passo 4 fecha o buraco de segurança e pode ir cedo. Passos 5–6 (operador) e 7 (admin) são independentes — podem ir em paralelo se houver 2 devs. Passo 8 fecha.
Risco principal: a colisão de cookies entre :3000 e :3001 (§5b/§6b) — se os nomes de cookie não forem distintos, fazer login numa app desloga a outra de forma confusa. Verificar explicitamente os nomes dos cookies no DevTools no Passo 5 e 7.