1. Summary of changes Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point. NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction. Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL. Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally. 2. Prisma/database changes New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored. User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched. Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session). 3. New environment variables None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else. 4. Email delivery configuration Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop). 5. Migration considerations for existing users/installations Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change. No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore. prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently). Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step. 6. Files changed New: src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service src/lib/auth/__tests__/otp.service.integration.test.ts src/services/notifications/templates/otp-email.ts src/features/auth/components/otp-code-step.tsx — shared code-entry UI src/features/auth/actions/request-sign-in-otp.ts src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE prisma/migrations/20260905190022_email_otp_authentication/ Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call). 7. Verification performed npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean. npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here. Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced. Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields. All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
This commit is contained in:
29 files changed
+1434
-357
No files matched your search
+59
-28
@@ -1,22 +1,45 @@
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import bcrypt from "bcryptjs";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import Google from "next-auth/providers/google";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { finalizeOtp, verifyOtp } from "@/lib/auth/otp.service";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { signInSchema } from "@/features/auth/schemas";
|
||||
import { checkRateLimit } from "@/lib/security/rate-limit";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
|
||||
// A dummy hash to compare against when no real user/passwordHash exists,
|
||||
// so a "no such account" / "Google-only account" attempt still pays
|
||||
// bcrypt's cost — without this, that branch returns near-instantly while
|
||||
// a wrong-password-for-a-real-account attempt always waits for a real
|
||||
// bcrypt.compare, letting a timing measurement distinguish "no such
|
||||
// account" from "wrong password" (an account-enumeration side channel).
|
||||
// Computed once at module load (not hand-typed) so it's guaranteed to be
|
||||
// a valid hash at the same cost factor registration uses.
|
||||
const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12);
|
||||
/**
|
||||
* Two credential shapes, both ending in the same finalizeOtp() call and
|
||||
* the same `{id, email, name, image}` return shape — see
|
||||
* src/lib/auth/otp.service.ts's own header for the full verify/finalize
|
||||
* design this builds on.
|
||||
*
|
||||
* `{ email, code }` — normal sign-in/registration: verifyOtp then
|
||||
* finalizeOtp back to back in this one request (functionally "verify and
|
||||
* consume in one shot" from the caller's point of view).
|
||||
*
|
||||
* `{ email, finalize: "true", token }` — used only by the installer's and
|
||||
* staff-invitation-acceptance's own immediate post-completion sign-in,
|
||||
* where the code was already consumed by their own earlier verifyOtp call
|
||||
* and can't be re-entered (one-time use). `token` must match either the
|
||||
* live INSTALLATION_TOKEN or a valid StaffInvitation.token for this exact
|
||||
* email — required because /api/auth/callback/credentials is a public
|
||||
* route regardless of this change (true of any Credentials provider), so
|
||||
* this path must independently prove the caller already holds a secret an
|
||||
* attacker can't have, not just trust that it's being called "from the
|
||||
* right place". Without this, a race window (someone else's verifiedAt-
|
||||
* but-not-yet-consumedAt row, mid-wizard) would be forgeable via a direct
|
||||
* POST to that route with just a guessed email.
|
||||
*/
|
||||
async function verifyFinalizeToken(email: string, token: string): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
|
||||
const installToken = process.env.INSTALLATION_TOKEN;
|
||||
if (installToken && secretsMatch(token, installToken)) return true;
|
||||
|
||||
const invitation = await prisma.staffInvitation.findUnique({ where: { token }, select: { email: true } });
|
||||
return !!invitation && invitation.email.toLowerCase() === email;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
@@ -32,32 +55,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
code: { label: "Code", type: "text" },
|
||||
// Only ever set by trusted server-side callers (the installer,
|
||||
// staff-invitation acceptance) — never rendered on any real form.
|
||||
// See this file's own header for the full finalize/token design.
|
||||
finalize: { label: "Finalize", type: "text" },
|
||||
token: { label: "Token", type: "text" },
|
||||
},
|
||||
authorize: async (raw) => {
|
||||
const parsed = signInSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const email = typeof raw?.email === "string" ? raw.email.trim().toLowerCase() : "";
|
||||
if (!email) return null;
|
||||
|
||||
// Keyed by the attempted email, not IP — this is what actually
|
||||
// stops credential stuffing against one account regardless of how
|
||||
// many source addresses it's spread across. 30 attempts per 15
|
||||
// minutes is well above anything a real forgetful user (or a
|
||||
// shared/office login) would ever hit, while still making online
|
||||
// guessing impractical — a real brute-force attempt wants
|
||||
// thousands of guesses, not tens.
|
||||
const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, {
|
||||
maxAttempts: 30,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
});
|
||||
// minutes is well above anything a real forgetful user would ever
|
||||
// hit, while still making online guessing impractical.
|
||||
const { allowed } = checkRateLimit(`signin:${email}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000 });
|
||||
if (!allowed) return null;
|
||||
|
||||
if (raw?.finalize === "true") {
|
||||
const token = typeof raw?.token === "string" ? raw.token : "";
|
||||
const trusted = await verifyFinalizeToken(email, token);
|
||||
if (!trusted) return null;
|
||||
if (!(await finalizeOtp(prisma, email))) return null;
|
||||
} else {
|
||||
const code = typeof raw?.code === "string" ? raw.code : "";
|
||||
if (!code) return null;
|
||||
const verified = await verifyOtp(prisma, email, code);
|
||||
if (!verified.ok) return null;
|
||||
if (!(await finalizeOtp(prisma, email))) return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
// Always compare against *something* — see DUMMY_HASH above — so
|
||||
// this call's timing doesn't reveal whether the account exists.
|
||||
const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
|
||||
if (!user?.passwordHash || !valid) return null;
|
||||
if (!user) return null;
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name, image: user.image };
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user