taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }