working variant of server/client kotlin architecture (1)
This commit is contained in:
parent
8b8724b0f1
commit
5df6709983
@ -1,9 +1,13 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "1.7.20"
|
kotlin("multiplatform") version "1.7.10"
|
||||||
kotlin("plugin.serialization") version "1.7.20"
|
kotlin("plugin.serialization") version "1.7.10"
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ktor_version="2.1.1"
|
||||||
|
val logback_version="1.2.10"
|
||||||
|
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "0.0.1-SNAPSHOT"
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
@ -51,7 +55,13 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val jvmMain by getting
|
val jvmMain by getting
|
||||||
val jvmTest by getting
|
val jvmTest by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation("io.ktor:ktor-server-core:$ktor_version")
|
||||||
|
implementation("io.ktor:ktor-server-netty:$ktor_version")
|
||||||
|
implementation("ch.qos.logback:logback-classic:$logback_version")
|
||||||
|
}
|
||||||
|
}
|
||||||
val jsMain by getting
|
val jsMain by getting
|
||||||
val jsTest by getting
|
val jsTest by getting
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ object BackgroundKeyGenerator {
|
|||||||
|
|
||||||
const val DefaultStrength = 4096
|
const val DefaultStrength = 4096
|
||||||
|
|
||||||
class EntropyLowException : Exception("entropy level is below requested")
|
class EntropyLowException(current: Int,requested: Int) : Exception("entropy level is below requested ($current/$requested)")
|
||||||
|
|
||||||
private var keyStrength = DefaultStrength
|
private var keyStrength = DefaultStrength
|
||||||
private var nextKey: Deferred<PrivateKey>? = null
|
private var nextKey: Deferred<PrivateKey>? = null
|
||||||
@ -73,14 +73,17 @@ object BackgroundKeyGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun randomBytes(length: Int,minEntropy: Int): ByteArray {
|
fun randomBytes(length: Int,minEntropy: Int): ByteArray {
|
||||||
|
addEntropyTimestamp()
|
||||||
entropyHash?.let {
|
entropyHash?.let {
|
||||||
if (minEntropy <= entropy) {
|
if (minEntropy <= entropy) {
|
||||||
entropy -= minEntropy
|
entropy -= minEntropy
|
||||||
addEntropyTimestamp()
|
|
||||||
return randomBytes(length, it)
|
return randomBytes(length, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw EntropyLowException()
|
if( minEntropy == 0 ) {
|
||||||
|
return randomBytes(length, null)
|
||||||
|
}
|
||||||
|
throw EntropyLowException(minEntropy, entropy)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun randomBytes(length: Int, IV: ByteArray? = null): ByteArray {
|
fun randomBytes(length: Int, IV: ByteArray? = null): ByteArray {
|
||||||
|
@ -11,7 +11,7 @@ import kotlin.random.Random
|
|||||||
* and a method to derive password accordingly.
|
* and a method to derive password accordingly.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
class PasswordDerivationParams(
|
data class PasswordDerivationParams(
|
||||||
val rounds: Int = 15000,
|
val rounds: Int = 15000,
|
||||||
val algorithm: HashAlgorithm = HashAlgorithm.SHA3_256,
|
val algorithm: HashAlgorithm = HashAlgorithm.SHA3_256,
|
||||||
val salt: ByteArray = Random.nextBytes(32),
|
val salt: ByteArray = Random.nextBytes(32),
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
package net.sergeych.superlogin
|
|
||||||
|
|
||||||
import net.sergeych.parsec3.Adapter
|
|
||||||
|
|
||||||
|
|
||||||
class SuperloginClient(adapter: Adapter<*>) {
|
|
||||||
|
|
||||||
// init {
|
|
||||||
// adapter.invokeCommand()
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun register() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -13,7 +13,8 @@ data class RegistrationArgs(
|
|||||||
val loginPublicKey: PublicKey,
|
val loginPublicKey: PublicKey,
|
||||||
val derivationParams: PasswordDerivationParams,
|
val derivationParams: PasswordDerivationParams,
|
||||||
val restoreId: ByteArray,
|
val restoreId: ByteArray,
|
||||||
val restoreData: ByteArray
|
val restoreData: ByteArray,
|
||||||
|
val extraData: ByteArray? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -21,11 +22,15 @@ sealed class AuthenticationResult {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Success(
|
data class Success(
|
||||||
val loginToken: ByteArray,
|
val loginToken: ByteArray,
|
||||||
|
val applicationData: ByteArray?
|
||||||
): AuthenticationResult()
|
): AuthenticationResult()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object LoginUnavailable: AuthenticationResult()
|
object LoginUnavailable: AuthenticationResult()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object LoginIdUnavailable: AuthenticationResult()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object RestoreIdUnavailable: AuthenticationResult()
|
object RestoreIdUnavailable: AuthenticationResult()
|
||||||
}
|
}
|
||||||
@ -36,23 +41,19 @@ data class LoginArgs(
|
|||||||
val packedSignedRecord: ByteArray
|
val packedSignedRecord: ByteArray
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LoginData(
|
|
||||||
val encryptedPrivateKey: ByteArray,
|
|
||||||
val loginNonce: ByteArray
|
|
||||||
)
|
|
||||||
|
|
||||||
class SuperloginServerApi<T: WithAdapter> : CommandHost<T>() {
|
class SuperloginServerApi<T: WithAdapter> : CommandHost<T>() {
|
||||||
|
|
||||||
val registerUser by command<RegistrationArgs,AuthenticationResult>()
|
val slRegister by command<RegistrationArgs,AuthenticationResult>()
|
||||||
val loginUserByToken by command<ByteArray,AuthenticationResult>()
|
val slLogout by command<Unit,Unit>()
|
||||||
|
val slLoginByToken by command<ByteArray,AuthenticationResult>()
|
||||||
|
|
||||||
val requestUserLoginParams by command<String,PasswordDerivationParams>()
|
val slRequestDerivationParams by command<String,PasswordDerivationParams>()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resstoreData by restoreId: password reset procedure start.
|
* Get resstoreData by restoreId: password reset procedure start.
|
||||||
*/
|
*/
|
||||||
val requestUserLogin by command<ByteArray,ByteArray>()
|
// val requestUserLogin by command<ByteArray,ByteArray>()
|
||||||
// val performLogin by command<LoginArgs
|
// val performLogin by command<LoginArgs
|
||||||
}
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package net.sergeych.superlogin.client
|
||||||
|
|
||||||
|
sealed class LoginState(val isLoggedIn: Boolean) {
|
||||||
|
/**
|
||||||
|
* User is logged in (either connected or yet not). Client application should save
|
||||||
|
* updated [loginData] at this point
|
||||||
|
*/
|
||||||
|
class LoggedIn<D>(val loginData: SuperloginData<D>) : LoginState(true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login state whatever it was now is logged out, and client application should delete
|
||||||
|
* any saved [SuperloginData] instance it has.
|
||||||
|
*/
|
||||||
|
object LoggedOut : LoginState(false)
|
||||||
|
}
|
@ -1,13 +1,19 @@
|
|||||||
package net.sergeych.superlogin
|
package net.sergeych.superlogin.client
|
||||||
|
|
||||||
|
import net.sergeych.boss_serialization.BossDecoder
|
||||||
|
import net.sergeych.boss_serialization_mp.BossEncoder
|
||||||
import net.sergeych.mp_logger.LogTag
|
import net.sergeych.mp_logger.LogTag
|
||||||
|
import net.sergeych.mp_logger.debug
|
||||||
import net.sergeych.mp_logger.error
|
import net.sergeych.mp_logger.error
|
||||||
import net.sergeych.mp_logger.exception
|
import net.sergeych.mp_logger.exception
|
||||||
import net.sergeych.parsec3.Adapter
|
import net.sergeych.parsec3.Adapter
|
||||||
import net.sergeych.parsec3.WithAdapter
|
import net.sergeych.parsec3.WithAdapter
|
||||||
|
import net.sergeych.superlogin.*
|
||||||
|
import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload
|
||||||
import net.sergeych.unikrypto.HashAlgorithm
|
import net.sergeych.unikrypto.HashAlgorithm
|
||||||
import net.sergeych.unikrypto.SymmetricKey
|
import net.sergeych.unikrypto.SymmetricKey
|
||||||
import net.sergeych.unikrypto.digest
|
import net.sergeych.unikrypto.digest
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registration instances are used to perform superlogin-compatible registration in a smart way
|
* Registration instances are used to perform superlogin-compatible registration in a smart way
|
||||||
@ -22,75 +28,106 @@ import net.sergeych.unikrypto.digest
|
|||||||
*
|
*
|
||||||
* See [register] for more.
|
* See [register] for more.
|
||||||
*/
|
*/
|
||||||
class Registration<T: WithAdapter>(val adapter: Adapter<T>,loginKeyStrength: Int = 4096): LogTag("SLREG") {
|
class Registration(
|
||||||
|
val adapter: Adapter<*>, loginKeyStrength: Int = 4096,
|
||||||
|
val pbkdfRounds: Int = 15000,
|
||||||
|
) : LogTag("SLREG") {
|
||||||
|
|
||||||
sealed class Result {
|
sealed class Result {
|
||||||
/**
|
/**
|
||||||
* Login is already in use or is somehow else invalid
|
* Login is already in use or is somehow else invalid
|
||||||
*/
|
*/
|
||||||
object InvalidLogin: Result()
|
object InvalidLogin : Result()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operation failed for nknown reason, usually it means
|
* Operation failed for nknown reason, usually it means
|
||||||
* network or server downtime
|
* network or server downtime
|
||||||
*/
|
*/
|
||||||
class NetworkFailure(val exception: Throwable?=null): Result()
|
class NetworkFailure(val exception: Throwable? = null) : Result()
|
||||||
|
|
||||||
class Success(val secret: String,val dataKey: SymmetricKey,loginToken: ByteArray): Result()
|
class Success(
|
||||||
|
val secret: String,
|
||||||
|
val dataKey: SymmetricKey,
|
||||||
|
val loginToken: ByteArray,
|
||||||
|
val encodedData: ByteArray?) : Result() {
|
||||||
|
inline fun <reified D>data() = encodedData?.let { BossDecoder.decodeFrom<D>(it)}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lastPasswordHash: ByteArray? = null
|
private var lastPasswordHash: ByteArray? = null
|
||||||
private var lastDerivationParams: PasswordDerivationParams? = null
|
private var lastDerivationParams: PasswordDerivationParams? = null
|
||||||
private var passwordKeys: DerivedKeys? = null
|
private var passwordKeys: DerivedKeys? = null
|
||||||
val api = SuperloginServerApi<T>()
|
val api = SuperloginServerApi<WithAdapter>()
|
||||||
private val deferredLoginKey = BackgroundKeyGenerator.getKeyAsync(loginKeyStrength)
|
private val deferredLoginKey = BackgroundKeyGenerator.getKeyAsync(loginKeyStrength)
|
||||||
private val dataKey = BackgroundKeyGenerator.randomSymmetricKey()
|
private val dataKey = BackgroundKeyGenerator.randomSymmetricKey()
|
||||||
|
|
||||||
|
inline suspend fun <reified T> register(
|
||||||
|
login: String,
|
||||||
|
password: String,
|
||||||
|
derivationParams: PasswordDerivationParams = PasswordDerivationParams(rounds = pbkdfRounds),
|
||||||
|
extraData: T? = null,
|
||||||
|
): Result = registerWithData(login, password, extraData?.let { BossEncoder.encode(it) })
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smart attempt to register. It is ok to repeatedly call it if the result is not [Result.Success]: it will
|
* Smart attempt to register. It is ok to repeatedly call it if the result is not [Result.Success]: it will
|
||||||
* cache internal data and reuse time-consuming precalculated values for caches and derived keys if the
|
* cache internal data and reuse time-consuming precalculated values for caches and derived keys if the
|
||||||
* password and derivarion parameters are not changed between calls.
|
* password and derivarion parameters are not changed between calls.
|
||||||
*/
|
*/
|
||||||
suspend fun register(
|
suspend fun registerWithData(
|
||||||
login: String,
|
login: String,
|
||||||
password: String,
|
password: String,
|
||||||
derivationParams: PasswordDerivationParams = PasswordDerivationParams()
|
extraData: ByteArray? = null,
|
||||||
): Result {
|
): Result {
|
||||||
val newPasswordHash = HashAlgorithm.SHA3_256.digest(password)
|
val newPasswordHash = HashAlgorithm.SHA3_256.digest(password)
|
||||||
if( lastPasswordHash?.contentEquals(newPasswordHash) != true ||
|
if (lastPasswordHash?.contentEquals(newPasswordHash) != true ||
|
||||||
lastDerivationParams?.equals(derivationParams) != true ||
|
passwordKeys == null
|
||||||
passwordKeys == null ) {
|
) {
|
||||||
passwordKeys = DerivedKeys.derive(password,derivationParams)
|
lastDerivationParams = PasswordDerivationParams(pbkdfRounds)
|
||||||
lastDerivationParams = derivationParams
|
passwordKeys = DerivedKeys.derive(password, lastDerivationParams!! )
|
||||||
lastPasswordHash = newPasswordHash
|
lastPasswordHash = newPasswordHash
|
||||||
}
|
}
|
||||||
val spl = SuperloginPayload(login, deferredLoginKey.await(), dataKey)
|
val loginPrivateKey = deferredLoginKey.await()
|
||||||
|
val spl = SuperloginRestoreAccessPayload(login, loginPrivateKey, dataKey)
|
||||||
repeat(10) {
|
repeat(10) {
|
||||||
val (restoreKey, restoreData) = AccessControlObject.pack(passwordKeys!!.loginAccessKey,spl)
|
val (restoreKey, restoreData) = AccessControlObject.pack(passwordKeys!!.loginAccessKey, spl)
|
||||||
try {
|
try {
|
||||||
val result = adapter.invokeCommand(
|
val result = adapter.invokeCommand(
|
||||||
api.registerUser, RegistrationArgs(
|
api.slRegister, RegistrationArgs(
|
||||||
login,
|
login,
|
||||||
passwordKeys!!.loginId,
|
passwordKeys!!.loginId,
|
||||||
deferredLoginKey.await().publicKey,
|
deferredLoginKey.await().publicKey,
|
||||||
derivationParams,
|
lastDerivationParams!!,
|
||||||
restoreKey.restoreId, restoreData
|
restoreKey.restoreId, restoreData,
|
||||||
|
extraData
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
when (result) {
|
when (result) {
|
||||||
AuthenticationResult.RestoreIdUnavailable -> {
|
AuthenticationResult.RestoreIdUnavailable -> {
|
||||||
// rare situation but still possible: just repack the ACO
|
// rare situation but still possible: just repack the ACO
|
||||||
|
debug { "retrying registration on restoreId clash" }
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthenticationResult.LoginIdUnavailable -> {
|
||||||
|
// rare situation: loginId is already in use. We have to re-derive password keys
|
||||||
|
// using new random salt:
|
||||||
|
debug { "retrying registration on loginId clash"}
|
||||||
|
lastDerivationParams = lastDerivationParams!!.copy(salt = Random.nextBytes(32))
|
||||||
|
passwordKeys = DerivedKeys.derive(password, lastDerivationParams!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthenticationResult.LoginUnavailable -> return Result.InvalidLogin
|
AuthenticationResult.LoginUnavailable -> return Result.InvalidLogin
|
||||||
is AuthenticationResult.Success -> return Result.Success(
|
|
||||||
restoreKey.secret,
|
is AuthenticationResult.Success -> {
|
||||||
dataKey,
|
return Result.Success(
|
||||||
result.loginToken
|
restoreKey.secret,
|
||||||
)
|
dataKey,
|
||||||
|
result.loginToken,
|
||||||
|
result.applicationData
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (x: Throwable) {
|
||||||
catch(x: Throwable) {
|
|
||||||
exception { "Failed to register" to x }
|
exception { "Failed to register" to x }
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,213 @@
|
|||||||
|
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.AuthenticationResult
|
||||||
|
import net.sergeych.superlogin.SuperloginServerApi
|
||||||
|
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 loginToken: ByteArray? = null,
|
||||||
|
val data: T? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SuperloginClient<D, S : WithAdapter>(
|
||||||
|
private val transport: Parsec3Transport<S>,
|
||||||
|
savedData: SuperloginData<D>? = null,
|
||||||
|
private val dataType: KType,
|
||||||
|
) : 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 slData: SuperloginData<D>? = savedData
|
||||||
|
set(value) {
|
||||||
|
if (field != value) {
|
||||||
|
field = value
|
||||||
|
if (value == null) {
|
||||||
|
// do actual disconnect work
|
||||||
|
_cflow.value = false
|
||||||
|
_state.value = LoginState.LoggedOut
|
||||||
|
if (!adapterReady.isActive) {
|
||||||
|
adapterReady.cancel()
|
||||||
|
adapterReady = CompletableDeferred()
|
||||||
|
}
|
||||||
|
globalLaunch {
|
||||||
|
transport.adapter().invokeCommand(api.slLogout)
|
||||||
|
adapterReady.complete(Unit)
|
||||||
|
}
|
||||||
|
} 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<D>)?.loginData?.data
|
||||||
|
|
||||||
|
private var adapterReady = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
override suspend fun adapter(): Adapter<S> {
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
adapterReady.await()
|
||||||
|
return transport.adapter()
|
||||||
|
} catch (x: Throwable) {
|
||||||
|
exception { "failed to get adapter" to x }
|
||||||
|
}
|
||||||
|
} while (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <A, R> call(ca: CommandDescriptor<A, R>, args: A ): R = adapter().invokeCommand(ca, args)
|
||||||
|
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 api = SuperloginServerApi<WithAdapter>()
|
||||||
|
|
||||||
|
private suspend fun tryRestoreLogin() {
|
||||||
|
slData?.loginToken?.let { token ->
|
||||||
|
try {
|
||||||
|
val ar = transport.adapter().invokeCommand(api.slLoginByToken, token)
|
||||||
|
slData = if (ar is AuthenticationResult.Success) {
|
||||||
|
val data: D? = ar.applicationData?.let { BossDecoder.decodeFrom(dataType, it) }
|
||||||
|
SuperloginData(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(rr.loginToken, rr.encodedData?.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")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
mustBeLoggedIn()
|
||||||
|
slData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun LoginByToken(token: ByteArray): SuperloginData<D> {
|
||||||
|
mustBeLoggedOut()
|
||||||
|
val r = invoke(api.slLoginByToken,token)
|
||||||
|
when(r) {
|
||||||
|
AuthenticationResult.LoginIdUnavailable -> TODO()
|
||||||
|
AuthenticationResult.LoginUnavailable -> TODO()
|
||||||
|
AuthenticationResult.RestoreIdUnavailable -> TODO()
|
||||||
|
is AuthenticationResult.Success -> TODO()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
inline operator fun <reified D, S : WithAdapter> invoke(
|
||||||
|
t: Parsec3Transport<S>,
|
||||||
|
savedData: SuperloginData<D>? = null,
|
||||||
|
): SuperloginClient<D, S> {
|
||||||
|
return SuperloginClient(t, savedData, typeOf<D>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package net.sergeych.superlogin.server
|
||||||
|
|
||||||
|
import net.sergeych.superlogin.AuthenticationResult
|
||||||
|
import net.sergeych.superlogin.RegistrationArgs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of procedures the server implementing superlogin protocol should provide
|
||||||
|
*/
|
||||||
|
interface SLServerTraits {
|
||||||
|
/**
|
||||||
|
* Register specified user. The server should check that:
|
||||||
|
*
|
||||||
|
* - [RegistrationArgs.loginName] is not used, otherwise return [AuthenticationResult.LoginUnavailable]
|
||||||
|
* - [RegistrationArgs.loginId] is not used, otherwise return [AuthenticationResult.LoginIdUnavailable]
|
||||||
|
* - [RegistrationArgs.restoreId] is not used or return [AuthenticationResult.RestoreIdUnavailable]
|
||||||
|
*
|
||||||
|
* Then it should save permanently data from `registrationArgs` in a way tha allow fast search (indexed,
|
||||||
|
* usually) by `loginName`, `loginId` and `restoreId`, and return [AuthenticationResult.Success] with
|
||||||
|
* newly generated random bytes string `loginToken` that would optionally simplify logging in.
|
||||||
|
*
|
||||||
|
* If the implementation does not provide login token, it should still provide random bytes string
|
||||||
|
* to maintain hight security level of the serivce.
|
||||||
|
*/
|
||||||
|
suspend fun register(registrationArgs: RegistrationArgs): AuthenticationResult
|
||||||
|
|
||||||
|
suspend fun logout() {}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package net.sergeych.superlogin
|
package net.sergeych.superlogin.server
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import net.sergeych.unikrypto.PrivateKey
|
import net.sergeych.unikrypto.PrivateKey
|
||||||
@ -13,7 +13,7 @@ import net.sergeych.unikrypto.SymmetricKey
|
|||||||
* - storage key (used to safely keep stored data)
|
* - storage key (used to safely keep stored data)
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SuperloginPayload(
|
data class SuperloginRestoreAccessPayload(
|
||||||
val login: String,
|
val login: String,
|
||||||
val loginPrivateKey: PrivateKey,
|
val loginPrivateKey: PrivateKey,
|
||||||
val dataStorageKey: SymmetricKey
|
val dataStorageKey: SymmetricKey
|
14
src/commonTest/kotlin/superlogin/assert_throws.kt
Normal file
14
src/commonTest/kotlin/superlogin/assert_throws.kt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package superlogin
|
||||||
|
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
suspend inline fun <reified T: Throwable> assertThrowsAsync(f: suspend () -> Unit) {
|
||||||
|
try {
|
||||||
|
f()
|
||||||
|
fail("Nothing was thrown while ${T::class.simpleName} is expected")
|
||||||
|
}
|
||||||
|
catch(x: Throwable) {
|
||||||
|
if( x !is T )
|
||||||
|
fail("${x::class.simpleName} was thrown instead of ${T::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package net.sergeych.superlogin.server
|
||||||
|
|
||||||
|
import net.sergeych.parsec3.CommandHost
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side API convenience base.
|
||||||
|
*/
|
||||||
|
open class SLServerApiBase<D>: CommandHost<SLServerSession<D>>()
|
@ -0,0 +1,18 @@
|
|||||||
|
package net.sergeych.superlogin.server
|
||||||
|
|
||||||
|
import net.sergeych.parsec3.WithAdapter
|
||||||
|
import net.sergeych.superlogin.client.SuperloginData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The superlogin server session. Please use only [loginName] and [loginToken], the rest could be
|
||||||
|
* a subject to change.
|
||||||
|
*/
|
||||||
|
open class SLServerSession<T>: WithAdapter() {
|
||||||
|
var slData: SuperloginData<T>? = null
|
||||||
|
|
||||||
|
val userData: T? get() = slData?.let { it.data }
|
||||||
|
|
||||||
|
val loginToken get() = slData?.loginToken
|
||||||
|
|
||||||
|
var loginName: String? = null
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package net.sergeych.superlogin.server
|
||||||
|
|
||||||
|
import net.sergeych.boss_serialization_mp.decodeBoss
|
||||||
|
import net.sergeych.parsec3.AdapterBuilder
|
||||||
|
import net.sergeych.parsec3.CommandHost
|
||||||
|
import net.sergeych.parsec3.WithAdapter
|
||||||
|
import net.sergeych.superlogin.AuthenticationResult
|
||||||
|
import net.sergeych.superlogin.SuperloginServerApi
|
||||||
|
import net.sergeych.superlogin.client.SuperloginData
|
||||||
|
|
||||||
|
inline fun <reified D, T : SLServerSession<D>, H : CommandHost<T>> AdapterBuilder<T, H>.superloginServer(
|
||||||
|
traits: SLServerTraits,
|
||||||
|
) {
|
||||||
|
val a2 = SuperloginServerApi<WithAdapter>()
|
||||||
|
on(a2.slRegister) { ra ->
|
||||||
|
traits.register(ra).also { rr ->
|
||||||
|
if( rr is AuthenticationResult.Success)
|
||||||
|
slData = SuperloginData<D>(rr.loginToken, rr.applicationData?.let { it.decodeBoss<D>()})
|
||||||
|
loginName = ra.loginName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on(a2.slLogout) {
|
||||||
|
println("--- logged out ---")
|
||||||
|
slData = null
|
||||||
|
loginName = null
|
||||||
|
traits.logout()
|
||||||
|
}
|
||||||
|
}
|
119
src/jvmTest/kotlin/net/sergeych/WsServerKtTest.kt
Normal file
119
src/jvmTest/kotlin/net/sergeych/WsServerKtTest.kt
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package net.sergeych
|
||||||
|
|
||||||
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.netty.*
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import net.sergeych.parsec3.Parsec3WSClient
|
||||||
|
import net.sergeych.parsec3.WithAdapter
|
||||||
|
import net.sergeych.parsec3.parsec3TransportServer
|
||||||
|
import net.sergeych.superlogin.AuthenticationResult
|
||||||
|
import net.sergeych.superlogin.RegistrationArgs
|
||||||
|
import net.sergeych.superlogin.SuperloginServerApi
|
||||||
|
import net.sergeych.superlogin.client.LoginState
|
||||||
|
import net.sergeych.superlogin.client.Registration
|
||||||
|
import net.sergeych.superlogin.client.SuperloginClient
|
||||||
|
import net.sergeych.superlogin.server.SLServerApiBase
|
||||||
|
import net.sergeych.superlogin.server.SLServerSession
|
||||||
|
import net.sergeych.superlogin.server.SLServerTraits
|
||||||
|
import net.sergeych.superlogin.server.superloginServer
|
||||||
|
import superlogin.assertThrowsAsync
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
|
||||||
|
data class TestSession(var buzz: String = "BuZZ") : SLServerSession<TestData>()
|
||||||
|
|
||||||
|
|
||||||
|
object TestApiServer : SLServerApiBase<TestData>() {
|
||||||
|
val loginName by command<Unit, String?>()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
object TestServerTraits : SLServerTraits {
|
||||||
|
val byLogin = mutableMapOf<String, RegistrationArgs>()
|
||||||
|
val byLoginId = mutableMapOf<List<Byte>, RegistrationArgs>()
|
||||||
|
val byRestoreId = mutableMapOf<List<Byte>, RegistrationArgs>()
|
||||||
|
val byToken = mutableMapOf<List<Byte>, RegistrationArgs>()
|
||||||
|
|
||||||
|
override suspend fun register(ra: RegistrationArgs): AuthenticationResult {
|
||||||
|
println("ra: ${ra.loginName}")
|
||||||
|
return when {
|
||||||
|
ra.loginName in byLogin -> {
|
||||||
|
AuthenticationResult.LoginUnavailable
|
||||||
|
}
|
||||||
|
ra.loginId.toList() in byLoginId -> AuthenticationResult.LoginIdUnavailable
|
||||||
|
ra.restoreId.toList() in byRestoreId -> AuthenticationResult.RestoreIdUnavailable
|
||||||
|
else -> {
|
||||||
|
byLogin[ra.loginName] = ra
|
||||||
|
byRestoreId[ra.restoreId.toList()] = ra
|
||||||
|
byLoginId[ra.loginId.toList()] = ra
|
||||||
|
val token = Random.Default.nextBytes(32)
|
||||||
|
byToken[token.toList()] = ra
|
||||||
|
AuthenticationResult.Success(token, ra.extraData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestData(
|
||||||
|
val foo: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
internal class WsServerKtTest {
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWsServer() {
|
||||||
|
|
||||||
|
embeddedServer(Netty, port = 8080) {
|
||||||
|
parsec3TransportServer(TestApiServer) {
|
||||||
|
newSession { TestSession() }
|
||||||
|
superloginServer(TestServerTraits)
|
||||||
|
on(api.loginName) {
|
||||||
|
loginName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start(wait = false)
|
||||||
|
|
||||||
|
val client = Parsec3WSClient("ws://localhost:8080/api/p3", SuperloginServerApi<WithAdapter>()) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val slc = SuperloginClient<TestData, WithAdapter>(client)
|
||||||
|
assertEquals(LoginState.LoggedOut, slc.state.value)
|
||||||
|
var rt = slc.register("foo", "passwd", TestData("bar!"))
|
||||||
|
assertIs<Registration.Result.Success>(rt)
|
||||||
|
val secret = rt.secret
|
||||||
|
var token = rt.loginToken
|
||||||
|
println(rt.secret)
|
||||||
|
assertEquals("bar!", rt.data<TestData>()?.foo)
|
||||||
|
|
||||||
|
assertEquals("foo", slc.call(TestApiServer.loginName))
|
||||||
|
|
||||||
|
val s = slc.state.value
|
||||||
|
assertIs<LoginState.LoggedIn<TestData>>(s)
|
||||||
|
assertEquals("bar!", s.loginData.data!!.foo)
|
||||||
|
|
||||||
|
assertThrowsAsync<IllegalStateException> {
|
||||||
|
slc.register("foo", "passwd", TestData("nobar"))
|
||||||
|
}
|
||||||
|
slc.logout()
|
||||||
|
assertIs<LoginState.LoggedOut>(slc.state.value)
|
||||||
|
assertEquals(null, slc.call(TestApiServer.loginName))
|
||||||
|
|
||||||
|
rt = slc.register("foo", "passwd", TestData("nobar"))
|
||||||
|
assertIs<Registration.Result.InvalidLogin>(rt)
|
||||||
|
|
||||||
|
// slc.loginByToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user