Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 12 additions & 3 deletions backend/src/admin/entities/admin.entity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { UserRole } from '../../roles/roles.enum';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
// import { UserRole } from '../roles/roles.enum';

@Entity('admins')
Expand All @@ -22,16 +28,19 @@ export class Admin {
@Column({
type: 'enum',
enum: UserRole,
default: UserRole.ADMIN
default: UserRole.ADMIN,
})
role: UserRole;

@Column({ default: true })
isActive: boolean;

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

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
}
34 changes: 33 additions & 1 deletion 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' };
}
}
16 changes: 12 additions & 4 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
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]),
TypeOrmModule.forFeature([
RefreshToken,
EmailVerification,
PasswordReset,
Admin,
]),
],
providers: [AuthService],
controllers: [AuthController],
providers: [AuthService, EmailService],
exports: [AuthService],
})
export class AuthModule {}
113 changes: 112 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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();


Expand All @@ -15,8 +19,13 @@ 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(email: string, password: string, role: string): Promise<Admin | null> {
Expand Down Expand Up @@ -96,4 +105,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;
}
Loading
Loading