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:
parent
bed5419409
commit
1bc837e606
@ -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"
|
||||||
|
|||||||
23
README.md
23
README.md
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
4
apps/admin-web/app/api/auth/[...nextauth]/route.ts
Normal file
4
apps/admin-web/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { handlers } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
|
export const runtime = 'nodejs';
|
||||||
79
apps/admin-web/app/login/login-form.tsx
Normal file
79
apps/admin-web/app/login/login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/admin-web/app/login/page.tsx
Normal file
15
apps/admin-web/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
44
apps/admin-web/lib/auth.config.ts
Normal file
44
apps/admin-web/lib/auth.config.ts
Normal 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;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
26
apps/admin-web/middleware.ts
Normal file
26
apps/admin-web/middleware.ts
Normal 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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -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'],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 }) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
423
docs/plans/auth-v0.2.md
Normal 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 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$<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 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<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 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.
|
||||||
43
packages/api/src/auth.ts
Normal file
43
packages/api/src/auth.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "failedAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lockedUntil" TIMESTAMP(3);
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
23
packages/db/src/crypto.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
129
scripts/auth-smoke.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user