diff --git a/.env.example b/.env.example index 84f1ba3..e07856f 100755 --- a/.env.example +++ b/.env.example @@ -17,6 +17,9 @@ PASSWORD_RESET_TTL_SECONDS=3600 AUTH_REQUIRE_EMAIL_VERIFICATION=false # Development-only: return reset tokens in API responses instead of email. AUTH_SHOW_RESET_TOKEN=false +# Enables optional TOTP enrollment for accounts. Users without enrollment can +# continue to sign in normally. +AUTH_TOTP_ENABLED=true SCHULOS_PORT=3000 # Local filesystem storage. Docker Compose maps these paths to a persistent volume. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c6c608..20bbc82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: run: pnpm lint - name: Unit tests run: pnpm test + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Browser smoke tests + run: pnpm test:e2e - name: Typecheck and production build run: pnpm typecheck - name: Audit production dependencies diff --git a/TODO.md b/TODO.md index a50aa46..49439a3 100644 --- a/TODO.md +++ b/TODO.md @@ -73,10 +73,10 @@ Use these sections only. Do not add a new sidebar entry if a workflow belongs in - [x] Add `docs/DATA_MODEL.md`, `docs/CONTRIBUTING.md`, and `docs/ROADMAP.md`. - [x] Add `docs/ARCHITECTURE.md` and `docs/SELF_HOSTING.md`. - [x] Move the root PRD to `docs/PRD.md` and keep one canonical index in `docs/README.md`. -- [ ] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests. Prisma migration deploy/drift, lint, typecheck, unit, and dependency audit checks are implemented; Playwright smoke tests wait on the test environment. +- [x] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests. - [ ] Add dependency audit and secret scanning in CI. - [x] Replace `bcryptjs` password hashing with Argon2id and document a safe rehash-on-login migration path. Legacy bcrypt hashes verify once and rehash on successful login. -- [ ] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes. Reset tokens, self-hosted verification bypass, and session configuration are implemented; TOTP/recovery codes remain. +- [x] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes. - [x] Implement central origin-based CSRF protection for cookie-authenticated mutation requests below `/api/`. - [x] Add real local file storage abstraction for uploads, exports, and backups with path traversal protection. S3-compatible adapter remains a future implementation behind the same interface. - [x] Add SQLite WAL configuration and verified online backup scripts. Restore remains a deliberate maintenance-environment workflow and requires a smoke test before production cutover. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 25a6d00..7aa20c6 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -85,8 +85,16 @@ success message whether or not an account exists. In development only, or when By default, self-hosted registrations are marked verified immediately. Set `AUTH_REQUIRE_EMAIL_VERIFICATION=true` only after integrating a reviewed email -verification delivery flow. TOTP and recovery-code enrollment remain a planned -security enhancement. +verification delivery flow. + +## Two-Factor Authentication + +Set `AUTH_TOTP_ENABLED=true` to allow authenticated users to enroll an RFC +compatible authenticator. Enrollment verifies one current six-digit code before +the secret is saved and returns ten single-use recovery codes once. Store these +codes offline; raw recovery codes are never stored. During login, an enrolled +user must provide either a current authenticator code or one unused recovery +code. ## Compliance Notice diff --git a/e2e-tests/smoke.spec.ts b/e2e-tests/smoke.spec.ts new file mode 100644 index 0000000..0535248 --- /dev/null +++ b/e2e-tests/smoke.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test('loads the signed-out application shell', async ({ page }) => { + await page.goto('/'); + + await expect(page).toHaveTitle(/SchulOS/i); + await expect(page.getByText('SchulOS', { exact: true }).first()).toBeVisible(); + await expect(page.getByLabel(/email/i).first()).toBeVisible(); + await expect(page.getByLabel(/password/i).first()).toBeVisible(); +}); + +test('rejects cross-origin API mutations', async ({ request }) => { + const response = await request.post('/api/auth', { + headers: { origin: 'https://attacker.example' }, + data: { action: 'logout' }, + }); + + expect(response.status()).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: 'Invalid request origin' }); +}); diff --git a/package.json b/package.json index 4ceb340..d2654f2 100755 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "db:wal": "node scripts/sqlite-wal.mjs", "db:backup": "node scripts/sqlite-backup.mjs", "test": "bun test", + "test:e2e": "playwright test --config playwright-dyad.config.ts", "typecheck": "next build --webpack && tsc --noEmit --pretty false" }, "dependencies": { @@ -88,6 +89,7 @@ "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", + "otpauth": "^9.5.1", "prisma": "^6.11.1", "react": "^19.0.0", "react-day-picker": "^9.8.0", diff --git a/playwright-dyad.config.ts b/playwright-dyad.config.ts index 327e7b6..56cda24 100644 --- a/playwright-dyad.config.ts +++ b/playwright-dyad.config.ts @@ -1,20 +1,24 @@ -import { defineConfig } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test"; -// Generated by Dyad. The dev server is started separately by Dyad's -// preview, so we point baseURL at the already-running proxy URL (passed via -// env) rather than using Playwright's `webServer` (which would double-start -// the app). -// // Uses your installed Google Chrome (no extra browser download). export default defineConfig({ testDir: "./e2e-tests", - // Run serially against the single dev server. workers: 1, fullyParallel: false, - reporter: [["json", { outputFile: "test-results/results.json" }]], + reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]], use: { - baseURL: process.env.DYAD_TEST_BASE_URL || "http://localhost:32100", - channel: "chrome", + baseURL: process.env.DYAD_TEST_BASE_URL || process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:32104", screenshot: "only-on-failure", trace: "retain-on-failure", }, + webServer: process.env.DYAD_TEST_BASE_URL + ? undefined + : { + command: "pnpm dev --hostname 127.0.0.1 --port 32104", + url: "http://127.0.0.1:32104", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16858fe..13343ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + otpauth: + specifier: ^9.5.1 + version: 9.5.1 prisma: specifier: ^6.11.1 version: 6.19.3(typescript@5.9.3) @@ -1023,6 +1026,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3916,6 +3923,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + otpauth@9.5.1: + resolution: {integrity: sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==} + outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} @@ -5696,6 +5706,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.12': optional: true + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8943,6 +8955,10 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + otpauth@9.5.1: + dependencies: + '@noble/hashes': 2.2.0 + outvariant@1.4.0: {} own-keys@1.0.2: diff --git a/prisma/migrations/20260802140000_add_communication_escalation/migration.sql b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql index b75e546..f4630bd 100644 --- a/prisma/migrations/20260802140000_add_communication_escalation/migration.sql +++ b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql @@ -25,9 +25,9 @@ CREATE TABLE "new_communication_rooms" ( "closeReason" TEXT, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, - CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "communication_rooms_classGroupId_fkey" FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); INSERT INTO "new_communication_rooms" ("id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt" FROM "communication_rooms"; @@ -37,3 +37,7 @@ PRAGMA foreign_keys=ON; CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId"); CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt"); +CREATE INDEX "communication_rooms_schoolId_idx" ON "communication_rooms"("schoolId"); +CREATE INDEX "communication_rooms_studentId_idx" ON "communication_rooms"("studentId"); +CREATE INDEX "communication_rooms_teacherId_idx" ON "communication_rooms"("teacherId"); +CREATE INDEX "communication_rooms_status_idx" ON "communication_rooms"("status"); diff --git a/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql index 8b792a0..a3bf9ce 100644 --- a/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql +++ b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql @@ -33,9 +33,9 @@ CREATE TABLE "new_illness_reports" ( "calendarEventId" TEXT, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, - CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "illness_reports_parentApprovedBy_fkey" FOREIGN KEY ("parentApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT "illness_reports_adminApprovedBy_fkey" FOREIGN KEY ("adminApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT "illness_reports_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE @@ -47,3 +47,8 @@ PRAGMA foreign_keys=ON; CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId"); CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus"); +CREATE INDEX "illness_reports_schoolId_idx" ON "illness_reports"("schoolId"); +CREATE INDEX "illness_reports_studentId_idx" ON "illness_reports"("studentId"); +CREATE INDEX "illness_reports_reportedBy_idx" ON "illness_reports"("reportedBy"); +CREATE INDEX "illness_reports_parentApprovalStatus_idx" ON "illness_reports"("parentApprovalStatus"); +CREATE INDEX "illness_reports_status_idx" ON "illness_reports"("status"); diff --git a/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql index 43d4271..4d32f3b 100644 --- a/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql +++ b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql @@ -24,8 +24,8 @@ CREATE TABLE "new_teacher_absences" ( "calendarEventId" TEXT, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, - CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "teacher_absences_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT "teacher_absences_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); @@ -36,3 +36,6 @@ PRAGMA foreign_keys=ON; CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId"); CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus"); +CREATE INDEX "teacher_absences_schoolId_idx" ON "teacher_absences"("schoolId"); +CREATE INDEX "teacher_absences_teacherId_idx" ON "teacher_absences"("teacherId"); +CREATE INDEX "teacher_absences_startDate_idx" ON "teacher_absences"("startDate"); diff --git a/prisma/migrations/20260802180000_add_two_factor_recovery_codes/migration.sql b/prisma/migrations/20260802180000_add_two_factor_recovery_codes/migration.sql new file mode 100644 index 0000000..b1bb177 --- /dev/null +++ b/prisma/migrations/20260802180000_add_two_factor_recovery_codes/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "TwoFactorRecoveryCode" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "TwoFactorRecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "TwoFactorRecoveryCode_codeHash_key" ON "TwoFactorRecoveryCode"("codeHash"); +CREATE INDEX "TwoFactorRecoveryCode_userId_idx" ON "TwoFactorRecoveryCode"("userId"); diff --git a/prisma/prisma/db/custom.db b/prisma/prisma/db/custom.db index 9908ff4..07f51fb 100644 Binary files a/prisma/prisma/db/custom.db and b/prisma/prisma/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e9de13..2e4d50e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -245,6 +245,7 @@ model User { dashboardWidgets DashboardWidget[] dashboardLayouts DashboardLayout[] passwordResetTokens PasswordResetToken[] + twoFactorRecoveryCodes TwoFactorRecoveryCode[] } model PasswordResetToken { @@ -261,6 +262,18 @@ model PasswordResetToken { @@index([expiresAt]) } +model TwoFactorRecoveryCode { + id String @id @default(cuid()) + userId String + codeHash String @unique + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + model SchoolYear { id String @id @default(cuid()) schoolId String diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 01bb865..34c382f 100755 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/auth'; import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit'; import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset'; +import { createRecoveryCodes, createTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from '@/lib/two-factor'; const loginSchema = z.object({ email: z.string().email(), @@ -37,11 +38,56 @@ const resetPasswordSchema = z.object({ password: z.string().min(12), }); +const twoFactorTokenSchema = z.object({ + token: z.string().min(6).max(20), +}); + export const POST = withRateLimit(async function POST(request: Request) { try { const body = await request.json(); const { action } = body; + if (action === 'setup-two-factor') { + const session = await getSession(); + if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (process.env.AUTH_TOTP_ENABLED !== 'true') return NextResponse.json({ error: 'Two-factor authentication is disabled' }, { status: 403 }); + const secret = createTotpSecret(); + return NextResponse.json({ secret, otpauthUrl: totpUri(session.user.email, secret) }); + } + + if (action === 'enable-two-factor') { + const session = await getSession(); + const parsed = twoFactorTokenSchema.extend({ secret: z.string().min(10) }).safeParse(body); + if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (process.env.AUTH_TOTP_ENABLED !== 'true') return NextResponse.json({ error: 'Two-factor authentication is disabled' }, { status: 403 }); + if (!parsed.success || !verifyTotp(parsed.data.secret, parsed.data.token)) { + return NextResponse.json({ error: 'Invalid authenticator code' }, { status: 400 }); + } + const recoveryCodes = createRecoveryCodes(); + await db.$transaction([ + db.user.update({ where: { id: session.userId }, data: { twoFactorSecret: parsed.data.secret } }), + db.twoFactorRecoveryCode.deleteMany({ where: { userId: session.userId } }), + db.twoFactorRecoveryCode.createMany({ + data: recoveryCodes.map((code) => ({ userId: session.userId, codeHash: hashRecoveryCode(code) })), + }), + ]); + return NextResponse.json({ recoveryCodes }); + } + + if (action === 'disable-two-factor') { + const session = await getSession(); + const parsed = twoFactorTokenSchema.safeParse(body); + if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!parsed.success || !session.user.twoFactorSecret || !verifyTotp(session.user.twoFactorSecret, parsed.data.token)) { + return NextResponse.json({ error: 'Invalid authenticator code' }, { status: 400 }); + } + await db.$transaction([ + db.user.update({ where: { id: session.userId }, data: { twoFactorSecret: null } }), + db.twoFactorRecoveryCode.deleteMany({ where: { userId: session.userId } }), + ]); + return NextResponse.json({ message: 'Two-factor authentication disabled' }); + } + if (action === 'request-password-reset') { const parsed = requestPasswordResetSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 }); @@ -129,6 +175,24 @@ export const POST = withRateLimit(async function POST(request: Request) { }); } + if (user.twoFactorSecret) { + const parsedTwoFactor = twoFactorTokenSchema.safeParse(body); + if (!parsedTwoFactor.success) { + return NextResponse.json({ error: 'Two-factor code required', twoFactorRequired: true }, { status: 401 }); + } + const providedCode = parsedTwoFactor.data.token; + const validTotp = verifyTotp(user.twoFactorSecret, providedCode); + const recoveryCode = validTotp ? null : await db.twoFactorRecoveryCode.findFirst({ + where: { userId: user.id, codeHash: hashRecoveryCode(providedCode), usedAt: null }, + }); + if (!validTotp && !recoveryCode) { + return NextResponse.json({ error: 'Invalid two-factor code', twoFactorRequired: true }, { status: 401 }); + } + if (recoveryCode) { + await db.twoFactorRecoveryCode.update({ where: { id: recoveryCode.id }, data: { usedAt: new Date() } }); + } + } + const secure = isSecureRequest(request); await createSession(user.id, secure); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 9996424..362d532 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -76,6 +76,7 @@ const sessionUserSelect = Prisma.validator()({ schoolId: true, locale: true, isDemo: true, + twoFactorSecret: true, createdAt: true, updatedAt: true, }); diff --git a/src/lib/two-factor.test.ts b/src/lib/two-factor.test.ts new file mode 100644 index 0000000..f589073 --- /dev/null +++ b/src/lib/two-factor.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { createRecoveryCodes, createTotpSecret, hashRecoveryCode, totpUri, verifyTotp } from './two-factor'; + +describe('two factor authentication', () => { + test('creates TOTP enrollment data', () => { + const secret = createTotpSecret(); + expect(secret.length).toBeGreaterThan(10); + expect(totpUri('teacher@school.example', secret)).toStartWith('otpauth://totp/'); + }); + + test('creates unique hashed recovery codes', () => { + const codes = createRecoveryCodes(); + expect(codes).toHaveLength(10); + expect(new Set(codes).size).toBe(10); + expect(hashRecoveryCode(codes[0])).not.toBe(codes[0]); + }); + + test('rejects malformed TOTP values', () => { + expect(verifyTotp(createTotpSecret(), 'invalid')).toBeFalse(); + }); +}); diff --git a/src/lib/two-factor.ts b/src/lib/two-factor.ts new file mode 100644 index 0000000..c3fdb94 --- /dev/null +++ b/src/lib/two-factor.ts @@ -0,0 +1,26 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { TOTP } from 'otpauth'; + +const RECOVERY_CODE_COUNT = 10; + +export function createTotpSecret(): string { + return new TOTP({ issuer: 'SchoolOS', label: 'SchoolOS account' }).secret.base32; +} + +export function totpUri(email: string, secret: string): string { + return new TOTP({ issuer: 'SchoolOS', label: email, secret }).toString(); +} + +export function verifyTotp(secret: string, token: string): boolean { + const normalizedToken = token.replace(/\s/g, ''); + if (!/^\d{6}$/.test(normalizedToken)) return false; + return new TOTP({ issuer: 'SchoolOS', label: 'SchoolOS account', secret }).validate({ token: normalizedToken, window: 1 }) !== null; +} + +export function hashRecoveryCode(code: string): string { + return createHash('sha256').update(code).digest('hex'); +} + +export function createRecoveryCodes(): string[] { + return Array.from({ length: RECOVERY_CODE_COUNT }, () => randomBytes(5).toString('hex').toUpperCase()); +}