Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/admin/entities/admin.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export class Admin {
@Column({ default: true })
isActive: boolean;

@Column({ default: false })
emailVerified: boolean;

@CreateDateColumn()
createdAt: Date;

Expand Down
32 changes: 32 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import {
VerifyEmailDto,
RequestEmailVerificationDto,
} from './dto/email-verification.dto';
import { ForgotPasswordDto, ResetPasswordDto } from './dto/password-reset.dto';

@Controller('auth')
export class AuthController {
Expand All @@ -26,4 +31,31 @@ export class AuthController {
await this.authService.logout(refreshToken);
return { message: 'Logged out' };
}

@Post('request-verification')
async requestEmailVerification(@Body() dto: RequestEmailVerificationDto) {
await this.authService.requestEmailVerification(dto.email);
return { message: 'Verification email sent' };
}

@Post('verify-email')
async verifyEmail(@Body() dto: VerifyEmailDto) {
await this.authService.verifyEmail(dto.token);
return { message: 'Email verified successfully' };
}

@Post('forgot-password')
async forgotPassword(@Body() dto: ForgotPasswordDto) {
await this.authService.forgotPassword(dto.email);
return {
message:
'If an account exists with this email, a password reset link has been sent',
};
}

@Post('reset-password')
async resetPassword(@Body() dto: ResetPasswordDto) {
await this.authService.resetPassword(dto.token, dto.newPassword);
return { message: 'Password reset successfully' };
}
}
18 changes: 14 additions & 4 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { EmailVerification } from './entities/email-verification.entity';
import { PasswordReset } from './entities/password-reset.entity';
import { Admin } from '../admin/entities/admin.entity';
import { EmailService } from './services/email.service';

@Module({
imports: [TypeOrmModule.forFeature([RefreshToken, Admin])],
providers: [AuthService],
imports: [
TypeOrmModule.forFeature([
RefreshToken,
EmailVerification,
PasswordReset,
Admin,
]),
],
providers: [AuthService, EmailService],
controllers: [AuthController],
exports: [AuthService],
})
Expand Down
112 changes: 111 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
import { EmailVerification } from './entities/email-verification.entity';
import { PasswordReset } from './entities/password-reset.entity';
import { Admin } from '../admin/entities/admin.entity';
import * as jwt from 'jsonwebtoken';
import * as bcrypt from 'bcrypt';
import { UserRole } from '../roles/roles.enum';
import * as dotenv from 'dotenv';
import { EmailService } from './services/email.service';
import { randomBytes } from 'crypto';
dotenv.config();

@Injectable()
export class AuthService {
constructor(
@InjectRepository(RefreshToken)
private readonly refreshTokenRepo: Repository<RefreshToken>,
@InjectRepository(EmailVerification)
private readonly emailVerificationRepo: Repository<EmailVerification>,
@InjectRepository(PasswordReset)
private readonly passwordResetRepo: Repository<PasswordReset>,
@InjectRepository(Admin)
private readonly adminRepo: Repository<Admin>,
private readonly emailService: EmailService,
) {}

async validateUser(
Expand Down Expand Up @@ -108,5 +117,106 @@ export class AuthService {
default:
return 7 * 24 * 60 * 60 * 1000;
}
} async requestEmailVerification(email: string): Promise<void> {
const user = await this.adminRepo.findOne({ where: { email } });
if (!user) {
throw new BadRequestException('User not found');
}

// Invalidate any existing verification tokens
await this.emailVerificationRepo.update(
{ email, verified: false },
{ verified: true }
);

const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours

const verification = this.emailVerificationRepo.create({
token,
email,
expiresAt,
user,
});

await this.emailVerificationRepo.save(verification);
await this.emailService.sendVerificationEmail(email, token);
}

async verifyEmail(token: string): Promise<void> {
const verification = await this.emailVerificationRepo.findOne({
where: { token, verified: false },
relations: ['user'],
});

if (!verification) {
throw new BadRequestException('Invalid verification token');
}

if (verification.expiresAt < new Date()) {
throw new BadRequestException('Verification token has expired');
}

verification.verified = true;
await this.emailVerificationRepo.save(verification);

if (verification.user) {
verification.user.emailVerified = true;
await this.adminRepo.save(verification.user);
}
}

async forgotPassword(email: string): Promise<void> {
const user = await this.adminRepo.findOne({ where: { email } });
if (!user) {
// Don't reveal that the user doesn't exist
return;
}

// Invalidate any existing reset tokens
await this.passwordResetRepo.update(
{ email, used: false },
{ used: true }
);

const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

const reset = this.passwordResetRepo.create({
token,
email,
expiresAt,
user,
});

await this.passwordResetRepo.save(reset);
await this.emailService.sendPasswordResetEmail(email, token);
}

async resetPassword(token: string, newPassword: string): Promise<void> {
const reset = await this.passwordResetRepo.findOne({
where: { token, used: false },
relations: ['user'],
});

if (!reset) {
throw new BadRequestException('Invalid reset token');
}

if (reset.expiresAt < new Date()) {
throw new BadRequestException('Reset token has expired');
}

if (!reset.user) {
throw new BadRequestException('User not found');
}

const hashedPassword = await bcrypt.hash(newPassword, 10);
reset.user.password = hashedPassword;
reset.used = true;

await this.adminRepo.save(reset.user);
await this.passwordResetRepo.save(reset);
}
}

13 changes: 13 additions & 0 deletions backend/src/auth/dto/email-verification.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IsEmail, IsString, IsNotEmpty } from 'class-validator';

