This commit is contained in:
2025-08-20 00:15:06 +02:00
parent 7beeb39616
commit b868e5c675
22 changed files with 93 additions and 228 deletions

View File

@@ -29,9 +29,8 @@ dependencies {
implementation(libs.exposed)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.migration)
implementation(libs.postgres)
implementation(libs.koin)
implementation(libs.koin.logger)
implementation(libs.random)
implementation(libs.otp)
implementation(libs.kdf)

View File

@@ -2,8 +2,8 @@
kotlin = "2.2.10"
ktor = "3.2.3"
exposed = "0.60.0"
migration = "1.0.0"
postgres = "42.7.5"
koin = "4.1.0"
random = "1.0.0"
otp = "1.1.1"
kdf = "1.0.3"
@@ -30,11 +30,9 @@ ktor-server-validation = { module = "io.ktor:ktor-server-request-validation", ve
exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
migration = { module = "com.infendro:ktor-migration", version.ref = "migration" }
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }
koin = { module = "io.insert-koin:koin-ktor", version.ref = "koin" }
koin-logger = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" }
random = { module = "com.infendro:random", version.ref = "random" }
otp = { module = "com.infendro:otp", version.ref = "otp" }
kdf = { module = "com.infendro:kdf", version.ref = "kdf" }

View File

