Add security libs: auth, audit, rate-limit, scoped db
This commit is contained in:
parent
149d8bc72c
commit
4cf886ea43
7 changed files with 497 additions and 0 deletions
46
scripts/security-audit.sh
Normal file
46
scripts/security-audit.sh
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/bin/bash
|
||||
# Security audit script - run to verify security implementation
|
||||
|
||||
echo "=== Security Audit ==="
|
||||
echo ""
|
||||
|
||||
BASE_URL="${1:-http://localhost:3000}"
|
||||
|
||||
echo "1. Testing unauthenticated API access (should fail)..."
|
||||
echo " Testing /api/children..."
|
||||
curl -s "$BASE_URL/api/children" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error"
|
||||
|
||||
echo ""
|
||||
echo "2. Testing unauthenticated growth access (should fail)..."
|
||||
curl -s "$BASE_URL/api/growth?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error"
|
||||
|
||||
echo ""
|
||||
echo "3. Testing unauthenticated logs access (should fail)..."
|
||||
curl -s "$BASE_URL/api/logs?childId=test&type=feed" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error"
|
||||
|
||||
echo ""
|
||||
echo "4. Testing vaccinations access (should fail)..."
|
||||
curl -s "$BASE_URL/api/vaccinations?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error"
|
||||
|
||||
echo ""
|
||||
echo "5. Testing medicines access (should fail)..."
|
||||
curl -s "$BASE_URL/api/medicines?childId=test" | grep -q "error" && echo " ✓ PASS - Blocked" || echo " ✗ FAIL - Should return error"
|
||||
|
||||
echo ""
|
||||
echo "6. Testing session cookie setting..."
|
||||
RESPONSE=$(curl -s -c /tmp/cookies.txt -X POST "$BASE_URL/api/auth/signin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@test.com","password":"wrong"}')
|
||||
echo " Response: $RESPONSE"
|
||||
|
||||
echo ""
|
||||
echo "7. Testing rate limiting..."
|
||||
for i in 1 2 3 4 5 6; do
|
||||
curl -s "$BASE_URL/api/auth/signin" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"hacker@test.com","password":"guess"}' | grep -q "Too many" && break
|
||||
done
|
||||
echo " Rate limited after $i attempts"
|
||||
|
||||
echo ""
|
||||
echo "=== Audit Complete ==="
|
||||
102
src/__tests__/security.test.ts
Normal file
102
src/__tests__/security.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Security verification tests
|
||||
// Run with: pnpm test -- src/__tests__/security.test.ts
|
||||
|
||||
import { validateSession, requireFamily, requireOwnership, requireTier } from "@/lib/auth";
|
||||
|
||||
describe("Security: Session Validation", () => {
|
||||
describe("validateSession", () => {
|
||||
it("should reject invalid session token", async () => {
|
||||
// Note: This requires mocking cookies()
|
||||
// In real tests, use next/headers in test environment
|
||||
});
|
||||
|
||||
it("should accept valid session token", async () => {
|
||||
// Validates session and returns user/family info
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireFamily", () => {
|
||||
it("should reject unauthenticated requests", async () => {
|
||||
const result = await requireFamily();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("should reject authenticated user without family", async () => {
|
||||
// User authenticated but familyId is null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Ownership Validation", () => {
|
||||
describe("requireOwnership", () => {
|
||||
it("should reject access to other family's children", async () => {
|
||||
// Try to access child that doesn't belong to user's family
|
||||
});
|
||||
|
||||
it("should allow access to own family's children", async () => {
|
||||
// Try to access child that belongs to user's family
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Tier Validation", () => {
|
||||
describe("requireTier", () => {
|
||||
it("should reject free tier for pro features", async () => {
|
||||
// User has free tier but accessing pro feature
|
||||
});
|
||||
|
||||
it("should allow pro tier for pro features", async () => {
|
||||
// User has pro tier accessing pro feature
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Rate Limiting", () => {
|
||||
describe("Auth Rate Limits", () => {
|
||||
it("should block after 5 failed signin attempts", async () => {
|
||||
// 5 signin failures within 15 minutes
|
||||
});
|
||||
|
||||
it("should block after 3 failed signup attempts", async () => {
|
||||
// 3 signup failures within 1 hour
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Password", () => {
|
||||
describe("bcrypt", () => {
|
||||
it("should hash password with cost 12", async () => {
|
||||
const hash = await bcrypt.hash("testpassword", 12);
|
||||
expect(hash.startsWith("$2a$12$")).toBe(true);
|
||||
});
|
||||
|
||||
it("should verify correct password", async () => {
|
||||
const hash = await bcrypt.hash("testpassword", 12);
|
||||
const valid = await bcrypt.compare("testpassword", hash);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject incorrect password", async () => {
|
||||
const hash = await bcrypt.hash("testpassword", 12);
|
||||
const valid = await bcrypt.compare("wrongpassword", hash);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Session Cookie", () => {
|
||||
describe("Cookie Settings", () => {
|
||||
it("should be httpOnly", () => {
|
||||
// Verify cookie configuration
|
||||
});
|
||||
|
||||
it("should be secure in production", () => {
|
||||
// Verify secure flag in production env
|
||||
});
|
||||
|
||||
it("should have sameSite: lax", () => {
|
||||
// Verify sameSite setting
|
||||
});
|
||||
});
|
||||
});
|
||||
71
src/app/api/cron/backup/route.ts
Normal file
71
src/app/api/cron/backup/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import fs from "fs/promises";
|
||||
import { gzip } from "zlib";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const gzipAsync = promisify(gzip);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = request.headers.get("x-cron-secret");
|
||||
if (secret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const filename = `tia-backup-${date}.sql.gz`;
|
||||
const tmpPath = `/tmp/${filename}`;
|
||||
|
||||
try {
|
||||
// 1. Dump database
|
||||
const dbUrl = process.env.DATABASE_URL_SUPERUSER || process.env.DATABASE_URL;
|
||||
await execAsync(`pg_dump "${dbUrl}" -F p -f /tmp/dump.sql`);
|
||||
|
||||
// 2. Compress
|
||||
const raw = await fs.readFile("/tmp/dump.sql");
|
||||
const compressed = await gzipAsync(raw);
|
||||
|
||||
// 3. Upload to R2
|
||||
if (process.env.R2_BACKUP_BUCKET && process.env.R2_BACKUP_ACCESS_KEY_ID) {
|
||||
const s3 = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_BACKUP_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_BACKUP_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: process.env.R2_BACKUP_BUCKET!,
|
||||
Key: filename,
|
||||
Body: compressed,
|
||||
}));
|
||||
|
||||
// 4. Delete backups older than 30 days
|
||||
const list = await s3.send(new ListObjectsV2Command({
|
||||
Bucket: process.env.R2_BACKUP_BUCKET!,
|
||||
}));
|
||||
|
||||
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
for (const obj of list.Contents || []) {
|
||||
if (obj.LastModified && obj.LastModified.getTime() < cutoff) {
|
||||
await s3.send(new DeleteObjectCommand({
|
||||
Bucket: process.env.R2_BACKUP_BUCKET!,
|
||||
Key: obj.Key!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Cleanup local
|
||||
await fs.unlink("/tmp/dump.sql").catch(() => {});
|
||||
|
||||
return NextResponse.json({ success: true, filename, size: compressed.length });
|
||||
} catch (e) {
|
||||
console.error("Backup failed:", e);
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
src/db/scoped.ts
Normal file
38
src/db/scoped.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { sql, dbUnscoped } from "./index";
|
||||
import { validateSession, requireFamily } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Run a callback within a Postgres transaction where
|
||||
* app.current_family_id is set. ALL data queries must go through this.
|
||||
*/
|
||||
export async function withFamilyContext<T>(
|
||||
familyId: string,
|
||||
callback: (tx: typeof sql) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await sql.begin(async (tx) => {
|
||||
await tx`SELECT set_config('app.current_family_id', ${familyId}, true)`;
|
||||
return await callback(tx);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: get session + scoped client in one call.
|
||||
* Use this in API routes instead of importing sql directly.
|
||||
*/
|
||||
export async function getScopedDb() {
|
||||
const auth = await requireFamily();
|
||||
if (!auth.success) {
|
||||
throw new NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { familyId, userId } = auth.session!;
|
||||
|
||||
return {
|
||||
session: auth.session!,
|
||||
run: <T>(cb: (tx: typeof sql) => Promise<T>) =>
|
||||
withFamilyContext(familyId!, cb),
|
||||
};
|
||||
}
|
||||
|
||||
// Need to import NextResponse for error throwing
|
||||
import { NextResponse } from "next/server";
|
||||
28
src/lib/audit.ts
Normal file
28
src/lib/audit.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { sql } from "@/db";
|
||||
|
||||
interface AuditOpts {
|
||||
familyId?: string;
|
||||
userId?: string;
|
||||
action: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
request?: Request;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function logAudit(opts: AuditOpts) {
|
||||
try {
|
||||
const ip = opts.request?.headers.get("x-forwarded-for")?.split(",")[0]
|
||||
|| opts.request?.headers.get("x-real-ip");
|
||||
const ua = opts.request?.headers.get("user-agent");
|
||||
|
||||
await sql`
|
||||
INSERT INTO audit_log (family_id, user_id, action, resource_type, resource_id, ip_address, user_agent, metadata)
|
||||
VALUES (${opts.familyId || null}, ${opts.userId || null}, ${opts.action},
|
||||
${opts.resourceType || null}, ${opts.resourceId || null},
|
||||
${ip || null}, ${ua || null}, ${JSON.stringify(opts.metadata || {})})`;
|
||||
} catch (e) {
|
||||
console.error("Audit log failed:", e);
|
||||
// Never throw - audit failures shouldn't break the request
|
||||
}
|
||||
}
|
||||
148
src/lib/auth.ts
Normal file
148
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Session validation and family resolver for API routes
|
||||
// All data routes must use this to validate sessions and get familyId
|
||||
|
||||
import { sql } from "@/db";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
interface SessionData {
|
||||
userId: string;
|
||||
familyId: string | null;
|
||||
familyName: string | null;
|
||||
tier: string | null;
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
status?: number;
|
||||
session?: SessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session cookie and resolve family information
|
||||
* Use this at the start of every data API route
|
||||
*/
|
||||
export async function validateSession(): Promise<AuthResult> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get("tia_session")?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return { success: false, error: "Not authenticated", status: 401 };
|
||||
}
|
||||
|
||||
// Verify session and get family info
|
||||
const sessions = await sql`
|
||||
SELECT s.user_id, s.expires, fm.family_id as family_id, f.name as family_name, f.tier
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
LEFT JOIN family_members fm ON fm.user_id = u.id
|
||||
LEFT JOIN families f ON f.id = fm.family_id
|
||||
WHERE s.session_token = ${sessionToken}
|
||||
AND s.expires > NOW()
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return { success: false, error: "Invalid or expired session", status: 401 };
|
||||
}
|
||||
|
||||
const session = sessions[0];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session: {
|
||||
userId: session.user_id,
|
||||
familyId: session.family_id,
|
||||
familyName: session.family_name,
|
||||
tier: session.tier,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Session validation error:", error);
|
||||
return { success: false, error: "Session validation failed", status: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authenticated user with a family
|
||||
* Use for routes that require family data
|
||||
*/
|
||||
export async function requireFamily(): Promise<AuthResult> {
|
||||
const auth = await validateSession();
|
||||
|
||||
if (!auth.success) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (!auth.session?.familyId) {
|
||||
return { success: false, error: "No family associated with session", status: 403 };
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authenticated user who owns a specific resource
|
||||
* Use to verify user has access to a resource (e.g., a specific child's data)
|
||||
*
|
||||
* @param resourceId - The ID of the resource to check
|
||||
* @param table - The table name to check ownership in
|
||||
* @param resourceLabel - Human-readable label for error messages
|
||||
*/
|
||||
export async function requireOwnership(
|
||||
resourceId: string,
|
||||
table: string,
|
||||
resourceLabel: string
|
||||
): Promise<AuthResult> {
|
||||
const auth = await requireFamily();
|
||||
|
||||
if (!auth.success) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
const familyId = auth.session!.familyId!;
|
||||
|
||||
// Check if resource belongs to user's family
|
||||
const result = await sql.unsafe(
|
||||
`SELECT id FROM ${table} WHERE id = $1 AND family_id = $2 LIMIT 1`,
|
||||
[resourceId, familyId]
|
||||
);
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `${resourceLabel} not found or access denied`,
|
||||
status: 404,
|
||||
};
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific tier level
|
||||
* Use for premium features
|
||||
*/
|
||||
export async function requireTier(minTier: "free" | "pro"): Promise<AuthResult> {
|
||||
const auth = await requireFamily();
|
||||
|
||||
if (!auth.success) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
const tier = auth.session?.tier || "free";
|
||||
const tierLevels = { free: 0, pro: 1 };
|
||||
const userLevel = tierLevels[tier as keyof typeof tierLevels] || 0;
|
||||
const requiredLevel = tierLevels[minTier];
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Requires ${minTier} tier or higher`,
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
64
src/lib/rate-limit.ts
Normal file
64
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Rate limiting utility
|
||||
// Uses in-memory storage for simplicity - should use Redis in production
|
||||
|
||||
interface RateLimitResult {
|
||||
success: boolean;
|
||||
remaining: number;
|
||||
reset: Date;
|
||||
}
|
||||
|
||||
const store = new Map<string, { count: number; reset: Date }>();
|
||||
|
||||
export function getClientIp(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
return request.headers.get("x-real-ip") || "unknown";
|
||||
}
|
||||
|
||||
export async function rateLimit(
|
||||
identifier: string,
|
||||
opts: { max: number; windowSec: number }
|
||||
): Promise<RateLimitResult> {
|
||||
const key = identifier;
|
||||
const now = new Date();
|
||||
const windowMs = opts.windowSec * 1000;
|
||||
|
||||
const record = store.get(key);
|
||||
|
||||
if (!record || record.reset.getTime() < now.getTime()) {
|
||||
// New window
|
||||
const reset = new Date(now.getTime() + windowMs);
|
||||
store.set(key, { count: 1, reset });
|
||||
return {
|
||||
success: true,
|
||||
remaining: opts.max - 1,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.count >= opts.max) {
|
||||
// Rate limited
|
||||
return {
|
||||
success: false,
|
||||
remaining: 0,
|
||||
reset: record.reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Increment count
|
||||
record.count++;
|
||||
store.set(key, record);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remaining: opts.max - record.count,
|
||||
reset: record.reset,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get rate limit key for an endpoint
|
||||
export function getRateLimitKey(endpoint: string, identifier: string): string {
|
||||
return `ratelimit:${endpoint}:${identifier}`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue