fix(auth): stabilize refresh expiry and frontend URL handling
This commit is contained in:
+21
-10
@@ -4,7 +4,7 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import jwt, { SignOptions } from "jsonwebtoken";
|
import jwt, { SignOptions } from "jsonwebtoken";
|
||||||
import type { StringValue } from "ms";
|
import ms, { type StringValue } from "ms";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient } from "./generated/client";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
@@ -135,6 +135,15 @@ const generateTokens = (userId: string, email: string) => {
|
|||||||
return { accessToken, refreshToken };
|
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
|
* POST /auth/register
|
||||||
* Register a new user
|
* 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);
|
const { accessToken, refreshToken } = generateTokens(user.id, user.email);
|
||||||
|
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = new Date();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: { userId: user.id, token: refreshToken, expiresAt },
|
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)
|
// Store refresh token in database for rotation tracking (if enabled)
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = new Date();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.refreshToken.create({
|
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)
|
// Store refresh token in database for rotation tracking (if enabled)
|
||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = new Date();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.refreshToken.create({
|
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);
|
const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.id, user.email);
|
||||||
|
|
||||||
// Store new refresh token
|
// Store new refresh token
|
||||||
const expiresAt = new Date();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
||||||
|
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: {
|
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!)
|
// For now, we'll return the token in development (remove in production!)
|
||||||
if (config.nodeEnv === "development") {
|
if (config.nodeEnv === "development") {
|
||||||
console.log(`[DEV] Password reset token for ${email}: ${resetToken}`);
|
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}`);
|
console.log(`[DEV] Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -55,16 +55,12 @@ const resolveJwtSecret = (nodeEnv: string): string => {
|
|||||||
|
|
||||||
const parseFrontendUrl = (raw: string | undefined): string | undefined => {
|
const parseFrontendUrl = (raw: string | undefined): string | undefined => {
|
||||||
if (!raw || raw.trim().length === 0) return undefined;
|
if (!raw || raw.trim().length === 0) return undefined;
|
||||||
const first = raw.split(",")[0]?.trim();
|
const normalized = raw
|
||||||
if (!first) return undefined;
|
.split(",")
|
||||||
try {
|
.map((origin) => origin.trim())
|
||||||
// Validate basic format
|
.filter((origin) => origin.length > 0)
|
||||||
new URL(/^https?:\/\//i.test(first) ? first : `http://${first}`);
|
.join(",");
|
||||||
} catch {
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
// Don't hard-fail; FRONTEND_URL supports multiple origins in other parts of the app.
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
return first;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveDatabaseUrl = (rawUrl?: string) => {
|
const resolveDatabaseUrl = (rawUrl?: string) => {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -19,6 +19,18 @@ export interface AuditLogData {
|
|||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Log a security event to the audit log
|
||||||
* This should be called for important security-related actions
|
* This should be called for important security-related actions
|
||||||
@@ -59,7 +71,7 @@ export const logAuditEvent = async (data: AuditLogData): Promise<void> => {
|
|||||||
export const getAuditLogs = async (
|
export const getAuditLogs = async (
|
||||||
userId?: string,
|
userId?: string,
|
||||||
limit: number = 100
|
limit: number = 100
|
||||||
): Promise<unknown[]> => {
|
): Promise<AuditLogResult[]> => {
|
||||||
try {
|
try {
|
||||||
// Check if audit logging is enabled via config
|
// Check if audit logging is enabled via config
|
||||||
const { config } = await import("../config");
|
const { config } = await import("../config");
|
||||||
@@ -84,7 +96,14 @@ export const getAuditLogs = async (
|
|||||||
|
|
||||||
return logs.map((log) => ({
|
return logs.map((log) => ({
|
||||||
...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) {
|
} catch (error) {
|
||||||
// Gracefully handle missing table or other errors
|
// Gracefully handle missing table or other errors
|
||||||
|
|||||||
Reference in New Issue
Block a user