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
46 changes: 38 additions & 8 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.1.5",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.9.0",
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.2",
"class": "^0.1.4",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class AuthController {
async login(
@Body('email') email: string,
@Body('password') password: string,
@Body('role') role: string, // expects 'ADMIN', 'STUDENT', or 'TUTOR'
@Body('role') role: string,
) {
const user = await this.authService.validateUser(email, password, role);
if (!user) throw new UnauthorizedException('Invalid credentials');
Expand Down
14 changes: 11 additions & 3 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
Expand All @@ -7,6 +7,8 @@ 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';
import { WorldcoinService } from './services/worldcoin.service';
import { WorldcoinVerificationMiddleware } from './middleware/worldcoin-verification.middleware';

@Module({
imports: [
Expand All @@ -19,6 +21,12 @@ import { EmailService } from './services/email.service';
],
providers: [AuthService, EmailService],
controllers: [AuthController],
exports: [AuthService],
exports: [AuthService, WorldcoinService],
})
export class AuthModule {}
export class AuthModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(WorldcoinVerificationMiddleware)
.forRoutes('auth/register/student', 'auth/register/tutor');
}
}
20 changes: 10 additions & 10 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Injectable, UnauthorizedException, BadRequestException } 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';
Expand Down Expand Up @@ -73,9 +77,8 @@ export class AuthService {
}

async refresh(refreshToken: string) {
let payload: any;
try {
payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
Expand Down Expand Up @@ -117,7 +120,8 @@ export class AuthService {
default:
return 7 * 24 * 60 * 60 * 1000;
}
} async requestEmailVerification(email: string): Promise<void> {
}
async requestEmailVerification(email: string): Promise<void> {
const user = await this.adminRepo.findOne({ where: { email } });
if (!user) {
throw new BadRequestException('User not found');
Expand All @@ -126,7 +130,7 @@ export class AuthService {
// Invalidate any existing verification tokens
await this.emailVerificationRepo.update(
{ email, verified: false },
{ verified: true }
{ verified: true },
);

const token = randomBytes(32).toString('hex');
Expand Down Expand Up @@ -174,10 +178,7 @@ export class AuthService {
}

// Invalidate any existing reset tokens
await this.passwordResetRepo.update(
{ email, used: false },
{ used: true }
);
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
Expand Down Expand Up @@ -219,4 +220,3 @@ export class AuthService {
await this.passwordResetRepo.save(reset);
}
}

2 changes: 1 addition & 1 deletion backend/src/auth/dto/email-verification.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ export class RequestEmailVerificationDto {
@IsEmail()
@IsNotEmpty()
email: string;
}
}
2 changes: 1 addition & 1 deletion backend/src/auth/dto/password-reset.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export class ResetPasswordDto {
@IsNotEmpty()
@MinLength(8)
newPassword: string;
}
}
23 changes: 23 additions & 0 deletions backend/src/auth/dto/worldcoin-proof.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IsString, IsNotEmpty } from 'class-validator';

export class WorldcoinProofDto {
@IsString()
@IsNotEmpty()
signal: string;

@IsString()
@IsNotEmpty()
proof: string;

@IsString()
@IsNotEmpty()
merkle_root: string;

@IsString()
@IsNotEmpty()
nullifier_hash: string;

@IsString()
@IsNotEmpty()
action: string;
}
10 changes: 8 additions & 2 deletions backend/src/auth/entities/password-reset.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn } from 'typeorm';
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
CreateDateColumn,
} from 'typeorm';
import { Admin } from '../../admin/entities/admin.entity';

@Entity()
Expand All @@ -23,4 +29,4 @@ export class PasswordReset {

@ManyToOne(() => Admin)
user: Admin;
}
}
44 changes: 44 additions & 0 deletions backend/src/auth/middleware/worldcoin-verification.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Injectable,
NestMiddleware,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { WorldcoinService } from '../services/worldcoin.service';

interface WorldcoinProofBody {
signal: string;
proof: string;
merkle_root: string;
nullifier_hash: string;
action: string;
}

@Injectable()
export class WorldcoinVerificationMiddleware implements NestMiddleware {
constructor(private readonly worldcoinService: WorldcoinService) {}

async use(req: Request, res: Response, next: NextFunction) {
const { signal, proof, merkle_root, nullifier_hash, action } =
req.body as WorldcoinProofBody;

if (!signal || !proof || !merkle_root || !nullifier_hash || !action) {
throw new BadRequestException('Missing Worldcoin proof data');
}

const isValid = await this.worldcoinService.verifyProof({
signal,
proof,
merkle_root,
nullifier_hash,
action,
});

if (!isValid) {
throw new UnauthorizedException('Invalid Worldcoin proof');
}

next();
}
}
6 changes: 3 additions & 3 deletions backend/src/auth/services/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class EmailService {

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,
Expand All @@ -35,7 +35,7 @@ export class EmailService {

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,
Expand All @@ -49,4 +49,4 @@ export class EmailService {
`,
});
}
}
}
43 changes: 43 additions & 0 deletions backend/src/auth/services/worldcoin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import axios, { AxiosResponse } from 'axios';
import { WorldcoinProofDto } from '../dto/worldcoin-proof.dto';

interface WorldcoinVerifyResponse {
success: boolean;
[key: string]: unknown;
}

@Injectable()
export class WorldcoinService {
private readonly VERIFY_ENDPOINT =
'https://developer.worldcoin.org/api/v1/verify';

async verifyProof(proofDto: WorldcoinProofDto): Promise<boolean> {
try {
const response: AxiosResponse<WorldcoinVerifyResponse> = await axios.post(
this.VERIFY_ENDPOINT,
{
signal: proofDto.signal,
proof: proofDto.proof,
merkle_root: proofDto.merkle_root,
nullifier_hash: proofDto.nullifier_hash,
action: proofDto.action,
},
);

if (response.data && typeof response.data.success === 'boolean') {
return response.data.success;
} else {
throw new HttpException(
'Invalid response from Worldcoin verification',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
} catch {
throw new HttpException(
'Worldcoin verification failed',
HttpStatus.UNAUTHORIZED,
);
}
}
}
Loading