full reborn

This commit is contained in:
2021-11-24 13:52:27 +06:00
parent 0ac6b0a4df
commit 6a6a197041
246 changed files with 4327 additions and 6952 deletions

View File

@@ -0,0 +1,18 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
id "com.android.library"
}
apply from: "$mppProjectWithSerializationPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":postssystem.features.common.client")
api project(":postssystem.features.auth.common")
}
}
}
}

View File

@@ -0,0 +1,55 @@
package dev.inmo.postssystem.features.auth.client
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.ktor.client.UnifiedRequester
import dev.inmo.micro_utils.ktor.common.buildStandardUrl
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import kotlinx.serialization.builtins.nullable
class ClientAuthFeature(
private val requester: UnifiedRequester,
baseUrl: String
) : AuthFeature {
private val rootUrl = buildStandardUrl(baseUrl.dropLastWhile { it == '/' }, authRootPathPart)
private val fullAuthPath = buildStandardUrl(
rootUrl,
authAuthPathPart
)
private val fullRefreshPath = buildStandardUrl(
rootUrl,
authRefreshPathPart
)
private val fullGetMePath = buildStandardUrl(
rootUrl,
authGetMePathPart
)
constructor(client: HttpClient, rootUrl: String): this(
UnifiedRequester(client),
rootUrl
)
override suspend fun auth(creds: AuthCreds): AuthTokenInfo? = requester.unipost(
fullAuthPath,
AuthCreds.serializer() to creds,
AuthTokenInfo.serializer().nullable
)
override suspend fun refresh(refresh: RefreshToken): AuthTokenInfo? = requester.unipost(
fullRefreshPath,
RefreshToken.serializer() to refresh,
AuthTokenInfo.serializer().nullable
)
override suspend fun getMe(authToken: AuthToken): User? = requester.unipost(
fullGetMePath,
AuthToken.serializer() to authToken,
User.serializer().nullable
)
fun isAuthRequest(builder: HttpRequestBuilder): Boolean = builder.url.buildString().let {
it == fullAuthPath || it == fullRefreshPath
}
}

View File