@@ -9,8 +9,6 @@ fun main(
) = EngineMain.main(args)
fun Application.module() {
configureDependencyInjection()
configureDatabase()
configureSerialization()

View File

@@ -1,15 +1,11 @@
package com.infendro.account.config
import com.infendro.account.model.Role
import com.infendro.account.model.entity.MigrationTable
import com.infendro.account.model.migration
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.util.SecureHasher
import com.infendro.migration.Migration
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject
fun Application.configureDatabase() {
val config = environment.config.database
@@ -19,16 +15,8 @@ fun Application.configureDatabase() {
user = config.username,
password = config.password,
)
migrate()
}
fun Application.migrate() {
transaction {
SchemaUtils.create(MigrationTable)
}
val accounts by inject<AccountRepository>()
install(Migration) {
migration("initial") {
exec(
"""
@@ -70,7 +58,7 @@ fun Application.migrate() {
""".trimIndent()
)
accounts.insert {
AccountRepository.insert {
val salt = SecureHasher.generateSalt()
this.username = "infendro"
@@ -80,4 +68,5 @@ fun Application.migrate() {
this.role = Role.OWNER
}
}
}
}

View File

@@ -1,34 +0,0 @@
package com.infendro.account.config
import com.infendro.account.model.repository.AccessRepository
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.MigrationRepository
import com.infendro.account.model.repository.SessionRepository
import com.infendro.account.service.AccessService
import com.infendro.account.service.AccountService
import com.infendro.account.service.RoleService
import com.infendro.account.service.SessionService
import io.ktor.server.application.*
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureDependencyInjection() {
install(Koin) {
slf4jLogger()
modules(module)
}
}
private val module = module {
singleOf(::MigrationRepository)
singleOf(::AccessRepository)
singleOf(::AccountRepository)
singleOf(::SessionRepository)
singleOf(::AccessService)
singleOf(::AccountService)
singleOf(::RoleService)
singleOf(::SessionService)
}

View File

@@ -7,15 +7,12 @@ import com.infendro.account.model.entity.SessionEntity
import com.infendro.account.model.entity.SessionTable
import com.infendro.account.model.repository.SessionRepository
import com.infendro.account.util.Hasher
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.session
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.sessions.*
import kotlin.time.Duration.Companion.days
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.koin.ktor.ext.inject
import kotlin.time.Duration.Companion.days
@Serializable
data class AuthenticationSession(
@@ -46,9 +43,7 @@ fun Application.configureSecurity() {
install(Authentication) {
session<AuthenticationSession> {
validate { authenticationSession ->
val sessionRepository by inject<SessionRepository>()
val session = sessionRepository
val session = SessionRepository
.singleOrNull { SessionTable.tokenHash eq Hasher.hash(authenticationSession.token) }
?: return@validate null

View File

@@ -1,34 +0,0 @@
package com.infendro.account.model
import com.infendro.account.model.entity.MigrationTable
import com.infendro.account.model.repository.MigrationRepository
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject
fun Application.migration(
name: String,
block: Transaction.() -> Unit,
) {
val migrations by inject<MigrationRepository>()
migrations
.singleOrNull { MigrationTable.name eq name }
?.run { return }
transaction {
try {
block()
log.info("""Migration "$name" successful""")
} catch (e: Exception) {
log.error("""Migration "$name" failed""")
throw e
}
}
migrations.insert {
this.name = name
}
}

View File

@@ -1,16 +0,0 @@
package com.infendro.account.model.entity
import org.jetbrains.exposed.dao.LongEntity
import org.jetbrains.exposed.dao.LongEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.LongIdTable
object MigrationTable : LongIdTable("migration", "id") {
val name = text("name")
}
class MigrationEntity(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<MigrationEntity>(MigrationTable)
var name by MigrationTable.name
}

View File

@@ -2,4 +2,4 @@ package com.infendro.account.model.repository
import com.infendro.account.model.entity.AccessEntity
class AccessRepository : Repository<AccessEntity>(AccessEntity)
object AccessRepository : Repository<AccessEntity>(AccessEntity)

View File

@@ -2,4 +2,4 @@ package com.infendro.account.model.repository
import com.infendro.account.model.entity.AccountEntity
class AccountRepository : Repository<AccountEntity>(AccountEntity)
object AccountRepository : Repository<AccountEntity>(AccountEntity)

View File

@@ -1,5 +0,0 @@
package com.infendro.account.model.repository
import com.infendro.account.model.entity.MigrationEntity
class MigrationRepository : Repository<MigrationEntity>(MigrationEntity)

View File

@@ -50,8 +50,8 @@ abstract class Repository<ENTITY : LongEntity>(
fun exists(
where: Op<Boolean>,
): Boolean {
return all(where).isNotEmpty()
): Boolean = transaction {
entityClass.find(where).any()
}
fun exists(

View File

@@ -2,4 +2,4 @@ package com.infendro.account.model.repository
import com.infendro.account.model.entity.SessionEntity
class SessionRepository : Repository<SessionEntity>(SessionEntity)
object SessionRepository : Repository<SessionEntity>(SessionEntity)

View File

@@ -3,15 +3,12 @@ package com.infendro.account.routing
import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.service.AccessService
import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.resources.Resource
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.resources.delete
import io.ktor.server.resources.get
import io.ktor.resources.*
import io.ktor.server.auth.*
import io.ktor.server.resources.*
import io.ktor.server.resources.post
import io.ktor.server.response.respond
import io.ktor.server.response.*
import io.ktor.server.routing.Routing
import org.koin.ktor.ext.inject
@Resource("/access")
private class Access {
@@ -28,20 +25,18 @@ private class Access {
}
fun Routing.access() {
val accessService by inject<AccessService>()
authenticate {
post<Access> {
val principal = call.principal<AuthenticationPrincipal>()!!
accessService.post(principal)
AccessService.post(principal)
.also { call.respond(it) }
}
get<Access.All> {
val principal = call.principal<AuthenticationPrincipal>()!!
accessService.getAll(principal)
AccessService.getAll(principal)
.also { call.respond(it) }
}
@@ -49,7 +44,7 @@ fun Routing.access() {
val principal = call.principal<AuthenticationPrincipal>()!!
val id = resource.id
accessService.deleteId(principal, id)
AccessService.deleteId(principal, id)
call.respond(OK)
}
}

View File

@@ -13,7 +13,6 @@ import io.ktor.server.resources.put
import io.ktor.server.response.*
import io.ktor.server.routing.Routing
import io.ktor.server.sessions.*
import org.koin.ktor.ext.inject
@Resource("/account")
private class Account {
@@ -71,12 +70,10 @@ private class Account {
}
fun Routing.account() {
val accountService by inject<AccountService>()
post<Account> {
val request = call.receive<PostAccountRequest>()
accountService.post(request)
AccountService.post(request)
.also { call.respond(it) }
}
@@ -84,21 +81,21 @@ fun Routing.account() {
get<Account.All> {
val principal = call.principal<AuthenticationPrincipal>()!!
accountService.getAll(principal)
AccountService.getAll(principal)
.also { call.respond(it) }
}
get<Account.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
accountService.getCurrent(principal)
AccountService.getCurrent(principal)
.also { call.respond(it) }
}
delete<Account.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
accountService.deleteCurrent(principal)
AccountService.deleteCurrent(principal)
call.respond(OK)
}
@@ -106,7 +103,7 @@ fun Routing.account() {
val principal = call.principal<AuthenticationPrincipal>()!!
val request = call.receive<PutAccountCurrentUsernameRequest>()
accountService.putCurrentUsername(principal, call.sessions, request)
AccountService.putCurrentUsername(principal, call.sessions, request)
call.respond(OK)
}
@@ -114,7 +111,7 @@ fun Routing.account() {
val principal = call.principal<AuthenticationPrincipal>()!!
val request = call.receive<PutAccountCurrentPasswordRequest>()
accountService.putCurrentPassword(principal, call.sessions, request)
AccountService.putCurrentPassword(principal, call.sessions, request)
call.respond(OK)
}
@@ -122,14 +119,14 @@ fun Routing.account() {
val principal = call.principal<AuthenticationPrincipal>()!!
val request = call.receive<PostAccountCurrentSecretRequest>()
accountService.postCurrentSecret(principal, call.sessions, request)
AccountService.postCurrentSecret(principal, call.sessions, request)
.also { call.respond(it) }
}
get<Account.Current.Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!!
accountService.getCurrentSessionAll(principal)
AccountService.getCurrentSessionAll(principal)
.also { call.respond(it) }
}
@@ -137,7 +134,7 @@ fun Routing.account() {
val principal = call.principal<AuthenticationPrincipal>()!!
val id = resource.id
accountService.deleteCurrentSessionId(principal, call.sessions, id)
AccountService.deleteCurrentSessionId(principal, call.sessions, id)
call.respond(OK)
}
@@ -145,7 +142,7 @@ fun Routing.account() {
val principal = call.principal<AuthenticationPrincipal>()!!
val id = resource.id
accountService.deleteId(principal, id)
AccountService.deleteId(principal, id)
call.respond(OK)
}
@@ -154,7 +151,7 @@ fun Routing.account() {
val id = resource.parent.id
val request = call.receive<PutAccountIdRoleRequest>()
accountService.putIdRole(principal, id, request)
AccountService.putIdRole(principal, id, request)
call.respond(OK)
}
}

View File

@@ -1,11 +1,10 @@
package com.infendro.account.routing
import com.infendro.account.service.RoleService
import io.ktor.resources.Resource
import io.ktor.server.resources.get
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import org.koin.ktor.ext.inject
import io.ktor.resources.*
import io.ktor.server.resources.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@Resource("/role")
private class Role {
@@ -16,10 +15,8 @@ private class Role {
}
fun Routing.role() {
val roleService by inject<RoleService>()
get<Role.All> {
roleService.getAll()
RoleService.getAll()
.also { call.respond(it) }
}
}

View File

@@ -4,17 +4,14 @@ import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.dto.request.PostSessionRequest
import com.infendro.account.service.SessionService
import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.resources.Resource
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.request.receive
import io.ktor.server.resources.delete
import io.ktor.server.resources.get
import io.ktor.resources.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.resources.*
import io.ktor.server.resources.post
import io.ktor.server.response.respond
import io.ktor.server.response.*
import io.ktor.server.routing.Routing
import io.ktor.server.sessions.sessions
import org.koin.ktor.ext.inject
import io.ktor.server.sessions.*
@Resource("/session")
private class Session {
@@ -36,12 +33,10 @@ private class Session {
}
fun Routing.session() {
val sessionService by inject<SessionService>()
post<Session> {
val request = call.receive<PostSessionRequest>()
sessionService.post(call.sessions, request)
SessionService.post(call.sessions, request)
call.respond(OK)
}
@@ -49,7 +44,7 @@ fun Routing.session() {
get<Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!!
sessionService.getAll(principal)
SessionService.getAll(principal)
.also { call.respond(it) }
}
@@ -57,21 +52,21 @@ fun Routing.session() {
val principal = call.principal<AuthenticationPrincipal>()!!
val id = resource.id
sessionService.deleteId(principal, id)
SessionService.deleteId(principal, id)
call.respond(OK)
}
get<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
sessionService.getCurrent(principal)
SessionService.getCurrent(principal)
.also { call.respond(it) }
}
delete<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
sessionService.deleteCurrent(principal, call.sessions)
SessionService.deleteCurrent(principal, call.sessions)
call.respond(OK)
}
}

View File

@@ -13,9 +13,7 @@ import com.infendro.account.util.Hasher
import com.infendro.account.util.TokenGenerator
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
class AccessService(
private val accessRepository: AccessRepository,
) {
object AccessService {
fun post(
principal: AuthenticationPrincipal,
): PostAccessResponse {
@@ -23,7 +21,7 @@ class AccessService(
throw ForbiddenException()
val token = TokenGenerator.generate()
accessRepository.insert {
AccessRepository.insert {
this.tokenHash = Hasher.hash(token)
}
@@ -38,7 +36,7 @@ class AccessService(
if (principal.role != OWNER)
throw ForbiddenException()
return accessRepository
return AccessRepository
.all()
.map { it.toResponse() }
}
@@ -50,10 +48,10 @@ class AccessService(
if (principal.role != OWNER)
throw ForbiddenException()
val access = accessRepository
val access = AccessRepository
.singleOrNull { AccessTable.id eq id }
?: throw NotFoundException()
accessRepository.delete(access)
AccessRepository.delete(access)
}
}

View File

@@ -20,26 +20,22 @@ import com.infendro.account.util.SecureHasher
import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
class AccountService(
private val accessRepository: AccessRepository,
private val accountRepository: AccountRepository,
private val sessionRepository: SessionRepository,
) {
object AccountService {
fun post(
request: PostAccountRequest,
): PostAccountResponse {
val access = accessRepository
val access = AccessRepository
.singleOrNull { AccessTable.tokenHash eq Hasher.hash(request.access.token) }
?: throw UnauthorizedException()
accountRepository
AccountRepository
.singleOrNull { AccountTable.username eq request.username }
?.run { throw ConflictException() }
accessRepository.delete(access)
AccessRepository.delete(access)
val secret = OTP.generateSecret()
accountRepository.insert {
AccountRepository.insert {
val salt = SecureHasher.generateSalt()
this.username = request.username
@@ -60,7 +56,7 @@ class AccountService(
if (principal.role != OWNER)
throw ForbiddenException()
return accountRepository
return AccountRepository
.all()
.map(AccountEntity::toResponse)
}
@@ -77,7 +73,7 @@ class AccountService(
if (principal.role == OWNER)
throw ForbiddenException()
accountRepository.delete(principal.account)
AccountRepository.delete(principal.account)
}
fun putCurrentUsername(
@@ -88,10 +84,10 @@ class AccountService(
if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException()
accountRepository.update(principal.account) {
AccountRepository.update(principal.account) {
this.username = request.username
}
sessionRepository.delete(principal.account.sessions())
SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
}
@@ -104,13 +100,13 @@ class AccountService(
if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException()
accountRepository.update(principal.account) {
AccountRepository.update(principal.account) {
val salt = SecureHasher.generateSalt()
this.passwordHash = SecureHasher.hash(request.password, salt)
this.passwordSalt = salt
}
sessionRepository.delete(principal.account.sessions())
SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
}
@@ -124,10 +120,10 @@ class AccountService(
throw UnauthorizedException()
val secret = OTP.generateSecret()
accountRepository.update(principal.account) {
AccountRepository.update(principal.account) {
this.secret = secret
}
sessionRepository.delete(principal.account.sessions())
SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
@@ -148,14 +144,14 @@ class AccountService(
sessions: CurrentSession,
id: Long,
) {
val session = sessionRepository
val session = SessionRepository
.singleOrNull { SessionTable.id eq id }
?: throw NotFoundException()
if (session.account().id != principal.account.id)
throw ForbiddenException()
sessionRepository.delete(session)
SessionRepository.delete(session)
if (session.id == principal.session.id)
sessions.clear<AuthenticationSession>()
@@ -165,14 +161,14 @@ class AccountService(
principal: AuthenticationPrincipal,
id: Long,
) {
val account = accountRepository
val account = AccountRepository
.singleOrNull { AccountTable.id eq id }
?: throw NotFoundException()
if (account.role !in principal.role.children)
throw ForbiddenException()
accountRepository.delete(account)
AccountRepository.delete(account)
}
fun putIdRole(
@@ -180,7 +176,7 @@ class AccountService(
id: Long,
request: PutAccountIdRoleRequest,
) {
val account = accountRepository
val account = AccountRepository
.singleOrNull { AccountTable.id eq id }
?: throw NotFoundException()
@@ -190,7 +186,7 @@ class AccountService(
if (request.role !in principal.role.children)
throw ForbiddenException()
accountRepository.update(account) {
AccountRepository.update(account) {
role = request.role
}
}

View File

@@ -4,7 +4,7 @@ import com.infendro.account.dto.response.RoleResponse
import com.infendro.account.dto.response.toResponse
import com.infendro.account.model.Role
class RoleService {
object RoleService {
fun getAll(): List<RoleResponse> {
return Role.ALL
.map(Role::toResponse)

View File

@@ -22,15 +22,12 @@ import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
class SessionService(
private val accountRepository: AccountRepository,
private val sessionRepository: SessionRepository,
) {
object SessionService {
fun post(
sessions: CurrentSession,
request: PostSessionRequest,
) {
val account = accountRepository
val account = AccountRepository
.singleOrNull { AccountTable.username eq request.username }
?: throw NotFoundException()
@@ -41,7 +38,7 @@ class SessionService(
throw UnauthorizedException()
val token = TokenGenerator.generate()
sessionRepository.insert {
SessionRepository.insert {
this.tokenHash = Hasher.hash(token)
this.account = account
}
@@ -57,7 +54,7 @@ class SessionService(
if (principal.role == USER)
throw ForbiddenException()
return accountRepository
return AccountRepository
.all { AccountTable.role inList principal.role.children }
.flatMap { it.sessions() }
.map(SessionEntity::toResponse)
@@ -70,14 +67,14 @@ class SessionService(
if (principal.role == USER)
throw ForbiddenException()
val session = sessionRepository
val session = SessionRepository
.singleOrNull { SessionTable.id eq id }
?: throw NotFoundException()
if (session.account().role !in principal.role.children)
throw ForbiddenException()
sessionRepository.delete(session)
SessionRepository.delete(session)
}
fun getCurrent(
@@ -90,7 +87,7 @@ class SessionService(
principal: AuthenticationPrincipal,
sessions: CurrentSession,
) {
sessionRepository.delete(principal.session)
SessionRepository.delete(principal.session)
sessions.clear<AuthenticationSession>()
}

View File

@@ -6,8 +6,8 @@ import com.infendro.kdf.PBKDF2
import com.infendro.random.PRNG
object SecureHasher {
private val kdf = PBKDF2(SHA256)
private val random = PRNG.HMAC
private val kdf = PBKDF2(SHA256)
fun hash(
value: String,