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

426 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](./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:
```prisma
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.
```ts
// 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`.
```ts
// 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()`:
```ts
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`:
```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:
```ts
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:
```ts
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:
```ts
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):
```json
"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:
```ts
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:
```ts
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`:
```ts
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`):
```ts
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:
```ts
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.
### 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 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.