Handle AppConextManager more robustly.

This commit is contained in:
Dain Nilsson 2022-08-19 17:28:31 +02:00
parent e60ded1347
commit f52fe40b01
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
12 changed files with 215 additions and 211 deletions

View File

@ -1,13 +1,12 @@
package com.yubico.authenticator
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.yubico.authenticator.logging.Log
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) {
private val channel = FlutterChannel(messenger, "android.state.appContext")
private val channel = MethodChannel(messenger, "android.state.appContext")
init {
channel.setHandler(coroutineScope) { method, args ->
@ -18,12 +17,11 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri
}
}
private suspend fun setContext(subPageIndex: Int): String {
val appContext = OperationContext.getByValue(subPageIndex)
appViewModel.setContext(appContext)
Log.d(TAG, "App context is now ${appContext}")
return FlutterChannel.NULL
appViewModel.setAppContext(appContext)
Log.d(TAG, "App context is now $appContext")
return NULL
}
companion object {

View File

@ -2,6 +2,10 @@ package com.yubico.authenticator
import com.yubico.yubikit.core.YubiKeyDevice
/**
* Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app.
*/
interface AppContextManager {
suspend fun processYubiKey(device: YubiKeyDevice)
fun dispose()
}

View File

@ -0,0 +1,105 @@
package com.yubico.authenticator
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import java.io.Closeable
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/**
* Observes a LiveData value, sending each change to Flutter via an EventChannel.
*/
inline fun <reified T> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, channelName: String): Closeable {
val channel = EventChannel(messenger, channelName)
var sink: EventChannel.EventSink? = null
channel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
events.success(value?.let(jsonSerializer::encodeToString) ?: NULL)
}
override fun onCancel(arguments: Any?) {
sink = null
}
})
val observer = Observer<T> {
sink?.success(it?.let(jsonSerializer::encodeToString) ?: NULL)
}
observe(lifecycleOwner, observer)
return Closeable {
removeObserver(observer)
channel.setStreamHandler(null)
}
}
typealias MethodHandler = suspend (method: String, args: Map<String, Any?>) -> String
/**
* Coroutine-based handing of MethodChannel methods called from Flutter.
*/
fun MethodChannel.setHandler(scope: CoroutineScope, handler: MethodHandler) {
setMethodCallHandler { call, result ->
// N.B. Arguments from Flutter are passed as a Map of basic types. We may want to
// consider JSON encoding if we need to pass more complex structures.
// Return values are always JSON strings.
val args = call.arguments<Map<String, Any?>>() ?: mapOf()
scope.launch {
try {
val response = handler.invoke(call.method, args)
result.success(response)
} catch (notImplemented: NotImplementedError) {
result.notImplemented()
} catch (error: Throwable) {
result.error(
error.javaClass.simpleName,
error.toString(),
"Cause: " + error.cause + ", Stacktrace: " + android.util.Log.getStackTraceString(
error
)
)
}
}
}
}
/**
* Coroutine-based method invocation to call a Flutter method and get a result.
*/
suspend fun MethodChannel.invoke(method: String, args: Any?): Any? =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
invokeMethod(
method,
args,
object : MethodChannel.Result {
override fun success(result: Any?) {
continuation.resume(result)
}
override fun error(
errorCode: String,
errorMessage: String?,
errorDetails: Any?
) {
continuation.resumeWithException(Exception("$errorCode: $errorMessage - $errorDetails"))
}
override fun notImplemented() {
continuation.resumeWithException(NotImplementedError("Method not implemented: $method"))
}
})
}
}

View File

