package com.dropbox.client2.android; import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.List; import java.util.Locale; import android.app.Activity; import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.content.pm.Signature; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; import com.dropbox.client2.DropboxAPI; import com.dropbox.client2.RESTUtility; //Note: This class's code is duplicated between Core SDK and Sync SDK. For now, //it has to be manually copied, but the code is set up so that it can be used in both //places, with only a few import changes above. If you're making changes here, you //should consider if the other side needs them. Don't break compatibility if you //don't have to. This is a hack we should get away from eventually. /** * This activity is used internally for authentication, but must be exposed both * so that Android can launch it and for backwards compatibility. */ public class AuthActivity extends Activity { private static final String TAG = AuthActivity.class.getName(); /** * The extra that goes in an intent to provide your consumer key for * Dropbox authentication. You won't ever have to use this. */ public static final String EXTRA_CONSUMER_KEY = "CONSUMER_KEY"; /** * The extra that goes in an intent when returning from Dropbox auth to * provide the user's access token, if auth succeeded. You won't ever have * to use this. */ public static final String EXTRA_ACCESS_TOKEN = "ACCESS_TOKEN"; /** * The extra that goes in an intent when returning from Dropbox auth to * provide the user's access token secret, if auth succeeded. You won't * ever have to use this. */ public static final String EXTRA_ACCESS_SECRET = "ACCESS_SECRET"; /** * The extra that goes in an intent when returning from Dropbox auth to * provide the user's Dropbox UID, if auth succeeded. You won't ever have * to use this. */ public static final String EXTRA_UID = "UID"; /** * Used for internal authentication. You won't ever have to use this. */ public static final String EXTRA_CONSUMER_SIG = "CONSUMER_SIG"; /** * Used for internal authentication. You won't ever have to use this. */ public static final String EXTRA_CALLING_PACKAGE = "CALLING_PACKAGE"; /** * Used for internal authentication. You won't ever have to use this. */ public static final String EXTRA_CALLING_CLASS = "CALLING_CLASS"; /** * Used for internal authentication. You won't ever have to use this. */ public static final String EXTRA_AUTH_STATE = "AUTH_STATE"; /** * Used for internal authentication. Allows app to request a specific UID to auth against * You won't ever have to use this. */ public static final String EXTRA_DESIRED_UID = "DESIRED_UID"; /** * Used for internal authentication. Allows app to request array of UIDs that should not be auth'd * You won't ever have to use this. */ public static final String EXTRA_ALREADY_AUTHED_UIDS = "ALREADY_AUTHED_UIDS"; /** * The Android action which the official Dropbox app will accept to * authenticate a user. You won't ever have to use this. */ public static final String ACTION_AUTHENTICATE_V1 = "com.dropbox.android.AUTHENTICATE_V1"; /** * The Android action which the official Dropbox app will accept to * authenticate a user. You won't ever have to use this. */ public static final String ACTION_AUTHENTICATE_V2 = "com.dropbox.android.AUTHENTICATE_V2"; /** * The version of the API for the web-auth callback with token (not the initial auth request). */ public static final int AUTH_VERSION = 1; /** * The path for a successful callback with token (not the initial auth request). */ public static final String AUTH_PATH_CONNECT = "/connect"; private static final String DEFAULT_WEB_HOST = "www.dropbox.com"; // saved instance state keys private static final String SIS_KEY_AUTH_STATE_NONCE = "SIS_KEY_AUTH_STATE_NONCE"; /** * Provider of the local security needs of an AuthActivity. * * <p> * You shouldn't need to use this class directly in your app. Instead, * simply configure {@code java.security}'s providers to match your preferences. * </p> */ public interface SecurityProvider { /** * Gets a SecureRandom implementation for use during authentication. */ SecureRandom getSecureRandom(); } // Class-level state used to replace the default SecureRandom implementation // if desired. private static SecurityProvider sSecurityProvider = new SecurityProvider() { @Override public SecureRandom getSecureRandom() { return new SecureRandom(); } }; private static final Object sSecurityProviderLock = new Object(); /** Used internally. */ public static Intent result = null; // Temporary storage for parameters before Activity is created private static String sAppKey; private static String sAppSecret; private static String sWebHost = DEFAULT_WEB_HOST; private static String sApiType; private static String sDesiredUid; private static String[] sAlreadyAuthedUids; // These instance variables need not be stored in savedInstanceState as onNewIntent() // does not read them. private String mAppKey; private String mAppSecret; private String mWebHost; private String mApiType; private String mDesiredUid; private String[] mAlreadyAuthedUids; // Stored in savedInstanceState to track an ongoing auth attempt, which // must include a locally-generated nonce in the response. private String mAuthStateNonce = null; private boolean mActivityDispatchHandlerPosted = false; /** * Set static authentication parameters */ static void setAuthParams(String appKey, String appSecret, String desiredUid, String[] alreadyAuthedUids) { setAuthParams(appKey, appSecret, desiredUid, alreadyAuthedUids, null, null); } /** * Set static authentication parameters */ static void setAuthParams(String appKey, String appSecret, String desiredUid, String[] alreadyAuthedUids, String webHost, String apiType) { sAppKey = appKey; sAppSecret = appSecret; sDesiredUid = desiredUid; sAlreadyAuthedUids = (alreadyAuthedUids != null) ? alreadyAuthedUids : new String[0]; sWebHost = (webHost != null) ? webHost : DEFAULT_WEB_HOST; sApiType = apiType; } /** * Create an intent which can be sent to this activity to start OAuth 1 authentication. * * @param context the source context * @param appKey the consumer key for the app * @param appSecret the consumer secret for the app * @param webHost the host to use for web authentication, or null for the default * @param apiType an identifier for the type of API being supported, or null for * the default * * @return a newly created intent. * * @deprecated Use {@link #makeOAuth2Intent} */ public static Intent makeIntent(Context context, String appKey, String appSecret, String webHost, String apiType) { if (appKey == null) throw new IllegalArgumentException("'appKey' can't be null"); if (appSecret == null) throw new IllegalArgumentException("'appSecret' can't be null"); setAuthParams(appKey, appSecret, null, null, webHost, apiType); return new Intent(context, AuthActivity.class); } /** * Create an intent which can be sent to this activity to start OAuth 2 authentication. * * @param context the source context * @param appKey the consumer key for the app * @param webHost the host to use for web authentication, or null for the default * @param apiType an identifier for the type of API being supported, or null for * the default * * @return a newly created intent. */ public static Intent makeOAuth2Intent(Context context, String appKey, String webHost, String apiType) { if (appKey == null) throw new IllegalArgumentException("'appKey' can't be null"); setAuthParams(appKey, null, null, null, webHost, apiType); return new Intent(context, AuthActivity.class); } /** * Check's the current app's manifest setup for authentication. * If the manifest is incorrect, an exception will be thrown. * If another app on the device is conflicting with this one, * the user will (optionally) be alerted and false will be returned. * * @param context the app context * @param appKey the consumer key for the app * @param alertUser whether to alert the user for the case where * multiple apps are conflicting. * * @return {@code true} if this app is properly set up for authentication. */ public static boolean checkAppBeforeAuth(Context context, String appKey, boolean alertUser) { // Check if the app has set up its manifest properly. Intent testIntent = new Intent(Intent.ACTION_VIEW); String scheme = "db-" +appKey; String uri = scheme + "://" + AUTH_VERSION + AUTH_PATH_CONNECT; testIntent.setData(Uri.parse(uri)); PackageManager pm = context.getPackageManager(); List<ResolveInfo> activities = pm.queryIntentActivities(testIntent, 0); if (null == activities || 0 == activities.size()) { throw new IllegalStateException("URI scheme in your app's " + "manifest is not set up correctly. You should have a " + AuthActivity.class.getName() + " with the " + "scheme: " + scheme); } else if (activities.size() > 1) { if (alertUser) { AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle("Security alert"); builder.setMessage("Another app on your phone may be trying to " + "pose as the app you are currently using. The malicious " + "app can't access your account, but linking to Dropbox " + "has been disabled as a precaution. Please contact " + "support@dropbox.com."); builder.setPositiveButton("OK", new OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }); builder.show(); } else { Log.w(TAG, "There are multiple apps registered for the AuthActivity " + "URI scheme (" + scheme + "). Another app may be trying to " + " impersonate this app, so authentication will be disabled."); } return false; } else { // Just one activity registered for the URI scheme. Now make sure // it's within the same package so when we return from web auth // we're going back to this app and not some other app. ResolveInfo resolveInfo = activities.get(0); if (null == resolveInfo || null == resolveInfo.activityInfo || !context.getPackageName().equals(resolveInfo.activityInfo.packageName)) { throw new IllegalStateException("There must be a " + AuthActivity.class.getName() + " within your app's package " + "registered for your URI scheme (" + scheme + "). However, " + "it appears that an activity in a different package is " + "registered for that scheme instead. If you have " + "multiple apps that all want to use the same access" + "token pair, designate one of them to do " + "authentication and have the other apps launch it " + "and then retrieve the token pair from it."); } } return true; } /** * Sets the SecurityProvider interface to use for all AuthActivity instances. * If set to null (or never set at all), default {@code java.security} providers * will be used instead. * * <p> * You shouldn't need to use this method directly in your app. Instead, * simply configure {@code java.security}'s providers to match your preferences. * </p> * * @param prov the new {@code SecurityProvider} interface. */ public static void setSecurityProvider(SecurityProvider prov) { synchronized (sSecurityProviderLock) { sSecurityProvider = prov; } } private static SecurityProvider getSecurityProvider() { synchronized (sSecurityProviderLock) { return sSecurityProvider; } } private static SecureRandom getSecureRandom() { SecurityProvider prov = getSecurityProvider(); if (null != prov) { return prov.getSecureRandom(); } return new SecureRandom(); } @Override protected void onCreate(Bundle savedInstanceState) { if (savedInstanceState == null) { result = null; mAuthStateNonce = null; mAppKey = sAppKey; mAppSecret = sAppSecret; mWebHost = sWebHost; mApiType = sApiType; mDesiredUid = sDesiredUid; mAlreadyAuthedUids = sAlreadyAuthedUids; } else { mAuthStateNonce = savedInstanceState.getString(SIS_KEY_AUTH_STATE_NONCE); } setAuthParams(null, null, null, null); setTheme(android.R.style.Theme_Translucent_NoTitleBar); super.onCreate(savedInstanceState); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(SIS_KEY_AUTH_STATE_NONCE, mAuthStateNonce); } /** * @return Intent to auth with official app * Extras should be filled in by callee */ private static Intent getOfficialAuthIntent() { Intent authIntent = new Intent(ACTION_AUTHENTICATE_V2); authIntent.setPackage("com.dropbox.android"); return authIntent; } @Override protected void onResume() { super.onResume(); if (isFinishing()) { return; } if (mAuthStateNonce != null || mAppKey == null) { // We somehow returned to this activity without being forwarded // here by the official app. Most likely caused by improper setup, // but could have other reasons if Android is acting up and killing // activities used in our process. authFinished(null); return; } result = null; if (mActivityDispatchHandlerPosted) { Log.w(TAG, "onResume called again before Handler run"); return; } // Random entropy passed through auth makes sure we don't accept a // response which didn't come from our request. Each random // value is only ever used once. final String state = createStateNonce(); // Create intent to auth with official app. final Intent officialAuthIntent = getOfficialAuthIntent(); officialAuthIntent.putExtra(EXTRA_CONSUMER_KEY, mAppKey); officialAuthIntent.putExtra(EXTRA_CONSUMER_SIG, getConsumerSig()); officialAuthIntent.putExtra(EXTRA_DESIRED_UID, mDesiredUid); officialAuthIntent.putExtra(EXTRA_ALREADY_AUTHED_UIDS, mAlreadyAuthedUids); officialAuthIntent.putExtra(EXTRA_CALLING_PACKAGE, getPackageName()); officialAuthIntent.putExtra(EXTRA_CALLING_CLASS, getClass().getName()); officialAuthIntent.putExtra(EXTRA_AUTH_STATE, state); /* * An Android bug exists where onResume may be called twice in rapid succession. * As mAuthNonceState would already be set at start of the second onResume, auth would fail. * Empirical research has found that posting the remainder of the auth logic to a handler * mitigates the issue by delaying remainder of auth logic to after the * previously posted onResume. */ new Handler(Looper.getMainLooper()).post(new Runnable() { public void run() { Log.d(TAG, "running startActivity in handler"); try { // Auth with official app, or fall back to web. if (hasDropboxApp(officialAuthIntent)) { startActivity(officialAuthIntent); } else { startWebAuth(state); } } catch (ActivityNotFoundException e) { Log.e(TAG, "Could not launch intent. User may have restricted profile", e); finish(); return; } // Save state that indicates we started a request, only after // we started one successfully. mAuthStateNonce = state; } }); mActivityDispatchHandlerPosted = true; } @Override protected void onNewIntent(Intent intent) { // Reject attempt to finish authentication if we never started (nonce=null) if (null == mAuthStateNonce) { authFinished(null); return; } String token = null, secret = null, uid = null, state = null; if (intent.hasExtra(EXTRA_ACCESS_TOKEN)) { // Dropbox app auth. token = intent.getStringExtra(EXTRA_ACCESS_TOKEN); secret = intent.getStringExtra(EXTRA_ACCESS_SECRET); uid = intent.getStringExtra(EXTRA_UID); state = intent.getStringExtra(EXTRA_AUTH_STATE); } else { // Web auth. Uri uri = intent.getData(); if (uri != null) { String path = uri.getPath(); if (AUTH_PATH_CONNECT.equals(path)) { try { token = uri.getQueryParameter("oauth_token"); secret = uri.getQueryParameter("oauth_token_secret"); uid = uri.getQueryParameter("uid"); state = uri.getQueryParameter("state"); } catch (UnsupportedOperationException e) {} } } } Intent newResult; if (token != null && !token.equals("") && (secret == null || !secret.equals("")) && uid != null && !uid.equals("") && state != null && !state.equals("")) { // Reject attempt to link if the nonce in the auth state doesn't match, // or if we never asked for auth at all. if (!mAuthStateNonce.equals(state)) { authFinished(null); return; } // Successful auth. newResult = new Intent(); newResult.putExtra(EXTRA_ACCESS_TOKEN, token); newResult.putExtra(EXTRA_ACCESS_SECRET, secret); newResult.putExtra(EXTRA_UID, uid); } else { // Unsuccessful auth, or missing required parameters. newResult = null; } authFinished(newResult); } private void authFinished(Intent authResult) { result = authResult; mAuthStateNonce = null; finish(); } private String getConsumerSig() { if (mAppSecret == null) return ""; // We don't use an app secret for OAuth 2. MessageDigest m; try { m = MessageDigest.getInstance("SHA-1"); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } m.update(mAppSecret.getBytes(), 0, mAppSecret.length()); BigInteger i = new BigInteger(1, m.digest()); String s = String.format("%1$040X", i); return s.substring(32); } private boolean hasDropboxApp(Intent intent) { PackageManager manager = getPackageManager(); List<ResolveInfo> infos = manager.queryIntentActivities(intent, 0); if (null == infos || 1 != infos.size()) { // The official app doesn't exist, or only an older version // is available, or multiple activities are confusing us. return false; } else { // The official app exists. Make sure it's the correct one by // checking signing keys. ResolveInfo resolveInfo = manager.resolveActivity(intent, 0); if (resolveInfo == null) { return false; } final PackageInfo packageInfo; try { packageInfo = manager.getPackageInfo( resolveInfo.activityInfo.packageName, PackageManager.GET_SIGNATURES); } catch (NameNotFoundException e) { return false; } for (Signature signature : packageInfo.signatures) { for (String dbSignature : DROPBOX_APP_SIGNATURES) { if (dbSignature.equals(signature.toCharsString())) { return true; } } } } return false; } private void startWebAuth(String state) { String path = "/connect"; Locale locale = Locale.getDefault(); // Web Auth currently does not support desiredUid and only one alreadyAuthUid (param n). // We use first alreadyAuthUid arbitrarily. // Note that the API treats alreadyAuthUid of 0 and not present equivalently. String alreadyAuthedUid = (mAlreadyAuthedUids.length > 0) ? mAlreadyAuthedUids[0] : "0"; String[] params = { "locale", locale.getLanguage()+"_"+locale.getCountry(), "k", mAppKey, "n", alreadyAuthedUid, "s", getConsumerSig(), "api", mApiType, "state", state}; String url = RESTUtility.buildURL(mWebHost, DropboxAPI.VERSION, path, params); Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); startActivity(intent); } private static final String[] DROPBOX_APP_SIGNATURES = { "308202223082018b02044bd207bd300d06092a864886f70d01010405003058310b3" + "009060355040613025553310b300906035504081302434131163014060355040713" + "0d53616e204672616e636973636f3110300e060355040a130744726f70626f78311" + "2301006035504031309546f6d204d65796572301e170d3130303432333230343930" + "315a170d3430303431353230343930315a3058310b3009060355040613025553310" + "b3009060355040813024341311630140603550407130d53616e204672616e636973" + "636f3110300e060355040a130744726f70626f783112301006035504031309546f6" + "d204d6579657230819f300d06092a864886f70d010101050003818d003081890281" + "8100ac1595d0ab278a9577f0ca5a14144f96eccde75f5616f36172c562fab0e98c4" + "8ad7d64f1091c6cc11ce084a4313d522f899378d312e112a748827545146a779def" + "a7c31d8c00c2ed73135802f6952f59798579859e0214d4e9c0554b53b26032a4d2d" + "fc2f62540d776df2ea70e2a6152945fb53fef5bac5344251595b729d48102030100" + "01300d06092a864886f70d01010405000381810055c425d94d036153203dc0bbeb3" + "516f94563b102fff39c3d4ed91278db24fc4424a244c2e59f03bbfea59404512b8b" + "f74662f2a32e37eafa2ac904c31f99cfc21c9ff375c977c432d3b6ec22776f28767" + "d0f292144884538c3d5669b568e4254e4ed75d9054f75229ac9d4ccd0b7c3c74a34" + "f07b7657083b2aa76225c0c56ffc", "308201e53082014ea00302010202044e17e115300d06092a864886f70d010105050" + "03037310b30090603550406130255533110300e060355040a1307416e64726f6964" + "311630140603550403130d416e64726f6964204465627567301e170d31313037303" + "93035303331375a170d3431303730313035303331375a3037310b30090603550406" + "130255533110300e060355040a1307416e64726f6964311630140603550403130d4" + "16e64726f696420446562756730819f300d06092a864886f70d010101050003818d" + "003081890281810096759fe5abea6a0757039b92adc68d672efa84732c3f959408e" + "12efa264545c61f23141026a6d01eceeeaa13ec7087087e5894a3363da8bf5c69ed" + "93657a6890738a80998e4ca22dc94848f30e2d0e1890000ae2cddf543b20c0c3828" + "deca6c7944b5ecd21a9d18c988b2b3e54517dafbc34b48e801bb1321e0fa49e4d57" + "5d7f0203010001300d06092a864886f70d0101050500038181002b6d4b65bcfa6ec" + "7bac97ae6d878064d47b3f9f8da654995b8ef4c385bc4fbfbb7a987f60783ef0348" + "760c0708acd4b7e63f0235c35a4fbcd5ec41b3b4cb295feaa7d5c27fa562a02562b" + "7e1f4776b85147be3e295714986c4a9a07183f48ea09ae4d3ea31b88d0016c65b93" + "526b9c45f2967c3d28dee1aff5a5b29b9c2c8639"}; private String createStateNonce() { final int NONCE_BYTES = 16; // 128 bits of randomness. byte randomBytes[] = new byte[NONCE_BYTES]; getSecureRandom().nextBytes(randomBytes); StringBuilder sb = new StringBuilder(); if (mAppSecret == null) { sb.append("oauth2:"); } for (int i = 0; i < NONCE_BYTES; ++i) { sb.append(String.format("%02x", (randomBytes[i]&0xff))); } return sb.toString(); } }