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

Commit c204e2f1 authored by stefan-niedermann's avatar stefan-niedermann
Browse files

Merge branch 'weblogin'

parents 35061253 63b36030
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -26,6 +26,9 @@ android {
        disable 'MissingTranslation'
        abortOnError false
    }
    dataBinding {
        enabled = true
    }
}

dependencies {
+6 −0
Original line number Diff line number Diff line
@@ -88,4 +88,10 @@ public class AboutActivity extends AppCompatActivity {
            }
        }
    }

    @Override
    public boolean onSupportNavigateUp() {
        finish(); // close this activity as oppose to navigating up
        return true;
    }
}
 No newline at end of file
+278 −25
Original line number Diff line number Diff line
@@ -3,25 +3,46 @@ package it.niedermann.owncloud.notes.android.activity;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.net.http.SslCertificate;
import android.net.http.SslError;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.webkit.SslErrorHandler;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;

import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.textfield.TextInputLayout;

import java.io.ByteArrayInputStream;
import java.net.URLDecoder;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

import at.bitfire.cert4android.CustomCertManager;
import at.bitfire.cert4android.IOnCertificateDecision;
import butterknife.BindView;
import butterknife.ButterKnife;
import it.niedermann.owncloud.notes.R;
@@ -45,10 +66,15 @@ public class SettingsActivity extends AppCompatActivity {
    public static final String DEFAULT_SETTINGS = "";
    public static final int CREDENTIALS_CHANGED = 3;

    public static final String LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":";
    public static final String WEBDAV_PATH_4_0_AND_LATER = "/remote.php/webdav";

    private SharedPreferences preferences = null;

    @BindView(R.id.settings_url)
    EditText field_url;
    @BindView(R.id.settings_username_wrapper)
    TextInputLayout username_wrapper;
    @BindView(R.id.settings_username)
    EditText field_username;
    @BindView(R.id.settings_password)
@@ -61,7 +87,10 @@ public class SettingsActivity extends AppCompatActivity {
    View urlWarnHttp;
    private String old_password = "";

    private WebView webView;

    private boolean first_run = false;
    private boolean useWebLogin = true;

    @Override
    public void onCreate(Bundle savedInstanceState) {
@@ -80,10 +109,38 @@ public class SettingsActivity extends AppCompatActivity {
            }
        }

        field_url.setOnFocusChangeListener((View v, boolean hasFocus) -> {
            new URLValidatorAsyncTask().execute(NotesClientUtil.formatURL(field_url.getText().toString()));
        setupListener();

        // Load current Preferences
        field_url.setText(preferences.getString(SETTINGS_URL, DEFAULT_SETTINGS));
        field_username.setText(preferences.getString(SETTINGS_USERNAME, DEFAULT_SETTINGS));
        old_password = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS);

        field_password.setOnEditorActionListener(new TextView.OnEditorActionListener() {
            @Override
            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
                login();
                return true;
            }
        });
        field_password.setOnFocusChangeListener(new View.OnFocusChangeListener() {
            @Override
            public void onFocusChange(View v, boolean hasFocus) {
                setPasswordHint(hasFocus);
            }
        });
        setPasswordHint(false);

        handleSubmitButtonEnabled();
    }

    private void setupListener() {
        field_url.setOnFocusChangeListener(new View.OnFocusChangeListener() {
            @Override
            public void onFocusChange(View v, boolean hasFocus) {
                new URLValidatorAsyncTask().execute(NotesClientUtil.formatURL(field_url.getText().toString()));
            }
        });
        field_url.addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
@@ -99,7 +156,7 @@ public class SettingsActivity extends AppCompatActivity {
                    urlWarnHttp.setVisibility(View.GONE);
                }

                handleSubmitButtonEnabled(field_url.getText(), field_username.getText());
                handleSubmitButtonEnabled();
            }

            @Override
@@ -115,7 +172,7 @@ public class SettingsActivity extends AppCompatActivity {

            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                handleSubmitButtonEnabled(field_url.getText(), field_username.getText());
                handleSubmitButtonEnabled();
            }

            @Override
@@ -124,23 +181,11 @@ public class SettingsActivity extends AppCompatActivity {
            }
        });

        // Load current Preferences
        field_url.setText(preferences.getString(SETTINGS_URL, DEFAULT_SETTINGS));
        field_username.setText(preferences.getString(SETTINGS_USERNAME, DEFAULT_SETTINGS));
        old_password = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS);

        field_password.setOnEditorActionListener((TextView v, int actionId, KeyEvent event) -> {
            login();
            return true;
        });
        field_password.setOnFocusChangeListener((View v, boolean hasFocus) -> {
            setPasswordHint(hasFocus);
        });
        setPasswordHint(false);

        btn_submit.setEnabled(false);
        btn_submit.setOnClickListener((View v) -> {
        btn_submit.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                login();
            }
        });
    }

