Files
formbricks-formbricks/apps/web/modules/auth/lib/utils.ts
Kunal Garg 979fd71a11 feat: reset password in accounts page (#5219)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-01 15:41:14 +00:00

305 lines
10 KiB
TypeScript

import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import redis from "@/modules/cache/redis";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { compare, hash } from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { logger } from "@formbricks/logger";
export const hashPassword = async (password: string) => {
const hashedPassword = await hash(password, 12);
return hashedPassword;
};
export const verifyPassword = async (password: string, hashedPassword: string) => {
const isValid = await compare(password, hashedPassword);
return isValid;
};
/**
* 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 rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
const now = Date.now();
if (redis) {
try {
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
multi.zcard(rateLimitKey);
multi.zadd(rateLimitKey, now, `${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][1] 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 = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
} else {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
// If Redis not configured, 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,
});
};