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
5 changes: 3 additions & 2 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('register')
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
register(@Body() registerDto: RegisterDto, @Req() request: Request) {
const ipAddress = request.ip || request.socket.remoteAddress;
return this.authService.register(registerDto, ipAddress);
}

@UseGuards(GoogleAuthGuard)
Expand Down
47 changes: 46 additions & 1 deletion src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly issuer = 'PropChain';

private readonly registrationIpMap = new Map<string, { email: string; expiresAt: Date }>();

private hashEmail(email: string): string {
return createSha256(email).slice(0, 12);
}
Expand Down Expand Up @@ -109,7 +111,17 @@ export class AuthService {
}));
}

async register(data: RegisterDto) {
async register(data: RegisterDto, ipAddress?: string) {
// Block re-registration from same IP until prior email is verified
if (ipAddress) {
const allowed = this.canRegisterFromIp(ipAddress);
if (!allowed) {
throw new BadRequestException(
'A registration from this IP is already pending email verification. Please verify your email before registering a new account.',
);
}
}

const existingUser = await this.usersService.findByEmail(data.email);
if (existingUser) {
throw new BadRequestException('A user with that email already exists');
Expand Down Expand Up @@ -166,13 +178,46 @@ export class AuthService {

// Only issue short-lived verification token after email is sent
// Full token pair is issued in verifyInitialEmail after verification succeeds

// Track IP for re-registration prevention
if (ipAddress) {
const expiryMs =
parseDuration(
this.configService.get<string>('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h',
24 * 60 * 60,
) * 1000;
this.registrationIpMap.set(ipAddress, {
email: user.email,
expiresAt: new Date(Date.now() + expiryMs),
});
}

return {
user: sanitizeUser(user),
message: 'Registration successful. Please check your email to verify your account.',
verificationToken,
};
}

private canRegisterFromIp(ipAddress: string): boolean {
const entry = this.registrationIpMap.get(ipAddress);
if (!entry) return true;
if (Date.now() > entry.expiresAt.getTime()) {
this.registrationIpMap.delete(ipAddress);
return true;
}
return false;
}

private cleanupIpForEmail(email: string): void {
for (const [ip, entry] of this.registrationIpMap.entries()) {
if (entry.email === email) {
this.registrationIpMap.delete(ip);
return;
}
}
}

/**
* Performs mandatory security checks before validating credentials.
*
Expand Down
63 changes: 63 additions & 0 deletions src/transactions/dto/audit-log-query.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { AuditLogQueryDto } from './audit-log-query.dto';

describe('AuditLogQueryDto', () => {
it('accepts valid ISO 8601 dates', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateFrom: '2024-01-01T00:00:00Z',
dateTo: '2024-12-31T23:59:59Z',
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('rejects malformed date strings for dateFrom', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateFrom: 'not-a-date',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('dateFrom');
});

it('rejects malformed date strings for dateTo', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateTo: 'bad-date-value',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe('dateTo');
});

it('accepts missing optional dates', async () => {
const dto = plainToInstance(AuditLogQueryDto, {});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('accepts dateFrom without dateTo', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateFrom: '2024-06-01T00:00:00Z',
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('accepts dateTo without dateFrom', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateTo: '2024-06-30T23:59:59Z',
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});

it('rejects numeric strings for dates', async () => {
const dto = plainToInstance(AuditLogQueryDto, {
dateFrom: 12345,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
});
12 changes: 5 additions & 7 deletions src/transactions/dto/audit-log-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { Type } from 'class-transformer';
import { IsISO8601, IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { IsOptional, IsString, IsDateString, IsInt, Min, Max } from 'class-validator';

export class AuditLogQueryDto {
@IsOptional()
@IsString()
actorId?: string;

@IsOptional()
@IsISO8601()
@Type(() => Date)
dateFrom?: Date;
@IsDateString()
dateFrom?: string;

@IsOptional()
@IsISO8601()
@Type(() => Date)
dateTo?: Date;
@IsDateString()
dateTo?: string;

@IsOptional()
@Type(() => Number)
Expand Down
Loading