diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d7bdac..62a265c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.10.0] - 2026-09-08 + +### Changed + +- **MFA enrolment now runs on BetterAuth's `twoFactor` plugin end to end.** The + settings page enables, verifies, disables and regenerates backup codes through the + plugin client, so the flag the login gate checks is finally the one enrolment + writes. **Users who had MFA enabled must enrol again** (the previous flag was never + enforced at login). LDAP/SSO accounts can enrol without a local password; backup + codes are stored encrypted. Enrolment, removal, regeneration and TOTP/backup-code + logins are written to the audit log; the credentials login audit no longer fires + before the second factor is verified. +- Compliance dashboard: the "User Authentication Controls" check reports real + two-factor coverage instead of "not yet implemented". + +### Security + +- Compliance dashboard counts (users, assets, audit logs) are scoped to the caller's + organization; they were computed across all tenants. +- Vercel builds run `prisma migrate deploy` only when `VERCEL_ENV=production`. + Preview builds used to migrate whatever database the Preview environment pointed + at, which on the personal deployment was production. + +### Removed + +- Custom `/api/auth/mfa/{setup,verify,disable}` routes, `lib/mfa.ts`, the + `otplib` dependency, the `user.mfaEnabled/mfaSecret/mfaBackupCodes` columns + (migration `20260908_betterauth_two_factor`) and the unused `encryptArray` helpers. + ## [0.9.6] - 2026-09-07 ### Security diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md index 1d0d63fd..58feae9c 100644 --- a/TECHNICAL_DEBT.md +++ b/TECHNICAL_DEBT.md @@ -1,6 +1,6 @@ # Technical Debt -Last updated: 2026-09-07 (v0.9.6 — register re-verification and debt sweep) +Last updated: 2026-09-08 (v0.10.0 — MFA on BetterAuth, D1 resolved) This document tracks issues found by whole-application audits. Items marked **FIXED** were resolved in the version noted; **DEFERRED** items are documented @@ -23,15 +23,31 @@ with a recommended fix. Two audits have run so far: | CI (lint, typecheck, unit, build) | green since 2026-09-07 (v0.9.5 fixed the lockfile/typings; the DB suites and the Build job first passed today) | | Production dependency advisories | 0 (was 1 critical, 53 high) | | Cross-tenant data access | no known open read/write path (status-type cache key fixed in v0.9.6) | -| MFA login enforcement | **NOT FUNCTIONAL** — decision required (D1) | +| MFA login enforcement | functional since v0.10.0 (enrolment on BetterAuth twoFactor) | | SSO (SAML/OIDC) login completion | **NOT FUNCTIONAL** — decision required (D2) | | TypeScript strict mode | off; 830 errors to clear (D3) | | Paid plan feature enforcement | **5 of 6 gated features unenforced server-side** (item 29, critical) | -| Advertised but unfinished features | 10 (items 33–42) | +| Advertised but unfinished features | 9 (items 33–36, 38–42; item 37 partly closed) | | Unit coverage of auth/tenant layer | partial (api-auth, url-validation, org-suspension now tested) | --- +## FIXED in v0.10.0 (2026-09-08) + +- **D1 resolved.** `MfaSettings.tsx` enrols through `authClient.twoFactor.enable / +verifyTotp / disable / generateBackupCodes`; the custom routes, `lib/mfa.ts`, + `otplib` and the `mfa*` user columns are gone (migration + `20260908_betterauth_two_factor`, which also adds the plugin's `verified`, + `failedVerificationCount` and `lockedUntil` columns to `twoFactor`). + `allowPasswordless: true` lets LDAP/SSO accounts enrol; backup codes are stored + encrypted. Audit coverage moved to `lib/auth-two-factor-audit.ts`, driven from the + BetterAuth `after` hook (enrolment, removal, regeneration, TOTP and backup-code + logins); the credentials login audit now waits for the second factor. + **Existing enrolments must re-enrol** — communicate before deploying. +- **Compliance route was cross-tenant**: every count in `api/admin/compliance` was + global. Now scoped to the admin's organization (403 without one). The dashboard's + MFA check reports real `twoFactorEnabled` coverage (part of item 37). + ## FIXED in v0.9.6 (2026-09-07) - **CI Build job never ran.** The build script is the Vercel build command @@ -132,16 +148,9 @@ with a recommended fix. Two audits have run so far: ## DEFERRED — decisions required -**D1. MFA is bypassable (critical).** Two disconnected implementations: the settings UI -(`user/[id]/settings/ui/MfaSettings.tsx`) calls custom `/api/auth/mfa/{setup,verify, -disable}` which set `user.mfaEnabled`/`mfaSecret`; the login gate is BetterAuth's -`twoFactor` plugin, which only fires on its own `user.twoFactorEnabled` column and -`twoFactor` table — set nowhere. Enabling MFA has no effect at login. -_Recommended:_ rewrite `MfaSettings.tsx` against `authClient.twoFactor.enable / -verifyTotp / disable` (the login side already uses `verifyTotp`), delete the custom -routes, `lib/mfa.ts`, and the `mfaSecret`/`mfaBackupCodes` columns. Existing -enrolments must re-enrol. Alternative: have the custom verify route also write -BetterAuth's columns (couples to library internals; not recommended). +**D1. MFA is bypassable.** _Resolved in v0.10.0 — see FIXED above._ Follow-ups left +open: org-wide "require MFA" policy and an admin "reset MFA for user" action (neither +existed before either). **D2. SSO login never completes (high).** SAML/OIDC callbacks create/link the user, mint a one-time token and redirect to `/login?sso_user=&sso_token=`; nothing reads @@ -186,9 +195,14 @@ excluded from `tsconfig` and CI, and was last touched on 2026-06-01 (three commi On Vercel prefer the platform header; self-hosted needs a trusted-proxy setting. 6. **Sentry `beforeSend`** scrubbing absent in all three configs (relies on `sendDefaultPii:false` only). -7. **Vercel preview deploys run `prisma migrate deploy`** against whatever - `DATABASE_URL` the preview has. Documented in `DEPLOYMENT.md`; consider gating on - `VERCEL_ENV === "production"` in the build command. +7. **Vercel preview deploys ran `prisma migrate deploy`** against whatever + `DATABASE_URL` the preview has. _Fixed in v0.10.0_: `vercel.json` now runs the + migration only when `VERCEL_ENV=production`. This was found the hard way — the + preview build of PR #87 applied the column-dropping MFA migration to the personal + deployment's production database (its Preview and Production environments share + `DATABASE_URL`) and broke sign-in on the June build until the columns were restored. + Still open: give Preview its own database, and keep the production URL out of + local `.env` files (the checked-out `.env` pointed at production too). 8. `.mcp.json` points at a work Sentry org from a private repo — remove before any open-sourcing. @@ -274,10 +288,11 @@ Decide per item: finish it or remove the promise. 36. **`white_label` is vaporware.** Listed as an Enterprise perk in `plan-features-shared.ts` and `docs/DEVELOPMENT_NOTES.md`; no branding feature, no schema, no gate. Implement or remove from the plan matrix. Effort L / S. -37. **Compliance dashboard hard-codes three checks** - (`admin/compliance/ui/ComplianceDashboard.tsx`): MFA and encryption coverage say - "not yet implemented", and the incident-response check ignores its input and - always returns "Not Configured". Effort S (remove) / M (back with settings). +37. **Compliance dashboard hard-codes two checks** + (`admin/compliance/ui/ComplianceDashboard.tsx`): encryption coverage says "not + yet implemented", and the incident-response check ignores its input and always + returns "Not Configured". (MFA coverage is real since v0.10.0.) Effort S (remove) + / M (back with settings). 38. **`email_templates` table is loaded and discarded.** `admin/settings/page.tsx` fetches it and `AdminSettingsPage.tsx` binds it to `_emailTemplates`; there is no editor and no route, and all outbound mail uses the hard-coded object in diff --git a/bun.lock b/bun.lock index 303bc603..ba37cafe 100644 --- a/bun.lock +++ b/bun.lock @@ -49,7 +49,6 @@ "maplibre-gl": "^5.24.0", "next": "16.2.6", "next-themes": "^0.4.6", - "otplib": "^13.5.0", "pdf-lib": "^1.17.1", "pg": "^8.23.0", "qrcode": "^1.5.4", @@ -505,18 +504,6 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@otplib/core": ["@otplib/core@13.5.0", "", {}, "sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ=="], - - "@otplib/hotp": ["@otplib/hotp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-1EwwAti05CeWJn4xXOVMBK9N6dIJlJw/cQkQyiL9OVlkeS452wAve3ffodi+5IUZdWoF1wxVug38RisdxDqDWw=="], - - "@otplib/plugin-base32-scure": ["@otplib/plugin-base32-scure@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@scure/base": "^2.2.0" } }, "sha512-3JEIHindUMiIeNL0jXepSAkZ/7IkWq4sPdG9eXK0lrMXZG9RdKu5oz/4tuuTodtsFUPYhKlRMqv5xUvQIkMIaA=="], - - "@otplib/plugin-crypto-noble": ["@otplib/plugin-crypto-noble@13.5.0", "", { "dependencies": { "@noble/hashes": "^2.2.0", "@otplib/core": "13.5.0" } }, "sha512-fihOAGFvc4b8XTKyIK3jifFP2nLrUNc2bOaJ3UkUKJjy1XI2FbHKG0WmOH+jFsPfa6VsOnouCnEmSznDxIe0pA=="], - - "@otplib/totp": ["@otplib/totp@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-GD9LzQnbHDwXCp79s8AaiA5cJR6luX5fEr9AN32AWx4ooRvllPbMkG80gHSA0jbGCnAIX/ChyOtTZ8tgJHMkSg=="], - - "@otplib/uri": ["@otplib/uri@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0" } }, "sha512-LsL1hqTEgJHY40U2eb/Qp6OR1tKkNGwHQTrqqjIuJj0cawGWkLEmeUFK1MHzWBQ4bgy5IdeB5Et7gIDSAH/CRw=="], - "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], @@ -735,8 +722,6 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@scure/base": ["@scure/base@2.4.0", "", {}, "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg=="], - "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@5.3.0", "", {}, "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA=="], "@sentry/browser": ["@sentry/browser@10.73.0", "", { "dependencies": { "@sentry/browser-utils": "10.73.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/feedback": "10.73.0", "@sentry/replay": "10.73.0", "@sentry/replay-canvas": "10.73.0" } }, "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ=="], @@ -1929,8 +1914,6 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "otplib": ["otplib@13.5.0", "", { "dependencies": { "@otplib/core": "13.5.0", "@otplib/hotp": "13.5.0", "@otplib/plugin-base32-scure": "13.5.0", "@otplib/plugin-crypto-noble": "13.5.0", "@otplib/totp": "13.5.0", "@otplib/uri": "13.5.0" } }, "sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ=="], - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], diff --git a/docs/DATABASE_MIGRATION_GUIDE.md b/docs/DATABASE_MIGRATION_GUIDE.md index f3d853a2..ef0a6a82 100644 --- a/docs/DATABASE_MIGRATION_GUIDE.md +++ b/docs/DATABASE_MIGRATION_GUIDE.md @@ -32,7 +32,7 @@ Asset Tracker uses **PostgreSQL** (15+) with **Prisma ORM**. The database contai 2. `20260129151226_add_ticket_system` — IT ticket system 3. `20260129165029_multi_tanancy` — Multi-tenancy (organizations, departments, roles, webhooks) 4. `20260305_betterauth_schema` — BetterAuth auth tables (replaces NextAuth) -- **Encrypted data** — Some fields (API keys, MFA secrets) are encrypted with `ENCRYPTION_KEY`. The same key must be used on the new database. +- **Encrypted data** — Some fields (API keys, integration credentials) are encrypted with `ENCRYPTION_KEY`, and BetterAuth encrypts TOTP secrets and backup codes with `BETTER_AUTH_SECRET`. The same key must be used on the new database. **Migration strategy:** Full `pg_dump` export → import into new database → switch `DATABASE_URL`. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index ad3bde96..2935d275 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -22,10 +22,10 @@ You need a PostgreSQL 15+ database. Options: Run these locally and save the output — you'll need them for env vars: ```bash -# BETTER_AUTH_SECRET — signs JWT tokens (must be at least 32 characters) +# BETTER_AUTH_SECRET — signs sessions and encrypts TOTP secrets (must be at least 32 characters) openssl rand -base64 32 -# ENCRYPTION_KEY — encrypts sensitive data at rest (API keys, MFA secrets) +# ENCRYPTION_KEY — encrypts sensitive data at rest (API keys, integration credentials) openssl rand -hex 32 # CRON_SECRET — protects cron job endpoints @@ -220,7 +220,7 @@ Optional email vars: ### Step 5: Preview Builds Warning -**Important:** The build command runs `prisma migrate deploy` on **every** deployment, including Preview builds. Preview environments must have their own `DATABASE_URL` (never share the production database). Additionally, set `DB_SCHEMA=public` if using the public schema instead of the default `assettool` schema. +**Important:** Since v0.10.0 the build command runs `prisma migrate deploy` only when `VERCEL_ENV=production`; Preview builds compile against whatever schema the preview database already has, so a preview of a branch that adds columns will fail on those pages until it is merged and released. Preview environments should still have their own `DATABASE_URL` (never share the production database): before v0.10.0 a preview build of a column-dropping migration was applied to production this way. Additionally, set `DB_SCHEMA=public` if using the public schema instead of the default `assettool` schema. ### Step 6: Deploy @@ -325,7 +325,7 @@ docker compose exec app npx prisma migrate status # Check migration state ``` **"no matching decryption secret" error:** -The `BETTER_AUTH_SECRET` changed. Users need to clear cookies / log in again. +The `BETTER_AUTH_SECRET` changed. Users need to clear cookies / log in again. Because it also encrypts TOTP secrets and backup codes, every MFA enrolment is invalidated and users must set up MFA again. **Email not sending:** Check Admin Settings > Email for env config status. Send a test email. Check logs: diff --git a/docs/DEVELOPMENT_NOTES.md b/docs/DEVELOPMENT_NOTES.md index ad18b6ea..dc959692 100644 --- a/docs/DEVELOPMENT_NOTES.md +++ b/docs/DEVELOPMENT_NOTES.md @@ -29,7 +29,7 @@ Shared database with `organizationId` column on all tenant-scoped tables. `scope - **Auth flow:** BetterAuth credential login with optional TOTP/backup code MFA step, SSO via OAuth2 (Microsoft, Google), LDAP/SAML - **Rate limiting:** IP-based (10 attempts/15 min) with progressive account lockout - **Session tracking:** IP + user-agent recorded, hourly JWT revalidation -- **Encryption:** AES-256-GCM at rest for MFA secrets, webhook secrets, API keys, SSO/LDAP creds +- **Encryption:** AES-256-GCM at rest for webhook secrets, API keys, SSO/LDAP creds; TOTP secrets and backup codes are encrypted by BetterAuth with `BETTER_AUTH_SECRET` - **Security headers:** Full CSP, HSTS, X-Frame-Options, X-Content-Type-Options - **RBAC:** 35 granular permissions via `requirePermission()` on 30+ routes diff --git a/package.json b/package.json index b9531354..a22d1960 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "asset-tracker", - "version": "0.9.6", + "version": "0.10.0", "private": true, "license": "MIT", "scripts": { @@ -73,7 +73,6 @@ "maplibre-gl": "^5.24.0", "next": "16.2.6", "next-themes": "^0.4.6", - "otplib": "^13.5.0", "pdf-lib": "^1.17.1", "pg": "^8.23.0", "qrcode": "^1.5.4", diff --git a/prisma/migrations/20260908_betterauth_two_factor/migration.sql b/prisma/migrations/20260908_betterauth_two_factor/migration.sql new file mode 100644 index 00000000..e92b18b5 --- /dev/null +++ b/prisma/migrations/20260908_betterauth_two_factor/migration.sql @@ -0,0 +1,13 @@ +-- MFA enrolment moves onto BetterAuth's twoFactor plugin (v0.10.0). +-- The custom mfa* columns are dropped: enrolments made through the old flow were +-- never enforced at login and must be redone. The twoFactor table gains the +-- verification and lockout columns the plugin writes. +ALTER TABLE "public"."user" + DROP COLUMN IF EXISTS "mfaEnabled", + DROP COLUMN IF EXISTS "mfaSecret", + DROP COLUMN IF EXISTS "mfaBackupCodes"; + +ALTER TABLE "public"."twoFactor" + ADD COLUMN IF NOT EXISTS "verified" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS "failedVerificationCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ(6); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b144870b..a1d7048c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1039,9 +1039,6 @@ model user { departmentId String? @db.Uuid creation_date DateTime @db.Timestamp(6) change_date DateTime? @db.Timestamp(6) - mfaEnabled Boolean @default(false) - mfaSecret String? @db.VarChar(255) - mfaBackupCodes String[] // Phase 4A: LDAP/SSO fields authProvider String @default("local") @db.VarChar(20) externalId String? @db.VarChar(255) @@ -1255,6 +1252,10 @@ model twoFactor { secret String backupCodes String userId String @db.Uuid + // Plugin-managed: enrolment confirmation and verification lockout + verified Boolean @default(true) + failedVerificationCount Int @default(0) + lockedUntil DateTime? @db.Timestamptz(6) user user @relation(fields: [userId], references: [userid], onDelete: Cascade) @@map("twoFactor") diff --git a/src/app/admin/compliance/ui/ComplianceDashboard.tsx b/src/app/admin/compliance/ui/ComplianceDashboard.tsx index 1395d8bc..136421cb 100644 --- a/src/app/admin/compliance/ui/ComplianceDashboard.tsx +++ b/src/app/admin/compliance/ui/ComplianceDashboard.tsx @@ -11,6 +11,7 @@ interface ComplianceData { totalUsers: number; adminUsers: number; regularUsers: number; + mfaEnabledUsers: number; }; auditCoverage: { totalEntities: number; @@ -35,10 +36,7 @@ interface ComplianceData { } type ComplianceStatus = - | "Compliant" - | "Needs Review" - | "Not Configured" - | "Not Yet Available"; + "Compliant" | "Needs Review" | "Not Configured" | "Not Yet Available"; interface ComplianceCheckItem { id: string; @@ -101,11 +99,12 @@ const complianceChecklist: ComplianceCheckItem[] = [ { id: "user-authentication", label: "User Authentication Controls", - description: - "Multi-factor authentication is not yet implemented. Coming in a future update.", + description: "Users protect their accounts with two-factor authentication.", framework: "HIPAA", - getStatus: () => { - return "Not Yet Available"; + getStatus: (data) => { + const { totalUsers, mfaEnabledUsers } = data.accessControl; + if (totalUsers === 0 || mfaEnabledUsers === 0) return "Not Configured"; + return mfaEnabledUsers === totalUsers ? "Compliant" : "Needs Review"; }, }, { diff --git a/src/app/api/admin/compliance/route.ts b/src/app/api/admin/compliance/route.ts index 7fb6d27b..2d771cd3 100644 --- a/src/app/api/admin/compliance/route.ts +++ b/src/app/api/admin/compliance/route.ts @@ -10,11 +10,21 @@ import { logger } from "@/lib/logger"; */ export async function GET() { try { - await requireApiAdmin(); + const admin = await requireApiAdmin(); + const organizationId = admin.organizationId; + if (!organizationId) { + return NextResponse.json( + { error: "Organization context required" }, + { status: 403 }, + ); + } - const [totalUsers, adminUsers] = await Promise.all([ - prisma.user.count(), - prisma.user.count({ where: { isadmin: true } }), + const [totalUsers, adminUsers, mfaEnabledUsers] = await Promise.all([ + prisma.user.count({ where: { organizationId } }), + prisma.user.count({ where: { organizationId, isadmin: true } }), + prisma.user.count({ + where: { organizationId, twoFactorEnabled: true }, + }), ]); const ninetyDaysAgo = new Date(); @@ -29,20 +39,22 @@ export async function GET() { totalAuditLogs, lastAuditLogEntry, ] = await Promise.all([ - prisma.asset.count(), - prisma.accessories.count(), - prisma.licence.count(), - prisma.consumable.count(), + prisma.asset.count({ where: { organizationId } }), + prisma.accessories.count({ where: { organizationId } }), + prisma.licence.count({ where: { organizationId } }), + prisma.consumable.count({ where: { organizationId } }), prisma.audit_logs.findMany({ where: { + user: { organizationId }, createdAt: { gte: ninetyDaysAgo }, entityId: { not: null }, }, select: { entityId: true }, distinct: ["entityId"], }), - prisma.audit_logs.count(), + prisma.audit_logs.count({ where: { user: { organizationId } } }), prisma.audit_logs.findFirst({ + where: { user: { organizationId } }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }), @@ -57,6 +69,7 @@ export async function GET() { const statusCounts = await prisma.asset.groupBy({ by: ["statustypeid"], + where: { organizationId }, _count: { assetid: true }, }); @@ -100,6 +113,7 @@ export async function GET() { totalUsers, adminUsers, regularUsers: totalUsers - adminUsers, + mfaEnabledUsers, }, auditCoverage: { totalEntities, diff --git a/src/app/api/auth/mfa/disable/route.ts b/src/app/api/auth/mfa/disable/route.ts deleted file mode 100644 index 188b926e..00000000 --- a/src/app/api/auth/mfa/disable/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; -import { requireApiAuth, requireNotDemoMode } from "@/lib/api-auth"; -import { verifyUserPassword } from "@/lib/auth-utils"; -import { createAuditLog, AUDIT_ACTIONS, AUDIT_ENTITIES } from "@/lib/audit-log"; -import { logger } from "@/lib/logger"; - -/** - * POST /api/auth/mfa/disable - * - * Disables MFA for the authenticated user after verifying their password. - * Custom logic: password re-verification via bcrypt, clearing encrypted MFA - * secrets and backup codes, and audit logging — not handled by BetterAuth's - * twoFactor plugin. - * - * BetterAuth equivalent: POST /api/auth/two-factor/disable (partial overlap) - * Note: Kept separate from BetterAuth's twoFactor plugin because this route - * handles encrypted secret clearing and audit logging not covered by the plugin. - */ -export async function POST(req: Request) { - try { - const demoBlock = requireNotDemoMode(); - if (demoBlock) return demoBlock; - - const authUser = await requireApiAuth(); - - if (!authUser.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { password } = await req.json(); - - if (!password || typeof password !== "string") { - return NextResponse.json( - { error: "Password is required to disable MFA" }, - { status: 400 }, - ); - } - - // Check MFA is currently enabled before bothering with password verification - const user = await prisma.user.findUnique({ - where: { userid: authUser.id }, - select: { mfaEnabled: true }, - }); - - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - if (!user.mfaEnabled) { - return NextResponse.json( - { error: "MFA is not enabled" }, - { status: 400 }, - ); - } - - // Verify password against accounts.password (BetterAuth's source of truth) — using - // user.password here was a bug because that column can be stale relative to accounts. - const isValidPassword = await verifyUserPassword(authUser.id, password); - - if (!isValidPassword) { - return NextResponse.json({ error: "Invalid password" }, { status: 403 }); - } - - // Disable MFA - await prisma.user.update({ - where: { userid: authUser.id }, - data: { - mfaEnabled: false, - mfaSecret: null, - mfaBackupCodes: [], - }, - }); - - await createAuditLog({ - userId: authUser.id, - action: AUDIT_ACTIONS.UPDATE, - entity: AUDIT_ENTITIES.USER, - entityId: authUser.id, - details: { reason: "MFA disabled" }, - }); - - return NextResponse.json({ success: true }); - } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - logger.error("POST /api/auth/mfa/disable error", { error }); - return NextResponse.json( - { error: "Failed to disable MFA" }, - { status: 500 }, - ); - } -} - -export const dynamic = "force-dynamic"; diff --git a/src/app/api/auth/mfa/setup/route.ts b/src/app/api/auth/mfa/setup/route.ts deleted file mode 100644 index 5b6eb46f..00000000 --- a/src/app/api/auth/mfa/setup/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextResponse } from "next/server"; -import QRCode from "qrcode"; -import prisma from "@/lib/prisma"; -import { requireApiAuth, requireNotDemoMode } from "@/lib/api-auth"; -import { generateMfaSecret, generateMfaUri } from "@/lib/mfa"; -import { encrypt } from "@/lib/encryption"; -import { logger } from "@/lib/logger"; - -/** - * POST /api/auth/mfa/setup - * - * Custom MFA setup route — generates a TOTP secret, stores it encrypted in the - * database, and returns a QR code. This is kept alongside BetterAuth's twoFactor - * plugin because it provides custom logic: encrypted secret storage, QR code - * generation via the `qrcode` library, and integration with our User model's - * mfaEnabled / mfaSecret fields. - * - * BetterAuth equivalent: POST /api/auth/two-factor/enable - * Note: Kept separate from BetterAuth's twoFactor plugin because this route - * handles encrypted secret storage and QR code generation not covered by the plugin. - */ -export async function POST() { - try { - const demoBlock = requireNotDemoMode(); - if (demoBlock) return demoBlock; - - const authUser = await requireApiAuth(); - - if (!authUser.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const user = await prisma.user.findUnique({ - where: { userid: authUser.id }, - select: { email: true, username: true, mfaEnabled: true }, - }); - - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - if (user.mfaEnabled) { - return NextResponse.json( - { error: "MFA is already enabled" }, - { status: 400 }, - ); - } - - // Generate secret and store it temporarily (not enabled until verified) - const secret = generateMfaSecret(); - const identifier = user.email || user.username || authUser.id; - const uri = generateMfaUri(secret, identifier); - - // Store the secret on the user (mfaEnabled remains false until verification) - // Encrypt the secret before persisting to the database. - await prisma.user.update({ - where: { userid: authUser.id }, - data: { mfaSecret: encrypt(secret) }, - }); - - // Generate QR code as data URI - const qrCodeDataUri = await QRCode.toDataURL(uri); - - return NextResponse.json({ - secret, - qrCode: qrCodeDataUri, - }); - } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - logger.error("POST /api/auth/mfa/setup error", { error }); - return NextResponse.json({ error: "Failed to setup MFA" }, { status: 500 }); - } -} - -export const dynamic = "force-dynamic"; diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts deleted file mode 100644 index 241d813d..00000000 --- a/src/app/api/auth/mfa/verify/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextResponse } from "next/server"; -import prisma from "@/lib/prisma"; -import { requireApiAuth, requireNotDemoMode } from "@/lib/api-auth"; -import { verifyMfaToken, generateBackupCodes } from "@/lib/mfa"; -import { createAuditLog, AUDIT_ACTIONS, AUDIT_ENTITIES } from "@/lib/audit-log"; -import { decrypt, encryptArray } from "@/lib/encryption"; -import { logger } from "@/lib/logger"; - -/** - * POST /api/auth/mfa/verify - * - * Verifies the TOTP code during MFA setup (confirming the user scanned the QR - * code correctly). On success, enables MFA on the user and returns one-time - * backup codes. Custom logic includes encrypted backup code storage and audit - * logging — not handled by BetterAuth's twoFactor plugin. - * - * BetterAuth equivalent: POST /api/auth/two-factor/verify-totp (partial overlap) - * Note: Kept separate from BetterAuth's twoFactor plugin because this route - * handles encrypted backup code storage and audit logging not covered by the plugin. - */ -export async function POST(req: Request) { - try { - const demoBlock = requireNotDemoMode(); - if (demoBlock) return demoBlock; - - const authUser = await requireApiAuth(); - - if (!authUser.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { token } = await req.json(); - - if (!token || typeof token !== "string") { - return NextResponse.json({ error: "Token is required" }, { status: 400 }); - } - - const user = await prisma.user.findUnique({ - where: { userid: authUser.id }, - select: { mfaSecret: true, mfaEnabled: true }, - }); - - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - if (user.mfaEnabled) { - return NextResponse.json( - { error: "MFA is already enabled" }, - { status: 400 }, - ); - } - - if (!user.mfaSecret) { - return NextResponse.json( - { - error: "MFA setup has not been initiated. Please start setup first.", - }, - { status: 400 }, - ); - } - - // Decrypt the secret before TOTP verification (handles legacy unencrypted data too) - const isValid = verifyMfaToken(decrypt(user.mfaSecret), token); - - if (!isValid) { - return NextResponse.json( - { error: "Invalid verification code" }, - { status: 400 }, - ); - } - - // Generate backup codes and encrypt them before persisting - const backupCodes = generateBackupCodes(); - - // Enable MFA on the user — store encrypted backup codes in the database - await prisma.user.update({ - where: { userid: authUser.id }, - data: { - mfaEnabled: true, - mfaBackupCodes: encryptArray(backupCodes), - }, - }); - - await createAuditLog({ - userId: authUser.id, - action: AUDIT_ACTIONS.UPDATE, - entity: AUDIT_ENTITIES.USER, - entityId: authUser.id, - details: { reason: "MFA enabled" }, - }); - - // Return backup codes (shown only once) - return NextResponse.json({ - success: true, - backupCodes, - }); - } catch (error) { - if (error instanceof Error && error.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - logger.error("POST /api/auth/mfa/verify error", { error }); - return NextResponse.json( - { error: "Failed to verify MFA" }, - { status: 500 }, - ); - } -} - -export const dynamic = "force-dynamic"; diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 1751e596..de1e53ab 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -22,13 +22,7 @@ const USER_SORT_FIELDS = ["firstname", "lastname", "email", "creation_date"]; const stripPassword = (user) => { if (!user) return user; - const { - password: _password, - mfaSecret: _mfaSecret, - mfaBackupCodes: _mfaBackupCodes, - ldapDN: _ldapDN, - ...rest - } = user; + const { password: _password, ldapDN: _ldapDN, ...rest } = user; return rest; }; diff --git a/src/app/user/[id]/settings/page.tsx b/src/app/user/[id]/settings/page.tsx index 60dad2b6..450b79ab 100755 --- a/src/app/user/[id]/settings/page.tsx +++ b/src/app/user/[id]/settings/page.tsx @@ -32,7 +32,8 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { firstname: true, lastname: true, email: true, - mfaEnabled: true, + twoFactorEnabled: true, + authProvider: true, }, }), prisma.user_preferences.findUnique({ @@ -93,7 +94,10 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { )}
- +
diff --git a/src/app/user/[id]/settings/ui/MfaSettings.tsx b/src/app/user/[id]/settings/ui/MfaSettings.tsx index 110f8257..ebb795ad 100644 --- a/src/app/user/[id]/settings/ui/MfaSettings.tsx +++ b/src/app/user/[id]/settings/ui/MfaSettings.tsx @@ -1,6 +1,17 @@ "use client"; import { useState } from "react"; +import { QRCodeCanvas } from "qrcode.react"; +import { toast } from "sonner"; +import { + Shield, + ShieldCheck, + ShieldOff, + Copy, + Check, + KeyRound, +} from "lucide-react"; +import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -12,127 +23,179 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { toast } from "sonner"; -import { Shield, ShieldCheck, ShieldOff, Copy, Check } from "lucide-react"; + +const ISSUER = "AssetTracker"; interface MfaSettingsProps { - userId: string; - mfaEnabled: boolean; + enabled: boolean; + /** Local accounts confirm enrolment changes with their password; LDAP/SSO accounts have none. */ + requiresPassword: boolean; } -type SetupStep = "idle" | "qr" | "verify" | "backup" | "disable"; +type Step = + "idle" | "password" | "qr" | "verify" | "backup" | "disable" | "regenerate"; + +type PasswordStep = Extract; + +const PASSWORD_PROMPTS: Record< + PasswordStep, + { title: string; description: string; action: string; destructive?: boolean } +> = { + password: { + title: "Confirm Your Password", + description: + "Enter your password to start setting up two-factor authentication.", + action: "Continue", + }, + disable: { + title: "Disable Two-Factor Authentication", + description: + "This removes the extra security layer from your account. Confirm with your password.", + action: "Disable MFA", + destructive: true, + }, + regenerate: { + title: "New Backup Codes", + description: + "Your existing backup codes stop working once new ones are generated.", + action: "Generate Codes", + }, +}; + +const errorMessage = ( + error: { message?: string } | null | undefined, + fallback: string, +) => error?.message || fallback; + +function secretFromTotpUri(uri: string): string { + try { + return new URL(uri).searchParams.get("secret") ?? ""; + } catch { + return ""; + } +} export default function MfaSettings({ - userId: _userId, - mfaEnabled: initialMfaEnabled, + enabled: initialEnabled, + requiresPassword, }: MfaSettingsProps) { - const [mfaEnabled, setMfaEnabled] = useState(initialMfaEnabled); - const [step, setStep] = useState("idle"); + const [enabled, setEnabled] = useState(initialEnabled); + const [step, setStep] = useState("idle"); const [isLoading, setIsLoading] = useState(false); - const [qrCode, setQrCode] = useState(""); - const [secret, setSecret] = useState(""); - const [verifyToken, setVerifyToken] = useState(""); + const [password, setPassword] = useState(""); + const [totpUri, setTotpUri] = useState(""); + const [code, setCode] = useState(""); const [backupCodes, setBackupCodes] = useState([]); - const [disablePassword, setDisablePassword] = useState(""); const [error, setError] = useState(""); const [copiedIndex, setCopiedIndex] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const resetState = () => { + const reset = () => { setStep("idle"); - setQrCode(""); - setSecret(""); - setVerifyToken(""); + setPassword(""); + setTotpUri(""); + setCode(""); setBackupCodes([]); - setDisablePassword(""); setError(""); setCopiedIndex(null); - setDialogOpen(false); }; - const handleStartSetup = async () => { + // Omitted (not empty) so passwordless accounts are accepted by the server. + const passwordBody = () => ({ password: password || undefined }); + + const startEnrolment = async () => { setIsLoading(true); setError(""); - try { - const res = await fetch("/api/auth/mfa/setup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "Failed to start MFA setup"); - } - setQrCode(data.qrCode); - setSecret(data.secret); - setStep("qr"); - setDialogOpen(true); - } catch (err) { - toast.error("Failed to start MFA setup", { - description: (err as Error).message, - }); - } finally { - setIsLoading(false); + const result = await authClient.twoFactor.enable({ + ...passwordBody(), + issuer: ISSUER, + }); + setIsLoading(false); + if (result.error || !result.data) { + setError(errorMessage(result.error, "Failed to start MFA setup")); + return; } + setTotpUri(result.data.totpURI); + setBackupCodes(result.data.backupCodes); + setStep("qr"); }; - const handleVerifyToken = async () => { + const confirmEnrolment = async () => { setIsLoading(true); setError(""); - try { - const res = await fetch("/api/auth/mfa/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: verifyToken }), - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "Failed to verify token"); - } - setBackupCodes(data.backupCodes); - setMfaEnabled(true); - setStep("backup"); - toast.success("MFA has been enabled successfully"); - } catch (err) { - setError((err as Error).message); - } finally { - setIsLoading(false); + const result = await authClient.twoFactor.verifyTotp({ code: code.trim() }); + setIsLoading(false); + if (result.error) { + setError(errorMessage(result.error, "Invalid verification code")); + return; } + setEnabled(true); + setStep("backup"); + toast.success("Two-factor authentication is enabled"); }; - const handleDisableMfa = async () => { + const disableMfa = async () => { setIsLoading(true); setError(""); - try { - const res = await fetch("/api/auth/mfa/disable", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password: disablePassword }), - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || "Failed to disable MFA"); - } - setMfaEnabled(false); - resetState(); - toast.success("MFA has been disabled"); - } catch (err) { - setError((err as Error).message); - } finally { - setIsLoading(false); + const result = await authClient.twoFactor.disable(passwordBody()); + setIsLoading(false); + if (result.error) { + setError(errorMessage(result.error, "Failed to disable MFA")); + return; + } + setEnabled(false); + reset(); + toast.success("Two-factor authentication is disabled"); + }; + + const regenerateBackupCodes = async () => { + setIsLoading(true); + setError(""); + const result = + await authClient.twoFactor.generateBackupCodes(passwordBody()); + setIsLoading(false); + if (result.error || !result.data) { + setError(errorMessage(result.error, "Failed to generate backup codes")); + return; + } + setBackupCodes(result.data.backupCodes); + setStep("backup"); + }; + + const submitPasswordStep = () => { + if (step === "password") return startEnrolment(); + if (step === "disable") return disableMfa(); + if (step === "regenerate") return regenerateBackupCodes(); + }; + + const openStep = (next: Step) => { + if (!requiresPassword && next === "password") { + void startEnrolment(); + return; } + setStep(next); }; - const copyBackupCode = (code: string, index: number) => { - navigator.clipboard.writeText(code); + const copyBackupCode = (value: string, index: number) => { + void navigator.clipboard.writeText(value); setCopiedIndex(index); setTimeout(() => setCopiedIndex(null), 2000); }; const copyAllBackupCodes = () => { - navigator.clipboard.writeText(backupCodes.join("\n")); + void navigator.clipboard.writeText(backupCodes.join("\n")); toast.success("All backup codes copied to clipboard"); }; + const passwordPrompt = + step === "password" || step === "disable" || step === "regenerate" + ? PASSWORD_PROMPTS[step] + : null; + const errorBox = error ? ( +
+ {error} +
+ ) : null; + return (

@@ -140,10 +203,10 @@ export default function MfaSettings({ Two-Factor Authentication

-
+

- {mfaEnabled ? ( + {enabled ? ( MFA is enabled @@ -156,46 +219,100 @@ export default function MfaSettings({ )}

- {mfaEnabled - ? "Your account is protected with two-factor authentication." + {enabled + ? "Every sign-in asks for a code from your authenticator app." : "Add an extra layer of security to your account by enabling two-factor authentication."}

- {mfaEnabled ? ( + {enabled ? ( +
+ + +
+ ) : ( - ) : ( - )}
- {/* MFA Setup Dialog */} { - if (!open && step !== "backup") { - resetState(); - } - if (!open && step === "backup") { - resetState(); - } + if (!open) reset(); }} > + {passwordPrompt && ( + <> + + {passwordPrompt.title} + + {requiresPassword + ? passwordPrompt.description + : passwordPrompt.description.replace( + /\s*Confirm with your password\.$/, + "", + )} + + +
{ + e.preventDefault(); + void submitPasswordStep(); + }} + > + {requiresPassword && ( +
+ + setPassword(e.target.value)} + autoFocus + /> +
+ )} + {errorBox} + + + + +
+ + )} + {step === "qr" && ( <> @@ -206,27 +323,25 @@ export default function MfaSettings({
- {qrCode && ( - // eslint-disable-next-line @next/next/no-img-element - MFA QR Code - )} +
+ +
- {secret} + {secretFromTotpUri(totpUri)}
- @@ -253,39 +368,44 @@ export default function MfaSettings({ complete setup. -
+
{ + e.preventDefault(); + void confirmEnrolment(); + }} + >
- + setVerifyToken(e.target.value)} + value={code} + onChange={(e) => setCode(e.target.value)} maxLength={6} inputMode="numeric" autoComplete="one-time-code" - autoFocus />
- {error && ( -
- {error} -
- )} -
- - - - + {errorBox} + + + + + )} @@ -300,18 +420,19 @@ export default function MfaSettings({
- {backupCodes.map((code, i) => ( + {backupCodes.map((value, i) => (
- {code} + {value}
- + )}
- - {/* Disable MFA Dialog */} - { - if (!open) resetState(); - }} - > - - - Disable Two-Factor Authentication - - Enter your password to confirm disabling MFA. This will remove the - extra security layer from your account. - - -
-
- - setDisablePassword(e.target.value)} - - autoFocus - /> -
- {error && ( -
- {error} -
- )} -
- - - - -
-
); } diff --git a/src/lib/__tests__/auth-two-factor-audit.test.ts b/src/lib/__tests__/auth-two-factor-audit.test.ts new file mode 100644 index 00000000..3781d6bc --- /dev/null +++ b/src/lib/__tests__/auth-two-factor-audit.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/audit-log", () => ({ + createAuditLog: vi.fn(), + AUDIT_ACTIONS: { LOGIN: "login", UPDATE: "update" }, + AUDIT_ENTITIES: { USER: "user" }, +})); + +import { createAuditLog } from "@/lib/audit-log"; +import { + classifyTwoFactorCall, + auditTwoFactorEvent, +} from "@/lib/auth-two-factor-audit"; + +const call = ( + overrides: Partial[0]>, +) => ({ + path: "/two-factor/verify-totp", + failed: false, + hadSession: false, + userId: "user-1", + ...overrides, +}); + +describe("classifyTwoFactorCall", () => { + it("ignores failed calls and calls without a user", () => { + expect(classifyTwoFactorCall(call({ failed: true }))).toBeNull(); + expect(classifyTwoFactorCall(call({ userId: null }))).toBeNull(); + }); + + it("treats verify-totp with a session as enrolment, without as login", () => { + expect(classifyTwoFactorCall(call({ hadSession: true }))).toEqual({ + kind: "enabled", + userId: "user-1", + }); + expect(classifyTwoFactorCall(call({}))).toEqual({ + kind: "login", + userId: "user-1", + method: "totp", + }); + }); + + it("classifies backup-code logins, disable and regeneration", () => { + expect( + classifyTwoFactorCall(call({ path: "/two-factor/verify-backup-code" })), + ).toEqual({ kind: "login", userId: "user-1", method: "backup_code" }); + expect( + classifyTwoFactorCall( + call({ path: "/two-factor/disable", hadSession: true }), + ), + ).toEqual({ + kind: "disabled", + userId: "user-1", + }); + expect( + classifyTwoFactorCall( + call({ path: "/two-factor/generate-backup-codes", hadSession: true }), + ), + ).toEqual({ kind: "backup_codes_regenerated", userId: "user-1" }); + }); + + it("ignores unrelated two-factor endpoints", () => { + expect( + classifyTwoFactorCall( + call({ path: "/two-factor/enable", hadSession: true }), + ), + ).toBeNull(); + expect( + classifyTwoFactorCall( + call({ path: "/two-factor/get-totp-uri", hadSession: true }), + ), + ).toBeNull(); + }); +}); + +describe("auditTwoFactorEvent", () => { + beforeEach(() => vi.clearAllMocks()); + + it("records logins with the method used", async () => { + await auditTwoFactorEvent({ + kind: "login", + userId: "user-1", + method: "totp", + }); + expect(createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + action: "login", + details: { method: "totp" }, + }), + ); + }); + + it("records enrolment changes as user updates with a reason", async () => { + await auditTwoFactorEvent({ kind: "disabled", userId: "user-1" }); + expect(createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: "update", + entityId: "user-1", + details: { reason: "MFA disabled" }, + }), + ); + }); +}); diff --git a/src/lib/__tests__/encryption.test.ts b/src/lib/__tests__/encryption.test.ts index 17e96453..37faf0f7 100644 --- a/src/lib/__tests__/encryption.test.ts +++ b/src/lib/__tests__/encryption.test.ts @@ -88,14 +88,6 @@ describe("encryption without ENCRYPTION_KEY (passthrough)", () => { const { decrypt } = await freshImport(); expect(decrypt("hello")).toBe("hello"); }); - - it("encryptArray / decryptArray return the original array", async () => { - const { encryptArray, decryptArray } = await freshImport(); - const input = ["a", "b", "c"]; - const encrypted = encryptArray(input); - expect(encrypted).toEqual(input); - expect(decryptArray(encrypted)).toEqual(input); - }); }); // --------------------------------------------------------------------------- @@ -131,19 +123,6 @@ describe("encryption with ENCRYPTION_KEY set", () => { expect(a).not.toBe(b); }); - it("encryptArray / decryptArray round-trip an array of strings", async () => { - const { encryptArray, decryptArray, isEncrypted } = await freshImport(); - const original = ["alpha", "bravo", "charlie"]; - const encrypted = encryptArray(original); - - // Every element should look encrypted - for (const el of encrypted) { - expect(isEncrypted(el)).toBe(true); - } - - expect(decryptArray(encrypted)).toEqual(original); - }); - it("decrypt passes through a non-encrypted string without error", async () => { const { decrypt } = await freshImport(); expect(decrypt("plain-legacy-value")).toBe("plain-legacy-value"); diff --git a/src/lib/__tests__/mfa.test.ts b/src/lib/__tests__/mfa.test.ts deleted file mode 100644 index c87d501a..00000000 --- a/src/lib/__tests__/mfa.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - generateMfaSecret, - generateMfaUri, - verifyMfaToken, - generateBackupCodes, - verifyBackupCode, -} from "@/lib/mfa"; - -describe("generateMfaSecret", () => { - it("returns a non-empty string", () => { - const secret = generateMfaSecret(); - expect(typeof secret).toBe("string"); - expect(secret.length).toBeGreaterThan(0); - }); - - it("generates unique secrets on successive calls", () => { - const secrets = new Set( - Array.from({ length: 20 }, () => generateMfaSecret()), - ); - expect(secrets.size).toBe(20); - }); -}); - -describe("generateMfaUri", () => { - it("returns a valid otpauth:// URI", () => { - const secret = generateMfaSecret(); - const uri = generateMfaUri(secret, "user@example.com"); - expect(uri).toMatch(/^otpauth:\/\/totp\//); - }); - - it("includes the email in the URI", () => { - const secret = generateMfaSecret(); - const uri = generateMfaUri(secret, "alice@example.com"); - expect(uri).toContain("alice"); - }); - - it("includes the issuer parameter as AssetTracker", () => { - const secret = generateMfaSecret(); - const uri = generateMfaUri(secret, "user@example.com"); - expect(uri).toContain("issuer=AssetTracker"); - }); - - it("includes the secret parameter", () => { - const secret = generateMfaSecret(); - const uri = generateMfaUri(secret, "user@example.com"); - expect(uri).toContain(`secret=${secret}`); - }); -}); - -describe("verifyMfaToken", () => { - it("accepts a valid TOTP token generated from the same secret", async () => { - const { generateSync, generateSecret } = await import("otplib"); - const secret = generateSecret(); - const token = generateSync({ secret }); - const result = verifyMfaToken(secret, token); - expect(result).toBe(true); - }); - - it("rejects an invalid token", () => { - const secret = generateMfaSecret(); - expect(verifyMfaToken(secret, "000000")).toBe(false); - }); - - it("throws on empty token", () => { - const secret = generateMfaSecret(); - expect(() => verifyMfaToken(secret, "")).toThrow(); - }); -}); - -describe("generateBackupCodes", () => { - it("generates 8 codes by default", () => { - const codes = generateBackupCodes(); - expect(codes).toHaveLength(8); - }); - - it("generates a custom number of codes", () => { - expect(generateBackupCodes(4)).toHaveLength(4); - expect(generateBackupCodes(12)).toHaveLength(12); - }); - - it("generates codes in uppercase hex format (8 chars each)", () => { - const codes = generateBackupCodes(); - for (const code of codes) { - expect(code).toMatch(/^[0-9A-F]{8}$/); - } - }); - - it("generates unique codes within a single batch", () => { - const codes = generateBackupCodes(50); - const unique = new Set(codes); - expect(unique.size).toBe(50); - }); -}); - -describe("verifyBackupCode", () => { - it("validates a correct backup code", () => { - const codes = generateBackupCodes(); - const target = codes[0]; - const result = verifyBackupCode(codes, target); - expect(result.valid).toBe(true); - expect(result.remainingCodes).not.toContain(target); - expect(result.remainingCodes).toHaveLength(codes.length - 1); - }); - - it("rejects an invalid backup code", () => { - const codes = generateBackupCodes(); - const result = verifyBackupCode(codes, "ZZZZZZZZ"); - expect(result.valid).toBe(false); - expect(result.remainingCodes).toEqual(codes); - }); - - it("is case-insensitive", () => { - const codes = generateBackupCodes(); - const target = codes[2]; - const result = verifyBackupCode(codes, target.toLowerCase()); - expect(result.valid).toBe(true); - }); - - it("strips dashes from input before matching", () => { - const codes = generateBackupCodes(); - const target = codes[0]; - const dashedCode = target.slice(0, 4) + "-" + target.slice(4); - const result = verifyBackupCode(codes, dashedCode); - expect(result.valid).toBe(true); - }); - - it("handles empty codes array", () => { - const result = verifyBackupCode([], "ABCDEF12"); - expect(result.valid).toBe(false); - expect(result.remainingCodes).toEqual([]); - }); - - it("removes only the matched code, leaving others intact", () => { - const codes = ["AAAA1111", "BBBB2222", "CCCC3333"]; - const result = verifyBackupCode(codes, "BBBB2222"); - expect(result.valid).toBe(true); - expect(result.remainingCodes).toEqual(["AAAA1111", "CCCC3333"]); - }); -}); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 124428a1..75dfbb3a 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -30,7 +30,6 @@ export interface SessionUser { departmentId?: string; authProvider?: string; isActive?: boolean; - mfaEnabled?: boolean; } export const authClient = createAuthClient({ diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index b196f006..f551ae9e 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -13,7 +13,7 @@ import { microsoftEntraId, } from "better-auth/plugins/generic-oauth"; import { nextCookies } from "better-auth/next-js"; -import { createAuthMiddleware } from "better-auth/api"; +import { createAuthMiddleware, APIError } from "better-auth/api"; import prisma from "@/lib/prisma"; import bcrypt from "bcryptjs"; import { logger } from "@/lib/logger"; @@ -23,6 +23,10 @@ import { recordSuccessfulLogin, } from "@/lib/account-lockout"; import { createAuditLog, AUDIT_ACTIONS, AUDIT_ENTITIES } from "@/lib/audit-log"; +import { + classifyTwoFactorCall, + auditTwoFactorEvent, +} from "@/lib/auth-two-factor-audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { parseDeviceName, parseBrowser } from "@/lib/session-tracking"; import { @@ -112,6 +116,57 @@ const vercelOrigins = [ .filter(Boolean) .map((h) => `https://${h}`); +type AuthHookContext = Parameters< + Parameters[0] +>[0]; + +/** Stamp device info on the session that was just created for a login. */ +async function enrichLatestSession(userId: string, userAgent: string | null) { + const deviceName = `${parseDeviceName(userAgent)} - ${parseBrowser(userAgent)}`; + try { + const latestSession = await prisma.sessions.findFirst({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + if (latestSession) { + await prisma.sessions.update({ + where: { id: latestSession.id }, + data: { deviceName, lastActive: new Date(), isCurrent: true }, + }); + } + } catch { + // Non-critical — don't break login + } +} + +/** + * Audit two-factor enrolment, removal and logins. The plugin's verify endpoints + * serve both enrolment (request carries a session cookie) and login (request + * carries only the two-factor cookie), so the cookie decides which it was. + */ +async function handleTwoFactorAfterHook(ctx: AuthHookContext) { + const returned = ctx.context.returned as + { user?: { id?: string } } | APIError | undefined; + const failed = returned instanceof APIError; + const returnedUserId = !failed ? returned?.user?.id : undefined; + const event = classifyTwoFactorCall({ + path: ctx.path, + failed, + hadSession: Boolean( + ctx.getCookie(ctx.context.authCookies.sessionToken.name), + ), + userId: ctx.context.session?.user?.id ?? returnedUserId ?? null, + }); + if (!event) return; + await auditTwoFactorEvent(event); + if (event.kind === "login") { + await enrichLatestSession( + event.userId, + ctx.headers?.get("user-agent") || null, + ); + } +} + export const auth = betterAuth({ appName: "AssetTracker", baseURL: process.env.BETTER_AUTH_URL || vercelOrigins[0], @@ -190,11 +245,6 @@ export const auth = betterAuth({ required: false, defaultValue: true, }, - mfaEnabled: { - type: "boolean", - required: false, - defaultValue: false, - }, password: { type: "string", required: false, @@ -246,6 +296,10 @@ export const auth = betterAuth({ plugins: [ twoFactor({ issuer: "AssetTracker", + // LDAP/SSO users have no credential account; local users still confirm + // enrolment changes with their password. + allowPasswordless: true, + backupCodeOptions: { storeBackupCodes: "encrypted" }, }), ...(process.env.MICROSOFT_CLIENT_ID && process.env.MICROSOFT_CLIENT_SECRET ? [ @@ -339,8 +393,7 @@ export const auth = betterAuth({ if (ctx.path !== "/sign-in/email") return; const body = ctx.body as - | { email?: string; password?: string } - | undefined; + { email?: string; password?: string } | undefined; if (!body?.email) return; const rawIdentifier = body.email; @@ -485,12 +538,25 @@ export const auth = betterAuth({ } }), after: createAuthMiddleware(async (ctx) => { + if (ctx.path.startsWith("/two-factor/")) { + await handleTwoFactorAfterHook(ctx); + return; + } if (ctx.path !== "/sign-in/email") return; const body = ctx.body as { email?: string } | undefined; if (!body?.email) return; const identifier = body.email; + // A 2FA user has passed the password step; the login completes (and is + // audited) in the /two-factor/verify-* after-hook. + const returned = ctx.context.returned as + { twoFactorRedirect?: boolean } | undefined; + if (returned?.twoFactorRedirect) { + await recordSuccessfulLogin(identifier); + return; + } + // Check if login succeeded (session cookie was set) const setCookie = ctx.context.responseHeaders?.get("set-cookie"); if (setCookie) { @@ -515,32 +581,12 @@ export const auth = betterAuth({ }, }); - // Enrich the most recent session with device info const ip = ctx.headers?.get("x-forwarded-for")?.split(",")[0]?.trim() || ctx.headers?.get("x-real-ip") || null; const userAgent = ctx.headers?.get("user-agent") || null; - const deviceName = `${parseDeviceName(userAgent)} - ${parseBrowser(userAgent)}`; - - try { - const latestSession = await prisma.sessions.findFirst({ - where: { userId: user.userid }, - orderBy: { createdAt: "desc" }, - }); - if (latestSession) { - await prisma.sessions.update({ - where: { id: latestSession.id }, - data: { - deviceName, - lastActive: new Date(), - isCurrent: true, - }, - }); - } - } catch { - // Non-critical — don't break login - } + await enrichLatestSession(user.userid, userAgent); try { recordLoginAttempt( diff --git a/src/lib/auth-two-factor-audit.ts b/src/lib/auth-two-factor-audit.ts new file mode 100644 index 00000000..53bbd964 --- /dev/null +++ b/src/lib/auth-two-factor-audit.ts @@ -0,0 +1,78 @@ +/** + * Audit trail for BetterAuth's two-factor endpoints. + * + * Enrolment, removal and backup-code regeneration are recorded as USER updates; + * a login completed with a TOTP or backup code is recorded as a LOGIN, mirroring + * the credentials login audit in `auth-server.ts`. + */ + +import { createAuditLog, AUDIT_ACTIONS, AUDIT_ENTITIES } from "@/lib/audit-log"; + +export interface TwoFactorCall { + path: string; + /** The endpoint threw; nothing happened, nothing to audit. */ + failed: boolean; + /** A session cookie accompanied the request: enrolment or management, not a login. */ + hadSession: boolean; + userId: string | null; +} + +export type TwoFactorEvent = + | { + kind: "enabled" | "disabled" | "backup_codes_regenerated"; + userId: string; + } + | { kind: "login"; userId: string; method: "totp" | "backup_code" }; + +const LOGIN_METHODS: Record = { + "/two-factor/verify-totp": "totp", + "/two-factor/verify-backup-code": "backup_code", +}; + +const UPDATE_REASONS: Record< + Exclude, + string +> = { + enabled: "MFA enabled", + disabled: "MFA disabled", + backup_codes_regenerated: "MFA backup codes regenerated", +}; + +export function classifyTwoFactorCall( + call: TwoFactorCall, +): TwoFactorEvent | null { + if (call.failed || !call.userId) return null; + const { path, userId } = call; + if (path === "/two-factor/disable") return { kind: "disabled", userId }; + if (path === "/two-factor/generate-backup-codes") { + return { kind: "backup_codes_regenerated", userId }; + } + const method = LOGIN_METHODS[path]; + if (!method) return null; + // verify-totp doubles as the enrolment confirmation when called with a session. + if (call.hadSession) + return method === "totp" ? { kind: "enabled", userId } : null; + return { kind: "login", userId, method }; +} + +export async function auditTwoFactorEvent( + event: TwoFactorEvent, +): Promise { + if (event.kind === "login") { + await createAuditLog({ + userId: event.userId, + action: AUDIT_ACTIONS.LOGIN, + entity: AUDIT_ENTITIES.USER, + entityId: event.userId, + details: { method: event.method }, + }); + return; + } + await createAuditLog({ + userId: event.userId, + action: AUDIT_ACTIONS.UPDATE, + entity: AUDIT_ENTITIES.USER, + entityId: event.userId, + details: { reason: UPDATE_REASONS[event.kind] }, + }); +} diff --git a/src/lib/encryption.ts b/src/lib/encryption.ts index d68b3619..6f84ca8b 100644 --- a/src/lib/encryption.ts +++ b/src/lib/encryption.ts @@ -146,23 +146,6 @@ export function isEncrypted(value: string): boolean { ); } -/** - * Encrypt an array of strings (e.g. backup codes). - * Each element is encrypted individually so the array structure is preserved. - * If ENCRYPTION_KEY is not set, returns the array unchanged. - */ -export function encryptArray(values: string[]): string[] { - return values.map(encrypt); -} - -/** - * Decrypt an array of strings previously encrypted with `encryptArray()`. - * Handles mixed arrays where some elements may be unencrypted (legacy data). - */ -export function decryptArray(values: string[]): string[] { - return values.map(decrypt); -} - /** * Produce a one-way SHA-256 hash of a value. * diff --git a/src/lib/mfa.ts b/src/lib/mfa.ts deleted file mode 100644 index 09df4cdd..00000000 --- a/src/lib/mfa.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as otplib from "otplib"; -import crypto from "crypto"; - -export function generateMfaSecret(): string { - return otplib.generateSecret(); -} - -export function generateMfaUri(secret: string, email: string): string { - return otplib.generateURI({ - issuer: "AssetTracker", - label: email, - secret, - strategy: "totp", - }); -} - -export function verifyMfaToken(secret: string, token: string): boolean { - const result = otplib.verifySync({ token, secret }); - return result.valid; -} - -export function generateBackupCodes(count = 8): string[] { - return Array.from({ length: count }, () => - crypto.randomBytes(4).toString("hex").toUpperCase(), - ); -} - -export function verifyBackupCode( - codes: string[], - code: string, -): { valid: boolean; remainingCodes: string[] } { - const normalizedCode = code.toUpperCase().replace(/-/g, ""); - const index = codes.indexOf(normalizedCode); - if (index === -1) return { valid: false, remainingCodes: codes }; - const remainingCodes = [...codes]; - remainingCodes.splice(index, 1); - return { valid: true, remainingCodes }; -} diff --git a/vercel.json b/vercel.json index e1d5a199..077a860d 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "node prisma/set-schema.mjs && prisma generate && prisma migrate deploy && next build", + "buildCommand": "node prisma/set-schema.mjs && prisma generate && if [ \"$VERCEL_ENV\" = \"production\" ]; then prisma migrate deploy; fi && next build", "installCommand": "bun install", "crons": [ {