Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5991 - Update ScribeJava to 6.6.3 and necessary refactoring #5997

Merged
merged 22 commits into from
Oct 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
13465b7
#5991 - Update from ScribeJava v3.1.0 to v6.6.3
poikilotherm Jul 4, 2019
9aa7fe8
#5991. Change BaseAPI to DefaultApi20, as upstream removed the old one.
poikilotherm Jul 5, 2019
1a4e449
#5991. Refactor OAuth login bean and provider superclass to be compat…
poikilotherm Jul 5, 2019
26af35e
#5991. Refactor ORCID provider to use current ScribeJava lib. Refacto…
poikilotherm Jul 5, 2019
01c5ff6
Merge branch 'develop' into 5991-oidc-provider
poikilotherm Aug 27, 2019
29f6629
Merge branch 'develop' into 5991-oidc-provider
poikilotherm Sep 13, 2019
69b5f07
Update to ScribeJava 6.8.1 from 6.6.3.
poikilotherm Sep 13, 2019
5a79443
Make OAuth2 state creation use secure RNG.
poikilotherm Sep 13, 2019
55a2712
Make OAuth2LoginBackingBean testable with injected FacesContext
poikilotherm Sep 13, 2019
e0b526f
Merge branch '6165-cannot-deploy' of https://github.com/IQSS/datavers…
poikilotherm Sep 13, 2019
015cb6a
Merge branch 'develop' into 5991-oidc-provider
poikilotherm Sep 19, 2019
4612141
Revert "Make OAuth2LoginBackingBean testable with injected FacesConte…
poikilotherm Sep 20, 2019
6472f97
Merge branch 'develop' into 5991-oidc-provider
poikilotherm Sep 23, 2019
a51497f
Make scope checking after authorization multi-scope aware.
poikilotherm Sep 23, 2019
81b367b
Remove scope attribute from OAuth2TokenData.
poikilotherm Sep 23, 2019
3916966
Refactor scope usage to make it optional.
poikilotherm Sep 24, 2019
2f82f35
Update Mockito to 2.28.2, latest release before 3.x
poikilotherm Sep 24, 2019
5dc272c
Check for empty scope in response but non-empty scope in provider. Re…
poikilotherm Sep 24, 2019
2c07a01
Merge branch 'develop' into 5991-oidc-provider
poikilotherm Oct 17, 2019
b658222
Rename SQL script for ScribeJava to reflect current version of Datave…
poikilotherm Oct 17, 2019
f630509
Update ScribeJava to v6.9.0
poikilotherm Oct 17, 2019
c729a27
Fix i18n for OAuth2 exceptions in AbstractOAuth2AuthenticationProvider
poikilotherm Oct 17, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<junit.jupiter.version>5.3.1</junit.jupiter.version>
<junit.vintage.version>5.3.1</junit.vintage.version>
<junit.platform.version>1.3.1</junit.platform.version>
<mockito.version>2.22.0</mockito.version>
<mockito.version>2.28.2</mockito.version>
<flyway.version>5.2.4</flyway.version>
<jhove.version>1.20.1</jhove.version>
<!--
Expand Down Expand Up @@ -495,7 +495,7 @@
<dependency>
<groupId>com.github.scribejava</groupId>
<artifactId>scribejava-apis</artifactId>
<version>3.1.0</version>
<version>6.9.0</version>
</dependency>
<!-- EXPERIMENTAL: -->
<!-- lyncode xoai OAI-PMH implementation: -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,12 +10,12 @@
import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo;
import edu.harvard.iq.dataverse.authorization.AuthenticationProvider;
import edu.harvard.iq.dataverse.authorization.AuthenticationProviderDisplayInfo;
import edu.harvard.iq.dataverse.util.BundleUtil;

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.*;
import java.util.concurrent.ExecutionException;
import java.util.logging.Level;
import java.util.logging.Logger;

Expand Down Expand Up @@ -89,50 +89,88 @@ public String toString() {
protected String clientSecret;
protected String baseUserEndpoint;
protected String redirectUrl;
protected String scope;
/**
* List of scopes to be requested for authorization at identity provider.
* Defaults to empty so no scope will be requested (use case: public info from GitHub)
*/
protected List<String> scope = Arrays.asList("");

public abstract BaseApi<OAuth20Service> 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())
.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);

// We need to check if scope is null first: GitHub is used without scope, so the responses scope is null.
// Checking scopes via Stream to be independent from order.
if ( ( accessToken.getScope() != null && ! getScope().stream().allMatch(accessToken.getScope()::contains) ) ||
( accessToken.getScope() == null && ! getSpacedScope().isEmpty() ) ) {
// We did not get the permissions on the scope(s) we need. Abort and inform the user.
throw new OAuth2Exception(200, BundleUtil.getStringFromBundle("auth.providers.insufficientScope", Arrays.asList(this.getTitle())), "");
}

