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 all 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,75 @@
/*
* ***** 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 LinkedinContactsImport class.<br>
* Used to sync contacts from the Linkedin 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
}
}
222 changes: 222 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,222 @@
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"),
SERVICE_ERROR_CODE("serviceErrorCode"),
ERROR_MESSAGE("message"),
ERROR_STATUS("status");

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

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_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(LinkedinOAuth2Constants.SERVICE_ERROR_CODE.getValue())
&& json.has(LinkedinOAuth2Constants.ERROR_MESSAGE.getValue())) {
if (json.get(LinkedinOAuth2Constants.SERVICE_ERROR_CODE.getValue()).asText()
.equals(LinkedinErrorCodes.SERVICE_ERROR_CODE_100.getValue())
&& json.get(LinkedinOAuth2Constants.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(LinkedinOAuth2Constants.SERVICE_ERROR_CODE.getValue()) + ", Status="
+ json.get(LinkedinOAuth2Constants.ERROR_STATUS.getValue()) + ", ErrorMessage="
+ json.get(LinkedinOAuth2Constants.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 access_token
oauthInfo.setUsername(username);
oauthInfo.setRefreshToken(credentials.get(LinkedinOAuth2Constants.ACCESS_TOKEN.getValue()).asText());
dataSource.syncDatasource(mailbox, oauthInfo, null);
return true;
}

}
4 changes: 2 additions & 2 deletions src/java/com/zimbra/oauth/handlers/impl/OAuth2Handler.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,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 +290,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