@@ -171,7 +216,7 @@ public class SettingsActivity extends AppCompatActivity {
        }
    }

    private void login() {
    private void legacyLogin() {
        String url = field_url.getText().toString().trim();
        String username = field_username.getText().toString();
        String password = field_password.getText().toString();
@@ -185,8 +230,205 @@ public class SettingsActivity extends AppCompatActivity {
        new LoginValidatorAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, url, username, password);
    }

    private void handleSubmitButtonEnabled(Editable url, Editable username) {
        if (field_username.getText().length() > 0 && field_url.getText().length() > 0) {
    private void login() {
        if (useWebLogin) {
            webLogin();
        } else {
            legacyLogin();
        }
    }

    /**
     * Obtain the X509Certificate from SslError
     *
     * @param error SslError
     * @return X509Certificate from error
     */
    public static X509Certificate getX509CertificateFromError(SslError error) {
        Bundle bundle = SslCertificate.saveState(error.getCertificate());
        X509Certificate x509Certificate;
        byte[] bytes = bundle.getByteArray("x509-certificate");
        if (bytes == null) {
            x509Certificate = null;
        } else {
            try {
                CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
                Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(bytes));
                x509Certificate = (X509Certificate) cert;
            } catch (CertificateException e) {
                x509Certificate = null;
            }
        }
        return x509Certificate;
    }

    private void webLogin() {
        setContentView(R.layout.activity_settings_webview);
        webView = findViewById(R.id.login_webview);
        webView.setVisibility(View.GONE);

        final ProgressBar progressBar = findViewById(R.id.login_webview_progress_bar);

        WebSettings settings = webView.getSettings();
        settings.setAllowFileAccess(false);
        settings.setJavaScriptEnabled(true);
        settings.setDomStorageEnabled(true);
        settings.setUserAgentString(getWebLoginUserAgent());
        settings.setSaveFormData(false);
        settings.setSavePassword(false);

        Map<String, String> headers = new HashMap<>();
        headers.put("OCS-APIREQUEST", "true");


        webView.loadUrl(normalizeUrlSuffix(NotesClientUtil.formatURL(field_url.getText().toString())) + "index.php/login/flow", headers);

        webView.setWebViewClient(new WebViewClient() {
            @Override
            public boolean shouldOverrideUrlLoading(WebView view, String url) {
                if (url.startsWith("nc://login/")) {
                    parseAndLoginFromWebView(url);
                    return true;
                }
                return false;
            }

            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);

                progressBar.setVisibility(View.GONE);
                webView.setVisibility(View.VISIBLE);
            }

            @Override
            public void onReceivedSslError(WebView view, final SslErrorHandler handler, SslError error) {
                X509Certificate cert = getX509CertificateFromError(error);

                try {
                    final boolean[] accepted = new boolean[1];
                    NoteServerSyncHelper.getInstance(NoteSQLiteOpenHelper.getInstance(getApplicationContext()))
                            .checkCertificate(cert.getEncoded(), true, new IOnCertificateDecision.Stub() {
                                @Override
                                public void accept() {
                                    Log.d("Note", "cert accepted");
                                    handler.proceed();
                                    accepted[0] = true;
                                }

                                @Override
                                public void reject() {
                                    Log.d("Note", "cert rejected");
                                    handler.cancel();
                                }
                            });
                } catch (Exception e) {
                    Log.e("Note", "Cert could not be verified");
                    handler.proceed();
                }
            }

        });

        // show snackbar after 60s to switch back to old login method
        new Handler().postDelayed(() -> {
            Snackbar.make(webView, R.string.fallback_weblogin_text, Snackbar.LENGTH_INDEFINITE)
                .setAction(R.string.fallback_weblogin_back, (View.OnClickListener) v -> initLegacyLogin(field_url.getText().toString())).show();
        }, 45 * 1000);
    }

    private String getWebLoginUserAgent() {
        return Build.MANUFACTURER.substring(0, 1).toUpperCase(Locale.getDefault()) +
                Build.MANUFACTURER.substring(1).toLowerCase(Locale.getDefault()) + " " + Build.MODEL;
    }

    private void parseAndLoginFromWebView(String dataString) {
        String prefix = "nc://login/";
        LoginUrlInfo loginUrlInfo = parseLoginDataUrl(prefix, dataString);

        if (loginUrlInfo != null) {
            String url = normalizeUrlSuffix(loginUrlInfo.serverAddress);

            new LoginValidatorAsyncTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, url, loginUrlInfo.username,
                    loginUrlInfo.password);
        }
    }

    /**
     * parses a URI string and returns a login data object with the information from the URI string.
     *
     * @param prefix     URI beginning, e.g. cloud://login/
     * @param dataString the complete URI
     * @return login data
     * @throws IllegalArgumentException when
     */
    private LoginUrlInfo parseLoginDataUrl(String prefix, String dataString) throws IllegalArgumentException {
        if (dataString.length() < prefix.length()) {
            throw new IllegalArgumentException("Invalid login URL detected");
        }
        LoginUrlInfo loginUrlInfo = new LoginUrlInfo();

        // format is basically xxx://login/server:xxx&user:xxx&password while all variables are optional
        String data = dataString.substring(prefix.length());

        // parse data
        String[] values = data.split("&");

        if (values.length < 1 || values.length > 3) {
            // error illegal number of URL elements detected
            throw new IllegalArgumentException("Illegal number of login URL elements detected: " + values.length);
        }

        for (String value : values) {
            if (value.startsWith("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
                loginUrlInfo.username = URLDecoder.decode(
                        value.substring(("user" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()));
            } else if (value.startsWith("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
                loginUrlInfo.password = URLDecoder.decode(
                        value.substring(("password" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()));
            } else if (value.startsWith("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR)) {
                loginUrlInfo.serverAddress = URLDecoder.decode(
                        value.substring(("server" + LOGIN_URL_DATA_KEY_VALUE_SEPARATOR).length()));
            } else {
                // error illegal URL element detected
                throw new IllegalArgumentException("Illegal magic login URL element detected: " + value);
            }
        }

        return loginUrlInfo;
    }

    private String normalizeUrlSuffix(String url) {
        if (url.toLowerCase(Locale.ROOT).endsWith(WEBDAV_PATH_4_0_AND_LATER)) {
            return url.substring(0, url.length() - WEBDAV_PATH_4_0_AND_LATER.length());
        }

        if (!url.endsWith("/")) {
            return url + "/";
        }

        return url;
    }

    private void initLegacyLogin(String oldUrl) {
        useWebLogin = false;
        new URLValidatorAsyncTask().execute(NotesClientUtil.formatURL(field_url.getText().toString()));

        webView.setVisibility(View.INVISIBLE);
        setContentView(R.layout.activity_settings);

        ButterKnife.bind(this);
        setupListener();

        field_url.setText(oldUrl);
        username_wrapper.setVisibility(View.VISIBLE);
        password_wrapper.setVisibility(View.VISIBLE);
    }

    private void handleSubmitButtonEnabled() {
        // drawable[2] is not null if url is valid, see URLValidatorAsyncTask::onPostExecute
        if (useWebLogin || field_url.getCompoundDrawables()[2] != null && (username_wrapper.getVisibility() == View.GONE ||
                (username_wrapper.getVisibility() == View.VISIBLE && field_username.getText().length() > 0))) {
            btn_submit.setEnabled(true);
        } else {
            btn_submit.setEnabled(false);
@@ -203,6 +445,7 @@ public class SettingsActivity extends AppCompatActivity {

        @Override
        protected void onPreExecute() {
            btn_submit.setEnabled(false);
            field_url.setCompoundDrawables(null, null, null, null);
        }

@@ -221,6 +464,7 @@ public class SettingsActivity extends AppCompatActivity {
            } else {
                field_url.setCompoundDrawables(null, null, null, null);
            }
            handleSubmitButtonEnabled();
        }
    }

@@ -284,4 +528,13 @@ public class SettingsActivity extends AppCompatActivity {
            field_password.setEnabled(enabled);
        }
    }

    /**
     * Data object holding the login url fields.
     */
    public class LoginUrlInfo {
        String serverAddress;
        String username;
        String password;
    }
}
+10 −0
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@ import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.AsyncTask;
import android.os.IBinder;
import android.os.RemoteException;
import android.preference.PreferenceManager;
import android.util.Log;
import android.widget.Toast;
@@ -27,6 +28,8 @@ import java.util.Set;

import at.bitfire.cert4android.CustomCertManager;
import at.bitfire.cert4android.CustomCertService;
import at.bitfire.cert4android.ICustomCertService;
import at.bitfire.cert4android.IOnCertificateDecision;
import it.niedermann.owncloud.notes.R;
import it.niedermann.owncloud.notes.android.activity.SettingsActivity;
import it.niedermann.owncloud.notes.model.CloudNote;
@@ -64,6 +67,7 @@ public class NoteServerSyncHelper {
    private final Context appContext;

    private CustomCertManager customCertManager;
    private ICustomCertService iCustomCertService;

    // Track network connection changes using a BroadcastReceiver
    private boolean networkConnected = false;
@@ -94,6 +98,7 @@ public class NoteServerSyncHelper {
    private final ServiceConnection certService = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName componentName, IBinder iBinder) {
            iCustomCertService = ICustomCertService.Stub.asInterface(iBinder);
            cert4androidReady = true;
            if (isSyncPossible()) {
                scheduleSync(false);
@@ -103,6 +108,7 @@ public class NoteServerSyncHelper {
        @Override
        public void onServiceDisconnected(ComponentName componentName) {
            cert4androidReady = false;
            iCustomCertService = null;
        }
    };

@@ -168,6 +174,10 @@ public class NoteServerSyncHelper {
        return customCertManager;
    }

    public void checkCertificate(byte[] cert, boolean foreground, IOnCertificateDecision callback) throws RemoteException {
        iCustomCertService.checkTrusted(cert, true, foreground, callback);
    }

    /**
     * Adds a callback method to the NoteServerSyncHelper for the synchronization part push local changes to the server.
     * All callbacks will be executed once the synchronization operations are done.
+3 −2
Original line number Diff line number Diff line
@@ -4,13 +4,14 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.preference.PreferenceManager;
import androidx.annotation.WorkerThread;
import android.text.Html;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.util.Log;
import android.widget.TextView;

import androidx.annotation.WorkerThread;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
@@ -93,6 +94,6 @@ public class SupportUtil {
    @WorkerThread
    public static CustomCertManager getCertManager(Context ctx) {
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(ctx);
        return new CustomCertManager(ctx, preferences.getBoolean(ctx.getString(R.string.pref_key_trust_system_certs), true));
        return new CustomCertManager(ctx, preferences.getBoolean(ctx.getString(R.string.pref_key_trust_system_certs), true), true, true);
    }
}
Loading