@ -1,6 +1,7 @@
package com.yubico.authenticator
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@ -15,12 +16,12 @@ enum class Icon(val value: String) {
class DialogManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) {
private val channel =
FlutterChannel(messenger, "com.yubico.authenticator.channel.dialog")
MethodChannel(messenger, "com.yubico.authenticator.channel.dialog")
private var onCancelled: OnDialogCancelled? = null
init {
channel.setHandler(coroutineScope) { method, args ->
channel.setHandler(coroutineScope) { method, _ ->
when (method) {
"cancel" -> dialogClosed()
else -> throw NotImplementedError()
@ -31,7 +32,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
fun showDialog(icon: Icon, title: String, description: String, cancelled: OnDialogCancelled?) {
onCancelled = cancelled
coroutineScope.launch {
channel.call(
channel.invoke(
"show",
Json.encodeToString(
mapOf(
@ -49,7 +50,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
title: String? = null,
description: String? = null
) {
channel.call(
channel.invoke(
"state",
Json.encodeToString(
mapOf(
@ -63,7 +64,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
fun closeDialog() {
coroutineScope.launch {
channel.call("close")
channel.invoke("close", NULL)
}
}
@ -74,7 +75,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro
it.invoke()
}
}
return FlutterChannel.NULL
return NULL
}
companion object {

View File

@ -1,76 +0,0 @@
package com.yubico.authenticator
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class FlutterChannel(
messenger: BinaryMessenger,
channel: String,
) {
companion object {
const val NULL = "null"
}
private val platform: MethodChannel = MethodChannel(messenger, channel)
fun setHandler(
scope: CoroutineScope,
handler: suspend (method: String, args: Map<String, Any?>) -> String
) {
platform.setMethodCallHandler { call, result ->
// N.B. Arguments from Flutter are passed as a Map of basic types. We may want to
// consider JSON encoding if we need to pass more complex structures.
// Return values are always JSON strings.
val args = call.arguments<Map<String, Any?>>() ?: mapOf()
scope.launch {
try {
val response = handler.invoke(call.method, args)
result.success(response)
} catch (notImplemented: NotImplementedError) {
result.notImplemented()
} catch (error: Throwable) {
result.error(
error.javaClass.simpleName,
error.toString(),
"Cause: " + error.cause + ", Stacktrace: " + android.util.Log.getStackTraceString(
error
)
)
}
}
}
}
suspend fun call(method: String, args: String = NULL): Any? =
withContext(Dispatchers.Main) {
suspendCoroutine { continuation ->
platform.invokeMethod(
method,
args,
object : MethodChannel.Result {
override fun success(result: Any?) {
continuation.resume(result)
}
override fun error(
errorCode: String,
errorMessage: String?,
errorDetails: Any?
) {
continuation.resumeWithException(Exception("$errorCode: $errorMessage - $errorDetails"))
}
override fun notImplemented() {
continuation.resumeWithException(NotImplementedError("Method not implemented: $method"))
}
})
}
}
}

View File

@ -1,7 +1,9 @@
package com.yubico.authenticator.oath
package com.yubico.authenticator
import kotlinx.serialization.json.Json
const val NULL = "null"
val jsonSerializer = Json {
// creates properties for default values
encodeDefaults = true

View File

@ -1,26 +0,0 @@
package com.yubico.authenticator
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import com.yubico.authenticator.oath.jsonSerializer
import io.flutter.plugin.common.EventChannel
import kotlinx.serialization.encodeToString
inline fun <reified T> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, channel: EventChannel) {
var sink: EventChannel.EventSink? = null
channel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
events.success(value?.let(jsonSerializer::encodeToString) ?: "null")
}
override fun onCancel(arguments: Any?) {
sink = null
}
})
observe(lifecycleOwner) {
sink?.success(it?.let(jsonSerializer::encodeToString) ?: "null")
}
}

View File

@ -20,10 +20,12 @@ import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
import com.yubico.yubikit.android.transport.usb.UsbConfiguration
import com.yubico.yubikit.core.Logger
import com.yubico.yubikit.core.YubiKeyDevice
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import kotlinx.coroutines.launch
import java.io.Closeable
import java.util.concurrent.Executors
import kotlin.properties.Delegates
@ -67,11 +69,7 @@ class MainActivity : FlutterFragmentActivity() {
Log.d(TAG, "Starting usb discovery")
yubikit.startUsbDiscovery(UsbConfiguration()) { device ->
viewModel.setConnectedYubiKey(device)
contextManager?.let {
lifecycleScope.launch {
it.processYubiKey(device)
}
}
processYubiKey(device)
}
hasNfc = startNfcDiscovery()
} else {
@ -85,13 +83,7 @@ class MainActivity : FlutterFragmentActivity() {
fun startNfcDiscovery(): Boolean =
try {
Log.d(TAG, "Starting nfc discovery")
yubikit.startNfcDiscovery(nfcConfiguration, this) { device ->
contextManager?.let {
lifecycleScope.launch {
it.processYubiKey(device)
}
}
}
yubikit.startNfcDiscovery(nfcConfiguration, this, ::processYubiKey)
true
} catch (e: NfcNotAvailable) {
false
@ -145,10 +137,14 @@ class MainActivity : FlutterFragmentActivity() {
val executor = Executors.newSingleThreadExecutor()
val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor)
lifecycleScope.launch {
contextManager?.processYubiKey(device)
device.remove {
executor.shutdown()
startNfcDiscovery()
try {
contextManager?.processYubiKey(device)
device.remove {
executor.shutdown()
startNfcDiscovery()
}
} catch (e: Throwable) {
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
}
}
} else {
@ -156,11 +152,24 @@ class MainActivity : FlutterFragmentActivity() {
}
}
private lateinit var appContext: AppContext
private fun processYubiKey(device: YubiKeyDevice) {
contextManager?.let {
lifecycleScope.launch {
try {
it.processYubiKey(device)
} catch (e: Throwable) {
Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString())
}
}
}
}
private var contextManager: AppContextManager? = null
private lateinit var appContext: AppContext
private lateinit var dialogManager: DialogManager
private lateinit var appPreferences: AppPreferences
private lateinit var flutterLog: FlutterLog
private lateinit var flutterStreams: List<Closeable>
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@ -172,19 +181,27 @@ class MainActivity : FlutterFragmentActivity() {
dialogManager = DialogManager(messenger, this.lifecycleScope)
appPreferences = AppPreferences(this)
viewModel.deviceInfo.streamTo(this, EventChannel(messenger, "android.devices.deviceInfo"))
oathViewModel.sessionState.streamTo(this, EventChannel(messenger, "android.oath.sessionState"))
oathViewModel.credentials.streamTo(this, EventChannel(messenger, "android.oath.credentials"))
flutterStreams = listOf(
viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"),
oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"),
oathViewModel.credentials.streamTo(this, messenger, "android.oath.credentials"),
)
viewModel.appContext.observe(this) {
contextManager?.dispose()
contextManager = when(it) {
OperationContext.Oath -> OathManager(messenger, viewModel, oathViewModel, dialogManager, appPreferences)
OperationContext.Oath -> OathManager(this, messenger, viewModel, oathViewModel, dialogManager, appPreferences)
else -> null
}
viewModel.connectedYubiKey.value?.let(::processYubiKey)
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
flutterStreams.forEach { it.close() }
super.cleanUpFlutterEngine(flutterEngine)
}
companion object {
const val TAG = "MainActivity"
}

View File

@ -20,7 +20,7 @@ class MainViewModel : ViewModel() {
private var _appContext = MutableLiveData(OperationContext.Oath)
val appContext: LiveData<OperationContext> = _appContext
fun setContext(appContext: OperationContext) = _appContext.postValue(appContext)
fun setAppContext(appContext: OperationContext) = _appContext.postValue(appContext)
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey

View File

@ -1,5 +1,7 @@
package com.yubico.authenticator.oath
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import com.yubico.authenticator.*
import com.yubico.authenticator.logging.Log
import com.yubico.authenticator.management.model
@ -16,6 +18,7 @@ import com.yubico.yubikit.core.util.Result
import com.yubico.yubikit.oath.*
import com.yubico.yubikit.support.DeviceUtil
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import java.net.URI
@ -25,6 +28,7 @@ import kotlin.coroutines.suspendCoroutine
typealias OathAction = (Result<OathSession, Exception>) -> Unit
class OathManager(
lifecycleOwner: LifecycleOwner,
messenger: BinaryMessenger,
private val appViewModel: MainViewModel,
private val oathViewModel: OathViewModel,
@ -38,14 +42,45 @@ class OathManager(
private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + _dispatcher)
private val oathChannel = FlutterChannel(messenger, "android.oath.methods")
private val oathChannel = MethodChannel(messenger, "android.oath.methods")
private val _memoryKeyProvider = ClearingMemProvider()
private val _keyManager = KeyManager(KeyStoreProvider(), _memoryKeyProvider)
private var pendingAction: OathAction? = null
private var refreshJob: Job? = null
private val usbObserver = Observer<UsbYubiKeyDevice?> {
refreshJob?.cancel()
if (it == null) {
appViewModel.setDeviceInfo(null)
oathViewModel.setSessionState(null)
}
}
private val credentialObserver = Observer<List<Model.CredentialWithCode>?> { codes ->
refreshJob?.cancel()
if(codes != null && appViewModel.connectedYubiKey.value != null) {
val expirations = codes
.filter { it.credential.oathType == Model.OathType.TOTP && !it.credential.touchRequired }
.mapNotNull { it.code?.validTo }
if(expirations.isNotEmpty()) {
val earliest = expirations.min() * 1000
val now = System.currentTimeMillis()
refreshJob = coroutineScope.launch {
if(earliest > now) {
delay(earliest - now)
}
requestRefresh()
}
}
}
}
init {
appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver)
oathViewModel.credentials.observe(lifecycleOwner, credentialObserver)
// OATH methods callable from Flutter:
oathChannel.setHandler(coroutineScope) { method, args ->
when (method) {
@ -71,12 +106,18 @@ class OathManager(
args["issuer"] as String?
)
"deleteAccount" -> deleteAccount(args["credentialId"] as String)
"requestRefresh" -> requestRefresh()
else -> throw NotImplementedError()
}
}
}
override fun dispose() {
appViewModel.connectedYubiKey.removeObserver(usbObserver)
oathViewModel.credentials.removeObserver(credentialObserver)
oathChannel.setMethodCallHandler(null)
coroutineScope.cancel()
}
override suspend fun processYubiKey(device: YubiKeyDevice) {
try {
device.withConnection<SmartCardConnection, Unit> {
@ -147,8 +188,6 @@ class OathManager(
}
}
//private var _isUsbKey = false
private suspend fun reset(): String {
useOathSession("Reset YubiKey") {
// note, it is ok to reset locked session
@ -156,7 +195,7 @@ class OathManager(
_keyManager.removeKey(it.deviceId)
oathViewModel.setSessionState(it.model(false))
}
return FlutterChannel.NULL
return NULL
}
private suspend fun unlock(password: String, remember: Boolean): String =
@ -193,7 +232,7 @@ class OathManager(
_keyManager.addKey(session.deviceId, accessKey, false)
oathViewModel.setSessionState(session.model(false))
Log.d(TAG, "Successfully set password")
FlutterChannel.NULL
NULL
}
private suspend fun unsetPassword(currentPassword: String): String =
@ -205,7 +244,7 @@ class OathManager(
_keyManager.removeKey(session.deviceId)
oathViewModel.setSessionState(session.model(false))
Log.d(TAG, "Successfully unset password")
return@useOathSession FlutterChannel.NULL
return@useOathSession NULL
}
}
throw Exception("Unset password failed")
@ -217,7 +256,7 @@ class OathManager(
oathViewModel.sessionState.value?.let {
oathViewModel.setSessionState(it.copy(isLocked = it.isAccessKeySet, isRemembered = false))
}
return FlutterChannel.NULL
return NULL
}
private suspend fun addAccount(
@ -261,19 +300,17 @@ class OathManager(
val credential = getOathCredential(session, credentialId)
session.deleteCredential(credential)
oathViewModel.removeCredential(credential.model(session.deviceId))
FlutterChannel.NULL
NULL
}
private suspend fun requestRefresh(): String {
private suspend fun requestRefresh() {
appViewModel.connectedYubiKey.value?.let {
useOathSessionUsb(it) {
oathViewModel.updateCredentials(
calculateOathCodes(it).model(it.deviceId)
)
}
return FlutterChannel.NULL
}
throw throw IllegalStateException("Cannot refresh for nfc key")
} ?: throw throw IllegalStateException("Cannot refresh for nfc key")
}
private suspend fun calculate(credentialId: String): String =

View File

@ -1,6 +1,7 @@
package com.yubico.authenticator.oath
import com.yubico.authenticator.device.Version
import com.yubico.authenticator.jsonSerializer
import com.yubico.authenticator.oath.OathTestHelper.code
import com.yubico.authenticator.oath.OathTestHelper.hotp
import com.yubico.authenticator.oath.OathTestHelper.totp

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -116,9 +115,6 @@ final androidCredentialListProvider = StateNotifierProvider.autoDispose
ref.watch(withContextProvider),
ref.watch(currentDeviceProvider)?.transport == Transport.usb,
);
ref.listen<WindowState>(windowStateProvider, (_, windowState) {
notifier._notifyWindowState(windowState);
}, fireImmediately: true);
return notifier;
},
);
@ -128,7 +124,6 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
final WithContext _withContext;
final bool _isUsbAttached;
late StreamSubscription _sub;
Timer? _timer;
_AndroidCredentialListNotifier(this._withContext, this._isUsbAttached)
: super() {
@ -138,22 +133,11 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
? List.unmodifiable(
(json as List).map((e) => OathPair.fromJson(e)).toList())
: null;
_scheduleRefresh();
});
}
void _notifyWindowState(WindowState windowState) {
if (!_isUsbAttached) return;
if (windowState.active) {
_scheduleRefresh();
} else {
_timer?.cancel();
}
}
@override
void dispose() {
_timer?.cancel();
_sub.cancel();
super.dispose();
}
@ -251,47 +235,4 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
rethrow;
}
}
_refresh() async {
if (!_isUsbAttached) return;
_log.debug('refreshing credentials...');
try {
await _methods.invokeMethod('requestRefresh');
} catch (e) {
_log.debug('Failure refreshing codes: $e');
}
}
_scheduleRefresh() {
if (!_isUsbAttached) return;
_timer?.cancel();
if (state == null) {
_log.debug('No OATH state, refresh immediately');
_refresh();
} else if (mounted) {
final expirations = (state ?? [])
.where((pair) =>
pair.credential.oathType == OathType.totp &&
!pair.credential.touchRequired)
.map((e) => e.code)
.whereType<OathCode>()
.map((e) => e.validTo);
if (expirations.isEmpty) {
_log.debug('No expirations, no refresh');
_timer = null;
} else {
final earliest = expirations.reduce(min) * 1000;
final now = DateTime.now().millisecondsSinceEpoch;
if (earliest < now) {
_log.debug('Already expired, refresh immediately');
_refresh();
} else {
_log.debug('Schedule refresh in ${earliest - now}ms');
_timer = Timer(Duration(milliseconds: earliest - now), _refresh);
}
}
}
}
}