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 }> }) { )}
- {mfaEnabled ? (
+ {enabled ? (
- {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."}