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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ EMAIL_FROM=noreply@hamplard.com
THROTTLE_TTL=60
THROTTLE_LIMIT=100

# CAPTCHA verification (required before auth nonce issuance)
# Server-side secret only — never a client-reported success flag
CAPTCHA_SECRET_KEY=your-captcha-secret-key
# Google reCAPTCHA, hCaptcha, and Cloudflare Turnstile all accept this siteverify form
CAPTCHA_VERIFY_URL=https://www.google.com/recaptcha/api/siteverify
CAPTCHA_TIMEOUT_MS=5000
CAPTCHA_MAX_FAILURES=5
CAPTCHA_BLOCK_DURATION_SECONDS=900

# Event polling interval (ms)
EVENT_POLLING_INTERVAL_MS=5000

Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function bootstrap() {
app.enableCors({
origin: corsOrigin,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-captcha-token'],
credentials: true,
});

Expand Down
112 changes: 112 additions & 0 deletions src/modules/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
HttpException,
HttpStatus,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Request } from 'express';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';

describe('AuthController', () => {
let controller: AuthController;
const authService = {
generateNonce: jest.fn().mockReturnValue('issued-nonce'),
login: jest.fn(),
};
const captchaService = {
verifyBeforeNonce: jest.fn().mockResolvedValue(undefined),
};

const request = { ip: '203.0.113.10', socket: { remoteAddress: '203.0.113.10' } } as Request;

beforeEach(async () => {
jest.clearAllMocks();
captchaService.verifyBeforeNonce.mockResolvedValue(undefined);
authService.generateNonce.mockReturnValue('issued-nonce');

const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: CaptchaService, useValue: captchaService },
],
}).compile();

controller = module.get(AuthController);
});

it('issues a nonce only after CAPTCHA verification succeeds', async () => {
const result = await controller.getNonce('GABCDEF', 'captcha-token', request);

expect(captchaService.verifyBeforeNonce).toHaveBeenCalledWith(
'captcha-token',
'203.0.113.10',
);
expect(authService.generateNonce).toHaveBeenCalledWith('GABCDEF');
expect(result).toEqual({ nonce: 'issued-nonce', address: 'GABCDEF' });
});

it('accepts a CAPTCHA token from the x-captcha-token header', async () => {
await controller.getNonce('GABCDEF', undefined, request, 'header-token');

expect(captchaService.verifyBeforeNonce).toHaveBeenCalledWith(
'header-token',
'203.0.113.10',
);
expect(authService.generateNonce).toHaveBeenCalled();
});