final OAuthRequest request = new OAuthRequest(Verb.GET, userEndpoint, service);
request.addHeader("Authorization", "Bearer " + accessToken.getAccessToken());
OAuthRequest request = new OAuthRequest(Verb.GET, getUserEndpoint(accessToken));
request.setCharset("UTF-8");

final Response response = request.send();
service.signRequest(accessToken, request);

Response response = service.execute(request);
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);
return new OAuth2UserRecord(getId(), parsed.userIdInProvider,
parsed.username,
OAuth2TokenData.from(accessToken),
parsed.displayInfo,
parsed.emails);
String body = response.getBody();
logger.log(Level.FINE, "In requestUserRecord. Body: {0}", body);
if ( responseCode == 200 && body != null ) {
return getUserRecord(body, accessToken, service);
} else {
throw new OAuth2Exception(responseCode, body, "Error getting the user info record.");
throw new OAuth2Exception(responseCode, body, BundleUtil.getStringFromBundle("auth.providers.exception.userinfo", Arrays.asList(this.getTitle())));
}
}

/**
* Get the user record from the response body.
* Might be overriden by subclasses to add information from the access token response not included
* within the request response body.
* @param accessToken Access token used to create the request
* @param responseBody The response body = message from provider
* @param service Not used in base class, but may be used in overrides to lookup more data
* @return A complete record to be forwarded to user handling logic
* @throws OAuth2Exception When some lookup fails in overrides
*/
protected OAuth2UserRecord getUserRecord(@NotNull String responseBody, @NotNull OAuth2AccessToken accessToken, @NotNull OAuth20Service service)
throws OAuth2Exception {

final ParsedUserResponse parsed = parseUserResponse(responseBody);
return new OAuth2UserRecord(getId(), parsed.userIdInProvider,
parsed.username,
OAuth2TokenData.from(accessToken),
parsed.displayInfo,
parsed.emails);
}

@Override
public boolean isUserInfoUpdateAllowed() {
Expand Down Expand Up @@ -195,6 +233,10 @@ public void setSubTitle(String subtitle) {
public String getSubTitle() {
return subTitle;
}

public List<String> getScope() { return scope; }

public String getSpacedScope() { return String.join(" ", getScope()); }

@Override
public int hashCode() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package edu.harvard.iq.dataverse.authorization.providers.oauth2;

import com.github.scribejava.core.oauth.AuthorizationUrlBuilder;
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;
Expand All @@ -8,9 +10,11 @@
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Serializable;
import java.security.SecureRandom;
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;
Expand All @@ -21,6 +25,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;

Expand Down Expand Up @@ -57,19 +63,77 @@ 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));

AuthorizationUrlBuilder aub = svc.createAuthorizationUrlBuilder()
.state(state);

// Do not include scope if empty string (necessary for GitHub)
if (!idp.getSpacedScope().isEmpty()) { aub.scope(idp.getSpacedScope()); }

return aub.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();

try {
Optional<AbstractOAuth2AuthenticationProvider> oIdp = parseStateFromRequest(req);
Optional<String> code = parseCodeFromRequest(req);

final String code = req.getParameter("code");
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);
session.configureSessionTimeout();
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.");
poikilotherm marked this conversation as resolved.
Show resolved Hide resolved
logger.log(Level.WARNING, "Threading exception caught. Message: {0}", ex.getLocalizedMessage());
}
}

private Optional<String> parseCodeFromRequest(@NotNull HttpServletRequest req) {
String code = req.getParameter("code");
if (code == null || code.trim().isEmpty()) {
try (BufferedReader rdr = req.getReader()) {
StringBuilder sb = new StringBuilder();
Expand All @@ -79,60 +143,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;
return Optional.empty();
} catch (IOException e) {
error = new OAuth2Exception(-1, "", "Could not parse OAuth2 code due to IO error.");
poikilotherm marked this conversation as resolved.
Show resolved Hide resolved
logger.log(Level.WARNING, "IOException getting code parameter.", e.getLocalizedMessage());
poikilotherm marked this conversation as resolved.
Show resolved Hide resolved
return Optional.empty();
}
}

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);
session.configureSessionTimeout();

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);
}

return Optional.of(code);
}

private AbstractOAuth2AuthenticationProvider parseState(String state) {
private Optional<AbstractOAuth2AuthenticationProvider> 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])) {
Expand All @@ -142,23 +182,25 @@ 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();
}
}

private String createState(AbstractOAuth2AuthenticationProvider idp, Optional<String> redirectPage ) {
if (idp == null) {
throw new IllegalArgumentException("idp cannot be null");
}
SecureRandom rand = new SecureRandom();

String base = idp.getId() + "~" + System.currentTimeMillis()
+ "~" + (int) java.lang.Math.round(java.lang.Math.random() * 1000)
+ "~" + rand.nextInt(1000)
+ redirectPage.map( page -> "~"+page).orElse("");

String encrypted = StringUtil.encrypt(base, idp.clientSecret);
Expand Down
Loading