MAI CALL - auth v0.2

# O que mudou
1 Schema: failedAttempts + lockedUntil em User; migration auth_v0_2_lockout aplicada; crypto.ts com hashSecret/verifySecret (Node scrypt nativo, zero deps)
2 packages/api/src/auth.ts — authenticateCredential com lockout de 5 tentativas
3 Seed reescrito: admin hashed admin1234, operadores hashed 1111/2222/3333
4 Porta das traseiras fechada: AUTH_DEV_AUTOLOGIN ignorado quando NODE_ENV=production, em ambas as apps
5 operator-pwa: Credentials provider usa PIN + allowedRoles:['OPERATOR']; cookies fieldops-op.*
6 Picker em 2 estados: lista → teclado PIN (botões grandes, dots de progresso, mensagem de erro sem dar pistas)
7 admin-web: Auth.js completo (auth.config, auth.ts, route handler, middleware, /login page, AUTH_SECRET no env) com cookies fieldops-admin.*
8 scripts/auth-smoke.ts (11/11 ✓); .env.example e README atualizados
This commit is contained in:
Pedro Gomes 2026-05-30 11:54:38 +01:00
parent bed5419409
commit 1bc837e606
25 changed files with 1119 additions and 80 deletions

View File

@ -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. # no session. This skips the login UI in local development and CI/E2E.
# #
# !!! NEVER set this to "true" in production. !!! # !!! 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 # The default of "false" here is intentional — a developer setting up locally
# must consciously opt in by editing their .env. See README "Auth" section. # must consciously opt in by editing their .env. See README "Auth" section.
AUTH_DEV_AUTOLOGIN="false" AUTH_DEV_AUTOLOGIN="false"

View File