it('does not issue a nonce when CAPTCHA verification fails', async () => {
captchaService.verifyBeforeNonce.mockRejectedValue(
new UnauthorizedException('CAPTCHA verification failed'),
);

await expect(controller.getNonce('GABCDEF', 'bad-token', request)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(authService.generateNonce).not.toHaveBeenCalled();
});

it('does not issue a nonce when the IP is temporarily blocked', async () => {
captchaService.verifyBeforeNonce.mockRejectedValue(
new HttpException(
'Too many failed CAPTCHA attempts. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
),
);

await expect(controller.getNonce('GABCDEF', 'token', request)).rejects.toBeInstanceOf(
HttpException,
);
expect(authService.generateNonce).not.toHaveBeenCalled();
});

it('does not issue a nonce when the CAPTCHA provider fails', async () => {
captchaService.verifyBeforeNonce.mockRejectedValue(
new ServiceUnavailableException('CAPTCHA verification is unavailable'),
);

await expect(controller.getNonce('GABCDEF', 'token', request)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(authService.generateNonce).not.toHaveBeenCalled();
});

it('does not trust a client-provided captchaSuccess flag', async () => {
captchaService.verifyBeforeNonce.mockRejectedValue(
new UnauthorizedException('CAPTCHA verification failed'),
);

await expect(
controller.getNonce('GABCDEF', 'forged-token', request),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(authService.generateNonce).not.toHaveBeenCalled();
expect(captchaService.verifyBeforeNonce).toHaveBeenCalledWith(
'forged-token',
'203.0.113.10',
);
});
});
28 changes: 23 additions & 5 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Controller, Get, Post, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Get, Post, Body, Query, HttpCode, HttpStatus, Req, Headers } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';
import { IsString, IsNotEmpty, IsOptional, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

Expand All @@ -21,11 +23,27 @@ class LoginDto {
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly captchaService: CaptchaService,
) {}

@Get('nonce')
@ApiOperation({ summary: 'Get challenge nonce for a Stellar address' })
getNonce(@Query('address') address: string) {
@ApiOperation({ summary: 'Get challenge nonce for a Stellar address after CAPTCHA verification' })
@ApiQuery({ name: 'address', required: true })
@ApiQuery({
name: 'captchaToken',
required: true,
description: 'CAPTCHA token from the configured provider. May also be sent as x-captcha-token.',
})
async getNonce(
@Query('address') address: string,
@Query('captchaToken') captchaToken: string,
@Req() req: Request,
@Headers('x-captcha-token') captchaHeader?: string,
) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
await this.captchaService.verifyBeforeNonce(captchaToken || captchaHeader, ip);
return { nonce: this.authService.generateNonce(address), address };
}

Expand Down
3 changes: 2 additions & 1 deletion src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { CaptchaService } from './captcha.service';
import { JwtStrategy } from './jwt.strategy';
import { GoogleAuthController } from './google-auth.controller';
import { GoogleAuthService } from './google-auth.service';
Expand All @@ -26,7 +27,7 @@ import { ReferralsModule } from '../referrals/referrals.module';
ReferralsModule,
],
controllers: [AuthController, GoogleAuthController],
providers: [AuthService, JwtStrategy, GoogleAuthService, GoogleStrategy, GoogleAuthGuard],
providers: [AuthService, CaptchaService, JwtStrategy, GoogleAuthService, GoogleStrategy, GoogleAuthGuard],
exports: [AuthService],
})
export class AuthModule {}
193 changes: 193 additions & 0 deletions src/modules/auth/captcha.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import {
BadRequestException,
HttpException,
HttpStatus,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import axios from 'axios';
import { CaptchaService } from './captcha.service';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

async function expectHttpStatus(promise: Promise<unknown>, status: number) {
const error = await promise.then(
() => {
throw new Error('Expected promise to reject');
},
(err) => err,
);
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(status);
}

describe('CaptchaService', () => {
let service: CaptchaService;
const configMap: Record<string, string | number> = {
CAPTCHA_SECRET_KEY: 'test-secret',
CAPTCHA_VERIFY_URL: 'https://captcha.test/siteverify',
CAPTCHA_TIMEOUT_MS: 2000,
CAPTCHA_MAX_FAILURES: 3,
CAPTCHA_BLOCK_DURATION_SECONDS: 900,
};

async function createService(overrides: Record<string, string | number | undefined> = {}) {
const merged = { ...configMap, ...overrides };
const module: TestingModule = await Test.createTestingModule({
providers: [
CaptchaService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, defaultValue?: unknown) =>
merged[key] !== undefined ? merged[key] : defaultValue,
),
},
},
],
}).compile();

return module.get(CaptchaService);
}

beforeEach(async () => {
jest.clearAllMocks();
service = await createService();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('accepts a token after the provider confirms success', async () => {
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } });

await expect(service.verifyBeforeNonce('valid-token', '1.1.1.1')).resolves.toBeUndefined();

expect(mockedAxios.post).toHaveBeenCalledWith(
'https://captcha.test/siteverify',
expect.stringContaining('secret=test-secret'),
expect.objectContaining({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
);
const body = mockedAxios.post.mock.calls[0][1] as string;
expect(body).toContain('response=valid-token');
expect(body).toContain('remoteip=1.1.1.1');
});

it('rejects an invalid token without treating client flags as proof', async () => {
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: false } });

await expect(service.verifyBeforeNonce('bad-token', '2.2.2.2')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});

