Skip to content

Commit

Permalink
Merge pull request #10098 from QualitativeDataRepository/IQSS/10093-s…
Browse files Browse the repository at this point in the history
…ignedUrls_with_privateUrlUsers

signed URLs with private url users, autogenerate API token
  • Loading branch information
pdurbin committed Nov 27, 2023
2 parents 454e0bb + 5511946 commit 410eb45
Show file tree
Hide file tree
Showing 17 changed files with 115 additions and 75 deletions.
5 changes: 5 additions & 0 deletions doc/release-notes/10093-signedUrl_improvements.md
@@ -0,0 +1,5 @@
A new version of the standard Dataverse Previewers from https://github/com/gdcc/dataverse-previewers is available. The new version supports the use of signedUrls rather than API keys when previewing restricted files (including files in draft dataset versions). Upgrading is highly recommended.

SignedUrls can now be used with PrivateUrl access tokens, which allows PrivateUrl users to view previewers that are configured to use SignedUrls. See #10093.

Launching a dataset-level configuration tool will automatically generate an API token when needed. This is consistent with how other types of tools work. See #10045.
5 changes: 5 additions & 0 deletions doc/sphinx-guides/source/api/auth.rst
Expand Up @@ -80,3 +80,8 @@ To test if bearer tokens are working, you can try something like the following (
export TOKEN=`curl -s -X POST --location "http://keycloak.mydomain.com:8090/realms/test/protocol/openid-connect/token" -H "Content-Type: application/x-www-form-urlencoded" -d "username=user&password=user&grant_type=password&client_id=test&client_secret=94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8" | jq '.access_token' -r | tr -d "\n"`
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/users/:me
Signed URLs
-----------

See :ref:`signed-urls`.
25 changes: 17 additions & 8 deletions doc/sphinx-guides/source/api/external-tools.rst
Expand Up @@ -160,17 +160,25 @@ Authorization Options

When called for datasets or data files that are not public (i.e. in a draft dataset or for a restricted file), external tools are allowed access via the user's credentials. This is accomplished by one of two mechanisms:

* Signed URLs (more secure, recommended)
.. _signed-urls:

- Configured via the ``allowedApiCalls`` section of the manifest. The tool will be provided with signed URLs allowing the specified access to the given dataset or datafile for the specified amount of time. The tool will not be able to access any other datasets or files the user may have access to and will not be able to make calls other than those specified.
- For tools invoked via a GET call, Dataverse will include a callback query parameter with a Base64 encoded value. The decoded value is a signed URL that can be called to retrieve a JSON response containing all of the queryParameters and allowedApiCalls specified in the manfiest.
- For tools invoked via POST, Dataverse will send a JSON body including the requested queryParameters and allowedApiCalls. Dataverse expects the response to the POST to indicate a redirect which Dataverse will use to open the tool.
Signed URLs
^^^^^^^^^^^

* API Token (deprecated, less secure, not recommended)
The signed URL mechanism is more secure than exposing API tokens and therefore recommended.

- Configured via the ``queryParameters`` by including an ``{apiToken}`` value. When this is present Dataverse will send the user's apiToken to the tool. With the user's API token, the tool can perform any action via the Dataverse API that the user could. External tools configured via this method should be assessed for their trustworthiness.
- For tools invoked via GET, this will be done via a query parameter in the request URL which could be cached in the browser's history. Dataverse expects the response to the POST to indicate a redirect which Dataverse will use to open the tool.
- For tools invoked via POST, Dataverse will send a JSON body including the apiToken.
- Configured via the ``allowedApiCalls`` section of the manifest. The tool will be provided with signed URLs allowing the specified access to the given dataset or datafile for the specified amount of time. The tool will not be able to access any other datasets or files the user may have access to and will not be able to make calls other than those specified.
- For tools invoked via a GET call, Dataverse will include a callback query parameter with a Base64 encoded value. The decoded value is a signed URL that can be called to retrieve a JSON response containing all of the queryParameters and allowedApiCalls specified in the manfiest.
- For tools invoked via POST, Dataverse will send a JSON body including the requested queryParameters and allowedApiCalls. Dataverse expects the response to the POST to indicate a redirect which Dataverse will use to open the tool.

API Token
^^^^^^^^^

The API token mechanism is deprecated. Because it is less secure than signed URLs, it is not recommended for new external tools.

- Configured via the ``queryParameters`` by including an ``{apiToken}`` value. When this is present Dataverse will send the user's apiToken to the tool. With the user's API token, the tool can perform any action via the Dataverse API that the user could. External tools configured via this method should be assessed for their trustworthiness.
- For tools invoked via GET, this will be done via a query parameter in the request URL which could be cached in the browser's history. Dataverse expects the response to the POST to indicate a redirect which Dataverse will use to open the tool.
- For tools invoked via POST, Dataverse will send a JSON body including the apiToken.

Internationalization of Your External Tool
++++++++++++++++++++++++++++++++++++++++++
Expand All @@ -187,6 +195,7 @@ Using Example Manifests to Get Started
++++++++++++++++++++++++++++++++++++++

Again, you can use :download:`fabulousFileTool.json <../_static/installation/files/root/external-tools/fabulousFileTool.json>` or :download:`dynamicDatasetTool.json <../_static/installation/files/root/external-tools/dynamicDatasetTool.json>` as a starting point for your own manifest file.
Additional working examples, including ones using :ref:`signed-urls`, are available at https://github.com/gdcc/dataverse-previewers .

Testing Your External Tool
--------------------------
Expand Down
12 changes: 3 additions & 9 deletions src/main/java/edu/harvard/iq/dataverse/DatasetPage.java
Expand Up @@ -5910,23 +5910,17 @@ public void setFolderPresort(boolean folderPresort) {
public void explore(ExternalTool externalTool) {
ApiToken apiToken = null;
User user = session.getUser();
if (user instanceof AuthenticatedUser) {
apiToken = authService.findApiTokenByUser((AuthenticatedUser) user);
} else if (user instanceof PrivateUrlUser) {
PrivateUrlUser privateUrlUser = (PrivateUrlUser) user;
PrivateUrl privUrl = privateUrlService.getPrivateUrlFromDatasetId(privateUrlUser.getDatasetId());
apiToken = new ApiToken();
apiToken.setTokenString(privUrl.getToken());
}
apiToken = authService.getValidApiTokenForUser(user);
ExternalToolHandler externalToolHandler = new ExternalToolHandler(externalTool, dataset, apiToken, session.getLocaleCode());
PrimeFaces.current().executeScript(externalToolHandler.getExploreScript());
}

public void configure(ExternalTool externalTool) {
ApiToken apiToken = null;
User user = session.getUser();
//Not enabled for PrivateUrlUsers (who wouldn't have write permissions anyway)
if (user instanceof AuthenticatedUser) {
apiToken = authService.findApiTokenByUser((AuthenticatedUser) user);
apiToken = authService.getValidApiTokenForAuthenticatedUser((AuthenticatedUser) user);
}
ExternalToolHandler externalToolHandler = new ExternalToolHandler(externalTool, dataset, apiToken, session.getLocaleCode());
PrimeFaces.current().executeScript(externalToolHandler.getConfigureScript());
Expand Down
Expand Up @@ -4,7 +4,6 @@
import edu.harvard.iq.dataverse.authorization.Permission;
import edu.harvard.iq.dataverse.authorization.users.ApiToken;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.dataaccess.DataAccess;
import edu.harvard.iq.dataverse.dataaccess.StorageIO;
Expand All @@ -16,8 +15,6 @@
import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler;
import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean;
import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean.MakeDataCountEntry;
import edu.harvard.iq.dataverse.privateurl.PrivateUrl;
import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean;
import edu.harvard.iq.dataverse.settings.SettingsServiceBean;
import edu.harvard.iq.dataverse.util.BundleUtil;
import edu.harvard.iq.dataverse.util.FileUtil;
Expand Down Expand Up @@ -75,8 +72,6 @@ public class FileDownloadServiceBean implements java.io.Serializable {
@EJB
AuthenticationServiceBean authService;
@EJB
PrivateUrlServiceBean privateUrlService;
@EJB
SettingsServiceBean settingsService;
@EJB
MailServiceBean mailService;
Expand Down Expand Up @@ -352,7 +347,7 @@ public void explore(GuestbookResponse guestbookResponse, FileMetadata fmd, Exter
User user = session.getUser();
DatasetVersion version = fmd.getDatasetVersion();
if (version.isDraft() || fmd.getDatasetVersion().isDeaccessioned() || (fmd.getDataFile().isRestricted()) || (FileUtil.isActivelyEmbargoed(fmd))) {
apiToken = getApiToken(user);
apiToken = authService.getValidApiTokenForUser(user);
}
DataFile dataFile = null;
if (fmd != null) {
Expand All @@ -379,24 +374,6 @@ public void explore(GuestbookResponse guestbookResponse, FileMetadata fmd, Exter
}
}

public ApiToken getApiToken(User user) {
ApiToken apiToken = null;
if (user instanceof AuthenticatedUser) {
AuthenticatedUser authenticatedUser = (AuthenticatedUser) user;
apiToken = authService.findApiTokenByUser(authenticatedUser);
if (apiToken == null || apiToken.isExpired()) {
//No un-expired token
apiToken = authService.generateApiTokenForUser(authenticatedUser);
}
} else if (user instanceof PrivateUrlUser) {
PrivateUrlUser privateUrlUser = (PrivateUrlUser) user;
PrivateUrl privateUrl = privateUrlService.getPrivateUrlFromDatasetId(privateUrlUser.getDatasetId());
apiToken = new ApiToken();
apiToken.setTokenString(privateUrl.getToken());
}
return apiToken;
}

public void downloadDatasetCitationXML(Dataset dataset) {
downloadCitationXML(null, dataset, false);
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/edu/harvard/iq/dataverse/FilePage.java
Expand Up @@ -1069,7 +1069,7 @@ public String preview(ExternalTool externalTool) {
ApiToken apiToken = null;
User user = session.getUser();
if (fileMetadata.getDatasetVersion().isDraft() || fileMetadata.getDatasetVersion().isDeaccessioned() || (fileMetadata.getDataFile().isRestricted()) || (FileUtil.isActivelyEmbargoed(fileMetadata))) {
apiToken=fileDownloadService.getApiToken(user);
apiToken=authService.getValidApiTokenForUser(user);
}
if(externalTool == null){
return "";
Expand Down
Expand Up @@ -11,6 +11,7 @@
import edu.harvard.iq.dataverse.authorization.groups.impl.explicit.ExplicitGroupServiceBean;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.GuestUser;
import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser;
import edu.harvard.iq.dataverse.engine.command.DataverseRequest;
import edu.harvard.iq.dataverse.mydata.MyDataFilterParams;
import edu.harvard.iq.dataverse.privateurl.PrivateUrlUtil;
Expand Down Expand Up @@ -96,18 +97,18 @@ public RoleAssignee getRoleAssignee(String identifier, Boolean augmented) {
if (identifier == null || identifier.isEmpty()) {
throw new IllegalArgumentException("Identifier cannot be null or empty string.");
}
switch (identifier.charAt(0)) {
case ':':
switch (identifier.substring(0,1)) {
case ":":
return predefinedRoleAssignees.get(identifier);
case '@':
case AuthenticatedUser.IDENTIFIER_PREFIX:
if (!augmented){
return authSvc.getAuthenticatedUser(identifier.substring(1));
} else {
return authSvc.getAuthenticatedUserWithProvider(identifier.substring(1));
}
case '&':
}
case Group.IDENTIFIER_PREFIX:
return groupSvc.getGroup(identifier.substring(1));
case '#':
case PrivateUrlUser.PREFIX:
return PrivateUrlUtil.identifier2roleAssignee(identifier);
default:
throw new IllegalArgumentException("Unsupported assignee identifier '" + identifier + "'");
Expand Down
5 changes: 1 addition & 4 deletions src/main/java/edu/harvard/iq/dataverse/api/Datasets.java
Expand Up @@ -3930,10 +3930,7 @@ public Response getExternalToolDVParams(@Context ContainerRequestContext crc,
}
ApiToken apiToken = null;
User u = getRequestUser(crc);
if (u instanceof AuthenticatedUser) {
apiToken = authSvc.findApiTokenByUser((AuthenticatedUser) u);
}

apiToken = authSvc.getValidApiTokenForUser(u);

ExternalToolHandler eth = new ExternalToolHandler(externalTool, target.getDataset(), apiToken, locale);
return ok(eth.createPostBody(eth.getParams(JsonUtil.getJsonObject(externalTool.getToolParameters()))));
Expand Down
6 changes: 2 additions & 4 deletions src/main/java/edu/harvard/iq/dataverse/api/Files.java
Expand Up @@ -814,10 +814,8 @@ public Response getExternalToolFMParams(@Context ContainerRequestContext crc, @P
return error(BAD_REQUEST, "External tool does not have file scope.");
}
ApiToken apiToken = null;
User u = getRequestUser(crc);
if (u instanceof AuthenticatedUser) {
apiToken = authSvc.findApiTokenByUser((AuthenticatedUser) u);
}
User user = getRequestUser(crc);
apiToken = authSvc.getValidApiTokenForUser(user);
FileMetadata target = fileSvc.findFileMetadata(fmid);
if (target == null) {
return error(BAD_REQUEST, "FileMetadata not found.");
Expand Down
Expand Up @@ -3,7 +3,10 @@
import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean;
import edu.harvard.iq.dataverse.authorization.users.ApiToken;
import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser;
import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser;
import edu.harvard.iq.dataverse.authorization.users.User;
import edu.harvard.iq.dataverse.privateurl.PrivateUrl;
import edu.harvard.iq.dataverse.privateurl.PrivateUrlServiceBean;
import edu.harvard.iq.dataverse.settings.JvmSettings;
import edu.harvard.iq.dataverse.util.UrlSignerUtil;

Expand All @@ -27,16 +30,18 @@ public class SignedUrlAuthMechanism implements AuthMechanism {

@Inject
protected AuthenticationServiceBean authSvc;

@Inject
protected PrivateUrlServiceBean privateUrlSvc;

@Override
public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse {
String signedUrlRequestParameter = getSignedUrlRequestParameter(containerRequestContext);
if (signedUrlRequestParameter == null) {
return null;
}
AuthenticatedUser authUser = getAuthenticatedUserFromSignedUrl(containerRequestContext);
if (authUser != null) {
return authUser;
User user = getAuthenticatedUserFromSignedUrl(containerRequestContext);
if (user != null) {
return user;
}
throw new WrappedAuthErrorResponse(RESPONSE_MESSAGE_BAD_SIGNED_URL);
}
Expand All @@ -45,26 +50,35 @@ private String getSignedUrlRequestParameter(ContainerRequestContext containerReq
return containerRequestContext.getUriInfo().getQueryParameters().getFirst(SIGNED_URL_TOKEN);
}

private AuthenticatedUser getAuthenticatedUserFromSignedUrl(ContainerRequestContext containerRequestContext) {
AuthenticatedUser authUser = null;
private User getAuthenticatedUserFromSignedUrl(ContainerRequestContext containerRequestContext) {
User user = null;
// The signedUrl contains a param telling which user this is supposed to be for.
// We don't trust this. So we lookup that user, and get their API key, and use
// that as a secret in validating the signedURL. If the signature can't be
// validated with their key, the user (or their API key) has been changed and
// we reject the request.
UriInfo uriInfo = containerRequestContext.getUriInfo();
String userId = uriInfo.getQueryParameters().getFirst(SIGNED_URL_USER);
AuthenticatedUser targetUser = authSvc.getAuthenticatedUser(userId);
ApiToken userApiToken = authSvc.findApiTokenByUser(targetUser);
User targetUser = null;
ApiToken userApiToken = null;
if (!userId.startsWith(PrivateUrlUser.PREFIX)) {
targetUser = authSvc.getAuthenticatedUser(userId);
userApiToken = authSvc.findApiTokenByUser((AuthenticatedUser) targetUser);
} else {
PrivateUrl privateUrl = privateUrlSvc.getPrivateUrlFromDatasetId(Long.parseLong(userId.substring(PrivateUrlUser.PREFIX.length())));
userApiToken = new ApiToken();
userApiToken.setTokenString(privateUrl.getToken());
targetUser = privateUrlSvc.getPrivateUrlUserFromToken(privateUrl.getToken());
}
if (targetUser != null && userApiToken != null) {
String signedUrl = URLDecoder.decode(uriInfo.getRequestUri().toString(), StandardCharsets.UTF_8);
String requestMethod = containerRequestContext.getMethod();
String signedUrlSigningKey = JvmSettings.API_SIGNING_SECRET.lookupOptional().orElse("") + userApiToken.getTokenString();
boolean isSignedUrlValid = UrlSignerUtil.isValidUrl(signedUrl, userId, requestMethod, signedUrlSigningKey);
if (isSignedUrlValid) {
authUser = targetUser;
user = targetUser;
}
}
return authUser;
return user;
}
}

0 comments on commit 410eb45

Please sign in to comment.