ConfigManager.kt (10609B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2020 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.cashier.config 18 19 import android.annotation.SuppressLint 20 import android.app.Application 21 import android.util.Log 22 import androidx.annotation.UiThread 23 import androidx.annotation.WorkerThread 24 import androidx.core.content.edit 25 import androidx.core.net.toUri 26 import androidx.lifecycle.LiveData 27 import androidx.lifecycle.MutableLiveData 28 import androidx.security.crypto.EncryptedSharedPreferences 29 import androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV 30 import androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM 31 import androidx.security.crypto.MasterKeys 32 import androidx.security.crypto.MasterKeys.AES256_GCM_SPEC 33 import io.ktor.client.HttpClient 34 import io.ktor.client.call.body 35 import io.ktor.client.request.get 36 import io.ktor.client.request.header 37 import io.ktor.client.request.post 38 import io.ktor.client.request.setBody 39 import io.ktor.http.ContentType 40 import io.ktor.http.HttpHeaders.Authorization 41 import io.ktor.http.HttpStatusCode.Companion.Unauthorized 42 import io.ktor.http.contentType 43 import kotlinx.coroutines.CoroutineScope 44 import kotlinx.coroutines.Dispatchers 45 import kotlinx.coroutines.launch 46 import kotlinx.coroutines.withContext 47 import kotlinx.serialization.json.buildJsonObject 48 import net.taler.cashier.BuildConfig 49 import net.taler.cashier.Response.Companion.response 50 import net.taler.common.ChallengeConfirmRequest 51 import net.taler.common.ChallengesResponse 52 import net.taler.common.Timestamp 53 import net.taler.common.TokenDuration 54 import net.taler.common.TokenRequest 55 import net.taler.common.TokenSuccessResponse 56 import net.taler.common.Version 57 import net.taler.lib.android.getIncompatibleStringOrNull 58 59 val VERSION_BANK = Version.parse(BuildConfig.BACKEND_API_VERSION)!! 60 private const val PREF_NAME = "net.taler.cashier.prefs" 61 private const val PREF_KEY_BANK_URL = "bankUrl" 62 private const val PREF_KEY_USERNAME = "username" 63 private const val PREF_KEY_PASSWORD = "password" 64 private const val PREF_KEY_ACCESS_TOKEN = "accessToken" 65 private const val PREF_KEY_EXPIRATION = "expiration" 66 private const val PREF_KEY_CURRENCY = "currency" 67 68 private val TAG = ConfigManager::class.java.simpleName 69 70 class ConfigManager( 71 private val app: Application, 72 private val scope: CoroutineScope, 73 private val httpClient: HttpClient, 74 ) { 75 76 val configDestination = ConfigFragmentDirections.actionGlobalConfigFragment() 77 78 private val masterKeyAlias = MasterKeys.getOrCreate(AES256_GCM_SPEC) 79 private val prefs = EncryptedSharedPreferences.create( 80 PREF_NAME, masterKeyAlias, app, AES256_SIV, AES256_GCM 81 ) 82 83 internal var config = Config( 84 bankUrl = prefs.getString(PREF_KEY_BANK_URL, "")!!, 85 username = prefs.getString(PREF_KEY_USERNAME, "")!!, 86 password = prefs.getString(PREF_KEY_PASSWORD, "")!!, 87 accessToken = prefs.getString(PREF_KEY_ACCESS_TOKEN, null), 88 expiration = prefs.getLong(PREF_KEY_EXPIRATION, 0).let { if (it == 0L) null else Timestamp.fromMillis(it) } 89 ) 90 91 private val mCurrency = MutableLiveData<String>( 92 prefs.getString(PREF_KEY_CURRENCY, null) 93 ) 94 internal val currency: LiveData<String> = mCurrency 95 96 private val mConfigResult = MutableLiveData<ConfigResult?>() 97 val configResult: LiveData<ConfigResult?> = mConfigResult 98 99 fun hasConfig() = config.bankUrl.isNotEmpty() 100 && config.username.isNotEmpty() 101 && config.password.isNotEmpty() 102 103 /** 104 * Start observing [configResult] after calling this to get the result async. 105 * Warning: Ignore null results that are used to reset old results. 106 */ 107 @UiThread 108 fun checkAndSaveConfig(config: Config, challengeIds: List<String> = emptyList()) = scope.launch { 109 mConfigResult.value = null 110 if (config.isTokenValid()) { 111 this@ConfigManager.config = config 112 mCurrency.postValue(prefs.getString(PREF_KEY_CURRENCY, null)) 113 mConfigResult.postValue(ConfigResult.Success) 114 return@launch 115 } 116 checkConfig(config).onError { failure -> 117 val result = if (failure.isOffline(app)) { 118 ConfigResult.Offline 119 } else { 120 ConfigResult.Error(failure.statusCode == Unauthorized, failure.msg) 121 } 122 mConfigResult.postValue(result) 123 }.onSuccess { response -> 124 val versionIncompatible = 125 VERSION_BANK.getIncompatibleStringOrNull(app, response.version) 126 val result = if (versionIncompatible != null) { 127 ConfigResult.Error(false, versionIncompatible) 128 } else { 129 // get access token 130 when (val tokenRes = getToken(config, challengeIds)) { 131 is TokenResult.Success, is TokenResult.TanRequired -> { 132 mCurrency.postValue(response.currency) 133 prefs.edit { putString(PREF_KEY_CURRENCY, response.currency) } 134 // save config 135 if (tokenRes is TokenResult.Success) { 136 saveConfig(config.copy( 137 accessToken = tokenRes.accessToken, 138 expiration = tokenRes.expiration 139 )) 140 } else { 141 saveConfig(config) 142 } 143 when (tokenRes) { 144 is TokenResult.Success -> ConfigResult.Success 145 is TokenResult.TanRequired -> ConfigResult.TanRequired(tokenRes.challenges, tokenRes.combiAnd) 146 } 147 } 148 149 is TokenResult.Error -> { 150 ConfigResult.Error(tokenRes.authError, tokenRes.msg) 151 } 152 153 else -> ConfigResult.Unknown 154 } 155 } 156 mConfigResult.postValue(result) 157 } 158 } 159 160 private suspend fun checkConfig(config: Config) = withContext(Dispatchers.IO) { 161 val url = config.bankUrl.toUri() 162 .buildUpon() 163 .appendPath("config") 164 .build() 165 .toString() 166 Log.d(TAG, "Checking config: $url") 167 response { 168 httpClient.get(url).body<ConfigResponse>() 169 } 170 } 171 172 private suspend fun getToken(config: Config, challengeIds: List<String> = emptyList()): TokenResult? = withContext(Dispatchers.IO) { 173 // fetch authentication token 174 val tokenUrl = config.bankUrl.toUri() 175 .buildUpon() 176 .appendPath("accounts") 177 .appendPath(config.username) 178 .appendPath("token") 179 .build() 180 .toString() 181 var result: TokenResult? = null 182 response { 183 val res = httpClient.post(tokenUrl) { 184 header(Authorization, config.basicAuth) 185 if (challengeIds.isNotEmpty()) { 186 header("Taler-Challenge-Ids", challengeIds.joinToString(",")) 187 } 188 contentType(ContentType.Application.Json) 189 setBody(TokenRequest(scope = "readwrite", duration = TokenDuration.Forever)) 190 } 191 192 return@response when (res.status.value) { 193 200 -> res.body<TokenSuccessResponse>() 194 202 -> res.body<ChallengesResponse>() 195 else -> res.body<Unit>() 196 } 197 }.onSuccess { res -> 198 if (res is TokenSuccessResponse) { 199 result = TokenResult.Success(res.expiration, res.accessToken) 200 } else if (res is ChallengesResponse) { 201 result = TokenResult.TanRequired(res.challenges, res.combiAnd) 202 } 203 }.onError { err -> 204 result = TokenResult.Error(err.statusCode == Unauthorized, err.msg) 205 } 206 return@withContext result 207 } 208 209 suspend fun requestChallenge( 210 baseUrl: String, 211 username: String, 212 challengeId: String 213 ) = withContext(Dispatchers.IO) { 214 val challengeUrl = baseUrl.toUri() 215 .buildUpon() 216 .appendPath("accounts") 217 .appendPath(username) 218 .appendPath("challenge") 219 .appendPath(challengeId) 220 .build() 221 .toString() 222 httpClient.post(challengeUrl) { 223 contentType(ContentType.Application.Json) 224 setBody(buildJsonObject { }) 225 } 226 } 227 228 suspend fun confirmChallenge( 229 baseUrl: String, 230 username: String, 231 challengeId: String, 232 tan: String 233 ) = withContext(Dispatchers.IO) { 234 val confirmUrl = baseUrl.toUri() 235 .buildUpon() 236 .appendPath("accounts") 237 .appendPath(username) 238 .appendPath("challenge") 239 .appendPath(challengeId) 240 .appendPath("confirm") 241 .build() 242 .toString() 243 httpClient.post(confirmUrl) { 244 contentType(ContentType.Application.Json) 245 setBody(ChallengeConfirmRequest(tan = tan)) 246 } 247 } 248 249 @WorkerThread 250 @SuppressLint("ApplySharedPref") 251 internal fun saveConfig(config: Config) { 252 this.config = config 253 prefs.edit(commit = true) { 254 putString(PREF_KEY_BANK_URL, config.bankUrl) 255 putString(PREF_KEY_USERNAME, config.username) 256 putString(PREF_KEY_PASSWORD, config.password) 257 if (config.accessToken != null) { 258 putString(PREF_KEY_ACCESS_TOKEN, config.accessToken) 259 } else { 260 remove(PREF_KEY_ACCESS_TOKEN) 261 } 262 if (config.expiration != null) { 263 putLong(PREF_KEY_EXPIRATION, config.expiration.ms) 264 } else { 265 remove(PREF_KEY_EXPIRATION) 266 } 267 } 268 } 269 270 @WorkerThread 271 fun lock() { 272 saveConfig(config.copy(password = "")) 273 } 274 275 }