@@ -0,0 +1,112 @@
package dev.inmo.postssystem.features.auth.client
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.common.*
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import io.ktor.client.HttpClientConfig
import io.ktor.client.features.cookies.*
import io.ktor.client.features.expectSuccess
import io.ktor.client.request.*
import io.ktor.client.statement.HttpReceivePipeline
import io.ktor.client.statement.HttpResponse
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object AuthUnavailableException : Exception()
fun HttpClientConfig<*>.installClientAuthenticator(
baseUrl: String,
scope: CoroutineScope,
initialAuthKey: Either<AuthKey, AuthTokenInfo>,
onAuthKeyUpdated: suspend (AuthTokenInfo) -> Unit,
onUserRetrieved: suspend (User?) -> Unit,
onAuthKeyInvalidated: suspend () -> Unit
) {
// install(Logging) {
// logger = Logger.DEFAULT
// level = LogLevel.HEADERS
// }
install(HttpCookies) {
// Will keep an in-memory map with all the cookies from previous requests.
storage = AcceptAllCookiesStorage()
}
val authMutex = Mutex()
var currentRefreshToken: RefreshToken? = null
initialAuthKey.onFirst {
currentRefreshToken = it as? RefreshToken
}.onSecond {
currentRefreshToken = it.refresh
}
val creds = initialAuthKey.t1 as? AuthCreds
var userRefreshJob: Job? = null
install("Auth Token Refresher") {
val clientAuthFeature = ClientAuthFeature(this, baseUrl)
fun refreshUser(newTokenInfo: AuthTokenInfo) {
userRefreshJob ?.cancel()
userRefreshJob = scope.launchSafelyWithoutExceptions {
onUserRetrieved(clientAuthFeature.getMe(newTokenInfo.token))
}
}
initialAuthKey.onSecond { refreshUser(it) }
suspend fun refreshToken() {
val capturedRefresh = currentRefreshToken
runCatching {
when {
capturedRefresh == null && creds == null -> throw AuthUnavailableException
capturedRefresh != null -> {
currentRefreshToken = null
val newTokenInfo = clientAuthFeature.refresh(capturedRefresh)
currentRefreshToken = newTokenInfo ?.refresh
if (newTokenInfo == null) {
refreshToken()
} else {
onAuthKeyUpdated(newTokenInfo)
refreshUser(newTokenInfo)
}
}
creds != null -> {
val newAuthTokenInfo = clientAuthFeature.auth(creds)
if (newAuthTokenInfo != null) {
onAuthKeyUpdated(newAuthTokenInfo)
refreshUser(newAuthTokenInfo)
currentRefreshToken = newAuthTokenInfo.refresh
}
}
}
}.onFailure {
onAuthKeyInvalidated()
}
}
sendPipeline.intercept(HttpSendPipeline.State) {
if (!context.url.buildString().startsWith(baseUrl) || clientAuthFeature.isAuthRequest(context)) {
return@intercept
}
context.expectSuccess = false
if (authMutex.isLocked) {
authMutex.withLock { /* do nothing, just wait while mutex will be freed */ }
}
}
receivePipeline.intercept(HttpReceivePipeline.Before) {
if (
context.request.url.toString().startsWith(baseUrl)
&& context.response.status == HttpStatusCode.Unauthorized
) {
authMutex.withLock { refreshToken() }
val newResponse = context.client ?.request<HttpResponse>{
takeFrom(context.request)
} ?: return@intercept
proceedWith(newResponse)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package dev.inmo.postssystem.features.auth.client.ui
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.common.common.UIModel
interface AuthUIModel : UIModel<AuthUIState> {
suspend fun initAuth(serverUrl: String, creds: AuthCreds)
}

View File

@@ -0,0 +1,20 @@
package dev.inmo.postssystem.features.auth.client.ui
import kotlinx.serialization.Serializable
@Serializable
sealed class AuthUIError
@Serializable
object ServerUnavailableAuthUIError : AuthUIError()
@Serializable
object AuthIncorrectAuthUIError : AuthUIError()
@Serializable
sealed class AuthUIState
@Serializable
data class InitAuthUIState(val showError: AuthUIError? = null) : AuthUIState()
val DefaultInitAuthUIState = InitAuthUIState()
@Serializable
object LoadingAuthUIState : AuthUIState()
@Serializable
object AuthorizedAuthUIState : AuthUIState()

View File

@@ -0,0 +1,34 @@
package dev.inmo.postssystem.features.auth.client.ui
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.common.common.UIViewModel
import dev.inmo.postssystem.features.users.common.Username
import kotlinx.coroutines.flow.StateFlow
class AuthUIViewModel(
private val model: AuthUIModel
) : UIViewModel<AuthUIState> {
override val currentState: StateFlow<AuthUIState>
get() = model.currentState
private fun checkIncomingData(
serverUrl: String,
username: String,
password: String
): Boolean {
return serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}
suspend fun initAuth(
serverUrl: String,
username: String,
password: String
) {
if (checkIncomingData(serverUrl, username, password)) {
model.initAuth(
serverUrl.takeIf { it.startsWith("http") } ?: "http://$serverUrl",
AuthCreds(Username(username), password)
)
}
}
}

View File

@@ -0,0 +1 @@
<manifest package="dev.inmo.postssystem.features.auth.client"/>

View File

@@ -0,0 +1,18 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
id "com.android.library"
}
apply from: "$mppProjectWithSerializationPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":postssystem.features.common.common")
api project(":postssystem.features.users.common")
}
}
}
}

View File

@@ -0,0 +1,9 @@
package dev.inmo.postssystem.features.auth.common
import dev.inmo.postssystem.features.users.common.User
interface AuthFeature {
suspend fun auth(creds: AuthCreds): AuthTokenInfo?
suspend fun refresh(refresh: RefreshToken): AuthTokenInfo?
suspend fun getMe(authToken: AuthToken): User?
}

