Skip to content

Commit e94ced2

Browse files
Stop the request-a-copy access token from bypassing the CLARIN licence gate (#1437)
BitstreamRestController.retrieve was annotated @PreAuthorize("#accessToken != null|| hasPermission(#uuid, 'BITSTREAM', 'READ')"), so a non-null request-a-copy access token satisfied authorization on its own: neither the resource policies nor the CLARIN licence gate that AuthorizeServiceImpl.authorizeAction runs for every other bitstream read were consulted. request.item.type is "all", so tokens really are minted, and the fork has no such token path at all. The token is still honoured (request.item.type is unchanged), but only after the same licence check a download without a token goes through. The check is not reimplemented: the new clarinBitstreamAccessTokenSecurity bean and BitstreamResourceAccessByToken both call AuthorizationBitstreamUtils.authorizeBitstream, which is the method the normal download path reaches. That gate reads the CLARIN dtoken from the request, so a caller who holds both an access token and a download token is served. Card X-12, owner decision O-8. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ac1eeec commit e94ced2

4 files changed

Lines changed: 537 additions & 1 deletion

File tree

dspace-server-webapp/src/main/java/org/dspace/app/rest/BitstreamRestController.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ public class BitstreamRestController {
116116
* @throws SQLException
117117
* @throws AuthorizeException
118118
*/
119-
@PreAuthorize("#accessToken != null|| hasPermission(#uuid, 'BITSTREAM', 'READ')")
119+
// CLARIN: a non-null access token used to satisfy this expression on its own, which streamed the
120+
// content past the resource policies AND past the CLARIN licence gate. The token is still accepted,
121+
// but only through clarinBitstreamAccessTokenSecurity, which runs the same licence check as a
122+
// download without a token. Do not restore the short-circuiting "or #accessToken != null".
123+
@PreAuthorize("hasPermission(#uuid, 'BITSTREAM', 'READ') "
124+
+ "or @clarinBitstreamAccessTokenSecurity.canDownloadWithAccessToken(#uuid, #accessToken)")
120125
@RequestMapping( method = {RequestMethod.GET, RequestMethod.HEAD}, value = "content")
121126
public ResponseEntity retrieve(@PathVariable UUID uuid,
122127
@Parameter(value = "accessToken", required = false) String accessToken,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE and NOTICE files at the root of the source
4+
* tree and available online at
5+
*
6+
* http://www.dspace.org/license/
7+
*/
8+
package org.dspace.app.rest.security;
9+
10+
import java.sql.SQLException;
11+
import java.util.UUID;
12+
13+
import org.apache.commons.lang3.StringUtils;
14+
import org.apache.logging.log4j.LogManager;
15+
import org.apache.logging.log4j.Logger;
16+
import org.dspace.app.requestitem.service.RequestItemService;
17+
import org.dspace.app.rest.utils.ContextUtil;
18+
import org.dspace.authorize.AuthorizationBitstreamUtils;
19+
import org.dspace.authorize.AuthorizeException;
20+
import org.dspace.content.Bitstream;
21+
import org.dspace.content.service.BitstreamService;
22+
import org.dspace.core.Context;
23+
import org.dspace.services.ConfigurationService;
24+
import org.dspace.services.RequestService;
25+
import org.dspace.services.model.Request;
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.stereotype.Component;
28+
29+
/**
30+
* Methods of this class are used on PreAuthorize annotations to decide whether a request-a-copy access
31+
* token may authorize a bitstream download.
32+
* <p>
33+
* CLARIN gates every restricted download behind its own licence flow. Vanilla DSpace 9 added a second,
34+
* independent way in: {@code BitstreamRestController.retrieve} accepted a request-a-copy access token as
35+
* proof of authorization on its own. A non-null token therefore streamed the content past the resource
36+
* policies <i>and</i> past the CLARIN licence gate.
37+
* <p>
38+
* Request-a-copy stays enabled (owner decision O-8): a token still grants access to a file the caller has
39+
* no READ policy for, but only after the same CLARIN licence check a normal download goes through. That
40+
* check is not re-implemented here - {@link AuthorizationBitstreamUtils#authorizeBitstream} is the single
41+
* implementation, and it is the very method
42+
* {@code AuthorizeServiceImpl.authorizeAction} calls for a download without a token. Wiring the token path
43+
* into it is what keeps the {@code dtoken} licence flow and the {@code accessToken} flow in agreement.
44+
*
45+
* @author Milan Majchrak (milan.majchrak at dataquest.sk)
46+
*/
47+
@Component(value = "clarinBitstreamAccessTokenSecurity")
48+
public class ClarinBitstreamAccessTokenSecurityBean {
49+
50+
private static final Logger log = LogManager.getLogger(ClarinBitstreamAccessTokenSecurityBean.class);
51+
52+
@Autowired
53+
private BitstreamService bitstreamService;
54+
@Autowired
55+
private RequestItemService requestItemService;
56+
@Autowired
57+
private AuthorizationBitstreamUtils authorizationBitstreamUtils;
58+
@Autowired
59+
private ConfigurationService configurationService;
60+
@Autowired
61+
private RequestService requestService;
62+
63+
/**
64+
* Check whether the supplied request-a-copy access token authorizes downloading the given bitstream.
65+
* <p>
66+
* The token is accepted only when <b>both</b> hold:
67+
* <ol>
68+
* <li>request-a-copy is enabled and the token is valid for this bitstream (accepted request, not
69+
* expired, right bitstream) - {@link RequestItemService#authorizeAccessByAccessToken};</li>
70+
* <li>the CLARIN licence gate lets the current user have the bitstream -
71+
* {@link AuthorizationBitstreamUtils#authorizeBitstream}.</li>
72+
* </ol>
73+
* A caller holding a valid token for a bitstream behind a CLARIN licence they have not agreed to gets
74+
* {@code false} here, so Spring Security answers 401/403 exactly as it does for a normal download that
75+
* the licence gate refuses, and the UI can send the user to the licence page.
76+
*
77+
* @param uuid bitstream ID from the request path
78+
* @param accessToken request-a-copy access token from the request, may be null
79+
* @return true only if the token is valid AND the CLARIN licence gate passes
80+
*/
81+
public boolean canDownloadWithAccessToken(UUID uuid, String accessToken) {
82+
if (uuid == null || StringUtils.isBlank(accessToken)) {
83+
return false;
84+
}
85+
86+
// If request-a-copy is switched off, no access token authorizes anything.
87+
if (configurationService.getProperty("request.item.type") == null) {
88+
return false;
89+
}
90+
91+
Request currentRequest = requestService.getCurrentRequest();
92+
if (currentRequest == null || currentRequest.getHttpServletRequest() == null) {
93+
return false;
94+
}
95+
Context context = ContextUtil.obtainContext(currentRequest.getHttpServletRequest());
96+
if (context == null) {
97+
return false;
98+
}
99+
100+
try {
101+
Bitstream bitstream = bitstreamService.find(context, uuid);
102+
if (bitstream == null || bitstream.isDeleted()) {
103+
// Let the REST layer answer 404; a token must not turn a missing bitstream into a 200.
104+
return false;
105+
}
106+
107+
// 1. The access token itself must be valid for this bitstream.
108+
requestItemService.authorizeAccessByAccessToken(context, bitstream, accessToken);
109+
110+
// 2. And the CLARIN licence gate must pass, the same call a download without a token makes
111+
// through AuthorizeServiceImpl.authorizeAction. Throws MissingLicenseAgreementException or
112+
// DownloadTokenExpiredException (both AuthorizeException) when the licence is not satisfied.
113+
authorizationBitstreamUtils.authorizeBitstream(context, bitstream);
114+
115+
return true;
116+
} catch (AuthorizeException e) {
117+
log.debug("Access token did not authorize download of bitstream {}: {}", uuid, e.getMessage());
118+
return false;
119+
} catch (SQLException e) {
120+
log.error("Failed to check the access token for bitstream " + uuid, e);
121+
return false;
122+
}
123+
}
124+
}

dspace-server-webapp/src/main/java/org/dspace/app/rest/utils/BitstreamResourceAccessByToken.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616

1717
import org.dspace.app.requestitem.factory.RequestItemServiceFactory;
1818
import org.dspace.app.requestitem.service.RequestItemService;
19+
import org.dspace.authorize.AuthorizationBitstreamUtils;
1920
import org.dspace.authorize.AuthorizeException;
2021
import org.dspace.content.Bitstream;
2122
import org.dspace.core.Context;
2223
import org.dspace.services.ConfigurationService;
2324
import org.dspace.services.factory.DSpaceServicesFactory;
25+
import org.dspace.utils.DSpace;
2426
import org.springframework.core.io.AbstractResource;
2527

2628
/**
@@ -43,6 +45,13 @@ public class BitstreamResourceAccessByToken extends BitstreamResource {
4345

4446
private ConfigurationService configurationService = DSpaceServicesFactory.getInstance().getConfigurationService();
4547

48+
/**
49+
* CLARIN licence gate. This is the same bean {@code AuthorizeServiceImpl.authorizeAction} uses for a
50+
* download without an access token, not a second copy of the licence rules.
51+
*/
52+
private AuthorizationBitstreamUtils authorizationBitstreamUtils =
53+
new DSpace().getServiceManager().getServicesByType(AuthorizationBitstreamUtils.class).get(0);
54+
4655
public BitstreamResourceAccessByToken(String name, UUID uuid, UUID currentUserUUID, Set<UUID> currentSpecialGroups,
4756
boolean shouldGenerateCoverPage, String accessToken) {
4857
super(name, uuid, currentUserUUID, currentSpecialGroups, shouldGenerateCoverPage);
@@ -85,6 +94,15 @@ public void fetchDocument() {
8594
} catch (AuthorizeException e) {
8695
throw new AuthorizeException("Authorization to bitstream " + uuid + " by access token FAILED");
8796
}
97+
98+
// CLARIN: a valid access token is not enough. Restricted downloads are gated by the CLARIN
99+
// licence flow, and this resource serves content with authorisation switched off, so the
100+
// gate has to be satisfied here too - before any content is read. authorizeBitstream()
101+
// does not consult the authorisation system, so turnOffAuthorisationSystem() above does
102+
// not weaken it; it throws MissingLicenseAgreementException/DownloadTokenExpiredException
103+
// when the licence has not been agreed.
104+
authorizationBitstreamUtils.authorizeBitstream(fileRetrievalContext, bitstream);
105+
88106
if (shouldGenerateCoverPage) {
89107
var coverPage = getCoverpageByteArray(fileRetrievalContext, bitstream);
90108

0 commit comments

Comments
 (0)