it('rejects a missing token and counts it as a failed attempt', async () => {
await expect(service.verifyBeforeNonce(undefined, '3.3.3.3')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mockedAxios.post).not.toHaveBeenCalled();
});

it('blocks the IP after repeated failed verifications', async () => {
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: false } });

await expect(service.verifyBeforeNonce('t1', '4.4.4.4')).rejects.toBeInstanceOf(
UnauthorizedException,
);
await expect(service.verifyBeforeNonce('t2', '4.4.4.4')).rejects.toBeInstanceOf(
UnauthorizedException,
);
await expectHttpStatus(
service.verifyBeforeNonce('t3', '4.4.4.4'),
HttpStatus.TOO_MANY_REQUESTS,
);
});

it('does not contact the provider while the IP is temporarily blocked', async () => {
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: false } });

await service.verifyBeforeNonce('t1', '5.5.5.5').catch(() => undefined);
await service.verifyBeforeNonce('t2', '5.5.5.5').catch(() => undefined);
await service.verifyBeforeNonce('t3', '5.5.5.5').catch(() => undefined);
mockedAxios.post.mockClear();

await expectHttpStatus(
service.verifyBeforeNonce('still-bad', '5.5.5.5'),
HttpStatus.TOO_MANY_REQUESTS,
);
expect(mockedAxios.post).not.toHaveBeenCalled();
});

it('allows a new attempt after the temporary block expires', async () => {
const now = 1_700_000_000_000;
jest.spyOn(Date, 'now').mockReturnValue(now);
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: false } });

await service.verifyBeforeNonce('t1', '6.6.6.6').catch(() => undefined);
await service.verifyBeforeNonce('t2', '6.6.6.6').catch(() => undefined);
await service.verifyBeforeNonce('t3', '6.6.6.6').catch(() => undefined);

(Date.now as jest.Mock).mockReturnValue(now + 901_000);
mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } });

await expect(service.verifyBeforeNonce('good-token', '6.6.6.6')).resolves.toBeUndefined();
});

it('does not issue success when the provider request fails', async () => {
mockedAxios.post.mockRejectedValue(new Error('ECONNRESET'));

await expect(service.verifyBeforeNonce('token', '7.7.7.7')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});

it('does not treat a provider HTTP error as a successful verification', async () => {
mockedAxios.post.mockResolvedValue({ status: 500, data: { success: true } });

await expect(service.verifyBeforeNonce('token', '8.8.8.8')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});

it('does not increment failure blocks when the provider is down', async () => {
mockedAxios.post.mockRejectedValue(new Error('timeout'));

await service.verifyBeforeNonce('token', '9.9.9.9').catch(() => undefined);
await service.verifyBeforeNonce('token', '9.9.9.9').catch(() => undefined);
await service.verifyBeforeNonce('token', '9.9.9.9').catch(() => undefined);

mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await expect(service.verifyBeforeNonce('token', '9.9.9.9')).resolves.toBeUndefined();
});

it('fails closed when the CAPTCHA secret is not configured', async () => {
service = await createService({ CAPTCHA_SECRET_KEY: '' });

await expect(service.verifyBeforeNonce('token', '10.10.10.10')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(mockedAxios.post).not.toHaveBeenCalled();
});

it('clears failed attempts after a successful verification', async () => {
mockedAxios.post
.mockResolvedValueOnce({ status: 200, data: { success: false } })
.mockResolvedValueOnce({ status: 200, data: { success: false } })
.mockResolvedValueOnce({ status: 200, data: { success: true } })
.mockResolvedValueOnce({ status: 200, data: { success: false } });

await service.verifyBeforeNonce('bad', '11.11.11.11').catch(() => undefined);
await service.verifyBeforeNonce('bad', '11.11.11.11').catch(() => undefined);
await expect(service.verifyBeforeNonce('good', '11.11.11.11')).resolves.toBeUndefined();

await expect(service.verifyBeforeNonce('bad-again', '11.11.11.11')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
});
Loading