View File

@@ -0,0 +1,36 @@
package dev.inmo.postssystem.features.auth.common
import com.benasher44.uuid.uuid4
import dev.inmo.postssystem.features.users.common.Username
import kotlinx.serialization.*
import kotlin.jvm.JvmInline
sealed interface AuthKey
@Serializable
@SerialName("authcreds")
data class AuthCreds(
val username: Username,
val password: String
): AuthKey
@Serializable
@SerialName("token")
@JvmInline
value class AuthToken(val string: String = uuid4().toString()): AuthKey {
override fun toString(): String = string
}
@Serializable
@SerialName("refresh")
@JvmInline
value class RefreshToken(val string: String = uuid4().toString()): AuthKey {
override fun toString(): String = string
}
@Serializable
data class AuthTokenInfo(
val token: AuthToken,
val refresh: RefreshToken
)

View File

@@ -0,0 +1,8 @@
package dev.inmo.postssystem.features.auth.common
const val tokenSessionKey = "token"
const val authRootPathPart = "auth"
const val authAuthPathPart = "auth"
const val authRefreshPathPart = "refresh"
const val authGetMePathPart = "getMe"

View File

@@ -0,0 +1 @@
<manifest package="dev.inmo.postssystem.features.auth.common"/>

View File

@@ -0,0 +1,17 @@
plugins {
id "org.jetbrains.kotlin.multiplatform"
id "org.jetbrains.kotlin.plugin.serialization"
}
apply from: "$mppJavaProjectPresetPath"
kotlin {
sourceSets {
commonMain {
dependencies {
api project(":postssystem.features.auth.common")
api project(":postssystem.features.common.server")
}
}
}
}

View File

@@ -0,0 +1,121 @@
package dev.inmo.postssystem.features.auth.server
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.auth.server.tokens.AuthTokensService
import dev.inmo.postssystem.features.common.server.sessions.ApplicationAuthenticationConfigurator
import dev.inmo.postssystem.features.users.common.User
import dev.inmo.micro_utils.coroutines.safely
import dev.inmo.micro_utils.ktor.server.configurators.*
import dev.inmo.micro_utils.ktor.server.unianswer
import dev.inmo.micro_utils.ktor.server.uniload
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.routing.*
import io.ktor.sessions.*
import kotlinx.serialization.builtins.nullable
data class AuthUserPrincipal(
val user: User
) : Principal
fun User.principal() = AuthUserPrincipal(this)
class AuthenticationRoutingConfigurator(
private val authFeature: AuthFeature,
private val authTokensService: AuthTokensService
) : ApplicationRoutingConfigurator.Element, ApplicationAuthenticationConfigurator.Element {
override fun Route.invoke() {
route(authRootPathPart) {
post(authAuthPathPart) {
safely(
{
// TODO:: add error info
it.printStackTrace()
call.respond(
HttpStatusCode.InternalServerError,
"Something went wrong"
)
}
) {
val creds = call.uniload(AuthCreds.serializer())
val tokenInfo = authFeature.auth(creds)
if (tokenInfo == null) {
if (call.response.status() == null) {
call.respond(HttpStatusCode.Forbidden)
}
} else {
call.sessions.set(tokenSessionKey, tokenInfo.token)
call.unianswer(
AuthTokenInfo.serializer().nullable,
tokenInfo
)
}
}
}
post(authRefreshPathPart) {
safely(
{
// TODO:: add error info
call.respond(
HttpStatusCode.InternalServerError,
"Something went wrong"
)
}
) {
val refreshToken = call.uniload(RefreshToken.serializer())
val tokenInfo = authFeature.refresh(refreshToken)
if (tokenInfo == null) {
if (call.response.status() == null) {
call.respond(HttpStatusCode.Forbidden)
}
} else {
call.sessions.set(tokenSessionKey, tokenInfo.token)
call.unianswer(
AuthTokenInfo.serializer().nullable,
tokenInfo
)
}
}
}
post(authGetMePathPart) {
safely(
{
// TODO:: add error info
call.respond(
HttpStatusCode.InternalServerError,
"Something went wrong"
)
}
) {
call.unianswer(
User.serializer().nullable,
authFeature.getMe(
call.uniload(AuthToken.serializer())
)
)
}
}
}
}
override fun Authentication.Configuration.invoke() {
session<AuthToken> {
validate {
val result = authTokensService.getUserPrincipal(it)
if (result.isSuccess) {
result.getOrThrow().principal()
} else {
null
}
}
challenge { call.respond(HttpStatusCode.Unauthorized) }
}
}
}

