/*
* Copyright @ 2015 Atlassian Pty Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.java.sip.communicator.impl.googlecontacts;
import java.awt.*;
import java.awt.event.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.atomic.*;
import javax.swing.*;
import net.java.sip.communicator.plugin.desktoputil.*;
import net.java.sip.communicator.service.credentialsstorage.*;
import net.java.sip.communicator.util.*;
import org.apache.http.HttpResponse;
import org.apache.http.client.*;
import org.apache.http.client.entity.*;
import org.apache.http.client.methods.*;
import org.apache.http.impl.client.*;
import org.apache.http.message.*;
import org.apache.http.util.*;
import org.jitsi.service.resources.*;
import com.google.api.client.auth.oauth2.*;
import com.google.api.client.http.*;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.javanet.*;
import com.google.api.client.json.jackson2.*;
import com.google.gson.*;
import com.google.gson.annotations.*;
/**
* OAuth 2 token store.
*
* @author Danny van Heumen
*/
public class OAuth2TokenStore
{
/**
* Logger.
*/
private static final Logger LOGGER = Logger
.getLogger(OAuth2TokenStore.class);
/**
* Symbol for refresh token in token server response.
*/
private static final String REFRESH_TOKEN_SYMBOL = "refresh_token";
/**
* Symbol for access token in token server response.
*/
private static final String ACCESS_TOKEN_SYMBOL = "access_token";
/**
* Symbol for expiration time in token server response.
*/
private static final String EXPIRES_IN_SYMBOL = "expires_in";
/**
* Google OAuth 2 token server.
*/
private static final GenericUrl GOOGLE_OAUTH2_TOKEN_SERVER =
new GenericUrl("https://accounts.google.com/o/oauth2/token");
/**
* Client ID for OAuth 2 based authentication.
*/
private static final String GOOGLE_API_CLIENT_ID
= GoogleAPIClientToken.GOOGLE_API_CLIENT_ID;
/**
* Client secret for OAuth 2 based authentication.
*/
private static final String GOOGLE_API_CLIENT_SECRET
= GoogleAPIClientToken.GOOGLE_API_CLIENT_SECRET;
/**
* Required OAuth 2 authentication scopes.
*/
private static final String GOOGLE_API_OAUTH2_SCOPES =
"profile%20email%20https://www.google.com/m8/feeds";
/**
* OAuth 2 redirect URL.
*/
private static final String GOOGLE_API_OAUTH2_REDIRECT_URI =
"urn:ietf:wg:oauth:2.0:oob";
/**
* Grant type for communication with token server.
*/
private static final String GOOGLE_API_GRANT_TYPE = "authorization_code";
/**
* Approval URL.
*/
private static final String APPROVAL_URL = String.format(
"https://accounts.google.com/o/oauth2/auth?scope=%s&redirect_uri=%s"
+ "&response_type=code&client_id=%s", GOOGLE_API_OAUTH2_SCOPES,
GOOGLE_API_OAUTH2_REDIRECT_URI, GOOGLE_API_CLIENT_ID);
private static final Gson GSON = new Gson();
/**
* The credential store.
*
* Note: The AtomicReference container is used as a shared container that is
* also passed on to some of the registered listeners for updating the
* credential data.
*/
private final AtomicReference<Credential> store =
new AtomicReference<Credential>(null);
/**
* Get the credential from the store. In case a credential does not (yet)
* exist, acquire one preferrably from the password store. Optionally,
* involve the user if a credential is not yet stored.
*
* @param identity The identity of the API token.
* @return Returns the credential.
* @throws FailedAcquireCredentialException
* @throws MalformedURLException In case requesting authn token failed.
*/
public synchronized Credential get(final String identity)
throws FailedAcquireCredentialException
{
if (GOOGLE_API_CLIENT_ID == null || GOOGLE_API_CLIENT_SECRET == null)
{
throw new IllegalStateException("Missing client ID or client "
+ "secret. It is not possible to use Google Contacts API "
+ "without it.");
}
if (this.store.get() == null)
{
try
{
acquireCredential(this.store, identity);
}
catch (final URISyntaxException | IOException | RuntimeException e)
{
throw new FailedAcquireCredentialException(e);
}
}
// should make sure that only succeeded requests reach up to here
return this.store.get();
}
/**
* Acquire a new credential instance.
*
* @param store credential store to update upon refreshing and other
* operations
* @param identity the identity to which the refresh token belongs
* @return Acquires and returns the credential instance.
* @throws URISyntaxException In case of bad redirect URI.
* @throws IOException
* @throws ClientProtocolException
*/
private static void acquireCredential(
final AtomicReference<Credential> store, final String identity)
throws URISyntaxException, ClientProtocolException, IOException
{
final TokenData token;
String refreshToken = restoreRefreshToken(identity);
if (refreshToken == null)
{
LOGGER.info("No credentials available yet. Requesting user to "
+ "approve access to Contacts API for identity " + identity
+ " using URL: " + APPROVAL_URL);
final OAuthApprovalDialog dialog =
new OAuthApprovalDialog(identity);
// Synchronize on OAuth approval dialog CLASS, to ensure that only
// one dialog shows at a time.
synchronized (OAuthApprovalDialog.class)
{
dialog.setVisible(true);
}
switch (dialog.getResponse())
{
case CONFIRMED:
// dialog is confirmed, so process entered approval code
final String approvalCode = dialog.getApprovalCode();
LOGGER.debug("Approval code from user: " + approvalCode);
token = requestAuthenticationToken(approvalCode);
saveRefreshToken(token, identity);
break;
case CANCELLED:
default:
// user one time cancellation
// let token remain null, as we do not have new information
// yet
token = null;
break;
}
}
else
{
token = new TokenData(null, refreshToken, 0);
}
store.set(createCredential(store, token));
}
/**
* Restore refresh token from encrypted credentials store.
*
* @param identity The identity corresponding to the refresh token.
* @return Returns the refresh token.
*/
private static String restoreRefreshToken(final String identity)
{
final CredentialsStorageService credentials =
GoogleContactsActivator.getCredentialsService();
return credentials
.loadPassword(GoogleContactsServiceImpl.CONFIGURATION_PATH + "."
+ identity);
}
/**
* Save refresh token for provided identity.
*
* @param token The refresh token.
* @param identity The identity.
* @throws IOException An IOException in case of errors.
*/
private static void saveRefreshToken(final TokenData token,
final String identity) throws IOException
{
final CredentialsStorageService credentials =
GoogleContactsActivator.getCredentialsService();
credentials.storePassword(GoogleContactsServiceImpl.CONFIGURATION_PATH
+ "." + identity, token.refreshToken);
}
/**
* Refresh OAuth2 authentication token.
*
* @throws IOException
* @throws FailedTokenRefreshException In case of failed token refresh
* operation.
*/
public synchronized void refresh() throws IOException, FailedTokenRefreshException
{
final Credential credential = this.store.get();
if (credential == null)
{
throw new IllegalStateException("A credential instance should "
+ "exist, but it does not. This is likely due to a bug.");
}
if (!credential.refreshToken())
{
LOGGER.warn("Refresh of OAuth2 authentication token failed.");
throw new FailedTokenRefreshException();
}
}
/**
* Create credential instance suitable for use in Google Contacts API.
*
* @param store reference to the credential store for updating credential
* data upon refreshing and other cases
* @param approvalCode the approval code received from Google by the user
* accepting the authorization request
* @return Returns a Credential instance.
* @throws URISyntaxException In case of bad OAuth 2 redirect URI.
*/
private static Credential createCredential(
final AtomicReference<Credential> store, final TokenData data)
throws URISyntaxException
{
final Credential.Builder builder =
new Credential.Builder(
BearerToken.authorizationHeaderAccessMethod());
builder.setTokenServerUrl(GOOGLE_OAUTH2_TOKEN_SERVER);
builder.setTransport(new NetHttpTransport());
builder.setJsonFactory(new JacksonFactory());
builder.setClientAuthentication(new HttpExecuteInterceptor()
{
@Override
public void intercept(HttpRequest request) throws IOException
{
final Object data =
((UrlEncodedContent) request.getContent()).getData();
if (data instanceof RefreshTokenRequest)
{
// Inject client authentication credentials in requests.
final RefreshTokenRequest content =
(RefreshTokenRequest) data;
content.put("client_id", GOOGLE_API_CLIENT_ID);
content.put("client_secret", GOOGLE_API_CLIENT_SECRET);
LOGGER.info("Inserting client authentication data into "
+ "refresh token request.");
if (LOGGER.isDebugEnabled())
{
LOGGER.debug("Request: " + content.toString());
}
}
else
{
LOGGER.debug("Unexpected type of request found.");
}
}
});
builder.addRefreshListener(new CredentialRefreshListener()
{
@Override
public void onTokenResponse(Credential credential,
TokenResponse tokenResponse) throws IOException
{
LOGGER.debug("Successful token refresh response: "
+ tokenResponse.toPrettyString());
store.set(credential);
}
@Override
public void onTokenErrorResponse(Credential credential,
TokenErrorResponse tokenErrorResponse) throws IOException
{
if (LOGGER.isDebugEnabled())
{
LOGGER.debug("Failed token refresh response: "
+ tokenErrorResponse.toPrettyString());
}
LOGGER.error("Failed to refresh OAuth2 token: "
+ tokenErrorResponse.getError() + ": "
+ tokenErrorResponse.getErrorDescription());
}
});
final Credential credential = builder.build();
credential.setAccessToken(data.accessToken);
credential.setRefreshToken(data.refreshToken);
credential.setExpiresInSeconds(data.expiration);
return credential;
}
/**
* Request an authentication token using the approval code received from the
* user.
*
* @param approvalCode the approval code
* @return Returns the acquired token data from OAuth 2 token server.
* @throws IOException
* @throws ClientProtocolException
*/
private static TokenData requestAuthenticationToken(
final String approvalCode) throws ClientProtocolException, IOException
{
final HttpClient client = new DefaultHttpClient();
final HttpPost post = new HttpPost(GOOGLE_OAUTH2_TOKEN_SERVER.toURI());
final UrlEncodedFormEntity entity =
new UrlEncodedFormEntity(Arrays.asList(new BasicNameValuePair(
"code", approvalCode), new BasicNameValuePair("client_id",
GOOGLE_API_CLIENT_ID), new BasicNameValuePair("client_secret",
GOOGLE_API_CLIENT_SECRET), new BasicNameValuePair(
"redirect_uri", GOOGLE_API_OAUTH2_REDIRECT_URI),
new BasicNameValuePair("grant_type", GOOGLE_API_GRANT_TYPE)));
post.setEntity(entity);
final HttpResponse httpResponse = client.execute(post);
final String responseJson = EntityUtils.toString(
httpResponse.getEntity(), StandardCharsets.UTF_8);
return GSON.fromJson(responseJson, TokenData.class);
}
/**
* OAuth 2 approval dialog for instructing user for instructing the user to
* open a web browser and approve Jitsi Google Contacts plugin access and
* for receiving the resulting approval code.
*
* @author Danny van Heumen
*/
private static class OAuthApprovalDialog
extends SIPCommDialog
{
private static final long serialVersionUID = 6792589736608633346L;
private final SIPCommLinkButton link;
private final SIPCommTextField code = new SIPCommTextField("");
// Initialize behavior of code input field.
{
code.setFont(code.getFont().deriveFont(Font.BOLD, 12));
code.setForeground(Color.BLACK);
}
private UserResponseType response = UserResponseType.CANCELLED;
/**
* Construct and initialize the OAuth 2 approval dialog.
*
* @param identity The identity for which approval is requested.
*/
public OAuthApprovalDialog(final String identity)
{
super(false);
final ResourceManagementService resources =
GoogleContactsActivator.getResourceManagementService();
final String instructionsText =
resources.getI18NString("impl.googlecontacts.INSTRUCTIONS");
// configure dialog
this.setTitle(resources
.getI18NString("impl.googlecontacts.OAUTH_DIALOG_TITLE"));
this.setMinimumSize(new Dimension(20, 20));
this.setPreferredSize(new Dimension(650, 220));
this.setBounds(10, 10, this.getWidth() - 20, this.getHeight() - 20);
this.setModal(true);
// main panel layout
JPanel mainPanel = new TransparentPanel(new BorderLayout());
mainPanel.setBorder(
BorderFactory.createEmptyBorder(10, 10, 5, 10));
final JPanel instructionPanel
= new TransparentPanel(new BorderLayout());
mainPanel.add(instructionPanel, BorderLayout.NORTH);
final JPanel buttonPanel = new TransparentPanel(new BorderLayout());
mainPanel.add(buttonPanel, BorderLayout.SOUTH);
// populate instruction dialog
final JLabel instructionLabel = new JLabel(instructionsText);
instructionPanel.add(instructionLabel,
BorderLayout.CENTER);
this.link =
new SIPCommLinkButton(resources.getI18NString(
"impl.googlecontacts.HYPERLINK_TEXT", new String[]
{ identity }));
this.link.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
LOGGER.info("Requesting user for approval via web page: "
+ APPROVAL_URL);
GoogleContactsActivator.getBrowserLauncherService()
.openURL(APPROVAL_URL);
}
});
instructionPanel.add(this.link, BorderLayout.SOUTH);
// input controls on main panel
final JLabel codeLabel =
new JLabel(resources.getI18NString("impl.googlecontacts.CODE"));
codeLabel.setOpaque(false);
mainPanel.add(codeLabel, BorderLayout.WEST);
mainPanel.add(this.code, BorderLayout.CENTER);
// buttons panel
final JButton doneButton =
new JButton(resources.getI18NString("service.gui.SAVE"));
doneButton.setMnemonic('s');
doneButton.addActionListener(new ActionListener()
{
@Override
public void actionPerformed(ActionEvent e)
{
OAuthApprovalDialog.this.response =
UserResponseType.CONFIRMED;
OAuthApprovalDialog.this.dispose();
}
});
buttonPanel.add(doneButton, BorderLayout.EAST);
this.add(mainPanel);
this.pack();
}
/**
* Get approval code entered by the user in the dialog input field.
*
* @return Returns the approval code.
*/
public String getApprovalCode()
{
return this.code.getText();
}
/**
* Is approval dialog confirmed with "Done" button.
*
* @return Returns whether or not the dialog is confirmed.
*/
public UserResponseType getResponse()
{
return this.response;
}
}
/**
* Container for token data for internal use.
*
* @author Danny van Heumen
*/
private static class TokenData
{
/**
* OAuth 2 access token.
*/
@SerializedName(value = ACCESS_TOKEN_SYMBOL)
private String accessToken;
/**
* OAuth 2 refresh token.
*/
@SerializedName(value = REFRESH_TOKEN_SYMBOL)
private String refreshToken;
/**
* Available time before expiration of the current access token.
*/
@SerializedName(value = EXPIRES_IN_SYMBOL)
private long expiration;
TokenData() {
}
/**
* Constructor for TokenData container.
*
* @param accessToken the access token
* @param refreshToken the refresh token (cannot be null)
* @param expirationTime the expiration time (must be >= 0)
*/
private TokenData(final String accessToken, final String refreshToken,
final long expirationTime)
{
this.accessToken = accessToken;
if (refreshToken == null)
{
throw new NullPointerException("refresh token cannot be null");
}
this.refreshToken = refreshToken;
if (expirationTime < 0)
{
throw new IllegalArgumentException(
"Expiration time cannot be smaller than zero");
}
this.expiration = expirationTime;
}
void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
void setExpiration(long expiration) {
this.expiration = expiration;
}
}
/**
* Exception for error case where we failed to acquire initial credential
* for OAuth 2 authentication and authorization.
*
* @author Danny van Heumen
*/
public static class FailedAcquireCredentialException
extends Exception
{
private static final long serialVersionUID = 5810534617383420431L;
private FailedAcquireCredentialException(final Throwable cause)
{
super(cause);
}
}
/**
* Exception for error case where we failed to refresh the OAuth 2 authn
* token.
*
* @author Danny van Heumen
*/
public static class FailedTokenRefreshException
extends Exception
{
private static final long serialVersionUID = 3166027054735734199L;
private FailedTokenRefreshException()
{
super("Failed to refresh OAuth2 token.");
}
}
}