diff --git a/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid-sandbox.json b/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid-sandbox.json index 61bf7b79c82..79363f4d42f 100644 --- a/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid-sandbox.json +++ b/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid-sandbox.json @@ -3,6 +3,6 @@ "factoryAlias":"oauth2", "title":"ORCID", "subtitle":"", - "factoryData":"type: orcid | userEndpoint: https://api.sandbox.orcid.org/v1.2/{ORCID}/orcid-profile | clientId: FIXME | clientSecret: FIXME", + "factoryData":"type: orcid | userEndpoint: https://api.sandbox.orcid.org/v2.0/{ORCID}/record | clientId: FIXME | clientSecret: FIXME", "enabled":true } diff --git a/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid.json b/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid.json index 0615bacb056..249c4226f25 100644 --- a/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid.json +++ b/doc/sphinx-guides/source/_static/installation/files/root/auth-providers/orcid.json @@ -3,6 +3,6 @@ "factoryAlias":"oauth2", "title":"ORCID", "subtitle":"", - "factoryData":"type: orcid | userEndpoint: https://api.orcid.org/v1.2/{ORCID}/orcid-profile | clientId: FIXME | clientSecret: FIXME", + "factoryData":"type: orcid | userEndpoint: https://api.orcid.org/v2.0/{ORCID}/record | clientId: FIXME | clientSecret: FIXME", "enabled":true } diff --git a/doc/sphinx-guides/source/conf.py b/doc/sphinx-guides/source/conf.py index 4f40290c5cf..af489bea0f7 100755 --- a/doc/sphinx-guides/source/conf.py +++ b/doc/sphinx-guides/source/conf.py @@ -64,9 +64,9 @@ # built documents. # # The short X.Y version. -version = '4.8.2' +version = '4.8.3' # The full version, including alpha/beta/rc tags. -release = '4.8.2' +release = '4.8.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/sphinx-guides/source/developers/big-data-support.rst b/doc/sphinx-guides/source/developers/big-data-support.rst index 5136a1707df..fa37176e6c3 100644 --- a/doc/sphinx-guides/source/developers/big-data-support.rst +++ b/doc/sphinx-guides/source/developers/big-data-support.rst @@ -112,7 +112,7 @@ Configuring the RSAL Mock Info for configuring the RSAL Mock: https://github.com/sbgrid/rsal/tree/master/mocks -Also, to configure Dataverse to use the new workflow you must do the following: +Also, to configure Dataverse to use the new workflow you must do the following (see also the section below on workflows): 1. Configure the RSAL URL: @@ -160,3 +160,99 @@ To specify replication sites that appear in rsync URLs: In the GUI, this is called "Local Access". It's where you can compute on files on your cluster. ``curl http://localhost:8080/api/admin/settings/:LocalDataAccessPath -X PUT -d "/programs/datagrid"`` + +Workflows +--------- + +Dataverse can perform two sequences of actions when datasets are published: one prior to publishing (marked by a ``PrePublishDataset`` trigger), and one after the publication has succeeded (``PostPublishDataset``). The pre-publish workflow is useful for having an external system prepare a dataset for being publicly accessed (a possibly lengthy activity that requires moving files around, uploading videos to a streaming server, etc.), or to start an approval process. A post-publish workflow might be used for sending notifications about the newly published dataset. + +Workflow steps are created using *step providers*. Dataverse ships with an internal step provider that offers some basic functionality, and with the ability to load 3rd party step providers. This allows installations to implement functionality they need without changing the Dataverse source code. + +Steps can be internal (say, writing some data to the log) or external. External steps involve Dataverse sending a request to an external system, and waiting for the system to reply. The wait period is arbitrary, and so allows the external system unbounded operation time. This is useful, e.g., for steps that require human intervension, such as manual approval of a dataset publication. + +The external system reports the step result back to dataverse, by sending a HTTP ``POST`` command to ``api/workflows/{invocation-id}``. The body of the request is passed to the paused step for further processing. + +If a step in a workflow fails, Dataverse make an effort to roll back all the steps that preceeded it. Some actions, such as writing to the log, cannot be rolled back. If such an action has a public external effect (e.g. send an EMail to a mailing list) it is advisable to put it in the post-release workflow. + +.. tip:: + For invoking external systems using a REST api, Dataverse's internal step + provider offers a step for sending and receiving customizable HTTP requests. + It's called *http/sr*, and is detailed below. + +Administration +~~~~~~~~~~~~~~ + +A Dataverse instance stores a set of workflows in its database. Workflows can be managed using the ``api/admin/workflows/`` endpoints of the :doc:`/api/native-api`. Sample workflow files are available in ``scripts/api/data/workflows``. + +At the moment, defining a workflow for each trigger is done for the entire instance, using the endpoint ``api/admin/workflows/default/«trigger type»``. + +In order to prevent unauthorized resuming of workflows, Dataverse maintains a "white list" of IP addresses from which resume requests are honored. This list is maintained using the ``/api/admin/workflows/ip-whitelist`` endpoint of the :doc:`/api/native-api`. By default, Dataverse honors resume requests from localhost only (``127.0.0.1;::1``), so set-ups that use a single server work with no additional configuration. + + +Available Steps +~~~~~~~~~~~~~~~ + +Dataverse has an internal step provider, whose id is ``:internal``. It offers the following steps: + +log ++++ + +A step that writes data about the current workflow invocation to the instance log. It also writes the messages in its ``parameters`` map. + +.. code:: json + + { + "provider":":internal", + "stepType":"log", + "parameters": { + "aMessage": "message content", + "anotherMessage": "message content, too" + } + } + + +pause ++++++ + +A step that pauses the workflow. The workflow is paused until a POST request is sent to ``/api/workflows/{invocation-id}``. + +.. code:: json + + { + "provider":":internal", + "stepType":"pause" + } + + +http/sr ++++++++ + +A step that sends a HTTP request to an external system, and then waits for a response. The response has to match a regular expression specified in the step parameters. The url, content type, and message body can use data from the workflow context, using a simple markup language. This step has specific parameters for rollback. + +.. code:: json + + { + "provider":":internal", + "stepType":"http/sr", + "parameters": { + "url":"http://localhost:5050/dump/${invocationId}", + "method":"POST", + "contentType":"text/plain", + "body":"START RELEASE ${dataset.id} as ${dataset.displayName}", + "expectedResponse":"OK.*", + "rollbackUrl":"http://localhost:5050/dump/${invocationId}", + "rollbackMethod":"DELETE ${dataset.id}" + } + } + +Available variables are: + +* ``invocationId`` +* ``dataset.id`` +* ``dataset.identifier`` +* ``dataset.globalId`` +* ``dataset.displayName`` +* ``dataset.citation`` +* ``minorVersion`` +* ``majorVersion`` +* ``releaseStatus`` diff --git a/doc/sphinx-guides/source/index.rst b/doc/sphinx-guides/source/index.rst index afb73458bc8..e6a023150ed 100755 --- a/doc/sphinx-guides/source/index.rst +++ b/doc/sphinx-guides/source/index.rst @@ -3,10 +3,10 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Dataverse 4.8.2 Guides +Dataverse 4.8.3 Guides ====================== -These guides are for the most recent version of Dataverse. For the guides for **version 4.8.1** please go `here `_. +These guides are for the most recent version of Dataverse. For the guides for **version 4.8.2** please go `here `_. .. toctree:: :glob: @@ -14,12 +14,11 @@ These guides are for the most recent version of Dataverse. For the guides for ** :maxdepth: 2 user/index - installation/index + admin/index api/index + installation/index developers/index style/index - admin/index - workflows How the Guides Are Organized ============================= diff --git a/doc/sphinx-guides/source/workflows.rst b/doc/sphinx-guides/source/workflows.rst deleted file mode 100644 index 477530ef7d1..00000000000 --- a/doc/sphinx-guides/source/workflows.rst +++ /dev/null @@ -1,92 +0,0 @@ -Workflows -========== - -Dataverse can perform two sequences of actions when datasets are published: one prior to publishing (marked by a ``PrePublishDataset`` trigger), and one after the publication has succeeded (``PostPublishDataset``). The pre-publish workflow is useful for having an external system prepare a dataset for being publicly accessed (a possibly lengthy activity that requires moving files around, uploading videos to a streaming server, etc.), or to start an approval process. A post-publish workflow might be used for sending notifications about the newly published dataset. - -Workflow steps are created using *step providers*. Dataverse ships with an internal step provider that offers some basic functionality, and with the ability to load 3rd party step providers. This allows installations to implement functionality they need without changing the Dataverse source code. - -Steps can be internal (say, writing some data to the log) or external. External steps involve Dataverse sending a request to an external system, and waiting for the system to reply. The wait period is arbitrary, and so allows the external system unbounded operation time. This is useful, e.g., for steps that require human intervension, such as manual approval of a dataset publication. - -The external system reports the step result back to dataverse, by sending a HTTP ``POST`` command to ``api/workflows/{invocation-id}``. The body of the request is passed to the paused step for further processing. - -If a step in a workflow fails, Dataverse make an effort to roll back all the steps that preceeded it. Some actions, such as writing to the log, cannot be rolled back. If such an action has a public external effect (e.g. send an EMail to a mailing list) it is advisable to put it in the post-release workflow. - -.. tip:: - For invoking external systems using a REST api, Dataverse's internal step - provider offers a step for sending and receiving customizable HTTP requests. - It's called *http/sr*, and is detailed below. - -Administration --------------- - -A Dataverse instance stores a set of workflows in its database. Workflows can be managed using the ``api/admin/workflows/`` endpoints of the :doc:`api/native-api`. Sample workflow files are available in ``scripts/api/data/workflows``. - -At the moment, defining a workflow for each trigger is done for the entire instance, using the endpoint ``api/admin/workflows/default/«trigger type»``. - -In order to prevent unauthorized resuming of workflows, Dataverse maintains a "white list" of IP addresses from which resume requests are honored. This list is maintained using the ``/api/admin/workflows/ip-whitelist`` endpoint of the :doc:`api/native-api`. By default, Dataverse honors resume requests from localhost only (``127.0.0.1;::1``), so set-ups that use a single server work with no additional configuration. - - -Available Steps ---------------- - -Dataverse has an internal step provider, whose id is ``:internal``. It offers the following steps: - -log -~~~~~~~~ -A step that writes data about the current workflow invocation to the instance log. It also writes the messages in its ``parameters`` map. - -.. code:: json - - { - "provider":":internal", - "stepType":"log", - "parameters": { - "aMessage": "message content", - "anotherMessage": "message content, too" - } - } - - -pause -~~~~~~~~ -A step that pauses the workflow. The workflow is paused until a POST request is sent to ``/api/workflows/{invocation-id}``. - -.. code:: json - - { - "provider":":internal", - "stepType":"pause" - } - - -http/sr -~~~~~~~~~ -A step that sends a HTTP request to an external system, and then waits for a response. The response has to match a regular expression specified in the step parameters. The url, content type, and message body can use data from the workflow context, using a simple markup language. This step has specific parameters for rollback. - -.. code:: json - - { - "provider":":internal", - "stepType":"http/sr", - "parameters": { - "url":"http://localhost:5050/dump/${invocationId}", - "method":"POST", - "contentType":"text/plain", - "body":"START RELEASE ${dataset.id} as ${dataset.displayName}", - "expectedResponse":"OK.*", - "rollbackUrl":"http://localhost:5050/dump/${invocationId}", - "rollbackMethod":"DELETE ${dataset.id}" - } - } - -Available variables are: - -* ``invocationId`` -* ``dataset.id`` -* ``dataset.identifier`` -* ``dataset.globalId`` -* ``dataset.displayName`` -* ``dataset.citation`` -* ``minorVersion`` -* ``majorVersion`` -* ``releaseStatus`` diff --git a/pom.xml b/pom.xml index 50782bab444..a9734d7503b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ edu.harvard.iq dataverse - 4.8.2 + 4.8.3 war dataverse diff --git a/scripts/api/data/authentication-providers/echo.json b/scripts/api/data/authentication-providers/base-oauth.json similarity index 100% rename from scripts/api/data/authentication-providers/echo.json rename to scripts/api/data/authentication-providers/base-oauth.json diff --git a/scripts/api/data/authentication-providers/base-oauth2.json b/scripts/api/data/authentication-providers/base-oauth2.json deleted file mode 100644 index 177fd12a023..00000000000 --- a/scripts/api/data/authentication-providers/base-oauth2.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id":"echo-dignified", - "factoryAlias":"Echo", - "title":"Dignified Echo provider", - "subtitle":"Approves everyone, based on their credentials, and adds some flair", - "factoryData":"Sir,Esq.", - "enabled":true -} diff --git a/scripts/api/data/authentication-providers/orcid-sandbox.json b/scripts/api/data/authentication-providers/orcid-sandbox.json index 7f017add1e6..3a1c311fff4 100644 --- a/scripts/api/data/authentication-providers/orcid-sandbox.json +++ b/scripts/api/data/authentication-providers/orcid-sandbox.json @@ -1,8 +1,8 @@ { - "id":"orcid-sandbox", + "id":"orcid-v2-sandbox", "factoryAlias":"oauth2", "title":"ORCID Sandbox", - "subtitle":"ORCiD - sandbox", - "factoryData":"type: orcid | userEndpoint: https://api.sandbox.orcid.org/v1.2/{ORCID}/orcid-profile | clientId: APP-HIV99BRM37FSWPH6 | clientSecret: ee844b70-f223-4f15-9b6f-4991bf8ed7f0", + "subtitle":"ORCiD - sandbox (v2)", + "factoryData":"type: orcid | userEndpoint: https://api.sandbox.orcid.org/v2.0/{ORCID}/person | clientId: APP-HIV99BRM37FSWPH6 | clientSecret: ee844b70-f223-4f15-9b6f-4991bf8ed7f0", "enabled":true } diff --git a/scripts/installer/Makefile b/scripts/installer/Makefile index df29cda93a5..046e6cb73cd 100644 --- a/scripts/installer/Makefile +++ b/scripts/installer/Makefile @@ -45,10 +45,10 @@ ${GLASSFISH_SETUP_SCRIPT}: glassfish-setup.sh /bin/cp glassfish-setup.sh ${INSTALLER_ZIP_DIR} -${POSTGRES_DRIVERS}: pgdriver/postgresql-8.4-703.jdbc4.jar pgdriver/postgresql-9.0-802.jdbc4.jar pgdriver/postgresql-9.1-902.jdbc4.jar +${POSTGRES_DRIVERS}: pgdriver/postgresql-8.4-703.jdbc4.jar pgdriver/postgresql-9.0-802.jdbc4.jar pgdriver/postgresql-9.1-902.jdbc4.jar pgdriver/postgresql-9.2-1004.jdbc4.jar pgdriver/postgresql-9.3-1104.jdbc4.jar pgdriver/postgresql-9.4.1212.jar pgdriver/postgresql-42.1.4.jar @echo copying postgres drviers @mkdir -p ${POSTGRES_DRIVERS} - /bin/cp pgdriver/postgresql-8.4-703.jdbc4.jar pgdriver/postgresql-9.0-802.jdbc4.jar pgdriver/postgresql-9.1-902.jdbc4.jar ${INSTALLER_ZIP_DIR}/pgdriver + /bin/cp pgdriver/postgresql-8.4-703.jdbc4.jar pgdriver/postgresql-9.0-802.jdbc4.jar pgdriver/postgresql-9.1-902.jdbc4.jar pgdriver/postgresql-9.2-1004.jdbc4.jar pgdriver/postgresql-9.3-1104.jdbc4.jar pgdriver/postgresql-9.4.1212.jar pgdriver/postgresql-42.1.4.jar ${INSTALLER_ZIP_DIR}/pgdriver ${API_SCRIPTS}: ../api/setup-datasetfields.sh ../api/setup-users.sh ../api/setup-dvs.sh ../api/setup-identity-providers.sh ../api/setup-all.sh ../api/post-install-api-block.sh ../api/setup-builtin-roles.sh ../api/data @echo copying api scripts diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index a4e3fa63c58..654f7f6a747 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -246,6 +246,7 @@ login.error=Error validating the username, email address, or password. Please tr user.error.cannotChangePassword=Sorry, your password cannot be changed. Please contact your system administrator. user.error.wrongPassword=Sorry, wrong password. login.button=Log In with {0} +login.button.orcid=Create or Connect your ORCID # authentication providers auth.providers.title=Other options auth.providers.tip=You can convert a Dataverse account to use one of the options above. Learn more. @@ -259,6 +260,7 @@ auth.providers.persistentUserIdName.orcid=ORCID iD auth.providers.persistentUserIdName.github=ID auth.providers.persistentUserIdTooltip.orcid=ORCID provides a persistent digital identifier that distinguishes you from other researchers. auth.providers.persistentUserIdTooltip.github=GitHub assigns a unique number to every user. +auth.providers.orcid.insufficientScope=Dataverse was not granted the permission to read user data from ORCID. # Friendly AuthenticationProvider names authenticationProvider.name.builtin=Dataverse authenticationProvider.name.null=(provider is unknown) @@ -331,7 +333,7 @@ oauth2.convertAccount.success=Your Dataverse account is now associated with your # oauth2/callback.xhtml oauth2.callback.page.title=OAuth Callback -oauth2.callback.message=OAuth2 Error - Sorry, the identification process did not succeed. +oauth2.callback.message=Authentication Error - Dataverse could not authenticate your ORCID login. Please make sure you authorize your ORCID account to connect with Dataverse. For more details about the information being requested, see the User Guide. # tab on dataverseuser.xhtml apitoken.title=API Token diff --git a/src/main/java/edu/harvard/iq/dataverse/LoginPage.java b/src/main/java/edu/harvard/iq/dataverse/LoginPage.java index 6bde3ba6775..0cf5f8c9821 100644 --- a/src/main/java/edu/harvard/iq/dataverse/LoginPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/LoginPage.java @@ -168,7 +168,7 @@ public String login() { } authReq.setIpAddress( dvRequestService.getDataverseRequest().getSourceAddress() ); try { - AuthenticatedUser r = authSvc.authenticate(credentialsAuthProviderId, authReq); + AuthenticatedUser r = authSvc.getCreateAuthenticatedUser(credentialsAuthProviderId, authReq); logger.log(Level.FINE, "User authenticated: {0}", r.getEmail()); session.setUser(r); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java index 92086575199..6865ef9af2d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java @@ -63,8 +63,6 @@ import edu.harvard.iq.dataverse.ingest.IngestServiceBean; import edu.harvard.iq.dataverse.userdata.UserListMaker; import edu.harvard.iq.dataverse.userdata.UserListResult; -import edu.harvard.iq.dataverse.util.StringUtil; -import java.math.BigDecimal; import java.util.Date; import java.util.ResourceBundle; import javax.inject.Inject; @@ -1002,4 +1000,10 @@ public Response validatePassword(String password) { .add("errors", errorArray) ); } + + @GET + @Path("/isOrcid") + public Response isOrcidEnabled() { + return authSvc.isOrcidEnabled() ? ok("Orcid is enabled") : ok("no orcid for you."); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticatedUserDisplayInfo.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticatedUserDisplayInfo.java index 739c0c915bf..909e124e5bd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticatedUserDisplayInfo.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticatedUserDisplayInfo.java @@ -15,7 +15,7 @@ public class AuthenticatedUserDisplayInfo extends RoleAssigneeDisplayInfo { private String firstName; private String position; - /** + /* * @todo Shouldn't we persist the displayName too? It still exists on the * authenticateduser table. */ diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index 9e3a438b11b..8eadbe70221 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -47,6 +47,7 @@ import javax.ejb.EJB; import javax.ejb.EJBException; import javax.ejb.Singleton; +import javax.inject.Named; import javax.persistence.EntityManager; import javax.persistence.NoResultException; import javax.persistence.NonUniqueResultException; @@ -63,6 +64,7 @@ * * Register the providers in the {@link #startup()} method. */ +@Named @Singleton public class AuthenticationServiceBean { private static final Logger logger = Logger.getLogger(AuthenticationServiceBean.class.getName()); @@ -234,7 +236,12 @@ public void removeApiToken(AuthenticatedUser user){ em.remove(apiToken); } } - } + } + + public boolean isOrcidEnabled() { + return oAuth2authenticationProviders.values().stream().anyMatch( s -> s.getId().toLowerCase().contains("orcid") ); + } + /** * Use with care! This method was written primarily for developers * interested in API testing who want to: @@ -318,7 +325,19 @@ public AuthenticatedUser getAuthenticatedUserByEmail( String email ) { } } - public AuthenticatedUser authenticate( String authenticationProviderId, AuthenticationRequest req ) throws AuthenticationFailedException { + /** + * Returns an {@link AuthenticatedUser} matching the passed provider id and the authentication request. If + * no such user exist, it is created and then returned. + * + * Invariant: upon successful return from this call, an {@link AuthenticatedUser} record + * matching the request and provider exists in the database. + * + * @param authenticationProviderId + * @param req + * @return The authenticated user for the passed provider id and authentication request. + * @throws AuthenticationFailedException + */ + public AuthenticatedUser getCreateAuthenticatedUser( String authenticationProviderId, AuthenticationRequest req ) throws AuthenticationFailedException { AuthenticationProvider prv = getAuthenticationProvider(authenticationProviderId); if ( prv == null ) throw new IllegalArgumentException("No authentication provider listed under id " + authenticationProviderId ); if ( ! (prv instanceof CredentialsAuthenticationProvider) ) { @@ -334,18 +353,16 @@ public AuthenticatedUser authenticate( String authenticationProviderId, Authenti user = userService.updateLastLogin(user); } - /** - * @todo Why does a method called "authenticate" have the potential - * to call "createAuthenticatedUser"? Isn't the creation of a user a - * different action than authenticating? - * - * @todo Wouldn't this be more readable with if/else rather than - * ternary? (please) - */ - return ( user == null ) ? - AuthenticationServiceBean.this.createAuthenticatedUser( - new UserRecordIdentifier(authenticationProviderId, resp.getUserId()), resp.getUserId(), resp.getUserDisplayInfo(), true ) - : (BuiltinAuthenticationProvider.PROVIDER_ID.equals(user.getAuthenticatedUserLookup().getAuthenticationProviderId())) ? user : updateAuthenticatedUser(user, resp.getUserDisplayInfo()); + if ( user == null ) { + return createAuthenticatedUser( + new UserRecordIdentifier(authenticationProviderId, resp.getUserId()), resp.getUserId(), resp.getUserDisplayInfo(), true ); + } else { + if (BuiltinAuthenticationProvider.PROVIDER_ID.equals(user.getAuthenticatedUserLookup().getAuthenticationProviderId())) { + return user; + } else { + return updateAuthenticatedUser(user, resp.getUserDisplayInfo()); + } + } } else { throw new AuthenticationFailedException(resp, "Authentication Failed: " + resp.getMessage()); } @@ -779,7 +796,7 @@ public AuthenticatedUser canLogInAsBuiltinUser(String username, String password) String credentialsAuthProviderId = BuiltinAuthenticationProvider.PROVIDER_ID; try { - AuthenticatedUser au = authenticate(credentialsAuthProviderId, authReq); + AuthenticatedUser au = getCreateAuthenticatedUser(credentialsAuthProviderId, authReq); logger.fine("User authenticated:" + au.getEmail()); return au; } catch (AuthenticationFailedException ex) { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/AbstractOAuth2AuthenticationProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/AbstractOAuth2AuthenticationProvider.java index 5e89df5118e..8cfb84e7ce3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/AbstractOAuth2AuthenticationProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/AbstractOAuth2AuthenticationProvider.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.logging.Level; import java.util.logging.Logger; /** @@ -90,8 +91,6 @@ public String toString() { protected String redirectUrl; protected String scope; - public AbstractOAuth2AuthenticationProvider(){} - public abstract BaseApi getApiInstance(); protected abstract ParsedUserResponse parseUserResponse( String responseBody ); @@ -111,6 +110,7 @@ public OAuth20Service getService(String state, String redirectUrl) { public OAuth2UserRecord getUserRecord(String code, String state, String redirectUrl) throws IOException, OAuth2Exception { OAuth20Service service = getService(state, redirectUrl); OAuth2AccessToken accessToken = service.getAccessToken(code); + final String userEndpoint = getUserEndpoint(accessToken); final OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint, service); @@ -120,13 +120,13 @@ public OAuth2UserRecord getUserRecord(String code, String state, String redirect final Response response = request.send(); int responseCode = response.getCode(); final String body = response.getBody(); - logger.fine("In getUserRecord. Body: " + body); + logger.log(Level.FINE, "In getUserRecord. Body: {0}", body); if ( responseCode == 200 ) { final ParsedUserResponse parsed = parseUserResponse(body); return new OAuth2UserRecord(getId(), parsed.userIdInProvider, parsed.username, - accessToken.getAccessToken(), + OAuth2TokenData.from(accessToken), parsed.displayInfo, parsed.emails); } else { diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2FirstLoginPage.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2FirstLoginPage.java index 9c29b4319b2..9ca92466465 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2FirstLoginPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2FirstLoginPage.java @@ -66,6 +66,9 @@ public class OAuth2FirstLoginPage implements java.io.Serializable { @EJB AuthTestDataServiceBean authTestDataSvc; + @EJB + OAuth2TokenDataServiceBean oauth2Tokens; + @Inject DataverseSession session; @@ -99,7 +102,7 @@ public void init() throws IOException { logger.fine("init called"); AbstractOAuth2AuthenticationProvider.DevOAuthAccountType devMode = systemConfig.getDevOAuthAccountType(); - logger.fine("devMode: " + devMode); + logger.log(Level.FINE, "devMode: {0}", devMode); if (!AbstractOAuth2AuthenticationProvider.DevOAuthAccountType.PRODUCTION.equals(devMode)) { if (devMode.toString().startsWith("RANDOM")) { Map randomUser = authTestDataSvc.getRandomUser(); @@ -136,7 +139,8 @@ public void init() throws IOException { } String randomUsername = randomUser.get("username"); String eppn = randomUser.get("eppn"); - String accessToken = "qwe-addssd-iiiiie"; + OAuth2TokenData accessToken = new OAuth2TokenData(); + accessToken.setAccessToken("qwe-addssd-iiiiie"); setNewUser(new OAuth2UserRecord(authProviderId, eppn, randomUsername, accessToken, new AuthenticatedUserDisplayInfo(firstName, lastName, email, "myAffiliation", "myPosition"), extraEmails)); @@ -185,7 +189,12 @@ public String createNewAccount() { userNotificationService.sendNotification(user, new Timestamp(new Date().getTime()), UserNotification.Type.CREATEACC, null); - + + final OAuth2TokenData tokenData = newUser.getTokenData(); + tokenData.setUser(user); + tokenData.setOauthProviderId(newUser.getServiceId()); + oauth2Tokens.store(tokenData); + return "/dataverse.xhtml?faces-redirect=true"; } @@ -196,13 +205,13 @@ public String convertExistingAccount() { auReq.putCredential(creds.get(0).getTitle(), getUsername()); auReq.putCredential(creds.get(1).getTitle(), getPassword()); try { - AuthenticatedUser existingUser = authenticationSvc.authenticate(BuiltinAuthenticationProvider.PROVIDER_ID, auReq); + AuthenticatedUser existingUser = authenticationSvc.getCreateAuthenticatedUser(BuiltinAuthenticationProvider.PROVIDER_ID, auReq); authenticationSvc.updateProvider(existingUser, newUser.getServiceId(), newUser.getIdInService()); builtinUserSvc.removeUser(existingUser.getUserIdentifier()); session.setUser(existingUser); - AuthenticationProvider authProvider = authenticationSvc.getAuthenticationProvider(newUser.getServiceId()); - JsfHelper.addSuccessMessage(BundleUtil.getStringFromBundle("oauth2.convertAccount.success", Arrays.asList(authProvider.getInfo().getTitle()))); + AuthenticationProvider newUserAuthProvider = authenticationSvc.getAuthenticationProvider(newUser.getServiceId()); + JsfHelper.addSuccessMessage(BundleUtil.getStringFromBundle("oauth2.convertAccount.success", Arrays.asList(newUserAuthProvider.getInfo().getTitle()))); return "/dataverse.xhtml?faces-redirect=true"; @@ -212,22 +221,17 @@ public String convertExistingAccount() { } } - public String testAction() { - Logger.getLogger(OAuth2FirstLoginPage.class.getName()).log(Level.INFO, "testAction"); - return "dataverse.xhtml"; - } - public boolean isEmailAvailable() { return authenticationSvc.isEmailAddressAvailable(getSelectedEmail()); } - /** + /* * @todo This was copied from DataverseUserPage and modified so consider * consolidating common code (DRY). */ public void validateUserName(FacesContext context, UIComponent toValidate, Object value) { String userName = (String) value; - logger.fine("Validating username: " + userName); + logger.log(Level.FINE, "Validating username: {0}", userName); boolean userNameFound = authenticationSvc.identifierExists(userName); if (userNameFound) { ((UIInput) toValidate).setValid(false); @@ -236,7 +240,7 @@ public void validateUserName(FacesContext context, UIComponent toValidate, Objec } } - /** + /* * @todo This was copied from DataverseUserPage and modified so consider * consolidating common code (DRY). */ @@ -336,11 +340,7 @@ public String getCreateFromWhereTip() { public boolean isConvertFromBuiltinIsPossible() { AuthenticationProvider builtinAuthProvider = authenticationSvc.getAuthenticationProvider(BuiltinAuthenticationProvider.PROVIDER_ID); - if (builtinAuthProvider != null) { - return true; - } else { - return false; - } + return builtinAuthProvider != null; } public String getSuggestConvertInsteadOfCreate() { @@ -371,7 +371,7 @@ public List getEmailsToPickFrom() { } } } - logger.fine(emailsToPickFrom.size() + " emails to pick from: " + emailsToPickFrom); + logger.log(Level.FINE, "{0} emails to pick from: {1}", new Object[]{emailsToPickFrom.size(), emailsToPickFrom}); return emailsToPickFrom; } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2LoginBackingBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2LoginBackingBean.java index 600e97b00bd..6fdc33b48b3 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2LoginBackingBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2LoginBackingBean.java @@ -4,7 +4,6 @@ import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; -import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.StringUtil; import java.io.BufferedReader; import java.io.IOException; @@ -26,8 +25,8 @@ import edu.harvard.iq.dataverse.util.SystemConfig; /** - * Backing bean of the oauth2 login process. Used from the login page and the - * callback page. + * Backing bean of the oauth2 login process. Used from the login and the + * callback pages. * * @author michael */ @@ -45,6 +44,9 @@ public class OAuth2LoginBackingBean implements Serializable { @EJB AuthenticationServiceBean authenticationSvc; + + @EJB + OAuth2TokenDataServiceBean oauth2Tokens; @EJB SystemConfig systemConfig; @@ -91,7 +93,7 @@ public void exchangeCodeForToken() throws IOException { oauthUser = idp.getUserRecord(code, state, getCallbackUrl()); UserRecordIdentifier idtf = oauthUser.getUserRecordIdentifier(); AuthenticatedUser dvUser = authenticationSvc.lookupUser(idtf); - + if (dvUser == null) { // need to create the user newAccountPage.setNewUser(oauthUser); @@ -100,6 +102,10 @@ public void exchangeCodeForToken() throws IOException { } else { // login the user and redirect to HOME of intended page (if any). session.setUser(dvUser); + final OAuth2TokenData tokenData = oauthUser.getTokenData(); + tokenData.setUser(dvUser); + tokenData.setOauthProviderId(idp.getId()); + oauth2Tokens.store(tokenData); String destination = redirectPage.orElse("/"); HttpServletResponse response = (HttpServletResponse) FacesContext.getCurrentInstance().getExternalContext().getResponse(); String prettyUrl = response.encodeRedirectURL(destination); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenData.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenData.java new file mode 100644 index 00000000000..c6ecf1fc008 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenData.java @@ -0,0 +1,179 @@ +package edu.harvard.iq.dataverse.authorization.providers.oauth2; + +import com.github.scribejava.core.model.OAuth2AccessToken; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import java.io.Serializable; +import java.sql.Timestamp; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; + +/** + * Token data for a given user, received from an OAuth2 system. Contains the + * user's access token for the remote system, as well as additional data, + * such as refresh token and expiry date. + * + * @author michael + */ +@NamedQueries({ + @NamedQuery( name="OAuth2TokenData.findByUserIdAndProviderId", + query = "SELECT d FROM OAuth2TokenData d WHERE d.user.id=:userId AND d.oauthProviderId=:providerId" ), + @NamedQuery( name="OAuth2TokenData.deleteByUserIdAndProviderId", + query = "DELETE FROM OAuth2TokenData d WHERE d.user.id=:userId AND d.oauthProviderId=:providerId" ) + +}) +@Entity +public class OAuth2TokenData implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private AuthenticatedUser user; + + private String oauthProviderId; + + private Timestamp expiryDate; + + @Column(length = 64) + private String accessToken; + + @Column(length = 64) + private String refreshToken; + + @Column(length = 64) + private String scope; + + @Column(length = 32) + private String tokenType; + + @Column(columnDefinition = "TEXT") + private String rawResponse; + + + /** + * Creates a new {@link OAuth2TokenData} instance, based on the data in + * the passed {@link OAuth2AccessToken}. + * @param accessTokenResponse The token parsed by the ScribeJava library. + * @return A new, pre-populated {@link OAuth2TokenData}. + */ + public static OAuth2TokenData from( OAuth2AccessToken accessTokenResponse ) { + OAuth2TokenData retVal = new OAuth2TokenData(); + retVal.setAccessToken(accessTokenResponse.getAccessToken()); + retVal.setRefreshToken( accessTokenResponse.getRefreshToken() ); + retVal.setScope( accessTokenResponse.getScope() ); + retVal.setTokenType( accessTokenResponse.getTokenType() ); + if ( accessTokenResponse.getExpiresIn() != null ) { + retVal.setExpiryDate( new Timestamp( System.currentTimeMillis() + accessTokenResponse.getExpiresIn())); + } + retVal.setRawResponse( accessTokenResponse.getRawResponse() ); + + return retVal; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public AuthenticatedUser getUser() { + return user; + } + + public void setUser(AuthenticatedUser user) { + this.user = user; + } + + public String getOauthProviderId() { + return oauthProviderId; + } + + public void setOauthProviderId(String oauthProviderId) { + this.oauthProviderId = oauthProviderId; + } + + public Timestamp getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Timestamp expiryDate) { + this.expiryDate = expiryDate; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getRawResponse() { + return rawResponse; + } + + public void setRawResponse(String rawResponse) { + this.rawResponse = rawResponse; + } + + @Override + public int hashCode() { + int hash = 5; + hash = 71 * hash + (int) (this.id ^ (this.id >>> 32)); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final OAuth2TokenData other = (OAuth2TokenData) obj; + if (this.id != other.id) { + return false; + } + return true; + } + + + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenDataServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenDataServiceBean.java new file mode 100644 index 00000000000..d8f1fa7600b --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2TokenDataServiceBean.java @@ -0,0 +1,44 @@ +package edu.harvard.iq.dataverse.authorization.providers.oauth2; + +import java.util.List; +import java.util.Optional; +import javax.ejb.Stateless; +import javax.inject.Named; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; + +/** + * CRUD for {@link OAuth2TokenData}. + * + * @author michael + */ +@Stateless +public class OAuth2TokenDataServiceBean { + + @PersistenceContext + private EntityManager em; + + public void store( OAuth2TokenData tokenData ) { + if ( tokenData.getId() != null ) { + // token exists, this is an update + em.merge(tokenData); + + } else { + // ensure there's only one token for each user/service pair. + em.createNamedQuery("OAuth2TokenData.deleteByUserIdAndProviderId") + .setParameter("userId", tokenData.getUser().getId() ) + .setParameter("providerId", tokenData.getOauthProviderId() ) + .executeUpdate(); + em.persist( tokenData ); + } + } + + public Optional get( long authenticatedUserId, String serviceId ) { + final List tokens = em.createNamedQuery("OAuth2TokenData.findByUserIdAndProviderId", OAuth2TokenData.class) + .setParameter("userId", authenticatedUserId ) + .setParameter("providerId", serviceId ) + .getResultList(); + return Optional.ofNullable( tokens.isEmpty() ? null : tokens.get(0) ); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java index eca69a3697f..234c2828ab5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/OAuth2UserRecord.java @@ -20,19 +20,19 @@ public class OAuth2UserRecord implements java.io.Serializable { /** A potentially mutable String that is easier on the eye than a number. */ private final String username; - private final String accessToken; - private final AuthenticatedUserDisplayInfo displayInfo; private final List availableEmailAddresses; + private final OAuth2TokenData tokenData; + public OAuth2UserRecord(String aServiceId, String anIdInService, String aUsername, - String anAccessToken, AuthenticatedUserDisplayInfo aDisplayInfo, + OAuth2TokenData someTokenData, AuthenticatedUserDisplayInfo aDisplayInfo, List someAvailableEmailAddresses) { serviceId = aServiceId; idInService = anIdInService; username = aUsername; - accessToken = anAccessToken; + tokenData = someTokenData; displayInfo = aDisplayInfo; availableEmailAddresses = someAvailableEmailAddresses; } @@ -49,10 +49,6 @@ public String getUsername() { return username; } - public String getAccessToken() { - return accessToken; - } - public List getAvailableEmailAddresses() { return availableEmailAddresses; } @@ -61,6 +57,10 @@ public AuthenticatedUserDisplayInfo getDisplayInfo() { return displayInfo; } + public OAuth2TokenData getTokenData() { + return tokenData; + } + @Override public String toString() { return "OAuth2UserRecord{" + "serviceId=" + serviceId + ", idInService=" + idInService + '}'; diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidApi.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidApi.java index 9fffe2171bc..d5f32e67bc0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidApi.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidApi.java @@ -12,7 +12,7 @@ public class OrcidApi extends DefaultApi20 { /** - * The instance holder pattern allows for lazy creation of the intance. + * The instance holder pattern allows for lazy creation of the instance. */ private static class SandboxInstanceHolder { private static final OrcidApi INSTANCE = diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidOAuth2AP.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidOAuth2AP.java index c56fe77bcf0..030680f29c4 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidOAuth2AP.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/oauth2/impl/OrcidOAuth2AP.java @@ -2,10 +2,16 @@ import com.github.scribejava.core.builder.api.BaseApi; import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.model.Response; +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.OAuth20Service; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationProviderDisplayInfo; import edu.harvard.iq.dataverse.authorization.providers.oauth2.AbstractOAuth2AuthenticationProvider; import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2Exception; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2TokenData; +import edu.harvard.iq.dataverse.authorization.providers.oauth2.OAuth2UserRecord; import edu.harvard.iq.dataverse.util.BundleUtil; import java.io.IOException; import java.io.StringReader; @@ -13,12 +19,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; +import static java.util.stream.Collectors.joining; import java.util.stream.IntStream; import java.util.stream.Stream; import javax.json.Json; +import javax.json.JsonObject; import javax.json.JsonReader; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -28,11 +37,17 @@ import org.w3c.dom.NodeList; import org.xml.sax.InputSource; import org.xml.sax.SAXException; +import javax.xml.xpath.XPathFactory; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; /** * OAuth2 identity provider for ORCiD. Note that ORCiD has two systems: sandbox * and production. Hence having the user endpoint as a parameter. + * * @author michael + * @author pameyer */ public class OrcidOAuth2AP extends AbstractOAuth2AuthenticationProvider { @@ -61,41 +76,69 @@ public String getUserEndpoint( OAuth2AccessToken token ) { public BaseApi getApiInstance() { return OrcidApi.instance( ! baseUserEndpoint.contains("sandbox") ); } + + @Override + public OAuth2UserRecord getUserRecord(String code, String state, String redirectUrl) throws IOException, OAuth2Exception { + OAuth20Service service = getService(state, redirectUrl); + OAuth2AccessToken accessToken = service.getAccessToken(code); + + if ( ! accessToken.getScope().contains(scope) ) { + // We did not get the permissions on the scope we need. Abort and inform the user. + throw new OAuth2Exception(200, BundleUtil.getStringFromBundle("auth.providers.orcid.insufficientScope"), ""); + } + + String orcidNumber = extractOrcidNumber(accessToken.getRawResponse()); + + final String userEndpoint = getUserEndpoint(accessToken); + + final OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint, service); + request.addHeader("Authorization", "Bearer " + accessToken.getAccessToken()); + request.setCharset("UTF-8"); + + final Response response = request.send(); + int responseCode = response.getCode(); + final String body = response.getBody(); + logger.log(Level.FINE, "In getUserRecord. Body: {0}", body); + if ( responseCode == 200 ) { + final ParsedUserResponse parsed = parseUserResponse(body); + AuthenticatedUserDisplayInfo orgData = getOrganizationalData(userEndpoint, accessToken.getAccessToken(), service); + parsed.displayInfo.setAffiliation(orgData.getAffiliation()); + parsed.displayInfo.setPosition(orgData.getPosition()); + + return new OAuth2UserRecord(getId(), orcidNumber, + parsed.username, + OAuth2TokenData.from(accessToken), + parsed.displayInfo, + parsed.emails); + } else { + throw new OAuth2Exception(responseCode, body, "Error getting the user info record."); + } + } + @Override protected ParsedUserResponse parseUserResponse(String responseBody) { DocumentBuilderFactory dbFact = DocumentBuilderFactory.newInstance(); try ( StringReader reader = new StringReader(responseBody)) { DocumentBuilder db = dbFact.newDocumentBuilder(); Document doc = db.parse( new InputSource(reader) ); - List orcidIdNodeList = getNodes(doc, "orcid-message", "orcid-profile","orcid-identifier","path"); - if ( orcidIdNodeList.size() != 1 ) { - throw new OAuth2Exception(0, responseBody, "Cannot find ORCiD id in response."); - } - String orcidId = orcidIdNodeList.get(0).getTextContent().trim(); - String firstName = getNodes(doc, "orcid-message", "orcid-profile", "orcid-bio", "personal-details", "given-names" ) + + String firstName = getNodes(doc, "person:person", "person:name", "personal-details:given-names" ) .stream().findFirst().map( Node::getTextContent ) .map( String::trim ).orElse(""); - String familyName = getNodes(doc, "orcid-message", "orcid-profile", "orcid-bio", "personal-details", "family-name" ) + String familyName = getNodes(doc, "person:person", "person:name", "personal-details:family-name") .stream().findFirst().map( Node::getTextContent ) .map( String::trim ).orElse(""); - String affiliation = getNodes(doc, "orcid-message", "orcid-profile", "orcid-activities", "affiliations", "affiliation", "organization", "name" ) + + // fallback - try to use the credit-name + if ( (firstName + familyName).equals("") ) { + firstName = getNodes(doc, "person:person", "person:name", "personal-details:credit-name" ) .stream().findFirst().map( Node::getTextContent ) .map( String::trim ).orElse(""); - List emails = new ArrayList<>(); - getNodes(doc, "orcid-message", "orcid-profile", "orcid-bio","contact-details","email").forEach( n ->{ - String email = n.getTextContent().trim(); - Node primaryAtt = n.getAttributes().getNamedItem("primary"); - boolean isPrimary = (primaryAtt!=null) && - (primaryAtt.getTextContent()!=null) && - (primaryAtt.getTextContent().trim().toLowerCase().equals("true")); - if ( isPrimary ) { - emails.add(0, email); - } else { - emails.add(email); - } - }); - String primaryEmail = (emails.size()>1) ? emails.get(0) : ""; + } + + String primaryEmail = getPrimaryEmail(doc); + List emails = getAllEmails(doc); // make the username up String username; @@ -104,9 +147,13 @@ protected ParsedUserResponse parseUserResponse(String responseBody) { } else { username = firstName.split(" ")[0] + "." + familyName; } + username = username.replaceAll("[^a-zA-Z0-9.]",""); + // returning the parsed user. The user-id-in-provider will be added by the caller, since ORCiD passes it + // on the access token response. + // Affilifation added after a later call. final ParsedUserResponse userResponse = new ParsedUserResponse( - new AuthenticatedUserDisplayInfo(firstName, familyName, primaryEmail, affiliation, ""), orcidId, username); + new AuthenticatedUserDisplayInfo(firstName, familyName, primaryEmail, "", ""), null, username); userResponse.emails.addAll(emails); return userResponse; @@ -117,8 +164,6 @@ protected ParsedUserResponse parseUserResponse(String responseBody) { logger.log(Level.SEVERE, "I/O error parsing response body from ORCiD: " + ex.getMessage(), ex); } catch (ParserConfigurationException ex) { logger.log(Level.SEVERE, "While parsing the ORCiD response: Bad parse configuration. " + ex.getMessage(), ex); - } catch (OAuth2Exception ex) { - logger.log(Level.SEVERE, "Semantic error parsing response body from ORCiD: " + ex.getMessage(), ex); } return null; @@ -146,6 +191,52 @@ private List getNodes( Node node, List path ) { } } + + /** + * retrieve email from ORCID 2.0 response document, or empty string if no primary email is present + */ + private String getPrimaryEmail(Document doc) { + // `xmlstarlet sel -t -c "/record:record/person:person/email:emails/email:email[@primary='true']/email:email"`, if you're curious + String p = "/person/emails/email[@primary='true']/email/text()"; + NodeList emails = xpathMatches( doc, p ); + String primaryEmail = ""; + if ( 1 == emails.getLength() ) { + primaryEmail = emails.item(0).getTextContent(); + } + // if there are no (or somehow more than 1) primary email(s), then we've already at failure value + return primaryEmail; + } + + /** + * retrieve all emails (including primary) from ORCID 2.0 response document + */ + private List getAllEmails(Document doc) { + String p = "/person/emails/email/email/text()"; + NodeList emails = xpathMatches( doc, p ); + List rs = new ArrayList<>(); + for(int i=0;i - - + diff --git a/src/main/webapp/loginpage.xhtml b/src/main/webapp/loginpage.xhtml index 6ded99c5eb6..229da67e691 100644 --- a/src/main/webapp/loginpage.xhtml +++ b/src/main/webapp/loginpage.xhtml @@ -5,6 +5,7 @@ xmlns:ui="http://java.sun.com/jsf/facelets" xmlns:o="http://omnifaces.org/ui" xmlns:p="http://primefaces.org/ui" + xmlns:fn="http://java.sun.com/jsp/jstl/functions" xmlns:jsf="http://xmlns.jcp.org/jsf"> @@ -187,16 +188,20 @@
- - + + - +

ORCID is an open, non-profit, community-based effort to provide a registry of unique researcher identifiers and a transparent method of linking research activities and outputs to these identifiers. ORCID is unique in its ability to reach across disciplines, research sectors, and national boundaries and its cooperation with other identifier systems. Find out more at orcid.org/about. -

+

+

+ This repository uses your ORCID for authentication (so you don't need another username/password combination). + Having your ORCID associated with your datasets also makes it easier for people to find the datasets you have published. +

diff --git a/src/main/webapp/oauth2/callback.xhtml b/src/main/webapp/oauth2/callback.xhtml index 80c02e26819..6ae50318e6c 100644 --- a/src/main/webapp/oauth2/callback.xhtml +++ b/src/main/webapp/oauth2/callback.xhtml @@ -20,8 +20,16 @@ -