From 27e41961ea541ae0ad6e0265177e6acb5bd27b33 Mon Sep 17 00:00:00 2001 From: Gonzalo D'Elia Date: Wed, 21 Feb 2024 12:34:14 -0300 Subject: [PATCH] Add claim service with recaptcha+ip check --- claim-tokens/Readme.md | 12 ++ .../config/custom-environment-variables.json | 19 +++ claim-tokens/config/default.json | 16 ++ claim-tokens/index.js | 154 ++++++++++++++++++ claim-tokens/logger.js | 7 + claim-tokens/package.json | 27 +++ claim-tokens/serverless.yml | 34 ++++ 7 files changed, 269 insertions(+) create mode 100644 claim-tokens/Readme.md create mode 100644 claim-tokens/config/custom-environment-variables.json create mode 100644 claim-tokens/config/default.json create mode 100644 claim-tokens/index.js create mode 100644 claim-tokens/logger.js create mode 100644 claim-tokens/package.json create mode 100644 claim-tokens/serverless.yml diff --git a/claim-tokens/Readme.md b/claim-tokens/Readme.md new file mode 100644 index 00000000..96e1ae6a --- /dev/null +++ b/claim-tokens/Readme.md @@ -0,0 +1,12 @@ +# claim-tokens + +## Environment variables + +```sh +IP_QUALITY_SCORE_SECRET_KEY= +RECAPTCHA_SECRET_KEY= +``` + +You can create a recaptcha secret key at [https://www.google.com/recaptcha/admin](https://www.google.com/recaptcha/admin). The SITE_KEY generated will be used in the frontend. The domain of the frontend should also be whitelisted (Use `127.0.01` for localhost). + +You can create a IP Quality Score Secret key at [https://www.ipqualityscore.com/](https://www.ipqualityscore.com/). diff --git a/claim-tokens/config/custom-environment-variables.json b/claim-tokens/config/custom-environment-variables.json new file mode 100644 index 00000000..ec9b8570 --- /dev/null +++ b/claim-tokens/config/custom-environment-variables.json @@ -0,0 +1,19 @@ +{ + "ipQualityScore": { + "secretKey": "IP_QUALITY_SCORE_SECRET_KEY" + }, + "logger": { + "Console": { + "level": "LOGGER_CONSOLE_LEVEL" + }, + "Papertrail": { + "host": "LOGGER_PAPERTRAIL_HOST", + "level": "LOGGER_PAPERTRAIL_LEVEL", + "port": "LOGGER_PAPERTRAIL_PORT", + "program": "LOGGER_PAPERTRAIL_PROGRAM" + } + }, + "recaptcha": { + "secretKey": "RECAPTCHA_SECRET_KEY" + } +} diff --git a/claim-tokens/config/default.json b/claim-tokens/config/default.json new file mode 100644 index 00000000..bd1ff96e --- /dev/null +++ b/claim-tokens/config/default.json @@ -0,0 +1,16 @@ +{ + "ipQualityScore": { + "maxScore": 75, + "url": "https://www.ipqualityscore.com/api/json" + }, + "logger": { + "Papertrail": { + "program": "hemi-claim-tokens-dev" + } + }, + "recaptcha": { + "action": "claim_tokens", + "minScore": 0.6, + "url": "https://www.google.com/recaptcha/api" + } +} diff --git a/claim-tokens/index.js b/claim-tokens/index.js new file mode 100644 index 00000000..6cc0fd07 --- /dev/null +++ b/claim-tokens/index.js @@ -0,0 +1,154 @@ +'use strict' + +const config = require('config') +const fetch = require('fetch-plus-plus') +const httpErrors = require('http-errors') +const { getReasonPhrase, StatusCodes } = require('http-status-codes') + +const { logger } = require('./logger') + +const errorResponse = ({ detail = '', status }) => ({ + body: JSON.stringify({ + detail: status >= StatusCodes.INTERNAL_SERVER_ERROR ? undefined : detail, + status, + title: getReasonPhrase(status), + type: `https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/${status}`, + }), + statusCode: status, +}) + +const successResponse = () => ({ + statusCode: StatusCodes.NO_CONTENT, +}) + +const fetchSiteVerify = function (token, userIp) { + const body = new URLSearchParams() + body.append('secret', config.get('recaptcha.secretKey')) + body.append('remoteip', userIp) + body.append('response', token) + + logger.debug('Sending request to verify recaptcha token') + // https://developers.google.com/recaptcha/docs/v3#site_verify_response + return fetch(`${config.get('recaptcha.url')}/siteverify`, { + body: body.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ignoreError: true, + method: 'POST', + }) +} + +const parseBody = function (body) { + if (!body) { + return undefined + } + try { + return JSON.parse(body) + } catch { + return undefined + } +} + +const verifyRecaptcha = (token, userIp) => + fetchSiteVerify(token, userIp).then(function (response) { + const action = config.get('recaptcha.action') + const errorCodes = response?.['error-codes'] ?? [] + + if (!response.success) { + logger.verbose('Recaptcha request failed', response) + throw new httpErrors.InternalServerError('Recaptcha request failed') + } + if (response.action !== action) { + logger.verbose('Expected action %s but got %s', action, response.action) + throw new httpErrors.BadRequest('Invalid action') + } + // Error code reference guide https://developers.google.com/recaptcha/docs/verify#error_code_reference + if (errorCodes.includes('invalid-input-secret')) { + logger.warn('The recaptcha secret is invalid or incorrect') + throw new httpErrors.InternalServerError( + 'The recaptcha secret is invalid or incorrect', + ) + } + if (errorCodes.includes('invalid-input-response')) { + logger.verbose('The recaptcha token is invalid') + throw new httpErrors.BadRequest('Invalid token') + } + if (errorCodes.includes('timeout-or-duplicate')) { + logger.verbose('Recaptcha request timed out or was a duplicate') + throw new httpErrors.TooManyRequests('Duplicate token') + } + if (errorCodes.includes('browser-error')) { + // This error code is not documented!!! But it is returned if calling from a domain that + // is not whitelisted. Logging a warn because that may could be a misconfiguration (our error) + // or someone else's frontend calling us (not our error) + // However, recaptcha does generate an invalid token (that's why we've reached this point), so I believe + // returning Invalid Token is correct, even though the error may be ours. + logger.warn( + 'The recaptcha configured domain is not whitelisted. It may be a misconfiguration', + ) + throw new httpErrors.BadRequest('Invalid token') + } + if (response.score < config.get('recaptcha.minScore')) { + logger.verbose( + 'Recaptcha returned %s, a score below the minimum', + response.score, + ) + throw new httpErrors.Forbidden('Low score') + } + logger.verbose('Recaptcha token verified correctly') + }) + +// See parameters and response in https://www.ipqualityscore.com/documentation/proxy-detection-api/overview +const verifyIP = publicIp => + fetch( + `${config.get('ipQualityScore.url')}/ip/${config.get( + 'ipQualityScore.secretKey', + )}/${publicIp}`, + { + queryString: { + // eslint-disable-next-line camelcase + allow_public_access_points: true, + strictness: 0, + }, + }, + ).then(function (response) { + if ( + response.is_crawler || + response.proxy || + response.fraud_score > config.get('ipQualityScore.maxScore') + ) { + throw new httpErrors.Forbidden('Suspicious IP address') + } + logger.verbose('IP address verified correctly') + }) + +const claimTokens = async function ({ body, requestContext }) { + logger.debug('Starting request to claim tokens') + + const parsedBody = parseBody(body) + if (!parsedBody?.token) { + logger.debug('Body sent is invalid') + throw new httpErrors.BadRequest('Invalid body') + } + return Promise.all([ + verifyRecaptcha(parsedBody.token, requestContext.identity.sourceIp), + verifyIP(requestContext.identity.sourceIp), + ]).then(function () { + // if no errors were thrown, it means all checks were successful. Let's proceed to send the email + // TODO send email + // TODO log the email+ip+timestamp + logger.info('Email to claim tokens sent') + return successResponse() + }) +} + +const post = event => + claimTokens(event).catch(err => + errorResponse({ + detail: err?.message, + status: err?.status ?? StatusCodes.INTERNAL_SERVER_ERROR, + }), + ) + +module.exports = { post } diff --git a/claim-tokens/logger.js b/claim-tokens/logger.js new file mode 100644 index 00000000..aac081ee --- /dev/null +++ b/claim-tokens/logger.js @@ -0,0 +1,7 @@ +'use strict' + +const config = require('config') +const createLogger = require('@bloq/service-logger') +const logger = createLogger(config.get('logger')) + +module.exports = { logger } diff --git a/claim-tokens/package.json b/claim-tokens/package.json new file mode 100644 index 00000000..8cb8692c --- /dev/null +++ b/claim-tokens/package.json @@ -0,0 +1,27 @@ +{ + "name": "claim-tokens", + "version": "1.0.0", + "description": "Tiny microservice that validates recaptcha tokens for claiming tokens from the Welcome Pack", + "main": "index.js", + "scripts": { + "dev": "sls offline start" + }, + "author": { + "email": "gonzalo@bloq.com", + "name": "Gonzalo D'Elia" + }, + "devDependencies": { + "serverless": "3.38.0", + "serverless-offline": "13.3.3" + }, + "dependencies": { + "@bloq/service-logger": "2.0.0", + "config": "3.3.11", + "fetch-plus-plus": "1.0.0", + "http-errors": "2.0.0", + "http-status-codes": "2.3.0" + }, + "engines": { + "node": ">=16" + } +} diff --git a/claim-tokens/serverless.yml b/claim-tokens/serverless.yml new file mode 100644 index 00000000..0ac8a7d9 --- /dev/null +++ b/claim-tokens/serverless.yml @@ -0,0 +1,34 @@ +service: hemi-claim-tokens + +useDotenv: true + +custom: + serverless-offline: + httpPort: 4000 + +plugins: + - serverless-offline + +provider: + name: 'aws' + stage: ${opt:stage,env:STAGE,'dev'} + region: ${opt:region,env:AWS_REGION,'eu-central-1'} + runtime: nodejs20.x + environment: + LOGGER_CONSOLE_LEVEL: ${env:LOGGER_CONSOLE_LEVEL,'debug'} + LOGGER_PAPERTRAIL_HOST: ${env:LOGGER_PAPERTRAIL_HOST,''} + LOGGER_PAPERTRAIL_LEVEL: ${env:LOGGER_PAPERTRAIL_LEVEL,'info'} + LOGGER_PAPERTRAIL_PORT: ${env:LOGGER_PAPERTRAIL_PORT,''} + LOGGER_PAPERTRAIL_PROGRAM: ${self:service}-${self:provider.stage} + +functions: + claim-tokens: + handler: ./index.post + environment: + IP_QUALITY_SCORE_SECRET_KEY: ${env:IP_QUALITY_SCORE_SECRET_KEY} + RECAPTCHA_SECRET_KEY: ${env:RECAPTCHA_SECRET_KEY} + events: + - http: + path: /claim + method: post + cors: true