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
@@ -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