Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a9f52972 authored by Fahim Salam Chowdhury's avatar Fahim Salam Chowdhury 👽
Browse files

feat: retrive userId from caldav/carddav pricipal url instead of

sanitizing userName

Typically nextcloud userId should be first part of it's email address.
It can be modified by the backend. So if the userId is changed, the
eDrive & Notes app will stop working properly.

On the other-hand, CalDav & CardDav principal url should contains the
userID in the end. So, we can retrieve the userId from there.

- Improve SssoGrantPermissionActivity to make it easier to maintain.

issue: https://gitlab.e.foundation/e/os/backlog/-/issues/1975
parent ca79e5ca
Loading
Loading
Loading
Loading
Loading
+0 −174
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2023
 * 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 <https://www.gnu.org/licenses/>.
 */

package com.owncloud.android.ui.activity;

import static com.nextcloud.android.sso.Constants.DELIMITER;
import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED;
import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND;
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_SERVER_URL;
import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE;
import static com.nextcloud.android.sso.Constants.SSO_TOKEN;
import static com.nextcloud.android.sso.Constants.SSO_USER_ID;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;

import com.nextcloud.android.utils.EncryptionUtils;
import com.owncloud.android.lib.common.OwnCloudAccount;
import com.owncloud.android.lib.common.accounts.AccountUtils;

import java.util.Arrays;
import java.util.UUID;
import java.util.logging.Level;

import at.bitfire.davdroid.R;
import at.bitfire.davdroid.log.Logger;
import at.bitfire.davdroid.util.SsoUtils;

public class SsoGrantPermissionActivity extends AppCompatActivity {

    private static final String[] ACCEPTED_PACKAGE_LIST = {"foundation.e.notes"};

    private Account account;
    private String packageName;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_sso_grant_permission);

        ComponentName callingActivity = getCallingActivity();

        if (callingActivity != null) {
            packageName = callingActivity.getPackageName();
            account = getIntent().getParcelableExtra(NEXTCLOUD_FILES_ACCOUNT);
            validateAndAutoGrandPermission();
        } else {
            Logger.INSTANCE.getLog().log(Level.SEVERE, "SsoGrantPermissionActivity: Calling Package is null");
            setResultAndExit(EXCEPTION_ACCOUNT_ACCESS_DECLINED);
        }
    }

    private void validateAndAutoGrandPermission() {
        if (!isValidRequest()) {
            Logger.INSTANCE.getLog().log(Level.SEVERE, "SsoGrantPermissionActivity: Invalid request");
            setResultAndExit(EXCEPTION_ACCOUNT_ACCESS_DECLINED);
            return;
        }

        grantPermission();
    }

    private boolean isValidRequest() {
        if (packageName == null || account == null) {
            return false;
        }

        boolean validPackage = Arrays.asList(ACCEPTED_PACKAGE_LIST)
                .contains(packageName);

        if (!validPackage) {
            return false;
        }

        return Arrays.asList(getAcceptedAccountTypeList())
                .contains(account.type);
    }

    private String[] getAcceptedAccountTypeList() {
        return new String[]{
                getString(R.string.eelo_account_type)
        };
    }

    private void grantPermission() {
        String serverUrl = getServerUrl();

        if (serverUrl == null) {
            return;
        }

        // create token
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        String userId = getUserId();

        saveToken(token, account.name);
        setResultData(token, userId, serverUrl);
        finish();
    }

    @NonNull
    private String getUserId() {
        final AccountManager accountManager = AccountManager.get(this);
        final String baseUrl = accountManager.getUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL);
        return SsoUtils.INSTANCE.sanitizeUserId(account.name, baseUrl);
    }

    private void setResultData(String token, String userId, String serverUrl) {
        final Bundle result = new Bundle();
        result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
        result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type);
        result.putString(AccountManager.KEY_AUTHTOKEN, NEXTCLOUD_SSO);
        result.putString(SSO_USER_ID, userId);
        result.putString(SSO_TOKEN, token);
        result.putString(SSO_SERVER_URL, serverUrl);

        Intent data = new Intent();
        data.putExtra(NEXTCLOUD_SSO, result);
        setResult(RESULT_OK, data);
    }

    @Nullable
    private String getServerUrl() {
        try {
            OwnCloudAccount ocAccount = new OwnCloudAccount(account, this);
            return ocAccount.getBaseUri().toString();
        } catch (AccountUtils.AccountNotFoundException e) {
            Logger.INSTANCE.getLog().log(Level.SEVERE, "SsoGrantPermissionActivity: Account not found");
            setResultAndExit(EXCEPTION_ACCOUNT_NOT_FOUND);
        }

        return null;
    }

    private void saveToken(String token, String accountName) {
        String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token);
        SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = sharedPreferences.edit();
        editor.putString(packageName + DELIMITER + accountName, hashedTokenWithSalt);
        editor.apply();
    }

    private void setResultAndExit(String exception) {
        Intent data = new Intent();
        data.putExtra(NEXTCLOUD_SSO_EXCEPTION, exception);
        setResult(RESULT_CANCELED, data);
        finish();
    }
}
 No newline at end of file
