From 1a4e449980e986f0ca878cc9eb958f3348c39767 Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Fri, 5 Jul 2019 12:48:34 +0200 Subject: [PATCH] #5991. Refactor OAuth login bean and provider superclass to be compatible with recent ScribeJava library. Refactored code structure a bit, too. --- .../AbstractOAuth2AuthenticationProvider.java | 58 +++++--- .../oauth2/OAuth2LoginBackingBean.java | 138 +++++++++++------- 2 files changed, 125 insertions(+), 71 deletions(-) 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 8cfb84e7ce3..40a2eb09572 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 @@ -1,7 +1,7 @@ package edu.harvard.iq.dataverse.authorization.providers.oauth2; import com.github.scribejava.core.builder.ServiceBuilder; -import com.github.scribejava.core.builder.api.BaseApi; +import com.github.scribejava.core.builder.api.DefaultApi20; import com.github.scribejava.core.model.OAuth2AccessToken; import com.github.scribejava.core.model.OAuthRequest; import com.github.scribejava.core.model.Response; @@ -10,12 +10,15 @@ import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationProvider; import edu.harvard.iq.dataverse.authorization.AuthenticationProviderDisplayInfo; + +import javax.validation.constraints.NotNull; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; @@ -91,35 +94,48 @@ public String toString() { protected String redirectUrl; protected String scope; - public abstract BaseApi getApiInstance(); + public abstract DefaultApi20 getApiInstance(); protected abstract ParsedUserResponse parseUserResponse( String responseBody ); - public OAuth20Service getService(String state, String redirectUrl) { - ServiceBuilder svcBuilder = new ServiceBuilder() - .apiKey(getClientId()) - .apiSecret(getClientSecret()) - .state(state) - .callback(redirectUrl); - if ( scope != null ) { - svcBuilder.scope(scope); - } - return svcBuilder.build( getApiInstance() ); + /** + * Build an OAuth20Service based on client ID & secret. Add default scope and insert + * callback URL. Build uses the real API object for the target service like GitHub etc. + * @param callbackUrl URL where the OAuth2 Provider should send browsers to after authz. + * @return A usable OAuth20Service object + */ + public OAuth20Service getService(String callbackUrl) { + return new ServiceBuilder(getClientId()) + .apiSecret(getClientSecret()) + .defaultScope(getScope()) + .callback(callbackUrl) + .build(getApiInstance()); } - public OAuth2UserRecord getUserRecord(String code, String state, String redirectUrl) throws IOException, OAuth2Exception { - OAuth20Service service = getService(state, redirectUrl); + /** + * Receive user data from OAuth2 provider after authn/z has been successfull. (Callback view uses this) + * Request a token and access the resource, parse output and return user details. + * @param code The authz code sent from the provider + * @param service The service object in use to communicate with the provider + * @return A user record containing all user details accessible for us + * @throws IOException Thrown when communication with the provider fails + * @throws OAuth2Exception Thrown when we cannot access the user details for some reason + * @throws InterruptedException Thrown when the requests thread is failing + * @throws ExecutionException Thrown when the requests thread is failing + */ + public OAuth2UserRecord getUserRecord(String code, @NotNull OAuth20Service service) + throws IOException, OAuth2Exception, InterruptedException, ExecutionException { + OAuth2AccessToken accessToken = service.getAccessToken(code); - - final String userEndpoint = getUserEndpoint(accessToken); + String userEndpoint = getUserEndpoint(accessToken); - final OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint, service); - request.addHeader("Authorization", "Bearer " + accessToken.getAccessToken()); + OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint); request.setCharset("UTF-8"); + service.signRequest(accessToken, request); - final Response response = request.send(); + Response response = service.execute(request); int responseCode = response.getCode(); - final String body = response.getBody(); + String body = response.getBody(); logger.log(Level.FINE, "In getUserRecord. Body: {0}", body); if ( responseCode == 200 ) { @@ -195,6 +211,8 @@ public void setSubTitle(String subtitle) { public String getSubTitle() { return subTitle; } + + public String getScope() { return scope; } @Override public int hashCode() { 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 6fdc33b48b3..57933b5a26c 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 @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.authorization.providers.oauth2; +import com.github.scribejava.core.oauth.OAuth20Service; import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; @@ -11,6 +12,7 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; import static java.util.stream.Collectors.toList; @@ -21,6 +23,8 @@ import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.validation.constraints.NotNull; + import static edu.harvard.iq.dataverse.util.StringUtil.toOption; import edu.harvard.iq.dataverse.util.SystemConfig; @@ -57,19 +61,73 @@ public class OAuth2LoginBackingBean implements Serializable { @Inject OAuth2FirstLoginPage newAccountPage; + /** + * Generate the OAuth2 Provider URL to be used in the login page link for the provider. + * @param idpId Unique ID for the provider (used to lookup in authn service bean) + * @param redirectPage page part of URL where we should be redirected after login (e.g. "dataverse.xhtml") + * @return A generated link for the OAuth2 provider login + */ public String linkFor(String idpId, String redirectPage) { AbstractOAuth2AuthenticationProvider idp = authenticationSvc.getOAuth2Provider(idpId); - return idp.getService(createState(idp, toOption(redirectPage) ), getCallbackUrl()).getAuthorizationUrl(); - } - - public String getCallbackUrl() { - return systemConfig.getOAuth2CallbackUrl(); + OAuth20Service svc = idp.getService(systemConfig.getOAuth2CallbackUrl()); + String state = createState(idp, toOption(redirectPage)); + + return svc.createAuthorizationUrlBuilder() + .state(state) + .scope(idp.getScope()) + .build(); } - + + /** + * View action for callback.xhtml, the browser redirect target for the OAuth2 provider. + * @throws IOException + */ public void exchangeCodeForToken() throws IOException { HttpServletRequest req = (HttpServletRequest) FacesContext.getCurrentInstance().getExternalContext().getRequest(); - - final String code = req.getParameter("code"); + + try { + Optional oIdp = parseStateFromRequest(req); + Optional code = parseCodeFromRequest(req); + + if (oIdp.isPresent() && code.isPresent()) { + AbstractOAuth2AuthenticationProvider idp = oIdp.get(); + + OAuth20Service svc = idp.getService(systemConfig.getOAuth2CallbackUrl()); + oauthUser = idp.getUserRecord(code.get(), svc); + + UserRecordIdentifier idtf = oauthUser.getUserRecordIdentifier(); + AuthenticatedUser dvUser = authenticationSvc.lookupUser(idtf); + + if (dvUser == null) { + // need to create the user + newAccountPage.setNewUser(oauthUser); + FacesContext.getCurrentInstance().getExternalContext().redirect("/oauth2/firstLogin.xhtml"); + + } 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); + FacesContext.getCurrentInstance().getExternalContext().redirect(prettyUrl); + } + } + } catch (OAuth2Exception ex) { + error = ex; + logger.log(Level.INFO, "OAuth2Exception caught. HTTP return code: {0}. Message: {1}. Message body: {2}", new Object[]{error.getHttpReturnCode(), error.getLocalizedMessage(), error.getMessageBody()}); + Logger.getLogger(OAuth2LoginBackingBean.class.getName()).log(Level.SEVERE, null, ex); + } catch (InterruptedException | ExecutionException ex) { + error = new OAuth2Exception(-1, "Please see server logs for more details", "Could not login due to threading exceptions."); + logger.log(Level.WARNING, "Threading exception caught. Message: {0}", ex.getLocalizedMessage()); + } + } + + private Optional parseCodeFromRequest(@NotNull HttpServletRequest req) { + String code = req.getParameter("code"); if (code == null || code.trim().isEmpty()) { try (BufferedReader rdr = req.getReader()) { StringBuilder sb = new StringBuilder(); @@ -79,58 +137,36 @@ public void exchangeCodeForToken() throws IOException { } error = new OAuth2Exception(-1, sb.toString(), "Remote system did not return an authorization code."); logger.log(Level.INFO, "OAuth2Exception getting code parameter. HTTP return code: {0}. Message: {1} Message body: {2}", new Object[]{error.getHttpReturnCode(), error.getLocalizedMessage(), error.getMessageBody()}); - return; - } - } - - final String state = req.getParameter("state"); - - try { - AbstractOAuth2AuthenticationProvider idp = parseState(state); - if (idp == null) { - throw new OAuth2Exception(-1, "", "Invalid 'state' parameter."); - } - 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); - FacesContext.getCurrentInstance().getExternalContext().redirect("/oauth2/firstLogin.xhtml"); - - } 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); - FacesContext.getCurrentInstance().getExternalContext().redirect(prettyUrl); + return Optional.empty(); + } catch (IOException e) { + error = new OAuth2Exception(-1, "", "Could not parse OAuth2 code due to IO error."); + logger.log(Level.WARNING, "IOException getting code parameter.", e.getLocalizedMessage()); + return Optional.empty(); } - - } catch (OAuth2Exception ex) { - error = ex; - logger.log(Level.INFO, "OAuth2Exception caught. HTTP return code: {0}. Message: {1}. Message body: {2}", new Object[]{error.getHttpReturnCode(), error.getLocalizedMessage(), error.getMessageBody()}); - Logger.getLogger(OAuth2LoginBackingBean.class.getName()).log(Level.SEVERE, null, ex); } - + return Optional.of(code); } - private AbstractOAuth2AuthenticationProvider parseState(String state) { + private Optional parseStateFromRequest(@NotNull HttpServletRequest req) { + String state = req.getParameter("state"); + + if (state == null) { + logger.log(Level.INFO, "No state present in request"); + return Optional.empty(); + } + String[] topFields = state.split("~", 2); if (topFields.length != 2) { logger.log(Level.INFO, "Wrong number of fields in state string", state); - return null; + return Optional.empty(); } AbstractOAuth2AuthenticationProvider idp = authenticationSvc.getOAuth2Provider(topFields[0]); if (idp == null) { logger.log(Level.INFO, "Can''t find IDP ''{0}''", topFields[0]); - return null; + return Optional.empty(); } + + // Verify the response by decrypting values and check for state valid timeout String raw = StringUtil.decrypt(topFields[1], idp.clientSecret); String[] stateFields = raw.split("~", -1); if (idp.getId().equals(stateFields[0])) { @@ -140,14 +176,14 @@ private AbstractOAuth2AuthenticationProvider parseState(String state) { if ( stateFields.length > 3) { redirectPage = Optional.ofNullable(stateFields[3]); } - return idp; + return Optional.of(idp); } else { logger.info("State timeout"); - return null; + return Optional.empty(); } } else { logger.log(Level.INFO, "Invalid id field: ''{0}''", stateFields[0]); - return null; + return Optional.empty(); } }