ledgerone_backend/write-2fa-speakeasy.mjs
2026-03-14 08:51:16 -04:00

323 lines
13 KiB
JavaScript

import { writeFileSync } from "fs";
writeFileSync("src/auth/twofa/two-factor.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
import * as speakeasy from "speakeasy";
import * as QRCode from "qrcode";
import { PrismaService } from "../../prisma/prisma.service";
import { EncryptionService } from "../../common/encryption.service";
@Injectable()
export class TwoFactorService {
constructor(
private readonly prisma: PrismaService,
private readonly encryption: EncryptionService,
) {}
async generateSecret(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true, twoFactorEnabled: true },
});
if (!user) throw new BadRequestException("User not found.");
if (user.twoFactorEnabled) {
throw new BadRequestException("2FA is already enabled.");
}
const secret = speakeasy.generateSecret({ name: \`LedgerOne:\${user.email}\`, length: 20 });
const otpAuthUrl = secret.otpauth_url ?? "";
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
// Store encrypted secret temporarily (not yet enabled)
await this.prisma.user.update({
where: { id: userId },
data: { twoFactorSecret: this.encryption.encrypt(secret.base32) },
});
return { qrCode: qrCodeDataUrl, otpAuthUrl };
}
async enableTwoFactor(userId: string, token: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorEnabled: true },
});
if (!user?.twoFactorSecret) {
throw new BadRequestException("Please generate a 2FA secret first.");
}
if (user.twoFactorEnabled) {
throw new BadRequestException("2FA is already enabled.");
}
const secret = this.encryption.decrypt(user.twoFactorSecret);
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
if (!isValid) {
throw new BadRequestException("Invalid TOTP token.");
}
await this.prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: true },
});
await this.prisma.auditLog.create({
data: { userId, action: "auth.2fa.enabled", metadata: {} },
});
return { message: "2FA enabled successfully." };
}
async disableTwoFactor(userId: string, token: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorEnabled: true },
});
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
throw new BadRequestException("2FA is not enabled.");
}
const secret = this.encryption.decrypt(user.twoFactorSecret);
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
if (!isValid) {
throw new BadRequestException("Invalid TOTP token.");
}
await this.prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: false, twoFactorSecret: null },
});
await this.prisma.auditLog.create({
data: { userId, action: "auth.2fa.disabled", metadata: {} },
});
return { message: "2FA disabled successfully." };
}
verifyToken(encryptedSecret: string, token: string): boolean {
const secret = this.encryption.decrypt(encryptedSecret);
return speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
}
}
`);
// Update auth.service.ts to use speakeasy instead of otplib
// (The login method needs to use speakeasy for 2FA verification)
// We need to update auth.service.ts to use speakeasy
writeFileSync("src/auth/auth.service.ts", `import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import * as crypto from "crypto";
import * as speakeasy from "speakeasy";
import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../prisma/prisma.service";
import { EmailService } from "../email/email.service";
import { EncryptionService } from "../common/encryption.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
const VERIFY_TOKEN_TTL_HOURS = 24;
const RESET_TOKEN_TTL_HOURS = 1;
const REFRESH_TOKEN_TTL_DAYS = 30;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly emailService: EmailService,
private readonly encryption: EncryptionService,
) {}
async register(payload: RegisterDto) {
const email = payload.email.toLowerCase().trim();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) throw new BadRequestException("Email already registered.");
const passwordHash = this.hashPassword(payload.password);
const user = await this.prisma.user.create({ data: { email, passwordHash } });
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
const verifyToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000);
await this.prisma.emailVerificationToken.upsert({
where: { userId: user.id },
update: { token: verifyToken, expiresAt },
create: { userId: user.id, token: verifyToken, expiresAt },
});
await this.emailService.sendVerificationEmail(email, verifyToken);
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
message: "Registration successful. Please verify your email.",
};
}
async login(payload: LoginDto) {
const email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
throw new UnauthorizedException("Invalid credentials.");
}
// ── 2FA enforcement ──────────────────────────────────────────────────────
if (user.twoFactorEnabled && user.twoFactorSecret) {
if (!payload.totpToken) {
return { requiresTwoFactor: true, accessToken: null, refreshToken: null };
}
const secret = this.encryption.decrypt(user.twoFactorSecret);
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 });
if (!isValid) {
throw new UnauthorizedException("Invalid TOTP code.");
}
}
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
};
}
async verifyEmail(token: string) {
const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } });
if (!record || record.expiresAt < new Date()) {
throw new BadRequestException("Invalid or expired verification token.");
}
await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } });
await this.prisma.emailVerificationToken.delete({ where: { token } });
return { message: "Email verified successfully." };
}
async refreshAccessToken(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
if (!record || record.revokedAt || record.expiresAt < new Date()) {
throw new UnauthorizedException("Invalid or expired refresh token.");
}
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
const accessToken = this.signAccessToken(record.userId);
const refreshToken = await this.createRefreshToken(record.userId);
return { accessToken, refreshToken };
}
async logout(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken);
await this.prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
return { message: "Logged out." };
}
async forgotPassword(payload: ForgotPasswordDto) {
const email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) return { message: "If that email exists, a reset link has been sent." };
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000);
await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } });
await this.emailService.sendPasswordResetEmail(email, token);
return { message: "If that email exists, a reset link has been sent." };
}
async resetPassword(payload: ResetPasswordDto) {
const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } });
if (!record || record.usedAt || record.expiresAt < new Date()) {
throw new BadRequestException("Invalid or expired reset token.");
}
const passwordHash = this.hashPassword(payload.password);
await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } });
await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
await this.prisma.refreshToken.updateMany({
where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return { message: "Password reset successfully. Please log in." };
}
async updateProfile(userId: string, payload: UpdateProfileDto) {
const data: Record<string, string> = {};
for (const [key, value] of Object.entries(payload)) {
const trimmed = (value as string | undefined)?.trim();
if (trimmed) data[key] = trimmed;
}
if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
const user = await this.prisma.user.update({ where: { id: userId }, data });
await this.prisma.auditLog.create({
data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
});
return {
user: {
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
companyName: user.companyName, addressLine1: user.addressLine1,
addressLine2: user.addressLine2, city: user.city, state: user.state,
postalCode: user.postalCode, country: user.country,
},
};
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException("User not found.");
return {
user: {
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
companyName: user.companyName, addressLine1: user.addressLine1,
addressLine2: user.addressLine2, city: user.city, state: user.state,
postalCode: user.postalCode, country: user.country,
emailVerified: user.emailVerified,
twoFactorEnabled: user.twoFactorEnabled,
createdAt: user.createdAt,
},
};
}
verifyToken(token: string): { sub: string } {
return this.jwtService.verify<{ sub: string }>(token);
}
private signAccessToken(userId: string): string {
return this.jwtService.sign({ sub: userId });
}
private async createRefreshToken(userId: string): Promise<string> {
const raw = crypto.randomBytes(40).toString("hex");
const tokenHash = this.hashToken(raw);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
return raw;
}
private hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
return \`\${salt}:\${hash}\`;
}
private verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
if (!salt || !hash) return false;
const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
}
}
`);
console.log("2FA service (speakeasy) + auth.service.ts written");