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
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"class-validator": "^0.14.3",
"dotenv": "^17.3.1",
"helmet": "^8.1.0",
"isomorphic-dompurify": "^3.15.0",
"joi": "^18.0.2",
"multer": "^2.1.1",
"nestjs-pino": "^4.6.1",
Expand Down
3 changes: 2 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { AuthService } from './auth.service';
import { TwoFactorService } from './two-factor.service';
import { AuthController } from './auth.controller';
import { User } from '../modules/user/entities/user.entity';
import { CacheModule } from '../modules/cache/cache.module';

@Module({
imports: [
UserModule,
TypeOrmModule.forFeature([User]),
// CacheModule,
CacheModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
Expand Down
104 changes: 60 additions & 44 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
VerifySignatureDto,
LinkWalletDto,
} from './dto/auth.dto';
// import { Cache } from 'cache-manager';
// import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import * as bcrypt from 'bcrypt';
import { randomUUID } from 'crypto';
import * as StellarSdk from '@stellar/stellar-sdk';
Expand All @@ -27,7 +27,7 @@ export class AuthService {
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly eventEmitter: EventEmitter2,
// @Inject(CACHE_MANAGER) private cacheManager: Cache,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}

async register(dto: RegisterDto) {
Expand Down Expand Up @@ -101,14 +101,30 @@ export class AuthService {
}

async generateNonce(publicKey: string): Promise<{ nonce: string }> {
// Validate Stellar public key format
if (!StellarSdk.StrKey.isValidEd25519PublicKey(publicKey)) {
throw new BadRequestException('Invalid Stellar public key format');
}

const rateLimitKey = `nonce:ratelimit:${publicKey}`;
const rateLimitCount = await this.cacheManager.get<number>(rateLimitKey);
if (
rateLimitCount !== undefined &&
rateLimitCount !== null &&
rateLimitCount >= 5
) {
throw new UnauthorizedException('Too many nonce requests');
}

const newCount = (rateLimitCount ?? 0) + 1;
await this.cacheManager.set(rateLimitKey, newCount, 900000);

const nonce = randomUUID();
// const cacheKey = `nonce:${publicKey}`;
// await this.cacheManager.set(cacheKey, nonce, 300000); // 300 seconds = 5 minutes
const cacheKey = `nonce:${publicKey}`;
await this.cacheManager.set(
cacheKey,
{ nonce, timestamp: Date.now() },
300000,
);

return { nonce };
}
Expand All @@ -118,41 +134,43 @@ export class AuthService {
): Promise<{ accessToken: string }> {
const { publicKey, signature, nonce } = dto;

// Validate public key format
if (!StellarSdk.StrKey.isValidEd25519PublicKey(publicKey)) {
throw new BadRequestException('Invalid Stellar public key format');
}

// Retrieve stored nonce
// const cacheKey = `nonce:${publicKey}`;
// const storedNonce = await this.cacheManager.get<string>(cacheKey);
const storedNonce = nonce; // Temporarily bypass cache for testing
const cacheKey = `nonce:${publicKey}`;
const stored = await this.cacheManager.get<{
nonce: string;
timestamp: number;
}>(cacheKey);
if (!stored) {
throw new UnauthorizedException('Nonce not found or expired');
}

if (Date.now() - stored.timestamp > 300000) {
await this.cacheManager.del(cacheKey);
throw new UnauthorizedException('Nonce not found or expired');
}

if (!storedNonce) {
throw new UnauthorizedException(
'Nonce not found or expired. Request a new nonce.',
);
if (stored.nonce !== nonce) {
throw new UnauthorizedException('Nonce mismatch');
}

// Verify signature
const isValidSignature = this.verifyWalletSignature(
publicKey,
signature,
storedNonce,
nonce,
);

if (!isValidSignature) {
throw new UnauthorizedException('Invalid signature');
}

// Consume the nonce (delete it)
// await this.cacheManager.del(cacheKey);
await this.cacheManager.del(cacheKey);

// Find or create user by public key
let user = await this.userService.findByPublicKey(publicKey);

if (!user) {
// Create new user with public key
user = await this.userService.create({
publicKey,
email: `${publicKey.substring(0, 10)}@stellar.wallet`,
Expand All @@ -165,43 +183,41 @@ export class AuthService {
};
}

/**
* Link a Stellar wallet to an already-authenticated email account.
*
* Flow:
* 1. Caller fetches a nonce via GET /auth/nonce?publicKey=<key>
* 2. Caller signs the nonce with the wallet's Ed25519 secret key
* 3. Caller POSTs { publicKey, nonce, signature } + Bearer JWT to this endpoint
*
* The method:
* - Validates the Stellar key format
* - Verifies the Ed25519 signature (same logic as verifySignature)
* - Delegates to UserService.linkWallet, which enforces uniqueness at the DB row level
*
* @param userId Extracted from the verified JWT by JwtAuthGuard
* @param dto LinkWalletDto from request body
*/
async linkWallet(
userId: string,
dto: LinkWalletDto,
): Promise<{ walletAddress: string; message: string }> {
const { publicKey, nonce, signature } = dto;

// 1. Validate Stellar public key format
if (!StellarSdk.StrKey.isValidEd25519PublicKey(publicKey)) {
throw new BadRequestException('Invalid Stellar public key format');
}

// 2. Verify the Ed25519 signature over the nonce
// This proves the caller controls the private key behind publicKey.
const cacheKey = `nonce:${publicKey}`;
const stored = await this.cacheManager.get<{
nonce: string;
timestamp: number;
}>(cacheKey);
if (!stored) {
throw new UnauthorizedException('Nonce not found or expired');
}

if (Date.now() - stored.timestamp > 300000) {
await this.cacheManager.del(cacheKey);
throw new UnauthorizedException('Nonce has expired');
}

if (stored.nonce !== nonce) {
throw new UnauthorizedException('Nonce mismatch');
}

const isValid = this.verifyWalletSignature(publicKey, signature, nonce);
if (!isValid) {
throw new UnauthorizedException(
'Signature verification failed. Ensure you signed the exact nonce bytes.',
);
throw new UnauthorizedException('Signature verification failed');
}

// 3. Persist the link; UserService throws ConflictException on duplicates
await this.cacheManager.del(cacheKey);

const updatedUser = await this.userService.linkWalletAddress(
userId,
publicKey,
Expand Down
7 changes: 1 addition & 6 deletions backend/src/auth/two-factor.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import { JwtService } from '@nestjs/jwt';
import { TwoFactorService } from './two-factor.service';
import { User } from '../modules/user/entities/user.entity';

jest.mock('qrcode', () => ({
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,mockqrcode'),
}));

const mockUser = (overrides: Partial<User> = {}): User =>
({
id: 'user-1',
Expand Down Expand Up @@ -50,14 +46,13 @@ describe('TwoFactorService', () => {
});

describe('enable()', () => {
it('returns secret, otpauthUrl, qrCodeDataUrl, and backupCodes', async () => {
it('returns secret, otpauthUrl, and backupCodes', async () => {
mockUserRepository.findOne.mockResolvedValue(mockUser());

const result = await service.enable('user-1');

expect(result.secret).toBeDefined();
expect(result.otpauthUrl).toMatch(/^otpauth:\/\/totp\/Nestera:/);
expect(result.qrCodeDataUrl).toBe('data:image/png;base64,mockqrcode');
expect(result.backupCodes).toHaveLength(8);
expect(mockUserRepository.update).toHaveBeenCalledWith('user-1', {
twoFactorSecret: result.secret,
Expand Down
16 changes: 16 additions & 0 deletions backend/src/common/database/connection-pool.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,20 @@ export class ConnectionPoolService {

return 0;
}

getPoolSummary() {
return {
averageUtilization: this.getAverageUtilization(),
latestMetrics: this.getLatestMetrics(),
totalCollected: this.metrics.length,
};
}

async getHealthStatus() {
const isHealthy = await this.checkPoolHealth();
return {
status: isHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date(),
};
}
}
21 changes: 21 additions & 0 deletions backend/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,27 @@ export default () => ({
10,
),
},
cors: {
enabled: process.env.CORS_ENABLED !== 'false',
origins: (process.env.CORS_ORIGINS || 'http://localhost:3000')
.split(',')
.map((o) => o.trim())
.filter(Boolean),
methods: (
process.env.CORS_METHODS || 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS'
)
.split(',')
.map((m) => m.trim())
.filter(Boolean),
allowedHeaders: (
process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization,Accept'
)
.split(',')
.map((h) => h.trim())
.filter(Boolean),
credentials: process.env.CORS_CREDENTIALS !== 'false',
maxAge: parseInt(process.env.CORS_MAX_AGE || '86400', 10),
},
balanceSync: {
cacheTtlSeconds: parseInt(
process.env.BALANCE_CACHE_TTL_SECONDS || '300',
Expand Down
31 changes: 24 additions & 7 deletions backend/src/modules/apm/distributed-tracing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export interface Span {
endTime?: number;
duration?: number;
tags: Record<string, string | number | boolean>;
logs: Array<{ timestamp: number; message: string; fields?: Record<string, unknown> }>;
logs: Array<{
timestamp: number;
message: string;
fields?: Record<string, unknown>;
}>;
status: 'active' | 'completed' | 'error';
error?: string;
}
Expand All @@ -35,7 +39,9 @@ export class DistributedTracingService {
this.samplingRate = parseFloat(process.env.APM_SAMPLING_RATE || '1.0');
}

parseTraceContext(headers: Record<string, string | string[] | undefined>): TraceContext | null {
parseTraceContext(
headers: Record<string, string | string[] | undefined>,
): TraceContext | null {
// W3C Trace Context format: traceparent header
const traceparent = headers['traceparent'] as string;
if (traceparent) {
Expand All @@ -47,7 +53,8 @@ export class DistributedTracingService {
if (ddTraceId) {
return {
traceId: ddTraceId,
spanId: (headers['x-datadog-parent-id'] as string) || this.generateSpanId(),
spanId:
(headers['x-datadog-parent-id'] as string) || this.generateSpanId(),
sampled: (headers['x-datadog-sampling-priority'] as string) !== '0',
baggage: {},
};
Expand Down Expand Up @@ -140,8 +147,12 @@ export class DistributedTracingService {

if (span.duration && span.duration > 1000) {
this.logger.warn(
`Slow span detected: ${operationName} took ${span.duration}ms`,
{ traceId: span.traceId, spanId: span.spanId, operationName: span.operationName },
`Slow span detected: ${span.operationName} took ${span.duration}ms`,
{
traceId: span.traceId,
spanId: span.spanId,
operationName: span.operationName,
},
);
}
}
Expand All @@ -150,7 +161,11 @@ export class DistributedTracingService {
span.tags[key] = value;
}

addSpanLog(span: Span, message: string, fields?: Record<string, unknown>): void {
addSpanLog(
span: Span,
message: string,
fields?: Record<string, unknown>,
): void {
span.logs.push({ timestamp: Date.now(), message, fields });
}

Expand Down Expand Up @@ -178,7 +193,9 @@ export class DistributedTracingService {
getTracingStats() {
const completed = this.completedSpans;
const errorSpans = completed.filter((s) => s.status === 'error');
const durations = completed.map((s) => s.duration || 0).filter((d) => d > 0);
const durations = completed
.map((s) => s.duration || 0)
.filter((d) => d > 0);
const sorted = [...durations].sort((a, b) => a - b);

return {
Expand Down
16 changes: 16 additions & 0 deletions backend/src/modules/webhooks/dto/create-webhook.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
import { IsUrl, IsArray, IsString, IsOptional } from 'class-validator';

export class CreateWebhookDto {
@IsUrl()
url: string;

@IsArray()
@IsString({ each: true })
events: string[];

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

@IsString()
@IsOptional()
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsUrl,
Expand Down
11 changes: 8 additions & 3 deletions backend/src/modules/webhooks/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export class WebhookService {
dto: CreateWebhookDto,
): Promise<WebhookSubscription> {
const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');
const sub = this.subRepo.create({ ...dto, userId, secret });
const sub: WebhookSubscription = this.subRepo.create({
...dto,
userId,
secret,
});
return this.subRepo.save(sub);
}

Expand Down Expand Up @@ -121,10 +125,11 @@ export class WebhookService {
sub?: WebhookSubscription,
): Promise<void> {
if (!sub) {
sub = await this.subRepo.findOne({
const found = await this.subRepo.findOne({
where: { id: delivery.subscriptionId },
});
if (!sub) return;
if (!found) return;
sub = found;
}

delivery.attempts += 1;
Expand Down
Loading
Loading