-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add claim service with recaptcha+ip check
- Loading branch information
Showing
7 changed files
with
269 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |