package org.fluxtream.connectors.fitbit; import net.sf.json.JSONObject; import oauth.signpost.exception.OAuthCommunicationException; import oauth.signpost.exception.OAuthExpectationFailedException; import oauth.signpost.exception.OAuthMessageSignerException; import oauth.signpost.exception.OAuthNotAuthorizedException; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang.StringUtils; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; import org.apache.http.client.HttpClient; import org.apache.http.client.ResponseHandler; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.BasicResponseHandler; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.message.BasicNameValuePair; import org.fluxtream.core.Configuration; import org.fluxtream.core.aspects.FlxLogger; import org.fluxtream.core.auth.AuthHelper; import org.fluxtream.core.connectors.Connector; import org.fluxtream.core.connectors.ObjectType; import org.fluxtream.core.connectors.SignpostOAuthHelper; import org.fluxtream.core.connectors.updaters.UpdateFailedException; import org.fluxtream.core.domain.ApiKey; import org.fluxtream.core.domain.Guest; import org.fluxtream.core.domain.Notification; import org.fluxtream.core.services.ApiDataService; import org.fluxtream.core.services.GuestService; import org.fluxtream.core.services.NotificationsService; import org.fluxtream.core.utils.UnexpectedHttpResponseCodeException; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.net.URLEncoder; import java.util.*; @Controller @RequestMapping(value = "/fitbit") public class FitbitOAuthController { public final static String HAS_OAUTH2 = "has_oauth2"; FlxLogger logger = FlxLogger.getLogger(FitbitOAuthController.class); @Autowired GuestService guestService; @Autowired ApiDataService apiDataService; @Autowired SignpostOAuthHelper signpostHelper; @Autowired NotificationsService notificationsService; @Autowired Configuration env; private static final String FITBIT_OAUTH_CONSUMER = "fitbitOAuthConsumer"; private static final String FITBIT_OAUTH_PROVIDER = "fitbitOAuthProvider"; private static final String FITBIT_RENEWTOKEN_APIKEYID = "fitbit.renewtoken.apiKeyId"; public static final String GET_USER_PROFILE_CALL = "FITBIT_GET_USER_PROFILE_CALL"; static { ObjectType.registerCustomObjectType(GET_USER_PROFILE_CALL); } @RequestMapping(value = "/token") public String getToken(HttpServletRequest request) throws IOException, ServletException, OAuthMessageSignerException, OAuthNotAuthorizedException, OAuthExpectationFailedException, OAuthCommunicationException { String redirectUri = getRedirectUri(); // Here we know that the redirectUri will work String approvalPageUrl = String.format("https://www.fitbit.com/oauth2/authorize?" + "prompt=consent&" + "redirect_uri=%s&" + "response_type=code&client_id=%s", redirectUri, env.get("fitbit.client.id")); approvalPageUrl += "&scope=" + URLEncoder.encode("activity nutrition profile settings sleep weight", "utf-8"); final String apiKeyIdParameter = request.getParameter("apiKeyId"); if (apiKeyIdParameter !=null && !StringUtils.isEmpty(apiKeyIdParameter)) approvalPageUrl += "&state=" + apiKeyIdParameter; return "redirect:" + approvalPageUrl; } @NotNull private String getRedirectUri() { return env.get("homeBaseUrl") + "fitbit/oauth2/swapToken"; } @RequestMapping(value = "/oauth2/swapToken") public String upgradeToken(HttpServletRequest request) throws Exception { final String errorMessage = request.getParameter("error"); final Guest guest = AuthHelper.getGuest(); Connector connector = Connector.getConnector("fitbit"); if (errorMessage!=null) { notificationsService.addNamedNotification(guest.getId(), Notification.Type.ERROR, connector.statusNotificationName(), "There was an error while setting you up with the fitbit service: " + errorMessage); return "redirect:/app"; } final String code = request.getParameter("code"); Map<String,String> parameters = new HashMap<String,String>(); parameters.put("grant_type", "authorization_code"); parameters.put("code", code); parameters.put("client_id", env.get("fitbit.client.id")); parameters.put("redirect_uri", getRedirectUri()); final String json = fetch("https://api.fitbit.com/oauth2/token", parameters); JSONObject token = JSONObject.fromObject(json); if (token.has("error")) { String errorCode = token.getString("error"); notificationsService.addNamedNotification(guest.getId(), Notification.Type.ERROR, connector.statusNotificationName(), errorCode); // NOTE: In the future if we implement renew for the Fitbit connector // we will potentially need to mark the connector as permanently failed. // The way to do this is to get hold of the existing apiKey and do: // guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null); return "redirect:/app"; } final String refresh_token = token.getString("refresh_token"); // Create the entry for this new apiKey in the apiKey table and populate // ApiKeyAttributes with all of the keys fro oauth.properties needed for // subsequent update of this connector instance. ApiKey apiKey; final String stateParameter = request.getParameter("state"); if (stateParameter !=null&&!StringUtils.isEmpty(stateParameter)) { long apiKeyId = Long.valueOf(stateParameter); apiKey = guestService.getApiKey(apiKeyId); } else { apiKey = guestService.createApiKey(guest.getId(), Connector.getConnector("fitbit")); } guestService.populateApiKey(apiKey.getId()); guestService.setApiKeyAttribute(apiKey, HAS_OAUTH2, "true"); guestService.setApiKeyAttribute(apiKey, "accessToken", token.getString("access_token")); guestService.setApiKeyAttribute(apiKey, "tokenExpires", String.valueOf(System.currentTimeMillis() + (token.getLong("expires_in")*1000))); guestService.setApiKeyAttribute(apiKey, "refreshToken", refresh_token); guestService.setApiKeyAttribute(apiKey, "fitbit.client.id", env.get("fitbit.client.id")); if (token.has("user_id")) guestService.setApiKeyAttribute(apiKey, "userId", token.getString("user_id")); // Record that this connector is now up guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_UP, null, null); if (stateParameter !=null&&!StringUtils.isEmpty(stateParameter)) return "redirect:/app/tokenRenewed/fitbit"; else return "redirect:/app/from/fitbit"; } public String fetch(String url, Map<String, String> params) throws UnexpectedHttpResponseCodeException, IOException { HttpClient client = new DefaultHttpClient(); String content = ""; try { HttpPost post = new HttpPost(url); Iterator<Map.Entry<String, String>> iterator = params.entrySet().iterator(); List<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(params.size()); while (iterator.hasNext()) { Map.Entry<String, String> entry = iterator.next(); nameValuePairs.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); } post.setEntity(new UrlEncodedFormEntity(nameValuePairs, "utf-8")); byte[] fitbitConsumerSecrets = Base64.encodeBase64((env.get("fitbit.client.id") + ":" + env.get("fitbitConsumerSecret")).getBytes()); String encodedSecrets = new String(fitbitConsumerSecrets); post.setHeader("Authorization", "Basic " + encodedSecrets); HttpResponse response = client.execute(post); if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { ResponseHandler<String> responseHandler = new BasicResponseHandler(); content = responseHandler.handleResponse(response); } else { throw new UnexpectedHttpResponseCodeException(response.getStatusLine().getStatusCode(), response.getStatusLine().getReasonPhrase()); } } finally { client.getConnectionManager().shutdown(); } return content; } public String getAccessToken(ApiKey apiKey) throws UpdateFailedException { final String expiresString = guestService.getApiKeyAttribute(apiKey, "tokenExpires"); long expires = Long.valueOf(expiresString); if (expires<System.currentTimeMillis()) refreshToken(apiKey, false); return guestService.getApiKeyAttribute(apiKey, "accessToken"); } private void refreshToken(ApiKey apiKey, boolean isOAuth2Upgrade) throws UpdateFailedException { // Check to see if we are running on a mirrored test instance // and should therefore refrain from swapping tokens lest we // invalidate an existing token instance String disableTokenSwap = env.get("disableTokenSwap"); Connector connector = Connector.getConnector("fitbit"); if(disableTokenSwap!=null && disableTokenSwap.equals("true")) { String msg = "**** Skipping refreshToken for fitbit connector instance because disableTokenSwap is set on this server"; StringBuilder sb2 = new StringBuilder("module=FitbitOauthController component=FitbitController action=refreshToken apiKeyId=" + apiKey.getId()) .append(" message=\"").append(msg).append("\""); logger.info(sb2.toString()); System.out.println(msg); // Notify the user that the tokens need to be manually renewed notificationsService.addNamedNotification(apiKey.getGuestId(), Notification.Type.WARNING, connector.statusNotificationName(), "Heads Up. This server cannot automatically refresh your Fitbit authentication tokens.<br>" + "Please head to <a href=\"javascript:App.manageConnectors()\">Manage Connectors</a>,<br>" + "scroll to the Fitbit connector, delete the connector, and re-add<br>" + "<p>We apologize for the inconvenience</p>"); // Record permanent failure since this connector won't work again until // it is reauthenticated guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null, ApiKey.PermanentFailReason.NEEDS_REAUTH); throw new UpdateFailedException("requires token reauthorization", true, ApiKey.PermanentFailReason.NEEDS_REAUTH); } else if (isOAuth2Upgrade){ // if we are upgrading to oauth2, the Api key used with this user // must match with the one this server is setup with if (!env.get("fitbitConsumerKey").equals(guestService.getApiKeyAttribute(apiKey, "fitbitConsumerKey"))) { String msg = "**** Skipping refreshToken for fitbit connector instance because we are upgrading to oauth2 and user and server keys don't match"; StringBuilder sb2 = new StringBuilder("module=FitbitOauthController component=FitbitController action=refreshToken apiKeyId=" + apiKey.getId()) .append(" message=\"").append(msg).append("\""); logger.info(sb2.toString()); System.out.println(msg); // Notify the user that the tokens need to be manually renewed notificationsService.addNamedNotification(apiKey.getGuestId(), Notification.Type.WARNING, connector.statusNotificationName(), "Heads Up. This server cannot automatically upgrade your keys to oauth2.<br>" + "Please head to <a href=\"javascript:App.manageConnectors()\">Manage Connectors</a>,<br>" + "scroll to the Fitbit connector, delete the connector, and re-add<br>" + "<p>We apologize for the inconvenience</p>"); // Record permanent failure since this connector won't work again until // it is reauthenticated guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null, ApiKey.PermanentFailReason.NEEDS_REAUTH); throw new UpdateFailedException("requires token reauthorization", true, ApiKey.PermanentFailReason.NEEDS_REAUTH); } } // We're not on a mirrored test server. Try to swap the expired // access token for a fresh one. Typically fitbit access tokens are good for // 1 hour from time of issue. String swapTokenUrl = "https://api.fitbit.com/oauth2/token"; String refreshToken; Map<String,String> params = new HashMap<String,String>(); // there is a one-off upgrade path to oauth2 for existing oauth1 users // that allows to avoid forcing them to re-authorize fluxtream... if (isOAuth2Upgrade) { // refresh_token parameter: // The user's OAuth 1.0a access token and access token secret concatenated with a colon. String oauth1AccessToken = guestService.getApiKeyAttribute(apiKey, "accessToken"); refreshToken = oauth1AccessToken + ":" + guestService.getApiKeyAttribute(apiKey, "tokenSecret"); } else refreshToken = guestService.getApiKeyAttribute(apiKey, "refreshToken"); params.put("refresh_token", refreshToken); params.put("grant_type", "refresh_token"); String fetched; try { fetched = fetch(swapTokenUrl, params); if (isOAuth2Upgrade) guestService.setApiKeyAttribute(apiKey, HAS_OAUTH2, "true"); // Record that this connector is now up guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_UP, null, null); } catch (Exception e) { // Notify the user that the tokens need to be manually renewed notificationsService.addNamedNotification(apiKey.getGuestId(), Notification.Type.WARNING, connector.statusNotificationName(), "Heads Up. We failed in our attempt to automatically refresh your Fitbit authentication tokens.<br>" + "Please head to <a href=\"javascript:App.manageConnectors()\">Manage Connectors</a>,<br>" + "scroll to the Fitbit connector, delete the connector, and re-add<br>" + "<p>We apologize for the inconvenience</p>"); // Record permanent update failure since this connector is never // going to succeed guestService.setApiKeyStatus(apiKey.getId(), ApiKey.Status.STATUS_PERMANENT_FAILURE, null, ApiKey.PermanentFailReason.NEEDS_REAUTH); throw new UpdateFailedException("refresh token attempt failed", e, true, ApiKey.PermanentFailReason.NEEDS_REAUTH); } JSONObject token = JSONObject.fromObject(fetched); final long expiresIn = token.getLong("expires_in"); final String access_token = token.getString("access_token"); final long now = System.currentTimeMillis(); long tokenExpires = now + (expiresIn*1000); guestService.setApiKeyAttribute(apiKey, "accessToken", access_token); guestService.setApiKeyAttribute(apiKey, "tokenExpires", String.valueOf(tokenExpires)); } void upgrade2OAuth2(ApiKey apiKey) throws UpdateFailedException { refreshToken(apiKey, true); } }