diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 7ac24c7..5b05804 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -9,6 +9,7 @@ - + diff --git a/.idea/modules.xml b/.idea/modules.xml index 5e1adac..f7ba136 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -4,6 +4,8 @@ + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 09e1b45..667c2c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,6 +24,7 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':mobileauthentication') implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" implementation 'com.android.support:appcompat-v7:26.1.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 880ade0..d178ce6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,20 +2,36 @@ + + + + - + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt b/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt index 858f26e..4149bed 100644 --- a/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt +++ b/app/src/main/java/ca/bc/gov/mobileauthenticationandroidexample/MainActivity.kt @@ -1,12 +1,108 @@ package ca.bc.gov.mobileauthenticationandroidexample +import android.content.Intent import android.support.v7.app.AppCompatActivity import android.os.Bundle +import android.util.Log +import ca.bc.gov.mobileauthentication.MobileAuthenticationClient +import ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException +import ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException +import ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException +import ca.bc.gov.mobileauthentication.data.models.Token class MainActivity : AppCompatActivity() { + private var client: MobileAuthenticationClient? = null + + private val tag = "MOBILE_AUTH" + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + val authEndpoint = "https://dev-sso.pathfinder.gov.bc.ca/auth/realms/mobile/protocol/openid-connect/auth" + val baseUrl = "https://dev-sso.pathfinder.gov.bc.ca/" + val clientId = "secure-image" + val realmName = "mobile" + val redirectUri = "bcgov://android" + + client = MobileAuthenticationClient(this, baseUrl, realmName, + authEndpoint, redirectUri, clientId) + + client?.authenticate() + } + + override fun onDestroy() { + super.onDestroy() + client?.clear() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + client?.handleAuthResult(requestCode, resultCode, data, object: MobileAuthenticationClient.TokenCallback { + override fun onError(throwable: Throwable) { + Log.e(tag, throwable.message) + } + override fun onSuccess(token: Token) { + Log.d(tag, "Authenticate Success") + getToken() + } + }) + } + + private fun getToken() { + client?.getToken(object: MobileAuthenticationClient.TokenCallback { + override fun onError(throwable: Throwable) { + when (throwable) { + is RefreshExpiredException -> { + Log.e(tag, "Refresh token is expired. Please re-authenticate.") + } + is NoRefreshTokenException -> { + Log.e(tag, "No Refresh token associated with Token") + } + is TokenNotFoundException -> { + Log.e(tag, "No Token was found") + } + } + } + + override fun onSuccess(token: Token) { + Log.d(tag, "Get Success") + refreshToken() + } + }) + } + + private fun refreshToken() { + client?.refreshToken(object: MobileAuthenticationClient.TokenCallback { + override fun onError(throwable: Throwable) { + Log.e(tag, throwable.message) + when (throwable) { + is RefreshExpiredException -> { + Log.e(tag, "Refresh token is expired. Please re-authenticate.") + } + is NoRefreshTokenException -> { + Log.e(tag, "No Refresh token associated with Token") + } + } + } + + override fun onSuccess(token: Token) { + Log.d(tag, "Refresh Success") + deleteToken() + } + }) + } + + private fun deleteToken() { + client?.deleteToken(object: MobileAuthenticationClient.DeleteCallback { + override fun onError(throwable: Throwable) { + Log.e(tag, throwable.message) + } + + override fun onSuccess() { + Log.d(tag, "Delete Success") + } + }) } } diff --git a/mobileauthentication/.gitignore b/mobileauthentication/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/mobileauthentication/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mobileauthentication/build.gradle b/mobileauthentication/build.gradle new file mode 100644 index 0000000..b95f3e8 --- /dev/null +++ b/mobileauthentication/build.gradle @@ -0,0 +1,60 @@ +apply plugin: 'com.android.library' + +apply plugin: 'kotlin-android' + +apply plugin: 'kotlin-android-extensions' + +android { + + compileSdkVersion 26 + + defaultConfig { + minSdkVersion 23 + targetSdkVersion 26 + compileSdkVersion 26 + buildToolsVersion '26.0.2' + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + } + + buildTypes { + release { + postprocessing { + removeUnusedCode false + removeUnusedResources false + obfuscate false + optimizeCode false + proguardFile 'proguard-rules.pro' + } + } + } + +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation "com.android.support:appcompat-v7:26.1.0" + implementation "com.android.support:customtabs:26.1.0" + implementation "com.android.support.constraint:constraint-layout:1.0.2" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.2.21" + + implementation "io.reactivex.rxjava2:rxandroid:2.0.1" + implementation "io.reactivex.rxjava2:rxjava:2.1.7" + implementation "io.reactivex.rxjava2:rxkotlin:2.2.0" + + implementation "com.squareup.retrofit2:retrofit:2.3.0" + implementation "com.squareup.retrofit2:converter-gson:2.3.0" + implementation "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0" + implementation "com.squareup.okhttp3:logging-interceptor:3.9.1" + + testImplementation "junit:junit:4.12" + testImplementation "com.nhaarman:mockito-kotlin:1.5.0" +} + +repositories { + mavenCentral() +} diff --git a/mobileauthentication/proguard-rules.pro b/mobileauthentication/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/mobileauthentication/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobileauthentication/src/main/AndroidManifest.xml b/mobileauthentication/src/main/AndroidManifest.xml new file mode 100644 index 0000000..02b8573 --- /dev/null +++ b/mobileauthentication/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt new file mode 100644 index 0000000..f2490dd --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationClient.kt @@ -0,0 +1,181 @@ +package ca.bc.gov.mobileauthentication + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.preference.PreferenceManager +import ca.bc.gov.mobileauthentication.common.Constants +import ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException +import ca.bc.gov.mobileauthentication.common.utils.UrlUtils +import ca.bc.gov.mobileauthentication.data.AuthApi +import ca.bc.gov.mobileauthentication.data.models.Token +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo +import ca.bc.gov.mobileauthentication.di.Injection +import ca.bc.gov.mobileauthentication.di.InjectionUtils +import ca.bc.gov.mobileauthentication.screens.redirect.RedirectActivity +import com.google.gson.Gson +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import io.reactivex.rxkotlin.subscribeBy +import io.reactivex.schedulers.Schedulers + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +class MobileAuthenticationClient( + private val context: Context, + override val baseUrl: String, + override val realmName: String, + override val authEndpoint: String, + override val redirectUri: String, + override val clientId: String) : MobileAuthenticationContract { + + private val disposables = CompositeDisposable() + + private val gson: Gson = Injection.provideGson() + private val grantType: String = Constants.GRANT_TYPE_AUTH_CODE + private val authApi: AuthApi = InjectionUtils.getAuthApi(UrlUtils.cleanBaseUrl(baseUrl)) + private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + private val tokenRepo: TokenRepo = InjectionUtils.getTokenRepo( + authApi, realmName, grantType, redirectUri, clientId, sharedPrefs) + + private var passedRequestCode: Int = DEFAULT_REQUEST_CODE + + /** + * Launches intent to activity which will handle OAuth2 Authorization Code Flow + */ + override fun authenticate(requestCode: Int) { + this.passedRequestCode = requestCode + Intent(context, RedirectActivity::class.java) + .putExtra(RedirectActivity.BASE_URL, baseUrl) + .putExtra(RedirectActivity.REALM_NAME, realmName) + .putExtra(RedirectActivity.AUTH_ENDPOINT, authEndpoint) + .putExtra(RedirectActivity.REDIRECT_URI, redirectUri) + .putExtra(RedirectActivity.CLIENT_ID, clientId) + .run { (context as Activity).startActivityForResult(this, requestCode) } + } + + /** + * Handles on activity result and determines if the authentication was successful + * or an error occurred. + */ + override fun handleAuthResult( + requestCode: Int, resultCode: Int, data: Intent?, + tokenCallback: TokenCallback) { + + if (resultCode == Activity.RESULT_OK && requestCode == passedRequestCode && data != null) { + val success = data.getBooleanExtra(MobileAuthenticationClient.SUCCESS, false) + if (success) { + val tokenJson = data.getStringExtra(MobileAuthenticationClient.TOKEN_JSON) + val token: Token = gson.fromJson(tokenJson, Token::class.java) + tokenCallback.onSuccess(token) + } + else { + val errorMessage = data.getStringExtra(MobileAuthenticationClient.ERROR_MESSAGE) + tokenCallback.onError(Throwable(errorMessage)) + } + } + } + + /** + * Gets Token from local storage. + * Token will be automatically refreshed if refresh token is not expired. + * If refresh token is expired a @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown + * If refresh token does not exist then @see ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException will be thrown + * If a token does not exist a @see ca.bc.gov.mobileauthentication.common.exceptions.TokenNotFoundException will be thrown + */ + override fun getToken(tokenCallback: TokenCallback) { + tokenRepo.getToken() + .firstElement() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()).subscribeBy( + onError = { + tokenCallback.onError(it) + }, + onSuccess = { token -> + tokenCallback.onSuccess(token) + }, + onComplete = { + tokenCallback.onError(TokenNotFoundException()) + } + ).addTo(disposables) + } + + /** + * Gets Token from local storage as a RxJava2 Observable + */ + override fun getTokenAsObservable(): Observable = tokenRepo.getToken() + + /** + * Refreshes token + * If refresh token is expired a @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown + * If refresh token does not exist then @see ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException will be thrown + */ + override fun refreshToken(tokenCallback: TokenCallback) { + tokenRepo.getToken() + .flatMap { token -> tokenRepo.refreshToken(token) } + .firstOrError() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()).subscribeBy( + onError = { + tokenCallback.onError(it) + }, + onSuccess = { token -> + tokenCallback.onSuccess(token) + } + ).addTo(disposables) + } + + /** + * Refreshes Token that is stored in local storage as a RxJava2 Observable + */ + override fun refreshTokenAsObservable(): Observable = tokenRepo.getToken() + .flatMap { token -> tokenRepo.refreshToken(token) } + + /** + * Deletes token from local storage + */ + override fun deleteToken(deleteCallback: DeleteCallback) { + tokenRepo.deleteToken() + .ignoreElements().subscribeBy( + onError = { + deleteCallback.onError(it) + }, + onComplete = { + deleteCallback.onSuccess() + } + ).addTo(disposables) + } + + /** + * Deletes token from local storage as a RxJava2 Observable + */ + override fun deleteTokenAsObservable(): Observable = tokenRepo.deleteToken() + + /** + * Clears all current callbacks + */ + override fun clear() { + disposables.clear() + } + + interface TokenCallback { + fun onError(throwable: Throwable) + fun onSuccess(token: Token) + } + + interface DeleteCallback { + fun onError(throwable: Throwable) + fun onSuccess() + } + + companion object { + const val DEFAULT_REQUEST_CODE = 1012 + const val SUCCESS = "SUCCESS" + const val ERROR_MESSAGE = "ERROR_MESSAGE" + const val TOKEN_JSON = "TOKEN_JSON" + } +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt new file mode 100644 index 0000000..3db8a98 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/MobileAuthenticationContract.kt @@ -0,0 +1,35 @@ +package ca.bc.gov.mobileauthentication + +import android.content.Intent +import ca.bc.gov.mobileauthentication.data.models.Token +import io.reactivex.Observable + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +interface MobileAuthenticationContract { + + val baseUrl: String + val realmName: String + val authEndpoint: String + val redirectUri: String + val clientId: String + + fun authenticate(requestCode: Int = MobileAuthenticationClient.DEFAULT_REQUEST_CODE) + + fun handleAuthResult(requestCode: Int, resultCode: Int, data: Intent?, + tokenCallback: MobileAuthenticationClient.TokenCallback) + + fun getToken(tokenCallback: MobileAuthenticationClient.TokenCallback) + fun getTokenAsObservable(): Observable + + fun refreshToken(tokenCallback: MobileAuthenticationClient.TokenCallback) + fun refreshTokenAsObservable(): Observable + + fun deleteToken(deleteCallback: MobileAuthenticationClient.DeleteCallback) + fun deleteTokenAsObservable(): Observable + + fun clear() + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt new file mode 100644 index 0000000..5fdfe97 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BasePresenter.kt @@ -0,0 +1,13 @@ +package ca.bc.gov.mobileauthentication.common + +/** + * Created by Aidan Laing on 2017-12-12. + * + */ +interface BasePresenter { + + fun subscribe() + + fun dispose() + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt new file mode 100644 index 0000000..63215bb --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/BaseView.kt @@ -0,0 +1,11 @@ +package ca.bc.gov.mobileauthentication.common + +/** + * Created by Aidan Laing on 2017-12-12. + * + */ +interface BaseView { + + var presenter: T? + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt new file mode 100644 index 0000000..b2b32c5 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/Constants.kt @@ -0,0 +1,13 @@ +package ca.bc.gov.mobileauthentication.common + +/** + * Created by Aidan Laing on 2018-01-18. + * + */ +object Constants { + const val READ_TIME_OUT = 20L + const val CONNECT_TIME_OUT = 20L + + const val GRANT_TYPE_AUTH_CODE = "authorization_code" + const val RESPONSE_TYPE_CODE = "code" +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt new file mode 100644 index 0000000..3790458 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/InvalidOperationException.kt @@ -0,0 +1,11 @@ +package ca.bc.gov.mobileauthentication.common.exceptions + +/** + * Created by Aidan Laing on 2018-01-05. + * Thrown when an incorrect operation is called on a data source. + */ +class InvalidOperationException: Throwable() { + + override val message: String? get() = "Invalid operation" + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt new file mode 100644 index 0000000..873d5f1 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoCodeException.kt @@ -0,0 +1,10 @@ +package ca.bc.gov.mobileauthentication.common.exceptions + +/** + * Created by Aidan Laing on 2018-01-31. + * Thrown when there is no code to exchange for a token remotely. + */ +class NoCodeException : Throwable() { + override val message: String? + get() = "Code is required for getting token" +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt new file mode 100644 index 0000000..b01d69d --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/NoRefreshTokenException.kt @@ -0,0 +1,10 @@ +package ca.bc.gov.mobileauthentication.common.exceptions + +/** + * Created by Aidan Laing on 2018-01-31. + * Thrown when token refresh is called and the refresh token does not exist. + */ +class NoRefreshTokenException : Throwable() { + override val message: String? + get() = "No refresh token" +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt new file mode 100644 index 0000000..9a3fc22 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/RefreshExpiredException.kt @@ -0,0 +1,10 @@ +package ca.bc.gov.mobileauthentication.common.exceptions + +/** + * Created by Aidan Laing on 2018-01-31. + * Thrown when token refresh is called and the refresh is expired. + */ +class RefreshExpiredException : Throwable() { + override val message: String? + get() = "Refresh token has expired. Please get new token using authenticate." +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt new file mode 100644 index 0000000..3f002e0 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/exceptions/TokenNotFoundException.kt @@ -0,0 +1,10 @@ +package ca.bc.gov.mobileauthentication.common.exceptions + +/** + * Created by Aidan Laing on 2018-01-31. + * Thrown when local database is queried and no Token exists. + */ +class TokenNotFoundException : Throwable() { + override val message: String? + get() = "No token found. Please call authenticate before trying to retrieve a token locally." +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt new file mode 100644 index 0000000..747feb9 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtils.kt @@ -0,0 +1,34 @@ +package ca.bc.gov.mobileauthentication.common.utils + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +object UrlUtils { + + /** + * Checks to see if base url ends with a / + * If the url ends with a / then return the passed url + * If the url DOES NOT end with a / then return the passed url concatenated with / + */ + fun cleanBaseUrl(baseUrl: String): String { + return if (!baseUrl.endsWith("/")) { + var cleanedBaseUrl = baseUrl + cleanedBaseUrl += "/" + cleanedBaseUrl + } else { + baseUrl + } + } + + /** + * Extracts code query param form url by taking a substring between + * code= and the first & or the end of the String + */ + fun extractCode(codeUrl: String): String { + return if (codeUrl.contains("code=".toRegex())) { + codeUrl.substringAfter("code=").substringBefore("&") + } else "" + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt new file mode 100644 index 0000000..b1073de --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/AuthApi.kt @@ -0,0 +1,42 @@ +package ca.bc.gov.mobileauthentication.data + +import ca.bc.gov.mobileauthentication.data.models.Token +import io.reactivex.Observable +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * Created by Aidan Laing on 2018-01-18. + * + */ +interface AuthApi { + + /** + * OAuth2 get Token call + */ + @POST("/auth/realms/{realm_name}/protocol/openid-connect/token") + @FormUrlEncoded + fun getToken( + @Path("realm_name") realmName: String, + @Field("grant_type") grantType: String, + @Field("redirect_uri") redirectUri: String, + @Field("client_id") client_id: String, + @Field("code") code: String + ): Observable + + /** + * OAuth2 refresh Token call + */ + @POST("/auth/realms/{realm_name}/protocol/openid-connect/token") + @FormUrlEncoded + fun refreshToken( + @Path("realm_name") realmName: String, + @Field("redirect_uri") redirectUri: String, + @Field("client_id") client_id: String, + @Field("refresh_token") refreshToken: String, + @Field("grant_type") grantType: String = "refresh_token" + ): Observable + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt new file mode 100644 index 0000000..ed3bb7f --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/models/Token.kt @@ -0,0 +1,26 @@ +package ca.bc.gov.mobileauthentication.data.models + +import com.google.gson.annotations.SerializedName + +/** + * Created by Aidan Laing on 2018-01-18. + * + */ +data class Token( + @SerializedName("access_token") val accessToken: String?, + @SerializedName("expires_in") val expiresIn: Long?, + @SerializedName("refresh_expires_in") val refreshExpiresIn: Long?, + @SerializedName("refresh_token") val refreshToken: String?, + @SerializedName("token_type") val bearer: String?, + @SerializedName("id_token") val idToken: String?, + @SerializedName("not-before-policy") val notBeforePolicy: Long?, + @SerializedName("session_state") val sessionState: String?, + val expiresAt: Long = System.currentTimeMillis() + (expiresIn ?: 0 * 1000), + val refreshExpiresAt: Long = System.currentTimeMillis() + (refreshExpiresIn ?: 0 * 1000) +) { + + fun isExpired(currentTime: Long = System.currentTimeMillis()): Boolean = expiresAt > currentTime + + fun isRefreshExpired(currentTime: Long = System.currentTimeMillis()): Boolean = refreshExpiresAt > currentTime + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt new file mode 100644 index 0000000..dc153a9 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/SecureSharedPrefs.kt @@ -0,0 +1,147 @@ +package ca.bc.gov.mobileauthentication.data.repos.token + +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.nio.charset.Charset +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +class SecureSharedPrefs( + private val keyStore: KeyStore, + private val sharedPrefs: SharedPreferences +) { + + init { + keyStore.load(null) + } + + /** + * Gets String from shared prefs, decrypts it, and returns the result + */ + fun getString(key: String): String { + val aesSecretKey = getAESSecretKey(key, 256) + return getDecryptedString(key, aesSecretKey) + } + + /** + * Saves an encrypted version of the passed String to shared prefs + */ + fun saveString(key: String, data: String) { + val aesSecretKey = getAESSecretKey(key, 256) + encryptAndSaveString(key, data, aesSecretKey) + } + + /** + * Deletes String associated with key in shared prefs + */ + fun deleteString(key: String) { + sharedPrefs.edit().remove(key).apply() + } + + /** + * Gets AES Secret key form keystore + * Generates a new one if it does not exist + */ + private fun getAESSecretKey( + alias: String, + keySize: Int + ): SecretKey { + + // Generating AES key if alias is not found + if (!keyStore.containsAlias(alias)) { + generateAESSecretKey(alias, keySize) + } + + // Getting secret key from keystore and returning + return keyStore.getKey(alias, null) as SecretKey + } + + /** + * Builds new AES Secret key + */ + private fun generateAESSecretKey(alias: String, keySize: Int) { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStore.provider) + + keyGenerator.init(KeyGenParameterSpec.Builder( + alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(keySize) + .build()) + + keyGenerator.generateKey() + } + + /** + * Encrypts passed string and saves in shared prefs + */ + private fun encryptAndSaveString( + key: String, + data: String, + aesSecretKey: SecretKey) { + + // Encryption setup + val encryptionCipher = Cipher.getInstance(AES_TRANSFORMATION) + encryptionCipher.init(Cipher.ENCRYPT_MODE, aesSecretKey) + + val dataBytes = data.toByteArray(Charset.defaultCharset()) + val encryptedBytes = encryptionCipher.doFinal(dataBytes) + + // Saving base 64 encoded encrypted bytes + val base64EncryptedBytesString = Base64.encodeToString(encryptedBytes, Base64.DEFAULT) + sharedPrefs.edit().putString(key, base64EncryptedBytesString).apply() + + // Saving base 64 encoded initialization vector + val base64IvString = Base64.encodeToString(encryptionCipher.iv, Base64.DEFAULT) + sharedPrefs.edit().putString(getIvKey(key), base64IvString).apply() + } + + /** + * Gets String associated with key in shared prefs and decrypts it + */ + private fun getDecryptedString( + key: String, + aesSecretKey: SecretKey + ): String { + + if (!sharedPrefs.contains(key) || !sharedPrefs.contains(getIvKey(key))) return "" + + // Decoding saved encrypted bytes + val base64EncryptedBytesString = sharedPrefs.getString(key, "") + val encryptedBytes = Base64.decode(base64EncryptedBytesString, Base64.DEFAULT) + + // Decoding saved initialization vector + val base64IvString = sharedPrefs.getString(getIvKey(key), "") + val savedIv = Base64.decode(base64IvString, Base64.DEFAULT) + + // Decryption setup + val decryptionCipher = Cipher.getInstance(AES_TRANSFORMATION) + val gcmParameterSpec = GCMParameterSpec(128, savedIv) + decryptionCipher.init(Cipher.DECRYPT_MODE, aesSecretKey, gcmParameterSpec) + + // Decrypt encrypted bytes and return + val decryptedBytes = decryptionCipher.doFinal(encryptedBytes) + return decryptedBytes.toString(Charset.defaultCharset()) + } + + /** + * Gets the iv key for the passed key + */ + private fun getIvKey(key: String): String { + return "iv_key_$key" + } + + companion object { + private val AES_TRANSFORMATION = "AES/GCM/NoPadding" + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt new file mode 100644 index 0000000..09cfa85 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenDataSource.kt @@ -0,0 +1,20 @@ +package ca.bc.gov.mobileauthentication.data.repos.token + +import ca.bc.gov.mobileauthentication.data.models.Token +import io.reactivex.Observable + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +interface TokenDataSource { + + fun getToken(code: String? = null): Observable + + fun saveToken(token: Token): Observable + + fun refreshToken(token: Token): Observable + + fun deleteToken(): Observable + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt new file mode 100644 index 0000000..355bfc5 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenLocalDataSource.kt @@ -0,0 +1,78 @@ +package ca.bc.gov.mobileauthentication.data.repos.token + +import ca.bc.gov.mobileauthentication.common.exceptions.InvalidOperationException +import ca.bc.gov.mobileauthentication.data.models.Token +import com.google.gson.Gson +import io.reactivex.Observable + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +class TokenLocalDataSource +private constructor( + private val gson: Gson, + private val secureSharedPrefs: SecureSharedPrefs +) : TokenDataSource { + + companion object { + + private const val TOKEN_KEY = "TOKEN_KEY" + + @Volatile + private var INSTANCE: TokenLocalDataSource? = null + + fun getInstance(gson: Gson, secureSharedPrefs: SecureSharedPrefs): TokenLocalDataSource = + INSTANCE ?: synchronized(this) { + INSTANCE ?: TokenLocalDataSource(gson, secureSharedPrefs) + .also { INSTANCE = it } + } + } + + /** + * Gets token from local db and returns + */ + override fun getToken(code: String?): Observable { + return Observable.create { emitter -> + val tokenJson = secureSharedPrefs.getString(TOKEN_KEY) + if (tokenJson.isNotBlank()) { + val token: Token = gson.fromJson(tokenJson, Token::class.java) + emitter.onNext(token) + } + emitter.onComplete() + } + } + + /** + * Saves token to local db and returns saved version + */ + override fun saveToken(token: Token): Observable { + return Observable.create { emitter -> + val tokenJson = gson.toJson(token) + secureSharedPrefs.saveString(TOKEN_KEY, tokenJson) + + val savedTokenJson = secureSharedPrefs.getString(TOKEN_KEY) + val savedToken: Token = gson.fromJson(savedTokenJson, Token::class.java) + emitter.onNext(savedToken) + emitter.onComplete() + } + } + + /** + * Invalid operation for local data source + */ + override fun refreshToken(token: Token): Observable { + return Observable.error(InvalidOperationException()) + } + + /** + * Deletes token form local db + */ + override fun deleteToken(): Observable { + return Observable.create { emitter -> + secureSharedPrefs.deleteString(TOKEN_KEY) + emitter.onNext(true) + emitter.onComplete() + } + } +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt new file mode 100644 index 0000000..3c62e64 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRemoteDataSource.kt @@ -0,0 +1,69 @@ +package ca.bc.gov.mobileauthentication.data.repos.token + +import ca.bc.gov.mobileauthentication.common.exceptions.InvalidOperationException +import ca.bc.gov.mobileauthentication.common.exceptions.NoCodeException +import ca.bc.gov.mobileauthentication.common.exceptions.NoRefreshTokenException +import ca.bc.gov.mobileauthentication.data.AuthApi +import ca.bc.gov.mobileauthentication.data.models.Token +import io.reactivex.Observable + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +class TokenRemoteDataSource +private constructor( + private val authApi: AuthApi, + private val realmName: String, + private val grantType: String, + private val redirectUri: String, + private val clientId: String +) : TokenDataSource { + + companion object { + + @Volatile + private var INSTANCE: TokenRemoteDataSource? = null + + fun getInstance( + authApi: AuthApi, + realmName: String, + grantType: String, + redirectUri: String, + clientId: String): TokenRemoteDataSource = INSTANCE ?: synchronized(this) { + INSTANCE ?: TokenRemoteDataSource( + authApi, realmName, grantType, redirectUri, clientId).also { INSTANCE = it } + } + } + + /** + * Exchanges code for token using authentication api + * Returns error if code is null + */ + override fun getToken(code: String?): Observable { + if (code == null) return Observable.error(NoCodeException()) + return authApi.getToken(realmName, grantType, redirectUri, clientId, code) + } + + /** + * Invalid operation for remote data source + */ + override fun saveToken(token: Token): Observable { + return Observable.error(InvalidOperationException()) + } + + /** + * Refreshes token using authentication api + * Returns error if there is no refresh token + */ + override fun refreshToken(token: Token): Observable { + val refreshToken = token.refreshToken ?: return Observable.error(NoRefreshTokenException()) + return authApi.refreshToken(realmName, redirectUri, clientId, refreshToken) + } + /** + * Invalid operation for remote data source + */ + override fun deleteToken(): Observable { + return Observable.error(InvalidOperationException()) + } +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt new file mode 100644 index 0000000..9f6eaa1 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/data/repos/token/TokenRepo.kt @@ -0,0 +1,81 @@ +package ca.bc.gov.mobileauthentication.data.repos.token + +import ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException +import ca.bc.gov.mobileauthentication.data.models.Token +import io.reactivex.Observable + +/** + * Created by Aidan Laing on 2018-01-23. + * + */ +class TokenRepo +private constructor( + private val remoteDataSource: TokenDataSource, + private val localDataSource: TokenDataSource +) : TokenDataSource { + + companion object { + + @Volatile + private var INSTANCE: TokenRepo? = null + + fun getInstance( + remoteDataSource: TokenDataSource, + localDataSource: TokenDataSource + ): TokenRepo = INSTANCE ?: synchronized(this) { + INSTANCE ?: TokenRepo(remoteDataSource, localDataSource) + .also { INSTANCE = it } + } + + fun destroyInstance() { + INSTANCE = null + } + } + + /** + * Gets token from remote if code IS NOT null + * Gets token from local if code IS null + * Returns token if valid + * If token from local db refresh is expired then @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown. + * If refresh token is not expired and token is expired then token will be refreshed and returned + */ + override fun getToken(code: String?): Observable { + return if (code != null) { + remoteDataSource.getToken(code) + .flatMap { localDataSource.saveToken(it) } + } else { + localDataSource.getToken() + .flatMap { + when { + it.isRefreshExpired() -> Observable.error(RefreshExpiredException()) + it.isExpired() -> refreshToken(it) + else -> Observable.just(it) + } + } + } + } + + /** + * Saves token locally + */ + override fun saveToken(token: Token): Observable { + return localDataSource.saveToken(token) + } + + /** + * Refreshes token and saves to local db + * If passed token refresh token is expired then @see ca.bc.gov.mobileauthentication.common.exceptions.RefreshExpiredException will be thrown. + */ + override fun refreshToken(token: Token): Observable { + return if (token.isRefreshExpired()) Observable.error(RefreshExpiredException()) + else remoteDataSource.refreshToken(token) + .flatMap { localDataSource.saveToken(it) } + } + + /** + * Deletes token from local db + */ + override fun deleteToken(): Observable { + return localDataSource.deleteToken() + } +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt new file mode 100644 index 0000000..54f4091 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/Injection.kt @@ -0,0 +1,108 @@ +package ca.bc.gov.mobileauthentication.di + +import android.content.SharedPreferences +import ca.bc.gov.mobileauthentication.data.AuthApi +import ca.bc.gov.mobileauthentication.data.repos.token.SecureSharedPrefs +import ca.bc.gov.mobileauthentication.data.repos.token.TokenLocalDataSource +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRemoteDataSource +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.CallAdapter +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.security.KeyStore +import java.util.concurrent.TimeUnit + +/** + * Created by Aidan Laing on 2018-01-18. + * + */ +object Injection { + + // OkHttpClient + @JvmStatic + fun provideOkHttpClient( + readTimeOut: Long, + connectTimeOut: Long, + interceptor: HttpLoggingInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .readTimeout(readTimeOut, TimeUnit.SECONDS) + .connectTimeout(connectTimeOut, TimeUnit.SECONDS) + .addInterceptor(interceptor) + .build() + + // Logging interceptor + @JvmStatic + fun provideHttpLoggingInterceptor( + loggingLevel: HttpLoggingInterceptor.Level + ): HttpLoggingInterceptor = HttpLoggingInterceptor() + .apply { level = loggingLevel } + + // Gson + private var cachedGson: Gson? = null + + @JvmStatic + fun provideGson(): Gson = cachedGson ?: GsonBuilder() + .setLenient() + .create() + .also { cachedGson = it } + + // Converter Factory + @JvmStatic + fun provideConverterFactory(gson: Gson): Converter.Factory = GsonConverterFactory.create(gson) + + // Call Adapter Factory + private var cachedCallAdapterFactory: CallAdapter.Factory? = null + + @JvmStatic + fun provideCallAdapterFactory(): CallAdapter.Factory = cachedCallAdapterFactory + ?: + RxJava2CallAdapterFactory.create() + .also { cachedCallAdapterFactory = it } + + // Retrofit + @JvmStatic + fun provideRetrofit( + apiDomain: String, + okHttpClient: OkHttpClient, + converterFactory: Converter.Factory, + callAdapterFactory: CallAdapter.Factory + ): Retrofit = Retrofit.Builder() + .baseUrl(apiDomain) + .client(okHttpClient) + .addConverterFactory(converterFactory) + .addCallAdapterFactory(callAdapterFactory) + .build() + + // Auth Api + @JvmStatic + fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) + + // Token Repo + @JvmStatic + fun provideTokenRepo( + authApi: AuthApi, + realmName: String, + grantType: String, + redirectUri: String, + clientId: String, + gson: Gson, + secureSharedPrefs: SecureSharedPrefs + ): TokenRepo = TokenRepo.getInstance( + TokenRemoteDataSource.getInstance(authApi, realmName, grantType, redirectUri, clientId), + TokenLocalDataSource.getInstance(gson, secureSharedPrefs)) + + // Secure shared prefs + @JvmStatic + fun provideSecureSharedPrefs( + keyStore: KeyStore, sharedPreferences: SharedPreferences): SecureSharedPrefs = + SecureSharedPrefs(keyStore, sharedPreferences) + + @JvmStatic + fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore") +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt new file mode 100644 index 0000000..3709ee1 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/di/InjectionUtils.kt @@ -0,0 +1,61 @@ +package ca.bc.gov.mobileauthentication.di + +import android.content.SharedPreferences +import ca.bc.gov.mobileauthentication.data.AuthApi +import ca.bc.gov.mobileauthentication.common.Constants +import ca.bc.gov.mobileauthentication.data.repos.token.SecureSharedPrefs +import ca.bc.gov.mobileauthentication.data.repos.token.TokenLocalDataSource +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRemoteDataSource +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo +import com.google.gson.Gson +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.CallAdapter +import retrofit2.Converter +import retrofit2.Retrofit +import java.security.KeyStore + +/** + * Created by Aidan Laing on 2018-01-18. + * + */ +object InjectionUtils { + + /** + * Gets Auth Api with standard params + */ + fun getAuthApi( + apiDomain: String, + gson: Gson = Injection.provideGson(), + converterFactory: Converter.Factory = Injection.provideConverterFactory(gson), + callAdapterFactory : CallAdapter.Factory = Injection.provideCallAdapterFactory(), + readTimeOut: Long = Constants.READ_TIME_OUT, + connectTimeOut: Long = Constants.CONNECT_TIME_OUT, + loggingLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY, + httpLoggingInterceptor: HttpLoggingInterceptor = Injection.provideHttpLoggingInterceptor( + loggingLevel), + okHttpClient: OkHttpClient = Injection.provideOkHttpClient( + readTimeOut, connectTimeOut, httpLoggingInterceptor), + retrofit: Retrofit = Injection.provideRetrofit( + apiDomain, okHttpClient, converterFactory, callAdapterFactory) + ): AuthApi = Injection.provideAuthApi(retrofit) + + /** + * Gets Token Repo with standard params + */ + fun getTokenRepo( + authApi: AuthApi, + realmName: String, + grantType: String, + redirectUri: String, + clientId: String, + sharedPreferences: SharedPreferences, + gson: Gson = Injection.provideGson(), + keyStore: KeyStore = Injection.provideKeyStore(), + secureSharedPrefs: SecureSharedPrefs = Injection.provideSecureSharedPrefs(keyStore, sharedPreferences) + ): TokenRepo = TokenRepo.getInstance( + TokenRemoteDataSource.getInstance(authApi, realmName, grantType, redirectUri, clientId), + TokenLocalDataSource.getInstance(gson, secureSharedPrefs) + ) + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt new file mode 100644 index 0000000..23fc0ac --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectActivity.kt @@ -0,0 +1,163 @@ +package ca.bc.gov.mobileauthentication.screens.redirect + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.preference.PreferenceManager +import android.widget.Toast +import ca.bc.gov.mobileauthentication.R +import ca.bc.gov.mobileauthentication.di.InjectionUtils +import android.support.customtabs.CustomTabsIntent +import android.support.v4.content.ContextCompat +import android.support.v7.app.AppCompatActivity +import android.view.View +import ca.bc.gov.mobileauthentication.MobileAuthenticationClient +import ca.bc.gov.mobileauthentication.common.Constants +import ca.bc.gov.mobileauthentication.common.utils.UrlUtils +import ca.bc.gov.mobileauthentication.di.Injection +import kotlinx.android.synthetic.main.activity_login.* + +class RedirectActivity : AppCompatActivity(), RedirectContract.View { + + override var presenter: RedirectContract.Presenter? = null + + override var loading: Boolean = false + + companion object { + const val BASE_URL = "BASE_URL" + const val REALM_NAME = "REALM_NAME" + const val AUTH_ENDPOINT = "AUTH_ENDPOINT" + const val REDIRECT_URI = "REDIRECT_URI" + const val CLIENT_ID = "CLIENT_ID" + } + + // Life cycle + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_login) + + val baseUrl: String? = intent.getStringExtra(BASE_URL) + val realmName: String? = intent.getStringExtra(REALM_NAME) + val authEndpoint: String? = intent.getStringExtra(AUTH_ENDPOINT) + val redirectUri: String? = intent.getStringExtra(REDIRECT_URI) + val clientId: String? = intent.getStringExtra(CLIENT_ID) + + // Checking for required params + if (baseUrl == null) { + showToastAndFinish(getString(R.string.error_missing_base_url)) + return + } + + if (realmName == null) { + showToastAndFinish(getString(R.string.error_missing_realm_name)) + return + } + + if (authEndpoint == null) { + showToastAndFinish(getString(R.string.error_missing_auth_endpoint)) + return + } + + if (redirectUri == null) { + showToastAndFinish(getString(R.string.error_missing_redirect_uri)) + return + } + + if (clientId == null) { + showToastAndFinish(getString(R.string.error_missing_client_id)) + return + } + + // Building presenter params + val grantType = Constants.GRANT_TYPE_AUTH_CODE + val responseType = Constants.RESPONSE_TYPE_CODE + + val authApi = InjectionUtils.getAuthApi(UrlUtils.cleanBaseUrl(baseUrl)) + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val tokenRepo = InjectionUtils.getTokenRepo( + authApi, realmName, grantType, redirectUri, clientId, sharedPreferences) + + val gson = Injection.provideGson() + + RedirectPresenter( + this, authEndpoint, redirectUri, clientId, responseType, tokenRepo, gson) + + presenter?.subscribe() + } + + override fun onDestroy() { + super.onDestroy() + presenter?.dispose() + } + + // Toasts + private fun showToastAndFinish(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + setResultError(message) + finish() + } + + // Deep link triggered + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + checkIntentForRedirect(intent) + } + + private fun checkIntentForRedirect(intent: Intent?) { + if (intent != null && intent.action == Intent.ACTION_VIEW && intent.data != null) { + presenter?.redirectReceived(intent.data.toString()) + } + } + + // Loading + override fun showLoading() { + progressBar.visibility = View.VISIBLE + } + + override fun hideLoading() { + progressBar.visibility = View.GONE + } + + // Login + override fun setUpLoginListener() { + loginTv.setOnClickListener { + presenter?.loginClicked() + } + } + + override fun setLoginTextLogin() { + loginTv.setText(R.string.login) + } + + override fun setLoginTextLoggingIn() { + loginTv.setText(R.string.logging_in) + } + + /** + * Goes to Chrome custom tab + */ + override fun loadWithChrome(url: String) { + CustomTabsIntent.Builder() + .addDefaultShareMenuItem() + .setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary)) + .setShowTitle(true) + .build() + .launchUrl(this, Uri.parse(url)) + } + + // Results + override fun setResultError(errorMessage: String) { + val data = Intent() + data.putExtra(MobileAuthenticationClient.SUCCESS, false) + data.putExtra(MobileAuthenticationClient.ERROR_MESSAGE, errorMessage) + setResult(Activity.RESULT_OK, data) + } + + override fun setResultSuccess(tokenJson: String) { + val data = Intent() + data.putExtra(MobileAuthenticationClient.SUCCESS, true) + data.putExtra(MobileAuthenticationClient.TOKEN_JSON, tokenJson) + setResult(Activity.RESULT_OK, data) + } +} diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt new file mode 100644 index 0000000..215b6d9 --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectContract.kt @@ -0,0 +1,36 @@ +package ca.bc.gov.mobileauthentication.screens.redirect + +import ca.bc.gov.mobileauthentication.common.BasePresenter +import ca.bc.gov.mobileauthentication.common.BaseView + +/** + * Created by Aidan Laing on 2017-12-12. + * + */ +interface RedirectContract { + + interface View: BaseView { + var loading: Boolean + + fun finish() + + fun showLoading() + fun hideLoading() + + fun setUpLoginListener() + fun setLoginTextLogin() + fun setLoginTextLoggingIn() + + fun loadWithChrome(url: String) + + fun setResultError(errorMessage: String) + fun setResultSuccess(tokenJson: String) + } + + interface Presenter: BasePresenter { + fun loginClicked() + + fun redirectReceived(redirectUrl: String) + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt new file mode 100644 index 0000000..5906e9a --- /dev/null +++ b/mobileauthentication/src/main/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenter.kt @@ -0,0 +1,97 @@ +package ca.bc.gov.mobileauthentication.screens.redirect + +import ca.bc.gov.mobileauthentication.common.utils.UrlUtils +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo +import com.google.gson.Gson +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import io.reactivex.rxkotlin.subscribeBy +import io.reactivex.schedulers.Schedulers + +/** + * Created by Aidan Laing on 2017-12-12. + * + */ +class RedirectPresenter( + private val view: RedirectContract.View, + private val authEndpoint: String, + private val redirectUri: String, + private val clientId: String, + private val responseType: String, + private val tokenRepo: TokenRepo, + private val gson: Gson +): RedirectContract.Presenter { + + private val disposables = CompositeDisposable() + + init { + view.presenter = this + } + + override fun subscribe() { + setViewLoginMode() + view.setUpLoginListener() + } + + override fun dispose() { + disposables.dispose() + } + + override fun loginClicked() { + if (!view.loading) view.loadWithChrome(buildAuthUrl()) + } + + // Auth url + fun buildAuthUrl(): String = + "$authEndpoint?response_type=$responseType&client_id=$clientId&redirect_uri=$redirectUri" + + // Redirect + override fun redirectReceived(redirectUrl: String) { + if (!redirectUrl.contains("code=".toRegex())) { + return + } + + val code = UrlUtils.extractCode(redirectUrl) + getToken(code) + } + + /** + * Gets token remotely using Authorization Code and saves locally + */ + fun getToken(code: String) { + tokenRepo.getToken(code) + .firstOrError() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe { + setViewLoadingMode() + }.subscribeBy( + onError = { + setViewLoginMode() + + view.setResultError(it.message ?: "Error logging in") + view.finish() + }, + onSuccess = { token -> + setViewLoginMode() + + val tokenJson = gson.toJson(token) + view.setResultSuccess(tokenJson) + view.finish() + } + ).addTo(disposables) + } + + fun setViewLoginMode() { + view.loading = false + view.hideLoading() + view.setLoginTextLogin() + } + + fun setViewLoadingMode() { + view.loading = true + view.showLoading() + view.setLoginTextLoggingIn() + } +} \ No newline at end of file diff --git a/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml b/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml new file mode 100644 index 0000000..946d1fd --- /dev/null +++ b/mobileauthentication/src/main/res/drawable/ic_bc_logo.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml new file mode 100644 index 0000000..d179061 --- /dev/null +++ b/mobileauthentication/src/main/res/drawable/selector_rect_color_primary_light_rounded_28dp.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml new file mode 100644 index 0000000..b1e408c --- /dev/null +++ b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_pressed_rounded_28dp.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml new file mode 100644 index 0000000..be2fce0 --- /dev/null +++ b/mobileauthentication/src/main/res/drawable/shape_rect_color_primary_light_rounded_28dp.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/mobileauthentication/src/main/res/layout/activity_login.xml b/mobileauthentication/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..4d41546 --- /dev/null +++ b/mobileauthentication/src/main/res/layout/activity_login.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mobileauthentication/src/main/res/values/colors.xml b/mobileauthentication/src/main/res/values/colors.xml new file mode 100644 index 0000000..0dfa5c6 --- /dev/null +++ b/mobileauthentication/src/main/res/values/colors.xml @@ -0,0 +1,13 @@ + + + + #003366 + #003366 + #FCBA19 + + #5475A7 + #4b6996 + + #FFFFFF + + diff --git a/mobileauthentication/src/main/res/values/strings.xml b/mobileauthentication/src/main/res/values/strings.xml new file mode 100644 index 0000000..bd2dc0f --- /dev/null +++ b/mobileauthentication/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Missing base url intent parameter + Missing realm name intent parameter + Missing auth endpoint intent parameter + Missing redirect uri intent parameter + Missing client id intent parameter + Login + Logging In + diff --git a/mobileauthentication/src/main/res/values/styles.xml b/mobileauthentication/src/main/res/values/styles.xml new file mode 100644 index 0000000..0176218 --- /dev/null +++ b/mobileauthentication/src/main/res/values/styles.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt new file mode 100644 index 0000000..e84045b --- /dev/null +++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/RxImmediateSchedulerRule.kt @@ -0,0 +1,49 @@ +package ca.bc.gov.mobileauthentication + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +import java.util.concurrent.Callable + +import io.reactivex.Scheduler +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.functions.Function +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers + +/** + * JUnit Test Rule which overrides RxJava and Android schedulers for use in unit tests. + * + * + * All schedulers are replaced with Schedulers.trampoline(). + */ +class RxImmediateSchedulerRule: TestRule { + + private val schedulerInstance = Schedulers.trampoline() + + private val schedulerFunction = Function { schedulerInstance } + + private val schedulerFunctionLazy = Function, Scheduler> { schedulerInstance } + + override fun apply(base: Statement, description: Description): Statement { + return object: Statement() { + + @Throws(Throwable::class) + override fun evaluate() { + RxAndroidPlugins.reset() + RxAndroidPlugins.setInitMainThreadSchedulerHandler(schedulerFunctionLazy) + + RxJavaPlugins.reset() + RxJavaPlugins.setIoSchedulerHandler(schedulerFunction) + RxJavaPlugins.setNewThreadSchedulerHandler(schedulerFunction) + RxJavaPlugins.setComputationSchedulerHandler(schedulerFunction) + + base.evaluate() + + RxAndroidPlugins.reset() + RxJavaPlugins.reset() + } + } + } +} \ No newline at end of file diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt new file mode 100644 index 0000000..0bd2d66 --- /dev/null +++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/common/utils/UrlUtilsTest.kt @@ -0,0 +1,58 @@ +package ca.bc.gov.mobileauthentication.common.utils + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Created by Aidan Laing on 2018-02-05. + * + */ +class UrlUtilsTest { + + @Test + fun cleanBaseUrlNoSlash() { + val baseUrl = "http://foobar.com" + val expected = "http://foobar.com/" + val actual = UrlUtils.cleanBaseUrl(baseUrl) + + assertEquals(expected, actual) + } + + @Test + fun cleanBaseUrlSlash() { + val baseUrl = "http://foobar.com/" + val expected = "http://foobar.com/" + val actual = UrlUtils.cleanBaseUrl(baseUrl) + + assertEquals(expected, actual) + } + + @Test + fun extractCodeSingleQueryParam() { + val codeUrl = "http://foobar.com?code=123" + val expected = "123" + val actual = UrlUtils.extractCode(codeUrl) + + assertEquals(expected, actual) + } + + @Test + fun extractCodeSingleMultipleParam() { + val codeUrl = "http://foobar.com?code=123&moo=abc" + val expected = "123" + val actual = UrlUtils.extractCode(codeUrl) + + assertEquals(expected, actual) + } + + @Test + fun extractCodeNoCode() { + val codeUrl = "http://foobar.com" + val expected = "" + val actual = UrlUtils.extractCode(codeUrl) + + assertEquals(expected, actual) + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt new file mode 100644 index 0000000..8648f7c --- /dev/null +++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/data/models/TokenTest.kt @@ -0,0 +1,91 @@ +package ca.bc.gov.mobileauthentication.data.models + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Created by Aidan Laing on 2018-02-05. + * + */ +class TokenTest { + + @Test + fun tokenExpiryAfterCurrentTime() { + val currentTime = 2000L + val expiresAt = 3000L + val token = Token(null, null, null, null, + null, null, null, null, expiresAt) + + val expected = true + val actual = token.isExpired(currentTime) + + assertEquals(expected, actual) + } + + @Test + fun tokenExpiryBeforeCurrentTime() { + val currentTime = 2000L + val expiresAt = 1000L + val token = Token(null, null, null, null, + null, null, null, null, expiresAt) + + val expected = false + val actual = token.isExpired(currentTime) + + assertEquals(expected, actual) + } + + @Test + fun tokenExpiryEqualToCurrentTime() { + val currentTime = 2000L + val expiresAt = 2000L + val token = Token(null, null, null, null, + null, null, null, null, expiresAt) + + val expected = false + val actual = token.isExpired(currentTime) + + assertEquals(expected, actual) + } + + @Test + fun refreshExpiryAfterCurrentTime() { + val currentTime = 2000L + val refreshExpiresAt = 3000L + val token = Token(null, null, null, null, + null, null, null, null, 0, refreshExpiresAt) + + val expected = true + val actual = token.isRefreshExpired(currentTime) + + assertEquals(expected, actual) + } + + @Test + fun refreshExpiryBeforeCurrentTime() { + val currentTime = 2000L + val refreshExpiresAt = 1000L + val token = Token(null, null, null, null, + null, null, null, null, 0, refreshExpiresAt) + + val expected = false + val actual = token.isRefreshExpired(currentTime) + + assertEquals(expected, actual) + } + + @Test + fun refreshExpiryEqualToCurrentTime() { + val currentTime = 2000L + val refreshExpiresAt = 2000L + val token = Token(null, null, null, null, + null, null, null, null, 0, refreshExpiresAt) + + val expected = false + val actual = token.isRefreshExpired(currentTime) + + assertEquals(expected, actual) + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt new file mode 100644 index 0000000..bc104c9 --- /dev/null +++ b/mobileauthentication/src/test/java/ca/bc/gov/mobileauthentication/screens/redirect/RedirectPresenterTest.kt @@ -0,0 +1,161 @@ +package ca.bc.gov.mobileauthentication.screens.redirect + +import ca.bc.gov.mobileauthentication.RxImmediateSchedulerRule +import ca.bc.gov.mobileauthentication.data.models.Token +import ca.bc.gov.mobileauthentication.data.repos.token.TokenRepo +import com.google.gson.Gson +import com.nhaarman.mockito_kotlin.* +import io.reactivex.Observable +import org.junit.After +import org.junit.Test + +import org.junit.Before +import org.junit.ClassRule +import org.junit.Assert.* + +/** + * Created by Aidan Laing on 2018-02-05. + * + */ +class RedirectPresenterTest { + + companion object { + @ClassRule + @JvmField + val rxSchedulers = RxImmediateSchedulerRule() + } + + private lateinit var view: RedirectContract.View + + private lateinit var tokenRepo: TokenRepo + + private lateinit var gson: Gson + + private val authEndpoint = "http://helloworld.com" + private val redirectUri = "hello://world" + private val clientId = "abc123" + private val responseType = "code" + + private lateinit var presenter: RedirectPresenter + + @Before + fun setUp() { + view = mock() + + tokenRepo = mock() + + gson = mock() + + presenter = RedirectPresenter(view, authEndpoint, redirectUri, clientId, responseType, + tokenRepo, gson) + } + + @After + fun tearDown() { + TokenRepo.destroyInstance() + } + + @Test + fun presenterSet() { + verify(view).presenter = presenter + } + + @Test + fun subscribe() { + presenter.subscribe() + + verify(view).setUpLoginListener() + } + + @Test + fun loginClickedNotLoading() { + whenever(view.loading).thenReturn(false) + + presenter.loginClicked() + + verify(view).loading + verify(view).loadWithChrome(any()) + } + + @Test + fun loginClickedLoading() { + whenever(view.loading).thenReturn(true) + + presenter.loginClicked() + + verify(view).loading + verify(view).presenter = presenter + verifyNoMoreInteractions(view) + } + + @Test + fun buildAuthUrl() { + val expected = "$authEndpoint?response_type=$responseType&client_id=$clientId&redirect_uri=$redirectUri" + val actual = presenter.buildAuthUrl() + + assertEquals(expected, actual) + } + + @Test + fun redirectReceived() { + val code = "123" + val token = Token("opensesame",null,null,null, + null,null,null,null) + whenever(tokenRepo.getToken(code)).thenReturn(Observable.just(token)) + + val tokenJson = "{ \"accessToken\" : \"opensesame\"}" + whenever(gson.toJson(token)).thenReturn(tokenJson) + + val redirectUrl = "http://www.foobar.com?code=$code" + + presenter.redirectReceived(redirectUrl) + + verify(view).setResultSuccess(tokenJson) + verify(view).finish() + } + + @Test + fun redirectReceivedNoCode() { + val redirectUrl = "http://www.foobar.com" + + presenter.redirectReceived(redirectUrl) + + verify(view).presenter = presenter + verifyNoMoreInteractions(view) + } + + @Test + fun getToken() { + val code = "123" + val token = Token("opensesame",null,null,null, + null,null,null,null) + whenever(tokenRepo.getToken(code)).thenReturn(Observable.just(token)) + + val tokenJson = "{ \"accessToken\" : \"opensesame\"}" + whenever(gson.toJson(token)).thenReturn(tokenJson) + + presenter.getToken(code) + + verify(view).setResultSuccess(tokenJson) + verify(view).finish() + } + + @Test + fun setViewLoginMode() { + presenter.setViewLoginMode() + + verify(view).loading = false + verify(view).hideLoading() + verify(view).setLoginTextLogin() + } + + @Test + fun setViewLoadingMode() { + presenter.setViewLoadingMode() + + verify(view).loading = true + verify(view).showLoading() + verify(view).setLoginTextLoggingIn() + } + +} \ No newline at end of file diff --git a/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/mobileauthentication/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index e7b4def..1861058 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':app' +include ':app', ':mobileauthentication'