View File

@@ -0,0 +1,23 @@
package dev.inmo.postssystem.features.auth.server
import dev.inmo.postssystem.features.auth.common.AuthToken
import dev.inmo.postssystem.features.common.common.Milliseconds
import dev.inmo.postssystem.features.auth.common.tokenSessionKey
import dev.inmo.micro_utils.ktor.server.configurators.ApplicationSessionsConfigurator
import io.ktor.sessions.*
import java.util.concurrent.TimeUnit
class SessionAuthenticationConfigurator(
private val maxAge: Milliseconds
) : ApplicationSessionsConfigurator.Element {
private val maxAgeInSeconds = TimeUnit.MILLISECONDS.toSeconds(maxAge)
override fun Sessions.Configuration.invoke() {
cookie<AuthToken>(tokenSessionKey) {
cookie.maxAgeInSeconds = maxAgeInSeconds
serializer = object : SessionSerializer<AuthToken> {
override fun deserialize(text: String): AuthToken = AuthToken(text)
override fun serialize(session: AuthToken): String = session.string
}
}
}
}

View File

@@ -0,0 +1,22 @@
package dev.inmo.postssystem.features.auth.server.tokens
import com.soywiz.klock.DateTime
import dev.inmo.postssystem.features.auth.common.AuthToken
import dev.inmo.postssystem.features.auth.common.RefreshToken
import dev.inmo.postssystem.features.users.common.UserId
import dev.inmo.micro_utils.repos.CRUDRepo
data class AuthTokenModel(
val token: AuthToken,
val refreshToken: RefreshToken,
val userId: UserId,
val expiring: DateTime,
val die: DateTime
)
interface AuthTokensRepo : CRUDRepo<AuthTokenModel, AuthToken, AuthTokenModel> {
suspend fun getByRefreshToken(refreshToken: RefreshToken): AuthTokenModel?
suspend fun replaceToken(toRemove: AuthToken, toInsert: AuthTokenModel): Boolean
suspend fun deleteDied(now: DateTime = DateTime.now())
}

View File

@@ -0,0 +1,18 @@
package dev.inmo.postssystem.features.auth.server.tokens
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.users.common.User
sealed class AuthTokenException : Exception()
object AuthTokenExpiredException : AuthTokenException()
object AuthTokenNotFoundException : AuthTokenException()
object UserNotFoundException : AuthTokenException()
interface AuthTokensService {
/**
* @return [User] or one of failure exceptions: [AuthTokenException]
*/
suspend fun getUserPrincipal(authToken: AuthToken): Result<User>
}

View File

