Add 4 files, update 14 files
This commit is contained in:
1 parent
278293b397
commit
2913b0332f
18 files changed
+227
-22
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
+14
-10
@@ -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"] } },
|
||||
],
|
||||
});
|
||||
Generated
+16
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ const sessionUserSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
schoolId: true,
|
||||
locale: true,
|
||||
isDemo: true,
|
||||
twoFactorSecret: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user