Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/totp.server.ts
T

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 };
}