323 lines
13 KiB
JavaScript
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");
|