diff --git a/backend/src/auth.ts b/backend/src/auth.ts index a616739..5b4b534 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -4,7 +4,7 @@ import express, { Request, Response } from "express"; import bcrypt from "bcrypt"; import jwt, { SignOptions } from "jsonwebtoken"; -import type { StringValue } from "ms"; +import ms, { type StringValue } from "ms"; import { z } from "zod"; import { PrismaClient } from "./generated/client"; import { config } from "./config"; @@ -135,6 +135,15 @@ const generateTokens = (userId: string, email: string) => { return { accessToken, refreshToken }; }; +const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => { + const parsed = ms(expiresIn as StringValue); + const ttlMs = typeof parsed === "number" && parsed > 0 ? parsed : fallbackMs; + return new Date(Date.now() + ttlMs); +}; + +const getRefreshTokenExpiresAt = (): Date => + resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000); + /** * POST /auth/register * Register a new user @@ -209,8 +218,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => const { accessToken, refreshToken } = generateTokens(user.id, user.email); if (config.enableRefreshTokenRotation) { - const expiresAt = new Date(); - expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + const expiresAt = getRefreshTokenExpiresAt(); await prisma.refreshToken.create({ data: { userId: user.id, token: refreshToken, expiresAt }, }); @@ -317,8 +325,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => // Store refresh token in database for rotation tracking (if enabled) if (config.enableRefreshTokenRotation) { - const expiresAt = new Date(); - expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + const expiresAt = getRefreshTokenExpiresAt(); try { await prisma.refreshToken.create({ @@ -446,8 +453,7 @@ router.post("/login", authRateLimiter, async (req: Request, res: Response) => { // Store refresh token in database for rotation tracking (if enabled) if (config.enableRefreshTokenRotation) { - const expiresAt = new Date(); - expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + const expiresAt = getRefreshTokenExpiresAt(); try { await prisma.refreshToken.create({ @@ -578,8 +584,7 @@ router.post("/refresh", async (req: Request, res: Response) => { const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.id, user.email); // Store new refresh token - const expiresAt = new Date(); - expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + const expiresAt = getRefreshTokenExpiresAt(); await prisma.refreshToken.create({ data: { @@ -861,7 +866,13 @@ router.post("/password-reset-request", authRateLimiter, async (req: Request, res // For now, we'll return the token in development (remove in production!) if (config.nodeEnv === "development") { console.log(`[DEV] Password reset token for ${email}: ${resetToken}`); - const baseUrl = config.frontendUrl || "http://localhost:6767"; + const baseUrlRaw = config.frontendUrl?.split(",")[0]?.trim(); + const baseUrlWithProtocol = baseUrlRaw + ? /^https?:\/\//i.test(baseUrlRaw) + ? baseUrlRaw + : `http://${baseUrlRaw}` + : "http://localhost:6767"; + const baseUrl = baseUrlWithProtocol.replace(/\/$/, ""); console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`); } } diff --git a/backend/src/config.ts b/backend/src/config.ts index a03400b..a2749f8 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -55,16 +55,12 @@ const resolveJwtSecret = (nodeEnv: string): string => { const parseFrontendUrl = (raw: string | undefined): string | undefined => { if (!raw || raw.trim().length === 0) return undefined; - const first = raw.split(",")[0]?.trim(); - if (!first) return undefined; - try { - // Validate basic format - new URL(/^https?:\/\//i.test(first) ? first : `http://${first}`); - } catch { - // Don't hard-fail; FRONTEND_URL supports multiple origins in other parts of the app. - return first; - } - return first; + const normalized = raw + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0) + .join(","); + return normalized.length > 0 ? normalized : undefined; }; const resolveDatabaseUrl = (rawUrl?: string) => { diff --git a/backend/src/scripts/migrate-existing-data.ts b/backend/src/scripts/migrate-existing-data.ts deleted file mode 100644 index ca89c97..0000000 --- a/backend/src/scripts/migrate-existing-data.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Data migration script for existing drawings and collections - * This script assigns existing data to a default user - * Run this if you have existing data before the auth migration - */ -import { PrismaClient } from '../generated/client'; -import bcrypt from 'bcrypt'; - -const prisma = new PrismaClient(); - -async function migrateExistingData() { - try { - console.log('Starting data migration...'); - - // Check if there are any drawings or collections without userId - // Note: After migration, userId is required, so this query is for pre-migration data - // We use a raw query or check for missing userId field - const allDrawings = await prisma.drawing.findMany({ - select: { id: true, userId: true }, - }); - const drawingsWithoutUser = allDrawings.filter((d) => !d.userId); - - const allCollections = await prisma.collection.findMany({ - select: { id: true, userId: true }, - }); - const collectionsWithoutUser = allCollections.filter((c) => !c.userId); - - if (drawingsWithoutUser.length === 0 && collectionsWithoutUser.length === 0) { - console.log('No data to migrate. All records already have userId.'); - return; - } - - console.log(`Found ${drawingsWithoutUser.length} drawings and ${collectionsWithoutUser.length} collections without userId`); - - // Create a default migration user - const defaultEmail = 'migration@excalidash.local'; - const defaultPassword = await bcrypt.hash('migration-temp-password-change-me', 10); - - let migrationUser = await prisma.user.findUnique({ - where: { email: defaultEmail }, - }); - - if (!migrationUser) { - migrationUser = await prisma.user.create({ - data: { - email: defaultEmail, - passwordHash: defaultPassword, - name: 'Migration User', - }, - }); - console.log('Created migration user:', migrationUser.id); - } - - // Update collections - if (collectionsWithoutUser.length > 0) { - const collectionIds = collectionsWithoutUser.map((c) => c.id); - await prisma.collection.updateMany({ - where: { - id: { in: collectionIds }, - }, - data: { - userId: migrationUser.id, - }, - }); - console.log(`Assigned ${collectionsWithoutUser.length} collections to migration user`); - } - - // Update drawings - if (drawingsWithoutUser.length > 0) { - const drawingIds = drawingsWithoutUser.map((d) => d.id); - await prisma.drawing.updateMany({ - where: { - id: { in: drawingIds }, - }, - data: { - userId: migrationUser.id, - }, - }); - console.log(`Assigned ${drawingsWithoutUser.length} drawings to migration user`); - } - - console.log('Migration completed successfully!'); - console.log(`⚠️ IMPORTANT: Change the password for user ${defaultEmail} or delete this user after assigning data to real users.`); - } catch (error) { - console.error('Migration failed:', error); - throw error; - } finally { - await prisma.$disconnect(); - } -} - -migrateExistingData(); \ No newline at end of file diff --git a/backend/src/utils/audit.ts b/backend/src/utils/audit.ts index dfc3c09..888af2b 100644 --- a/backend/src/utils/audit.ts +++ b/backend/src/utils/audit.ts @@ -19,6 +19,18 @@ export interface AuditLogData { details?: Record; } +export interface AuditLogResult { + id: string; + userId: string | null; + action: string; + resource: string | null; + ipAddress: string | null; + userAgent: string | null; + details: unknown | null; + createdAt: Date; + user: { id: string; email: string; name: string } | null; +} + /** * Log a security event to the audit log * This should be called for important security-related actions @@ -59,7 +71,7 @@ export const logAuditEvent = async (data: AuditLogData): Promise => { export const getAuditLogs = async ( userId?: string, limit: number = 100 -): Promise => { +): Promise => { try { // Check if audit logging is enabled via config const { config } = await import("../config"); @@ -84,7 +96,14 @@ export const getAuditLogs = async ( return logs.map((log) => ({ ...log, - details: log.details ? JSON.parse(log.details) : null, + details: (() => { + if (!log.details) return null; + try { + return JSON.parse(log.details) as unknown; + } catch { + return null; + } + })(), })); } catch (error) { // Gracefully handle missing table or other errors