Skip to content

Commit

Permalink
Add claim service with recaptcha+ip check
Browse files Browse the repository at this point in the history
  • Loading branch information
gndelia committed Feb 21, 2024
1 parent 5901c8e commit 27e4196
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 0 deletions.
12 changes: 12 additions & 0 deletions claim-tokens/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# claim-tokens

## Environment variables

```sh
IP_QUALITY_SCORE_SECRET_KEY=<ip-quality-score-secret-key>
RECAPTCHA_SECRET_KEY=<recaptcha-v3-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/).
19 changes: 19 additions & 0 deletions claim-tokens/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 16 additions & 0 deletions claim-tokens/config/default.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
154 changes: 154 additions & 0 deletions claim-tokens/index.js
Original file line number Diff line number Diff line change
@@ -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 }
7 changes: 7 additions & 0 deletions claim-tokens/logger.js
Original file line number Diff line number Diff line change
@@ -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 }
27 changes: 27 additions & 0 deletions claim-tokens/package.json
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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"
}
}
34 changes: 34 additions & 0 deletions claim-tokens/serverless.yml
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 27e4196

Please sign in to comment.