0852d5a804
- Add Content-Security-Policy and Strict-Transport-Security headers - Whitelist allowed file extensions on attachment uploads - Add rate limiting to password change (5/5min) and file uploads (20/60s) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
118 lines
3.3 KiB
TypeScript
118 lines
3.3 KiB
TypeScript
import { json, error } from '@sveltejs/kit';
|
|
import type { RequestHandler } from './$types.js';
|
|
import { requireAuth } from '$lib/server/guards.js';
|
|
import { hashPassword, verifyPassword } from '$lib/server/auth.js';
|
|
import { withBypassRLS } from '$lib/server/rls.js';
|
|
import { passwordChangeLimiter } from '$lib/server/ratelimit.js';
|
|
import { z } from 'zod';
|
|
|
|
const profileSchema = z.object({
|
|
name: z.string().min(1, 'Name is required').max(100),
|
|
email: z.string().email('Invalid email address')
|
|
});
|
|
|
|
const passwordSchema = z.object({
|
|
currentPassword: z.string().min(1, 'Current password is required'),
|
|
newPassword: z.string().min(8, 'New password must be at least 8 characters')
|
|
});
|
|
|
|
// Update profile
|
|
export const PATCH: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
|
requireAuth(locals.user);
|
|
|
|
const body = await request.json();
|
|
|
|
// Password change
|
|
if (body.currentPassword !== undefined) {
|
|
const limit = passwordChangeLimiter(getClientAddress());
|
|
if (!limit.ok) {
|
|
return json({ error: 'Too many attempts', retryAfter: limit.retryAfter }, { status: 429 });
|
|
}
|
|
const result = passwordSchema.safeParse(body);
|
|
if (!result.success) {
|
|
return json({ error: result.error.issues[0].message }, { status: 400 });
|
|
}
|
|
|
|
const updated = await withBypassRLS(async (tx) => {
|
|
const user = await tx.user.findUnique({ where: { id: locals.user!.id } });
|
|
if (!user) throw error(404, 'User not found');
|
|
|
|
const valid = await verifyPassword(result.data.currentPassword, user.passwordHash);
|
|
if (!valid) {
|
|
return null;
|
|
}
|
|
|
|
const newHash = await hashPassword(result.data.newPassword);
|
|
await tx.user.update({
|
|
where: { id: locals.user!.id },
|
|
data: { passwordHash: newHash }
|
|
});
|
|
|
|
// Invalidate all other sessions
|
|
await tx.session.deleteMany({
|
|
where: {
|
|
userId: locals.user!.id,
|
|
id: { not: locals.session!.id }
|
|
}
|
|
});
|
|
|
|
return true;
|
|
});
|
|
|
|
if (!updated) {
|
|
return json({ error: 'Current password is incorrect' }, { status: 400 });
|
|
}
|
|
|
|
return json({ ok: true });
|
|
}
|
|
|
|
// Profile update
|
|
const result = profileSchema.safeParse(body);
|
|
if (!result.success) {
|
|
return json({ error: result.error.issues[0].message }, { status: 400 });
|
|
}
|
|
|
|
const user = await withBypassRLS(async (tx) => {
|
|
// Check if email is already taken by another user in the same tenant
|
|
if (result.data.email !== locals.user!.email) {
|
|
const existing = await tx.user.findFirst({
|
|
where: {
|
|
email: result.data.email,
|
|
tenantId: (await tx.user.findUnique({ where: { id: locals.user!.id }, select: { tenantId: true } }))!.tenantId,
|
|
id: { not: locals.user!.id }
|
|
}
|
|
});
|
|
if (existing) return null;
|
|
}
|
|
|
|
return tx.user.update({
|
|
where: { id: locals.user!.id },
|
|
data: { name: result.data.name, email: result.data.email },
|
|
select: { id: true, name: true, email: true }
|
|
});
|
|
});
|
|
|
|
if (!user) {
|
|
return json({ error: 'Email is already in use' }, { status: 409 });
|
|
}
|
|
|
|
return json({ user });
|
|
};
|
|
|
|
// Delete all other sessions
|
|
export const DELETE: RequestHandler = async ({ locals }) => {
|
|
requireAuth(locals.user);
|
|
|
|
const count = await withBypassRLS(async (tx) => {
|
|
const result = await tx.session.deleteMany({
|
|
where: {
|
|
userId: locals.user!.id,
|
|
id: { not: locals.session!.id }
|
|
}
|
|
});
|
|
return result.count;
|
|
});
|
|
|
|
return json({ ok: true, sessionsRevoked: count });
|
|
};
|