@@ -0,0 +1,184 @@
package dev.inmo.postssystem.features.auth.server.tokens
import com.soywiz.klock.DateTime
import com.soywiz.klock.milliseconds
import dev.inmo.postssystem.features.auth.common.*
import dev.inmo.postssystem.features.common.common.Milliseconds
import dev.inmo.postssystem.features.users.common.*
import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions
import dev.inmo.micro_utils.repos.create
import dev.inmo.micro_utils.repos.deleteById
import dev.inmo.micro_utils.repos.exposed.AbstractExposedCRUDRepo
import dev.inmo.micro_utils.repos.exposed.initTable
import kotlinx.coroutines.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.InsertStatement
import org.jetbrains.exposed.sql.statements.UpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
private class ExposedAuthTokensRepo(
override val database: Database
) : AuthTokensRepo, AbstractExposedCRUDRepo<AuthTokenModel, AuthToken, AuthTokenModel>(
tableName = "ExposedAuthTokensRepo"
) {
private val tokenColumn = text("token")
private val refreshTokenColumn = text("refreshToken")
private val userIdColumn = long("userId")
private val expiringColumn = double("expiring")
private val dieColumn = double("die")
override val primaryKey: PrimaryKey = PrimaryKey(tokenColumn)
override val selectByIds: SqlExpressionBuilder.(List<AuthToken>) -> Op<Boolean> = {
tokenColumn.inList(it.map { it.string })
}
override val selectById: SqlExpressionBuilder.(AuthToken) -> Op<Boolean> = {
tokenColumn.eq(it.string)
}
override val ResultRow.asObject: AuthTokenModel
get() = AuthTokenModel(
AuthToken(get(tokenColumn)),
RefreshToken(get(refreshTokenColumn)),
UserId(get(userIdColumn)),
DateTime(get(expiringColumn)),
DateTime(get(dieColumn))
)
init {
initTable()
}
override fun insert(value: AuthTokenModel, it: InsertStatement<Number>) {
it[tokenColumn] = value.token.string
it[refreshTokenColumn] = value.refreshToken.string
it[userIdColumn] = value.userId.long
it[expiringColumn] = value.expiring.unixMillisDouble
it[dieColumn] = value.die.unixMillisDouble
}
override fun update(id: AuthToken, value: AuthTokenModel, it: UpdateStatement) {
it[tokenColumn] = value.token.string
it[refreshTokenColumn] = value.refreshToken.string
it[userIdColumn] = value.userId.long
it[expiringColumn] = value.expiring.unixMillisDouble
it[dieColumn] = value.die.unixMillisDouble
}
override fun InsertStatement<Number>.asObject(value: AuthTokenModel): AuthTokenModel = AuthTokenModel(
AuthToken(get(tokenColumn)),
RefreshToken(get(refreshTokenColumn)),
UserId(get(userIdColumn)),
DateTime(get(expiringColumn)),
DateTime(get(dieColumn))
)
override suspend fun getByRefreshToken(refreshToken: RefreshToken): AuthTokenModel? = transaction(database) {
select { refreshTokenColumn.eq(refreshToken.string) }.limit(1).firstOrNull() ?.asObject
}
override suspend fun replaceToken(toRemove: AuthToken, toInsert: AuthTokenModel): Boolean = transaction {
deleteWhere { tokenColumn.eq(toRemove.string) } > 0 && insert {
insert(toInsert, it)
}.insertedCount > 0
}
override suspend fun deleteDied(
now: DateTime
) = transaction(database) {
val nowAsDouble = now.unixMillisDouble
val tokens = select { dieColumn.less(nowAsDouble) }.map { it[tokenColumn] }
deleteWhere { dieColumn.less(nowAsDouble) }
tokens
}.forEach {
deleteObjectsIdsChannel.emit(AuthToken(it))
}
}
class DefaultAuthTokensService(
private val authTokensRepo: AuthTokensRepo,
private val usersRepo: ReadUsersStorage,
private val userAuthenticator: UserAuthenticator,
private val tokenLifetime: Milliseconds,
private val cleaningScope: CoroutineScope
) : AuthTokensService, AuthFeature {
private val tokenDieLifetime = tokenLifetime * 2
constructor(
database: Database,
usersRepo: ReadUsersStorage,
userAuthenticator: UserAuthenticator,
tokenLifetime: Milliseconds,
cleaningScope: CoroutineScope
): this(
ExposedAuthTokensRepo(database),
usersRepo,
userAuthenticator,
tokenLifetime,
cleaningScope
)
init {
cleaningScope.launchSafelyWithoutExceptions {
while (isActive) {
authTokensRepo.deleteDied()
delay(tokenLifetime)
}
}
}
override suspend fun getUserPrincipal(authToken: AuthToken): Result<User> {
val authTokenModel = authTokensRepo.getById(authToken) ?: return Result.failure(AuthTokenNotFoundException)
return if (authTokenModel.expiring < DateTime.now()) {
Result.failure(AuthTokenExpiredException)
} else {
val user = usersRepo.getById(authTokenModel.userId) ?: return Result.failure(UserNotFoundException)
Result.success(user)
}
}
override suspend fun auth(creds: AuthCreds): AuthTokenInfo? {
val user = userAuthenticator(creds) ?: return null
val now = DateTime.now()
val preAuthTokenModel = AuthTokenModel(
AuthToken(),
RefreshToken(),
user.id,
now + tokenLifetime.milliseconds,
now + tokenDieLifetime.milliseconds
)
val tokenModel = authTokensRepo.create(preAuthTokenModel).firstOrNull() ?: return null
return AuthTokenInfo(
tokenModel.token,
tokenModel.refreshToken
)
}
override suspend fun refresh(refresh: RefreshToken): AuthTokenInfo? {
val previousAuthTokenModel = authTokensRepo.getByRefreshToken(refresh) ?: return null
val now = DateTime.now()
if (previousAuthTokenModel.die < now) {
authTokensRepo.deleteById(previousAuthTokenModel.token)
return null
}
val newAuthTokenModel = AuthTokenModel(
AuthToken(),
RefreshToken(),
previousAuthTokenModel.userId,
now + tokenLifetime.milliseconds,
now + tokenDieLifetime.milliseconds
)
return if (authTokensRepo.replaceToken(previousAuthTokenModel.token, newAuthTokenModel)) {
AuthTokenInfo(newAuthTokenModel.token, newAuthTokenModel.refreshToken)
} else {
null
}
}
override suspend fun getMe(authToken: AuthToken): User? {
return usersRepo.getById(
authTokensRepo.getById(authToken) ?.userId ?: return null
)
}
}

