diff --git a/README.md b/README.md index e02514f..71e9820 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ cd api python3 -m unittest discover -p 'test_*.py' ``` +Run specific test case: + +``` +cd api +python3 -m unittest tests.test_api.TestAPI.test_ask_route_blocked_users +``` + ### Run Flake8 and isort ``` diff --git a/api/api/api.py b/api/api/api.py index 1152ddb..aacb6d5 100644 --- a/api/api/api.py +++ b/api/api/api.py @@ -4,7 +4,8 @@ from flask_cors import CORS from flask_migrate import Migrate -from .manage import create_access_keys_cmd, create_enabled_token_cmd +from .manage import (block_user_cmd, create_access_keys_cmd, + create_enabled_token_cmd) from .routes import apiv1 from .services import Web3Singleton from .services.database import db @@ -39,6 +40,7 @@ def create_app(): # Add cli commands app.cli.add_command(create_access_keys_cmd) app.cli.add_command(create_enabled_token_cmd) + app.cli.add_command(block_user_cmd) with app.app_context(): db.init_app(app) diff --git a/api/api/manage.py b/api/api/manage.py index 356cf6e..6e208c9 100644 --- a/api/api/manage.py +++ b/api/api/manage.py @@ -5,7 +5,7 @@ from flask.cli import with_appcontext from .services import Web3Singleton -from .services.database import AccessKey, AccessKeyConfig, Token +from .services.database import AccessKey, AccessKeyConfig, BlockedUsers, Token from .utils import generate_access_key @@ -62,3 +62,25 @@ def create_enabled_token_cmd(name, chain_id, address, max_amount_day, type): token.save() logging.info('Token created successfully') + + +@click.command(name='block_user') +@click.argument('address') +@with_appcontext +def block_user_cmd(address): + w3 = Web3Singleton( + current_app.config['FAUCET_RPC_URL'], + current_app.config['FAUCET_PRIVATE_KEY'] + ) + + # check if Token already exists + check_user = BlockedUsers.get_by_address(address) + + if check_user: + raise Exception('User %s already blocked' % address) + + blocked_user = BlockedUsers() + blocked_user.address = w3.to_checksum_address(address) + blocked_user.save() + + logging.info('User blocked successfully') diff --git a/api/api/services/database.py b/api/api/services/database.py index 87c8ae9..72350fd 100644 --- a/api/api/services/database.py +++ b/api/api/services/database.py @@ -2,7 +2,7 @@ from datetime import datetime from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import MetaData +from sqlalchemy import MetaData, func from api.const import (DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, FaucetRequestType) @@ -93,7 +93,7 @@ def enabled_tokens(cls): @classmethod def get_by_address(cls, address): return cls.query.filter_by(address=address).first() - + @classmethod def get_by_address_and_chain_id(cls, address, chain_id): return cls.query.filter_by(address=address, @@ -196,3 +196,16 @@ def get_amount_sum_by_access_key_and_token(cls, access_key_id=access_key_id, token=token_address ).first().amount + + +class BlockedUsers(BaseModel): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + address = db.Column(db.String(42), nullable=False) + created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + updated = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + + __tablename__ = "blocked_users" + + @classmethod + def get_by_address(cls, address): + return cls.query.filter(func.lower(cls.address) == func.lower(address)).first() diff --git a/api/api/services/validator.py b/api/api/services/validator.py index b7342f2..d9abfa3 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -6,7 +6,7 @@ from api.const import TokenType from .captcha import captcha_verify -from .database import AccessKeyConfig, Token, Transaction +from .database import AccessKeyConfig, BlockedUsers, Token, Transaction from .rate_limit import Strategy @@ -18,6 +18,7 @@ class AskEndpointValidator: 'UNSUPPORTED_CHAIN': 'chainId: %s is not supported. Supported chainId: %s', 'INVALID_RECIPIENT': 'recipient: A valid recipient address must be specified', 'INVALID_RECIPIENT_ITSELF': 'recipient: address cant\'t be the Faucet address itself', + 'BLOCKED_RECIPIENT': 'Recipient address is blocked', 'REQUIRED_AMOUNT': 'amount: is required', 'AMOUNT_ZERO': 'amount: must be greater than 0', 'INVALID_TOKEN_ADDRESS': 'tokenAddress: A valid token address must be specified', @@ -32,6 +33,10 @@ def __init__(self, request_data, validate_captcha, access_key=None, *args, **kwa self.errors = [] def validate(self): + self.blocked_user_validation() + if len(self.errors) > 0: + return False + self.data_validation() if len(self.errors) > 0: return False @@ -59,6 +64,17 @@ def validate(self): return False return True + def blocked_user_validation(self): + recipient = self.request_data.get('recipient', None) + # Run validation on blocked users only if `recipient` is available. + # Let next validation steps do the rest. + if recipient: + # check if recipient in blocked_users, return 403 + user = BlockedUsers.get_by_address(recipient) + if user: + self.errors.append(self.messages['BLOCKED_RECIPIENT']) + self.http_return_code = 403 + def data_validation(self): if self.request_data.get('chainId') != current_app.config['FAUCET_CHAIN_ID']: self.errors.append(self.messages['UNSUPPORTED_CHAIN'] % ( diff --git a/api/migrations/versions/4cacf36b2356_.py b/api/migrations/versions/4cacf36b2356_.py index 5a6e4f8..c8c591b 100644 --- a/api/migrations/versions/4cacf36b2356_.py +++ b/api/migrations/versions/4cacf36b2356_.py @@ -5,12 +5,11 @@ Create Date: 2024-03-09 11:37:03.009350 """ -from alembic import op import sqlalchemy as sa +from alembic import op from api.services.database import flask_db_convention - # revision identifiers, used by Alembic. revision = '4cacf36b2356' down_revision = '022497197c7a' diff --git a/api/migrations/versions/fc63d8242f0d_.py b/api/migrations/versions/fc63d8242f0d_.py new file mode 100644 index 0000000..719023d --- /dev/null +++ b/api/migrations/versions/fc63d8242f0d_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: fc63d8242f0d +Revises: 4cacf36b2356 +Create Date: 2024-04-10 10:25:15.572753 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'fc63d8242f0d' +down_revision = '4cacf36b2356' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('blocked_users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('address', sa.String(length=42), nullable=False), + sa.Column('created', sa.DateTime(), nullable=False), + sa.Column('updated', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_blocked_users')) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('blocked_users') + # ### end Alembic commands ### diff --git a/api/scripts/sample_cli_request.py b/api/scripts/sample_cli_request.py index f44a5e9..3c01fb4 100644 --- a/api/scripts/sample_cli_request.py +++ b/api/scripts/sample_cli_request.py @@ -1,6 +1,5 @@ import requests - ASK_API_ENDPOINT = 'https://api.faucet.dev.gnosisdev.com/api/v1/cli/ask' ACCESS_KEY_ID = '__ACCESS_KEY_ID__' ACCESS_KEY_SECRET = '__ACCESS_KEY_SECRET__' diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 7cfac1b..f0be193 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -34,7 +34,7 @@ def _mock(self, env_variables=None): ] if env_variables: self.patchers.append(mock.patch.dict(os.environ, env_variables)) - + for p in self.patchers: p.start() diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 64f74a3..83ceaff 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -1,7 +1,7 @@ import unittest from api.const import ZERO_ADDRESS -from api.services.database import Transaction +from api.services.database import BlockedUsers, Transaction from .conftest import BaseTest, api_prefix # from mock import patch @@ -130,6 +130,29 @@ def test_ask_route_token_transaction(self): self.assertEqual(response.get_json().get('transactionHash'), transaction.hash) + def test_ask_route_blocked_users(self): + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 200) + + # Add recipient to BlockedUsers + blocked_user = BlockedUsers(address=ZERO_ADDRESS) + blocked_user.save() + + response = self.client.post(api_prefix + '/ask', json={ + 'captcha': CAPTCHA_TEST_RESPONSE_TOKEN, + 'chainId': FAUCET_CHAIN_ID, + 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, + 'recipient': ZERO_ADDRESS, + 'tokenAddress': ERC20_TOKEN_ADDRESS + }) + self.assertEqual(response.status_code, 403) + if __name__ == '__main__': unittest.main()