+80 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2024
 * 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 <https://www.gnu.org/licenses/>.
 */

package com.owncloud.android.ui.activity

import android.accounts.Account
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import at.bitfire.davdroid.R
import com.nextcloud.android.sso.Constants
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

@AndroidEntryPoint
class SsoGrantPermissionActivity: AppCompatActivity() {

    private val viewModel: SsoGrantPermissionViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sso_grant_permission)

        lifecycleScope.launch {
            viewModel.event.collectLatest {
                when (it) {
                    is SsoGrantPermissionEvent.PermissionGranted -> setSuccessResult(it.bundle)
                    is SsoGrantPermissionEvent.PermissionDenied -> setCanceledResult(it.errorMessage)
                }
            }
        }

        initiateValidation()
    }

    private fun initiateValidation() {
        val account: Account? =
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT, Account::class.java)
            } else {
                intent.getParcelableExtra(Constants.NEXTCLOUD_FILES_ACCOUNT)
            }

        viewModel.initValidation(
            callingActivity = callingActivity,
            account = account
        )
    }

    private fun setCanceledResult(exception: String) {
        val data = Intent()
        data.putExtra(Constants.NEXTCLOUD_SSO_EXCEPTION, exception)
        setResult(RESULT_CANCELED, data)
        finish()
    }

    private fun setSuccessResult(result: Bundle) {
        val data = Intent()
        data.putExtra(Constants.NEXTCLOUD_SSO, result)
        setResult(RESULT_OK, data)
        finish()
    }
}
+26 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2024
 * 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 <https://www.gnu.org/licenses/>.
 */

package com.owncloud.android.ui.activity

import android.os.Bundle

sealed class SsoGrantPermissionEvent {

    data class PermissionGranted(val bundle: Bundle) : SsoGrantPermissionEvent()

    data class PermissionDenied(val errorMessage: String) : SsoGrantPermissionEvent()
}
+174 −0
Original line number Diff line number Diff line
/*
 * Copyright MURENA SAS 2024
 * 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 <https://www.gnu.org/licenses/>.
 */

package com.owncloud.android.ui.activity

import android.accounts.Account
import android.accounts.AccountManager
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger.log
import at.bitfire.davdroid.util.UserIdFetcher
import com.nextcloud.android.sso.Constants
import com.nextcloud.android.utils.EncryptionUtils
import com.owncloud.android.lib.common.OwnCloudAccount
import com.owncloud.android.lib.common.accounts.AccountUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.logging.Level
import javax.inject.Inject

