Skip to content

Commit

Permalink
Adds faucet backend recaptcha support
Browse files Browse the repository at this point in the history
  • Loading branch information
johnalotoski committed Jul 7, 2020
1 parent 47971d4 commit 414f4be
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 15 deletions.
48 changes: 45 additions & 3 deletions nix/nixos/cardano-faucet-service.nix
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ in {
description = "The default path to the faucet passphrase file.";
};

faucetSecretRecaptchaPath = mkOption {
type = types.str;
default = "/var/lib/keys/faucet.recaptcha";
description = ''
The default path to the faucet recaptcha file. This file
contains the secret recaptcha key that is associated with the
site key being used by a recaptcha implemented on a front-end.
For details, see:
https://developers.google.com/recaptcha/docs/display
'';
};

lovelacesToGiveAnonymous = mkOption {
type = types.int;
default = 1000000000;
Expand Down Expand Up @@ -153,6 +165,16 @@ in {
description = "Whether to use a Byron wallet or a Shelley wallet for faucet funding operations/APIs.";
};

useRecaptchaOnAnon = mkOption {
type = types.bool;
default = true;
description = ''
Whether to expect a recaptcha post parameter for anonymous requests.
If true, the expected POST parameter which will be validated by the
backend is: g-recaptcha-response
'';
};

walletApi = mkOption {
type = types.str;
default = "http://localhost:8090/v2";
Expand Down Expand Up @@ -299,32 +321,52 @@ in {
FAUCET_LISTEN_PORT = toString cfg.faucetListenPort;
FAUCET_SECRET_MNEMONIC_PATH = "${cfg.faucetBasePath}/faucet.mnemonic";
FAUCET_SECRET_PASSPHRASE_PATH = "${cfg.faucetBasePath}/faucet.passphrase";
FAUCET_SECRET_RECAPTCHA_PATH = "${cfg.faucetBasePath}/faucet.recaptcha";
FAUCET_WALLET_ID_PATH = "${cfg.faucetBasePath}/faucet.id";
LOVELACES_TO_GIVE_ANON = toString cfg.lovelacesToGiveAnonymous;
LOVELACES_TO_GIVE_APIKEY = toString cfg.lovelacesToGiveApiKeyAuth;
RATE_LIMIT_ON_SUCCESS = if cfg.rateLimitOnSuccess then "TRUE" else "FALSE";
SECS_BETWEEN_REQS_ANON = toString cfg.secondsBetweenRequestsAnonymous;
SECS_BETWEEN_REQS_APIKEY = toString cfg.secondsBetweenRequestsApiKeyAuth;
USE_BYRON_WALLET = if cfg.useByronWallet then "TRUE" else "FALSE";
USE_RECAPTCHA_ON_ANON = if cfg.useRecaptchaOnAnon then "TRUE" else "FALSE";
WALLET_API = cfg.walletApi;
WALLET_LISTEN_PORT = toString cfg.walletListenPort;
};

preStart = ''
mkdir -p ${cfg.faucetBasePath}
cd ${cfg.faucetBasePath}
# For all files but the api key file, copy the files
# into place only if they do not already exist.
# The reason is that new faucet wallets will
# require removing an old wallet manually first if
# the new wallet name will remain the same. It will also
# help prevent accidental overwrites. The api key file
# is updated more routinely and is the exception.
# Automatically copy api key updates into place
if ! [ -s faucet.apikey ]; then
chmod 0600 faucet.mnemonic
fi
cp ${cfg.faucetApiKeyPath} faucet.apikey
chmod 0400 faucet.apikey
if ! [ -s faucet.mnemonic ]; then
cp ${cfg.faucetSecretMnemonicPath} faucet.mnemonic
chmod 0400 faucet.mnemonic
fi
if ! [ -s faucet.passphrase ]; then
cp ${cfg.faucetSecretPassphrasePath} faucet.passphrase
chmod 0400 faucet.passphrase
fi
if ! [ -s faucet.apikey ]; then
cp ${cfg.faucetApiKeyPath} faucet.apikey
chmod 0400 faucet.apikey
if ! [ -s faucet.recaptcha ]; then
cp ${cfg.faucetSecretRecaptchaPath} faucet.recaptcha
chmod 0400 faucet.recaptcha
fi
if ! [ -s faucet.id ]; then
${defaultPackages.create-faucet-wallet} \
-e ${if cfg.useByronWallet then "byron" else "shelley"} \
Expand Down
2 changes: 2 additions & 0 deletions src/cardano-faucet.cr
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ DB.open "sqlite3://last-seen.sqlite" do |db|
Log.debug { "FAUCET_LISTEN_PORT: #{FAUCET_LISTEN_PORT}" }
Log.debug { "FAUCET_LOG_LEVEL: #{FAUCET_LOG_LEVEL}" }
Log.debug { "FAUCET_PASSPHRASE_PATH: #{FAUCET_PASSPHRASE_PATH}" }
Log.debug { "FAUCET_RECAPTCHA_PATH: #{FAUCET_RECAPTCHA_PATH}" }
Log.debug { "FAUCET_WALLET_ID_PATH: #{FAUCET_WALLET_ID_PATH}" }
Log.debug { "FAUCET_WALLET_ID: #{FAUCET_WALLET_ID}" }
Log.debug { "GENESIS_BLOCK_HASH: #{faucet.settings.genesis_block_hash}" }
Expand All @@ -50,6 +51,7 @@ DB.open "sqlite3://last-seen.sqlite" do |db|
Log.debug { "SECS_BETWEEN_REQS_ANON: #{SECS_BETWEEN_REQS_ANON}" }
Log.debug { "SECS_BETWEEN_REQS_APIKEY: #{SECS_BETWEEN_REQS_APIKEY}" }
Log.debug { "USE_BYRON_WALLET: #{USE_BYRON_WALLET}" }
Log.debug { "USE_RECAPTCHA_ON_ANON: #{USE_RECAPTCHA_ON_ANON}" }
Log.debug { "WALLET_API: #{WALLET_API}" }
Log.debug { "WALLET_LISTEN_PORT: #{WALLET_LISTEN_PORT}" }

Expand Down
89 changes: 79 additions & 10 deletions src/cardano-module.cr
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ module Cardano
end

class Wallet
def self.recaptchaVerify(recaptchaResponse, ip)
params = HTTP::Params.encode({"secret" => RECAPTCHA_SECRET,
"response" => recaptchaResponse,
"remoteip" => ip })
response = HTTP::Client.post(RECAPTCHA_URI + "?" + params, RECAPTCHA_HEADER)
result = response.body
statusCode = response.status_code
statusMessage = response.status_message
Log.debug { "response: #{response}" }
Log.debug { "submitted ip: #{ip}" }
if response.success?
Log.debug { "statusCode: #{statusCode}" }
Log.debug { "statusMessage: #{statusMessage}" }
Log.debug { "Result: #{result.to_s.delete('\n')}" }
else
Log.error { "statusCode: #{statusCode}" }
Log.error { "statusMessage: #{statusMessage}" }
Log.error { "Result: #{result.to_s.delete('\n')}" }
apiRaise response
end
return response
end

def self.apiPost(path, body)
client = HTTP::Client.new(API_URI)
response = client.post(path, HEADERS, body)
Expand Down Expand Up @@ -318,6 +341,32 @@ module Cardano
}
end

def on_recaptcha_required
msg = {statusCode: 403,
error: "Forbidden",
message: "Anonymous Access Requires Recaptcha: please request funds via the frontend and complete the recaptcha",
}

Log.debug { msg.to_json }
{
status: HTTP::Status::FORBIDDEN,
body: msg,
}
end

def on_recaptcha_failed
msg = {statusCode: 403,
error: "Forbidden",
message: "Recaptcha verification failed",
}

Log.debug { msg.to_json }
{
status: HTTP::Status::FORBIDDEN,
body: msg,
}
end

def on_post(context : HTTP::Server::Context) : Response
amount = LOVELACES_TO_GIVE_ANON
apiKey = ""
Expand All @@ -343,25 +392,46 @@ module Cardano
timeBetweenRequests = SECS_BETWEEN_REQS_ANON.seconds
end

ipPort = context.request.remote_address
xRealIp = context.request.headers["X-Real-IP"]?
ip = Socket::IPAddress.parse("tcp://#{real_ip_port(xRealIp) || ipPort}").address

if authenticated
Log.info { "Auth Request: #{apiKey} \"#{API_KEYS[apiKey][:comment]}\" " \
"LOVELACES_PER_TX: #{amount} " \
"PERIOD_PER_TX: #{API_KEYS[apiKey][:periodPerTx]} " \
"IP: #{context.request.remote_address || "NA"} " \
"X-Real-IP: #{context.request.headers["X-Real-IP"]? || "NA"}" }
"IP-PORT: #{ipPort || "NA"} " \
"X-Real-IP: #{xRealIp || "NA"}" }
else
Log.info { "Anon Request: LOVELACES_PER_TX: #{amount} " \
"PERIOD_PER_TX: #{SECS_BETWEEN_REQS_ANON} " \
"IP: #{context.request.remote_address || "NA"} " \
"X-Real-IP: #{context.request.headers["X-Real-IP"]? || "NA"}" }
"IP-PORT: #{ipPort || "NA"} " \
"X-Real-IP: #{xRealIp || "NA"}" }
end

