full reborn
This commit is contained in:
18
features/auth/client/build.gradle
Normal file
18
features/auth/client/build.gradle
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
@@ -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()
|
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
1
features/auth/client/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest package="dev.inmo.postssystem.features.auth.client"/>
|
18
features/auth/common/build.gradle
Normal file
18
features/auth/common/build.gradle
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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?
|
||||
}
|
@@ -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
|
||||
)
|
@@ -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"
|
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
1
features/auth/common/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest package="dev.inmo.postssystem.features.auth.common"/>
|
17
features/auth/server/build.gradle
Normal file
17
features/auth/server/build.gradle
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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())
|
||||
}
|
||||
|
@@ -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>
|
||||
}
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
@@ -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?
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user