Skip to content

Commit

Permalink
Merge pull request #90 from IUBLibTech/sda_recaptcha
Browse files Browse the repository at this point in the history
[IUS-2220] optionally support recaptcha
  • Loading branch information
aploshay authored Mar 30, 2024
2 parents 886cf52 + e48ac28 commit 24cb2ba
Show file tree
Hide file tree
Showing 13 changed files with 202 additions and 130 deletions.
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# only let through requests that Google gives a score of > this score (between 0 and 1),
# where 1.0 is very likely a good interaction, 0.0 is very likely a bot;
# If the initial request fails to pass, our code (currently) falls back to a v2 reCAPTCHA challenge.
# See https://developers.google.com/recaptcha/docs/v3
RECAPTCHA_MINIMUM_SCORE=0.5
# v3
RECAPTCHA_SITE_KEY_V3='your_recaptcha_v3_site_key'
RECAPTCHA_SECRET_KEY_V3='your_recaptcha_v3_secret_key'
# v2 -- used for fallback if v3 verification fails
RECAPTCHA_SITE_KEY='your_recaptcha_v2_site_key'
RECAPTCHA_SECRET_KEY='your_recaptcha_v2_secret_key'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ config/skylight.yml
config/settings.local.yml
config/settings/*.local.yml
.secrets.sh
.env

# other things to ignore
vendor/*
import/*
*.swp
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ group :development, :test do
end

gem 'willow_sword', github: 'notch8/willow_sword'

gem 'dotenv-rails'
gem 'recaptcha'
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ GEM
devise
diff-lcs (1.5.1)
docopt (0.5.0)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
down (4.8.1)
addressable (~> 2.5)
draper (4.0.2)
Expand Down Expand Up @@ -781,6 +785,7 @@ GEM
rdf-xsd (3.2.1)
rdf (~> 3.2)
rexml (~> 3.2)
recaptcha (5.16.0)
redic (1.5.3)
hiredis
redis (4.8.1)
Expand Down Expand Up @@ -1048,6 +1053,7 @@ DEPENDENCIES
database_cleaner
devise
devise-guests (~> 0.6)
dotenv-rails
down (~> 4.4)
edtf
ezid-client
Expand All @@ -1073,6 +1079,7 @@ DEPENDENCIES
rack (~> 2.2.6)
rails (~> 5.1.6)
rails-controller-testing
recaptcha
redis (~> 4.0)
resque
resque-pool
Expand Down
30 changes: 25 additions & 5 deletions app/controllers/archive_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class ArchiveController < ApplicationController

def user_is_authorized?
set_variables
true # satisfy open access requirement
authenticated_user? && recaptcha_success?
end

def status
Expand All @@ -18,10 +18,9 @@ def status

def download_request
if user_is_authorized?
result = @archive_file.get!
result = @archive_file.get!(request_metadata)
if result[:file_path].present?
send_file(result[:file_path], filename: result[:filename])
@archive_file.downloaded!
else
unless result[:message]
Rails.logger.error("Message missing from #{@archive_file} result: #{result}")
Expand All @@ -34,18 +33,39 @@ def download_request
end
end
else
redirect_back fallback_location: root_url, alert: 'Action unavailable'
@archive_file.log_denied_attempt!(request_metadata, update_only: true)
redirect_back fallback_location: root_url, alert: @failure_description
end
end

private
def variable_params
params.permit(:collection, :object, :format, :request)
params.permit(:collection, :object, :format, :request, 'g-recaptcha-response'.to_sym, 'g-recaptcha-response-data'.to_sym => [:sda_request])
end

def set_variables
@collection = params[:collection]
@object = "#{variable_params[:object]}.#{variable_params[:format]}"
@archive_file = ArchiveFile.new(collection: @collection, object: @object)
end

def authenticated_user?
return true unless Settings.archive_api.require_user_authentication
@failure_description = 'Action available only to signed-in users.'
user_signed_in?
end

def recaptcha_success?
return true unless Settings.recaptcha.use?
v3_success = verify_recaptcha(action: 'sda_request', minimum_score: Settings.recaptcha.minimum_score.to_f, secret_key: Settings.recaptcha.v3.secret_key)
v2_success = verify_recaptcha unless v3_success
@failure_description = 'Action requires successful recaptcha completion.'
v3_success || v2_success
end

def request_metadata
user_metadata = { time: Time.now, user: current_user&.email }
user_metadata.merge!(recaptcha: recaptcha_reply || {}) if Settings.recaptcha.use?
user_metadata
end
end
114 changes: 45 additions & 69 deletions app/models/archive_file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,92 +28,65 @@ def status
end
end

def display_status
display_message_for(status)
def description_for_status(method:, lookup_status:, lookup_hash:)
Rails.logger.error("##{method} called with invalid key: #{lookup_status}") unless lookup_status.in?(lookup_hash.keys)
lookup_hash[lookup_status]
end

# a single archive_status can map to more than one #status
# multiple #status values map to the same end user message
def display_messages
@display_messages ||= begin
available = 'File found in archives but not yet staged for download. Attempt file download to initiate transfer from archives.'
requested = 'File transfer from archives has started. Please allow up to 1 hour for transfer to complete, then re-attempt download.'
{ staging_available: available, # refined 503/unstaged status
staging_requested: requested, # refined 503/unstaged status
staged_after_request: requested, # refined 200/staged status -- don't consider available for download until "local" status, copied from SDA cache to scratch
staged_without_request: available, # refined 200/staged status -- requires user request to start downloading workflow
local: 'File is available for immediate download',
not_found: 'File not found in archives',
no_response: 'File archives server is not responding',
unexpected: 'Unexpected response from file archives server',
too_many_requests: 'File is available in archives, but too many transfer requests are running. Please try again later.' }
end
end

def display_message_for(current_status)
Rails.logger.error("#display_message_for called with invalid key: #{current_status}") unless current_status.in?(display_messages.keys)
display_messages[current_status]
end

def request_action
request_action_for(status)
end

def request_action_for(current_status)
request_actions[current_status]
# used in descriptive fields, above action button
def display_status(current_status = status)
description_for_status(method: :display_status, lookup_status: current_status, lookup_hash: Settings.archive_api.status_messages.to_hash.with_indifferent_access)
end

def request_actions
@request_actions ||= begin
available = 'Request file from archives'
requested = 'File transfer from archives has started'
{ staging_available: available, # refined 503/unstaged status
staging_requested: requested, # refined 503/unstaged status
staged_after_request: requested, # refined 200/staged status -- don't consider available for download until "local" status, copied from SDA cache to scratch
staged_without_request: available, # refined 200/staged status -- requires user request to start downloading workflow
local: 'Download',
not_found: 'File not found in archives',
no_response: 'File archives server is not responding',
unexpected: 'Unexpected response from file archives server',
too_many_requests: 'File is available in archives, but too many transfer requests are running. Please try again later.' }
end
# used for button text
def request_action(current_status = status)
description_for_status(method: :request_action, lookup_status: current_status, lookup_hash: Settings.archive_api.request_actions.to_hash.with_indifferent_access)
end

def request_actionable?(request_status = status)
request_status.in? [:staging_available, :staged_without_request, :local]
end

# used for :notice and :alert messages in controller flash
def flash_message(current_status = status)
description_for(method: :flash_message, lookup_status: current_status, lookup_hash: Settings.archive_api.flash_messages.to_hash.with_indifferent_access)
end

# requests staging (if available and not requested yet)
# returns describing status, action taken (if any), and descriptive message
# @return Hash
def get!
def get!(request_hash = {})
current_status = status
request_hash.merge!({ status: current_status })
case current_status
when :local
{ status: current_status, action: nil, file_path: local_path, filename: local_filename, message: display_message_for(current_status) }
create_or_update_job_file!({ latest_user_download: Time.now, downloads: [request_hash] })
{ status: current_status, action: nil, file_path: local_path, filename: local_filename, message: display_status(current_status) }
when :staging_available, :staged_without_request
stage_request!(current_status)
stage_request!(request_hash)
when :staging_requested, :staged_after_request
# no action -- wait for DownloadArchivalFilesTask to stage and download
{ status: current_status, action: nil, message: display_message_for(:staging_requested) }
create_or_update_job_file!({ requests: [request_hash] })
{ status: current_status, action: nil, message: display_status(:staging_requested) }
when :not_found, :no_response, :unexpected
{ status: current_status, action: nil, message: display_message_for(current_status) }
create_or_update_job_file!({ requests: [request_hash] })
{ status: current_status, action: nil, message: display_status(current_status) }
else
Rails.logger.warn("Unexpected archive file status: #{current_status}")
create_or_update_job_file!({ requests: [request_hash] })
{ status: current_status, action: nil, message: 'Unknown file status' }
end
end

def log_denied_attempt!(request_hash = {}, update_only: false)
create_or_update_job_file!({ denials: [request_hash] }, update_only: update_only)
end

# bypasses status in job file via checking directly
def downloaded?
File.exist?(local_path)
end

# called by ArchiveController after successful user download
def downloaded!
create_or_update_job_file!({ user_downloaded: Time.now })
end

def staged?
archive_status.in? [:staged_without_request, :staged_after_request]
end
Expand Down Expand Up @@ -225,13 +198,14 @@ def archive_request(method: Net::HTTP::Head)

# if not yet staged: requests for staging (if possible)
# @return Hash
def stage_request!(current_status)
def stage_request!(request_hash = {})
Rails.logger.warn("Staging request for #{archive_url} made in status: #{status}") if staged? # log :staged_without_request cases
if block_new_jobs?
{ status: current_status, action: :throttled, message: display_message_for(:too_many_requests), alert: true }
log_denied_attempt!(request_hash.merge({ reason: 'block_new_jobs' })) # FIXME: update_only false or true here?
{ status: request_hash[:status], action: :throttled, message: display_status(:too_many_requests), alert: true }
else
create_or_update_job_file!
{ status: current_status, action: :create_or_update_job_file!, message: display_message_for(:staging_requested) }
create_or_update_job_file!({ requests: [request_hash.merge({ action: 'create_or_update_job_file!'})] })
{ status: request_hash[:status], action: :create_or_update_job_file!, message: display_status(:staging_requested) }
end
end

Expand All @@ -241,35 +215,37 @@ def job_file_path

# @return nil, Symbol [:staging_available, :staging_requested, :staged_after_request, :local]
def job_status
return unless job_file?
current_job_parameters[:status]
archive_file_worker&.job_status
end

def job_file?
File.exist?(job_file_path)
end

def current_job_parameters
return {} unless job_file?
YAML.load_file(job_file_path)
# avoid memoization for current results
def archive_file_worker
@archive_worker ||= begin
return unless job_file?
ArchiveFileWorker.new(job_file_path, logger: Rails.logger)
end
end

def default_job_parameters
{ url: archive_url, filename: local_filename, file_path: local_path, collection: collection, object: object, status: status, created_at: Time.now }
end

def create_or_update_job_file!(new_params = nil)
def create_or_update_job_file!(new_params = nil, update_only: false)
if job_file?
unless new_params # only update an existing file with new, non-default job parameters
Rails.logger.warn("Ignoring duplicate call to create default job parameters file for #{archive_url}")
return
end
new_params = current_job_parameters.merge(new_params)
else
archive_file_worker.update_job_yaml(new_params)
elsif !update_only
new_params ||= {}
new_params = default_job_parameters.merge(new_params)
new_params = new_params.merge(updated_at: Time.now)
File.write(job_file_path, new_params.to_yaml)
end
new_params = new_params.merge(updated_at: Time.now)
File.write(job_file_path, new_params.to_yaml)
end
end
Loading

0 comments on commit 24cb2ba

Please sign in to comment.