full reborn
This commit is contained in:
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