diff --git a/.env.example b/.env.example index e16f28b..55ed796 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ AUTH_SECRET="dev-secret-do-not-use-in-production-please-change-me" # no session. This skips the login UI in local development and CI/E2E. # # !!! NEVER set this to "true" in production. !!! +# Even if set to "true", this flag is IGNORED when NODE_ENV=production. # The default of "false" here is intentional — a developer setting up locally # must consciously opt in by editing their .env. See README "Auth" section. AUTH_DEV_AUTOLOGIN="false" diff --git a/README.md b/README.md index e4b9873..b159b84 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ pnpm --filter @repo/admin-web dev 1. Open http://localhost:3000. With `AUTH_DEV_AUTOLOGIN=true` you land on the home page as - `admin@demo.local`. To simulate a real operator, navigate to - http://localhost:3000/select-operator and tap **op1@demo.local**. + `admin@demo.local`. To simulate a real operator log-in, navigate to + http://localhost:3000/select-operator, tap **op1@demo.local**, then + enter PIN **1111** on the keypad. + (op2 = **2222**, op3 = **3333**) 2. Tap **Pedir manutenção**. 3. Select a workstation, optionally attach a photo, write a description, and tap **Enviar pedido**. @@ -92,7 +94,10 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears. ### As admin / maintenance supervisor (port 3001) -1. Open http://localhost:3001 — it lands on the maintenance queue. +1. Open http://localhost:3001. + With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue + automatically. Without it, you see a login form — use + **admin@demo.local** / **admin1234**. 2. The queue refreshes every 5 s; new requests appear automatically. 3. Click **Aceitar** to claim a request (status: Em curso). 4. Click **Marcar resolvido**, optionally add a note, click **Confirmar** @@ -144,8 +149,8 @@ Expected: **1 passed** in ~30 s. | Limitation | Detail | Target | | --- | --- | --- | -| **No real authentication** | `AUTH_DEV_AUTOLOGIN=true` lets anyone in as admin. The Credentials provider accepts any seeded email without a password. | v0.2 pre-pilot | -| **Operator picker, not TAG/card** | Operator identity is chosen from a list rather than read from an RFID badge. | MY QUALITY module | +| **No real authentication in dev** | `AUTH_DEV_AUTOLOGIN=true` lets anyone in as admin (dev/test only — ignored when `NODE_ENV=production`). Real auth is implemented: admin uses email + password, operators use list + PIN. | — | +| **Operator picker + PIN, not TAG/card** | Operator identity is chosen from a list and confirmed with a PIN, rather than read from an RFID badge. | MY QUALITY module | | **No multi-tenant onboarding UI** | Tenants are created via `pnpm db:seed` / SQL only. | when 2nd customer onboards | | **No SLAs, alerts, or timers** | `DomainEvent` rows are written and ready; reporting is not built yet. | v0.2 | | **Single photo per request** | No video, audio, or multiple photos. | when pilot asks | @@ -156,9 +161,10 @@ Expected: **1 passed** in ~30 s. | **No observability** | Structured logs via Pino; no trace/metric pipeline. | when pilot requires it | > ⚠️ **NEVER deploy with `AUTH_DEV_AUTOLOGIN=true`.** That flag is a -> back door. The chokepoint is `apps/operator-pwa/lib/auth.ts → -> resolveUser()` (and the equivalent in `apps/admin-web/lib/auth.ts`). -> Replace with real authentication before any non-dev deployment. +> back door for local dev and CI only. It is **ignored at the code level** +> when `NODE_ENV=production`, so a misconfigured `.env` in production won't +> open a hole. The chokepoints are `apps/*/lib/auth.ts → resolveUser()` and +> `apps/*/middleware.ts`. --- @@ -181,6 +187,7 @@ Expected: **1 passed** in ~30 s. | `pnpm format` | Prettier write across the workspace | | `pnpm tsx scripts/storage-smoke.ts` | Verify MinIO presigned upload/download | | `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle | +| `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout | --- diff --git a/apps/admin-web/app/api/auth/[...nextauth]/route.ts b/apps/admin-web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8f4b86d --- /dev/null +++ b/apps/admin-web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,4 @@ +import { handlers } from '@/lib/auth'; + +export const { GET, POST } = handlers; +export const runtime = 'nodejs'; diff --git a/apps/admin-web/app/login/login-form.tsx b/apps/admin-web/app/login/login-form.tsx new file mode 100644 index 0000000..c1ff07a --- /dev/null +++ b/apps/admin-web/app/login/login-form.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { signIn } from 'next-auth/react'; + +export function LoginForm() { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + const email = (form.elements.namedItem('email') as HTMLInputElement).value; + const password = (form.elements.namedItem('password') as HTMLInputElement).value; + + setBusy(true); + setError(null); + try { + const result = await signIn('credentials', { email, password, redirect: false }); + if (result?.error) { + setError('Email ou password incorretos. Tente novamente.'); + } else { + router.push('/maintenance'); + router.refresh(); + } + } catch { + setError('Erro inesperado. Tente novamente.'); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + +
+ ); +} diff --git a/apps/admin-web/app/login/page.tsx b/apps/admin-web/app/login/page.tsx new file mode 100644 index 0000000..96293ac --- /dev/null +++ b/apps/admin-web/app/login/page.tsx @@ -0,0 +1,15 @@ +import { LoginForm } from './login-form'; + +export default function LoginPage() { + return ( +
+
+

FieldOps

+

+ Acesso à consola de manutenção +

+
+ +
+ ); +} diff --git a/apps/admin-web/env.ts b/apps/admin-web/env.ts index f1043da..31a529a 100644 --- a/apps/admin-web/env.ts +++ b/apps/admin-web/env.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), + AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'), AUTH_DEV_AUTOLOGIN: z .string() .optional() @@ -17,6 +18,7 @@ export const env = createEnv({ }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, + AUTH_SECRET: process.env.AUTH_SECRET, AUTH_DEV_AUTOLOGIN: process.env.AUTH_DEV_AUTOLOGIN, LOG_LEVEL: process.env.LOG_LEVEL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, diff --git a/apps/admin-web/lib/auth.config.ts b/apps/admin-web/lib/auth.config.ts new file mode 100644 index 0000000..ba07d67 --- /dev/null +++ b/apps/admin-web/lib/auth.config.ts @@ -0,0 +1,44 @@ +import type { NextAuthConfig } from 'next-auth'; + +/** + * Edge-safe portion of the Auth.js config for admin-web. + * Imported by middleware — no Credentials provider, no Prisma. + */ +export const authConfig = { + trustHost: true, + session: { strategy: 'jwt' }, + pages: { + signIn: '/login', + }, + // Distinct cookie names prevent session collision with operator-pwa on localhost + // (cookies are not isolated by port — only by name and domain). + cookies: { + sessionToken: { name: 'fieldops-admin.session-token' }, + callbackUrl: { name: 'fieldops-admin.callback-url' }, + csrfToken: { name: 'fieldops-admin.csrf-token' }, + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const u = user as any; + token.id = u.id; + token.role = u.role; + token.tenantId = u.tenantId; + } + return token; + }, + async session({ session, token }) { + if (token && session.user) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session.user as any).id = token.id; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session.user as any).role = token.role; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (session.user as any).tenantId = token.tenantId; + } + return session; + }, + }, + providers: [], +} satisfies NextAuthConfig; diff --git a/apps/admin-web/lib/auth.ts b/apps/admin-web/lib/auth.ts index 1e87586..55ba266 100644 --- a/apps/admin-web/lib/auth.ts +++ b/apps/admin-web/lib/auth.ts @@ -1,18 +1,72 @@ +import NextAuth from 'next-auth'; +import Credentials from 'next-auth/providers/credentials'; import { prisma } from '@repo/db'; +import { authenticateCredential } from '@repo/api'; import type { SessionUser } from '@repo/api'; +import { authConfig } from './auth.config'; -// v0.1 admin-web auth: AUTH_DEV_AUTOLOGIN=true → always admin@demo.local. -// No session/cookie mechanism needed for the demo phase. +const AUTH_SECRET = process.env['AUTH_SECRET']; + +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + secret: AUTH_SECRET, + providers: [ + Credentials({ + name: 'Email + password', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + const email = credentials?.email; + const password = credentials?.password; + if (typeof email !== 'string' || typeof password !== 'string') return null; + const u = await authenticateCredential({ + email, + secret: password, + allowedRoles: ['ADMIN', 'SUPERVISOR'], + }); + if (!u) return null; + return { + id: u.id, + email: u.email, + name: u.email, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + role: u.role as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tenantId: u.tenantId as any, + }; + }, + }), + ], +}); + +/** + * Resolve the current user for server-side code (RSC, route handlers, tRPC). + * Falls back to dev autologin only outside production. + */ export async function resolveUser(): Promise { - if (process.env['AUTH_DEV_AUTOLOGIN'] !== 'true') return null; + const session = await auth(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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 admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); - if (!admin) return null; + 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, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + role: admin.role as any, + tenantId: admin.tenantId, + }; + } + } - return { - id: admin.id, - email: admin.email, - role: admin.role as 'ADMIN', - tenantId: admin.tenantId, - }; + return null; } diff --git a/apps/admin-web/middleware.ts b/apps/admin-web/middleware.ts new file mode 100644 index 0000000..34899f7 --- /dev/null +++ b/apps/admin-web/middleware.ts @@ -0,0 +1,26 @@ +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).*)', + ], +}; diff --git a/apps/admin-web/next.config.ts b/apps/admin-web/next.config.ts index c82fa39..33d866c 100644 --- a/apps/admin-web/next.config.ts +++ b/apps/admin-web/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next'; +import './env'; // Validate env vars at build time const config: NextConfig = { transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'], diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 1b473e5..dc6fb7f 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -23,6 +23,7 @@ "@trpc/server": "^11.0.0", "lucide-react": "^0.469.0", "next": "15.3.9", + "next-auth": "5.0.0-beta.25", "pino": "^9.5.0", "pino-pretty": "^11.3.0", "react": "^19.0.0", diff --git a/apps/operator-pwa/app/select-operator/operator-picker.tsx b/apps/operator-pwa/app/select-operator/operator-picker.tsx index b611c4d..0f127e1 100644 --- a/apps/operator-pwa/app/select-operator/operator-picker.tsx +++ b/apps/operator-pwa/app/select-operator/operator-picker.tsx @@ -3,35 +3,31 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { signIn } from 'next-auth/react'; +import { ArrowLeft, Delete } from 'lucide-react'; interface Operator { id: string; email: string; } -export function OperatorPicker({ operators }: { operators: Operator[] }) { - const router = useRouter(); - const [busy, setBusy] = useState(null); - const [error, setError] = useState(null); +// ── State types ────────────────────────────────────────────────────────────── - async function handleSelect(email: string) { - setBusy(email); - setError(null); - try { - const result = await signIn('credentials', { email, redirect: false }); - if (result?.error) { - setError(`Não foi possível entrar como ${email}`); - } else { - router.push('/'); - router.refresh(); - } - } catch { - setError('Erro inesperado. Tente novamente.'); - } finally { - setBusy(null); - } - } +type PickerState = + | { step: 'list' } + | { step: 'pin'; operator: Operator }; +const PIN_MIN = 4; +const PIN_MAX = 6; + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function OperatorList({ + operators, + onSelect, +}: { + operators: Operator[]; + onSelect: (op: Operator) => void; +}) { if (operators.length === 0) { return (

@@ -39,20 +35,170 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {

); } - return (
{operators.map((op) => ( ))} - {error &&

{error}

}
); } + +function PinPad({ + operator, + onBack, +}: { + operator: Operator; + onBack: () => void; +}) { + const router = useRouter(); + const [digits, setDigits] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function press(d: string) { + if (digits.length >= PIN_MAX) return; + setDigits((prev) => prev + d); + setError(null); + } + + function erase() { + setDigits((prev) => prev.slice(0, -1)); + setError(null); + } + + async function submit() { + if (digits.length < PIN_MIN || busy) return; + setBusy(true); + setError(null); + try { + const result = await signIn('credentials', { + email: operator.email, + pin: digits, + redirect: false, + }); + if (result?.error) { + setDigits(''); + setError('PIN incorreto ou conta bloqueada. Tente novamente.'); + } else { + router.push('/'); + router.refresh(); + } + } catch { + setDigits(''); + setError('Erro inesperado. Tente novamente.'); + } finally { + setBusy(false); + } + } + + const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'del']; + + return ( +
+ {/* Header */} +
+ +
+

Operador selecionado

+

{operator.email}

+
+
+ + {/* PIN dots */} +
+ {Array.from({ length: PIN_MAX }).map((_, i) => ( +
+ ))} +
+ + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Numpad */} +
+ {keys.map((key, idx) => { + if (key === '') { + return
; + } + if (key === 'del') { + return ( + + ); + } + return ( + + ); + })} +
+ + {/* Submit */} + +
+ ); +} + +// ── Main component ─────────────────────────────────────────────────────────── + +export function OperatorPicker({ operators }: { operators: Operator[] }) { + const [state, setState] = useState({ step: 'list' }); + + if (state.step === 'pin') { + return ( + setState({ step: 'list' })} + /> + ); + } + + return ( + setState({ step: 'pin', operator: op })} + /> + ); +} diff --git a/apps/operator-pwa/lib/auth.config.ts b/apps/operator-pwa/lib/auth.config.ts index d0a3748..475c07d 100644 --- a/apps/operator-pwa/lib/auth.config.ts +++ b/apps/operator-pwa/lib/auth.config.ts @@ -10,7 +10,14 @@ export const authConfig = { trustHost: true, session: { strategy: 'jwt' }, pages: { - // No login UI in this scaffold phase. See auth.ts for the placeholder. + signIn: '/select-operator', + }, + // Distinct cookie names prevent session collision when both apps run on localhost + // (cookies are not isolated by port — only by name and domain). + cookies: { + sessionToken: { name: 'fieldops-op.session-token' }, + callbackUrl: { name: 'fieldops-op.callback-url' }, + csrfToken: { name: 'fieldops-op.csrf-token' }, }, callbacks: { async jwt({ token, user }) { diff --git a/apps/operator-pwa/lib/auth.ts b/apps/operator-pwa/lib/auth.ts index 4d4076d..1cf2bfd 100644 --- a/apps/operator-pwa/lib/auth.ts +++ b/apps/operator-pwa/lib/auth.ts @@ -1,7 +1,8 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import { prisma } from '@repo/db'; -import type { SessionUser } from '@repo/api'; +import { authenticateCredential } from '@repo/api'; +import type { SessionUser } from '@repo/api'; // eslint-disable-line @typescript-eslint/no-unused-vars import { env } from '../env'; import { authConfig } from './auth.config'; @@ -36,24 +37,29 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ secret: env.AUTH_SECRET, providers: [ Credentials({ - name: 'Email (placeholder)', + name: 'Operador (PIN)', credentials: { - email: { label: 'Email', type: 'email' }, + email: { label: 'Email', type: 'text' }, + pin: { label: 'PIN', type: 'password' }, }, async authorize(credentials) { const email = credentials?.email; - if (typeof email !== 'string' || !email) return null; - const user = await prisma.user.findFirst({ where: { email } }); - if (!user) return null; - // NO password verification — placeholder only. + 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: user.id, - email: user.email, - name: user.email, + id: u.id, + email: u.email, + name: u.email, // eslint-disable-next-line @typescript-eslint/no-explicit-any - role: user.role as any, + role: u.role as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - tenantId: user.tenantId as any, + tenantId: u.tenantId as any, }; }, }), @@ -74,9 +80,9 @@ export async function resolveUser(): Promise { return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId }; } - if (env.AUTH_DEV_AUTOLOGIN) { - // Dev back door. Production guards: env flag default is false; this branch - // is also a no-op if the seed user doesn't exist. + const autologinAllowed = env.AUTH_DEV_AUTOLOGIN && process.env.NODE_ENV !== 'production'; + if (autologinAllowed) { + // Dev back door. Disabled in production even if the env flag is set. const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); if (admin) { return { diff --git a/apps/operator-pwa/middleware.ts b/apps/operator-pwa/middleware.ts index 8c694a2..b973c14 100644 --- a/apps/operator-pwa/middleware.ts +++ b/apps/operator-pwa/middleware.ts @@ -9,9 +9,10 @@ const { auth } = NextAuth(authConfig); export default auth((req) => { const isLoggedIn = !!req.auth?.user; - // AUTH_DEV_AUTOLOGIN bypasses the picker redirect — resolveUser() handles - // the autologin fallback server-side; the middleware just stays out of the way. - const isAutologin = process.env['AUTH_DEV_AUTOLOGIN'] === 'true'; + // AUTH_DEV_AUTOLOGIN bypasses the picker redirect in dev only. + // Ignored in production even when the flag is set. + const isAutologin = + process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production'; const { pathname } = req.nextUrl; // On the picker itself: skip if already logged in. diff --git a/docs/plans/auth-v0.2.md b/docs/plans/auth-v0.2.md new file mode 100644 index 0000000..5048ed1 --- /dev/null +++ b/docs/plans/auth-v0.2.md @@ -0,0 +1,423 @@ +# 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`](./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) + +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$$". Serve para password (admin) e PIN (operador). */ +export async function hashSecret(plain: string): Promise { + 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 { + 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 { + 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 0–9, 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 { + 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 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. diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts new file mode 100644 index 0000000..b9f2a85 --- /dev/null +++ b/packages/api/src/auth.ts @@ -0,0 +1,43 @@ +import { prisma, verifySecret } from '@repo/db'; +import type { SessionUser } from './context'; + +const MAX_ATTEMPTS = 5; +const LOCK_MS = 5 * 60_000; + +/** + * Authenticates by email + secret (PIN or password), restricted to the given roles. + * Uses the unscoped Prisma client: login happens before a tenant is known. + * Returns the SessionUser on success, null on any failure (wrong credentials, wrong role, lockout). + */ +export async function authenticateCredential(opts: { + email: string; + secret: string; + allowedRoles: SessionUser['role'][]; +}): Promise { + 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 }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9e70767..a624d60 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,3 +1,4 @@ export { appRouter, type AppRouter } from './routers/_app'; export { createTRPCContext, type Context, type SessionUser } from './context'; export { createCallerFactory } from './trpc'; +export { authenticateCredential } from './auth'; diff --git a/packages/db/prisma/migrations/20260530105026_auth_v0_2_lockout/migration.sql b/packages/db/prisma/migrations/20260530105026_auth_v0_2_lockout/migration.sql new file mode 100644 index 0000000..b80d2a6 --- /dev/null +++ b/packages/db/prisma/migrations/20260530105026_auth_v0_2_lockout/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lockedUntil" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 725bca2..974424e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -37,12 +37,14 @@ model Tenant { } model User { - id String @id @default(cuid()) - tenantId String - email String - passwordHash String? - role UserRole @default(OPERATOR) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + tenantId String + email String + passwordHash String? + role UserRole @default(OPERATOR) + createdAt DateTime @default(now()) + failedAttempts Int @default(0) + lockedUntil DateTime? tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index c2104c1..853b679 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -8,12 +8,19 @@ const here = path.dirname(fileURLToPath(import.meta.url)); loadEnv({ path: path.resolve(here, '../../../.env') }); const { PrismaClient, UserRole } = await import('@prisma/client'); +const { hashSecret } = await import('../src/crypto.js'); + const prisma = new PrismaClient(); const DEMO_TENANT_NAME = 'Demo Factory'; const DEMO_ADMIN_EMAIL = 'admin@demo.local'; +const DEMO_ADMIN_PASSWORD = 'admin1234'; -const OPERATOR_EMAILS = ['op1@demo.local', 'op2@demo.local', 'op3@demo.local']; +const OPERATORS = [ + { email: 'op1@demo.local', pin: '1111' }, + { email: 'op2@demo.local', pin: '2222' }, + { email: 'op3@demo.local', pin: '3333' }, +]; const WORKSTATIONS = [ { code: 'CTR04', name: 'Controlo 04', area: 'Montagem' }, @@ -38,23 +45,33 @@ async function main() { tenantId: tenant.id, email: DEMO_ADMIN_EMAIL, role: UserRole.ADMIN, + passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD), }, }); - await prisma.user.createMany({ - data: OPERATOR_EMAILS.map((email) => ({ - tenantId: tenant.id, - email, - role: UserRole.OPERATOR, - })), - }); + for (const op of OPERATORS) { + await prisma.user.create({ + data: { + tenantId: tenant.id, + email: op.email, + role: UserRole.OPERATOR, + passwordHash: await hashSecret(op.pin), + }, + }); + } await prisma.workstation.createMany({ data: WORKSTATIONS.map((ws) => ({ tenantId: tenant.id, ...ws })), }); console.warn( - `Seed complete — tenant=${tenant.id} (${tenant.name}), admin=${DEMO_ADMIN_EMAIL}, operators=${OPERATOR_EMAILS.length}, workstations=${WORKSTATIONS.length}`, + `Seed complete — tenant=${tenant.id} (${tenant.name})`, + ); + console.warn( + ` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`, + ); + console.warn( + ` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`, ); } diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts new file mode 100644 index 0000000..567e12e --- /dev/null +++ b/packages/db/src/crypto.ts @@ -0,0 +1,23 @@ +import { randomBytes, scrypt as _scrypt, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; + +const scrypt = promisify(_scrypt); +const KEYLEN = 64; + +/** Returns "scrypt$$". Works for both passwords (admin) and PINs (operator). */ +export async function hashSecret(plain: string): Promise { + const salt = randomBytes(16); + const derived = (await scrypt(plain, salt, KEYLEN)) as Buffer; + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`; +} + +/** Constant-time verification. Returns false for null/malformed stored values — never throws. */ +export async function verifySecret(plain: string, stored: string | null): Promise { + 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); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index c79b180..c8aa220 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,3 +2,4 @@ export { prisma, type DbClient } from './client'; export { tenantScoped, type TenantScopedClient } from './tenant-extension'; export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client'; export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client'; +export { hashSecret, verifySecret } from './crypto'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b87202..241985b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: next: specifier: 15.3.9 version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next-auth: + specifier: 5.0.0-beta.25 + version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) pino: specifier: ^9.5.0 version: 9.14.0 diff --git a/scripts/auth-smoke.ts b/scripts/auth-smoke.ts new file mode 100644 index 0000000..f1d02ea --- /dev/null +++ b/scripts/auth-smoke.ts @@ -0,0 +1,129 @@ +/** + * Auth smoke test — verifies hashSecret/verifySecret and authenticateCredential. + * Run: pnpm tsx scripts/auth-smoke.ts + * Requires: Docker Postgres running + pnpm db:seed already done. + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as loadEnv } from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ path: path.resolve(here, '../.env') }); + +import { hashSecret, verifySecret, prisma } from '../packages/db/src/index.js'; +import { authenticateCredential } from '../packages/api/src/index.js'; + +let passed = 0; +let failed = 0; + +function ok(label: string) { + console.log(` ✓ ${label}`); + passed++; +} + +function fail(label: string, detail?: unknown) { + console.error(` ✗ ${label}`, detail ?? ''); + failed++; +} + +async function main() { + // ── 1. Crypto unit tests (no DB) ───────────────────────────────────────────── + console.log('\n[1] Crypto unit tests'); + + const hash = await hashSecret('1234'); + if (await verifySecret('1234', hash)) ok('verifySecret correct input → true'); + else fail('verifySecret correct input → true'); + + if (!(await verifySecret('9999', hash))) ok('verifySecret wrong input → false'); + else fail('verifySecret wrong input → false'); + + if (!(await verifySecret('x', null))) ok('verifySecret null stored → false'); + else fail('verifySecret null stored → false'); + + if (!(await verifySecret('x', 'malformed'))) ok('verifySecret malformed stored → false'); + else fail('verifySecret malformed stored → false'); + + // ── 2. authenticateCredential — success cases ───────────────────────────────── + console.log('\n[2] authenticateCredential — success cases'); + + const op1 = await authenticateCredential({ + email: 'op1@demo.local', + secret: '1111', + allowedRoles: ['OPERATOR'], + }); + if (op1 && op1.role === 'OPERATOR') ok('op1 + correct PIN → user returned'); + else fail('op1 + correct PIN → user returned', op1); + + const admin = await authenticateCredential({ + email: 'admin@demo.local', + secret: 'admin1234', + allowedRoles: ['ADMIN', 'SUPERVISOR'], + }); + if (admin && admin.role === 'ADMIN') ok('admin + correct password → user returned'); + else fail('admin + correct password → user returned', admin); + + // ── 3. authenticateCredential — failure cases ───────────────────────────────── + console.log('\n[3] authenticateCredential — failure cases'); + + const wrongPin = await authenticateCredential({ + email: 'op2@demo.local', + secret: '0000', + allowedRoles: ['OPERATOR'], + }); + if (!wrongPin) ok('op2 + wrong PIN → null'); + else fail('op2 + wrong PIN → null'); + + const wrongRole = await authenticateCredential({ + email: 'admin@demo.local', + secret: 'admin1234', + allowedRoles: ['OPERATOR'], + }); + if (!wrongRole) ok('admin trying to log in as OPERATOR → null'); + else fail('admin trying to log in as OPERATOR → null'); + + const noUser = await authenticateCredential({ + email: 'ghost@demo.local', + secret: '1111', + allowedRoles: ['OPERATOR'], + }); + if (!noUser) ok('unknown email → null'); + else fail('unknown email → null'); + + // ── 4. Lockout after 5 wrong PINs ───────────────────────────────────────────── + console.log('\n[4] Lockout after 5 failed attempts (op3@demo.local)'); + + // Reset first in case a prior run left the account locked + await prisma.user.updateMany({ + where: { email: 'op3@demo.local' }, + data: { failedAttempts: 0, lockedUntil: null }, + }); + + for (let i = 1; i <= 5; i++) { + await authenticateCredential({ email: 'op3@demo.local', secret: '0000', allowedRoles: ['OPERATOR'] }); + } + + const afterLockout = await authenticateCredential({ + email: 'op3@demo.local', + secret: '3333', // correct PIN + allowedRoles: ['OPERATOR'], + }); + if (!afterLockout) ok('6th attempt (correct PIN) still blocked by lockout → null'); + else fail('6th attempt (correct PIN) still blocked by lockout → null'); + + // Reset op3 so the DB isn't left in a broken state + await prisma.user.updateMany({ + where: { email: 'op3@demo.local' }, + data: { failedAttempts: 0, lockedUntil: null }, + }); + ok('op3 lockout reset — DB clean'); + + // ── Summary ─────────────────────────────────────────────────────────────────── + await prisma.$disconnect(); + console.log(`\nResults: ${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});