173 lines
4.8 KiB
TypeScript
173 lines
4.8 KiB
TypeScript
import * as OTPAuth from "otpauth";
|
|
import { loadRequiredWebConfig } from "@toolctrl/config";
|
|
import CryptoApi from "~/utils/api/.server/CryptoApi";
|
|
import {
|
|
activateUserTotpPendingSecret,
|
|
getUserTotpPendingEnrollment,
|
|
setUserMfaRecoveryCodes,
|
|
setUserTotpPendingSecret,
|
|
} from "~/utils/db/users.db.server";
|
|
import { generateRecoveryCodes } from "~/utils/auth/mfa/recovery-codes.server";
|
|
|
|
export const TOTP_RECOVERY_CODE_COUNT = 10;
|
|
const TOTP_DIGITS = 6;
|
|
const TOTP_PERIOD_SECONDS = 30;
|
|
const DEFAULT_WINDOW = 1;
|
|
const DEFAULT_SECRET_SIZE = 20;
|
|
|
|
export type TotpEnrollment = {
|
|
secret: string;
|
|
encryptedSecret: string;
|
|
otpauthUri: string;
|
|
qrValue: string;
|
|
};
|
|
|
|
export type TotpSafeStatus = {
|
|
enabled: boolean;
|
|
pending: boolean;
|
|
enabledAt: Date | null;
|
|
pendingCreatedAt: Date | null;
|
|
};
|
|
|
|
export type TotpEnrollmentResult = {
|
|
encryptedSecret: string;
|
|
otpauthUri: string;
|
|
recoveryCodesPending: boolean;
|
|
secret: string;
|
|
};
|
|
|
|
type OtpauthOptions = {
|
|
appName?: string;
|
|
issuer?: string;
|
|
};
|
|
|
|
type VerifyOptions = {
|
|
window?: number;
|
|
timestamp?: number;
|
|
};
|
|
|
|
function getDefaultTotpIssuer() {
|
|
return loadRequiredWebConfig().appName;
|
|
}
|
|
|
|
function buildTotp(secret: string, label: string, issuer = getDefaultTotpIssuer()) {
|
|
return new OTPAuth.TOTP({
|
|
issuer,
|
|
label,
|
|
algorithm: "SHA1",
|
|
digits: TOTP_DIGITS,
|
|
period: TOTP_PERIOD_SECONDS,
|
|
secret: OTPAuth.Secret.fromBase32(secret),
|
|
});
|
|
}
|
|
|
|
export function generateTotpSecret() {
|
|
return new OTPAuth.Secret({ size: DEFAULT_SECRET_SIZE }).base32;
|
|
}
|
|
|
|
export function encryptTotpSecret(secret: string) {
|
|
return CryptoApi.encrypt(secret);
|
|
}
|
|
|
|
export function decryptTotpSecret(encryptedSecret: string) {
|
|
return CryptoApi.decrypt(encryptedSecret);
|
|
}
|
|
|
|
export function buildTotpOtpauthUri(secret: string, userEmail: string, options: OtpauthOptions = {}) {
|
|
const issuer = options.issuer ?? options.appName ?? getDefaultTotpIssuer();
|
|
return buildTotp(secret, userEmail, issuer).toString();
|
|
}
|
|
|
|
export function startTotpEnrollment(userEmail: string, options: OtpauthOptions = {}): TotpEnrollment {
|
|
const secret = generateTotpSecret();
|
|
const otpauthUri = buildTotpOtpauthUri(secret, userEmail, options);
|
|
return {
|
|
secret,
|
|
encryptedSecret: encryptTotpSecret(secret),
|
|
otpauthUri,
|
|
qrValue: otpauthUri,
|
|
};
|
|
}
|
|
|
|
export function verifyTotpCode(encryptedSecret: string, code: string, options: VerifyOptions = {}) {
|
|
if (!new RegExp(`^\\d{${TOTP_DIGITS}}$`).test(code)) {
|
|
return false;
|
|
}
|
|
const window = options.window ?? DEFAULT_WINDOW;
|
|
if (!Number.isInteger(window) || window < 0) {
|
|
throw new Error("window must be a non-negative integer");
|
|
}
|
|
let secret: string;
|
|
try {
|
|
secret = decryptTotpSecret(encryptedSecret);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const timestamp = options.timestamp ?? Date.now();
|
|
const delta = buildTotp(secret, "verify").validate({ token: code, window, timestamp });
|
|
return delta !== null;
|
|
}
|
|
|
|
export function getTotpSafeStatus({
|
|
totpEnabledAt,
|
|
totpPendingCreatedAt,
|
|
hasEncryptedSecret,
|
|
hasEncryptedPendingSecret,
|
|
}: {
|
|
totpEnabledAt: Date | null | undefined;
|
|
totpPendingCreatedAt: Date | null | undefined;
|
|
hasEncryptedSecret: boolean;
|
|
hasEncryptedPendingSecret: boolean;
|
|
}): TotpSafeStatus {
|
|
return {
|
|
enabled: Boolean(hasEncryptedSecret && totpEnabledAt),
|
|
pending: Boolean(hasEncryptedPendingSecret && totpPendingCreatedAt),
|
|
enabledAt: totpEnabledAt ?? null,
|
|
pendingCreatedAt: totpPendingCreatedAt ?? null,
|
|
};
|
|
}
|
|
|
|
export async function createTotpEnrollmentForUser({
|
|
userId,
|
|
userEmail,
|
|
issuer,
|
|
now = new Date(),
|
|
}: {
|
|
userId: string;
|
|
userEmail: string;
|
|
issuer?: string;
|
|
now?: Date;
|
|
}): Promise<TotpEnrollmentResult> {
|
|
const enrollment = startTotpEnrollment(userEmail, { issuer });
|
|
await setUserTotpPendingSecret(userId, enrollment.encryptedSecret, now);
|
|
return {
|
|
secret: enrollment.secret,
|
|
encryptedSecret: enrollment.encryptedSecret,
|
|
otpauthUri: enrollment.otpauthUri,
|
|
recoveryCodesPending: true,
|
|
};
|
|
}
|
|
|
|
export async function activatePendingTotpForUser({
|
|
userId,
|
|
token,
|
|
now = new Date(),
|
|
}: {
|
|
userId: string;
|
|
token: string;
|
|
now?: Date;
|
|
}): Promise<{ activated: boolean; recoveryCodes: string[] }> {
|
|
const pending = await getUserTotpPendingEnrollment(userId);
|
|
if (!pending?.totpPendingSecretEncrypted) {
|
|
return { activated: false, recoveryCodes: [] };
|
|
}
|
|
if (!verifyTotpCode(pending.totpPendingSecretEncrypted, token, { timestamp: now.getTime() })) {
|
|
return { activated: false, recoveryCodes: [] };
|
|
}
|
|
const recovery = generateRecoveryCodes({ count: TOTP_RECOVERY_CODE_COUNT, now });
|
|
await activateUserTotpPendingSecret(userId, pending.totpPendingSecretEncrypted, now);
|
|
await setUserMfaRecoveryCodes(userId, recovery.entries, recovery.generatedAt);
|
|
return { activated: true, recoveryCodes: recovery.codes };
|
|
}
|
|
|