View File

@@ -0,0 +1,9 @@
package dev.inmo.postssystem.features.auth.server.tokens
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.users.common.User
fun interface UserAuthenticator {
suspend operator fun invoke(authCreds: AuthCreds): User?
}

View File

@@ -0,0 +1,44 @@
package dev.inmo.postssystem.features.auth.server.tokens
import dev.inmo.postssystem.features.auth.common.AuthCreds
import dev.inmo.postssystem.features.users.common.*
import dev.inmo.micro_utils.repos.exposed.AbstractExposedReadCRUDRepo
import dev.inmo.micro_utils.repos.exposed.initTable
import org.jetbrains.exposed.sql.*
private class ExposedUsersAuthenticationRepo(
override val database: Database,
private val usersRepo: ExposedUsersStorage
) : AbstractExposedReadCRUDRepo<UserId, AuthCreds>("UsersAuthentications") {
private val passwordColumn = text("password")
private val userIdColumn = long("userid").uniqueIndex() references usersRepo.userIdColumn
override val primaryKey: PrimaryKey = PrimaryKey(userIdColumn)
override val ResultRow.asObject: UserId
get() = UserId(get(userIdColumn))
override val selectById: SqlExpressionBuilder.(AuthCreds) -> Op<Boolean> = {
usersRepo.select {
usersRepo.usernameColumn.eq(it.username.string)
}.firstOrNull() ?.get(usersRepo.userIdColumn) ?.let { userId ->
userIdColumn.eq(userId).and(passwordColumn.eq(it.password))
} ?: Op.FALSE
}
init {
initTable()
uniqueIndex("${tableName}_user_password", userIdColumn, passwordColumn)
}
}
fun exposedUsersAuthenticator(
database: Database,
usersRepo: ExposedUsersStorage
): UserAuthenticator {
val usersAuthenticatorRepo = ExposedUsersAuthenticationRepo(database, usersRepo)
return UserAuthenticator {
val userId = usersAuthenticatorRepo.getById(it) ?: return@UserAuthenticator null
usersRepo.getById(userId)
}
}