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

ZCS-2691 Create DataSource implementation for LinkedIn contacts via oAuth #44

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* ***** BEGIN LICENSE BLOCK *****
* Zimbra OAuth Social Extension
* Copyright (C) 2018 Synacor, Inc.
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software Foundation,
* version 2 of the License.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
* You should have received a copy of the GNU General Public License along with this program.
* If not, see <https://www.gnu.org/licenses/>.
* ***** END LICENSE BLOCK *****
*/

package com.zimbra.oauth.handlers.impl;

import java.util.List;

import com.zimbra.common.service.ServiceException;
import com.zimbra.common.util.ZimbraLog;
import com.zimbra.cs.account.DataSource;
import com.zimbra.cs.account.DataSource.DataImport;
import com.zimbra.oauth.handlers.impl.LinkedinOAuth2Handler.LinkedinOAuth2Constants;
import com.zimbra.oauth.utilities.Configuration;
import com.zimbra.oauth.utilities.LdapConfiguration;

/**
* The FacebookContactsImport class.<br>
Copy link
Collaborator

@desouzas desouzas Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkedinContactsImport

Also missing this docblock header on the handler class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* Used to sync contacts from the Facebook social service.<br>
* Source from the original YahooContactsImport class by @author Greg Solovyev.
*
* @author Zimbra API Team
* @package com.zimbra.oauth.handlers.impl
* @copyright Copyright © 2018
*/
public class LinkedinContactsImport implements DataImport {

/**
* The datasource under import.
*/
private final DataSource mDataSource;

/**
* Configuration wrapper.
*/
private Configuration config;

/**
* Constructor.
*
* @param datasource The datasource to set
*/
public LinkedinContactsImport(DataSource datasource) {
mDataSource = datasource;
try {
config = LdapConfiguration.buildConfiguration(LinkedinOAuth2Constants.CLIENT_NAME.getValue());
} catch (final ServiceException e) {
ZimbraLog.extensions.info("Error loading configuration for Linkedin: %s",
e.getMessage());
ZimbraLog.extensions.debug(e);
}
}

@Override
public void test() throws ServiceException {
// to be implemented with contact sync
}

@Override
public void importData(List<Integer> folderIds, boolean fullSync) throws ServiceException {
// to be implemented with contact sync
}
}
231 changes: 231 additions & 0 deletions src/java/com/zimbra/oauth/handlers/impl/LinkedinOAuth2Handler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package com.zimbra.oauth.handlers.impl;

import java.io.IOException;

import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.lang.StringUtils;

import com.fasterxml.jackson.databind.JsonNode;
import com.zimbra.client.ZMailbox;
import com.zimbra.common.service.ServiceException;
import com.zimbra.common.util.ZimbraLog;
import com.zimbra.cs.account.Account;
import com.zimbra.oauth.handlers.IOAuth2Handler;
import com.zimbra.oauth.models.OAuthInfo;
import com.zimbra.oauth.utilities.Configuration;
import com.zimbra.oauth.utilities.OAuth2ConfigConstants;
import com.zimbra.oauth.utilities.OAuth2HttpConstants;
import com.zimbra.oauth.utilities.OAuth2Utilities;
import com.zimbra.soap.admin.type.DataSourceType;