if !ANONYMOUS_ACCESS && !authenticated
return on_forbidden
end

if USE_RECAPTCHA_ON_ANON && !authenticated
if context.request.query_params.has_key?("g-recaptcha-response")
gRecaptchaResponse = context.request.query_params["g-recaptcha-response"]
response = Wallet.recaptchaVerify(gRecaptchaResponse, ip)
Log.debug { response }
if JSON.parse(response.body)["success"]? && JSON.parse(response.body)["success"].to_s == "true"
Log.info { "Recaptcha Verified: true" }
else
Log.info { "Recaptcha Verified: false" }
return on_recaptcha_failed
end
else
Log.info { "Recaptcha Verified: not provided" }
return on_recaptcha_required
end
end

rate_limiter, ip = limit_rate(
real_ip(context.request.headers["X-Real-IP"]?) || context.request.remote_address,
ip,
timeBetweenRequests.as(Time::Span),
apiKey,
apiKeyComment,
Expand Down Expand Up @@ -434,14 +504,14 @@ module Cardano
}
end

def real_ip(header : String) : String
def real_ip_port(header : String) : String
"#{header}:443"
end

def real_ip(header : Nil) : Nil
def real_ip_port(header : Nil) : Nil
end

def limit_rate(remote : Nil,
def limit_rate(ip : Nil,
timeBetweenRequests : Time::Span,
apiKey : String,
apiKeyComment : String,
Expand All @@ -457,7 +527,7 @@ module Cardano
"")
end

def limit_rate(remote : String,
def limit_rate(ip : String,
timeBetweenRequests : Time::Span,
apiKey : String,
apiKeyComment : String,
Expand All @@ -466,7 +536,6 @@ module Cardano
txId : String
) : Tuple(NamedTuple(time: Time, allow: Bool, try_again: Time), String)

ip = Socket::IPAddress.parse("tcp://#{remote}").address
allow_after = @lastRequestTime - timeBetweenRequests

found = nil
Expand Down
10 changes: 8 additions & 2 deletions src/setup.cr
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,31 @@ FAUCET_LOG_SOURCES = ENV.fetch("CRYSTAL_LOG_SOURCES", "*")
FAUCET_LISTEN_ADDRESS = ENV.fetch("FAUCET_LISTEN_ADDRESS", "127.0.0.1")
FAUCET_LISTEN_PORT = ENV.fetch("FAUCET_LISTEN_PORT", "8091").to_i
FAUCET_PASSPHRASE_PATH = ENV.fetch("FAUCET_SECRET_PASSPHRASE_PATH", "/var/lib/cardano-faucet/faucet.passphrase")
FAUCET_RECAPTCHA_PATH = ENV.fetch("FAUCET_SECRET_RECAPTCHA_PATH", "/var/lib/cardano-faucet/faucet.recaptcha")
FAUCET_WALLET_ID_PATH = ENV.fetch("FAUCET_WALLET_ID_PATH", "/var/lib/cardano-faucet/faucet.id")
LOVELACES_TO_GIVE_ANON = ENV.fetch("LOVELACES_TO_GIVE_ANON", "1000000000").to_u64
LOVELACES_TO_GIVE_APIKEY = ENV.fetch("LOVELACES_TO_GIVE_APIKEY", "1000000000").to_u64
RATE_LIMIT_ON_SUCCESS = ENV.fetch("RATE_LIMIT_ON_SUCCESS", "TRUE") == "TRUE" ? true : false
SECS_BETWEEN_REQS_ANON = ENV.fetch("SECS_BETWEEN_REQS_ANON", "86400").to_u32
SECS_BETWEEN_REQS_APIKEY = ENV.fetch("SECS_BETWEEN_REQS_APIKEY", "0").to_u32
USE_BYRON_WALLET = ENV.fetch("USE_BYRON_WALLET", "TRUE") == "TRUE" ? true : false
USE_RECAPTCHA_ON_ANON = ENV.fetch("USE_RECAPTCHA_ON_ANON", "TRUE") == "TRUE" ? true : false
WALLET_LISTEN_PORT = ENV.fetch("WALLET_LISTEN_PORT", "8090").to_i
WALLET_API = ENV.fetch("WALLET_API", "http://localhost:#{WALLET_LISTEN_PORT}/v2")

FAUCET_WALLET_ID = readFile(FAUCET_WALLET_ID_PATH)
RECAPTCHA_SECRET = readFile(FAUCET_RECAPTCHA_PATH)
SECRET_PASSPHRASE = readFile(FAUCET_PASSPHRASE_PATH)
API_KEYS = readKeys(FAUCET_API_KEY_PATH)

API_KEY_LEN = 32_u8
API_KEY_COMMENT_MAX_LEN = 64_u8

API_URI = URI.parse("#{WALLET_API}")
HEADERS = HTTP::Headers{"Content-Type" => "application/json; charset=utf-8"}
API_URI = URI.parse("#{WALLET_API}")
HEADERS = HTTP::Headers{"Content-Type" => "application/json; charset=utf-8"}

RECAPTCHA_URI = "https://www.google.com/recaptcha/api/siteverify"
RECAPTCHA_HEADER = HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"}

MIN_METRICS_PERIOD = 10_u8

Expand Down

0 comments on commit 414f4be

Please sign in to comment.