@ -72,8 +72,10 @@ pnpm --filter @repo/admin-web dev
1. Open http://localhost:3000. 1. Open http://localhost:3000.
With `AUTH_DEV_AUTOLOGIN=true` you land on the home page as With `AUTH_DEV_AUTOLOGIN=true` you land on the home page as
`admin@demo.local`. To simulate a real operator, navigate to `admin@demo.local`. To simulate a real operator log-in, navigate to
http://localhost:3000/select-operator and tap **op1@demo.local**. 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**. 2. Tap **Pedir manutenção**.
3. Select a workstation, optionally attach a photo, write a description, 3. Select a workstation, optionally attach a photo, write a description,
and tap **Enviar pedido**. and tap **Enviar pedido**.
@ -92,7 +94,10 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
### As admin / maintenance supervisor (port 3001) ### 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. 2. The queue refreshes every 5 s; new requests appear automatically.
3. Click **Aceitar** to claim a request (status: Em curso). 3. Click **Aceitar** to claim a request (status: Em curso).
4. Click **Marcar resolvido**, optionally add a note, click **Confirmar** 4. Click **Marcar resolvido**, optionally add a note, click **Confirmar**
@ -144,8 +149,8 @@ Expected: **1 passed** in ~30 s.
| Limitation | Detail | Target | | 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 | | **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, not TAG/card** | Operator identity is chosen from a list rather than read from an RFID badge. | MY QUALITY module | | **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 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 | | **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 | | **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 | | **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 > ⚠️ **NEVER deploy with `AUTH_DEV_AUTOLOGIN=true`.** That flag is a
> back door. The chokepoint is `apps/operator-pwa/lib/auth.ts → > back door for local dev and CI only. It is **ignored at the code level**
> resolveUser()` (and the equivalent in `apps/admin-web/lib/auth.ts`). > when `NODE_ENV=production`, so a misconfigured `.env` in production won't
> Replace with real authentication before any non-dev deployment. > 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 format` | Prettier write across the workspace |
| `pnpm tsx scripts/storage-smoke.ts` | Verify MinIO presigned upload/download | | `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/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle |
| `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout |
--- ---

View File

@ -0,0 +1,4 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
export const runtime = 'nodejs';

View File

@ -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<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
disabled={busy}
className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
placeholder="admin@demo.local"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
disabled={busy}
className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={busy}
className="mt-2 w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
>
{busy ? 'A entrar…' : 'Entrar'}
</button>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { LoginForm } from './login-form';
export default function LoginPage() {
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
<header className="text-center">
<h1 className="text-2xl font-bold tracking-tight">FieldOps</h1>
<p className="mt-1 text-sm text-muted-foreground">
Acesso à consola de manutenção
</p>
</header>
<LoginForm />
</main>
);
}

View File

@ -4,6 +4,7 @@ import { z } from 'zod';
export const env = createEnv({ export const env = createEnv({
server: { server: {
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(1, 'AUTH_SECRET is required'),
AUTH_DEV_AUTOLOGIN: z AUTH_DEV_AUTOLOGIN: z
.string() .string()
.optional() .optional()
@ -17,6 +18,7 @@ export const env = createEnv({
}, },
runtimeEnv: { runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_DEV_AUTOLOGIN: process.env.AUTH_DEV_AUTOLOGIN, AUTH_DEV_AUTOLOGIN: process.env.AUTH_DEV_AUTOLOGIN,
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,

View File

@ -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;

View File

@ -1,18 +1,72 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from '@repo/db'; import { prisma } from '@repo/db';
import { authenticateCredential } from '@repo/api';
import type { SessionUser } 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. const AUTH_SECRET = process.env['AUTH_SECRET'];
// No session/cookie mechanism needed for the demo phase.
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<SessionUser | null> { export async function resolveUser(): Promise<SessionUser | null> {
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 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' } }); const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } });
if (!admin) return null; if (admin) {
return { return {
id: admin.id, id: admin.id,
email: admin.email, email: admin.email,
role: admin.role as 'ADMIN', // eslint-disable-next-line @typescript-eslint/no-explicit-any
role: admin.role as any,
tenantId: admin.tenantId, tenantId: admin.tenantId,
}; };
}
}
return null;
} }

View File

@ -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).*)',
],
};

View File

@ -1,4 +1,5 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
import './env'; // Validate env vars at build time
const config: NextConfig = { const config: NextConfig = {
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'], transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],

View File

@ -23,6 +23,7 @@
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "15.3.9", "next": "15.3.9",
"next-auth": "5.0.0-beta.25",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^11.3.0", "pino-pretty": "^11.3.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@ -3,35 +3,31 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { ArrowLeft, Delete } from 'lucide-react';
interface Operator { interface Operator {
id: string; id: string;
email: string; email: string;
} }
export function OperatorPicker({ operators }: { operators: Operator[] }) { // ── State types ──────────────────────────────────────────────────────────────
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSelect(email: string) { type PickerState =
setBusy(email); | { step: 'list' }
setError(null); | { step: 'pin'; operator: Operator };
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);
}
}
const PIN_MIN = 4;
const PIN_MAX = 6;
// ── Sub-components ───────────────────────────────────────────────────────────
function OperatorList({
operators,
onSelect,
}: {
operators: Operator[];
onSelect: (op: Operator) => void;
}) {
if (operators.length === 0) { if (operators.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -39,20 +35,170 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
</p> </p>
); );
} }
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{operators.map((op) => ( {operators.map((op) => (
<button <button
key={op.id} key={op.id}
onClick={() => handleSelect(op.email)} onClick={() => onSelect(op)}
disabled={busy !== null} className="w-full rounded-xl border border-border bg-card px-6 py-5 text-left text-base font-medium transition-colors hover:bg-accent active:scale-[0.98]"
className="w-full rounded-xl border border-border bg-card px-6 py-5 text-left text-base font-medium transition-colors hover:bg-accent active:scale-[0.98] disabled:opacity-50"
> >
{busy === op.email ? 'A entrar…' : op.email} {op.email}
</button> </button>
))} ))}
{error && <p className="text-sm text-destructive">{error}</p>}
</div> </div>
); );
} }
function PinPad({
operator,
onBack,
}: {
operator: Operator;
onBack: () => void;
}) {
const router = useRouter();
const [digits, setDigits] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
disabled={busy}
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
aria-label="Voltar"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div>
<p className="text-xs text-muted-foreground">Operador selecionado</p>
<p className="text-sm font-medium">{operator.email}</p>
</div>
</div>
{/* PIN dots */}
<div className="flex justify-center gap-4">
{Array.from({ length: PIN_MAX }).map((_, i) => (
<div
key={i}
className={`h-4 w-4 rounded-full border-2 transition-colors ${
i < digits.length
? 'border-primary bg-primary'
: 'border-muted-foreground bg-transparent'
}`}
/>
))}
</div>
{/* Error */}
{error && (
<p className="text-center text-sm text-destructive">{error}</p>
)}
{/* Numpad */}
<div className="grid grid-cols-3 gap-3">
{keys.map((key, idx) => {
if (key === '') {
return <div key={idx} />;
}
if (key === 'del') {
return (
<button
key={idx}
onClick={erase}
disabled={busy || digits.length === 0}
className="flex items-center justify-center rounded-2xl border border-border bg-card py-5 text-lg font-medium transition-colors hover:bg-accent active:scale-[0.97] disabled:opacity-40"
aria-label="Apagar"
>
<Delete className="h-5 w-5" />
</button>
);
}
return (
<button
key={idx}
onClick={() => press(key)}
disabled={busy || digits.length >= PIN_MAX}
className="rounded-2xl border border-border bg-card py-5 text-xl font-semibold transition-colors hover:bg-accent active:scale-[0.97] disabled:opacity-40"
>
{key}
</button>
);
})}
</div>
{/* Submit */}
<button
onClick={submit}
disabled={digits.length < PIN_MIN || busy}
className="w-full rounded-xl bg-primary py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-40"
>
{busy ? 'A entrar…' : 'Entrar'}
</button>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export function OperatorPicker({ operators }: { operators: Operator[] }) {
const [state, setState] = useState<PickerState>({ step: 'list' });
if (state.step === 'pin') {
return (
<PinPad
operator={state.operator}
onBack={() => setState({ step: 'list' })}
/>
);
}
return (
<OperatorList
operators={operators}
onSelect={(op) => setState({ step: 'pin', operator: op })}
/>
);
}

View File

@ -10,7 +10,14 @@ export const authConfig = {
trustHost: true, trustHost: true,
session: { strategy: 'jwt' }, session: { strategy: 'jwt' },
pages: { 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: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {

View File

@ -1,7 +1,8 @@
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials'; import Credentials from 'next-auth/providers/credentials';
import { prisma } from '@repo/db'; 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 { env } from '../env';
import { authConfig } from './auth.config'; import { authConfig } from './auth.config';
@ -36,24 +37,29 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
secret: env.AUTH_SECRET, secret: env.AUTH_SECRET,
providers: [ providers: [
Credentials({ Credentials({
name: 'Email (placeholder)', name: 'Operador (PIN)',
credentials: { credentials: {
email: { label: 'Email', type: 'email' }, email: { label: 'Email', type: 'text' },
pin: { label: 'PIN', type: 'password' },
}, },
async authorize(credentials) { async authorize(credentials) {
const email = credentials?.email; const email = credentials?.email;
if (typeof email !== 'string' || !email) return null; const pin = credentials?.pin;
const user = await prisma.user.findFirst({ where: { email } }); if (typeof email !== 'string' || typeof pin !== 'string') return null;
if (!user) return null; const u = await authenticateCredential({
// NO password verification — placeholder only. email,
secret: pin,
allowedRoles: ['OPERATOR'],
});
if (!u) return null;
return { return {
id: user.id, id: u.id,
email: user.email, email: u.email,
name: user.email, name: u.email,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // 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<SessionUser | null> {
return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId }; return { id: u.id, email: u.email, role: u.role, tenantId: u.tenantId };
} }
if (env.AUTH_DEV_AUTOLOGIN) { const autologinAllowed = env.AUTH_DEV_AUTOLOGIN && process.env.NODE_ENV !== 'production';
// Dev back door. Production guards: env flag default is false; this branch if (autologinAllowed) {
// is also a no-op if the seed user doesn't exist. // Dev back door. Disabled in production even if the env flag is set.
const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } }); const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } });
if (admin) { if (admin) {
return { return {

View File

@ -9,9 +9,10 @@ const { auth } = NextAuth(authConfig);
export default auth((req) => { export default auth((req) => {
const isLoggedIn = !!req.auth?.user; const isLoggedIn = !!req.auth?.user;
// AUTH_DEV_AUTOLOGIN bypasses the picker redirect — resolveUser() handles // AUTH_DEV_AUTOLOGIN bypasses the picker redirect in dev only.
// the autologin fallback server-side; the middleware just stays out of the way. // Ignored in production even when the flag is set.
const isAutologin = process.env['AUTH_DEV_AUTOLOGIN'] === 'true'; const isAutologin =
process.env['AUTH_DEV_AUTOLOGIN'] === 'true' && process.env.NODE_ENV !== 'production';
const { pathname } = req.nextUrl; const { pathname } = req.nextUrl;
// On the picker itself: skip if already logged in. // On the picker itself: skip if already logged in.

423
docs/plans/auth-v0.2.md Normal file
View File

@ -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 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.

43
packages/api/src/auth.ts Normal file
View File

@ -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<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 };
}

View File

@ -1,3 +1,4 @@
export { appRouter, type AppRouter } from './routers/_app'; export { appRouter, type AppRouter } from './routers/_app';
export { createTRPCContext, type Context, type SessionUser } from './context'; export { createTRPCContext, type Context, type SessionUser } from './context';
export { createCallerFactory } from './trpc'; export { createCallerFactory } from './trpc';
export { authenticateCredential } from './auth';

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "lockedUntil" TIMESTAMP(3);

View File

@ -43,6 +43,8 @@ model User {
passwordHash String? passwordHash String?
role UserRole @default(OPERATOR) role UserRole @default(OPERATOR)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
failedAttempts Int @default(0)
lockedUntil DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

View File

@ -8,12 +8,19 @@ const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../../.env') }); loadEnv({ path: path.resolve(here, '../../../.env') });
const { PrismaClient, UserRole } = await import('@prisma/client'); const { PrismaClient, UserRole } = await import('@prisma/client');
const { hashSecret } = await import('../src/crypto.js');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory'; const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local'; 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 = [ const WORKSTATIONS = [
{ code: 'CTR04', name: 'Controlo 04', area: 'Montagem' }, { code: 'CTR04', name: 'Controlo 04', area: 'Montagem' },
@ -38,23 +45,33 @@ async function main() {
tenantId: tenant.id, tenantId: tenant.id,
email: DEMO_ADMIN_EMAIL, email: DEMO_ADMIN_EMAIL,
role: UserRole.ADMIN, role: UserRole.ADMIN,
passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD),
}, },
}); });
await prisma.user.createMany({ for (const op of OPERATORS) {
data: OPERATOR_EMAILS.map((email) => ({ await prisma.user.create({
data: {
tenantId: tenant.id, tenantId: tenant.id,
email, email: op.email,
role: UserRole.OPERATOR, role: UserRole.OPERATOR,
})), passwordHash: await hashSecret(op.pin),
},
}); });
}
await prisma.workstation.createMany({ await prisma.workstation.createMany({
data: WORKSTATIONS.map((ws) => ({ tenantId: tenant.id, ...ws })), data: WORKSTATIONS.map((ws) => ({ tenantId: tenant.id, ...ws })),
}); });
console.warn( 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(' | ')}`,
); );
} }

23
packages/db/src/crypto.ts Normal file
View File

@ -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$<saltHex>$<hashHex>". Works for both passwords (admin) and PINs (operator). */
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')}`;
}
/** Constant-time verification. Returns false for null/malformed stored values — never throws. */
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);
}

View File

@ -2,3 +2,4 @@ export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension'; export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client'; export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client'; export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client';
export { hashSecret, verifySecret } from './crypto';

3
pnpm-lock.yaml generated
View File

@ -68,6 +68,9 @@ importers:
next: next:
specifier: 15.3.9 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) 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: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0

129
scripts/auth-smoke.ts Normal file
View File

@ -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);
});