From 717b35b724d6e826db562fd8b167fad26a3ee3ed Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Thu, 16 May 2024 00:43:52 +0100 Subject: [PATCH] phase out automatic captcha --- .env.example | 2 - Dockerfile.prod | 1 - .../src/captcha/captcha.controller.ts | 156 +----------------- .../production/src/captcha/captcha.service.ts | 116 +------------ .../src/captcha/dtos/automatic.dto.ts | 13 -- .../src/captcha/interfaces/token-captcha.ts | 4 - apps/production/src/common/constants.ts | 4 - docker-compose-prod.yml | 1 - 8 files changed, 7 insertions(+), 290 deletions(-) delete mode 100644 apps/production/src/captcha/dtos/automatic.dto.ts delete mode 100644 apps/production/src/captcha/interfaces/token-captcha.ts diff --git a/.env.example b/.env.example index 00fb50372..1e02c3cee 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,6 @@ CDN_ACCESS_TOKEN=SOME_SECRET_TOKEN # Captcha API CAPTCHA_SALT= -## We use AES-256-GCM, so the key length MUST BE 32 bytes -CAPTCHA_ENCRYPTION_KEY= # Google SSO GOOGLE_OAUTH2_CLIENT_ID= diff --git a/Dockerfile.prod b/Dockerfile.prod index d5ce2a423..7100bfc2e 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -33,7 +33,6 @@ ENV TZ=UTC \ CDN_URL=http://localhost:5006 \ CDN_ACCESS_TOKEN=SOME_SECRET_TOKEN \ CAPTCHA_SALT= \ - CAPTCHA_ENCRYPTION_KEY= \ GOOGLE_OAUTH2_CLIENT_ID= \ GOOGLE_OAUTH2_CLIENT_SECRET= \ GITHUB_OAUTH2_CLIENT_ID= \ diff --git a/apps/production/src/captcha/captcha.controller.ts b/apps/production/src/captcha/captcha.controller.ts index 4977c70ae..1788d15b6 100644 --- a/apps/production/src/captcha/captcha.controller.ts +++ b/apps/production/src/captcha/captcha.controller.ts @@ -4,12 +4,7 @@ import { Body, UseGuards, ForbiddenException, - InternalServerErrorException, Headers, - Request, - Req, - Response, - Res, Ip, HttpCode, } from '@nestjs/common' @@ -17,14 +12,12 @@ import * as dayjs from 'dayjs' import * as utc from 'dayjs/plugin/utc' import { AppLoggerService } from '../logger/logger.service' -import { CAPTCHA_COOKIE_KEY } from '../common/constants' import { getIPFromHeaders } from '../common/utils' -import { CaptchaService, DUMMY_PIDS, isDummyPID } from './captcha.service' +import { CaptchaService, DUMMY_PIDS } from './captcha.service' import { BotDetectionGuard } from '../common/guards/bot-detection.guard' import { BotDetection } from '../common/decorators/bot-detection.decorator' import { ManualDTO } from './dtos/manual.dto' import { ValidateDTO } from './dtos/validate.dto' -import { AutomaticDTO } from './dtos/automatic.dto' import { GenerateDTO, DEFAULT_THEME } from './dtos/generate.dto' dayjs.extend(utc) @@ -53,15 +46,13 @@ export class CaptchaController { return this.captchaService.generateCaptcha(theme) } - @Post('/verify-manual') + @Post('/verify') @HttpCode(200) @UseGuards(BotDetectionGuard) @BotDetection() - async verifyManual( + async verify( @Body() manualDTO: ManualDTO, - @Req() request: Request, @Headers() headers, - @Res({ passthrough: true }) response: Response, @Ip() reqIP, ): Promise { this.logger.log({ manualDTO }, 'POST /captcha/verify-manual') @@ -69,8 +60,6 @@ export class CaptchaController { const { 'user-agent': userAgent } = headers // todo: add origin checks - // @ts-ignore - const tokenCookie = request?.cookies?.[CAPTCHA_COOKIE_KEY] const { code, hash, pid } = manualDTO await this.captchaService.validatePIDForCAPTCHA(pid) @@ -78,7 +67,7 @@ export class CaptchaController { const timestamp = dayjs.utc().unix() // For dummy (test) PIDs - if (pid === DUMMY_PIDS.MANUAL_PASS) { + if (pid === DUMMY_PIDS.ALWAYS_PASS) { const dummyToken = this.captchaService.generateDummyToken() return { success: true, @@ -96,32 +85,7 @@ export class CaptchaController { throw new ForbiddenException('Incorrect captcha') } - let decrypted - - try { - decrypted = await this.captchaService.decryptTokenCaptcha(tokenCookie) - } catch (e) { - decrypted = { - manuallyVerified: 0, - automaticallyVerified: 0, - } - } - - const token = await this.captchaService.generateToken( - pid, - hash, - timestamp, - false, - ) - - const { manuallyVerified, automaticallyVerified } = - this.captchaService.incrementManuallyVerified(decrypted) - - const newTokenCookie = this.captchaService.getTokenCaptcha( - manuallyVerified, - automaticallyVerified, - ) - this.captchaService.setTokenCookie(response, newTokenCookie) + const token = await this.captchaService.generateToken(pid, hash, timestamp) const ip = getIPFromHeaders(headers) || reqIP || '' @@ -142,116 +106,6 @@ export class CaptchaController { } } - @Post('/verify') - @HttpCode(200) - @UseGuards(BotDetectionGuard) - @BotDetection() - async autoVerifiable( - @Body() automaticDTO: AutomaticDTO, - @Req() request: Request, - @Headers() headers, - @Res({ passthrough: true }) response: Response, - @Ip() reqIP, - ): Promise { - this.logger.log(automaticDTO, 'POST /captcha/verify') - - const { 'user-agent': userAgent } = headers - // todo: add origin checks - - const { pid } = automaticDTO - - await this.captchaService.validatePIDForCAPTCHA(pid) - - // @ts-ignore - let tokenCookie = request?.cookies?.[CAPTCHA_COOKIE_KEY] - - let verifiable - - try { - verifiable = await this.captchaService.autoVerifiable(pid, tokenCookie) - } catch (e) { - // Either there was no cookie or the cookie was invalid - let newTokenCookie - - if ( - isDummyPID(pid) && - (pid !== DUMMY_PIDS.AUTO_PASS || pid === DUMMY_PIDS.ALWAYS_FAIL) - ) { - throw new ForbiddenException('Captcha required') - } - - // Set a new cookie - try { - newTokenCookie = this.captchaService.getTokenCaptcha() - } catch (reason) { - console.error(reason) - throw new InternalServerErrorException( - 'Could not generate a captcha cookie', - ) - } - - tokenCookie = newTokenCookie - - this.captchaService.setTokenCookie(response, newTokenCookie) - } - - if (!verifiable) { - throw new ForbiddenException('Captcha required') - } - - let decrypted - - const timestamp = dayjs.utc().unix() - const token = await this.captchaService.generateToken( - pid, - null, - timestamp, - true, - ) - - if (pid === DUMMY_PIDS.AUTO_PASS) { - return { - success: true, - token, - timestamp, - hash: null, - } - } - - try { - decrypted = await this.captchaService.decryptTokenCaptcha(tokenCookie) - } catch (e) { - throw new InternalServerErrorException('Could not decrypt captcha cookie') - } - - const { manuallyVerified, automaticallyVerified } = - this.captchaService.incrementAutomaticallyVerified(decrypted) - - const newTokenCookie = this.captchaService.getTokenCaptcha( - manuallyVerified, - automaticallyVerified, - ) - - this.captchaService.setTokenCookie(response, newTokenCookie) - - const ip = getIPFromHeaders(headers) || reqIP || '' - - await this.captchaService.logCaptchaPass( - pid, - userAgent, - timestamp, - false, - ip, - ) - - return { - success: true, - token, - timestamp, - hash: null, - } - } - @Post('/validate') @HttpCode(200) async validateToken(@Body() validateDTO: ValidateDTO): Promise { diff --git a/apps/production/src/captcha/captcha.service.ts b/apps/production/src/captcha/captcha.service.ts index 673e3c941..b781c6a5d 100644 --- a/apps/production/src/captcha/captcha.service.ts +++ b/apps/production/src/captcha/captcha.service.ts @@ -13,26 +13,21 @@ import * as utc from 'dayjs/plugin/utc' import { ProjectService } from '../project/project.service' import { AppLoggerService } from '../logger/logger.service' import { - isDevelopment, redis, REDIS_LOG_CAPTCHA_CACHE_KEY, isValidPID, getRedisCaptchaKey, CAPTCHA_SALT, - CAPTCHA_ENCRYPTION_KEY, - CAPTCHA_COOKIE_KEY, CAPTCHA_TOKEN_LIFETIME, } from '../common/constants' import { getGeoDetails } from '../common/utils' import { getElValue } from '../analytics/analytics.controller' import { GeneratedCaptcha } from './interfaces/generated-captcha' -import { TokenCaptcha } from './interfaces/token-captcha' dayjs.extend(utc) export const DUMMY_PIDS = { - AUTO_PASS: 'AP00000000000', - MANUAL_PASS: 'MP00000000000', + ALWAYS_PASS: 'AP00000000000', ALWAYS_FAIL: 'FAIL000000000', } @@ -55,14 +50,6 @@ const decryptString = (text: string, key: string): string => { return bytes.toString(CryptoJS.enc.Utf8) } -// Set the weights for the manual and automatic verifications -const MANUAL_WEIGHT = 2 -const AUTO_WEIGHT = 1 -const THRESHOLD = 1.5 - -// 300 days -const COOKIE_MAX_AGE = 300 * 24 * 60 * 60 * 1000 - const captchaString = (text: string) => `${_toLower(text)}${CAPTCHA_SALT}` const isTokenAlreadyUsed = async (token: string): Promise => { @@ -167,7 +154,6 @@ export class CaptchaService { pid: string, captchaHash: string, timestamp: number, - autoVerified: boolean, ): Promise { if (isDummyPID(pid)) { return this.generateDummyToken() @@ -189,7 +175,6 @@ export class CaptchaService { const token = { hash: captchaHash, timestamp, - autoVerified, pid, } @@ -211,8 +196,7 @@ export class CaptchaService { return { hash: 'DUMMY_HASH00000111112222233333444445555566666777778888899999', timestamp: dayjs().unix(), - autoVerified: false, - pid: DUMMY_PIDS.AUTO_PASS, + pid: DUMMY_PIDS.ALWAYS_PASS, } } @@ -266,100 +250,4 @@ export class CaptchaService { verifyCaptcha(text: string, captchaHash: string): boolean { return captchaHash === this.hashCaptcha(text) } - - incrementManuallyVerified(tokenCaptcha: TokenCaptcha): TokenCaptcha { - return { - ...tokenCaptcha, - manuallyVerified: 1 + tokenCaptcha.manuallyVerified, - } - } - - incrementAutomaticallyVerified(tokenCaptcha: TokenCaptcha): TokenCaptcha { - return { - ...tokenCaptcha, - automaticallyVerified: 1 + tokenCaptcha.automaticallyVerified, - } - } - - private async canPassWithoutVerification( - tokenCaptcha: TokenCaptcha, - ): Promise { - const { manuallyVerified, automaticallyVerified } = tokenCaptcha - - // Calculate the weighted average of the manual and automatic verifications - const weightedAverage = - (MANUAL_WEIGHT * manuallyVerified + AUTO_WEIGHT * automaticallyVerified) / - (MANUAL_WEIGHT + AUTO_WEIGHT) - - // If the weighted average is above a certain threshold, the user can pass without verification - return weightedAverage >= THRESHOLD - } - - async decryptTokenCaptcha( - jwtCookie: string | undefined, - ): Promise { - try { - const decryptedtokenCaptcha = decryptString( - jwtCookie, - CAPTCHA_ENCRYPTION_KEY, - ) - - // @ts-ignore - return JSON.parse(decryptedtokenCaptcha) - } catch (e) { - throw new Error('Invalid JWT captcha') - } - } - - getTokenCaptcha(manuallyVerified = 0, automaticallyVerified = 0): string { - const tokenCaptcha: TokenCaptcha = { - manuallyVerified, - automaticallyVerified, - } - - // @ts-ignore - const encryptedTokenCaptcha: string = encryptString( - JSON.stringify(tokenCaptcha), - CAPTCHA_ENCRYPTION_KEY, - ) - - return encryptedTokenCaptcha - } - - async autoVerifiable( - pid: string, - tokenCookie: string | undefined, - ): Promise { - if (pid === DUMMY_PIDS.AUTO_PASS) { - return true - } - - if (!tokenCookie) { - throw new Error('No JWT captcha cookie') - } - - const tokenCaptcha: TokenCaptcha = - await this.decryptTokenCaptcha(tokenCookie) - - return this.canPassWithoutVerification(tokenCaptcha) - } - - setTokenCookie(response: any, tokenCookie: string): void { - if (isDevelopment) { - // @ts-ignore - response.cookie(CAPTCHA_COOKIE_KEY, tokenCookie, { - httpOnly: true, - maxAge: COOKIE_MAX_AGE, - }) - } else { - // @ts-ignore - response.cookie(CAPTCHA_COOKIE_KEY, tokenCookie, { - httpOnly: true, - secure: true, - sameSite: 'none', - maxAge: COOKIE_MAX_AGE, - domain: '.swetrix.com', - }) - } - } } diff --git a/apps/production/src/captcha/dtos/automatic.dto.ts b/apps/production/src/captcha/dtos/automatic.dto.ts deleted file mode 100644 index d9a2a3532..000000000 --- a/apps/production/src/captcha/dtos/automatic.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger' -import { IsNotEmpty, IsString } from 'class-validator' - -export class AutomaticDTO { - @ApiProperty({ - example: 'aUn1quEid-3', - required: true, - description: 'A unique project ID', - }) - @IsNotEmpty() - @IsString() - pid: string -} diff --git a/apps/production/src/captcha/interfaces/token-captcha.ts b/apps/production/src/captcha/interfaces/token-captcha.ts deleted file mode 100644 index 29e747368..000000000 --- a/apps/production/src/captcha/interfaces/token-captcha.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TokenCaptcha { - manuallyVerified: number - automaticallyVerified: number -} diff --git a/apps/production/src/common/constants.ts b/apps/production/src/common/constants.ts index ef06c109e..9453d51d4 100644 --- a/apps/production/src/common/constants.ts +++ b/apps/production/src/common/constants.ts @@ -88,7 +88,6 @@ const REDIS_SSO_UUID = 'sso:uuid' // Captcha service const { CAPTCHA_SALT } = process.env -const { CAPTCHA_ENCRYPTION_KEY } = process.env // 3600 sec -> 1 hour const redisProjectCacheTimeout = 3600 @@ -115,7 +114,6 @@ const SEND_WARNING_AT_PERC = 85 const PROJECT_INVITE_EXPIRE = 48 -const CAPTCHA_COOKIE_KEY = 'swetrix-captcha-token' const CAPTCHA_TOKEN_LIFETIME = 300 // seconds (5 minutes). const CAPTCHA_SECRET_KEY_LENGTH = 50 @@ -217,11 +215,9 @@ export { ORIGINS_REGEX, REDIS_LOG_PERF_CACHE_KEY, CAPTCHA_SALT, - CAPTCHA_ENCRYPTION_KEY, EMAIL_ACTION_ENCRYPTION_KEY, isDevelopment, getRedisCaptchaKey, - CAPTCHA_COOKIE_KEY, CAPTCHA_TOKEN_LIFETIME, CAPTCHA_SECRET_KEY_LENGTH, PRODUCTION_ORIGIN, diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index ee25b6d28..438a2dafc 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -36,7 +36,6 @@ services: - CDN_URL=http://localhost:5006 - CDN_ACCESS_TOKEN=SOME_SECRET_TOKEN - CAPTCHA_SALT= - - CAPTCHA_ENCRYPTION_KEY= - GOOGLE_OAUTH2_CLIENT_ID= - GOOGLE_OAUTH2_CLIENT_SECRET= - GITHUB_OAUTH2_CLIENT_ID=