From c669cb9ffef1529bd84fdb6a02d122b97ae76598 Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:00:36 +0200 Subject: [PATCH 1/7] update: more gitignore --- .gitignore | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6769e21..45483d0 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ venv/ ENV/ env.bak/ venv.bak/ +*/.env # Spyder project settings .spyderproject @@ -157,4 +158,12 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ + + +*/*.whl +*/*.tar.gz + +*/bin + +*/pyvenv.cfg \ No newline at end of file From aa65751502a0f1f568e413e0f0e7fcc98d4e3af4 Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:01:03 +0200 Subject: [PATCH 2/7] add: docker compose and more documentation for easier local testing --- README.md | 37 ++++++++++++++++++++++++++++++++++- docker-compose.yaml | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yaml diff --git a/README.md b/README.md index 0a754ba..284ebb4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ cd api python3 -m venv .venv . .venv/bin/activate pip3 install -r requirements-dev.txt + +pip install setuptools ``` ### Run application @@ -66,6 +68,19 @@ flask -A api create_enabled_token xDAI 10200 0x000000000000000000000000000000000 Once enabled, the token will appear in the list of enabled tokens on the endpoint `api/v1/info`. +#### Create access keys + +To create access keys on the API just run the command `create_access_keys`. +Accepted parameters: token name, chain ID, token address, maximum amount per day per user, whether native or erc20 + +Samples below: + +``` +cd /api +flask -A api create_access_keys +``` + + #### Change maximum daily amounts per user If you want to change the amount you are giving out for a specific token, make sure you have sqlite @@ -96,4 +111,24 @@ yarn ``` cd app yarn start -``` \ No newline at end of file +``` + + +### Docker Compose Up and create Access keys + +``` + +docker-compose up --build -d + +docker ps + +docker exec -it /bin/bash + +docker exec -it faee3118d09e flask -A api create_access_keys + +docker exec -it faee3118d09e flask -A api create_enabled_token xDAI 100 0x0000000000000000000000000000000000000000 0.01 native + +docker logs -f faee3118d09e + +``` + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c4d31b4 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,47 @@ +version: '3.8' + +services: + api: + build: + context: ./api + dockerfile: Dockerfile + # image: "ghcr.io/gnosischain/faucet-py-api:v0.4.9@sha256:58761f4fa91274dc393fbaf3a61c434f6b6627ada8a1cfe92a9b8bff265fe5f5" + container_name: api + command: ["sh", "/api/scripts/production_run_api.sh"] + env_file: "./api/.env" + environment: + - FAUCET_DATABASE_URI=sqlite:////db/gc_faucet.db + ports: + - "8000:8000" + volumes: + - db-volume:/db:rw + deploy: + resources: + limits: + cpus: '0.5' + memory: 500M + reservations: + cpus: '0.25' + memory: 250M + + # ui: + # build: + # context: ./app + # dockerfile: Dockerfile + # # image: "ghcr.io/gnosischain/faucet-py-ui:v0.4.9-gc@sha256:819f44e801d69d847c8866ff717281a2e989cb5e9076aad56c7a410b7a552b06" + # container_name: ui + # ports: + # - "80:80" + # env_file: "./app/.env" + # deploy: + # resources: + # limits: + # cpus: '0.1' + # memory: 50M + # reservations: + # cpus: '0.05' + # memory: 25M + +volumes: + db-volume: + driver: local \ No newline at end of file From c7a61ce6264709ecba1e4d3d62be3f1e790506ab Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:01:34 +0200 Subject: [PATCH 3/7] add: a bit more logging during ask --- api/api/routes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/api/routes.py b/api/api/routes.py index 1f6f927..eea1d5f 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -69,6 +69,7 @@ def _ask(request_data, request_headers, validate_captcha=True, validate_csrf=Tru try: # convert recipient address to checksum address recipient = Web3.to_checksum_address(validator.recipient) + print(f"will try to send {amount_wei} to {recipient}") w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], current_app.config['FAUCET_PRIVATE_KEY']) @@ -78,11 +79,13 @@ def _ask(request_data, request_headers, validate_captcha=True, validate_csrf=Tru current_app.config['FAUCET_ADDRESS'], recipient, amount_wei) + print(f"native token txn: {tx_hash}") else: tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei, validator.token.address) + print(f"token with address txn: {validator.token.address}") # save transaction data on DB transaction = Transaction() From 5d20283adfbd3a26daa2c269a79151e4ab1dbc3e Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:02:18 +0200 Subject: [PATCH 4/7] update: fetch nonce before sending txn and use nonce in txn to avoid situation where nonce is not up to speed --- api/api/services/transaction.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/api/api/services/transaction.py b/api/api/services/transaction.py index d5c1f04..794ebfa 100644 --- a/api/api/services/transaction.py +++ b/api/api/services/transaction.py @@ -28,12 +28,26 @@ def claim_native(w3, sender, recipient, amount): - recipient: String - amount: integer in wei format """ + + nonce = w3.eth.get_transaction_count(sender) + tx_dict = { 'from': sender, 'to': recipient, - 'value': amount + 'value': amount, + 'nonce': nonce } - return w3.eth.send_transaction(tx_dict).hex() + + tx_hash = w3.eth.send_transaction(tx_dict).hex() + + # this may cause a timeout, keep here for testing purposes + # receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + # if receipt.status == 1: + # print(f"transaction successful {tx_hash}") + # else: + # print(f"transaction failed {tx_hash}") + + return tx_hash def claim_token(w3, sender, recipient, amount, token_address): From dc8b07af3cf3cb17b9241d4abed387e513dd097a Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:52:44 +0200 Subject: [PATCH 5/7] update: add supertools as dependency and update readme --- README.md | 8 +++++--- api/requirements-dev.txt | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 284ebb4..7a4b05e 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ yarn start ### Docker Compose Up and create Access keys +If you do not reset the volume you will be able to reuse the sqlite database with latest data (access keys and enabled tokens) + ``` docker-compose up --build -d @@ -124,11 +126,11 @@ docker ps docker exec -it /bin/bash -docker exec -it faee3118d09e flask -A api create_access_keys +docker exec -it flask -A api create_access_keys -docker exec -it faee3118d09e flask -A api create_enabled_token xDAI 100 0x0000000000000000000000000000000000000000 0.01 native +docker exec -it flask -A api create_enabled_token xDAI 100 0x0000000000000000000000000000000000000000 0.01 native -docker logs -f faee3118d09e +docker logs -f ``` diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index 246729a..7b854df 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt flake8==7.0.0 -isort==5.13.2 \ No newline at end of file +isort==5.13.2 +setuptools==1.0.1 \ No newline at end of file From 64849712858a0ff26952be9157a981ecdbe669ea Mon Sep 17 00:00:00 2001 From: riccardo <106812074+riccardo-gnosis@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:59:45 +0200 Subject: [PATCH 6/7] use logging lib --- api/api/routes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/api/routes.py b/api/api/routes.py index eea1d5f..b8afab0 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -1,3 +1,5 @@ +import logging + from flask import Blueprint, current_app, jsonify, request from web3 import Web3 @@ -69,7 +71,7 @@ def _ask(request_data, request_headers, validate_captcha=True, validate_csrf=Tru try: # convert recipient address to checksum address recipient = Web3.to_checksum_address(validator.recipient) - print(f"will try to send {amount_wei} to {recipient}") + logging.info(f'will try to send {amount_wei} to {recipient}') w3 = Web3Singleton(current_app.config['FAUCET_RPC_URL'], current_app.config['FAUCET_PRIVATE_KEY']) @@ -79,13 +81,13 @@ def _ask(request_data, request_headers, validate_captcha=True, validate_csrf=Tru current_app.config['FAUCET_ADDRESS'], recipient, amount_wei) - print(f"native token txn: {tx_hash}") + logging.info(f'native token txn: {tx_hash}') else: tx_hash = claim_token(w3, current_app.config['FAUCET_ADDRESS'], recipient, amount_wei, validator.token.address) - print(f"token with address txn: {validator.token.address}") + logging.info(f'token with address {validator.token.address} txn: {tx_hash}') # save transaction data on DB transaction = Transaction() From 4744472d2b271aa758b881a1231d27b31e34d30f Mon Sep 17 00:00:00 2001 From: Giacomo Licari Date: Sun, 22 Dec 2024 13:24:31 +0100 Subject: [PATCH 7/7] Add support for Cloudflare Captcha --- .github/workflows/publish-api.yaml | 46 ++++++------ .github/workflows/publish-ui.yaml | 52 +++++++------- api/.env.example | 6 +- api/api/routes.py | 4 ++ api/api/services/__init__.py | 1 + api/api/services/captcha.py | 72 +++++++++++++++---- api/api/services/csrf.py | 13 ++-- api/api/services/validator.py | 15 +++- api/api/settings.py | 6 +- api/tests/conftest.py | 11 ++- api/tests/temp_env_var.py | 1 + api/tests/test_api_cli.py | 2 +- app/.env.example | 2 +- app/Dockerfile | 4 +- app/package.json | 1 + app/src/App.tsx | 7 +- .../components/Captcha/CloudflareCaptcha.tsx | 28 ++++++++ .../Captcha/{Captcha.tsx => HCaptcha.tsx} | 6 +- app/src/components/FaucetForm/Faucet.tsx | 66 ++++++++--------- app/yarn.lock | 23 +++--- 20 files changed, 233 insertions(+), 133 deletions(-) create mode 100644 app/src/components/Captcha/CloudflareCaptcha.tsx rename app/src/components/Captcha/{Captcha.tsx => HCaptcha.tsx} (65%) diff --git a/.github/workflows/publish-api.yaml b/.github/workflows/publish-api.yaml index 84358f8..5d91a11 100644 --- a/.github/workflows/publish-api.yaml +++ b/.github/workflows/publish-api.yaml @@ -77,28 +77,28 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - eks-deployment-restart: - # Run job on branch dev only - if: github.ref == 'refs/heads/dev' - runs-on: ubuntu-latest - needs: build-and-push-image - permissions: - id-token: write # Required for the OIDC, see https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#OIDC - contents: read - steps: - - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4.0.0 - with: - audience: sts.amazonaws.com - role-to-assume: ${{ secrets.DEV_AWS_EKS_ROLE }} - role-session-name: GitHub_to_AWS_via_FederatedOIDC - aws-region: ${{ secrets.DEV_AWS_REGION }} + # eks-deployment-restart: + # # Run job on branch dev only + # if: github.ref == 'refs/heads/dev' + # runs-on: ubuntu-latest + # needs: build-and-push-image + # permissions: + # id-token: write # Required for the OIDC, see https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#OIDC + # contents: read + # steps: + # - name: configure aws credentials + # uses: aws-actions/configure-aws-credentials@v4.0.0 + # with: + # audience: sts.amazonaws.com + # role-to-assume: ${{ secrets.DEV_AWS_EKS_ROLE }} + # role-session-name: GitHub_to_AWS_via_FederatedOIDC + # aws-region: ${{ secrets.DEV_AWS_REGION }} - - name: Configure kubectl for EKS - run: aws eks update-kubeconfig --name ${{ secrets.DEV_AWS_EKS_CLUSTER }} --region ${{ secrets.DEV_AWS_REGION }} + # - name: Configure kubectl for EKS + # run: aws eks update-kubeconfig --name ${{ secrets.DEV_AWS_EKS_CLUSTER }} --region ${{ secrets.DEV_AWS_REGION }} - - name: Restart Bridge Explorer Deployment - if: github.ref == 'refs/heads/dev' - run: | - kubectl config use-context arn:aws:eks:${{ secrets.DEV_AWS_REGION }}:${{ secrets.DEV_AWS_ACCOUNT_ID }}:cluster/${{ secrets.DEV_AWS_EKS_CLUSTER }} - kubectl rollout restart deploy/${{ secrets.DEV_AWS_EKS_DEPLOYMENT_API }} -n ${{ secrets.DEV_AWS_EKS_NAMESPACE }} \ No newline at end of file + # - name: Restart Deployment + # if: github.ref == 'refs/heads/dev' + # run: | + # kubectl config use-context arn:aws:eks:${{ secrets.DEV_AWS_REGION }}:${{ secrets.DEV_AWS_ACCOUNT_ID }}:cluster/${{ secrets.DEV_AWS_EKS_CLUSTER }} + # kubectl rollout restart deploy/${{ secrets.DEV_AWS_EKS_DEPLOYMENT_API }} -n ${{ secrets.DEV_AWS_EKS_NAMESPACE }} \ No newline at end of file diff --git a/.github/workflows/publish-ui.yaml b/.github/workflows/publish-ui.yaml index 490bef2..6daa219 100644 --- a/.github/workflows/publish-ui.yaml +++ b/.github/workflows/publish-ui.yaml @@ -55,7 +55,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | - "REACT_APP_HCAPTCHA_SITE_KEY=${{ secrets.DEV_REACT_APP_HCAPTCHA_SITE_KEY }}" + "REACT_APP_CAPTCHA_SITE_KEY=${{ secrets.DEV_REACT_APP_CAPTCHA_SITE_KEY }}" "REACT_APP_FAUCET_API_URL=${{ secrets.DEV_REACT_APP_FAUCET_API_URL}}" - name: Gnosis Chain - Main branch / tags - Build and push Docker image @@ -67,7 +67,7 @@ jobs: tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-gc labels: ${{ steps.meta.outputs.labels }} build-args: | - "REACT_APP_HCAPTCHA_SITE_KEY=${{ secrets.PROD_GC_REACT_APP_HCAPTCHA_SITE_KEY }}" + "REACT_APP_CAPTCHA_SITE_KEY=${{ secrets.PROD_GC_REACT_APP_CAPTCHA_SITE_KEY }}" "REACT_APP_FAUCET_API_URL=${{ secrets.PROD_GC_REACT_APP_FAUCET_API_URL}}" - name: Chiado Chain - Main branch / tags - Build and push Docker image @@ -79,31 +79,31 @@ jobs: tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-chiado labels: ${{ steps.meta.outputs.labels }} build-args: | - "REACT_APP_HCAPTCHA_SITE_KEY=${{ secrets.PROD_CHIADO_REACT_APP_HCAPTCHA_SITE_KEY }}" + "REACT_APP_CAPTCHA_SITE_KEY=${{ secrets.PROD_CHIADO_REACT_APP_CAPTCHA_SITE_KEY }}" "REACT_APP_FAUCET_API_URL=${{ secrets.PROD_CHIADO_REACT_APP_FAUCET_API_URL}}" - eks-deployment-restart: - # Run job on branch dev only - if: github.ref == 'refs/heads/dev' - runs-on: ubuntu-latest - needs: build-and-push-image - permissions: - id-token: write # Required for the OIDC, see https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#OIDC - contents: read - steps: - - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v4.0.0 - with: - audience: sts.amazonaws.com - role-to-assume: ${{ secrets.DEV_AWS_EKS_ROLE }} - role-session-name: GitHub_to_AWS_via_FederatedOIDC - aws-region: ${{ secrets.DEV_AWS_REGION }} + # eks-deployment-restart: + # # Run job on branch dev only + # if: github.ref == 'refs/heads/dev' + # runs-on: ubuntu-latest + # needs: build-and-push-image + # permissions: + # id-token: write # Required for the OIDC, see https://github.com/aws-actions/configure-aws-credentials?tab=readme-ov-file#OIDC + # contents: read + # steps: + # - name: configure aws credentials + # uses: aws-actions/configure-aws-credentials@v4.0.0 + # with: + # audience: sts.amazonaws.com + # role-to-assume: ${{ secrets.DEV_AWS_EKS_ROLE }} + # role-session-name: GitHub_to_AWS_via_FederatedOIDC + # aws-region: ${{ secrets.DEV_AWS_REGION }} - - name: Configure kubectl for EKS - run: aws eks update-kubeconfig --name ${{ secrets.DEV_AWS_EKS_CLUSTER }} --region ${{ secrets.DEV_AWS_REGION }} + # - name: Configure kubectl for EKS + # run: aws eks update-kubeconfig --name ${{ secrets.DEV_AWS_EKS_CLUSTER }} --region ${{ secrets.DEV_AWS_REGION }} - - name: Restart Bridge Explorer Deployment - if: github.ref == 'refs/heads/dev' - run: | - kubectl config use-context arn:aws:eks:${{ secrets.DEV_AWS_REGION }}:${{ secrets.DEV_AWS_ACCOUNT_ID }}:cluster/${{ secrets.DEV_AWS_EKS_CLUSTER }} - kubectl rollout restart deploy/${{ secrets.DEV_AWS_EKS_DEPLOYMENT_UI }} -n ${{ secrets.DEV_AWS_EKS_NAMESPACE }} \ No newline at end of file + # - name: Restart Deployment + # if: github.ref == 'refs/heads/dev' + # run: | + # kubectl config use-context arn:aws:eks:${{ secrets.DEV_AWS_REGION }}:${{ secrets.DEV_AWS_ACCOUNT_ID }}:cluster/${{ secrets.DEV_AWS_EKS_CLUSTER }} + # kubectl rollout restart deploy/${{ secrets.DEV_AWS_EKS_DEPLOYMENT_UI }} -n ${{ secrets.DEV_AWS_EKS_NAMESPACE }} \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index f158dc3..e0ce30d 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,8 +1,10 @@ -FAUCET_AMOUNT=0.1 +FAUCET_AMOUNT=0.001 FAUCET_PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000 FAUCET_RPC_URL=https://rpc.chiadochain.net FAUCET_CHAIN_ID=10200 FAUCET_DATABASE_URI=sqlite:// CAPTCHA_VERIFY_ENDPOINT=https://api.hcaptcha.com/siteverify CAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000 -CAPTCHA_SITE_KEY=xxxxx-xxxxx-xxxxx-xxxxx \ No newline at end of file +CAPTCHA_SITE_KEY=xxxxx-xxxxx-xxxxx-xxxxx +CSRF_PRIVATE_KEY="!!CREATE_YOUR_RSA_PRIVATE_KEY!!" +CSRF_SECRET_SALT="test-salt" \ No newline at end of file diff --git a/api/api/routes.py b/api/api/routes.py index b8afab0..d1554de 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -8,6 +8,7 @@ claim_token) from .services.database import AccessKey, Token, Transaction + apiv1 = Blueprint("version1", "version1") @@ -116,6 +117,9 @@ def ask(): @apiv1.route("/cli/ask", methods=["POST"]) def cli_ask(): + if not current_app.config['FAUCET_ENABLE_CLI_API']: + return jsonify(errors=['Endpoint disabled']), 403 + access_key_id = request.headers.get('X-faucet-access-key-id', None) secret_access_key = request.headers.get('X-faucet-secret-access-key', None) diff --git a/api/api/services/__init__.py b/api/api/services/__init__.py index 77abb75..11a079b 100644 --- a/api/api/services/__init__.py +++ b/api/api/services/__init__.py @@ -4,3 +4,4 @@ from .token import Token from .transaction import Web3Singleton, claim_native, claim_token from .validator import AskEndpointValidator +from .captcha import CaptchaSingleton diff --git a/api/api/services/captcha.py b/api/api/services/captcha.py index 5008eb6..de1beb3 100644 --- a/api/api/services/captcha.py +++ b/api/api/services/captcha.py @@ -5,16 +5,62 @@ logging.basicConfig(level=logging.INFO) -def captcha_verify(client_response, catpcha_api_url, secret_key, remote_ip, site_key): - request = requests.post(catpcha_api_url, data={ - 'response': client_response, - 'secret': secret_key, - 'remoteip': remote_ip, - 'sitekey': site_key - }) - - logging.info('Captcha verify response: %s' % request.json()) - - if request.status_code != 200: - return False - return request.json()['success'] == True +class Captcha: + def __init__(self, provider): + self.provider = provider + + def verify(self, client_response, catpcha_api_url, secret_key, remote_ip, site_key=None): + logging.info('Captcha: Remote IP %s' % remote_ip) + + if self.provider == 'HCAPTCHA': + request = requests.post(catpcha_api_url, data={ + 'response': client_response, + 'secret': secret_key, + 'remoteip': remote_ip, + 'sitekey': site_key + }) + + logging.info('Captcha: verify response %s' % request.json()) + + if request.status_code != 200: + return False + return request.json()['success'] is True + elif self.provider == 'CLOUDFLARE': + request = requests.post(catpcha_api_url, data={ + 'response': client_response, + 'secret': secret_key, + 'remoteip': remote_ip + }) + + logging.info('Captcha: verify response %s' % request.json()) + + if request.status_code != 200: + return False + return request.json()['success'] is True + else: + raise NotImplementedError + + +class CaptchaSingleton: + _instance = None + + def __new__(cls, provider): + if not hasattr(cls, 'instance'): + cls.instance = Captcha(provider) + return cls.instance + + +# def captcha_verify(client_response, catpcha_api_url, secret_key, remote_ip, site_key): +# logging.info('Captcha: Remote IP %s' % remote_ip) +# request = requests.post(catpcha_api_url, data={ +# 'response': client_response, +# 'secret': secret_key, +# 'remoteip': remote_ip, +# 'sitekey': site_key +# }) + +# logging.info('Captcha: verify response %s' % request.json()) + +# if request.status_code != 200: +# return False +# return request.json()['success'] == True diff --git a/api/api/services/csrf.py b/api/api/services/csrf.py index 3e2f11c..b514e63 100644 --- a/api/api/services/csrf.py +++ b/api/api/services/csrf.py @@ -6,9 +6,10 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA -# Waiting period: the minimum time interval between UI asks for the CSFR token -# and the time it asks for funds. -CSRF_TIMESTAMP_MIN_SECONDS = 15 +# Waiting period: the minimum time interval between the UI asks +# for the CSFR token to /api/v1/info and the time the UI can ask for funds. +# This check aims to block any bots that could be triggering actions through the UI. +CSRF_TIMESTAMP_MIN_SECONDS = 5 class CSRFTokenItem: @@ -45,9 +46,9 @@ def validate_token(self, request_id, token, timestamp): decrypted_text = cipher_rsa.decrypt(bytes.fromhex(token)).decode() expected_text = '%s%s%f' % (request_id, self._salt, timestamp) if decrypted_text == expected_text: - # Check that timestamp is OK, the diff between now() and creation time in seconds - # must be greater than min. waiting period. - # Waiting period: the minimum time interval between UI asks for the CSFR token and the time it asks for funds. + # Check that the timestamp is OK, the diff between now() and creation time in seconds + # must be greater than the minimum waiting period. + # Waiting period: the minimum time interval between UI asks for the CSFR token and the time the UI can ask for funds. seconds_diff = (datetime.now()-datetime.fromtimestamp(timestamp)).total_seconds() if seconds_diff > CSRF_TIMESTAMP_MIN_SECONDS: return True diff --git a/api/api/services/validator.py b/api/api/services/validator.py index 8972c4f..266ff69 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -5,7 +5,7 @@ from flask import current_app, request from web3 import Web3 -from .captcha import captcha_verify +from .captcha import CaptchaSingleton from .csrf import CSRF from .database import AccessKeyConfig, BlockedUsers, Token, Transaction from .rate_limit import Strategy @@ -140,14 +140,23 @@ def data_validation(self): def captcha_validation(self): error_key = 'captcha' - # check hcatpcha - catpcha_verified = captcha_verify( + + captcha = CaptchaSingleton(current_app.config['CAPTCHA_PROVIDER']) + catpcha_verified = captcha.verify( self.request_data.get('captcha'), current_app.config['CAPTCHA_VERIFY_ENDPOINT'], current_app.config['CAPTCHA_SECRET_KEY'], self.ip_address, current_app.config['CAPTCHA_SITE_KEY'] ) + # check hcatpcha + # catpcha_verified = captcha_verify( + # self.request_data.get('captcha'), + # current_app.config['CAPTCHA_VERIFY_ENDPOINT'], + # current_app.config['CAPTCHA_SECRET_KEY'], + # self.ip_address, + # current_app.config['CAPTCHA_SITE_KEY'] + # ) if not catpcha_verified: self.errors.append('%s: validation failed' % error_key) diff --git a/api/api/settings.py b/api/api/settings.py index 8e5c029..bf0c036 100644 --- a/api/api/settings.py +++ b/api/api/settings.py @@ -19,14 +19,18 @@ FAUCET_ADDRESS: LocalAccount = Account.from_key(FAUCET_PRIVATE_KEY).address FAUCET_RATE_LIMIT_STRATEGY = rate_limit_strategy FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS = int(os.getenv('FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS', 86400)) # 86400 = 24h +FAUCET_ENABLE_CLI_API = os.getenv('FAUCET_ENABLE_CLI_API', "False") == "True" SQLALCHEMY_DATABASE_URI = os.getenv('FAUCET_DATABASE_URI') CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', '*') +CAPTCHA_PROVIDER = os.getenv('CAPTCHA_PROVIDER', 'HCAPTCHA') CAPTCHA_VERIFY_ENDPOINT = os.getenv('CAPTCHA_VERIFY_ENDPOINT') CAPTCHA_SECRET_KEY = os.getenv('CAPTCHA_SECRET_KEY') -CAPTCHA_SITE_KEY = os.getenv('CAPTCHA_SITE_KEY') +CAPTCHA_SITE_KEY = os.getenv('CAPTCHA_SITE_KEY', None) # It's mandatory for HCAPTCHA +if CAPTCHA_PROVIDER == 'HCAPTCHA' and CAPTCHA_SITE_KEY is None: + raise ValueError('CAPTCHA_SITE_KEY is mandatory for HCAPTCHA') CSRF_PRIVATE_KEY = os.getenv('CSRF_PRIVATE_KEY') CSRF_SECRET_SALT = os.getenv('CSRF_SECRET_SALT') diff --git a/api/tests/conftest.py b/api/tests/conftest.py index c02621b..f6eaa35 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -4,7 +4,6 @@ from api.services import CSRF, Strategy from api.services.database import Token, db -from flask.testing import FlaskClient from api import create_app @@ -25,13 +24,19 @@ def mock_claim_erc20(self, *args): tx_hash = '0x1' + '%d' % self.erc20_tx_counter * 63 self.erc20_tx_counter += 1 return tx_hash + + def mock_captcha_verify(self, *args): + class Test: + def verify(self, *args): + return True + return Test def _mock(self, env_variables=None): # Mock values self.patchers = [ mock.patch('api.routes.claim_native', self.mock_claim_native), mock.patch('api.routes.claim_token', self.mock_claim_erc20), - mock.patch('api.services.validator.captcha_verify', return_value=True), + mock.patch('api.services.validator.CaptchaSingleton', self.mock_captcha_verify), mock.patch('api.api.print_info', return_value=None) ] if env_variables: @@ -133,4 +138,4 @@ def setUp(self): self.csrf = CSRF.instance # use same token for the whole test # use a timestamp that would be actually validated by the CSRF class. - self.csrf_token = self.csrf.generate_token(timestamp=self.valid_csrf_timestamp) + self.csrf_token = self.csrf.generate_token(timestamp=self.valid_csrf_timestamp) \ No newline at end of file diff --git a/api/tests/temp_env_var.py b/api/tests/temp_env_var.py index ef369d2..a3fd286 100644 --- a/api/tests/temp_env_var.py +++ b/api/tests/temp_env_var.py @@ -38,6 +38,7 @@ 'FAUCET_RATE_LIMIT_TIME_LIMIT_SECONDS': '10', 'FAUCET_DATABASE_URI': 'sqlite://', # run in-memory # 'FAUCET_DATABASE_URI': 'sqlite:///test.db', + 'FAUCET_ENABLE_CLI_API': 'True', 'CAPTCHA_SECRET_KEY': CAPTCHA_TEST_SECRET_KEY, 'CSRF_PRIVATE_KEY': privatekey.export_key().decode(), 'CSRF_SECRET_SALT': 'testsalt' diff --git a/api/tests/test_api_cli.py b/api/tests/test_api_cli.py index 4c39641..588efbd 100644 --- a/api/tests/test_api_cli.py +++ b/api/tests/test_api_cli.py @@ -10,7 +10,7 @@ ERC20_TOKEN_ADDRESS, FAUCET_CHAIN_ID) -class TestAPICli(BaseTest): +class TestAPICliEnabledEndpoints(BaseTest): def test_ask_route_parameters(self): access_key_id, secret_access_key = generate_access_key() http_headers = { diff --git a/app/.env.example b/app/.env.example index 8d828ba..477dd51 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,2 +1,2 @@ -REACT_APP_HCAPTCHA_SITE_KEY= +REACT_APP_CAPTCHA_SITE_KEY= REACT_APP_FAUCET_API_URL=http://localhost:8000/api/v1 \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile index fc31313..fa7b3f6 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -8,9 +8,9 @@ RUN yarn COPY . . ENV NODE_ENV production -ARG REACT_APP_HCAPTCHA_SITE_KEY +ARG REACT_APP_CAPTCHA_SITE_KEY ARG REACT_APP_FAUCET_API_URL -ENV REACT_APP_HCAPTCHA_SITE_KEY ${REACT_APP_HCAPTCHA_SITE_KEY} +ENV REACT_APP_CAPTCHA_SITE_KEY ${REACT_APP_HAPTCHA_SITE_KEY} ENV REACT_APP_FAUCET_API_URL ${REACT_APP_FAUCET_API_URL} RUN yarn build diff --git a/app/package.json b/app/package.json index 49e5358..d3efd79 100644 --- a/app/package.json +++ b/app/package.json @@ -17,6 +17,7 @@ "react-scripts": "5.0.1", "react-select": "^5.8.0", "react-toastify": "^9.1.3", + "react-turnstile": "^1.1.4", "typescript": "^5.3.3", "web-vitals": "^2.1.0" }, diff --git a/app/src/App.tsx b/app/src/App.tsx index b89391a..4feeb35 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -48,11 +48,8 @@ function App(): JSX.Element { toast.error("Network error") }) .finally(() => { - // 5 seconds waiting period - setTimeout(function () { - setFaucetLoading(false) - setLoading(false) - }, 5000) + setFaucetLoading(false) + setLoading(false) }) }, []) diff --git a/app/src/components/Captcha/CloudflareCaptcha.tsx b/app/src/components/Captcha/CloudflareCaptcha.tsx new file mode 100644 index 0000000..65654f4 --- /dev/null +++ b/app/src/components/Captcha/CloudflareCaptcha.tsx @@ -0,0 +1,28 @@ +import React from "react" +import Turnstile from "react-turnstile"; + +const siteKey = process.env.REACT_APP_CAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001" + +interface CaptchaProps { + setCaptchaToken: (token: string) => void, + // windowWidth: number, + // captchaRef: null +} + +const CloudflareCaptchaWidget: React.FC = ({ setCaptchaToken }) => { + + const onVerifyCaptcha = (token: string) => { + console.log(token) + setCaptchaToken(token) + } + + return ( + + ) +} + +export default CloudflareCaptchaWidget diff --git a/app/src/components/Captcha/Captcha.tsx b/app/src/components/Captcha/HCaptcha.tsx similarity index 65% rename from app/src/components/Captcha/Captcha.tsx rename to app/src/components/Captcha/HCaptcha.tsx index 2b6adad..54ccf5e 100644 --- a/app/src/components/Captcha/Captcha.tsx +++ b/app/src/components/Captcha/HCaptcha.tsx @@ -1,7 +1,7 @@ import React, { RefObject } from "react" import HCaptcha from "@hcaptcha/react-hcaptcha" -const siteKey = process.env.REACT_APP_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001" +const siteKey = process.env.REACT_APP_CAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001" // common test key interface CaptchaProps { setCaptchaToken: (token: string) => void, @@ -9,7 +9,7 @@ interface CaptchaProps { captchaRef: RefObject } -const Captcha: React.FC = ({ setCaptchaToken, windowWidth, captchaRef }) => { +const HCaptchaWidget: React.FC = ({ setCaptchaToken, windowWidth, captchaRef }) => { const onVerifyCaptcha = (token: string) => { setCaptchaToken(token) @@ -25,4 +25,4 @@ const Captcha: React.FC = ({ setCaptchaToken, windowWidth, captcha ) } -export default Captcha +export default HCaptchaWidget diff --git a/app/src/components/FaucetForm/Faucet.tsx b/app/src/components/FaucetForm/Faucet.tsx index 0d7e038..c98c316 100644 --- a/app/src/components/FaucetForm/Faucet.tsx +++ b/app/src/components/FaucetForm/Faucet.tsx @@ -1,10 +1,9 @@ -import { useState, useRef, ChangeEvent, FormEvent, useEffect, Dispatch, SetStateAction } from "react" +import { useState, ChangeEvent, FormEvent, useEffect, Dispatch, SetStateAction } from "react" import "./Faucet.css" import { toast } from "react-toastify" import axios from "axios" -import Captcha from "../Captcha/Captcha" +import Captcha from "../Captcha/CloudflareCaptcha" import TokenSelect, { Token } from "../TokenSelect/TokenSelect" -import HCaptcha from "@hcaptcha/react-hcaptcha" import { formatLimit } from "../../utils" interface FaucetProps { @@ -29,8 +28,6 @@ function Faucet({ enabledTokens, chainId, setLoading, csrfToken, requestId, time const [windowWidth, setWindowWidth] = useState(window.innerWidth) const [requestOngoing, setRequestOngoing] = useState(false) - const captchaRef = useRef(null) - useEffect(() => { const handleResize = () => setWindowWidth(window.innerWidth) window.addEventListener("resize", handleResize) @@ -106,34 +103,33 @@ function Faucet({ enabledTokens, chainId, setLoading, csrfToken, requestId, time 'X-CSRFToken': csrfToken } - setTimeout(function() { - axios - .post(apiURL, requestData, { - headers: headers - }) - .then((response) => { - setWalletAddress("") - - if (enabledTokens.length > 1 ) { - setToken(null) - } - - // Reset captcha - setCaptchaToken("") - captchaRef.current?.resetCaptcha() - - setLoading(false) - setRequestOngoing(false) - - toast.success("Token sent to your wallet address") - setTxHash(`${response.data.transactionHash}`) - }) - .catch((error) => { - toast.error(formatErrors(error.response.data.errors)) - setLoading(false) - setRequestOngoing(false) - }) - }, 10000) // 10 seconds delay + axios + .post(apiURL, requestData, { + headers: headers + }) + .then((response) => { + setWalletAddress("") + + if (enabledTokens.length > 1 ) { + setToken(null) + } + + // Reset captcha + setCaptchaToken("") + // For HCAPTCHA: + // captchaRef.current?.resetCaptcha() + + setLoading(false) + setRequestOngoing(false) + + toast.success("Token sent to your wallet address") + setTxHash(`${response.data.transactionHash}`) + }) + .catch((error) => { + toast.error(formatErrors(error.response.data.errors)) + setLoading(false) + setRequestOngoing(false) + }) } catch (error) { if (error instanceof Error) { toast.error(error.message) @@ -184,8 +180,8 @@ function Faucet({ enabledTokens, chainId, setLoading, csrfToken, requestId, time