Skip to content
Open
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
338 changes: 320 additions & 18 deletions apps/backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,20 @@
"@nestjs/platform-socket.io": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.0",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.0.1",
"@stellar/stellar-sdk": "^14.5.0",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"dotenv": "^17.3.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^8.0.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { StellarModule } from './modules/stellar/stellar.module';
import { WebhookModule } from './modules/webhook/webhook.module';
import { User } from './modules/user/entities/user.entity';
import { RefreshToken } from './modules/user/entities/refresh-token.entity';
import { EmailVerification } from './modules/user/entities/email-verification.entity';
import { Escrow } from './modules/escrow/entities/escrow.entity';
import { Party } from './modules/escrow/entities/party.entity';
import { Condition } from './modules/escrow/entities/condition.entity';
Expand All @@ -30,6 +31,7 @@ import { StellarEventModule } from './modules/stellar/stellar-event.module';
import { AssetsModule } from './modules/assets/assets.module';
import { AllowedAsset } from './modules/assets/entities/allowed-asset.entity';
import { IpfsModule } from './modules/ipfs/ipfs.module';
import { HealthModule } from './modules/health/health.module';
import { EscrowGateway } from './gateways/escrow.gateway';
import stellarConfig from './config/stellar.config';
import ipfsConfig from './config/ipfs.config';
Expand All @@ -52,6 +54,7 @@ import ipfsConfig from './config/ipfs.config';
entities: [
User,
RefreshToken,
EmailVerification,
Escrow,
Party,
Condition,
Expand Down Expand Up @@ -82,6 +85,7 @@ import ipfsConfig from './config/ipfs.config';
forwardRef(() => StellarEventModule),
AssetsModule,
IpfsModule,
HealthModule,
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
secret:
Expand Down
57 changes: 57 additions & 0 deletions apps/backend/src/migrations/1780262000000-ImplementFourFeatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class ImplementFourFeatures1780262000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Add new fields to User entity
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "displayName" varchar(100)`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "email" varchar(255)`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "emailVerified" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUrl" varchar(500)`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "bio" text`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "preferredAsset" varchar(20) NOT NULL DEFAULT 'XLM'`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_users_email" ON "users" ("email") WHERE "email" IS NOT NULL`);

// Create EmailVerification table
await queryRunner.query(`
CREATE TABLE "email_verifications" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"token" character varying NOT NULL,
"expiresAt" TIMESTAMP NOT NULL,
"isUsed" boolean NOT NULL DEFAULT false,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_email_verifications_id" PRIMARY KEY ("id"),
CONSTRAINT "UQ_email_verifications_token" UNIQUE ("token")
)
`);
await queryRunner.query(`ALTER TABLE "email_verifications" ADD CONSTRAINT "FK_email_verifications_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE`);

// Add new fields to Escrow entity
await queryRunner.query(`ALTER TABLE "escrows" ADD COLUMN "releasedAmount" numeric(18,7) NOT NULL DEFAULT 0`);

