diff --git a/app/build.gradle b/app/build.gradle index bbc936db2af49c316b8bc8b5c0897d3b09f3f850..da915fcb75180323ee468711fe0b7aa46c930cc8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -166,6 +166,15 @@ dependencies { implementation 'net.openid:appauth:0.11.1' implementation 'junit:junit:4.13.2' implementation 'foundation.e:elib:0.0.1-alpha11' + compileOnly 'org.jbundle.util.osgi.wrapped:org.jbundle.util.osgi.wrapped.org.apache.http.client:4.1.2' // remove after entire switch to lib v2 + implementation "commons-httpclient:commons-httpclient:3.1@jar" // remove after entire switch to lib v2 + implementation 'org.apache.jackrabbit:jackrabbit-webdav:2.13.5' // remove after entire switch to lib v2 + implementation 'com.google.code.gson:gson:2.10.1' + implementation("com.github.nextcloud:android-library:master-SNAPSHOT") { + exclude group: 'com.gitlab.bitfireAT', module: 'dav4jvm' + exclude group: 'org.ogce', module: 'xpp3' // unused in Android and brings wrong Junit version + exclude group: 'com.squareup.okhttp3' + } // for tests androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}" diff --git a/app/proguard-rules-release.pro b/app/proguard-rules-release.pro index 2d3ee962c0d3a044081faf48632d88e1631c71d3..45a059defc9e1d2cc86ed1019a0a2aa349e4c5c5 100644 --- a/app/proguard-rules-release.pro +++ b/app/proguard-rules-release.pro @@ -26,3 +26,43 @@ public static **[] values(); public static ** valueOf(java.lang.String); } + +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- + +-keep class com.owncloud.android.** { *; } +-keep interface com.owncloud.android.** { *; } +-keep class com.nextcloud.android.** { *; } +-keep interface com.nextcloud.android.** { *; } +-keep class org.apache.commons.httpclient.** { *; } +-keep interface org.apache.commons.httpclient.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 615739d987fbc5ff3e6ceee29371cb77603898c5..d3f5abf34bba2de03a57b53963f79bff04ce36d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -177,6 +177,9 @@ android:label="@string/webdav_add_mount_title" android:parentActivityName=".ui.webdav.WebdavMountsActivity" /> + + - + + + diff --git a/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl b/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl new file mode 100644 index 0000000000000000000000000000000000000000..d0c5015b0d2c2414446b0d20a1368491a058ba6b --- /dev/null +++ b/app/src/main/aidl/com/nextcloud/android/sso/aidl/IInputStreamService.aidl @@ -0,0 +1,29 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso.aidl; + +// Declare the interface. +interface IInputStreamService { + + ParcelFileDescriptor performNextcloudRequestAndBodyStream(in ParcelFileDescriptor input, in ParcelFileDescriptor requestBodyParcelFileDescriptor); + + ParcelFileDescriptor performNextcloudRequest(in ParcelFileDescriptor input); + + ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2(in ParcelFileDescriptor input, in ParcelFileDescriptor requestBodyParcelFileDescriptor); + + ParcelFileDescriptor performNextcloudRequestV2(in ParcelFileDescriptor input); +} diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt index 777dac73674a195cfae13484a9e044fbc84028eb..eb618d92b85b3a845a2eda7ab5fa0df826938a83 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -147,6 +147,7 @@ class AccountSettings( if (credentials.userName != null) { bundle.putString(KEY_USERNAME, credentials.userName) bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + bundle.putString("oc_display_name", credentials.userName) } if (credentials.certificateAlias != null) { bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) diff --git a/app/src/main/java/com/nextcloud/android/sso/Constants.java b/app/src/main/java/com/nextcloud/android/sso/Constants.java new file mode 100644 index 0000000000000000000000000000000000000000..1a077b7d15463371ad95cf8aa912624b2964ed94 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/Constants.java @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + +public final class Constants { + // Authenticator related constants + public static final String SSO_USER_ID = "user_id"; + public static final String SSO_TOKEN = "token"; + public static final String SSO_SERVER_URL = "server_url"; + public static final String SSO_SHARED_PREFERENCE = "single-sign-on"; + public static final String NEXTCLOUD_SSO_EXCEPTION = "NextcloudSsoException"; + public static final String NEXTCLOUD_SSO = "NextcloudSSO"; + public static final String NEXTCLOUD_FILES_ACCOUNT = "NextcloudFilesAccount"; + public static final String DELIMITER = "_"; + + // Custom Exceptions + public static final String EXCEPTION_INVALID_TOKEN = "CE_1"; + public static final String EXCEPTION_ACCOUNT_NOT_FOUND = "CE_2"; + public static final String EXCEPTION_UNSUPPORTED_METHOD = "CE_3"; + public static final String EXCEPTION_INVALID_REQUEST_URL = "CE_4"; + public static final String EXCEPTION_HTTP_REQUEST_FAILED = "CE_5"; + public static final String EXCEPTION_ACCOUNT_ACCESS_DECLINED = "CE_6"; + + private Constants() { + // No instance + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java new file mode 100644 index 0000000000000000000000000000000000000000..9ca400d2d11216541ebe29519f9b24389ca81147 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/InputStreamBinder.java @@ -0,0 +1,530 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + + +import static com.nextcloud.android.sso.Constants.DELIMITER; +import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND; +import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL; +import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_TOKEN; +import static com.nextcloud.android.sso.Constants.EXCEPTION_UNSUPPORTED_METHOD; +import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Binder; +import android.os.ParcelFileDescriptor; +import android.text.TextUtils; + +import androidx.annotation.VisibleForTesting; + +import com.nextcloud.android.sso.aidl.IInputStreamService; +import com.nextcloud.android.sso.aidl.NextcloudRequest; +import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil; +import com.nextcloud.android.utils.AccountManagerUtils; +import com.nextcloud.android.utils.EncryptionUtils; +import com.owncloud.android.lib.common.OwnCloudAccount; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.OwnCloudClientManager; +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory; +import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; +import com.owncloud.android.lib.common.accounts.AccountUtils; + +import org.apache.commons.httpclient.HttpConnection; +import org.apache.commons.httpclient.HttpMethodBase; +import org.apache.commons.httpclient.HttpState; +import org.apache.commons.httpclient.NameValuePair; +import org.apache.commons.httpclient.methods.DeleteMethod; +import org.apache.commons.httpclient.methods.GetMethod; +import org.apache.commons.httpclient.methods.HeadMethod; +import org.apache.commons.httpclient.methods.InputStreamRequestEntity; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.PutMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.methods.StringRequestEntity; +import org.apache.jackrabbit.webdav.DavConstants; +import org.apache.jackrabbit.webdav.client.methods.MkColMethod; +import org.apache.jackrabbit.webdav.client.methods.PropFindMethod; +import org.apache.jackrabbit.webdav.property.DavPropertyNameSet; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; + +import at.bitfire.davdroid.log.Logger; + +public class InputStreamBinder extends IInputStreamService.Stub { + private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json"; + private static final String CHARSET_UTF8 = "UTF-8"; + + private static final int HTTP_STATUS_CODE_OK = 200; + private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300; + + private static final char PATH_SEPARATOR = '/'; + private static final int ZERO_LENGTH = 0; + private final Context context; + + public InputStreamBinder(Context context) { + this.context = context; + } + + public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStreamV2(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + Response response = new Response(); + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + response = processRequestV2(request, requestBodyInputStream); + } catch (Exception e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStreamV2(exception, response.getPlainHeadersString()); + InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody()); + + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Logger.INSTANCE.getLog().log(Level.SEVERE, "InputStreamBinder: Done sending result"), + response.getMethod()); + } catch (IOException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Error while sending response back to client app", e); + } + + return null; + } + + public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) { + return performNextcloudRequestAndBodyStream(input, null); + } + + public ParcelFileDescriptor performNextcloudRequestAndBodyStream( + ParcelFileDescriptor input, + ParcelFileDescriptor requestBodyParcelFileDescriptor) { + // read the input + final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input); + + final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ? + new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null; + Exception exception = null; + HttpMethodBase httpMethod = null; + InputStream httpStream = new InputStream() { + @Override + public int read() { + return ZERO_LENGTH; + } + }; + + try { + // Start request and catch exceptions + NextcloudRequest request = deserializeObjectAndCloseStream(is); + httpMethod = processRequest(request, requestBodyInputStream); + httpStream = httpMethod.getResponseBodyAsStream(); + } catch (Exception e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Error during Nextcloud request", e); + exception = e; + } + + try { + // Write exception to the stream followed by the actual network stream + InputStream exceptionStream = serializeObjectToInputStream(exception); + InputStream resultStream; + if (httpStream != null) { + resultStream = new java.io.SequenceInputStream(exceptionStream, httpStream); + } else { + resultStream = exceptionStream; + } + + return ParcelFileDescriptorUtil.pipeFrom(resultStream, + thread -> Logger.INSTANCE.getLog().log(Level.INFO, "InputStreamBinder: Done sending result"), + httpMethod); + } catch (IOException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Error while sending response back to client app", e); + } + + return null; + } + + private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) { + byte[] baosByteArray = new byte[0]; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(exception); + oos.writeObject(headers); + oos.flush(); + oos.close(); + + baosByteArray = baos.toByteArray(); + } catch (IOException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Error while sending response back to client app", e); + } + + return new ByteArrayInputStream(baosByteArray); + } + + private ByteArrayInputStream serializeObjectToInputStream(T obj) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + oos.writeObject(obj); + oos.flush(); + oos.close(); + return new ByteArrayInputStream(baos.toByteArray()); + } + + private T deserializeObjectAndCloseStream(InputStream is) throws IOException, + ClassNotFoundException { + ObjectInputStream ois = new ObjectInputStream(is); + T result = (T) ois.readObject(); + is.close(); + ois.close(); + return result; + } + + public class NCPropFindMethod extends PropFindMethod { + NCPropFindMethod(String uri, int propfindType, int depth) throws IOException { + super(uri, propfindType, new DavPropertyNameSet(), depth); + } + + @Override + protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) { + // Do not process the response body here. Instead pass it on to client app. + } + } + + private HttpMethodBase buildMethod(NextcloudRequest request, Uri baseUri, InputStream requestBodyInputStream) + throws IOException { + String requestUrl = baseUri + request.getUrl(); + HttpMethodBase method; + switch (request.getMethod()) { + case "GET": + method = new GetMethod(requestUrl); + break; + + case "POST": + method = new PostMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PostMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PostMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PATCH": + method = new PatchMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PatchMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PatchMethod) method).setRequestEntity(requestEntity); + } + break; + + case "PUT": + method = new PutMethod(requestUrl); + if (requestBodyInputStream != null) { + RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream); + ((PutMethod) method).setRequestEntity(requestEntity); + } else if (request.getRequestBody() != null) { + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + CONTENT_TYPE_APPLICATION_JSON, + CHARSET_UTF8); + ((PutMethod) method).setRequestEntity(requestEntity); + } + break; + + case "DELETE": + method = new DeleteMethod(requestUrl); + break; + + case "PROPFIND": + method = new NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1); + if (request.getRequestBody() != null) { + //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml + StringRequestEntity requestEntity = new StringRequestEntity( + request.getRequestBody(), + "text/xml; charset=UTF-8", + CHARSET_UTF8); + ((PropFindMethod) method).setRequestEntity(requestEntity); + } + break; + + case "MKCOL": + method = new MkColMethod(requestUrl); + break; + + case "HEAD": + method = new HeadMethod(requestUrl); + break; + + default: + throw new UnsupportedOperationException(EXCEPTION_UNSUPPORTED_METHOD); + + } + return method; + } + + private HttpMethodBase processRequest(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = AccountManagerUtils.getAccountByName(context, request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + Uri serverUri = Uri.parse(AccountUtils.getBaseUrlForAccount(context, account)); + OwnCloudClient client = OwnCloudClientFactory.createOwnCloudClient(serverUri, context, true); + client.setCredentials(OwnCloudCredentialsFactory.newBasicCredentials(account.name, getAcountPwd(account, context))); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return method; + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + Logger.INSTANCE.getLog().log(Level.SEVERE, "InputStreamBinder: " + total); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream) + throws UnsupportedOperationException, + com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException, + OperationCanceledException, AuthenticatorException, IOException { + Account account = AccountManagerUtils.getAccountByName(context, request.getAccountName()); + if (account == null) { + throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND); + } + + // Validate token + if (!isValid(request)) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + // Validate URL + if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) { + throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL, + new IllegalStateException("URL need to start with a /")); + } + + Uri serverUri = Uri.parse(AccountUtils.getBaseUrlForAccount(context, account)); + OwnCloudClient client = OwnCloudClientFactory.createOwnCloudClient(serverUri, context, true); + client.setCredentials(OwnCloudCredentialsFactory.newBasicCredentials(account.name, getAcountPwd(account, context))); + + HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream); + + if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) { + method.setQueryString(convertListToNVP(request.getParameterV2())); + } else { + method.setQueryString(convertMapToNVP(request.getParameter())); + } + + method.addRequestHeader("OCS-APIREQUEST", "true"); + + for (Map.Entry> header : request.getHeader().entrySet()) { + // https://stackoverflow.com/a/3097052 + method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue())); + + if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) { + throw new IllegalStateException( + "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " + + "Please remove the header before making a request"); + } + } + + client.setFollowRedirects(request.isFollowRedirects()); + int status = client.executeMethod(method); + + // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success + if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) { + return new Response(method); + } else { + InputStream inputStream = method.getResponseBodyAsStream(); + String total = "No response body"; + + // If response body is available + if (inputStream != null) { + total = inputStreamToString(inputStream); + } + + method.releaseConnection(); + throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED, + new IllegalStateException(String.valueOf(status), + new IllegalStateException(total))); + } + } + + private static String getAcountPwd(Account account, Context ctx) throws AccountUtils.AccountNotFoundException { + return AccountManager.get(ctx).getPassword(account); + } + + + private boolean isValid(NextcloudRequest request) { + String callingPackageName = context.getPackageManager().getNameForUid(Binder.getCallingUid()); + + SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE, + Context.MODE_PRIVATE); + String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), ""); + return validateToken(hash, request.getToken()); + } + + private boolean validateToken(String hash, String token) { + if (hash.isEmpty() || !hash.contains("$")) { + throw new IllegalStateException(EXCEPTION_INVALID_TOKEN); + } + + String salt = hash.split("\\$")[1]; // TODO extract "$" + + String newHash = EncryptionUtils.generateSHA512(token, salt); + + // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings + // and don't exit prematurely if the string does not match anymore to prevent timing-attacks + return isEqual(hash.getBytes(), newHash.getBytes()); + } + + // Taken from http://codahale.com/a-lesson-in-timing-attacks/ + private static boolean isEqual(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + + int result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + + private static String inputStreamToString(InputStream inputStream) { + try { + StringBuilder total = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + String line = reader.readLine(); + while (line != null) { + total.append(line).append('\n'); + line = reader.readLine(); + } + return total.toString(); + } catch (Exception e) { + return e.getMessage(); + } + } + + @VisibleForTesting + public static NameValuePair[] convertMapToNVP(Map map) { + NameValuePair[] nvp = new NameValuePair[map.size()]; + int i = 0; + for (String key : map.keySet()) { + nvp[i] = new NameValuePair(key, map.get(key)); + i++; + } + return nvp; + } + + @VisibleForTesting + public static NameValuePair[] convertListToNVP(Collection list) { + NameValuePair[] nvp = new NameValuePair[list.size()]; + int i = 0; + for (QueryParam pair : list) { + nvp[i] = new NameValuePair(pair.key, pair.value); + i++; + } + return nvp; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java new file mode 100644 index 0000000000000000000000000000000000000000..36cafee004175ec60bd28addf20fd6b316458101 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PatchMethod.java @@ -0,0 +1,108 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + + +import org.apache.commons.httpclient.methods.ByteArrayRequestEntity; +import org.apache.commons.httpclient.methods.EntityEnclosingMethod; +import org.apache.commons.httpclient.methods.PostMethod; +import org.apache.commons.httpclient.methods.RequestEntity; +import org.apache.commons.httpclient.util.EncodingUtil; + +import java.util.Vector; + +public class PatchMethod extends PostMethod { + + /** + * The buffered request body consisting of NameValuePairs. + */ + private Vector params = new Vector(); + + /** + * No-arg constructor. + */ + public PatchMethod() { + super(); + } + + /** + * Constructor specifying a URI. + * + * @param uri either an absolute or relative URI + */ + public PatchMethod(String uri) { + super(uri); + } + + /** + * Returns "PATCH". + * + * @return "PATCH" + * @since 2.0 + */ + @Override + public String getName() { + return "PATCH"; + } + + /** + * Returns true if there is a request body to be sent. + * + * @return boolean + * @since 2.0beta1 + */ + protected boolean hasRequestContent() { + if (!this.params.isEmpty()) { + return true; + } else { + return super.hasRequestContent(); + } + } + + /** + * Clears request body. + * + * @since 2.0beta1 + */ + protected void clearRequestBody() { + this.params.clear(); + super.clearRequestBody(); + } + + /** + * Generates a request entity from the patch parameters, if present. Calls {@link + * EntityEnclosingMethod#generateRequestBody()} if parameters have not been set. + * + * @since 3.0 + */ + protected RequestEntity generateRequestEntity() { + if (!this.params.isEmpty()) { + // Use a ByteArrayRequestEntity instead of a StringRequestEntity. + // This is to avoid potential encoding issues. Form url encoded strings + // are ASCII by definition but the content type may not be. Treating the content + // as bytes allows us to keep the current charset without worrying about how + // this charset will effect the encoding of the form url encoded string. + String content = EncodingUtil.formUrlEncode(getParameters(), getRequestCharSet()); + return new ByteArrayRequestEntity( + EncodingUtil.getAsciiBytes(content), + FORM_URL_ENCODED_CONTENT_TYPE + ); + } else { + return super.generateRequestEntity(); + } + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java new file mode 100644 index 0000000000000000000000000000000000000000..8f9f0a95bb63e3ac2d785355e85a10081a89097e --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/PlainHeader.java @@ -0,0 +1,53 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +public class PlainHeader implements Serializable { + private static final long serialVersionUID = 3284979177401282512L; + + private String name; + private String value; + + PlainHeader(String name, String value) { + this.name = name; + this.value = value; + } + + private void writeObject(ObjectOutputStream oos) throws IOException { + oos.writeObject(name); + oos.writeObject(value); + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + name = (String) in.readObject(); + value = (String) in.readObject(); + } + + public String getName() { + return this.name; + } + + public String getValue() { + return this.value; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/QueryParam.java b/app/src/main/java/com/nextcloud/android/sso/QueryParam.java new file mode 100644 index 0000000000000000000000000000000000000000..96f46daca127120021cd539d76f87ffb1df44263 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/QueryParam.java @@ -0,0 +1,32 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + + +import java.io.Serializable; + +public class QueryParam implements Serializable { + private static final long serialVersionUID = 21523240203234211L; // must be same as in SSO project + + public String key; + public String value; + + public QueryParam(String key, String value) { + this.key = key; + this.value = value; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/Response.java b/app/src/main/java/com/nextcloud/android/sso/Response.java new file mode 100644 index 0000000000000000000000000000000000000000..7ed410f18fad2328293db322124667b119e6a0b1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/Response.java @@ -0,0 +1,69 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso; + + +import com.google.gson.Gson; + +import org.apache.commons.httpclient.Header; +import org.apache.commons.httpclient.HttpMethodBase; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class Response { + private InputStream body; + private Header[] headers; + private HttpMethodBase method; + + public Response() { + headers = new Header[0]; + body = new InputStream() { + @Override + public int read() { + return 0; + } + }; + } + + public Response(HttpMethodBase methodBase) throws IOException { + this.method = methodBase; + this.body = methodBase.getResponseBodyAsStream(); + this.headers = methodBase.getResponseHeaders(); + } + + public String getPlainHeadersString() { + List arrayList = new ArrayList<>(headers.length); + + for (Header header : headers) { + arrayList.add(new PlainHeader(header.getName(), header.getValue())); + } + + Gson gson = new Gson(); + return gson.toJson(arrayList); + } + + public InputStream getBody() { + return this.body; + } + + public HttpMethodBase getMethod() { + return method; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java b/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java new file mode 100644 index 0000000000000000000000000000000000000000..71990107bef2a773d6d6816ef1c819c0d3ed8955 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/IThreadListener.java @@ -0,0 +1,23 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso.aidl; + +public interface IThreadListener { + + void onThreadFinished(final Thread thread); + +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java b/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java new file mode 100644 index 0000000000000000000000000000000000000000..cce3d96b1cd0eeda615a16974a53433871c955de --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/NextcloudRequest.java @@ -0,0 +1,154 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso.aidl; + + +import com.nextcloud.android.sso.QueryParam; + +import java.io.Serializable; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public final class NextcloudRequest implements Serializable { + + private static final long serialVersionUID = 215521212534240L; + + private String method; + private Map> header = new HashMap<>(); + private Map parameter = new HashMap<>(); + private final Collection parameterV2 = new LinkedList<>(); + private String requestBody; + private String url; + private String token; + private String packageName; + private String accountName; + private boolean followRedirects; + + private NextcloudRequest() { } + + public static class Builder { + private NextcloudRequest ncr; + + public Builder() { + ncr = new NextcloudRequest(); + } + + public NextcloudRequest build() { + return ncr; + } + + public Builder setMethod(String method) { + ncr.method = method; + return this; + } + + public Builder setHeader(Map> header) { + ncr.header = header; + return this; + } + + public Builder setParameter(Map parameter) { + ncr.parameter = parameter; + return this; + } + + public Builder setRequestBody(String requestBody) { + ncr.requestBody = requestBody; + return this; + } + + public Builder setUrl(String url) { + ncr.url = url; + return this; + } + + public Builder setToken(String token) { + ncr.token = token; + return this; + } + + public Builder setAccountName(String accountName) { + ncr.accountName = accountName; + return this; + } + + /** + * Default value: true + * @param followRedirects + * @return + */ + public Builder setFollowRedirects(boolean followRedirects) { + ncr.followRedirects = followRedirects; + return this; + } + } + + public String getMethod() { + return this.method; + } + + public Map> getHeader() { + return this.header; + } + + public Map getParameter() { + return this.parameter; + } + + public String getRequestBody() { + return this.requestBody; + } + + public String getUrl() { + return this.url; + } + + public String getToken() { + return this.token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPackageName() { + return this.packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getAccountName() { + return this.accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public boolean isFollowRedirects() { + return this.followRedirects; + } + + public Collection getParameterV2() { + return parameterV2; + } +} diff --git a/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java b/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..900ee0655ff519d8ceb4a37e314ae9d038cd43a8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/sso/aidl/ParcelFileDescriptorUtil.java @@ -0,0 +1,98 @@ +/* + * 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 . + */ + +package com.nextcloud.android.sso.aidl; + +import android.os.ParcelFileDescriptor; + +import org.apache.commons.httpclient.HttpMethodBase; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.logging.Level; + +import at.bitfire.davdroid.log.Logger; + +public final class ParcelFileDescriptorUtil { + + private ParcelFileDescriptorUtil() { } + + public static ParcelFileDescriptor pipeFrom(InputStream inputStream, + IThreadListener listener, + HttpMethodBase method) + throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ParcelFileDescriptor readSide = pipe[0]; + ParcelFileDescriptor writeSide = pipe[1]; + + // start the transfer thread + new TransferThread(inputStream, + new ParcelFileDescriptor.AutoCloseOutputStream(writeSide), + listener, + method) + .start(); + + return readSide; + } + + public static class TransferThread extends Thread { + private static final String TAG = TransferThread.class.getCanonicalName(); + private final InputStream inputStream; + private final OutputStream outputStream; + private final IThreadListener threadListener; + private final HttpMethodBase httpMethod; + + TransferThread(InputStream in, OutputStream out, IThreadListener listener, HttpMethodBase method) { + super("ParcelFileDescriptor Transfer Thread"); + inputStream = in; + outputStream = out; + threadListener = listener; + httpMethod = method; + setDaemon(true); + } + + @Override + public void run() { + byte[] buf = new byte[1024]; + int len; + + try { + while ((len = inputStream.read(buf)) > 0) { + outputStream.write(buf, 0, len); + } + outputStream.flush(); // just to be safe + } catch (IOException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "writing failed", e); + } finally { + try { + inputStream.close(); + outputStream.close(); + } catch (IOException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, TAG, e); + } + } + if (threadListener != null) { + threadListener.onThreadFinished(this); + } + + if (httpMethod != null) { + Logger.INSTANCE.getLog().log(Level.INFO, "releaseConnection"); + httpMethod.releaseConnection(); + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java b/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..bf05b5e7b667ba86389de56916e6e378a9a2815a --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/utils/AccountManagerUtils.java @@ -0,0 +1,57 @@ +/* + * 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 . + */ + +package com.nextcloud.android.utils; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.R; + +public final class AccountManagerUtils { + + private AccountManagerUtils() { + // utility class -> private constructor + } + + @Nullable + public static Account getAccountByName(@NonNull Context context, String name) { + for (Account account : getAccounts(context)) { + if (account.name.equals(name)) { + return account; + } + } + + return null; + } + + @NonNull + public static Account[] getAccounts(@NonNull Context context) { + AccountManager accountManager = AccountManager.get(context); + return accountManager.getAccountsByType(getAccountType(context)); + } + + @NonNull + public static String getAccountType(@NonNull Context context) { + return context.getString(R.string.eelo_account_type); + } + +} diff --git a/app/src/main/java/com/nextcloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/nextcloud/android/utils/EncryptionUtils.java new file mode 100644 index 0000000000000000000000000000000000000000..4a96122cb5313495c69d1ee4e82ad7be8b79bd9b --- /dev/null +++ b/app/src/main/java/com/nextcloud/android/utils/EncryptionUtils.java @@ -0,0 +1,93 @@ +/* + * 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 . + */ + +package com.nextcloud.android.utils; + +import android.util.Base64; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.logging.Level; + +import at.bitfire.davdroid.log.Logger; + +public final class EncryptionUtils { + + public static final int saltLength = 40; + + private static final String HASH_DELIMITER = "$"; + + private EncryptionUtils() { + // utility class -> private constructor + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token) { + String salt = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.randomBytes(EncryptionUtils.saltLength)); + + return generateSHA512(token, salt); + } + + /** + * Generate a SHA512 with appended salt + * + * @param token token to be hashed + * @return SHA512 with appended salt, delimiter HASH_DELIMITER + */ + public static String generateSHA512(String token, String salt) { + MessageDigest digest; + String hashedToken = ""; + byte[] hash; + try { + digest = MessageDigest.getInstance("SHA-512"); + digest.update(salt.getBytes()); + hash = digest.digest(token.getBytes()); + + StringBuilder stringBuilder = new StringBuilder(); + for (byte hashByte : hash) { + stringBuilder.append(Integer.toString((hashByte & 0xff) + 0x100, 16).substring(1)); + } + + stringBuilder.append(HASH_DELIMITER).append(salt); + + hashedToken = stringBuilder.toString(); + + } catch (NoSuchAlgorithmException e) { + Logger.INSTANCE.getLog().log(Level.SEVERE, "Generating SHA512 failed", e); + } + + return hashedToken; + } + + + public static String encodeBytesToBase64String(byte[] bytes) { + return Base64.encodeToString(bytes, Base64.NO_WRAP); + } + + public static byte[] randomBytes(int size) { + SecureRandom random = new SecureRandom(); + final byte[] iv = new byte[size]; + random.nextBytes(iv); + + return iv; + } +} diff --git a/app/src/main/java/com/owncloud/android/services/AccountManagerService.java b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java new file mode 100644 index 0000000000000000000000000000000000000000..276f3686b67ff3041df196b5effb0c78197ed714 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/AccountManagerService.java @@ -0,0 +1,47 @@ +/* + * 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 . + */ + +package com.owncloud.android.services; + + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import com.nextcloud.android.sso.InputStreamBinder; + +public class AccountManagerService extends Service { + + private InputStreamBinder mBinder; + + @Override + public void onCreate() { + super.onCreate(); + } + + @Override + public IBinder onBind(Intent intent) { + if(mBinder == null) { + mBinder = new InputStreamBinder(getApplicationContext()); + } + return mBinder; + } + + @Override + public boolean onUnbind(Intent intent) { + return super.onUnbind(intent); + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java new file mode 100644 index 0000000000000000000000000000000000000000..c62ae46ee1676e08b9ce52d0567b78cf031fa723 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SsoGrantPermissionActivity.java @@ -0,0 +1,165 @@ +/* + * 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 . + */ + +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.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.Constants; +import at.bitfire.davdroid.R; +import at.bitfire.davdroid.log.Logger; + +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 = account.name; + + saveToken(token, userId); + setResultData(token, userId, serverUrl); + finish(); + } + + private void setResultData(String token, String userId, String serverUrl) { + final Bundle result = new Bundle(); + result.putString(AccountManager.KEY_ACCOUNT_NAME, userId); + 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 userId) { + String hashedTokenWithSalt = EncryptionUtils.generateSHA512(token); + SharedPreferences sharedPreferences = getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(packageName + DELIMITER + userId, 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 diff --git a/app/src/main/res/layout/activity_sso_grant_permission.xml b/app/src/main/res/layout/activity_sso_grant_permission.xml new file mode 100644 index 0000000000000000000000000000000000000000..e83e7ae5eac12824562f1ee7f382fadb0b315bcf --- /dev/null +++ b/app/src/main/res/layout/activity_sso_grant_permission.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file