@HiltViewModel
class SsoGrantPermissionViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val database: AppDatabase,
): ViewModel() {

    private val acceptedAccountTypes = listOf(context.getString(R.string.eelo_account_type))
    private val acceptedPackages = listOf("foundation.e.notes")

    private val _event = MutableSharedFlow<SsoGrantPermissionEvent>()
    val event = _event.asSharedFlow()

    fun initValidation(callingActivity: ComponentName?, account: Account?) {
        viewModelScope.launch(Dispatchers.IO) {
            val packageName = getCallingPackageName(callingActivity) ?: return@launch
            validate(packageName, account)
        }
    }

    private suspend fun getCallingPackageName(callingActivity: ComponentName?): String? {
        if (callingActivity == null) {
            log.log(Level.SEVERE, "SsoGrantPermissionViewModel: Calling Package is null")
            _event.emit(
                SsoGrantPermissionEvent.PermissionDenied(
                    errorMessage = Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED
                )
            )
            return null
        }

        return callingActivity.packageName
    }

    private suspend fun validate(packageName: String?, account: Account?) {
        if (!isValidRequest(packageName, account)) {
            log.log(Level.SEVERE, "SsoGrantPermissionViewModel: Invalid request")
            _event.emit(
                SsoGrantPermissionEvent.PermissionDenied(
                    errorMessage = Constants.EXCEPTION_ACCOUNT_ACCESS_DECLINED
                )
            )
        }

        val serverUrl = getServerUrl(account!!) ?: return

        val token = UUID.randomUUID().toString().replace("-".toRegex(), "")
        val userId = getUserId(account)

        saveToken(
            token = token,
            accountName = userId,
            packageName = packageName!!
        )

        passSuccessfulData(
            account = account,
            token = token,
            userId = userId,
            serverUrl = serverUrl
        )

    }

    private fun isValidRequest(packageName: String?, account: Account?): Boolean {
        if (packageName == null || account == null) {
            return false
        }

        return acceptedPackages.contains(packageName) && acceptedAccountTypes.contains(account.type)
    }

    private suspend fun getServerUrl(account: Account): String? {
        try {
            val ocAccount = OwnCloudAccount(account, context)
            return ocAccount.baseUri.toString()
        } catch (e: AccountUtils.AccountNotFoundException) {
            log.log(Level.SEVERE, "SsoGrantPermissionViewModel: Account not found")
            _event.emit(
                SsoGrantPermissionEvent.PermissionDenied(
                    errorMessage = Constants.EXCEPTION_ACCOUNT_NOT_FOUND
                )
            )
        }
        return null
    }

    private fun getUserId(account: Account): String {
        val accountManager = AccountManager.get(context)
        val userId = accountManager.getUserData(account, AccountUtils.Constants.KEY_USER_ID)

        if (!userId.isNullOrBlank()) {
            return userId
        }

        val principalUrl =
            database.serviceDao().getByAccountName(account.name)?.principal?.toString()
                ?: return account.name

        return UserIdFetcher.retrieveUserId(principalUrl) ?: account.name
    }

    private fun saveToken(token: String, accountName: String, packageName: String) {
        val hashedTokenWithSalt = EncryptionUtils.generateSHA512(token)
        val sharedPreferences =
            context.getSharedPreferences(Constants.SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE)
        val editor = sharedPreferences.edit()
        editor.putString(packageName + Constants.DELIMITER + accountName, hashedTokenWithSalt)
        editor.apply()
    }

    private suspend fun passSuccessfulData(
        account: Account,
        token: String,
        userId: String,
        serverUrl: String
    ) {
        val result = Bundle()
        result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
        result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
        result.putString(AccountManager.KEY_AUTHTOKEN, Constants.NEXTCLOUD_SSO)
        result.putString(Constants.SSO_USER_ID, userId)
        result.putString(Constants.SSO_TOKEN, token)
        result.putString(Constants.SSO_SERVER_URL, serverUrl)

        _event.emit(
            SsoGrantPermissionEvent.PermissionGranted(
                bundle = result
            )
        )
    }
}
+6 −5
Original line number Diff line number Diff line
@@ -19,7 +19,7 @@ import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.util.SsoUtils
import at.bitfire.davdroid.util.UserIdFetcher
import at.bitfire.davdroid.util.setAndVerifyUserData
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
@@ -163,15 +163,16 @@ class AccountSettings(
                bundle.putString(NCAccountUtils.Constants.KEY_OC_BASE_URL, baseUrl)
            }

            addUserIdToBundle(bundle, userName, baseUrl)
            addUserIdToBundle(bundle, userName, url)
            addEmailToBundle(bundle, email, userName)

            return bundle
        }

        private fun addUserIdToBundle(bundle: Bundle, userName: String?, baseUrl: String?) {
            userName?.let {
                val userId = SsoUtils.sanitizeUserId(it, baseUrl)
        private fun addUserIdToBundle(bundle: Bundle, userName: String?, url: String?) {
            val userId = UserIdFetcher.retrieveUserId(url) ?: userName

            userId?.let {
                bundle.putString(NCAccountUtils.Constants.KEY_USER_ID, userId)
            }
        }
Loading