triannon 2.0.1 → 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|