// Add new fields to Condition entity
await queryRunner.query(`ALTER TABLE "escrow_conditions" ADD COLUMN "isReleased" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "escrow_conditions" ADD COLUMN "releasedAt" TIMESTAMP`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Drop new columns from Condition entity
await queryRunner.query(`ALTER TABLE "escrow_conditions" DROP COLUMN "releasedAt"`);
await queryRunner.query(`ALTER TABLE "escrow_conditions" DROP COLUMN "isReleased"`);

// Drop new columns from Escrow entity
await queryRunner.query(`ALTER TABLE "escrows" DROP COLUMN "releasedAmount"`);

// Drop EmailVerification table
await queryRunner.query(`DROP TABLE "email_verifications"`);

// Drop new columns from User entity
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_users_email"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "preferredAsset"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "bio"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarUrl"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "emailVerified"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "email"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "displayName"`);
}
}
5 changes: 5 additions & 0 deletions apps/backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { AuthService } from './services/auth.service';
import { AuthGuard } from './middleware/auth.guard';
import { AdminGuard } from './middleware/admin.guard';
import { UserModule } from '../user/user.module';
import { IpfsModule } from '../ipfs/ipfs.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailVerification } from '../user/entities/email-verification.entity';

@Module({
imports: [
UserModule,
IpfsModule,
TypeOrmModule.forFeature([EmailVerification]),
JwtModule.registerAsync({
useFactory: () => ({
secret:
Expand Down
79 changes: 72 additions & 7 deletions apps/backend/src/modules/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,52 @@
Req,
HttpCode,
HttpStatus,
Res,
Patch,
Query,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { Request } from 'express';
import { ThrottlerGuard } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request, Response } from 'express';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from '../services/auth.service';
import {
ChallengeDto,
VerifyDto,
RefreshTokenDto,
LogoutDto,
} from '../dto/auth.dto';
import { UpdateProfileDto } from '../dto/profile.dto';
import { AuthGuard } from '../middleware/auth.guard';
import { AuthThrottlerGuard } from '../middleware/auth-throttler.guard';

@Controller('auth')
@UseGuards(ThrottlerGuard)
@UseGuards(AuthThrottlerGuard)
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('challenge')
@HttpCode(HttpStatus.OK)
async challenge(@Body() challengeDto: ChallengeDto) {
@Throttle({ default: { limit: 10, ttl: 60000 } })
async challenge(@Body() challengeDto: ChallengeDto, @Res({ passthrough: true }) res: Response) {
return this.authService.generateChallenge(challengeDto.walletAddress);
}

Check failure on line 40 in apps/backend/src/modules/auth/controllers/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'res' is defined but never used

@Post('verify')
@HttpCode(HttpStatus.OK)
async verify(@Body() verifyDto: VerifyDto) {
@Throttle({ default: { limit: 5, ttl: 60000 } })
async verify(@Body() verifyDto: VerifyDto, @Res({ passthrough: true }) res: Response) {
return this.authService.verifySignature(
verifyDto.signature,
verifyDto.publicKey,
);
}

Check failure on line 50 in apps/backend/src/modules/auth/controllers/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'res' is defined but never used

@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
@Throttle({ default: { limit: 20, ttl: 60000 } })
async refresh(@Body() refreshTokenDto: RefreshTokenDto, @Res({ passthrough: true }) res: Response) {
return this.authService.refreshAccessToken(refreshTokenDto.refreshToken);
}

Expand All @@ -49,18 +60,72 @@
@UseGuards(AuthGuard)
async getCurrentUser(@Req() req: Request & { user: { userId: string } }) {
const user = await this.authService.getCurrentUser(req.user.userId);
return {

Check failure on line 63 in apps/backend/src/modules/auth/controllers/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'res' is defined but never used
id: user.id,
walletAddress: user.walletAddress,
isActive: user.isActive,
createdAt: user.createdAt,
displayName: user.displayName,
email: user.email,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
bio: user.bio,
preferredAsset: user.preferredAsset,
};
}

@Patch('profile')
@UseGuards(AuthGuard)
async updateProfile(
@Req() req: Request & { user: { userId: string } },
@Body() updateProfileDto: UpdateProfileDto,
) {
const user = await this.authService.updateProfile(req.user.userId, updateProfileDto);
return {
id: user.id,
walletAddress: user.walletAddress,
displayName: user.displayName,
email: user.email,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
bio: user.bio,
preferredAsset: user.preferredAsset,
};
}

@Post('profile/avatar')
@UseGuards(AuthGuard)
@UseInterceptors(FileInterceptor('avatar'))
async uploadAvatar(
@Req() req: Request & { user: { userId: string } },
@UploadedFile() file: { buffer: Buffer; originalname: string },
) {
const user = await this.authService.uploadAvatar(req.user.userId, file);
return {
id: user.id,
avatarUrl: user.avatarUrl,
};
}

@Post('profile/verify-email')
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async sendEmailVerification(@Req() req: Request & { user: { userId: string } }) {
await this.authService.sendEmailVerification(req.user.userId);
return { message: 'Verification email sent' };
}

@Get('profile/verify-email')
async verifyEmail(@Query('token') token: string) {
await this.authService.verifyEmail(token);
return { message: 'Email verified successfully' };
}

@Post('logout')
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async logout(@Body() logoutDto: LogoutDto) {
@Throttle({ default: { limit: 10, ttl: 60000 } })
async logout(@Body() logoutDto: LogoutDto, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(logoutDto.refreshToken);
return { message: 'Successfully logged out' };
}
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/src/modules/auth/dto/profile.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';

export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(100)
displayName?: string;

@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;

@IsOptional()
@IsBoolean()
emailVerified?: boolean;

@IsOptional()
@IsString()
bio?: string;

@IsOptional()
@IsString()
@MaxLength(20)
preferredAsset?: string;
}
15 changes: 15 additions & 0 deletions apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {

Check failure on line 6 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type

Check failure on line 6 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Async method 'getTracker' has no 'await' expression
// Get client IP, handle proxy headers
return (

Check failure on line 8 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unsafe return of a value of type `any`
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||

Check failure on line 9 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unsafe member access ['x-forwarded-for'] on an `any` value

Check failure on line 9 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unsafe call of an `any` typed value

Check failure on line 9 in apps/backend/src/modules/auth/middleware/auth-throttler.guard.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unsafe call of an `any` typed value
req.ip ||
req.connection?.remoteAddress ||
'unknown-ip'
);
}
}
77 changes: 76 additions & 1 deletion apps/backend/src/modules/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as crypto from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { UserService } from '../../user/user.service';
import { EmailVerification } from '../../user/entities/email-verification.entity';
import { UpdateProfileDto } from '../dto/profile.dto';
import { IpfsService } from '../../ipfs/ipfs.service';

// Stellar SDK types for signature verification
interface StellarKeypair {
Expand All @@ -25,6 +30,9 @@ export class AuthService {
private userService: UserService,
private jwtService: JwtService,
private configService: ConfigService,
@InjectRepository(EmailVerification)
private emailVerificationRepository: Repository<EmailVerification>,
private ipfsService: IpfsService,
) {}

async generateChallenge(
Expand Down Expand Up @@ -119,6 +127,73 @@ export class AuthService {
return user;
}

async updateProfile(userId: string, updateProfileDto: UpdateProfileDto): Promise<User> {
const user = await this.userService.findById(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}

// If email is being updated, reset emailVerified
if (updateProfileDto.email && updateProfileDto.email !== user.email) {
updateProfileDto.emailVerified = false;
}

return this.userService.update(userId, updateProfileDto);
}

async uploadAvatar(userId: string, file: { buffer: Buffer; originalname: string }): Promise<User> {
const user = await this.userService.findById(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}

const cid = await this.ipfsService.uploadFile(file.buffer, file.originalname);
const avatarUrl = this.ipfsService.getGatewayUrl(cid);

return this.userService.update(userId, { avatarUrl });
}

async sendEmailVerification(userId: string): Promise<void> {
const user = await this.userService.findById(userId);
if (!user) {
throw new UnauthorizedException('User not found');
}
if (!user.email) {
throw new BadRequestException('No email set for user');
}

// Generate token
const token = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);

// Save token
const emailVerification = this.emailVerificationRepository.create({
userId,
token,
expiresAt,
});
await this.emailVerificationRepository.save(emailVerification);

// TODO: Actually send email (for now, just log it
console.log(`Email verification token for ${user.email}: ${token}`);
}

async verifyEmail(token: string): Promise<void> {
const verification = await this.emailVerificationRepository.findOne({
where: { token, isUsed: false },
});

if (!verification || verification.expiresAt < new Date()) {
throw new BadRequestException('Invalid or expired verification token');
}

verification.isUsed = true;
await this.emailVerificationRepository.save(verification);

await this.userService.update(verification.userId, { emailVerified: true });
}

async validateToken(
token: string,
): Promise<{ userId: string; walletAddress: string }> {
Expand Down
Loading
Loading