FieldOps/docs/plans/auth-v0.2.md

21 KiB
Raw Blame History

Plano — Auth real v0.2 (pré-piloto)

ESTADO: IMPLEMENTADO (2026-05-30). Os 8 passos foram concluídos e verificados (typecheck limpo, scripts/auth-smoke.ts 11/11, E2E do MAI CALL verde). Endurecimentos pré-piloto ainda diferidos (não-bugs): enumeração por timing, rate-limit por IP, prefixo __Secure- dos cookies, PINs/segredo de demo, HTTPS em produção. Ver §10 e a memory project-phase.

Autor: Opus 4.8 (sessão de design, 2026-05-30). Implementado 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 46 dígitos. Tudo o resto do produto (MAI CALL) continua a funcionar igual.

Decisões fixadas (não revisitar sem motivo forte)

  1. 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. O role decide qual UI/validação se aplica.
  2. Hashing com crypto.scrypt nativo do Node 22 — zero dependências, sem build nativo (evita atrito no Windows + pnpm 11 que bloqueia postinstall). NÃO instalar bcrypt/argon nativos.
  3. AUTH_DEV_AUTOLOGIN não desaparece — passa a ser ignorado quando NODE_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.
  4. 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.
  5. 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.
  6. 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.
  7. 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-pwaapps/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-pwaapps/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 (logo NODE_ENV === 'development') e injecta AUTH_DEV_AUTOLOGIN=true nos webServers. O gating só desliga o autologin em production. 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.

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:

  1. Lista (como hoje): mostra operadores; tap selecciona em vez de fazer signIn imediato.
  2. Teclado de PIN: depois de escolher, mostra o nome do operador + um teclado numérico (botões 09, 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.

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).
    • cookies com prefixo fieldops-admin. (sessionToken / callbackUrl / csrfToken) — ver §5b.
  • apps/admin-web/lib/auth.ts — substitui o resolveUser placeholder 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/signOut de next-auth/react funcionam sem SessionProvider, por isso apps/admin-web/app/providers.tsx nã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 hashSecret de @repo/db (ou diretamente de ./crypto — mas o seed já importa @prisma/client dinamicamente; usar import estático de ../src/crypto.js/@repo/db conforme o que o build resolver; preferir @repo/db).
  • admin: passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD).
  • cada operador: passwordHash: await hashSecret(pin) com PINs distintos. Como createMany não permite valores assíncronos por linha de forma limpa, trocar o createMany dos operadores por um loop for ... 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 do AUTH_DEV_AUTOLOGIN, acrescentar que mesmo a true é 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 por NODE_ENV.

9. Verificação — smoke script (segue o padrão do repo)

Criar scripts/auth-smoke.ts (como os outros scripts/*-smoke.ts):

  1. corre contra a BD seeded.
  2. authenticateCredential({ email:'op1@demo.local', secret:'1111', allowedRoles:['OPERATOR'] }) → devolve user. ✓
  3. PIN errado 5× → 6ª chamada com PIN certo devolve null (lockout ativo). ✓
  4. role errado: authenticateCredential({ email:'admin@demo.local', secret:'admin1234', allowedRoles:['OPERATOR'] })null. ✓
  5. admin com password certa e allowedRoles:['ADMIN','SUPERVISOR'] → user. ✓
  6. no fim, repor failedAttempts=0, lockedUntil=null do 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.

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 56 (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.