export class VerifyEmailDto {
@IsString()
@IsNotEmpty()
token: string;
}

export class RequestEmailVerificationDto {
@IsEmail()
@IsNotEmpty()
email: string;
}
18 changes: 18 additions & 0 deletions backend/src/auth/dto/password-reset.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator';

export class ForgotPasswordDto {
@IsEmail()
@IsNotEmpty()
email: string;
}

export class ResetPasswordDto {
@IsString()
@IsNotEmpty()
token: string;

@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword: string;
}
32 changes: 32 additions & 0 deletions backend/src/auth/entities/email-verification.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
CreateDateColumn,
} from 'typeorm';
import { Admin } from '../../admin/entities/admin.entity';

@Entity()
export class EmailVerification {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
token: string;

@Column()
email: string;

@Column({ default: false })
verified: boolean;

@CreateDateColumn()
createdAt: Date;

@Column()
expiresAt: Date;

@ManyToOne(() => Admin, { nullable: true })
user: Admin;
}
26 changes: 26 additions & 0 deletions backend/src/auth/entities/password-reset.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm';
import { Admin } from '../../admin/entities/admin.entity';

@Entity()
export class PasswordReset {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
token: string;

@Column()
email: string;

@Column({ default: false })
used: boolean;

@CreateDateColumn()
createdAt: Date;

@Column()
expiresAt: Date;

@ManyToOne(() => Admin)
user: Admin;
}
52 changes: 52 additions & 0 deletions backend/src/auth/services/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import * as nodemailer from 'nodemailer';

@Injectable()
export class EmailService {
private transporter: nodemailer.Transporter;

constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}

async sendVerificationEmail(email: string, token: string): Promise<void> {
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${token}`;

await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to: email,
subject: 'Verify your email address',
html: `
<h1>Email Verification</h1>
<p>Please click the link below to verify your email address:</p>
<a href="${verificationUrl}">${verificationUrl}</a>
<p>This link will expire in 24 hours.</p>
`,
});
}

async sendPasswordResetEmail(email: string, token: string): Promise<void> {
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;

await this.transporter.sendMail({
from: process.env.SMTP_FROM,
to: email,
subject: 'Reset your password',
html: `
<h1>Password Reset</h1>
<p>Please click the link below to reset your password:</p>
<a href="${resetUrl}">${resetUrl}</a>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, please ignore this email.</p>
`,
});
}
}
Loading