Skip to content

Commit

Permalink
Merge pull request #641 from sul-dlss/cdl
Browse files Browse the repository at this point in the history
Support IIIF authentication  services for controlled digital lending
  • Loading branch information
cbeer authored Sep 24, 2020
2 parents 38a121d + ce0d8c4 commit 6afbe82
Show file tree
Hide file tree
Showing 23 changed files with 643 additions and 24 deletions.
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Layout/LineLength:
Metrics/MethodLength:
Exclude:
- 'app/models/ability.rb'
- 'app/services/cdl_service.rb'

Naming/HeredocDelimiterNaming:
Enabled: false
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ gem 'dor-rights-auth', require: 'dor/rights_auth'
gem 'dalli'
gem 'retries'
gem 'zipline'
gem 'jwt'
gem 'redis'

group :production do
gem 'newrelic_rpm'
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ GEM
activesupport
jbuilder (2.10.1)
activesupport (>= 5.0.0)
jwt (2.2.2)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
Expand Down Expand Up @@ -261,6 +262,7 @@ GEM
rb-fsevent (0.10.4)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis (4.2.1)
regexp_parser (1.8.0)
retries (0.0.5)
rexml (3.2.4)
Expand Down Expand Up @@ -398,12 +400,14 @@ DEPENDENCIES
http
iiif-image-api (~> 0.2)
jbuilder (~> 2.7)
jwt
listen (>= 3.0.5, < 3.2)
newrelic_rpm
okcomputer
puma (~> 4.3)
rails (~> 6.0)
rails-controller-testing
redis
retries
rspec-rails (~> 4.0)
rubocop (~> 0.50)
Expand Down
9 changes: 8 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ def webauth_user
User.new(id: session[:remote_user] || request.remote_user,
ip_address: request.remote_ip,
webauth_user: true,
ldap_groups: ldap_groups)
ldap_groups: ldap_groups,
jwt_tokens: cookies.encrypted[:tokens]).tap { |user| clean_up_expired_cdl_tokens(user) }
end

def workgroups_from_env
Expand All @@ -111,4 +112,10 @@ def rescue_can_can(exception)

render file: "#{Rails.root}/public/403.html", status: :forbidden, layout: false
end

def clean_up_expired_cdl_tokens(user)
return unless cookies.encrypted[:tokens] && user.cdl_tokens.count != cookies.encrypted[:tokens].length

cookies.encrypted[:tokens] = user.cdl_tokens.pluck(:token)
end
end
129 changes: 129 additions & 0 deletions app/controllers/cdl_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# frozen_string_literal: true

##
# Controlled digital lending endpoint (also protected in production by shibboleth)
class CdlController < ApplicationController
before_action do
raise CanCan::AccessDenied, 'Unable to authenticate' unless current_user
end
skip_forgery_protection only: [:show, :show_options]

before_action :write_auth_session_info, only: [:create]
before_action :validate_token, only: [:delete]

# Render some information about an active CDL token so the viewer can display e.g.
# the current due date.
def show
render json: {
payload: existing_payload&.except(:token),
availability_url: ("#{Settings.cdl.url}/availability/#{barcode}" if barcode)
}.reject { |_k, v| v.blank? }
end

def show_options
response.headers['Access-Control-Allow-Headers'] = 'Authorization'
self.response_body = ''
end

# "Check out" a book for controlled digital lending:
# - authenicate the user using shibboleth (so we know they're eligible for CDL)
# - get the Symphony barcode of the digital item
# - bounce the user over to requests to perform the Symphony hold + check out
# THEN, the user gets bounced back to us with a token that represents their successful checkout
# (and coincidentally is also stored + used as the IIIF access cookie)
# The JWT token will contain:
# - jti (circ record key)
# - sub (sunetid)
# - aud (druid)
# - exp (due date)
# - barcode (note: the actual item barcode may differ from the one in the SDR item)
def create
render json: 'invalid barcode', status: 400 and return unless barcode

checkout_params = {
id: params[:id],
barcode: barcode,
modal: true,
return_to: cdl_checkout_success_iiif_auth_api_url(params[:id])
}

redirect_to "#{Settings.cdl.url}/checkout?#{checkout_params.to_param}"
end

def create_success
current_user.append_jwt_token(params[:token])
cookies.encrypted[:tokens] = current_user.jwt_tokens

respond_to do |format|
format.html { render html: '<html><script>window.close();</script></html>'.html_safe }
format.js { render js: 'window.close();' }
end
end

# "Check in" a book from controlled digital lending
# As with #create, we bounce the user to requests to handle the Symphony interaction,
# and after they come back we'll clean up cookies + tokens on this end.
def delete
token = existing_payload[:token]

checkin_params = { token: token, return_to: cdl_checkin_success_iiif_auth_api_url(params[:id]) }
redirect_to "#{Settings.cdl.url}/checkin?#{checkin_params.to_param}"
end

def delete_success
# Note: the user may have lost its token already (because it was marked as expired)
bad_tokens, good_tokens = current_user.cdl_tokens.partition { |payload| payload['aud'] == params[:id] }
cookies.encrypted[:tokens] = good_tokens.pluck(:token) if bad_tokens.any?

respond_to do |format|
format.html { render html: '<html><script>window.close();</script></html>'.html_safe }
format.js { render js: 'window.close();' }
end
end

def renew
token = existing_payload[:token]

renew_params = { modal: true, token: token, return_to: cdl_renew_success_iiif_auth_api_url(params[:id]) }
redirect_to "#{Settings.cdl.url}/renew?#{renew_params.to_param}"
end

def renew_success
current_user.append_jwt_token(params[:token])
cookies.encrypted[:tokens] = current_user.jwt_tokens

