triannon 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/triannon/application_controller.rb +128 -0
- data/app/controllers/triannon/auth_controller.rb +436 -0
- data/app/models/triannon/annotation.rb +2 -2
- data/config/routes.rb +30 -47
- data/config/triannon.yml +43 -6
- data/lib/generators/triannon/install_generator.rb +34 -7
- data/lib/tasks/triannon_tasks.rake +4 -2
- data/lib/triannon/version.rb +1 -1
- metadata +47 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8bbca5b2eeb46df56a16905386aa0896e32edc1d
|
4
|
+
data.tar.gz: 4057efb53bd40de5dd20a637b0e6eeaa581e3274
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ade73d1212e2d5ef3fb815946279af50e58a018999ebb0b1c7d8c8db9a5f56d85ddb4a42d9472d5c142e2ea1e1079646889fac21859afe61370ca47c85dae552
|
7
|
+
data.tar.gz: db831b37c53215465bb6441ea599dca56722c467a9968c477e7b2a0780d2070c7d8d2b717be6b645a1bff23d1c5a0ceb70578294c05a5535e5d3dd11057b2e22
|
@@ -1,4 +1,132 @@
|
|
1
1
|
module Triannon
|
2
2
|
class ApplicationController < ActionController::Base
|
3
|
+
|
4
|
+
before_action :authorize
|
5
|
+
|
6
|
+
#--- Authentication methods
|
7
|
+
#
|
8
|
+
# The #access_token_data method is generally available to the
|
9
|
+
# application. The #access_token_generate and #access_token_validate?
|
10
|
+
# methods are consolidated here to provide a unified view of these methods,
|
11
|
+
# although the application generally may not need to call them. They are
|
12
|
+
# used specifically in the auth_controller and these methods are tested
|
13
|
+
# in the auth_controller_spec.
|
14
|
+
|
15
|
+
# construct and encrypt an access token, using login data
|
16
|
+
# save the token into session[:access_token]
|
17
|
+
def access_token_generate(data)
|
18
|
+
timestamp = Time.now.to_i.to_s # seconds since epoch
|
19
|
+
salt = SecureRandom.base64(64)
|
20
|
+
key = ActiveSupport::KeyGenerator.new(timestamp).generate_key(salt)
|
21
|
+
crypt = ActiveSupport::MessageEncryptor.new(key)
|
22
|
+
session[:access_data] = [timestamp, salt]
|
23
|
+
session[:access_token] = crypt.encrypt_and_sign([data, timestamp])
|
24
|
+
end
|
25
|
+
|
26
|
+
# decrypt, parse and validate access token
|
27
|
+
def access_token_valid?(code)
|
28
|
+
begin
|
29
|
+
if code == session[:access_token]
|
30
|
+
identity, salt = session[:access_data]
|
31
|
+
key = ActiveSupport::KeyGenerator.new(identity).generate_key(salt)
|
32
|
+
crypt = ActiveSupport::MessageEncryptor.new(key)
|
33
|
+
data, timestamp = crypt.decrypt_and_verify(code)
|
34
|
+
elapsed = Time.now.to_i - timestamp.to_i # sec since token was issued
|
35
|
+
return data if elapsed < Triannon.config[:access_token_expiry]
|
36
|
+
end
|
37
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
38
|
+
# This is an invalid code, so return nil (a falsy value).
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Extract access login data from Authorization header, if it is valid.
|
43
|
+
# @param headers [Hash] request.headers with 'Authorization'
|
44
|
+
# @return login_data [Hash|nil]
|
45
|
+
def access_token_data(headers)
|
46
|
+
auth = headers['Authorization']
|
47
|
+
unless auth.nil? || auth !~ /^Bearer/
|
48
|
+
token = auth.split.last
|
49
|
+
access_token_valid?(token)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def authorize
|
57
|
+
# Require authorization on POST and DELETE requests.
|
58
|
+
auth_methods = ['POST','DELETE']
|
59
|
+
return true unless auth_methods.include? request.method
|
60
|
+
# Allow any requests to the /auth paths; provided that an
|
61
|
+
# anno root container cannot start with 'auth' in the name
|
62
|
+
# (which is controlled by the routes constraints).
|
63
|
+
return true if request.path =~ /^\/auth/
|
64
|
+
authorized_workgroup?
|
65
|
+
end
|
66
|
+
|
67
|
+
def authorized_workgroup?
|
68
|
+
# If the request does not map to a configured container, allow access.
|
69
|
+
container_auth = container_authorization
|
70
|
+
return true if container_auth.empty?
|
71
|
+
if request.headers['Authorization'].nil?
|
72
|
+
render401
|
73
|
+
return false
|
74
|
+
end
|
75
|
+
# Identify an intersection of the user and the authorized workgroups.
|
76
|
+
container_groups = container_auth['workgroups'] || []
|
77
|
+
match = container_groups & user_workgroups
|
78
|
+
if match.empty?
|
79
|
+
render403
|
80
|
+
false
|
81
|
+
else
|
82
|
+
true
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Extract container authorization from the configuration parameters
|
87
|
+
# @return authorization [Hash]
|
88
|
+
def container_authorization
|
89
|
+
container_config = request_container_config
|
90
|
+
if container_config.instance_of? Hash
|
91
|
+
container_config['auth'] || {}
|
92
|
+
else
|
93
|
+
{}
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Map the request root container to config parameters;
|
98
|
+
# assume that a request can only map to one root container.
|
99
|
+
# If this mapping fails, assume that authorization is OK.
|
100
|
+
def request_container_config
|
101
|
+
# TODO: refine the matching algorithm, esp if there is more than one
|
102
|
+
# match rather than assume it can only match one container.
|
103
|
+
request_container = params['anno_root']
|
104
|
+
configs = Triannon.config[:ldp]['anno_containers']
|
105
|
+
configs[request_container]
|
106
|
+
end
|
107
|
+
|
108
|
+
# Extract user workgroups from the access token
|
109
|
+
# @return workgroups [Array<String>]
|
110
|
+
def user_workgroups
|
111
|
+
access_data = access_token_data(request.headers)
|
112
|
+
if access_data.instance_of? Hash
|
113
|
+
access_data['workgroups'] || []
|
114
|
+
else
|
115
|
+
[]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def render401
|
120
|
+
respond_to do |format|
|
121
|
+
format.all { head :unauthorized }
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def render403
|
126
|
+
respond_to do |format|
|
127
|
+
format.all { head :forbidden }
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
3
131
|
end
|
4
132
|
end
|
@@ -0,0 +1,436 @@
|
|
1
|
+
require_dependency "triannon/application_controller"
|
2
|
+
|
3
|
+
module Triannon
|
4
|
+
# Adapted from http://image-auth.iiif.io/api/image/2.1/authentication.html
|
5
|
+
class AuthController < ApplicationController
|
6
|
+
include RdfResponseFormats
|
7
|
+
|
8
|
+
# HTTP request methods accepted by /auth/login
|
9
|
+
# TODO: enable GET when triannon supports true user authentication
|
10
|
+
LOGIN_ACCEPT = 'OPTIONS, POST'
|
11
|
+
|
12
|
+
# OPTIONS /auth/login
|
13
|
+
def options
|
14
|
+
# The request MUST use HTTP OPTIONS
|
15
|
+
case request.request_method
|
16
|
+
when 'OPTIONS'
|
17
|
+
if cookies[:login_user]
|
18
|
+
info = service_info_logout
|
19
|
+
else
|
20
|
+
info = service_info_login
|
21
|
+
end
|
22
|
+
# TODO: include optional info, such as service_info_client_identity
|
23
|
+
json_response(info, 200)
|
24
|
+
else
|
25
|
+
# The routes should prevent any execution here.
|
26
|
+
request_method_error(LOGIN_ACCEPT)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# POST to /auth/login
|
31
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#login-service
|
32
|
+
def login
|
33
|
+
# The service must set a Cookie for the Access Token Service to retrieve
|
34
|
+
# to determine the user information provided by the authentication system.
|
35
|
+
case request.request_method
|
36
|
+
when 'POST'
|
37
|
+
login_handler_post
|
38
|
+
else
|
39
|
+
# The routes should prevent any execution here.
|
40
|
+
request_method_error(LOGIN_ACCEPT)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# GET /auth/logout
|
45
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#logout-service
|
46
|
+
def logout
|
47
|
+
case request.request_method
|
48
|
+
when 'GET'
|
49
|
+
cookies.delete(:login_user)
|
50
|
+
reset_session
|
51
|
+
redirect_to root_url, notice: 'Successfully logged out.'
|
52
|
+
else
|
53
|
+
# The routes should prevent any execution here.
|
54
|
+
request_method_error('GET')
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# POST /auth/client_identity
|
59
|
+
# A request MUST carry a body with:
|
60
|
+
# { "clientId" : "ID", "clientSecret" : "SECRET" }
|
61
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#client-identity-service
|
62
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#error-conditions
|
63
|
+
# return json body [String] containing: { "authorizationCode": code }
|
64
|
+
def client_identity
|
65
|
+
return unless process_post?
|
66
|
+
return unless process_json?
|
67
|
+
data = JSON.parse(request.body.read)
|
68
|
+
required_fields = ['clientId', 'clientSecret']
|
69
|
+
identity = parse_identity(data, required_fields)
|
70
|
+
if identity['clientId'] && identity['clientSecret']
|
71
|
+
if authorized_client? identity
|
72
|
+
id = identity['clientId']
|
73
|
+
pass = identity['clientSecret']
|
74
|
+
code = { authorizationCode: auth_code_generate(id, pass) }
|
75
|
+
json_response(code, 200)
|
76
|
+
else
|
77
|
+
err = {
|
78
|
+
error: 'invalidClient',
|
79
|
+
errorDescription: 'Invalid client credentials',
|
80
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
81
|
+
}
|
82
|
+
json_response(err, 401)
|
83
|
+
end
|
84
|
+
else
|
85
|
+
err = {
|
86
|
+
error: 'invalidClient',
|
87
|
+
errorDescription: 'Insufficient client data for authentication',
|
88
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
89
|
+
}
|
90
|
+
json_response(err, 401)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
# GET /auth/access_token
|
96
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#access-token-service
|
97
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#error-conditions
|
98
|
+
def access_token
|
99
|
+
# The cookie established via the login service must be passed to this
|
100
|
+
# service. The service should delete the cookie from the login service
|
101
|
+
# and create a new cookie that allows the user to access content.
|
102
|
+
if session[:login_data]
|
103
|
+
if session[:client_data]
|
104
|
+
# When an authorization code was obtained using /auth/client_identity,
|
105
|
+
# that code must be passed to the Access Token Service as well.
|
106
|
+
auth_code = params[:code]
|
107
|
+
if auth_code.nil?
|
108
|
+
auth_code_required
|
109
|
+
elsif auth_code_valid?(auth_code)
|
110
|
+
access_token_granted
|
111
|
+
else
|
112
|
+
auth_code_invalid
|
113
|
+
end
|
114
|
+
else
|
115
|
+
# Without an authentication code, a login session is sufficient for
|
116
|
+
# granting an access token. However, the only way to enable a login
|
117
|
+
# session is for an authorized client to provide user data in POST
|
118
|
+
# /auth/login, which requires the client to first obtain an
|
119
|
+
# authentication code. Hence, this block of code should never get
|
120
|
+
# executed (unless login requirements change).
|
121
|
+
access_token_granted
|
122
|
+
end
|
123
|
+
else
|
124
|
+
login_required
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# GET /auth/access_validate
|
129
|
+
# Authorize access based on validating an access token
|
130
|
+
def access_validate
|
131
|
+
auth = request.headers['Authorization']
|
132
|
+
if auth.nil? || auth !~ /Bearer/
|
133
|
+
access_token_invalid
|
134
|
+
else
|
135
|
+
token = auth.split[1]
|
136
|
+
if access_token_valid?(token)
|
137
|
+
response.status = 200
|
138
|
+
render nothing: true
|
139
|
+
else
|
140
|
+
access_token_invalid
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
# --------------------------------------------------------------------
|
148
|
+
# Access tokens
|
149
|
+
|
150
|
+
# Grant an access token for authorized access
|
151
|
+
def access_token_granted
|
152
|
+
cookies.delete(:login_user)
|
153
|
+
login_data = session.delete(:login_data)
|
154
|
+
access_token_generate(login_data) # saves to session[:access_token]
|
155
|
+
data = {
|
156
|
+
accessToken: session[:access_token],
|
157
|
+
tokenType: 'Bearer',
|
158
|
+
expiresIn: Triannon.config[:access_token_expiry]
|
159
|
+
}
|
160
|
+
json_response(data, 200)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Issue a 401 to challenge for a client access token
|
164
|
+
def access_token_invalid
|
165
|
+
err = {
|
166
|
+
error: 'invalidAccess',
|
167
|
+
errorDescription: 'invalid access token',
|
168
|
+
errorUri: ''
|
169
|
+
}
|
170
|
+
json_response(err, 401)
|
171
|
+
end
|
172
|
+
|
173
|
+
|
174
|
+
# --------------------------------------------------------------------
|
175
|
+
# User authentication
|
176
|
+
|
177
|
+
# Handles POST /auth/login
|
178
|
+
# The request MUST include a URI parameter 'code=client_token' where
|
179
|
+
# the 'client_token' has been obtained from /auth/client_identity and
|
180
|
+
# the request MUST carry a body with the following JSON template:
|
181
|
+
# { "userId" : "ID", "workgroups" : "wgA, wgB" }
|
182
|
+
# Note that the current 'SearchWorks' requirements do not specify
|
183
|
+
# a 'userSecret' (it's not available).
|
184
|
+
def login_handler_post
|
185
|
+
return unless process_post?
|
186
|
+
return unless process_json?
|
187
|
+
auth_code = params[:code]
|
188
|
+
if auth_code.nil?
|
189
|
+
auth_code_required
|
190
|
+
elsif auth_code_valid?(auth_code)
|
191
|
+
begin
|
192
|
+
data = JSON.parse(request.body.read)
|
193
|
+
required_fields = ['userId', 'workgroups']
|
194
|
+
identity = parse_identity(data, required_fields)
|
195
|
+
# When an authorized client POSTs user data, it is simply accepted.
|
196
|
+
if identity['userId'] && identity['workgroups']
|
197
|
+
# Coerce workgroups into an Array.
|
198
|
+
wg = identity['workgroups'] || []
|
199
|
+
wg = wg.split(',') if wg.instance_of? String
|
200
|
+
wg.delete_if {|e| e.empty? }
|
201
|
+
identity['workgroups'] = wg
|
202
|
+
# Save the login_data until an access token is requested.
|
203
|
+
# Note that session data must be JSON compatible.
|
204
|
+
identity.to_json # check JSON compatibility
|
205
|
+
cookies[:login_user] = identity['userId']
|
206
|
+
session[:login_data] = identity
|
207
|
+
redirect_to root_url, notice: 'Successfully logged in.'
|
208
|
+
else
|
209
|
+
login_required
|
210
|
+
end
|
211
|
+
rescue
|
212
|
+
login_required(422)
|
213
|
+
end
|
214
|
+
else
|
215
|
+
auth_code_invalid
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def login_required(status=401)
|
220
|
+
if request.format == :html
|
221
|
+
request_http_basic_authentication
|
222
|
+
elsif request.format == :json
|
223
|
+
# response.headers["WWW-Authenticate"] = %(Basic realm="Application")
|
224
|
+
if status == 401
|
225
|
+
err = {
|
226
|
+
error: '401 Unauthorized',
|
227
|
+
errorDescription: 'login credentials required',
|
228
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
229
|
+
}
|
230
|
+
end
|
231
|
+
if status == 422
|
232
|
+
err = {
|
233
|
+
error: '422 Unprocessable Entity',
|
234
|
+
errorDescription: 'login credentials cannot be parsed',
|
235
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
236
|
+
}
|
237
|
+
end
|
238
|
+
json_response(err, status)
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
|
243
|
+
# --------------------------------------------------------------------
|
244
|
+
# Client authentication
|
245
|
+
|
246
|
+
# Authenticates known clients
|
247
|
+
# @param identity [Hash] with fields 'clientId' and 'clientSecret'
|
248
|
+
def authorized_client?(identity)
|
249
|
+
@clients ||= Triannon.config[:authorized_clients]
|
250
|
+
@clients[identity['clientId']] == identity['clientSecret']
|
251
|
+
end
|
252
|
+
|
253
|
+
# --------------------------------------------------------------------
|
254
|
+
# Authentication tokens
|
255
|
+
|
256
|
+
# construct and encrypt an authorization code
|
257
|
+
def auth_code_generate(id, pass)
|
258
|
+
identity = "#{id};;;#{pass}"
|
259
|
+
timestamp = Time.now.to_i.to_s # seconds since epoch
|
260
|
+
salt = SecureRandom.base64(64)
|
261
|
+
key = ActiveSupport::KeyGenerator.new(identity).generate_key(salt)
|
262
|
+
crypt = ActiveSupport::MessageEncryptor.new(key)
|
263
|
+
session[:client_data] = [identity, salt]
|
264
|
+
session[:client_token] = crypt.encrypt_and_sign([id, timestamp])
|
265
|
+
end
|
266
|
+
|
267
|
+
# decrypt, parse and validate authorization code
|
268
|
+
def auth_code_valid?(code)
|
269
|
+
begin
|
270
|
+
if code == session[:client_token]
|
271
|
+
identity, salt = session[:client_data]
|
272
|
+
key = ActiveSupport::KeyGenerator.new(identity).generate_key(salt)
|
273
|
+
crypt = ActiveSupport::MessageEncryptor.new(key)
|
274
|
+
data, timestamp = crypt.decrypt_and_verify(code)
|
275
|
+
elapsed = Time.now.to_i - timestamp.to_i # sec since code was issued
|
276
|
+
return data if elapsed < Triannon.config[:client_token_expiry]
|
277
|
+
end
|
278
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
279
|
+
# This is an invalid code, so return nil (a falsy value).
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
# Issue a 403 for invalid client authorization codes
|
284
|
+
def auth_code_invalid
|
285
|
+
err = {
|
286
|
+
error: 'invalidClient',
|
287
|
+
errorDescription: 'Unable to validate authorization code',
|
288
|
+
errorUri: ''
|
289
|
+
}
|
290
|
+
json_response(err, 403)
|
291
|
+
end
|
292
|
+
|
293
|
+
# Issue a 401 to challenge for a client authorization code
|
294
|
+
def auth_code_required
|
295
|
+
err = {
|
296
|
+
error: 'invalidClient',
|
297
|
+
errorDescription: 'authorization code is required',
|
298
|
+
errorUri: ''
|
299
|
+
}
|
300
|
+
json_response(err, 401)
|
301
|
+
end
|
302
|
+
|
303
|
+
|
304
|
+
# --------------------------------------------------------------------
|
305
|
+
# Service information data
|
306
|
+
# TODO: evaluate whether we need this data
|
307
|
+
|
308
|
+
# return uri [String] the configured Triannon host URI
|
309
|
+
def service_base_uri
|
310
|
+
uri = RDF::URI.new(Triannon.config[:triannon_base_url])
|
311
|
+
uri.to_s.sub(uri.path,'')
|
312
|
+
end
|
313
|
+
|
314
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#access-token-service
|
315
|
+
# return info [Hash] access token service information
|
316
|
+
def service_info_access_token
|
317
|
+
{
|
318
|
+
service: {
|
319
|
+
"@id" => service_base_uri + '/auth/access_token',
|
320
|
+
"profile" => "http://iiif.io/api/image/2/auth/token",
|
321
|
+
"label" => "Request Access Token for Triannon"
|
322
|
+
}
|
323
|
+
}
|
324
|
+
end
|
325
|
+
|
326
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#client-identity-service
|
327
|
+
# return info [Hash] client identity service information
|
328
|
+
def service_info_client_identity
|
329
|
+
{
|
330
|
+
service: {
|
331
|
+
"@id" => service_base_uri + '/auth/client_identity',
|
332
|
+
"profile" => "http://iiif.io/api/image/2/auth/clientId"
|
333
|
+
}
|
334
|
+
}
|
335
|
+
end
|
336
|
+
|
337
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#login-service
|
338
|
+
# return info [Hash] login service information
|
339
|
+
def service_info_login
|
340
|
+
{
|
341
|
+
service: {
|
342
|
+
"@id" => service_base_uri + '/auth/login',
|
343
|
+
"profile" => "http://iiif.io/api/image/2/auth/login",
|
344
|
+
"label" => "Login to Triannon"
|
345
|
+
}
|
346
|
+
}
|
347
|
+
end
|
348
|
+
|
349
|
+
# http://image-auth.iiif.io/api/image/2.1/authentication.html#logout-service
|
350
|
+
# return info [Hash] logout service information
|
351
|
+
def service_info_logout
|
352
|
+
{
|
353
|
+
service: {
|
354
|
+
"@id" => service_base_uri + '/auth/logout',
|
355
|
+
"profile" => "http://iiif.io/api/image/2/auth/logout",
|
356
|
+
"label" => "Logout of Triannon"
|
357
|
+
}
|
358
|
+
}
|
359
|
+
end
|
360
|
+
|
361
|
+
|
362
|
+
# --------------------------------------------------------------------
|
363
|
+
# Utility methods
|
364
|
+
|
365
|
+
|
366
|
+
# @param data [Hash] Hash.to_json is rendered
|
367
|
+
# @param status [Integer] HTTP status code
|
368
|
+
def json_response(data, status)
|
369
|
+
render json: data.to_json, content_type: json_type_accepted, status: status
|
370
|
+
end
|
371
|
+
|
372
|
+
# Response content type to match an HTTP accept type for JSON formats
|
373
|
+
def json_type_accepted
|
374
|
+
mime_type_from_accept(['application/json', 'text/x-json', 'application/jsonrequest'])
|
375
|
+
end
|
376
|
+
|
377
|
+
# Parse POST JSON data to ensure it contains required fields
|
378
|
+
# @param fields [Array<String>] an array of required fields
|
379
|
+
def parse_identity(data, fields)
|
380
|
+
identity = Hash[fields.map {|f| [f, nil]}]
|
381
|
+
if fields.map {|f| data.key? f }.all?
|
382
|
+
fields.each {|f| identity[f] = data[f] }
|
383
|
+
end
|
384
|
+
identity
|
385
|
+
end
|
386
|
+
|
387
|
+
# Is the request content type JSON? If not, issue a 415 error.
|
388
|
+
def process_json?
|
389
|
+
if request.headers['Content-Type'] =~ /json/
|
390
|
+
true
|
391
|
+
else
|
392
|
+
logger.debug "Rejected Content-Type: #{request.headers['Content-Type']}"
|
393
|
+
render nothing: true, status: 415
|
394
|
+
false
|
395
|
+
end
|
396
|
+
end
|
397
|
+
|
398
|
+
# Is the request method POST? If not, issue a 405 error.
|
399
|
+
def process_post?
|
400
|
+
if request.post?
|
401
|
+
true
|
402
|
+
else
|
403
|
+
logger.debug "Rejected Request Method: #{request.request_method}"
|
404
|
+
err = {
|
405
|
+
error: 'invalidRequest',
|
406
|
+
errorDescription: "#{request.path} accepts POST requests, not #{request.request_method}",
|
407
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
408
|
+
}
|
409
|
+
response.headers.merge!({'Allow' => 'POST'})
|
410
|
+
json_response(err, 405)
|
411
|
+
false
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
# @param accept [String] a csv for request methods accepted
|
416
|
+
def request_method_error(accept)
|
417
|
+
logger.debug "Rejected Request Method: #{request.request_method}"
|
418
|
+
response.status = 405
|
419
|
+
response.headers.merge!(Allow: accept)
|
420
|
+
respond_to do |format|
|
421
|
+
format.json {
|
422
|
+
err = {
|
423
|
+
error: 'invalidRequest',
|
424
|
+
errorDescription: "#{request.path} accepts: #{accept}",
|
425
|
+
errorUri: 'http://image-auth.iiif.io/api/image/2.1/authentication.html'
|
426
|
+
}
|
427
|
+
render json: err.to_json, content_type: json_type_accepted
|
428
|
+
}
|
429
|
+
format.html {
|
430
|
+
render nothing: true
|
431
|
+
}
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
end # AuthController
|
436
|
+
end # Triannon
|
@@ -40,7 +40,7 @@ module Triannon
|
|
40
40
|
# Instance Methods ----------------------------------------------------------------
|
41
41
|
|
42
42
|
def save
|
43
|
-
|
43
|
+
run_callbacks :save do
|
44
44
|
# TODO: check if valid anno?
|
45
45
|
@id = Triannon::LdpWriter.create_anno(self, root_container) if graph && graph.size > 2
|
46
46
|
# reload from storage to get the anno id within the graph
|
@@ -51,7 +51,7 @@ module Triannon
|
|
51
51
|
end
|
52
52
|
|
53
53
|
def destroy
|
54
|
-
|
54
|
+
run_callbacks :destroy do
|
55
55
|
Triannon::LdpWriter.delete_anno "#{root_container}/#{id}"
|
56
56
|
end
|
57
57
|
end
|
data/config/routes.rb
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
Triannon::Engine.routes.draw do
|
2
2
|
|
3
|
+
# Authentication routes; these must precede '/:anno_root/*' and they
|
4
|
+
# preclude the use of an anno root named 'auth'.
|
5
|
+
match '/auth/login', to: 'auth#options', via: [:options]
|
6
|
+
match '/auth/login', to: 'auth#login', via: [:post]
|
7
|
+
get '/auth/logout', to: 'auth#logout'
|
8
|
+
get '/auth/access_token', to: 'auth#access_token'
|
9
|
+
get '/auth/access_validate', to: 'auth#access_validate'
|
10
|
+
post '/auth/client_identity', to: 'auth#client_identity'
|
11
|
+
|
3
12
|
# 1. can't use resourceful routing because of :anno_root (dynamic path segment)
|
4
13
|
|
5
14
|
# 2. couldn't figure out how to exclude specific values with regexp constraint since beginning and end regex matchers
|
@@ -8,75 +17,49 @@ Triannon::Engine.routes.draw do
|
|
8
17
|
# get -> new action
|
9
18
|
get '/annotations/:anno_root/new', to: 'annotations#new'
|
10
19
|
get '/:anno_root/new', to: 'annotations#new',
|
11
|
-
constraints: lambda { |
|
12
|
-
anno_root = request.path_parameters[:anno_root]
|
13
|
-
id = request.path_parameters[:id]
|
14
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/ && id !~ /^search$/ && id !~ /^new$/
|
15
|
-
}
|
20
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
16
21
|
|
17
22
|
# get -> search controller find action
|
18
23
|
get '/annotations/:anno_root/search', to: 'search#find'
|
19
24
|
get '/annotations/search', to: 'search#find'
|
20
25
|
get '/:anno_root/search', to: 'search#find',
|
21
|
-
constraints: lambda { |
|
22
|
-
anno_root = request.path_parameters[:anno_root]
|
23
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/
|
24
|
-
}
|
26
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
25
27
|
get '/search', to: 'search#find'
|
26
28
|
|
27
|
-
# get
|
29
|
+
# get + id -> show action
|
28
30
|
get '/annotations/:anno_root/:id(.:format)', to: 'annotations#show',
|
29
|
-
constraints: lambda { |
|
30
|
-
anno_root = request.path_parameters[:anno_root]
|
31
|
-
anno_root !~ /^search$/ && anno_root !~ /^new$/
|
32
|
-
}
|
31
|
+
constraints: lambda { |r| anno_root_filter(r) && id_filter(r) }
|
33
32
|
get '/:anno_root/:id(.:format)', to: 'annotations#show',
|
34
|
-
constraints: lambda { |
|
35
|
-
anno_root = request.path_parameters[:anno_root]
|
36
|
-
id = request.path_parameters[:id]
|
37
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/ && id !~ /^search$/ && id !~ /^new$/
|
38
|
-
}
|
33
|
+
constraints: lambda { |r| anno_root_filter(r) && id_filter(r) }
|
39
34
|
|
40
|
-
# get
|
35
|
+
# get - id -> index action
|
41
36
|
get '/annotations/:anno_root', to: 'annotations#index',
|
42
|
-
constraints: lambda { |
|
43
|
-
anno_root = request.path_parameters[:anno_root]
|
44
|
-
anno_root !~ /^search$/ && anno_root !~ /^new$/
|
45
|
-
}
|
37
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
46
38
|
get '/:anno_root', to: 'annotations#index',
|
47
|
-
constraints: lambda { |
|
48
|
-
anno_root = request.path_parameters[:anno_root]
|
49
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/
|
50
|
-
}
|
39
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
51
40
|
|
52
41
|
# post -> create action
|
53
42
|
post '/annotations/:anno_root', to: 'annotations#create',
|
54
|
-
constraints: lambda { |
|
55
|
-
anno_root = request.path_parameters[:anno_root]
|
56
|
-
id = request.path_parameters[:id]
|
57
|
-
anno_root !~ /^search$/ && anno_root !~ /^new$/ && id !~ /^search$/ && id !~ /^new$/
|
58
|
-
}
|
43
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
59
44
|
post '/:anno_root', to: 'annotations#create',
|
60
|
-
constraints: lambda { |
|
61
|
-
anno_root = request.path_parameters[:anno_root]
|
62
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/
|
63
|
-
}
|
45
|
+
constraints: lambda { |r| anno_root_filter(r) }
|
64
46
|
|
65
47
|
# delete -> destroy action
|
66
48
|
delete '/annotations/:anno_root/:id(.:format)', to: 'annotations#destroy',
|
67
|
-
constraints: lambda { |
|
68
|
-
anno_root = request.path_parameters[:anno_root]
|
69
|
-
id = request.path_parameters[:id]
|
70
|
-
anno_root !~ /^search$/ && anno_root !~ /^new$/ && id !~ /^search$/ && id !~ /^new$/
|
71
|
-
}
|
49
|
+
constraints: lambda { |r| anno_root_filter(r) && id_filter(r) }
|
72
50
|
delete '/:anno_root/:id(.:format)', to: 'annotations#destroy',
|
73
|
-
constraints: lambda { |
|
74
|
-
anno_root = request.path_parameters[:anno_root]
|
75
|
-
anno_root !~ /^annotations$/ && anno_root !~ /^search$/ && anno_root !~ /^new$/
|
76
|
-
}
|
77
|
-
|
51
|
+
constraints: lambda { |r| anno_root_filter(r) && id_filter(r) }
|
78
52
|
|
79
53
|
get '/annotations', to: 'search#find'
|
80
54
|
root to: 'search#find'
|
81
55
|
|
82
56
|
end
|
57
|
+
|
58
|
+
|
59
|
+
def anno_root_filter(request)
|
60
|
+
request.path_parameters[:anno_root] !~ /^annotations$|^auth$|^new$|^search$/
|
61
|
+
end
|
62
|
+
|
63
|
+
def id_filter(request)
|
64
|
+
request.path_parameters[:id] !~ /^new$|^search$/
|
65
|
+
end
|
data/config/triannon.yml
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# http://www.yaml.org/YAML_for_ruby.html
|
2
|
+
|
1
3
|
development:
|
2
4
|
ldp:
|
3
5
|
url: http://your.ldp_store_url.here
|
@@ -7,32 +9,67 @@ development:
|
|
7
9
|
# the container names here will also map to paths in the triannon url, e.g.
|
8
10
|
# "foo" here will mean you add a foo anno by POST to http://your.triannon-server.com/annotations/foo
|
9
11
|
# and you get the foo anno by GET to http://your.triannon-server.com/annotations/foo/(anno_uuid)
|
12
|
+
# auth: this configures access for POST and DELETE requests; when it is
|
13
|
+
# missing or the 'users' or 'workgroups' are empty, all access is OK.
|
14
|
+
# otherwise, the client must submit an access token containing data
|
15
|
+
# to be compared against these configuration parameters.
|
10
16
|
anno_containers:
|
11
|
-
|
12
|
-
|
17
|
+
foo:
|
18
|
+
bar:
|
19
|
+
auth:
|
20
|
+
users: []
|
21
|
+
workgroups:
|
22
|
+
- org:wg-A
|
23
|
+
- org:wg-B
|
13
24
|
solr_url: http://your.triannon_solr_url.here
|
14
25
|
# triannon_base_url: the prefix for the urls for your annos
|
15
26
|
triannon_base_url: http://your.triannon-server.com/annotations/
|
16
27
|
max_solr_retries: 5
|
17
28
|
base_sleep_seconds: 1
|
18
29
|
max_sleep_seconds: 5
|
30
|
+
authorized_clients:
|
31
|
+
clientA: secretA
|
32
|
+
clientB: secretB
|
33
|
+
# expiry values are in seconds
|
34
|
+
client_token_expiry: 60
|
35
|
+
access_token_expiry: 3600
|
19
36
|
|
20
37
|
test: &test
|
21
38
|
ldp:
|
22
39
|
url: http://your.ldp_store_url.here
|
23
40
|
uber_container: anno
|
24
41
|
anno_containers:
|
25
|
-
|
26
|
-
|
42
|
+
foo:
|
43
|
+
bar:
|
44
|
+
auth:
|
45
|
+
users: []
|
46
|
+
workgroups:
|
47
|
+
- org:wg-A
|
48
|
+
- org:wg-B
|
27
49
|
solr_url: http://your.triannon_solr_url.here
|
28
50
|
triannon_base_url: http://your.triannon-server.com/annotations/
|
51
|
+
authorized_clients:
|
52
|
+
clientA: secretA
|
53
|
+
clientB: secretB
|
54
|
+
# expiry values are in seconds
|
55
|
+
client_token_expiry: 60
|
56
|
+
access_token_expiry: 3600
|
29
57
|
|
30
58
|
production:
|
31
59
|
ldp:
|
32
60
|
url: http://your.ldp_store_url.here
|
33
61
|
uber_container: anno
|
34
62
|
anno_containers:
|
35
|
-
|
36
|
-
|
63
|
+
foo:
|
64
|
+
bar:
|
65
|
+
auth:
|
66
|
+
users: []
|
67
|
+
workgroups:
|
68
|
+
- org:wg-A
|
69
|
+
- org:wg-B
|
37
70
|
solr_url: http://your.triannon_solr_url.here
|
38
71
|
triannon_base_url: http://your.triannon-server.com/annotations/
|
72
|
+
authorized_clients:
|
73
|
+
# expiry values are in seconds
|
74
|
+
client_token_expiry: 60
|
75
|
+
access_token_expiry: 3600
|
@@ -20,32 +20,59 @@ development:
|
|
20
20
|
# "foo" here will mean you add a foo anno by POST to http://your.triannon-server.com/annotations/foo
|
21
21
|
# and you get the foo anno by GET to http://your.triannon-server.com/annotations/foo/(anno_uuid)
|
22
22
|
anno_containers:
|
23
|
-
|
24
|
-
|
23
|
+
foo:
|
24
|
+
bar:
|
25
|
+
auth:
|
26
|
+
users: []
|
27
|
+
workgroups:
|
28
|
+
- org:wg-A
|
29
|
+
- org:wg-B
|
25
30
|
solr_url: http://localhost:8983/solr/triannon
|
26
31
|
triannon_base_url: http://your.triannon-server.com/annotations/
|
27
32
|
max_solr_retries: 5
|
28
33
|
base_sleep_seconds: 1
|
29
34
|
max_sleep_seconds: 5
|
35
|
+
authorized_clients:
|
36
|
+
clientA: secretA
|
37
|
+
clientB: secretB
|
38
|
+
# expiry values are in seconds
|
39
|
+
client_token_expiry: 60
|
40
|
+
access_token_expiry: 3600
|
30
41
|
test: &test
|
31
42
|
ldp:
|
32
43
|
url: http://localhost:8983/fedora/rest
|
33
44
|
uber_container: anno
|
34
45
|
anno_containers:
|
35
|
-
|
36
|
-
|
46
|
+
foo:
|
47
|
+
bar:
|
48
|
+
auth:
|
49
|
+
users: []
|
50
|
+
workgroups:
|
51
|
+
- org:wg-A
|
52
|
+
- org:wg-B
|
37
53
|
solr_url: http://localhost:8983/solr/triannon
|
38
54
|
triannon_base_url: http://your.triannon-server.com/annotations/
|
55
|
+
authorized_clients:
|
56
|
+
clientA: secretA
|
57
|
+
clientB: secretB
|
58
|
+
# expiry values are in seconds
|
59
|
+
client_token_expiry: 60
|
60
|
+
access_token_expiry: 3600
|
39
61
|
production:
|
40
62
|
ldp:
|
41
63
|
url:
|
42
64
|
uber_container: anno
|
43
65
|
anno_containers:
|
44
|
-
|
45
|
-
|
66
|
+
foo:
|
67
|
+
bar:
|
68
|
+
auth:
|
69
|
+
users: []
|
70
|
+
workgroups:
|
71
|
+
- org:wg-A
|
72
|
+
- org:wg-B
|
46
73
|
solr_url:
|
47
74
|
triannon_base_url:
|
48
|
-
|
75
|
+
YML
|
49
76
|
create_file 'config/triannon.yml', default_yml
|
50
77
|
end
|
51
78
|
|
@@ -40,8 +40,10 @@ namespace :triannon do
|
|
40
40
|
puts "ERROR: Triannon config file missing: #{Triannon.triannon_file} - are you in the rails app root directory?"
|
41
41
|
raise "Triannon config file missing: #{Triannon.triannon_file}"
|
42
42
|
end
|
43
|
-
Triannon.config[:ldp]['
|
44
|
-
|
43
|
+
uber_container = Triannon.config[:ldp]['uber_container']
|
44
|
+
containers = Triannon.config[:ldp]['anno_containers'].keys
|
45
|
+
containers.each { |c|
|
46
|
+
Triannon::LdpWriter.create_basic_container(uber_container, c.dup)
|
45
47
|
}
|
46
48
|
end
|
47
49
|
end # namespace triannon
|
data/lib/triannon/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,16 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: triannon
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 3.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Naomi Dushay
|
8
8
|
- Willy Mene
|
9
|
+
- Darren Weber
|
9
10
|
autorequire:
|
10
11
|
bindir: bin
|
11
12
|
cert_chain: []
|
12
|
-
date: 2015-
|
13
|
+
date: 2015-07-22 00:00:00.000000000 Z
|
13
14
|
dependencies:
|
14
15
|
- !ruby/object:Gem::Dependency
|
15
16
|
name: rails
|
@@ -235,6 +236,48 @@ dependencies:
|
|
235
236
|
- - ">="
|
236
237
|
- !ruby/object:Gem::Version
|
237
238
|
version: '0'
|
239
|
+
- !ruby/object:Gem::Dependency
|
240
|
+
name: pry
|
241
|
+
requirement: !ruby/object:Gem::Requirement
|
242
|
+
requirements:
|
243
|
+
- - ">="
|
244
|
+
- !ruby/object:Gem::Version
|
245
|
+
version: '0'
|
246
|
+
type: :development
|
247
|
+
prerelease: false
|
248
|
+
version_requirements: !ruby/object:Gem::Requirement
|
249
|
+
requirements:
|
250
|
+
- - ">="
|
251
|
+
- !ruby/object:Gem::Version
|
252
|
+
version: '0'
|
253
|
+
- !ruby/object:Gem::Dependency
|
254
|
+
name: pry-doc
|
255
|
+
requirement: !ruby/object:Gem::Requirement
|
256
|
+
requirements:
|
257
|
+
- - ">="
|
258
|
+
- !ruby/object:Gem::Version
|
259
|
+
version: '0'
|
260
|
+
type: :development
|
261
|
+
prerelease: false
|
262
|
+
version_requirements: !ruby/object:Gem::Requirement
|
263
|
+
requirements:
|
264
|
+
- - ">="
|
265
|
+
- !ruby/object:Gem::Version
|
266
|
+
version: '0'
|
267
|
+
- !ruby/object:Gem::Dependency
|
268
|
+
name: pry-rails
|
269
|
+
requirement: !ruby/object:Gem::Requirement
|
270
|
+
requirements:
|
271
|
+
- - ">="
|
272
|
+
- !ruby/object:Gem::Version
|
273
|
+
version: '0'
|
274
|
+
type: :development
|
275
|
+
prerelease: false
|
276
|
+
version_requirements: !ruby/object:Gem::Requirement
|
277
|
+
requirements:
|
278
|
+
- - ">="
|
279
|
+
- !ruby/object:Gem::Version
|
280
|
+
version: '0'
|
238
281
|
- !ruby/object:Gem::Dependency
|
239
282
|
name: rubocop
|
240
283
|
requirement: !ruby/object:Gem::Requirement
|
@@ -321,6 +364,7 @@ files:
|
|
321
364
|
- app/controllers/concerns/rdf_response_formats.rb
|
322
365
|
- app/controllers/triannon/annotations_controller.rb
|
323
366
|
- app/controllers/triannon/application_controller.rb
|
367
|
+
- app/controllers/triannon/auth_controller.rb
|
324
368
|
- app/controllers/triannon/search_controller.rb
|
325
369
|
- app/helpers/triannon/application_helper.rb
|
326
370
|
- app/models/triannon/annotation.rb
|
@@ -372,7 +416,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
372
416
|
version: '0'
|
373
417
|
requirements: []
|
374
418
|
rubyforge_project:
|
375
|
-
rubygems_version: 2.4.
|
419
|
+
rubygems_version: 2.4.8
|
376
420
|
signing_key:
|
377
421
|
specification_version: 4
|
378
422
|
summary: Rails engine for working with OpenAnnotations stored in Fedora4
|