/* * Nextcloud SingleSignOn * * @author David Luhmer * * 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, either version 3 of the License, or * (at your option) any later version. * * 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 <http://www.gnu.org/licenses/>. */ package com.nextcloud.android.sso; import android.Manifest; import android.accounts.Account; import android.accounts.AccountManager; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; import android.util.Log; import android.widget.Toast; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.AndroidGetAccountsPermissionNotGranted; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountPermissionNotGrantedException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotInstalledException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppNotSupportedException; import com.nextcloud.android.sso.exceptions.SSOException; import com.nextcloud.android.sso.model.SingleSignOnAccount; import com.nextcloud.android.sso.ui.UiExceptionManager; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import io.reactivex.annotations.NonNull; import static android.app.Activity.RESULT_CANCELED; import static android.app.Activity.RESULT_OK; import static com.nextcloud.android.sso.Constants.NEXTCLOUD_FILES_ACCOUNT; import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO; import static com.nextcloud.android.sso.Constants.NEXTCLOUD_SSO_EXCEPTION; import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; public class AccountImporter { private static final String TAG = AccountImporter.class.getCanonicalName(); private static final String PREF_ACCOUNT_STRING = "PREF_ACCOUNT_STRING"; public static final int CHOOSE_ACCOUNT_SSO = 4242; public static final int REQUEST_AUTH_TOKEN_SSO = 4243; public static final int REQUEST_GET_ACCOUNTS_PERMISSION = 4244; private static SharedPreferences SHARED_PREFERENCES; private static final List<String> APPS = Arrays.asList(Constants.PACKAGE_NAME_PROD, Constants.PACKAGE_NAME_DEV); private static final String[] ACCOUNT_TYPES = {Constants.ACCOUNT_TYPE_PROD, Constants.ACCOUNT_TYPE_DEV}; public static boolean accountsToImportAvailable(Context context) { return findAccounts(context).size() > 0; } public static void pickNewAccount(Activity activity) throws NextcloudFilesAppNotInstalledException, AndroidGetAccountsPermissionNotGranted { checkAndroidAccountPermissions(activity); if (appInstalledOrNot(activity)) { Intent intent = AccountManager.newChooseAccountIntent(null, null, ACCOUNT_TYPES, true, null, null, null, null); activity.startActivityForResult(intent, CHOOSE_ACCOUNT_SSO); } else { throw new NextcloudFilesAppNotInstalledException(); } } public static void pickNewAccount(Fragment fragment) throws NextcloudFilesAppNotInstalledException, AndroidGetAccountsPermissionNotGranted { checkAndroidAccountPermissions(fragment.getContext()); if (appInstalledOrNot(fragment.getContext())) { Intent intent = AccountManager.newChooseAccountIntent(null, null, ACCOUNT_TYPES, true, null, null, null, null); fragment.startActivityForResult(intent, CHOOSE_ACCOUNT_SSO); } else { throw new NextcloudFilesAppNotInstalledException(); } } public static void requestAndroidAccountPermissionsAndPickAccount(Activity activity) { ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.GET_ACCOUNTS}, REQUEST_GET_ACCOUNTS_PERMISSION); } private static void checkAndroidAccountPermissions(Context context) throws AndroidGetAccountsPermissionNotGranted { // https://developer.android.com/reference/android/accounts/AccountManager#getAccountsByType(java.lang.String) // Caller targeting API level below Build.VERSION_CODES.O that have not been granted the // Manifest.permission.GET_ACCOUNTS permission, will only see those accounts managed by // AbstractAccountAuthenticators whose signature matches the client. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Do something for lollipop and above versions if (ContextCompat.checkSelfPermission(context, Manifest.permission.GET_ACCOUNTS) != PackageManager.PERMISSION_GRANTED) { Log.e(TAG, "Permission not granted yet!"); throw new AndroidGetAccountsPermissionNotGranted(); } else { Log.d(TAG, "Permission granted!"); } } } private static boolean appInstalledOrNot(Context context) { boolean returnValue = false; PackageManager pm = context.getPackageManager(); for (String app : APPS) { try { pm.getPackageInfo(app, PackageManager.GET_ACTIVITIES); returnValue = true; break; } catch (PackageManager.NameNotFoundException e) { Log.v(TAG, e.getMessage()); } } return returnValue; } // Find all currently installed nextcloud accounts on the phone public static List<Account> findAccounts(final Context context) { final AccountManager accMgr = AccountManager.get(context); final Account[] accounts = accMgr.getAccounts(); List<Account> accountsAvailable = new ArrayList<>(); for (final Account account : accounts) { for (String accountType : ACCOUNT_TYPES) { if (accountType.equals(account.type)) { accountsAvailable.add(account); } } } return accountsAvailable; } public static Account getAccountForName(Context context, String name) { for (Account account : findAccounts(context)) { if (account.name.equals(name)) { return account; } } return null; } public static void clearAllAuthTokens(Context context) { SharedPreferences mPrefs = getSharedPreferences(context); for (String key : mPrefs.getAll().keySet()) { if (key.startsWith(PREF_ACCOUNT_STRING)) { mPrefs.edit().remove(key).apply(); } } } public static SingleSignOnAccount getSingleSignOnAccount(Context context, final String accountName) throws NextcloudFilesAppAccountNotFoundException { SharedPreferences mPrefs = getSharedPreferences(context); String prefKey = getPrefKeyForAccount(accountName); if (mPrefs.contains(prefKey)) { try { return SingleSignOnAccount.fromString(mPrefs.getString(prefKey, null)); } catch (ClassNotFoundException | IOException e) { Log.e(TAG, "[getSingleSignOnAccount]", e); } } throw new NextcloudFilesAppAccountNotFoundException(); } public static SingleSignOnAccount extractSingleSignOnAccountFromResponse(Intent intent, Context context) { Bundle future = intent.getBundleExtra(NEXTCLOUD_SSO); String accountName = future.getString(AccountManager.KEY_ACCOUNT_NAME); String userId = future.getString(Constants.SSO_USER_ID); if (userId == null) { // backwards compatibility userId = future.getString("username"); } String token = future.getString(Constants.SSO_TOKEN); String serverUrl = future.getString(Constants.SSO_SERVER_URL); String type = future.getString("accountType"); SharedPreferences mPrefs = getSharedPreferences(context); String prefKey = getPrefKeyForAccount(accountName); SingleSignOnAccount ssoAccount = new SingleSignOnAccount(accountName, userId, token, serverUrl, type); try { mPrefs.edit().putString(prefKey, SingleSignOnAccount.toString(ssoAccount)).apply(); } catch (IOException e) { Log.e(TAG, "SSO failed", e); } return ssoAccount; } public interface IAccountAccessGranted { void accountAccessGranted(SingleSignOnAccount singleSignOnAccount); } public static void onActivityResult(int requestCode, int resultCode, Intent data, Activity activity, IAccountAccessGranted callback) throws AccountImportCancelledException { onActivityResult(requestCode, resultCode, data, activity, null, callback); } public static void onActivityResult(int requestCode, int resultCode, Intent data, Fragment fragment, IAccountAccessGranted callback) throws AccountImportCancelledException { onActivityResult(requestCode, resultCode, data, null, fragment, callback); } private static void onActivityResult(int requestCode, int resultCode, Intent data, Activity activity, Fragment fragment, IAccountAccessGranted callback) throws AccountImportCancelledException { Context context = (activity != null) ? activity : fragment.getContext(); if (resultCode == RESULT_OK) { switch (requestCode) { case CHOOSE_ACCOUNT_SSO: try { if (activity != null) { requestAuthToken(activity, data); } else { requestAuthToken(fragment, data); } } catch (NextcloudFilesAppNotSupportedException | NextcloudFilesAppAccountPermissionNotGrantedException e) { UiExceptionManager.showDialogForException(context, e); } break; case REQUEST_AUTH_TOKEN_SSO: SingleSignOnAccount singleSignOnAccount = extractSingleSignOnAccountFromResponse(data, context); callback.accountAccessGranted(singleSignOnAccount); break; case REQUEST_GET_ACCOUNTS_PERMISSION: try { if(activity != null) { pickNewAccount(activity); } else { pickNewAccount(fragment); } } catch (NextcloudFilesAppNotInstalledException | AndroidGetAccountsPermissionNotGranted e) { UiExceptionManager.showDialogForException(context, e); } break; default: break; } } else if (resultCode == RESULT_CANCELED) { switch (requestCode) { case CHOOSE_ACCOUNT_SSO: // nothing to do here throw new AccountImportCancelledException(); case REQUEST_AUTH_TOKEN_SSO: try { handleFailedAuthRequest(data); } catch (SSOException e) { UiExceptionManager.showDialogForException(context, e); } catch (Exception e) { Toast.makeText(context, e.getMessage(), Toast.LENGTH_LONG).show(); //e.printStackTrace(); Log.e(TAG, e.getMessage()); } break; case REQUEST_GET_ACCOUNTS_PERMISSION: UiExceptionManager.showDialogForException(context, new AndroidGetAccountsPermissionNotGranted()); break; default: break; } } } public static void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults, Activity activity) { onRequestPermissionsResult(requestCode, permissions, grantResults, activity, null); } public static void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults, Fragment fragment) { onRequestPermissionsResult(requestCode, permissions, grantResults, null, fragment); } private static void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults, Activity activity, Fragment fragment) { Context context = (activity != null) ? activity : fragment.getContext(); switch (requestCode) { case REQUEST_GET_ACCOUNTS_PERMISSION: if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // Permissions have been granted.. start pick account dialog try { if (activity != null) { pickNewAccount(activity); } else { pickNewAccount(fragment); } } catch (NextcloudFilesAppNotInstalledException | AndroidGetAccountsPermissionNotGranted e) { UiExceptionManager.showDialogForException(context, e); } } else { // user declined the permission request.. UiExceptionManager.showDialogForException(context, new AndroidGetAccountsPermissionNotGranted()); } break; default: break; } } public static void handleFailedAuthRequest(Intent data) throws SSOException { String exception = data.getStringExtra(NEXTCLOUD_SSO_EXCEPTION); throw SSOException.parseNextcloudCustomException(new Exception(exception)); } public static void authenticateSingleSignAccount(Fragment fragment, SingleSignOnAccount account) throws NextcloudFilesAppNotSupportedException, NextcloudFilesAppAccountPermissionNotGrantedException { Intent intent = new Intent(); intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name); requestAuthToken(fragment, intent); } public static void authenticateSingleSignAccount(Activity activity, SingleSignOnAccount account) throws NextcloudFilesAppNotSupportedException, NextcloudFilesAppAccountPermissionNotGrantedException { Intent intent = new Intent(); intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name); requestAuthToken(activity, intent); } public static void requestAuthToken(Fragment fragment, Intent intent) throws NextcloudFilesAppNotSupportedException, NextcloudFilesAppAccountPermissionNotGrantedException { Intent authIntent = buildRequestAuthTokenIntent(fragment.getContext(), intent); try { fragment.startActivityForResult(authIntent, REQUEST_AUTH_TOKEN_SSO); } catch (ActivityNotFoundException e) { throw new NextcloudFilesAppNotSupportedException(); } } public static void requestAuthToken(Activity activity, Intent intent) throws NextcloudFilesAppNotSupportedException, NextcloudFilesAppAccountPermissionNotGrantedException { Intent authIntent = buildRequestAuthTokenIntent(activity, intent); try { activity.startActivityForResult(authIntent, REQUEST_AUTH_TOKEN_SSO); } catch (ActivityNotFoundException e) { throw new NextcloudFilesAppNotSupportedException(); } } private static Intent buildRequestAuthTokenIntent(Context context, Intent intent) throws NextcloudFilesAppAccountPermissionNotGrantedException { String accountName = intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); Account account = AccountImporter.getAccountForName(context, accountName); if(account == null) { throw new NextcloudFilesAppAccountPermissionNotGrantedException(); } String componentName; if (account.type.equalsIgnoreCase(Constants.ACCOUNT_TYPE_DEV)) { componentName = Constants.PACKAGE_NAME_DEV; } else { componentName = Constants.PACKAGE_NAME_PROD; } Intent authIntent = new Intent(); authIntent.setComponent(new ComponentName(componentName, "com.owncloud.android.ui.activity.SsoGrantPermissionActivity")); authIntent.putExtra(NEXTCLOUD_FILES_ACCOUNT, account); return authIntent; } public static SharedPreferences getSharedPreferences(Context context) { if(SHARED_PREFERENCES != null) { return SHARED_PREFERENCES; } else { return context.getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); } } protected static String getPrefKeyForAccount(String accountName) { return PREF_ACCOUNT_STRING + accountName; } /** * Allows developers to set the shared preferences that the account information should be stored in. * This is helpful when writing unit tests */ public static void setSharedPreferences(SharedPreferences sharedPreferences) { AccountImporter.SHARED_PREFERENCES = sharedPreferences; } }