feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)
Il portale /client/<slug> era protetto dal solo token in URL: chiunque ricevesse o intercettasse il link entrava, per sempre, senza identificarsi. Ora l'admin registra le email autorizzate per cliente e il cliente si identifica con un codice usa-e-getta prima di vedere qualsiasi dato. - Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso) - migration 0015 (gia applicata a prod): client_emails, otp_codes, clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post - admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist e revoca sessioni in blocco - gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro), TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint, risposta identica per email in whitelist e non (no enumeration) - sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento page viene renderizzato in parallelo al layout, quindi gattare nel layout nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato. Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione. NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude fuori dal loro portale. Checklist in .planning/STATE.md. SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+54
-1
@@ -40,11 +40,60 @@ export const clients = pgTable("clients", {
|
||||
// Conversazioni inbox: timestamp of the admin's last read of this client's
|
||||
// conversation. NULL = never read (treated as unread). Set via markConversationRead.
|
||||
admin_last_read_at: timestamp("admin_last_read_at", { withTimezone: true }),
|
||||
// OTP gate (v2.3): revoca in blocco delle sessioni portale già emesse.
|
||||
// Una sessione è valida solo se firmata DOPO questo istante. NULL = mai revocate.
|
||||
sessions_valid_from: timestamp("sessions_valid_from", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
// ============ CLIENT ACCESS (OTP) ============
|
||||
// Whitelist admin-gestita: nessuna auto-registrazione. Un cliente può avere più
|
||||
// email (i soci del progetto accedono allo stesso portale).
|
||||
export const client_emails = pgTable(
|
||||
"client_emails",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// L'indice reale è su (client_id, lower(email)) — vedi 0015_otp_access.sql.
|
||||
// Drizzle non modella le expression index: qui serve solo a documentarlo.
|
||||
uniqueIndex("client_emails_client_email_idx").on(table.client_id, table.email),
|
||||
]
|
||||
);
|
||||
|
||||
// Codici OTP emessi. Si persiste solo l'hash: il codice in chiaro vive nell'email.
|
||||
export const otp_codes = pgTable(
|
||||
"otp_codes",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
client_id: text("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
code_hash: text("code_hash").notNull(),
|
||||
expires_at: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
consumed_at: timestamp("consumed_at", { withTimezone: true }),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => [index("otp_codes_client_email_idx").on(table.client_id, table.email)]
|
||||
);
|
||||
|
||||
// ============ PROJECTS ============
|
||||
export const projects = pgTable("projects", {
|
||||
id: text("id")
|
||||
@@ -799,4 +848,8 @@ export type NewReminder = typeof reminders.$inferInsert;
|
||||
export type ClientTranscript = typeof clientTranscripts.$inferSelect;
|
||||
export type NewClientTranscript = typeof clientTranscripts.$inferInsert;
|
||||
export type Proposal = typeof proposals.$inferSelect;
|
||||
export type NewProposal = typeof proposals.$inferInsert;
|
||||
export type NewProposal = typeof proposals.$inferInsert;
|
||||
export type ClientEmail = typeof client_emails.$inferSelect;
|
||||
export type NewClientEmail = typeof client_emails.$inferInsert;
|
||||
export type OtpCode = typeof otp_codes.$inferSelect;
|
||||
export type NewOtpCode = typeof otp_codes.$inferInsert;
|
||||
Reference in New Issue
Block a user