Add 21 files, update 16 files, rename 1 file
This commit is contained in:
1 parent
b9e4ed4dfc
commit
278293b397
39 files changed
+2434
-61
No files matched your search
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user