chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { type RedisWithClusterOptions } from "~/redis.server";
|
||||
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiterCore.server";
|
||||
|
||||
// MFA rate limiting: two sliding windows in series, both of which must pass.
|
||||
// A per-minute window covers interactive retries; a cumulative daily window
|
||||
// caps total attempts per pending-MFA session.
|
||||
//
|
||||
// Free of `env.server` so it can be tested directly against a container Redis;
|
||||
// the env-derived production singletons live in `mfaRateLimiterGlobal.server.ts`.
|
||||
|
||||
// Production policy. Exported so tests assert against the real numbers.
|
||||
export const MFA_PER_MINUTE_ATTEMPTS = 5;
|
||||
export const MFA_DAILY_ATTEMPTS = 30;
|
||||
|
||||
export type MfaRateLimiters = {
|
||||
perMinute: Pick<RateLimiter, "limit">;
|
||||
daily: Pick<RateLimiter, "limit">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the pair of MFA rate limiters. Production passes the env-derived
|
||||
* Redis connection and the default policy; tests inject a container
|
||||
* Redis (and may override the attempt caps to isolate one window).
|
||||
*/
|
||||
export function createMfaRateLimiters(options: {
|
||||
redisOptions: RedisWithClusterOptions;
|
||||
perMinuteAttempts?: number;
|
||||
dailyAttempts?: number;
|
||||
}): { perMinute: RateLimiter; daily: RateLimiter } {
|
||||
const redisClient = createRedisRateLimitClient(options.redisOptions);
|
||||
|
||||
return {
|
||||
perMinute: new RateLimiter({
|
||||
redisClient,
|
||||
keyPrefix: "mfa:validation",
|
||||
limiter: Ratelimit.slidingWindow(options.perMinuteAttempts ?? MFA_PER_MINUTE_ATTEMPTS, "1 m"),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
}),
|
||||
daily: new RateLimiter({
|
||||
redisClient,
|
||||
keyPrefix: "mfa:validation:daily",
|
||||
limiter: Ratelimit.slidingWindow(options.dailyAttempts ?? MFA_DAILY_ATTEMPTS, "24 h"),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export class MfaRateLimitError extends Error {
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(retryAfter: number) {
|
||||
super(`MFA validation rate limit exceeded.`);
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user can attempt MFA validation, enforcing both the
|
||||
* per-minute and the daily cap. The daily cap is checked first.
|
||||
* @param userId - The user ID to rate limit
|
||||
* @param limiters - The limiter pair (production singletons or test-injected)
|
||||
* @throws {MfaRateLimitError} If either rate limit is exceeded
|
||||
*/
|
||||
export async function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters): Promise<void> {
|
||||
const dailyResult = await limiters.daily.limit(userId);
|
||||
if (!dailyResult.success) {
|
||||
const retryAfter = new Date(dailyResult.reset).getTime() - Date.now();
|
||||
throw new MfaRateLimitError(retryAfter);
|
||||
}
|
||||
|
||||
const result = await limiters.perMinute.limit(userId);
|
||||
if (!result.success) {
|
||||
const retryAfter = new Date(result.reset).getTime() - Date.now();
|
||||
throw new MfaRateLimitError(retryAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import {
|
||||
checkMfaRateLimit as checkMfaRateLimitWith,
|
||||
createMfaRateLimiters,
|
||||
type MfaRateLimiters,
|
||||
} from "./mfaRateLimiter.server";
|
||||
|
||||
// Production singletons, wired to the env-derived rate-limit Redis.
|
||||
// Kept out of `mfaRateLimiter.server.ts` so that module stays free of
|
||||
// `env.server` and remains testable in isolation (see that file).
|
||||
const mfaRateLimiters = singleton("mfaRateLimiters", () =>
|
||||
createMfaRateLimiters({
|
||||
redisOptions: {
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const mfaRateLimiter = mfaRateLimiters.perMinute;
|
||||
export const mfaDailyRateLimiter = mfaRateLimiters.daily;
|
||||
|
||||
/**
|
||||
* Production entrypoint: rate-limit an MFA validation attempt for `userId`
|
||||
* against the env-configured limiter pair. Throws `MfaRateLimitError` when
|
||||
* either the per-minute or the cumulative daily cap is exceeded.
|
||||
*/
|
||||
export function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters = mfaRateLimiters) {
|
||||
return checkMfaRateLimitWith(userId, limiters);
|
||||
}
|
||||
|
||||
export { MfaRateLimitError } from "./mfaRateLimiter.server";
|
||||
@@ -0,0 +1,378 @@
|
||||
import { type SecretReference, type User, type PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { createRandomStringGenerator } from "@better-auth/utils/random";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { createHash } from "@better-auth/utils/hash";
|
||||
import { createOTP } from "@better-auth/utils/otp";
|
||||
import { base32 } from "@better-auth/utils/base32";
|
||||
import { z } from "zod";
|
||||
import { scheduleEmail } from "../scheduleEmail.server";
|
||||
|
||||
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
|
||||
|
||||
const SecretSchema = z.object({
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
export class MultiFactorAuthenticationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.mfaEnabledAt) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.mfaSecretReference) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// validate the TOTP code
|
||||
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = await this.#verifyTotpCodeOrRecoveryCode(
|
||||
user,
|
||||
user.mfaSecretReference,
|
||||
params.totpCode,
|
||||
params.recoveryCode
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete the MFA secret
|
||||
await secretStore.deleteSecret(user.mfaSecretReference.key);
|
||||
|
||||
// Delete the MFA backup codes
|
||||
await this.#prismaClient.mfaBackupCode.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaEnabledAt: null,
|
||||
mfaSecretReference: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleEmail({
|
||||
email: "mfa-disabled",
|
||||
to: user.email,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async enableTotp(userId: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ServiceValidationError("User not found");
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore("DATABASE");
|
||||
|
||||
// Generate a new secret
|
||||
const secret = generateRandomString(24);
|
||||
const secretKey = `mfa:${userId}:${generateRandomString(8)}`;
|
||||
|
||||
// Store the secret in the SecretStore
|
||||
await secretStore.setSecret(secretKey, {
|
||||
secret,
|
||||
});
|
||||
|
||||
// Update the user's secret reference to the secret store
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaSecretReference: {
|
||||
create: {
|
||||
provider: "DATABASE",
|
||||
key: secretKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Return the secret and the recovery codes
|
||||
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
||||
|
||||
const displaySecret = base32.encode(secret, {
|
||||
padding: false,
|
||||
});
|
||||
|
||||
return {
|
||||
secret: displaySecret,
|
||||
otpAuthUrl,
|
||||
};
|
||||
}
|
||||
|
||||
public async validateTotpSetup(userId: string, totpCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ServiceValidationError("User not found");
|
||||
}
|
||||
|
||||
if (!user.mfaSecretReference) {
|
||||
throw new ServiceValidationError("User has not enabled MFA");
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
throw new ServiceValidationError("User has not enabled MFA");
|
||||
}
|
||||
|
||||
const secret = secretResult.secret;
|
||||
|
||||
const otp = createOTP(secret, {
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const isValid = await otp.verify(totpCode);
|
||||
|
||||
if (!isValid) {
|
||||
// Return the secret and the recovery codes
|
||||
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
||||
|
||||
const displaySecret = base32.encode(secret, {
|
||||
padding: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
otpAuthUrl,
|
||||
secret: displaySecret,
|
||||
};
|
||||
}
|
||||
|
||||
// Now that we've validated the TOTP code, we can enable MFA for the user
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaEnabledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate a new set of recovery codes
|
||||
const recoveryCodes = Array.from({ length: 9 }, () => generateRandomString(16, "a-z", "0-9"));
|
||||
|
||||
// Delete any existing recovery codes
|
||||
await this.#prismaClient.mfaBackupCode.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Hash and store the recovery codes
|
||||
for (const code of recoveryCodes) {
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(code);
|
||||
await this.#prismaClient.mfaBackupCode.create({
|
||||
data: {
|
||||
userId,
|
||||
code: hashedCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await scheduleEmail({
|
||||
email: "mfa-enabled",
|
||||
to: user.email,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recoveryCodes,
|
||||
};
|
||||
}
|
||||
|
||||
async #verifyTotpCodeOrRecoveryCode(
|
||||
user: User,
|
||||
secretReference: SecretReference,
|
||||
totpCode?: string,
|
||||
recoveryCode?: string
|
||||
) {
|
||||
if (!totpCode && !recoveryCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof totpCode === "string" && totpCode.length === 6) {
|
||||
return this.#verifyTotpCode(user, secretReference, totpCode);
|
||||
}
|
||||
|
||||
if (typeof recoveryCode === "string") {
|
||||
return this.#verifyRecoveryCode(user, recoveryCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async #verifyTotpCode(user: User, secretReference: SecretReference, totpCode: string) {
|
||||
const secretStore = getSecretStore(secretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, secretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secret = secretResult.secret;
|
||||
|
||||
const isValid = await createOTP(secret, {
|
||||
digits: 6,
|
||||
period: 30,
|
||||
}).verify(totpCode);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async #verifyRecoveryCode(user: User, recoveryCode: string) {
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
||||
|
||||
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
||||
where: { userId: user.id, code: hashedCode, usedAt: null },
|
||||
});
|
||||
|
||||
return !!backupCode;
|
||||
}
|
||||
|
||||
// Public methods for login flow with security measures
|
||||
public async verifyTotpForLogin(userId: string, totpCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.mfaEnabledAt || !user.mfaSecretReference) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for replay attack - if this code was already used
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(totpCode);
|
||||
if (user.mfaLastUsedCode === hashedCode) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the TOTP code
|
||||
const isValid = await this.#verifyTotpCode(user, user.mfaSecretReference, totpCode);
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Mark this code as used to prevent replay
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaLastUsedCode: hashedCode,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async verifyRecoveryCodeForLogin(userId: string, recoveryCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.mfaEnabledAt) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
||||
|
||||
// Find an unused recovery code
|
||||
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
code: hashedCode,
|
||||
usedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!backupCode) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Mark this recovery code as used
|
||||
await this.#prismaClient.mfaBackupCode.update({
|
||||
where: { id: backupCode.id },
|
||||
data: {
|
||||
usedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user