respond_to do |format|
format.html { render html: '<html><script>window.close();</script></html>'.html_safe }
format.js { render js: 'window.close();' }
end
end

private

def write_auth_session_info
return if session[:remote_user]

session[:remote_user] = request.env['REMOTE_USER']
session[:workgroups] = workgroups_from_env.join(';')
end

def existing_payload
@existing_payload ||= current_user.cdl_tokens.find { |token| token[:aud] == params[:id] }&.with_indifferent_access
end

def validate_token
render json: 'Token not found', status: :bad_request if existing_payload.blank?
end

def barcode
@barcode ||= begin
return existing_payload&.dig('barcode') if existing_payload&.dig('barcode')

public_xml = Purl.public_xml(params[:id])
doc = Nokogiri::XML.parse(public_xml)
barcode = doc.xpath('//identityMetadata/sourceId[@source="sul"]')&.text&.sub(/^stanford_/, '')

barcode if barcode.starts_with?('36105')
end
end
end
10 changes: 8 additions & 2 deletions app/controllers/iiif_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def show
return unless stale?(cache_headers_show(projection))

authorize! :read, projection
expires_in 10.minutes, public: anonymous_ability.can?(:read, projection)
expires_in cache_time, public: anonymous_ability.can?(:read, projection)

set_image_response_headers

Expand All @@ -46,7 +46,7 @@ def metadata
return
end

expires_in 10.minutes, public: false
expires_in cache_time, public: false
authorize! :read_metadata, current_image

status = if degraded_identifier? || can?(:access, current_image)
Expand Down Expand Up @@ -190,4 +190,10 @@ def degraded?
def ensure_valid_identifier
raise ActionController::RoutingError, "invalid identifer" unless stacks_identifier.valid?
end

def cache_time
return 1.minute if current_image.cdl_restricted?

10.minutes
end
end
6 changes: 5 additions & 1 deletion app/controllers/iiif_token_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ def create
# app-user, or are accessing material from a location-specific kiosk.
# Other anonymous users are not eligible.
def token_eligible_user?
current_user.token_user? || current_user.webauth_user? || current_user.app_user? || current_user.location?
current_user.token_user? ||
current_user.webauth_user? ||
current_user.app_user? ||
current_user.location? ||
current_user.cdl_tokens.any?
end

# Handle IIIF Authentication 1.0 browser-based client application requests
Expand Down
14 changes: 14 additions & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ def initialize(user)
end
end

if user.cdl_tokens.present?
# TODO: Actually check if the CDL object is downloadable
# can [:download, :read], models do |f|
# ...
# end

can [:access], models do |f|
value, _rule = f.rights.cdl_rights_for_file(f.id.file_name)
next unless value

user.cdl_tokens.any? { |payload| payload['aud'] == f.id.druid }
end
end

cannot :download, RestrictedImage

# These are called when checking to see if the image response should be served
Expand Down
6 changes: 6 additions & 0 deletions app/models/concerns/stacks_rights.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ def stanford_restricted?
value
end

def cdl_restricted?
value, _rule = rights.cdl_rights_for_file id.file_name

value
end

# Returns true if a given file has any location restrictions.
# Falls back to the object-level behavior if none at file level.
def restricted_by_location?
Expand Down
51 changes: 49 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
class User
include ActiveModel::Model

attr_accessor :id, :webauth_user, :anonymous_locatable_user, :app_user, :token_user, :ldap_groups, :ip_address
attr_accessor :id, :webauth_user, :anonymous_locatable_user, :app_user, :token_user,
:ldap_groups, :ip_address, :jwt_tokens

def webauth_user?
webauth_user
Expand Down Expand Up @@ -39,6 +40,52 @@ def location?
locations.any?
end

def append_jwt_token(token)
self.jwt_tokens = (cdl_tokens.to_a + [decode_token(token)&.first])
.compact
.sort_by { |x| x.fetch(:iat, Time.zone.at(0)) }
.reverse
.uniq { |x| x[:aud] }
.pluck(:token)
end

def cdl_tokens
return to_enum(:cdl_tokens) unless block_given?

(jwt_tokens || []).each do |token|
payload, _headers = decode_token(token)
next unless payload && payload['sub'] == id && !token_expired?(payload)

yield payload
end
end

def decode_token(token)
payload, headers = JWT.decode(token, Settings.cdl.jwt.secret, true, {
algorithm: Settings.cdl.jwt.algorithm, sub: id, verify_sub: true
})
[payload&.merge(token: token)&.with_indifferent_access, headers]
rescue JWT::ExpiredSignature, JWT::InvalidSubError
nil
end

def token_expired?(payload)
return true if payload['exp'] < Time.zone.now.to_i

redis&.get("cdl.#{payload['jti']}") == 'expired'
rescue Redis::BaseError => e
Honeybadger.notify(e) if Rails.env.production?
Rails.logger.error(e)

false
end

def redis
return unless Settings.cdl.redis.present? || ENV['REDIS_URL']

@redis ||= Redis.new(Settings.cdl.redis.to_h)
end

def self.from_token(token, additional_attributes = {})
attributes, timestamp, expiry = encryptor.decrypt_and_verify(token)
expiry ||= timestamp + Settings.token.default_expiry_time
Expand All @@ -54,7 +101,7 @@ def token
self.class.encryptor.encrypt_and_sign(
[
# stored parameters
{ id: id, ldap_groups: ldap_groups, ip_address: ip_address },
{ id: id, ldap_groups: ldap_groups, ip_address: ip_address, jwt_tokens: jwt_tokens },
# mint time
mint_time,
# expiry time
Expand Down
Loading

0 comments on commit 6afbe82

Please sign in to comment.