Add 21 files, update 16 files, rename 1 file

This commit is contained in:
[dyad] committed 2026-08-02 17:26:49 +02:00
1 parent b9e4ed4dfc
commit 278293b397
39 files changed
+2434 -61

No files matched your search

+68
View File
@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
import {
hashPassword,
verifyPassword,
needsPasswordRehash,
createSession,
getSession,
clearSession,
@@ -12,6 +13,7 @@ import {
getSessionCookieOptions,
} from '@/lib/auth';
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset';
const loginSchema = z.object({
email: z.string().email(),
@@ -26,11 +28,69 @@ const registerSchema = z.object({
schoolId: z.string().optional(),
});
const requestPasswordResetSchema = z.object({
email: z.string().email(),
});
const resetPasswordSchema = z.object({
token: z.string().min(40),
password: z.string().min(12),
});
export const POST = withRateLimit(async function POST(request: Request) {
try {
const body = await request.json();
const { action } = body;
if (action === 'request-password-reset') {
const parsed = requestPasswordResetSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
const key = `password-reset:${getRateLimitKey(request)}`;
const result = checkRateLimit(key, 5, 60 * 60 * 1000);
if (!result.allowed) {
const response = createRateLimitResponse(result);
return NextResponse.json(response.body, { status: response.status, headers: response.headers });
}
const user = await db.user.findFirst({ where: { email: parsed.data.email, deletedAt: null } });
let developmentToken: string | undefined;
if (user) {
await db.passwordResetToken.deleteMany({ where: { userId: user.id, usedAt: null } });
const { token, tokenHash } = createPasswordResetToken();
await db.passwordResetToken.create({
data: { userId: user.id, tokenHash, expiresAt: passwordResetExpiry() },
});
if (process.env.NODE_ENV !== 'production' || process.env.AUTH_SHOW_RESET_TOKEN === 'true') {
developmentToken = token;
}
}
return NextResponse.json({
message: 'If the account exists, reset instructions have been prepared.',
...(developmentToken ? { developmentToken } : {}),
});
}
if (action === 'reset-password') {
const parsed = resetPasswordSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
const reset = await db.passwordResetToken.findFirst({
where: { tokenHash: hashResetToken(parsed.data.token), usedAt: null, expiresAt: { gt: new Date() } },
include: { user: true },
});
if (!reset || reset.user.deletedAt) {
return NextResponse.json({ error: 'Invalid or expired reset token' }, { status: 400 });
}
await db.$transaction([
db.user.update({ where: { id: reset.userId }, data: { passwordHash: await hashPassword(parsed.data.password) } }),
db.passwordResetToken.update({ where: { id: reset.id }, data: { usedAt: new Date() } }),
]);
return NextResponse.json({ message: 'Password reset complete' });
}
// ── Login ──
if (action === 'login') {
const parsed = loginSchema.safeParse(body);
@@ -62,6 +122,13 @@ export const POST = withRateLimit(async function POST(request: Request) {
);
}
if (needsPasswordRehash(user.passwordHash)) {
await db.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(password) },
});
}
const secure = isSecureRequest(request);
await createSession(user.id, secure);
@@ -112,6 +179,7 @@ export const POST = withRateLimit(async function POST(request: Request) {
lastName,
schoolId: schoolId ?? null,
role: 'TEACHER',
emailVerifiedAt: process.env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true' ? null : new Date(),
},
});
+13 -4
View File
@@ -2,6 +2,7 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { withRateLimit } from '@/lib/rate-limit';
import { deleteStorageFile, readStorageFile, writeStorageFile } from '@/lib/storage';
// GET /api/backup — List backups for a school
export async function GET(req: Request) {
@@ -41,9 +42,12 @@ export const POST = withRateLimit(async function POST(req: Request) {
if (!backup || backup.status !== 'completed') {
return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 });
}
// In a real implementation, we would restore the DB from the backup JSON
// For now, we just mark the restore as acknowledged
return NextResponse.json({ message: 'Backup restore initiated', backup });
const content = await readStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
return NextResponse.json({
message: 'Backup file verified. Restore must run in a disposable maintenance environment.',
backup,
bytes: content.byteLength,
});
}
// Create a new backup — export entire DB as JSON
@@ -106,6 +110,7 @@ export const POST = withRateLimit(async function POST(req: Request) {
const jsonStr = JSON.stringify(backupData, null, 2);
const size = Buffer.byteLength(jsonStr, 'utf-8');
await writeStorageFile('backups', `${schoolId}/${filename}`, jsonStr);
const backup = await db.backup.create({
data: {
@@ -120,7 +125,6 @@ export const POST = withRateLimit(async function POST(req: Request) {
return NextResponse.json({
...backup,
data: backupData, // Include the actual backup data for download
});
} catch (error) {
console.error('Backup create error:', error);
@@ -151,6 +155,11 @@ export async function DELETE(req: NextRequest) {
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
const backup = await db.backup.findUnique({ where: { id } });
if (!backup) {
return NextResponse.json({ error: 'Backup not found' }, { status: 404 });
}
await deleteStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
await db.backup.delete({ where: { id } });
return NextResponse.json({ message: 'Backup deleted' });
} catch (error) {
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from 'bun:test';
import bcrypt from 'bcryptjs';
import { hashPassword, needsPasswordRehash, verifyPassword } from './auth';
describe('password hashing', () => {
test('creates and verifies Argon2id hashes', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(hash.startsWith('$argon2id$')).toBeTrue();
expect(await verifyPassword('correct horse battery staple', hash)).toBeTrue();
expect(await verifyPassword('incorrect password', hash)).toBeFalse();
expect(needsPasswordRehash(hash)).toBeFalse();
});
test('verifies legacy bcrypt hashes for rehash-on-login migration', async () => {
const legacyHash = await bcrypt.hash('legacy-password', 10);
expect(await verifyPassword('legacy-password', legacyHash)).toBeTrue();
expect(needsPasswordRehash(legacyHash)).toBeTrue();
});
});
+21 -3
View File
@@ -1,10 +1,22 @@
import bcrypt from 'bcryptjs';
import argon2 from 'argon2';
import { cookies } from 'next/headers';
import { Prisma } from '@prisma/client';
import { db } from '@/lib/db';
export const SESSION_COOKIE_NAME = 'ct_session';
export const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;
const parsedSessionMaxAge = Number.parseInt(process.env.SESSION_MAX_AGE_SECONDS ?? '', 10);
export const SESSION_COOKIE_MAX_AGE = Number.isFinite(parsedSessionMaxAge) && parsedSessionMaxAge >= 60
? parsedSessionMaxAge
: DEFAULT_SESSION_MAX_AGE;
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
} as const;
/**
* Determine whether the Secure flag should be set on cookies.
@@ -32,17 +44,23 @@ export function getSessionCookieOptions(secure: boolean) {
}
export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
return argon2.hash(password, { ...ARGON2_OPTIONS, raw: false });
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
return bcrypt.compare(password, hash);
}
export function needsPasswordRehash(hash: string): boolean {
return !hash.startsWith('$argon2');
}
export async function createSession(userId: string, secure?: boolean): Promise<void> {
const cookieStore = await cookies();
const cookieSecure = secure ?? (process.env.NODE_ENV === 'production');
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { isCsrfProtectedRequest, isTrustedRequestOrigin } from './csrf';
describe('CSRF origin protection', () => {
test('allows safe methods without origin headers', () => {
const request = new Request('https://school.example/api/students');
expect(isCsrfProtectedRequest(request)).toBeFalse();
expect(isTrustedRequestOrigin(request)).toBeTrue();
});
test('allows mutations from the request origin', () => {
const request = new Request('https://school.example/api/students', {
method: 'POST',
headers: { origin: 'https://school.example' },
});
expect(isCsrfProtectedRequest(request)).toBeTrue();
expect(isTrustedRequestOrigin(request)).toBeTrue();
});
test('rejects cross-origin mutation requests', () => {
const request = new Request('https://school.example/api/students', {
method: 'DELETE',
headers: { origin: 'https://attacker.example' },
});
expect(isTrustedRequestOrigin(request)).toBeFalse();
});
});
+41
View File
@@ -0,0 +1,41 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function normalizeOrigin(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
function configuredOrigins(): Set<string> {
return new Set(
(process.env.NEXT_PUBLIC_APP_URL ?? '')
.split(',')
.map((origin) => normalizeOrigin(origin.trim()))
.filter((origin): origin is string => Boolean(origin))
);
}
export function isCsrfProtectedRequest(request: Request): boolean {
return !SAFE_METHODS.has(request.method.toUpperCase())
&& new URL(request.url).pathname.startsWith('/api/');
}
export function isTrustedRequestOrigin(request: Request): boolean {
if (!isCsrfProtectedRequest(request)) return true;
const requestOrigin = normalizeOrigin(request.url);
const origin = normalizeOrigin(request.headers.get('origin') ?? '');
const referer = normalizeOrigin(request.headers.get('referer') ?? '');
const allowedOrigins = configuredOrigins();
if (requestOrigin) allowedOrigins.add(requestOrigin);
if (process.env.NODE_ENV !== 'production') {
allowedOrigins.add('http://localhost:3000');
allowedOrigins.add('http://127.0.0.1:3000');
}
const suppliedOrigin = origin ?? referer;
return suppliedOrigin !== null && allowedOrigins.has(suppliedOrigin);
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test';
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from './password-reset';
describe('password reset tokens', () => {
test('creates opaque tokens and stable SHA-256 lookup hashes', () => {
const { token, tokenHash } = createPasswordResetToken();
expect(token.length).toBeGreaterThan(30);
expect(tokenHash).toBe(hashResetToken(token));
expect(tokenHash).not.toBe(token);
});
test('uses a one-hour default expiry', () => {
const now = new Date('2026-08-02T10:00:00.000Z');
expect(passwordResetExpiry(now).toISOString()).toBe('2026-08-02T11:00:00.000Z');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'node:crypto';
const TOKEN_BYTES = 32;
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
export function hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function createPasswordResetToken(): { token: string; tokenHash: string } {
const token = randomBytes(TOKEN_BYTES).toString('base64url');
return { token, tokenHash: hashResetToken(token) };
}
export function passwordResetExpiry(now = new Date()): Date {
const configuredTtl = Number.parseInt(process.env.PASSWORD_RESET_TTL_SECONDS ?? '', 10);
const ttl = Number.isFinite(configuredTtl) && configuredTtl >= 300
? configuredTtl
: DEFAULT_TOKEN_TTL_SECONDS;
return new Date(now.getTime() + ttl * 1000);
}
+3
View File
@@ -26,6 +26,9 @@ export const RATE_LIMITS = {
// Auth POST endpoints: 30 requests per minute (allows retries & multiple tabs)
auth: { limit: 30, windowMs: 60 * 1000 },
// Password reset requests: 5 requests per hour per source IP.
passwordReset: { limit: 5, windowMs: 60 * 60 * 1000 },
// Auth GET endpoint: 120 requests per minute (high frequency, low cost)
authGet: { limit: 120, windowMs: 60 * 1000 },
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { deleteStorageFile, readStorageFile, storagePath, writeStorageFile } from './storage';
const originalBackupPath = process.env.BACKUP_PATH;
const tempDirectories: string[] = [];
afterEach(async () => {
process.env.BACKUP_PATH = originalBackupPath;
await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe('local storage', () => {
test('writes, reads, and deletes files inside the configured storage area', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'schulos-storage-'));
tempDirectories.push(directory);
process.env.BACKUP_PATH = directory;
await writeStorageFile('backups', 'school-1/backup.json', '{"version":1}');
expect((await readStorageFile('backups', 'school-1/backup.json')).toString()).toBe('{"version":1}');
await deleteStorageFile('backups', 'school-1/backup.json');
expect(storagePath('backups', 'school-1/backup.json')).toContain(directory);
});
test('rejects path traversal outside storage root', () => {
expect(() => storagePath('backups', '../../outside.json')).toThrow('Storage path must remain within its configured area');
});
});
+50
View File
@@ -0,0 +1,50 @@
import path from 'node:path';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
export type StorageArea = 'uploads' | 'exports' | 'backups';
const DEFAULT_STORAGE_ROOT = path.join(process.cwd(), 'data');
function getAreaRoot(area: StorageArea): string {
const configured = area === 'uploads'
? process.env.STORAGE_PATH
: area === 'exports'
? process.env.EXPORT_PATH
: process.env.BACKUP_PATH;
return path.resolve(configured || path.join(DEFAULT_STORAGE_ROOT, area));
}
function getSafePath(area: StorageArea, relativePath: string): string {
const root = getAreaRoot(area);
const target = path.resolve(root, relativePath);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error('Storage path must remain within its configured area');
}
return target;
}
export function storagePath(area: StorageArea, relativePath: string): string {
return getSafePath(area, relativePath);
}
export async function writeStorageFile(
area: StorageArea,
relativePath: string,
content: string | Buffer
): Promise<string> {
const target = getSafePath(area, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content, { mode: 0o600 });
return target;
}
export async function readStorageFile(area: StorageArea, relativePath: string): Promise<Buffer> {
return readFile(getSafePath(area, relativePath));
}
export async function deleteStorageFile(area: StorageArea, relativePath: string): Promise<void> {
await rm(getSafePath(area, relativePath), { force: true });
}