296 lines
12 KiB
JavaScript
296 lines
12 KiB
JavaScript
import { writeFileSync } from "fs";
|
|
|
|
// ─── auth.service.ts ─────────────────────────────────────────────────────────
|
|
writeFileSync("src/auth/auth.service.ts", `import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from "@nestjs/common";
|
|
import * as crypto from "crypto";
|
|
import { JwtService } from "@nestjs/jwt";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { EmailService } from "../email/email.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,
|
|
) {}
|
|
|
|
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.");
|
|
}
|
|
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, 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));
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── auth.controller.ts ──────────────────────────────────────────────────────
|
|
writeFileSync("src/auth/auth.controller.ts", `import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
|
import { ok } from "../common/response";
|
|
import { AuthService } from "./auth.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";
|
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
import { Public } from "../common/decorators/public.decorator";
|
|
|
|
@Controller("auth")
|
|
@UseGuards(JwtAuthGuard)
|
|
export class AuthController {
|
|
constructor(private readonly authService: AuthService) {}
|
|
|
|
@Public()
|
|
@Post("register")
|
|
async register(@Body() payload: RegisterDto) {
|
|
return ok(await this.authService.register(payload));
|
|
}
|
|
|
|
@Public()
|
|
@Post("login")
|
|
async login(@Body() payload: LoginDto) {
|
|
return ok(await this.authService.login(payload));
|
|
}
|
|
|
|
@Public()
|
|
@Get("verify-email")
|
|
async verifyEmail(@Query("token") token: string) {
|
|
return ok(await this.authService.verifyEmail(token));
|
|
}
|
|
|
|
@Public()
|
|
@Post("refresh")
|
|
async refresh(@Body("refreshToken") refreshToken: string) {
|
|
return ok(await this.authService.refreshAccessToken(refreshToken));
|
|
}
|
|
|
|
@Post("logout")
|
|
async logout(@Body("refreshToken") refreshToken: string) {
|
|
return ok(await this.authService.logout(refreshToken));
|
|
}
|
|
|
|
@Public()
|
|
@Post("forgot-password")
|
|
async forgotPassword(@Body() payload: ForgotPasswordDto) {
|
|
return ok(await this.authService.forgotPassword(payload));
|
|
}
|
|
|
|
@Public()
|
|
@Post("reset-password")
|
|
async resetPassword(@Body() payload: ResetPasswordDto) {
|
|
return ok(await this.authService.resetPassword(payload));
|
|
}
|
|
|
|
@Get("me")
|
|
async me(@CurrentUser() userId: string) {
|
|
return ok(await this.authService.getProfile(userId));
|
|
}
|
|
|
|
@Patch("profile")
|
|
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
|
return ok(await this.authService.updateProfile(userId, payload));
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── auth.module.ts ──────────────────────────────────────────────────────────
|
|
writeFileSync("src/auth/auth.module.ts", `import { Module } from "@nestjs/common";
|
|
import { JwtModule } from "@nestjs/jwt";
|
|
import { AuthController } from "./auth.controller";
|
|
import { AuthService } from "./auth.service";
|
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
|
|
|
@Module({
|
|
imports: [
|
|
JwtModule.register({
|
|
secret: process.env.JWT_SECRET,
|
|
signOptions: { expiresIn: "15m" },
|
|
}),
|
|
],
|
|
controllers: [AuthController],
|
|
providers: [AuthService, JwtAuthGuard],
|
|
exports: [AuthService, JwtModule, JwtAuthGuard],
|
|
})
|
|
export class AuthModule {}
|
|
`);
|
|
|
|
console.log("auth files written");
|