diff --git a/.env.example b/.env.example index 517f41b..328cbe2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/main.ts b/src/main.ts index b41c649..9acc998 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, }); diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts new file mode 100644 index 0000000..2c186e2 --- /dev/null +++ b/src/modules/auth/auth.controller.spec.ts @@ -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', + ); + }); +}); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index c228171..fedad79 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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'; @@ -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 }; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index c09baac..eb678ca 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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'; @@ -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 {} diff --git a/src/modules/auth/captcha.service.spec.ts b/src/modules/auth/captcha.service.spec.ts new file mode 100644 index 0000000..5babd27 --- /dev/null +++ b/src/modules/auth/captcha.service.spec.ts @@ -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; + +async function expectHttpStatus(promise: Promise, 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 = { + 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 = {}) { + 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, + ); + }); +}); diff --git a/src/modules/auth/captcha.service.ts b/src/modules/auth/captcha.service.ts new file mode 100644 index 0000000..954332b --- /dev/null +++ b/src/modules/auth/captcha.service.ts @@ -0,0 +1,157 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosError } from 'axios'; + +interface AttemptState { + failures: number; + blockedUntil?: number; +} + +@Injectable() +export class CaptchaService { + private readonly logger = new Logger(CaptchaService.name); + private readonly attempts = new Map(); + + private readonly secretKey: string; + private readonly verifyUrl: string; + private readonly timeoutMs: number; + private readonly maxFailures: number; + private readonly blockDurationMs: number; + + constructor(private readonly config: ConfigService) { + this.secretKey = this.config.get('CAPTCHA_SECRET_KEY', ''); + this.verifyUrl = this.config.get( + 'CAPTCHA_VERIFY_URL', + 'https://www.google.com/recaptcha/api/siteverify', + ); + this.timeoutMs = this.readNumber('CAPTCHA_TIMEOUT_MS', 5000); + this.maxFailures = this.readNumber('CAPTCHA_MAX_FAILURES', 5); + this.blockDurationMs = + this.readNumber('CAPTCHA_BLOCK_DURATION_SECONDS', 900) * 1000; + } + + /** + * Verify a CAPTCHA token with the configured provider before a nonce is issued. + * Client-reported success flags are ignored; only the provider response is trusted. + */ + async verifyBeforeNonce(token: string | undefined, ip: string): Promise { + const clientIp = ip || 'unknown'; + this.assertNotBlocked(clientIp); + + const captchaToken = token?.trim(); + if (!captchaToken) { + this.rejectFailedAttempt(clientIp, new BadRequestException('CAPTCHA token is required')); + } + + if (!this.secretKey) { + this.logger.error('CAPTCHA_SECRET_KEY is not configured; refusing nonce issuance'); + throw new ServiceUnavailableException('CAPTCHA verification is unavailable'); + } + + let payload: { success?: boolean }; + try { + const params = new URLSearchParams(); + params.append('secret', this.secretKey); + params.append('response', captchaToken); + if (clientIp !== 'unknown') { + params.append('remoteip', clientIp); + } + + const response = await axios.post(this.verifyUrl, params.toString(), { + timeout: this.timeoutMs, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + validateStatus: () => true, + }); + + if (response.status < 200 || response.status >= 300) { + this.logger.warn(`CAPTCHA provider returned HTTP ${response.status}`); + throw new ServiceUnavailableException('CAPTCHA verification is unavailable'); + } + + payload = response.data; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + const axiosError = error as AxiosError; + this.logger.warn( + `CAPTCHA provider request failed: ${axiosError?.message ?? 'unknown error'}`, + ); + throw new ServiceUnavailableException('CAPTCHA verification is unavailable'); + } + + if (payload?.success === true) { + this.attempts.delete(clientIp); + return; + } + + if (payload?.success === false) { + this.rejectFailedAttempt( + clientIp, + new UnauthorizedException('CAPTCHA verification failed'), + ); + } + + this.logger.warn('CAPTCHA provider returned an unexpected payload; refusing nonce issuance'); + throw new ServiceUnavailableException('CAPTCHA verification is unavailable'); + } + + private assertNotBlocked(ip: string): void { + const state = this.getState(ip); + if (state.blockedUntil && Date.now() < state.blockedUntil) { + throw new HttpException( + 'Too many failed CAPTCHA attempts. Try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + private rejectFailedAttempt(ip: string, exception: Error): never { + const state = this.getState(ip); + state.failures += 1; + + if (state.failures >= this.maxFailures) { + state.blockedUntil = Date.now() + this.blockDurationMs; + this.logger.warn( + `Temporarily blocking IP after ${state.failures} failed CAPTCHA attempts`, + ); + throw new HttpException( + 'Too many failed CAPTCHA attempts. Try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + throw exception; + } + + private getState(ip: string): AttemptState { + const existing = this.attempts.get(ip); + if (!existing) { + const fresh: AttemptState = { failures: 0 }; + this.attempts.set(ip, fresh); + return fresh; + } + + if (existing.blockedUntil && Date.now() >= existing.blockedUntil) { + existing.failures = 0; + existing.blockedUntil = undefined; + } + + return existing; + } + + private readNumber(key: string, fallback: number): number { + const value = this.config.get(key, fallback); + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } +}