diff --git a/README.md b/README.md index 71e9820..0a754ba 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,17 @@ flask -A api create_enabled_token GNO 10200 0x19C653Da7c37c66208fbfbE8908A5051B5 flask -A api create_enabled_token xDAI 10200 0x0000000000000000000000000000000000000000 0.01 native ``` -Once enabled, the token wil appear in the list of enabled tokens on the endpoint `api/v1/info`. +Once enabled, the token will appear in the list of enabled tokens on the endpoint `api/v1/info`. + +#### 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 +installed on the server, e.g. apk update && apk add sqlite. + +Enter the database: `sqlite path/to/database` + +Search for the token to update: `select chain_id, max_amount_day from tokens where name = 'xDAI'` +Update amount: `update tokens set max_amount_day = 0.00015 where chain_id = 100;` ## ReactJS Frontend diff --git a/api/api/routes.py b/api/api/routes.py index ed8f566..1f6f927 100644 --- a/api/api/routes.py +++ b/api/api/routes.py @@ -38,7 +38,8 @@ def info(): chainName=current_app.config['FAUCET_CHAIN_NAME'], faucetAddress=current_app.config['FAUCET_ADDRESS'], csrfToken=csrf_item.token, - csrfRequestId=csrf_item.request_id + csrfRequestId=csrf_item.request_id, + csrfTimestamp=csrf_item.timestamp ), 200 diff --git a/api/api/services/csrf.py b/api/api/services/csrf.py index d6811f1..3e2f11c 100644 --- a/api/api/services/csrf.py +++ b/api/api/services/csrf.py @@ -1,14 +1,21 @@ +# from api.settings import CSRF_TIMESTAMP_MAX_SECONDS import random +from datetime import datetime 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 + class CSRFTokenItem: - def __init__(self, request_id, token): + def __init__(self, request_id, token, timestamp): self.request_id = request_id self.token = token + self.timestamp = timestamp class CSRFToken: @@ -17,23 +24,34 @@ def __init__(self, privkey, salt): self._pubkey = self._privkey.publickey() self._salt = salt - def generate_token(self): + def generate_token(self, timestamp=None): request_id = '%d' % random.randint(0, 1000) - data_to_encrypt = '%s%s' % (request_id, self._salt) + if not timestamp: + timestamp = datetime.now().timestamp() + data_to_encrypt = '%s%s%f' % (request_id, self._salt, timestamp) cipher_rsa = PKCS1_OAEP.new(self._pubkey) + # Data_to_encrypt can be of variable length, but not longer than + # the RSA modulus (in bytes) minus 2, minus twice the hash output size. + # For instance, if you use RSA 2048 and SHA-256, the longest + # message you can encrypt is 190 byte long. token = cipher_rsa.encrypt(data_to_encrypt.encode()) - return CSRFTokenItem(request_id, token.hex()) + return CSRFTokenItem(request_id, token.hex(), timestamp) - def validate_token(self, request_id, token): + def validate_token(self, request_id, token, timestamp): try: cipher_rsa = PKCS1_OAEP.new(self._privkey) decrypted_text = cipher_rsa.decrypt(bytes.fromhex(token)).decode() - - expected_text = '%s%s' % (request_id, self._salt) + expected_text = '%s%s%f' % (request_id, self._salt, timestamp) if decrypted_text == expected_text: - return True + # 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. + seconds_diff = (datetime.now()-datetime.fromtimestamp(timestamp)).total_seconds() + if seconds_diff > CSRF_TIMESTAMP_MIN_SECONDS: + return True + return False return False except Exception: return False diff --git a/api/api/services/validator.py b/api/api/services/validator.py index 9347a5b..8972c4f 100644 --- a/api/api/services/validator.py +++ b/api/api/services/validator.py @@ -86,7 +86,12 @@ def csrf_validation(self): self.errors.append('Bad request') self.http_return_code = 400 - csrf_valid = self.csrf.validate_token(request_id, token) + timestamp = self.request_data.get('timestamp', None) + if not timestamp: + self.errors.append('Bad request') + self.http_return_code = 400 + + csrf_valid = self.csrf.validate_token(request_id, token, timestamp) if not csrf_valid: self.errors.append('Bad request') self.http_return_code = 400 diff --git a/api/scripts/local_test_run.sh b/api/scripts/local_test_run.sh index 34e9d33..46d3312 100644 --- a/api/scripts/local_test_run.sh +++ b/api/scripts/local_test_run.sh @@ -1,6 +1,6 @@ -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask db upgrade -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask create_enabled_token xDAI 10200 0x0000000000000000000000000000000000000000 10 native -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask create_access_keys +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///$(pwd)/test.db python3 -m flask db upgrade +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///$(pwd)/test.db python3 -m flask create_enabled_token xDAI 10200 0x0000000000000000000000000000000000000000 0.0001 native +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///$(pwd)/test.db python3 -m flask create_access_keys # Take note of the access keys # Run API on port 3000 -FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///test.db python3 -m flask run -p 3000 \ No newline at end of file +FLASK_APP=api FAUCET_DATABASE_URI=sqlite:///$(pwd)/test.db python3 -m flask run -p 8000 \ No newline at end of file diff --git a/api/test.db b/api/test.db new file mode 100644 index 0000000..40764bf Binary files /dev/null and b/api/test.db differ diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 142a533..c02621b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,5 +1,6 @@ import os -from unittest import TestCase, TestResult, mock +from unittest import TestCase, mock +from datetime import datetime from api.services import CSRF, Strategy from api.services.database import Token, db @@ -13,6 +14,7 @@ class BaseTest(TestCase): + valid_csrf_timestamp = datetime(2020, 1, 18, 9, 30, 0).timestamp() def mock_claim_native(self, *args): tx_hash = '0x0' + '%d' % self.native_tx_counter * 63 @@ -73,7 +75,8 @@ def setUp(self): self.csrf = CSRF.instance # use same token for the whole test - self.csrf_token = self.csrf.generate_token() + # use a timestamp that would be actually validated by the CSRF class. + self.csrf_token = self.csrf.generate_token(timestamp=self.valid_csrf_timestamp) def tearDown(self): ''' @@ -105,7 +108,8 @@ def setUp(self): self.csrf = CSRF.instance # use same token for the whole test - self.csrf_token = self.csrf.generate_token() + # use a timestamp that would be actually validated by the CSRF class. + self.csrf_token = self.csrf.generate_token(timestamp=self.valid_csrf_timestamp) class RateLimitIPorAddressBaseTest(BaseTest): @@ -128,4 +132,5 @@ def setUp(self): self.csrf = CSRF.instance # use same token for the whole test - self.csrf_token = self.csrf.generate_token() \ No newline at end of file + # use a timestamp that would be actually validated by the CSRF class. + self.csrf_token = self.csrf.generate_token(timestamp=self.valid_csrf_timestamp) diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 4df9f36..04d30b1 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -35,7 +35,8 @@ def test_ask_route_parameters(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -48,7 +49,8 @@ def test_ask_route_parameters(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -60,7 +62,8 @@ def test_ask_route_parameters(self): 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'tokenAddress': NATIVE_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -73,7 +76,8 @@ def test_ask_route_parameters(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': 'not an address', 'tokenAddress': NATIVE_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -85,7 +89,8 @@ def test_ask_route_parameters(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': '0x00000123', 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -97,7 +102,8 @@ def test_ask_route_parameters(self): 'chainId': FAUCET_CHAIN_ID, 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -110,7 +116,8 @@ def test_ask_route_parameters(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY + 1, 'recipient': ZERO_ADDRESS, 'tokenAddress': 'non existing token address', - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -123,7 +130,8 @@ def test_ask_route_native_transaction(self): 'amount': DEFAULT_NATIVE_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': NATIVE_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -140,7 +148,8 @@ def test_ask_route_token_transaction(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': '0x' + '1234' * 10, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -152,7 +161,8 @@ def test_ask_route_token_transaction(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -168,7 +178,8 @@ def test_ask_route_blocked_users(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -184,7 +195,8 @@ def test_ask_route_blocked_users(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.csrf_token.timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) diff --git a/api/tests/test_api_claim_rate_limit.py b/api/tests/test_api_claim_rate_limit.py index df7bb5f..fc3143f 100644 --- a/api/tests/test_api_claim_rate_limit.py +++ b/api/tests/test_api_claim_rate_limit.py @@ -20,7 +20,8 @@ def test_ask_route_limit_by_ip(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.valid_csrf_timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -33,7 +34,8 @@ def test_ask_route_limit_by_ip(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.valid_csrf_timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -49,7 +51,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.valid_csrf_timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -66,7 +69,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.valid_csrf_timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) @@ -84,7 +88,8 @@ def test_ask_route_limit_by_ip_or_address(self): 'amount': DEFAULT_ERC20_MAX_AMOUNT_PER_DAY, 'recipient': ZERO_ADDRESS, 'tokenAddress': ERC20_TOKEN_ADDRESS, - 'requestId': self.csrf_token.request_id + 'requestId': self.csrf_token.request_id, + 'timestamp': self.valid_csrf_timestamp }, headers={ 'X-CSRFToken': self.csrf_token.token }) diff --git a/api/tests/test_csrf.py b/api/tests/test_csrf.py index f40fea2..c7c92f4 100644 --- a/api/tests/test_csrf.py +++ b/api/tests/test_csrf.py @@ -1,19 +1,30 @@ from .conftest import BaseTest +from datetime import datetime + class TestCSRF(BaseTest): def test_values(self): - token_obj = self.csrf.generate_token() + timestamp = datetime(2020, 1, 18, 9, 30, 0).timestamp() + token_obj = self.csrf.generate_token(timestamp=timestamp) self.assertTrue( - self.csrf.validate_token(token_obj.request_id, token_obj.token) + self.csrf.validate_token(token_obj.request_id, token_obj.token, token_obj.timestamp) + ) + self.assertFalse( + self.csrf.validate_token('myfakeid', token_obj.token, token_obj.timestamp) ) self.assertFalse( - self.csrf.validate_token('myfakeid', token_obj.token) + self.csrf.validate_token('myfakeid', 'myfaketoken', token_obj.timestamp) ) self.assertFalse( - self.csrf.validate_token('myfakeid', 'myfaketoken') + self.csrf.validate_token(token_obj.request_id, 'myfaketoken', token_obj.timestamp) ) + # test with timestamp for which diff between now() and creation time in seconds + # is lower than min. waiting period. + # Validation must return False since time interval is lower than mimimum waiting period. + timestamp = datetime.now().timestamp() + token_obj = self.csrf.generate_token(timestamp=timestamp) self.assertFalse( - self.csrf.validate_token(token_obj.request_id, 'myfaketoken') + self.csrf.validate_token(token_obj.request_id, token_obj.token, token_obj.timestamp) ) diff --git a/app/src/App.tsx b/app/src/App.tsx index db02437..b89391a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import Faucet from "./components/FaucetForm/Faucet" interface CSRFInfo { csrfToken: string requestId: string + timestamp: number } const chainName:{ [key: string]: string }= { @@ -22,7 +23,7 @@ function App(): JSX.Element { const [loading, setLoading] = useState(true) const [enabledTokens, setEnabledTokens] = useState([]) const [faucetLoading, setFaucetLoading] = useState(true) - const [csrfInfo, setCSRFInfo] = useState({csrfToken: '', requestId: ''}) + const [csrfInfo, setCSRFInfo] = useState({csrfToken: '', requestId: '', timestamp: 0}) const getFaucetInfo = async () => { return axios.get(`${process.env.REACT_APP_FAUCET_API_URL}/info`) @@ -40,21 +41,24 @@ function App(): JSX.Element { csrfInfo.csrfToken = response.data.csrfToken, csrfInfo.requestId = response.data.csrfRequestId + csrfInfo.timestamp = response.data.csrfTimestamp setCSRFInfo(csrfInfo) - }) .catch(() => { toast.error("Network error") }) .finally(() => { - setFaucetLoading(false) - setLoading(false) + // 5 seconds waiting period + setTimeout(function () { + setFaucetLoading(false) + setLoading(false) + }, 5000) }) }, []) const title = faucetLoading ? "FAUCET" : `${chainName[chainId]} CHAIN` const subtitle = faucetLoading - ? "Loading..." + ? "Application loading..." : (chainId === 100 ? "Faucet" : "Testnet Faucet") return ( @@ -80,6 +84,7 @@ function App(): JSX.Element { setLoading={setLoading} csrfToken={csrfInfo.csrfToken} requestId={csrfInfo.requestId} + timestamp={csrfInfo.timestamp} />

Want more{chainId === 100 ? '?' : ' on Gnosis Chain?'}