public class LinkedinOAuth2Handler extends OAuth2Handler implements IOAuth2Handler {
protected enum LinkedinOAuth2Constants {
AUTHORIZE_URI_TEMPLATE("https://www.linkedin.com/oauth/v2/authorization?client_id=%s&redirect_uri=%s&response_type=%s&scope=%s"),
RESPONSE_TYPE("code"),
RELAY_KEY("state"),
CLIENT_NAME("linkedin"),
HOST_LINKEDIN("www.linkedin.com"),
REQUIRED_SCOPES("r_basicprofile,r_emailaddress"),
SCOPE_DELIMITER(" "),
AUTHENTICATE_URI("https://www.linkedin.com/oauth/v2/accessToken"),
ACCESS_TOKEN("access_token"),
EXPIRES_IN("expires_in")
;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this on the line before for consistency with the project. Same below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


private String constant;

LinkedinOAuth2Constants(String value) {
constant = value;
}

public String getValue() {
return constant;
}
}

protected enum LinkedinMeConstants {
ME_URI("https://api.linkedin.com/v2/me"),
ID("id"),
FIRST_NAME("firstName"),
LAST_NAME("lastName")
;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


private String constant;

LinkedinMeConstants(String value) {
constant = value;
}

public String getValue() {
return constant;
}
}

protected enum LinkedinErrorCodes {
ERROR("error"),
USER_CANCELLED_LOGIN("user_cancelled_login"),
USER_CANCELLED_AUTHORIZE("user_cancelled_authorize"),
ERROR_DESCRIPTION("error_description"),
DEFAULT_ERROR("default_error"),
SERVICE_ERROR_CODE("serviceErrorCode"),
ERROR_MESSAGE("message"),
Copy link
Collaborator

@desouzas desouzas Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this and error_description a key? Move each of the response keys to the general LinkedInOAuth2Constants Keep your error codes in here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

ERROR_STATUS("status"),
SERVICE_ERROR_CODE_100("100"),
ERROR_MESSAGE_NOT_ENOUGH_PERM("Not enough permissions to access")
;

private String constant;

LinkedinErrorCodes(String value) {
constant = value;
}

public String getValue() {
return constant;
}

public static LinkedinErrorCodes fromString(String value) {
for (LinkedinErrorCodes code : LinkedinErrorCodes.values()) {
if (code.getValue().equals(value)) {
return code;
}
}
return LinkedinErrorCodes.DEFAULT_ERROR;
}
}

public LinkedinOAuth2Handler(Configuration config) {
super(config, LinkedinOAuth2Constants.CLIENT_NAME.getValue(), LinkedinOAuth2Constants.HOST_LINKEDIN.getValue());
authorizeUriTemplate = LinkedinOAuth2Constants.AUTHORIZE_URI_TEMPLATE.getValue();
requiredScopes = LinkedinOAuth2Constants.REQUIRED_SCOPES.getValue();
scopeDelimiter = LinkedinOAuth2Constants.SCOPE_DELIMITER.getValue();
relayKey = LinkedinOAuth2Constants.RELAY_KEY.getValue();
authenticateUri = LinkedinOAuth2Constants.AUTHENTICATE_URI.getValue();
dataSource.addImportClass(DataSourceType.oauth2contact.name(), LinkedinContactsImport.class.getCanonicalName());
}

@Override
protected void validateTokenResponse(JsonNode response) throws ServiceException {
if (response.has(LinkedinErrorCodes.ERROR.getValue())) {
final String error = response.get(LinkedinErrorCodes.ERROR.getValue()).asText();
final JsonNode errorMsg = response.get(LinkedinErrorCodes.ERROR_DESCRIPTION.getValue());
ZimbraLog.extensions.debug("Response from linkedin: %s", response.asText());
switch (LinkedinErrorCodes.fromString(StringUtils.upperCase(error))) {
case USER_CANCELLED_LOGIN:
ZimbraLog.extensions.info(
"User cancelled on login screen : " + errorMsg);
throw ServiceException.OPERATION_DENIED(
"User cancelled on login screen");
case USER_CANCELLED_AUTHORIZE:
ZimbraLog.extensions.info(
"User cancelled to authorize : " + errorMsg);
throw ServiceException.OPERATION_DENIED(
"User cancelled to authorize");
case DEFAULT_ERROR:
default:
ZimbraLog.extensions
.warn("Unexpected error while trying to validate token: " + errorMsg);
throw ServiceException.PERM_DENIED("Token validation failed");
}
}

// ensure the tokens we requested are present
if (!response.has(LinkedinOAuth2Constants.ACCESS_TOKEN.getValue()) || !response.has(LinkedinOAuth2Constants.EXPIRES_IN.getValue())) {
throw ServiceException.PARSE_ERROR("Unexpected response from social service.", null);
}
}

@Override
protected String getPrimaryEmail(JsonNode credentials, Account account) throws ServiceException {
Copy link
Collaborator

@desouzas desouzas Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Format all of this method's source (lines appear too long).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formatted code

JsonNode json = null;
final String basicToken = credentials.get(LinkedinOAuth2Constants.ACCESS_TOKEN.getValue()).asText();
final String url = LinkedinMeConstants.ME_URI.getValue();

try {
final GetMethod request = new GetMethod(url);
request.setRequestHeader(OAuth2HttpConstants.HEADER_CONTENT_TYPE.getValue(),
"application/x-www-form-urlencoded");
request.setRequestHeader(OAuth2HttpConstants.HEADER_ACCEPT.getValue(), "application/json");
request.setRequestHeader(OAuth2HttpConstants.HEADER_AUTHORIZATION.getValue(),
"Bearer " + basicToken);
json = executeRequestForJson(request);
} catch (final IOException e) {
ZimbraLog.extensions.warnQuietly("There was an issue acquiring the account details.",
e);
throw ServiceException.FAILURE("There was an issue acquiring the account details.",
null);
}
// check for errors
if (json.has(LinkedinErrorCodes.SERVICE_ERROR_CODE.getValue())
&& json.has(LinkedinErrorCodes.ERROR_MESSAGE.getValue())) {
if (json.get(LinkedinErrorCodes.SERVICE_ERROR_CODE.getValue()).asText().equals(LinkedinErrorCodes.SERVICE_ERROR_CODE_100.getValue())
&& json.get(LinkedinErrorCodes.ERROR_MESSAGE.getValue()).asText().contains(LinkedinErrorCodes.ERROR_MESSAGE_NOT_ENOUGH_PERM.getValue())
) {
return account.getMail();
}
ZimbraLog.extensions.warnQuietly("Error occured while getting profile details."
+ " Code=" + json.get(LinkedinErrorCodes.SERVICE_ERROR_CODE.getValue())
+ ", Status=" + json.get(LinkedinErrorCodes.ERROR_STATUS.getValue())
+ ", ErrorMessage=" + json.get(LinkedinErrorCodes.ERROR_MESSAGE.getValue()),
null);
throw ServiceException.FAILURE("Error occured while getting profile details.",
null);
}
// no errors found
if (json.has(LinkedinMeConstants.FIRST_NAME.getValue()) && json.has(LinkedinMeConstants.LAST_NAME.getValue())
&& !json.get(LinkedinMeConstants.FIRST_NAME.getValue()).asText().isEmpty()
&& !json.get(LinkedinMeConstants.LAST_NAME.getValue()).asText().isEmpty()) {
return json.get(LinkedinMeConstants.FIRST_NAME.getValue()).asText() + "." + json.get(LinkedinMeConstants.LAST_NAME.getValue()).asText();
} else if (json.has(LinkedinMeConstants.ID.getValue()) && !json.get(LinkedinMeConstants.ID.getValue()).asText().isEmpty()) {
return json.get(LinkedinMeConstants.ID.getValue()).asText();
}

// if we couldn't retrieve the user first & last name, the response from
// downstream is missing data
// this could be the result of a misconfigured application id/secret
// (not enough scopes)
ZimbraLog.extensions.error("The user id could not be retrieved from the social service api.");
throw ServiceException.UNSUPPORTED();
}

@Override
public Boolean authenticate(OAuthInfo oauthInfo) throws ServiceException {
final Account account = oauthInfo.getAccount();
final String clientId = config.getString(
String.format(OAuth2ConfigConstants.LC_OAUTH_CLIENT_ID_TEMPLATE.getValue(), client), client,
account);
final String clientSecret = config.getString(
String.format(OAuth2ConfigConstants.LC_OAUTH_CLIENT_SECRET_TEMPLATE.getValue(), client),
client, account);
final String clientRedirectUri = config.getString(
String.format(OAuth2ConfigConstants.LC_OAUTH_CLIENT_REDIRECT_URI_TEMPLATE.getValue(), client),
client, account);
if (StringUtils.isEmpty(clientId) || StringUtils.isEmpty(clientSecret)
|| StringUtils.isEmpty(clientRedirectUri)) {
throw ServiceException.FAILURE("Required config(id, secret and redirectUri) parameters are not provided.", null);
}
final String basicToken = OAuth2Utilities.encodeBasicHeader(clientId, clientSecret);
// set client specific properties
oauthInfo.setClientId(clientId);
oauthInfo.setClientSecret(clientSecret);
oauthInfo.setClientRedirectUri(clientRedirectUri);
oauthInfo.setTokenUrl(authenticateUri);
// request credentials from social service
final JsonNode credentials = getTokenRequest(oauthInfo, basicToken);
// ensure the response contains the necessary credentials
validateTokenResponse(credentials);
// determine account associated with credentials
final String username = getPrimaryEmail(credentials, account);
ZimbraLog.extensions.trace("Authentication performed for:" + username);

// get zimbra mailbox
final ZMailbox mailbox = getZimbraMailbox(oauthInfo.getZmAuthToken());

// store refreshToken
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// store accessToken

May not be unreasonable to note that linkedin tokens are short-lived only. But that could be left for the import implementation where a small change may be necessary in syncDatasource to not set a polling time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// store accessToken

oauthInfo.setUsername(username);
oauthInfo.setRefreshToken(credentials.get(LinkedinOAuth2Constants.ACCESS_TOKEN.getValue()).asText());
dataSource.syncDatasource(mailbox, oauthInfo, null);
return true;
}

}
19 changes: 17 additions & 2 deletions src/java/com/zimbra/oauth/handlers/impl/OAuth2Handler.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@
* @copyright Copyright © 2018
*/
public abstract class OAuth2Handler {
// for relay encoding
public enum RelayEnum {
RELAY("relay"),
Copy link
Collaborator

@desouzas desouzas Jul 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants already exist in the project.
https://github.com/Zimbra/zm-oauth-social/blob/develop/src/java/com/zimbra/oauth/utilities/OAuth2HttpConstants.java#L38

Also "relay" isn't used anymore since the tickets with the ds type updates - it's now "state" into the authorize call, and whatever the social service uses out to the social service (typically "state" - but configurable for each service), and same thing on return into authenticate (whatever the service uses).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this enum is not used anywhere, so removed. I added it while i was testing JweUtil to pass the relay values but i removed that part of code.

TYPE("type"),
JWT("jwt");

String name;
RelayEnum(String name) {
this.name = name;
}

public String getValue() {
return this.name;
}
}

public static final String RELAY_DELIMETER = ";";
/**
Expand Down Expand Up @@ -277,7 +292,7 @@ public String authorize(Map<String, String> params, Account account) throws Serv
if (relayValue.isEmpty()) {
relayValue = "&" + relayKey + "=";
}
relayValue += RELAY_DELIMETER
relayValue += URLEncoder.encode(RELAY_DELIMETER, OAuth2Constants.ENCODING.getValue())
+ URLEncoder.encode(type, OAuth2Constants.ENCODING.getValue());
} catch (final UnsupportedEncodingException e) {
throw ServiceException.INVALID_REQUEST("Unable to encode type parameter.", e);
Expand All @@ -290,7 +305,7 @@ public String authorize(Map<String, String> params, Account account) throws Serv
// jwt is third and optional
if (!jwt.isEmpty()) {
try {
relayValue += RELAY_DELIMETER
relayValue += URLEncoder.encode(RELAY_DELIMETER, OAuth2Constants.ENCODING.getValue())
+ URLEncoder.encode(jwt, OAuth2Constants.ENCODING.getValue());
} catch (final UnsupportedEncodingException e) {
throw ServiceException.INVALID_REQUEST("Unable to encode jwt parameter.", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,9 @@ private static String getValidatedRelay(String url) {
relay = decodedUrl;
}
} catch (final UnsupportedEncodingException e) {
ZimbraLog.extensions.info("Unable to decode relay parameter.");
ZimbraLog.extensions.info("Unable to decode relay parameter. URI=" + url);
} catch (final URISyntaxException e) {
ZimbraLog.extensions.info("Invalid relay URI syntax found.");
ZimbraLog.extensions.info("Invalid relay URI syntax found. URI=" + url);
}
}
return relay;
Expand Down