full reborn
This commit is contained in:
.github/workflows
LICENSEREADME.mdbuild.gradlebusiness_cases/post_creating
client
common
src
commonMain
kotlin
dev
inmo
postssystem
business_cases
post_creating
main
server
client
build.gradle
src
commonMain
kotlin
dev
inmo
jsMain
kotlin
dev
resources
jvmMain
kotlin
dev
inmo
postssystem
client
main
core
api
src
commonMain
kotlin
dev
inmo
postssystem
core
commonTest
kotlin
dev
inmo
postssystem
core
jvmMain
kotlin
dev
inmo
postssystem
core
content
api
business
content_adapters
binary
main
exposed
build.gradlegradle.properties
src
jvmMain
kotlin
dev
inmo
postssystem
core
exposed
jvmTest
kotlin
dev
inmo
postssystem
ktor
client
common
src
commonMain
kotlin
dev
inmo
postssystem
main
server
features
auth
client
common
server
common
client
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
common
jvmMain
kotlin
dev
inmo
postssystem
features
common
common
main
server
files
client
build.gradle
src
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
jvmMain
kotlin
dev
inmo
postssystem
features
main
server
roles
client
common
manager
client
common
server
server
status
template
client
common
server
users
client
common
build.gradle
src
commonMain
kotlin
dev
inmo
postssystem
features
users
jvmMain
kotlin
dev
inmo
postssystem
features
users
common
main
server
gradle/wrapper
gradlewgradlew.batmimes_generator
mppAndroidProject.gradlemppJavaProject.gradlemppJsProject.gradlemppProjectWithSerialization.gradlepubconf.kpsbpublish.gradlepublish.kpsbpublishing
api
src
commonMain
kotlin
main
exposed
ktor
client
src
commonMain
kotlin
com
insanusmokrassar
postssystem
main
common
src
commonMain
kotlin
com
insanusmokrassar
main
server
server
settings.gradle
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientAuthFeature.kt
Normal file
55
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientAuthFeature.kt
Normal 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
|
||||
}
|
||||
}
|
112
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientCookiesConfigurator.kt
Normal file
112
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ClientCookiesConfigurator.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIModel.kt
Normal file
8
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIModel.kt
Normal 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)
|
||||
}
|
20
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIState.kt
Normal file
20
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIState.kt
Normal 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()
|
34
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIViewModel.kt
Normal file
34
features/auth/client/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/client/ui/AuthUIViewModel.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthFeature.kt
Normal file
9
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthFeature.kt
Normal 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?
|
||||
}
|
36
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthModels.kt
Normal file
36
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/AuthModels.kt
Normal 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
|
||||
)
|
8
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/Constants.kt
Normal file
8
features/auth/common/src/commonMain/kotlin/dev/inmo/postssystem/features/auth/common/Constants.kt
Normal 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"
|
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
121
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/AuthenticationRoutingConfigurator.kt
Normal file
121
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/AuthenticationRoutingConfigurator.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
23
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/SessionAuthenticationConfigurator.kt
Normal file
23
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/SessionAuthenticationConfigurator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
22
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/AuthTokensRepo.kt
Normal file
22
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/AuthTokensRepo.kt
Normal 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())
|
||||
}
|
||||
|
18
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/AuthTokensService.kt
Normal file
18
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/AuthTokensService.kt
Normal 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>
|
||||
}
|
184
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/DefaultAuthTokensService.kt
Normal file
184
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/DefaultAuthTokensService.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
9
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/UserAuthenticator.kt
Normal file
9
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/UserAuthenticator.kt
Normal 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?
|
||||
}
|
||||
|
44
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/UsersAuths.kt
Normal file
44
features/auth/server/src/jvmMain/kotlin/dev/inmo/postssystem/features/auth/server/tokens/UsersAuths.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user