477 lines
19 KiB
Kotlin
477 lines
19 KiB
Kotlin
package net.sergeych.superlogin.client
|
|
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.coroutineScope
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.serialization.Serializable
|
|
import net.sergeych.boss_serialization.BossDecoder
|
|
import net.sergeych.boss_serialization_mp.BossEncoder
|
|
import net.sergeych.mp_logger.*
|
|
import net.sergeych.mp_tools.globalLaunch
|
|
import net.sergeych.parsec3.*
|
|
import net.sergeych.superlogin.*
|
|
import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload
|
|
import net.sergeych.unikrypto.*
|
|
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<T>(
|
|
val loginName: String,
|
|
val loginToken: ByteArray? = null,
|
|
val data: T? = null,
|
|
)
|
|
|
|
@Serializable
|
|
class ClientState<T>(val slData: SuperloginData<T>, val dataKey: SymmetricKey) {
|
|
val loginName by slData::loginName
|
|
val loginToken by slData::loginToken
|
|
val data by slData::data
|
|
|
|
constructor(loginName: String,loginToken: ByteArray?,data: T?, dataKey: SymmetricKey)
|
|
: this(SuperloginData(loginName, loginToken, data), dataKey)
|
|
}
|
|
|
|
|
|
class SuperloginClient<D, S : WithAdapter>(
|
|
private val transport: Parsec3Transport<S>,
|
|
savedData: ClientState<D>? = null,
|
|
private val dataType: KType,
|
|
override val exceptionsRegistry: ExceptionsRegistry = ExceptionsRegistry(),
|
|
) : Parsec3Transport<S>, Loggable by LogTag("SLCLI") {
|
|
|
|
private val _state = MutableStateFlow<LoginState>(
|
|
if (savedData == null) LoginState.LoggedOut
|
|
else LoginState.LoggedIn(savedData)
|
|
)
|
|
val state: StateFlow<LoginState> = _state
|
|
|
|
private val _cflow = MutableStateFlow<Boolean>(false)
|
|
override val connectedFlow: StateFlow<Boolean> = _cflow
|
|
|
|
private var clientState: ClientState<D>? = 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("UNCHECKED_CAST")
|
|
val applicationData: D?
|
|
get() = (state.value as? LoginState.LoggedIn<D>)?.loginData?.data
|
|
|
|
private var adapterReady = MutableStateFlow<Boolean>(false)
|
|
|
|
/**
|
|
* The flow that tracks readiness state of the connetion adapter. In other works,
|
|
* when its value is false, [adapter] deferred is not completed and [call] method
|
|
* will wait until it is ready.
|
|
*
|
|
* The reason for it is as follows: when connetion drops,
|
|
* superlogin client awaits its automatic restore (by parsec3) and then tries to re-login.
|
|
* Until this login restore will finish either successful or not, calling parsec3 commands
|
|
* may produce unpredictable results, so it is automatically postponed until login state
|
|
* is restored. This is completely transparent to the caller, and this state flow allows
|
|
* client to be notified on actual connection state.
|
|
*/
|
|
val connectionReady = adapterReady.asStateFlow()
|
|
|
|
override suspend fun adapter(): Adapter<S> {
|
|
adapterReady.waitFor(true)
|
|
return transport.adapter()
|
|
}
|
|
|
|
/**
|
|
* Call client API commands with it (uses [adapter] under the hood)
|
|
*/
|
|
suspend fun <A, R> call(ca: CommandDescriptor<A, R>, args: A): R = adapter().invokeCommand(ca, args)
|
|
|
|
/**
|
|
* Call client API commands with it (uses [adapter] under the hood)
|
|
*/
|
|
suspend fun <R> call(ca: CommandDescriptor<Unit, R>): R = adapter().invokeCommand(ca)
|
|
|
|
private suspend fun <A, R> invoke(ca: CommandDescriptor<A, R>, args: A): R =
|
|
transport.adapter().invokeCommand(ca, args)
|
|
|
|
private suspend fun <R> invoke(ca: CommandDescriptor<Unit, R>): R = transport.adapter().invokeCommand(ca)
|
|
|
|
private var jobs = listOf<Job>()
|
|
|
|
private val serverApi = SuperloginServerApi<WithAdapter>()
|
|
|
|
private suspend fun tryRestoreLogin() {
|
|
clientState?.let { clientState ->
|
|
clientState.loginToken?.let { token ->
|
|
debug { "trying to restore login with a token" }
|
|
while (true) {
|
|
try {
|
|
val ar = transport.adapter().invokeCommand(serverApi.slLoginByToken, token)
|
|
this.clientState = if (ar is AuthenticationResult.Success) {
|
|
val data: D? = ar.applicationData?.let { BossDecoder.decodeFrom(dataType, it) }
|
|
debug { "login restored by the token: ${ar.loginName}" }
|
|
ClientState(ar.loginName, ar.loginToken, data, clientState.dataKey)
|
|
} else {
|
|
debug { "failed to restore login by the token: $ar" }
|
|
null
|
|
}
|
|
break
|
|
} catch (t: Throwable) {
|
|
exception { "failed to restore login by token, will retry" to t }
|
|
delay(1500)
|
|
}
|
|
}
|
|
}
|
|
} ?: warning { "tryRestoreLogin is ignored as slData is now null" }
|
|
adapterReady.value = true
|
|
}
|
|
|
|
init {
|
|
transport.registerExceptinos(SuperloginExceptionsRegistry)
|
|
jobs += globalLaunch {
|
|
transport.connectedFlow.collect { on ->
|
|
if (on) tryRestoreLogin()
|
|
else {
|
|
adapterReady.value = false
|
|
_cflow.value = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
override fun close() {
|
|
transport.close()
|
|
}
|
|
|
|
/**
|
|
* Force dropping and re-establish underlying parsec3 connection and restore
|
|
* login state to the current.
|
|
*/
|
|
override fun reconnect() {
|
|
adapterReady.value = false
|
|
transport.reconnect()
|
|
}
|
|
|
|
private var registration: Registration? = null
|
|
|
|
/**
|
|
* Whether the client is supposed to be logged in. Note that it is also true when
|
|
* there is no ready connection (means also offline), if there is information about staved
|
|
* loged in state. It can change at aby time as server may drop login state too. Use
|
|
* [state] flow to track the state changes and [adapterReady] flow to track connection state
|
|
* that are in fact independent to some degree.
|
|
*/
|
|
val isLoggedIn get() = state.value.isLoggedIn
|
|
|
|
/**
|
|
* The data storage key, a random key created when registering. It is retrieved automatically
|
|
* on login by password and on registration, _It does not restore when logged in by key
|
|
* as it would require storing user's password which must not be done_.
|
|
* Use [retrieveDataKey] to restore to with a password (when logged in)
|
|
*/
|
|
val dataKey: SymmetricKey? get() = clientState?.dataKey
|
|
|
|
/**
|
|
* 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) {
|
|
clientState = ClientState(loginName, rr.loginToken, extractData(rr.encodedData), rr.dataKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
clientState = 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,newDataKey: SymmetricKey): ClientState<D>? {
|
|
mustBeLoggedOut()
|
|
val r = invoke(serverApi.slLoginByToken, token)
|
|
return when (r) {
|
|
is AuthenticationResult.Success -> ClientState(
|
|
r.loginName,
|
|
r.loginToken,
|
|
extractData(r.applicationData),
|
|
newDataKey
|
|
).also {
|
|
clientState = it
|
|
}
|
|
else -> null
|
|
}
|
|
}
|
|
|
|
suspend fun loginByPassword(loginName: String, password: String): ClientState<D>? {
|
|
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.slRequestACOByLoginName,
|
|
RequestACOByLoginNameArgs(loginName, keys.loginId)
|
|
).let { loginRequest ->
|
|
try {
|
|
AccessControlObject.unpackWithKey<SuperloginRestoreAccessPayload>(
|
|
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) {
|
|
ClientState(loginName, result.loginToken, extractData(result.applicationData),
|
|
aco.payload.dataStorageKey)
|
|
.also {
|
|
clientState = it
|
|
}
|
|
} else null
|
|
}
|
|
} catch (t: Throwable) {
|
|
debug { "error while signign in: $t" }
|
|
null
|
|
}
|
|
}
|
|
}
|
|
|
|
// /**
|
|
// * Try to retrieve dataKey (usually after login by token), it is impossible
|
|
// * to do without a valid password key. Should be logged in. If [dataKey] is not null
|
|
// * what means is already known, returns it immediatel, otherwise uses password
|
|
// * to access ACO on the server and extract data key.
|
|
// * @return data key or null if it is impossible to do (no connection or wrong password)
|
|
// */
|
|
// suspend fun retrieveDataKey(password: String): SymmetricKey? {
|
|
// mustBeLoggedIn()
|
|
// dataKey?.let { return it }
|
|
//
|
|
// val loginName = slData?.loginName ?: throw SLInternalException("slData: empty login name")
|
|
// try {
|
|
// val params = invoke(
|
|
// serverApi.slRequestDerivationParams,
|
|
// loginName
|
|
// )
|
|
// val keys = DerivedKeys.derive(password, params)
|
|
// // Request login data by derived it
|
|
// return invoke(
|
|
// serverApi.slRequestACOByLoginName,
|
|
// RequestACOByLoginNameArgs(loginName, keys.loginId)
|
|
// ).let { loginRequest ->
|
|
// AccessControlObject.unpackWithKey<SuperloginRestoreAccessPayload>(
|
|
// loginRequest.packedACO,
|
|
// keys.loginAccessKey
|
|
// )?.let { aco ->
|
|
// aco.data.payload.dataStorageKey.also { dataKey = it }
|
|
// }
|
|
// }
|
|
// } catch (t: Throwable) {
|
|
// t.printStackTrace()
|
|
// return null
|
|
// }
|
|
// }
|
|
|
|
/**
|
|
* Resets password and log in using a `secret` string (one that wwas reported on registration. __Never store
|
|
* secrt string in your app__. Always ask user to enter it just before the operation and wipe it out
|
|
* immediately after. It is a time-consuming procedure. Note that on success the client state changes
|
|
* to [LoginState.LoggedIn].
|
|
*
|
|
* @param secret the secret string as was reported when registering
|
|
* @param newPassword new password (apply strength checks, it is not checked here)
|
|
* @param loginKeyStrength desired login key strength (it will be generated there)
|
|
* @param params password derivation params: it is possible to change its strength here
|
|
* @return login data instance on success or null
|
|
*/
|
|
suspend fun resetPasswordAndSignIn(
|
|
secret: String, newPassword: String,
|
|
params: PasswordDerivationParams = PasswordDerivationParams(),
|
|
loginKeyStrength: Int = 2048,
|
|
): ClientState<D>? {
|
|
mustBeLoggedOut()
|
|
return try {
|
|
val (id, key) = RestoreKey.parse(secret)
|
|
val packedACO = invoke(serverApi.slRequestACOBySecretId, id)
|
|
AccessControlObject.unpackWithKey<SuperloginRestoreAccessPayload>(packedACO, key)?.let {
|
|
changePasswordWithACO(it, newPassword, params, loginKeyStrength)
|
|
clientState
|
|
}
|
|
} catch (x: RestoreKey.InvalidSecretException) {
|
|
null
|
|
} catch (x: Exception) {
|
|
x.printStackTrace()
|
|
null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Changes the password (which includes generating new login key). Does not require any particular
|
|
* [state]. This is a long operation. On success, it changes (updates) [state] to [LoginState.LoggedIn]
|
|
* with new data whatever it was before. Be aware of it.
|
|
*/
|
|
protected suspend fun changePasswordWithACO(
|
|
aco: AccessControlObject<SuperloginRestoreAccessPayload>,
|
|
newPassword: String,
|
|
params: PasswordDerivationParams = PasswordDerivationParams(),
|
|
loginKeyStrength: Int = 2048,
|
|
): Boolean {
|
|
return coroutineScope {
|
|
// Get current nonce in parallel
|
|
val deferredNonce = async { invoke(serverApi.slGetNonce) }
|
|
// get login key in parallel
|
|
val newLoginKey = BackgroundKeyGenerator.getKeyAsync(loginKeyStrength)
|
|
// derive keys in main scope
|
|
val keys = DerivedKeys.derive(newPassword, params)
|
|
|
|
// new ACO payload: new login key, old data storage key and login
|
|
val newSlp = SuperloginRestoreAccessPayload(
|
|
aco.payload.login,
|
|
newLoginKey.await(),
|
|
aco.payload.dataStorageKey
|
|
)
|
|
// new ACO with a new password key and payload (but the same secret!)
|
|
val newAco = aco.updatePasswordKey(keys.loginAccessKey).updatePayload(newSlp)
|
|
// trying to update
|
|
val result = invoke(
|
|
serverApi.slChangePasswordAndLogin, ChangePasswordArgs(
|
|
aco.payload.login,
|
|
SignedRecord.pack(
|
|
aco.payload.loginPrivateKey,
|
|
ChangePasswordPayload(newAco.packed, params, newLoginKey.await().publicKey, keys.loginId),
|
|
deferredNonce.await()
|
|
)
|
|
)
|
|
)
|
|
when (result) {
|
|
is AuthenticationResult.Success -> {
|
|
clientState = ClientState(result.loginName, result.loginToken, extractData(result.applicationData),
|
|
aco.payload.dataStorageKey)
|
|
true
|
|
}
|
|
|
|
else -> {
|
|
warning { "Change password result: $result" }
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change password for a logged-in user using its known password. It is a long operation.
|
|
*
|
|
* @param oldPassword existing password (re-request it from a user!)
|
|
* @param newPassword new password. we do not check it, but it should be strong - check it on your end
|
|
* for example with [net.sergeych.unikrypto.Passwords] tools
|
|
* @param passwordDerivationParams at this point derivation parameters are always updated so it is possible
|
|
* to set it to desired
|
|
* @param loginKeyStrength login key is regenerated so its strength could be updated here
|
|
*
|
|
* @return true if the password has been successfully changed, false if the server didn't allow it.
|
|
*
|
|
* @throws InvalidPasswordError if the oldPassword is wrong
|
|
*/
|
|
suspend fun changePassword(
|
|
oldPassword: String, newPassword: String,
|
|
passwordDerivationParams: PasswordDerivationParams = PasswordDerivationParams(),
|
|
loginKeyStrength: Int = 2048,
|
|
): Boolean {
|
|
mustBeLoggedIn()
|
|
val loginName = clientState?.loginName ?: throw SLInternalException("loginName should be defined here")
|
|
val dp = invoke(serverApi.slRequestDerivationParams, loginName)
|
|
val keys = DerivedKeys.derive(oldPassword, dp)
|
|
val data = invoke(serverApi.slRequestACOByLoginName, RequestACOByLoginNameArgs(loginName, keys.loginId))
|
|
try {
|
|
return AccessControlObject.unpackWithKey<SuperloginRestoreAccessPayload>(
|
|
data.packedACO,
|
|
keys.loginAccessKey
|
|
)
|
|
?.let {
|
|
changePasswordWithACO(it, newPassword, passwordDerivationParams, loginKeyStrength)
|
|
} ?: false
|
|
} catch (e: Exception) {
|
|
when (e) {
|
|
is Container.StructureError,
|
|
is Container.DecryptionError,
|
|
is EncryptedBinaryStorage.DecryptionFailed ->
|
|
throw InvalidPasswordError()
|
|
|
|
else -> throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
companion object {
|
|
inline operator fun <reified D, S : WithAdapter> invoke(
|
|
t: Parsec3Transport<S>,
|
|
savedData: ClientState<D>? = null,
|
|
): SuperloginClient<D, S> {
|
|
return SuperloginClient(t, savedData, typeOf<D>())
|
|
}
|
|
}
|
|
|
|
} |