package net.sergeych.superlogin.client import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.Serializable import net.sergeych.boss_serialization.BossDecoder import net.sergeych.boss_serialization_mp.BossEncoder import net.sergeych.mp_logger.LogTag import net.sergeych.mp_logger.Loggable import net.sergeych.mp_logger.exception import net.sergeych.mp_logger.warning import net.sergeych.mp_tools.globalLaunch import net.sergeych.parsec3.Adapter import net.sergeych.parsec3.CommandDescriptor import net.sergeych.parsec3.Parsec3Transport import net.sergeych.parsec3.WithAdapter import net.sergeych.superlogin.* import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload import net.sergeych.unikrypto.SignedRecord import kotlin.reflect.KType import kotlin.reflect.typeOf /** * Application might want to serialize and store updated instances of it in some * permanent storage to restore login data also in offline more, and re-login * automatically where possible withot asking the password again. * * Superlogin client _does not store the data itself_ but only notifies client software * that it is changed and ought to be updated. */ @Serializable data class SuperloginData( val loginName: String, val loginToken: ByteArray? = null, val data: T? = null, ) class SuperloginClient( private val transport: Parsec3Transport, savedData: SuperloginData? = null, private val dataType: KType, ) : Parsec3Transport, Loggable by LogTag("SLCLI") { private val _state = MutableStateFlow( if (savedData == null) LoginState.LoggedOut else LoginState.LoggedIn(savedData) ) val state: StateFlow = _state private val _cflow = MutableStateFlow(false) override val connectedFlow: StateFlow = _cflow private var slData: SuperloginData? = savedData set(value) { if (field != value) { field = value if (value == null) { // do actual disconnect work _cflow.value = false _state.value = LoginState.LoggedOut } else { val v = _state.value if (v !is LoginState.LoggedIn<*> || v.loginData != value) { _state.value = LoginState.LoggedIn(value) if (!adapterReady.isCompleted) adapterReady.complete(Unit) } } } } val applicationData: D? get() = (state.value as? LoginState.LoggedIn)?.loginData?.data private var adapterReady = CompletableDeferred() override suspend fun adapter(): Adapter { do { try { adapterReady.await() return transport.adapter() } catch (x: Throwable) { exception { "failed to get adapter" to x } } } while (true) } suspend fun call(ca: CommandDescriptor, args: A): R = adapter().invokeCommand(ca, args) suspend fun call(ca: CommandDescriptor): R = adapter().invokeCommand(ca) private suspend fun invoke(ca: CommandDescriptor, args: A): R = transport.adapter().invokeCommand(ca, args) private suspend fun invoke(ca: CommandDescriptor): R = transport.adapter().invokeCommand(ca) private var jobs = listOf() private val serverApi = SuperloginServerApi() private suspend fun tryRestoreLogin() { slData?.loginToken?.let { token -> try { val ar = transport.adapter().invokeCommand(serverApi.slLoginByToken, token) slData = if (ar is AuthenticationResult.Success) { val data: D? = ar.applicationData?.let { BossDecoder.decodeFrom(dataType, it) } SuperloginData(ar.loginName, ar.loginToken, data) } else { null } } catch (t: Throwable) { exception { "failed to restore login by token, will retry" to t } delay(1500) tryRestoreLogin() } } ?: warning { "tryRestoreLogin is ignored as slData is now null" } } init { jobs += globalLaunch { transport.connectedFlow.collect { on -> if (on) tryRestoreLogin() else { _cflow.value = false } } } } override fun close() { transport.close() } override fun reconnect() { transport.reconnect() if (!adapterReady.isActive) { adapterReady.cancel() adapterReady = CompletableDeferred() } } private var registration: Registration? = null val isLoggedIn get() = state.value.isLoggedIn /** * Perform registration and login attempt and return the result. It automatically caches and reuses intermediate * long-calculated keys so it runs much fatser when called again with the same password. * * _Client should be in logged-out state!_ Use [logout] as needed * * If result is [Registration.Result.Success], user state is _logged in_. * * @throws IllegalStateException if logged in */ suspend fun register( loginName: String, password: String, data: D? = null, loginKeyStrength: Int = 2048, pbkdfRounds: Int = 15000, ): Registration.Result { mustBeLoggedOut() val rn = registration ?: Registration(transport.adapter(), loginKeyStrength, pbkdfRounds) .also { registration = it } return rn.registerWithData(loginName, password, extraData = BossEncoder.encode(dataType, data)) .also { rr -> if (rr is Registration.Result.Success) { slData = SuperloginData(loginName, rr.loginToken, extractData(rr.encodedData)) } } } private fun extractData(rr: ByteArray?): D? = rr?.let { BossDecoder.decodeFrom(dataType, it) } private fun mustBeLoggedOut() { if (isLoggedIn) throw IllegalStateException("please log out first") } private fun mustBeLoggedIn() { if (!isLoggedIn) throw IllegalStateException("please log in first") } suspend fun logout() { mustBeLoggedIn() invoke(serverApi.slLogout) slData = null } /** * Try to log in by specified token, returned by [Registration.Result.Success.loginToken] or * [SuperloginData.loginToken] respectively. * * @return updated login data (and new token value) or null if token is not (or not anymore) * available for logging in. */ suspend fun loginByToken(token: ByteArray): SuperloginData? { mustBeLoggedOut() val r = invoke(serverApi.slLoginByToken, token) return when (r) { AuthenticationResult.LoginIdUnavailable -> TODO() AuthenticationResult.LoginUnavailable -> null AuthenticationResult.RestoreIdUnavailable -> TODO() is AuthenticationResult.Success -> SuperloginData( r.loginName, r.loginToken, extractData(r.applicationData) ).also { slData = it } } } suspend fun loginByPassword(loginName: String, password: String): SuperloginData? { mustBeLoggedOut() // Request derivation params val params = invoke(serverApi.slRequestDerivationParams, loginName) val keys = DerivedKeys.derive(password, params) // Request login data by derived it return invoke( serverApi.slRequestLoginData, RequestLoginDataArgs(loginName, keys.loginId) ).let { loginRequest -> try { AccessControlObject.unpackWithPasswordKey( loginRequest.packedACO, keys.loginAccessKey )?.let { aco -> val result = invoke( serverApi.slLoginByKey, SignedRecord.pack( aco.payload.loginPrivateKey, LoginByPasswordPayload(loginName), loginRequest.nonce ) ) if (result is AuthenticationResult.Success) { SuperloginData(loginName, result.loginToken, extractData(result.applicationData)) .also { slData = it } } else null } } catch (t: Throwable) { t.printStackTrace() throw t } } } companion object { inline operator fun invoke( t: Parsec3Transport, savedData: SuperloginData? = null, ): SuperloginClient { return SuperloginClient(t, savedData, typeOf()) } } }