Files
formbricks/apps/web/modules/auth/lib/utils.ts
Victor Hugo dos Santos aaea129d4f fix: api key hashing algorithm (#6639)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-10-13 14:36:37 +00:00

309 lines
10 KiB
TypeScript

import * as Sentry from "@sentry/nextjs";
import { createHash, randomUUID } from "crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { hashSecret, verifySecret } from "@/lib/crypto";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const hashPassword = async (password: string) => {
return await hashSecret(password, 12);
};
export const verifyPassword = async (password: string, hashedPassword: string) => {
return await verifySecret(password, hashedPassword);
};
/**
* Creates a consistent hashed identifier for audit logging that protects PII
* while still allowing pattern tracking and rate limiting.
*
* @param identifier - The identifier to hash (email, IP, etc.)
* @param prefix - Optional prefix for the hash (e.g., "email", "ip")
* @returns A consistent SHA-256 hash that can be used for tracking without exposing PII
*/
export const createAuditIdentifier = (identifier: string, prefix: string = "actor"): string => {
if (!identifier || identifier === "unknown" || identifier === "unknown_user") {
return UNKNOWN_DATA;
}
// Create a consistent hash that can be used for pattern detection
// Use a longer hash for better collision resistance in compliance scenarios
const hash = createHash("sha256").update(identifier.toLowerCase()).digest("hex");
return `${prefix}_${hash.substring(0, 32)}`; // Use first 32 chars for better uniqueness
};
export const logAuthEvent = (
action: TAuditAction,
status: TAuditStatus,
userId: string,
email?: string,
additionalData: Record<string, any> = {}
) => {
const auditActorId = userId === UNKNOWN_DATA && email ? createAuditIdentifier(email, "email") : userId;
// Log failures to Sentry for monitoring and alerting
if (status === "failure" && SENTRY_DSN && IS_PRODUCTION) {
const error = new Error(`Authentication ${action} failed`);
Sentry.captureException(error, {
tags: {
component: "authentication",
action,
status,
...(additionalData.tags ?? {}),
},
extra: {
userId: auditActorId,
provider: additionalData.provider,
authMethod: additionalData.authMethod,
failureReason: additionalData.failureReason,
...additionalData,
},
});
}
queueAuditEventBackground({
action,
targetType: "user",
userId: auditActorId,
targetId: auditActorId,
organizationId: UNKNOWN_DATA,
status,
userType: "user",
newObject: {
...additionalData,
},
});
};
/**
* Helper function for logging authentication attempts with consistent failure reasons.
*
* @param failureReason - Specific reason for authentication failure
* @param provider - Authentication provider (credentials, token, etc.)
* @param authMethod - Authentication method (password, totp, backup_code, etc.)
* @param userId - User ID (use UNKNOWN_DATA if not available)
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logAuthAttempt = (
failureReason: string,
provider: string,
authMethod: string,
userId: string = UNKNOWN_DATA,
email?: string,
additionalData: Record<string, any> = {}
) => {
logAuthEvent("authenticationAttempted", "failure", userId, email, {
failureReason,
provider,
authMethod,
...additionalData,
});
};
/**
* Helper function for logging successful authentication events.
*
* @param action - The specific success action (passwordVerified, twoFactorVerified, etc.)
* @param provider - Authentication provider
* @param authMethod - Authentication method
* @param userId - User ID
* @param email - User email - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logAuthSuccess = (
action: TAuditAction,
provider: string,
authMethod: string,
userId: string,
email: string,
additionalData: Record<string, any> = {}
) => {
logAuthEvent(action, "success", userId, email, {
provider,
authMethod,
...additionalData,
});
};
/**
* Helper function for logging two-factor authentication attempts.
*
* @param isSuccess - Whether the 2FA attempt was successful
* @param authMethod - 2FA method (totp, backup_code)
* @param userId - User ID
* @param email - User email - used ONLY to create hashed identifier, never stored
* @param failureReason - Failure reason (only for failed attempts)
* @param additionalData - Additional context data
*/
export const logTwoFactorAttempt = (
isSuccess: boolean,
authMethod: string,
userId: string,
email: string,
failureReason?: string,
additionalData: Record<string, any> = {}
) => {
const action = isSuccess ? "twoFactorVerified" : "twoFactorAttempted";
const status = isSuccess ? "success" : "failure";
logAuthEvent(action, status, userId, email, {
provider: "credentials",
authMethod,
...(failureReason && !isSuccess ? { failureReason } : {}),
...additionalData,
});
};
/**
* Helper function for logging email verification attempts.
*
* @param isSuccess - Whether the verification was successful
* @param failureReason - Failure reason (only for failed attempts)
* @param userId - User ID (use UNKNOWN_DATA if not available)
* @param email - User email (optional) - used ONLY to create hashed identifier, never stored
* @param additionalData - Additional context data
*/
export const logEmailVerificationAttempt = (
isSuccess: boolean,
failureReason?: string,
userId: string = UNKNOWN_DATA,
email?: string,
additionalData: Record<string, any> = {}
) => {
const action = isSuccess ? "emailVerified" : "emailVerificationAttempted";
const status = isSuccess ? "success" : "failure";
logAuthEvent(action, status, userId, email, {
provider: "token",
authMethod: "email_verification",
...(failureReason && !isSuccess ? { failureReason } : {}),
...additionalData,
});
};
// Rate limiting constants
const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes
const AGGREGATION_THRESHOLD = 3; // After 3 failures, start aggregating
/**
* Rate limiting decision function for authentication audit logs.
* Uses Redis for distributed rate limiting across Kubernetes pods.
*
* **What this function does:**
* - Returns true/false to indicate whether an auth attempt should be logged
* - Always returns true for successful authentications (no rate limiting)
* - For failures: allows first 3 attempts per identifier within 5-minute window
* - After 3 failures: allows every 10th attempt OR after 1+ minute gap
* - Uses hashed identifiers to protect PII while enabling tracking
* - Returns false if Redis is unavailable (fail closed)
*
* **Use cases:**
* - Gate authentication failure logging to prevent spam
* - Provide consistent rate limiting decisions across Kubernetes pods
* - Protect user PII through identifier hashing
*
* **Example usage:**
* ```typescript
* if (await shouldLogAuthFailure(user.email)) {
* logAuthAttempt("invalid_password", "credentials", "password", user.id, user.email);
* }
* ```
*
* @param identifier - Unique identifier for rate limiting (email, token, etc.) - will be hashed
* @param isSuccess - Whether this is a successful authentication (defaults to false)
* @returns Promise<boolean> - Whether this attempt should be logged to audit trail
*/
export const shouldLogAuthFailure = async (
identifier: string,
isSuccess: boolean = false
): Promise<boolean> => {
// Always log successful authentications
if (isSuccess) return true;
const now = Date.now();
const bucketStart = Math.floor(now / RATE_LIMIT_WINDOW) * RATE_LIMIT_WINDOW;
const rateLimitKey = createCacheKey.rateLimit.core(
"auth",
createAuditIdentifier(identifier, "ratelimit"),
bucketStart
);
try {
// Get Redis client
const redis = await cache.getRedisClient();
if (!redis) {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
return false;
}
// Use Redis for distributed rate limiting
const multi = redis.multi();
// Remove expired entries and count recent failures
multi.zRemRangeByScore(rateLimitKey, 0, bucketStart);
multi.zCard(rateLimitKey);
multi.zAdd(rateLimitKey, { score: now, value: `${now}:${randomUUID()}` });
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1] as unknown as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zRange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = Number.parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn({ error }, "Redis rate limiting failed, not logging due to Redis requirement");
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
};
/**
* Logs a user sign out event for audit compliance.
*
* @param userId - The ID of the user signing out
* @param userEmail - The email of the user signing out
* @param context - Additional context about the sign out (reason, redirect URL, etc.)
*/
export const logSignOut = (
userId: string,
userEmail: string,
context?: {
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
}
) => {
logAuthEvent("userSignedOut", "success", userId, userEmail, {
provider: "session",
authMethod: "sign_out",
reason: context?.reason || "user_initiated", // NOSONAR // We want to check for empty strings
redirectUrl: context?.redirectUrl,
organizationId: context?.organizationId,
});
};