379 lines
9.1 KiB
TypeScript
379 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|