Compare commits

..

4 Commits

Author SHA1 Message Date
9841b3bda3 Refactor authentication 2026-05-10 11:09:31 +02:00
f5ca766293 Refactor 2026-03-27 18:31:07 +01:00
c718bc2c6a Upgrade dependencies 2026-02-19 14:54:39 +01:00
6d04f15c45 update dependencies 2025-12-13 02:29:12 +01:00
30 changed files with 318 additions and 404 deletions

View File

@@ -1,3 +1,5 @@
import com.infendro.plugin.infendro
group = "com.infendro.account" group = "com.infendro.account"
version = "0.0.1" version = "0.0.1"
@@ -5,17 +7,18 @@ application {
mainClass.set("com.infendro.account.ApplicationKt") mainClass.set("com.infendro.account.ApplicationKt")
} }
repositories {
maven("https://git.infendro.com/api/packages/Infendro/maven")
mavenCentral()
}
plugins { plugins {
alias(libs.plugins.infendro)
alias(libs.plugins.kotlin) alias(libs.plugins.kotlin)
alias(libs.plugins.ktor) alias(libs.plugins.ktor)
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
} }
repositories {
infendro()
mavenCentral()
}
dependencies { dependencies {
implementation(libs.ktor.server) implementation(libs.ktor.server)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)
@@ -26,16 +29,20 @@ dependencies {
implementation(libs.ktor.server.contentNegotiation) implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization) implementation(libs.ktor.server.serialization)
implementation(libs.ktor.server.validation) implementation(libs.ktor.server.validation)
implementation(libs.exposed) implementation(libs.exposed)
implementation(libs.exposed.dao) implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc) implementation(libs.exposed.jdbc)
implementation(libs.migration) implementation(libs.migration)
implementation(libs.postgres) implementation(libs.postgres)
implementation(libs.random)
implementation(libs.otp) implementation(libs.bytes)
implementation(libs.kdf)
implementation(libs.hash)
implementation(libs.encoding) implementation(libs.encoding)
implementation(libs.hash)
implementation(libs.kdf)
implementation(libs.otp)
implementation(libs.prng)
implementation(libs.logback) implementation(libs.logback)
} }

View File

@@ -1,17 +1,23 @@
[versions] [versions]
kotlin = "2.2.10" infendro = "1.1.0"
ktor = "3.2.3" kotlin = "2.3.0"
exposed = "0.61.0" ktor = "3.4.0"
migration = "1.1.0"
postgres = "42.7.5" exposed = "1.0.0"
random = "1.0.0" migration = "1.2.0"
otp = "1.1.1" postgres = "42.7.10"
kdf = "1.0.3"
hash = "1.2.2" bytes = "1.4.0"
encoding = "1.1.0" encoding = "1.3.0"
logback = "1.5.18" hash = "1.6.0"
kdf = "1.2.0"
otp = "1.3.0"
prng = "1.3.0"
logback = "1.5.32"
[plugins] [plugins]
infendro = { id = "com.infendro.plugin", version.ref = "infendro" }
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" } ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
@@ -30,13 +36,14 @@ ktor-server-validation = { module = "io.ktor:ktor-server-request-validation", ve
exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", 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" } exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
migration = { module = "com.infendro:ktor-migration", version.ref = "migration" } migration = { module = "com.infendro:migration-ktor", version.ref = "migration" }
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }
random = { module = "com.infendro:random", version.ref = "random" } bytes = { module = "com.infendro:bytes", version.ref = "bytes" }
otp = { module = "com.infendro:otp", version.ref = "otp" }
kdf = { module = "com.infendro:kdf", version.ref = "kdf" }
hash = { module = "com.infendro:hash", version.ref = "hash" }
encoding = { module = "com.infendro:encoding", version.ref = "encoding" } encoding = { module = "com.infendro:encoding", version.ref = "encoding" }
hash = { module = "com.infendro:hash", version.ref = "hash" }
kdf = { module = "com.infendro:kdf", version.ref = "kdf" }
otp = { module = "com.infendro:otp", version.ref = "otp" }
prng = { module = "com.infendro:prng", version.ref = "prng" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }

View File

@@ -1 +1,7 @@
rootProject.name = "backend" rootProject.name = "backend"
pluginManagement.repositories {
maven("https://git.infendro.com/api/packages/Infendro/maven")
gradlePluginPortal()
mavenCentral()
}

View File

@@ -1,12 +1,11 @@
package com.infendro.account package com.infendro.account
import com.infendro.account.config.* import com.infendro.account.config.*
import com.infendro.account.config.configureSession
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.cio.* import io.ktor.server.cio.*
fun main( fun main(args: Array<String>) = EngineMain.main(args)
args: Array<String>,
) = EngineMain.main(args)
fun Application.module() { fun Application.module() {
configureDatabase() configureDatabase()
@@ -15,6 +14,7 @@ fun Application.module() {
configureValidation() configureValidation()
configureException() configureException()
configureSession()
configureSecurity() configureSecurity()
configureRouting() configureRouting()
} }

View File

@@ -1,11 +1,9 @@
package com.infendro.account.config package com.infendro.account.config
import com.infendro.account.model.Role
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.util.SecureHasher
import com.infendro.ktor.migration.Migration import com.infendro.ktor.migration.Migration
import initial
import io.ktor.server.application.* import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.v1.jdbc.Database
fun Application.configureDatabase() { fun Application.configureDatabase() {
val config = configuration val config = configuration
@@ -17,56 +15,6 @@ fun Application.configureDatabase() {
) )
install(Migration) { install(Migration) {
migration("initial") { initial()
exec(
"""
CREATE TABLE access
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL
);
CREATE TABLE account
(
id BIGSERIAL
PRIMARY KEY,
username TEXT
UNIQUE
NOT NULL,
password_hash TEXT
NOT NULL,
password_salt TEXT
NOT NULL,
secret TEXT
NOT NULL,
role TEXT
NOT NULL
);
CREATE TABLE session
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL,
account_id BIGINT
REFERENCES account (id) ON DELETE CASCADE
NOT NULL
);
""".trimIndent()
)
AccountRepository.insert {
val salt = SecureHasher.generateSalt()
this.username = "infendro"
this.passwordHash = SecureHasher.hash("password", salt)
this.passwordSalt = salt
this.secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA===="
this.role = Role.OWNER
}
}
} }
} }

View File

@@ -1,25 +1,14 @@
package com.infendro.account.config package com.infendro.account.config
import com.infendro.account.exception.client.UnauthorizedException
import com.infendro.account.model.Role import com.infendro.account.model.Role
import com.infendro.account.model.entity.AccountEntity import com.infendro.account.model.entity.AccountEntity
import com.infendro.account.model.entity.SessionEntity 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.model.repository.SessionRepository
import com.infendro.account.util.Hasher
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.* import io.ktor.server.auth.*
import io.ktor.server.sessions.* import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import kotlin.time.Duration.Companion.days
@Serializable data class Principal(
data class AuthenticationSession(
val token: String,
)
data class AuthenticationPrincipal(
val account: AccountEntity, val account: AccountEntity,
val session: SessionEntity, val session: SessionEntity,
) { ) {
@@ -28,34 +17,20 @@ data class AuthenticationPrincipal(
} }
fun Application.configureSecurity() { fun Application.configureSecurity() {
val config = configuration
install(Sessions) {
cookie<AuthenticationSession>("Authentication") {
this.cookie.domain = config.security.cookie.domain
this.cookie.path = config.security.cookie.path
this.cookie.httpOnly = true
this.cookie.secure = config.security.cookie.secure
this.cookie.extensions["SameSite"] = "Strict"
this.cookie.maxAge = 7.days
}
}
install(Authentication) { install(Authentication) {
session<AuthenticationSession> { bearer {
validate { session -> authenticate { credential ->
val session = SessionRepository SessionRepository.by(credential.token)?.principal
.singleOrNull { SessionTable.tokenHash eq Hasher.hash(session.token) }
?: return@validate null
sessions.set(session)
AuthenticationPrincipal(session.account(), session)
} }
challenge { }
call.sessions.clear<AuthenticationSession>() session<Session> {
validate { session ->
throw UnauthorizedException() SessionRepository.by(session.token)?.principal
?.also { sessions.set(session) }
} }
} }
} }
} }
val SessionEntity.principal: Principal
get() = Principal(account(), this)

View File

@@ -0,0 +1,26 @@
package com.infendro.account.config
import io.ktor.server.application.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.days
@Serializable
data class Session(
val token: String,
)
fun Application.configureSession() {
val config = configuration
install(Sessions) {
cookie<Session>("Authentication") {
this.cookie.domain = config.security.cookie.domain
this.cookie.path = config.security.cookie.path
this.cookie.httpOnly = true
this.cookie.secure = config.security.cookie.secure
this.cookie.extensions["SameSite"] = "Strict"
this.cookie.maxAge = 7.days
}
}
}

View File

@@ -18,12 +18,10 @@ data class PostAccountRequest(
) )
} }
fun RequestValidationConfig.validatePostAccountRequest() { fun RequestValidationConfig.validatePostAccountRequest() = validate<PostAccountRequest> { request ->
validate<PostAccountRequest> { request ->
when { when {
!USERNAME.matches(request.username) -> Invalid("") !USERNAME.matches(request.username) -> Invalid("")
!PASSWORD.matches(request.password) -> Invalid("") !PASSWORD.matches(request.password) -> Invalid("")
else -> Valid else -> Valid
} }
}
} }

View File

@@ -11,11 +11,9 @@ data class PutAccountCurrentPasswordRequest(
val otp: String, val otp: String,
) )
fun RequestValidationConfig.validatePutAccountCurrentPasswordRequest() { fun RequestValidationConfig.validatePutAccountCurrentPasswordRequest() = validate<PutAccountCurrentPasswordRequest> { request ->
validate<PutAccountCurrentPasswordRequest> { request ->
when { when {
!PASSWORD.matches(request.password) -> Invalid("") !PASSWORD.matches(request.password) -> Invalid("")
else -> Valid else -> Valid
} }
}
} }

View File

@@ -11,11 +11,9 @@ data class PutAccountCurrentUsernameRequest(
val otp: String, val otp: String,
) )
fun RequestValidationConfig.validatePutAccountCurrentUsernameRequest() { fun RequestValidationConfig.validatePutAccountCurrentUsernameRequest() = validate<PutAccountCurrentUsernameRequest> { request ->
validate<PutAccountCurrentUsernameRequest> { request ->
when { when {
!USERNAME.matches(request.username) -> Invalid("") !USERNAME.matches(request.username) -> Invalid("")
else -> Valid else -> Valid
} }
}
} }

View File

@@ -13,11 +13,9 @@ data class AccountResponse(
val sessions: List<SessionResponse>, val sessions: List<SessionResponse>,
) )
fun AccountEntity.toResponse(): AccountResponse { fun AccountEntity.toResponse() = AccountResponse(
return AccountResponse(
id = id.value, id = id.value,
username = username, username = username,
role = role, role = role,
sessions = sessions().map(SessionEntity::toResponse), sessions = sessions().map(SessionEntity::toResponse),
) )
}

View File

@@ -9,9 +9,7 @@ data class SessionResponse(
val accountId: Long, val accountId: Long,
) )
fun SessionEntity.toResponse(): SessionResponse { fun SessionEntity.toResponse() = SessionResponse(
return SessionResponse(
id = id.value, id = id.value,
accountId = account().id.value, accountId = account().id.value,
) )
}

View File

@@ -0,0 +1,56 @@
import com.infendro.account.model.Role
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.util.PasswordHasher
import com.infendro.ktor.migration.MigrationConfig
fun MigrationConfig.initial() = migration("initial") {
exec(
"""
CREATE TABLE access
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL
);
CREATE TABLE account
(
id BIGSERIAL
PRIMARY KEY,
username TEXT
UNIQUE
NOT NULL,
password_hash TEXT
NOT NULL,
password_salt TEXT
NOT NULL,
secret TEXT
NOT NULL,
role TEXT
NOT NULL
);
CREATE TABLE session
(
id BIGSERIAL
PRIMARY KEY,
token_hash TEXT
NOT NULL,
account_id BIGINT
REFERENCES account (id) ON DELETE CASCADE
NOT NULL
);
""".trimIndent(),
)
AccountRepository.insert {
val salt = PasswordHasher.generateSalt()
this.username = "infendro"
this.passwordHash = PasswordHasher.hash("password", salt)
this.passwordSalt = salt
this.secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA===="
this.role = Role.OWNER
}
}

View File

@@ -13,16 +13,5 @@ enum class Role(
} }
} }
infix fun List<Role>.except( infix fun List<Role>.except(roles: List<Role>) = filterNot { it in roles }
roles: List<Role>, infix fun List<Role>.except(role: Role) = filterNot { it == role }
): List<Role> {
return filterNot { it in roles }
.toList()
}
infix fun List<Role>.except(
role: Role,
): List<Role> {
return filterNot { it == role }
.toList()
}

View File

@@ -1,9 +1,9 @@
package com.infendro.account.model.entity package com.infendro.account.model.entity
import org.jetbrains.exposed.dao.LongEntity import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.v1.dao.LongEntity
import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.v1.dao.LongEntityClass
object AccessTable : LongIdTable("access", "id") { object AccessTable : LongIdTable("access", "id") {
val tokenHash = text("token_hash") val tokenHash = text("token_hash")

View File

@@ -1,11 +1,11 @@
package com.infendro.account.model.entity package com.infendro.account.model.entity
import com.infendro.account.model.Role import com.infendro.account.model.Role
import org.jetbrains.exposed.dao.LongEntity import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.v1.dao.LongEntity
import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.v1.dao.LongEntityClass
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
object AccountTable : LongIdTable("account", "id") { object AccountTable : LongIdTable("account", "id") {
val username = text("username") val username = text("username")
@@ -20,7 +20,7 @@ object AccountTable : LongIdTable("account", "id") {
else -> throw Error() else -> throw Error()
} }
}, },
toDb = { it.name } toDb = { it.name },
) )
} }

View File

@@ -1,10 +1,10 @@
package com.infendro.account.model.entity package com.infendro.account.model.entity
import org.jetbrains.exposed.dao.LongEntity import org.jetbrains.exposed.v1.core.dao.id.EntityID
import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.v1.dao.LongEntity
import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.v1.dao.LongEntityClass
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
object SessionTable : LongIdTable("session", "id") { object SessionTable : LongIdTable("session", "id") {
val tokenHash = text("token_hash") val tokenHash = text("token_hash")

View File

@@ -1,9 +1,9 @@
package com.infendro.account.model.repository package com.infendro.account.model.repository
import org.jetbrains.exposed.dao.LongEntity import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.dao.LongEntityClass import org.jetbrains.exposed.v1.dao.LongEntity
import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.v1.dao.LongEntityClass
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
abstract class Repository<ENTITY : LongEntity>( abstract class Repository<ENTITY : LongEntity>(
private val entityClass: LongEntityClass<ENTITY>, private val entityClass: LongEntityClass<ENTITY>,
@@ -12,113 +12,75 @@ abstract class Repository<ENTITY : LongEntity>(
entityClass.all().toList() entityClass.all().toList()
} }
fun all( fun all(where: Op<Boolean>): List<ENTITY> = transaction {
where: Op<Boolean>,
): List<ENTITY> = transaction {
entityClass.find(where).toList() entityClass.find(where).toList()
} }
fun all( fun all(where: () -> Op<Boolean>): List<ENTITY> {
where: () -> Op<Boolean>,
): List<ENTITY> {
return all(where()) return all(where())
} }
fun single( fun single(where: Op<Boolean>): ENTITY = transaction {
where: Op<Boolean>,
): ENTITY = transaction {
entityClass.find(where).single() entityClass.find(where).single()
} }
fun single( fun single(where: () -> Op<Boolean>): ENTITY {
where: () -> Op<Boolean>,
): ENTITY {
return single(where()) return single(where())
} }
fun singleOrNull( fun singleOrNull(where: Op<Boolean>): ENTITY? = transaction {
where: Op<Boolean>,
): ENTITY? = transaction {
entityClass.find(where).singleOrNull() entityClass.find(where).singleOrNull()
} }
fun singleOrNull( fun singleOrNull(where: () -> Op<Boolean>): ENTITY? {
where: () -> Op<Boolean>,
): ENTITY? {
return singleOrNull(where()) return singleOrNull(where())
} }
fun exists( fun exists(where: Op<Boolean>): Boolean = transaction {
where: Op<Boolean>,
): Boolean = transaction {
entityClass.find(where).any() entityClass.find(where).any()
} }
fun exists( fun exists(where: () -> Op<Boolean>): Boolean {
where: () -> Op<Boolean>,
): Boolean {
return exists(where()) return exists(where())
} }
fun insert( fun insert(block: ENTITY.() -> Unit): Unit = transaction {
block: ENTITY.() -> Unit,
): Unit = transaction {
entityClass.new(block) entityClass.new(block)
} }
fun update( fun update(entity: ENTITY, block: ENTITY.() -> Unit): Unit = transaction {
entity: ENTITY,
block: ENTITY.() -> Unit,
): Unit = transaction {
entity.block() entity.block()
} }
fun update( fun update(entities: Iterable<ENTITY>, block: ENTITY.() -> Unit) {
entities: Iterable<ENTITY>,
block: ENTITY.() -> Unit,
) {
for (entity in entities) { for (entity in entities) {
update(entity, block) update(entity, block)
} }
} }
fun update( fun update(where: Op<Boolean>, block: ENTITY.() -> Unit) {
where: Op<Boolean>,
block: ENTITY.() -> Unit,
) {
update(all(where), block) update(all(where), block)
} }
fun update( fun update(where: () -> Op<Boolean>, block: ENTITY.() -> Unit) {
where: () -> Op<Boolean>,
block: ENTITY.() -> Unit,
) {
update(where(), block) update(where(), block)
} }
fun delete( fun delete(entity: ENTITY): Unit = transaction {
entity: ENTITY,
): Unit = transaction {
entity.delete() entity.delete()
} }
fun delete( fun delete(entities: Iterable<ENTITY>) {
entities: Iterable<ENTITY>,
) {
for (entity in entities) { for (entity in entities) {
delete(entity) delete(entity)
} }
} }
fun delete( fun delete(where: Op<Boolean>) {
where: Op<Boolean>,
) {
delete(all(where)) delete(all(where))
} }
fun delete( fun delete(where: () -> Op<Boolean>) {
where: () -> Op<Boolean>,
) {
delete(where()) delete(where())
} }
} }

View File

@@ -1,5 +1,10 @@
package com.infendro.account.model.repository package com.infendro.account.model.repository
import com.infendro.account.model.entity.SessionEntity import com.infendro.account.model.entity.SessionEntity
import com.infendro.account.model.entity.SessionTable
import com.infendro.account.util.Hasher
import org.jetbrains.exposed.v1.core.eq
object SessionRepository : Repository<SessionEntity>(SessionEntity) object SessionRepository : Repository<SessionEntity>(SessionEntity) {
fun by(token: String) = singleOrNull { SessionTable.tokenHash eq Hasher.hash(token) }
}

View File

@@ -1,6 +1,6 @@
package com.infendro.account.routing package com.infendro.account.routing
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.service.AccessService import com.infendro.account.service.AccessService
import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.resources.* import io.ktor.resources.*
@@ -27,21 +27,21 @@ private class Access {
fun Routing.access() { fun Routing.access() {
authenticate { authenticate {
post<Access> { post<Access> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccessService.post(principal) AccessService.post(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
get<Access.All> { get<Access.All> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccessService.getAll(principal) AccessService.getAll(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
delete<Access.Id> { resource -> delete<Access.Id> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val id = resource.id val id = resource.id
AccessService.deleteId(principal, id) AccessService.deleteId(principal, id)

View File

@@ -1,6 +1,6 @@
package com.infendro.account.routing package com.infendro.account.routing
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.dto.request.* import com.infendro.account.dto.request.*
import com.infendro.account.service.AccountService import com.infendro.account.service.AccountService
import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.OK
@@ -79,28 +79,28 @@ fun Routing.account() {
authenticate { authenticate {
get<Account.All> { get<Account.All> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccountService.getAll(principal) AccountService.getAll(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
get<Account.Current> { get<Account.Current> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccountService.getCurrent(principal) AccountService.getCurrent(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
delete<Account.Current> { delete<Account.Current> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccountService.deleteCurrent(principal) AccountService.deleteCurrent(principal)
call.respond(OK) call.respond(OK)
} }
put<Account.Current.Username> { put<Account.Current.Username> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val request = call.receive<PutAccountCurrentUsernameRequest>() val request = call.receive<PutAccountCurrentUsernameRequest>()
AccountService.putCurrentUsername(principal, call.sessions, request) AccountService.putCurrentUsername(principal, call.sessions, request)
@@ -108,7 +108,7 @@ fun Routing.account() {
} }
put<Account.Current.Password> { put<Account.Current.Password> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val request = call.receive<PutAccountCurrentPasswordRequest>() val request = call.receive<PutAccountCurrentPasswordRequest>()
AccountService.putCurrentPassword(principal, call.sessions, request) AccountService.putCurrentPassword(principal, call.sessions, request)
@@ -116,7 +116,7 @@ fun Routing.account() {
} }
post<Account.Current.Secret> { post<Account.Current.Secret> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val request = call.receive<PostAccountCurrentSecretRequest>() val request = call.receive<PostAccountCurrentSecretRequest>()
AccountService.postCurrentSecret(principal, call.sessions, request) AccountService.postCurrentSecret(principal, call.sessions, request)
@@ -124,14 +124,14 @@ fun Routing.account() {
} }
get<Account.Current.Session.All> { get<Account.Current.Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
AccountService.getCurrentSessionAll(principal) AccountService.getCurrentSessionAll(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
delete<Account.Current.Session.Id> { resource -> delete<Account.Current.Session.Id> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val id = resource.id val id = resource.id
AccountService.deleteCurrentSessionId(principal, call.sessions, id) AccountService.deleteCurrentSessionId(principal, call.sessions, id)
@@ -139,7 +139,7 @@ fun Routing.account() {
} }
delete<Account.Id> { resource -> delete<Account.Id> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val id = resource.id val id = resource.id
AccountService.deleteId(principal, id) AccountService.deleteId(principal, id)
@@ -147,7 +147,7 @@ fun Routing.account() {
} }
put<Account.Id.Role> { resource -> put<Account.Id.Role> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val id = resource.parent.id val id = resource.parent.id
val request = call.receive<PutAccountIdRoleRequest>() val request = call.receive<PutAccountIdRoleRequest>()

View File

@@ -1,6 +1,6 @@
package com.infendro.account.routing package com.infendro.account.routing
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.dto.request.PostSessionRequest import com.infendro.account.dto.request.PostSessionRequest
import com.infendro.account.service.SessionService import com.infendro.account.service.SessionService
import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.OK
@@ -42,14 +42,14 @@ fun Routing.session() {
authenticate { authenticate {
get<Session.All> { get<Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
SessionService.getAll(principal) SessionService.getAll(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
delete<Session.Id> { resource -> delete<Session.Id> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
val id = resource.id val id = resource.id
SessionService.deleteId(principal, id) SessionService.deleteId(principal, id)
@@ -57,14 +57,14 @@ fun Routing.session() {
} }
get<Session.Current> { get<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
SessionService.getCurrent(principal) SessionService.getCurrent(principal)
.also { call.respond(it) } .also { call.respond(it) }
} }
delete<Session.Current> { delete<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<Principal>()!!
SessionService.deleteCurrent(principal, call.sessions) SessionService.deleteCurrent(principal, call.sessions)
call.respond(OK) call.respond(OK)

View File

@@ -1,6 +1,6 @@
package com.infendro.account.service package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.dto.response.AccessResponse import com.infendro.account.dto.response.AccessResponse
import com.infendro.account.dto.response.PostAccessResponse import com.infendro.account.dto.response.PostAccessResponse
import com.infendro.account.dto.response.toResponse import com.infendro.account.dto.response.toResponse
@@ -11,12 +11,10 @@ import com.infendro.account.model.entity.AccessTable
import com.infendro.account.model.repository.AccessRepository import com.infendro.account.model.repository.AccessRepository
import com.infendro.account.util.Hasher import com.infendro.account.util.Hasher
import com.infendro.account.util.TokenGenerator import com.infendro.account.util.TokenGenerator
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.v1.core.eq
object AccessService { object AccessService {
fun post( fun post(principal: Principal): PostAccessResponse {
principal: AuthenticationPrincipal,
): PostAccessResponse {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -26,13 +24,11 @@ object AccessService {
} }
return PostAccessResponse( return PostAccessResponse(
token = token token = token,
) )
} }
fun getAll( fun getAll(principal: Principal): List<AccessResponse> {
principal: AuthenticationPrincipal,
): List<AccessResponse> {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -41,10 +37,7 @@ object AccessService {
.map { it.toResponse() } .map { it.toResponse() }
} }
fun deleteId( fun deleteId(principal: Principal, id: Long) {
principal: AuthenticationPrincipal,
id: Long,
) {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()

View File

@@ -1,7 +1,7 @@
package com.infendro.account.service package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.config.AuthenticationSession import com.infendro.account.config.Session
import com.infendro.account.dto.request.* import com.infendro.account.dto.request.*
import com.infendro.account.dto.response.* import com.infendro.account.dto.response.*
import com.infendro.account.exception.client.ConflictException import com.infendro.account.exception.client.ConflictException
@@ -16,14 +16,12 @@ import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.SessionRepository import com.infendro.account.model.repository.SessionRepository
import com.infendro.account.util.Hasher import com.infendro.account.util.Hasher
import com.infendro.account.util.OTP import com.infendro.account.util.OTP
import com.infendro.account.util.SecureHasher import com.infendro.account.util.PasswordHasher
import io.ktor.server.sessions.* import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.v1.core.eq
object AccountService { object AccountService {
fun post( fun post(request: PostAccountRequest): PostAccountResponse {
request: PostAccountRequest,
): PostAccountResponse {
val access = AccessRepository val access = AccessRepository
.singleOrNull { AccessTable.tokenHash eq Hasher.hash(request.access.token) } .singleOrNull { AccessTable.tokenHash eq Hasher.hash(request.access.token) }
?: throw UnauthorizedException() ?: throw UnauthorizedException()
@@ -36,23 +34,21 @@ object AccountService {
val secret = OTP.generateSecret() val secret = OTP.generateSecret()
AccountRepository.insert { AccountRepository.insert {
val salt = SecureHasher.generateSalt() val salt = PasswordHasher.generateSalt()
this.username = request.username this.username = request.username
this.passwordHash = SecureHasher.hash(request.password, salt) this.passwordHash = PasswordHasher.hash(request.password, salt)
this.passwordSalt = salt this.passwordSalt = salt
this.secret = secret this.secret = secret
this.role = USER this.role = USER
} }
return PostAccountResponse( return PostAccountResponse(
secret = secret secret = secret,
) )
} }
fun getAll( fun getAll(principal: Principal): List<AccountResponse> {
principal: AuthenticationPrincipal,
): List<AccountResponse> {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -61,26 +57,18 @@ object AccountService {
.map(AccountEntity::toResponse) .map(AccountEntity::toResponse)
} }
fun getCurrent( fun getCurrent(principal: Principal): AccountResponse {
principal: AuthenticationPrincipal,
): AccountResponse {
return principal.account.toResponse() return principal.account.toResponse()
} }
fun deleteCurrent( fun deleteCurrent(principal: Principal) {
principal: AuthenticationPrincipal,
) {
if (principal.role == OWNER) if (principal.role == OWNER)
throw ForbiddenException() throw ForbiddenException()
AccountRepository.delete(principal.account) AccountRepository.delete(principal.account)
} }
fun putCurrentUsername( fun putCurrentUsername(principal: Principal, sessions: CurrentSession, request: PutAccountCurrentUsernameRequest) {
principal: AuthenticationPrincipal,
sessions: CurrentSession,
request: PutAccountCurrentUsernameRequest,
) {
if (!OTP.verify(principal.account.secret, request.otp)) if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException() throw UnauthorizedException()
@@ -89,34 +77,30 @@ object AccountService {
} }
SessionRepository.delete(principal.account.sessions()) SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
fun putCurrentPassword( fun putCurrentPassword(principal: Principal, sessions: CurrentSession, request: PutAccountCurrentPasswordRequest) {
principal: AuthenticationPrincipal,
sessions: CurrentSession,
request: PutAccountCurrentPasswordRequest,
) {
if (!OTP.verify(principal.account.secret, request.otp)) if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException() throw UnauthorizedException()
AccountRepository.update(principal.account) { AccountRepository.update(principal.account) {
val salt = SecureHasher.generateSalt() val salt = PasswordHasher.generateSalt()
this.passwordHash = SecureHasher.hash(request.password, salt) this.passwordHash = PasswordHasher.hash(request.password, salt)
this.passwordSalt = salt this.passwordSalt = salt
} }
SessionRepository.delete(principal.account.sessions()) SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
fun postCurrentSecret( fun postCurrentSecret(
principal: AuthenticationPrincipal, principal: Principal,
sessions: CurrentSession, sessions: CurrentSession,
request: PostAccountCurrentSecretRequest, request: PostAccountCurrentSecretRequest
): PostAccountCurrentSecretResponse { ): PostAccountCurrentSecretResponse {
if (principal.account.passwordHash != SecureHasher.hash(request.password, principal.account.passwordSalt)) if (principal.account.passwordHash != PasswordHasher.hash(request.password, principal.account.passwordSalt))
throw UnauthorizedException() throw UnauthorizedException()
val secret = OTP.generateSecret() val secret = OTP.generateSecret()
@@ -125,25 +109,19 @@ object AccountService {
} }
SessionRepository.delete(principal.account.sessions()) SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
return PostAccountCurrentSecretResponse( return PostAccountCurrentSecretResponse(
secret = secret secret = secret,
) )
} }
fun getCurrentSessionAll( fun getCurrentSessionAll(principal: Principal): List<SessionResponse> {
principal: AuthenticationPrincipal,
): List<SessionResponse> {
return principal.account.sessions() return principal.account.sessions()
.map(SessionEntity::toResponse) .map(SessionEntity::toResponse)
} }
fun deleteCurrentSessionId( fun deleteCurrentSessionId(principal: Principal, sessions: CurrentSession, id: Long) {
principal: AuthenticationPrincipal,
sessions: CurrentSession,
id: Long,
) {
val session = SessionRepository val session = SessionRepository
.singleOrNull { SessionTable.id eq id } .singleOrNull { SessionTable.id eq id }
?: throw NotFoundException() ?: throw NotFoundException()
@@ -154,13 +132,10 @@ object AccountService {
SessionRepository.delete(session) SessionRepository.delete(session)
if (session.id == principal.session.id) if (session.id == principal.session.id)
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
fun deleteId( fun deleteId(principal: Principal, id: Long) {
principal: AuthenticationPrincipal,
id: Long,
) {
val account = AccountRepository val account = AccountRepository
.singleOrNull { AccountTable.id eq id } .singleOrNull { AccountTable.id eq id }
?: throw NotFoundException() ?: throw NotFoundException()
@@ -171,11 +146,7 @@ object AccountService {
AccountRepository.delete(account) AccountRepository.delete(account)
} }
fun putIdRole( fun putIdRole(principal: Principal, id: Long, request: PutAccountIdRoleRequest) {
principal: AuthenticationPrincipal,
id: Long,
request: PutAccountIdRoleRequest,
) {
val account = AccountRepository val account = AccountRepository
.singleOrNull { AccountTable.id eq id } .singleOrNull { AccountTable.id eq id }
?: throw NotFoundException() ?: throw NotFoundException()

View File

@@ -1,7 +1,7 @@
package com.infendro.account.service package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.Principal
import com.infendro.account.config.AuthenticationSession import com.infendro.account.config.Session
import com.infendro.account.dto.request.PostSessionRequest import com.infendro.account.dto.request.PostSessionRequest
import com.infendro.account.dto.response.SessionResponse import com.infendro.account.dto.response.SessionResponse
import com.infendro.account.dto.response.toResponse import com.infendro.account.dto.response.toResponse
@@ -16,17 +16,14 @@ import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.SessionRepository import com.infendro.account.model.repository.SessionRepository
import com.infendro.account.util.Hasher import com.infendro.account.util.Hasher
import com.infendro.account.util.OTP import com.infendro.account.util.OTP
import com.infendro.account.util.SecureHasher import com.infendro.account.util.PasswordHasher
import com.infendro.account.util.TokenGenerator import com.infendro.account.util.TokenGenerator
import io.ktor.server.sessions.* import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.v1.core.inList
object SessionService { object SessionService {
fun post( fun post(sessions: CurrentSession, request: PostSessionRequest) {
sessions: CurrentSession,
request: PostSessionRequest,
) {
val account = AccountRepository val account = AccountRepository
.singleOrNull { AccountTable.username eq request.username } .singleOrNull { AccountTable.username eq request.username }
?: throw NotFoundException() ?: throw NotFoundException()
@@ -34,7 +31,7 @@ object SessionService {
if (!OTP.verify(account.secret, request.otp)) if (!OTP.verify(account.secret, request.otp))
throw UnauthorizedException() throw UnauthorizedException()
if (account.passwordHash != SecureHasher.hash(request.password, account.passwordSalt)) if (account.passwordHash != PasswordHasher.hash(request.password, account.passwordSalt))
throw UnauthorizedException() throw UnauthorizedException()
val token = TokenGenerator.generate() val token = TokenGenerator.generate()
@@ -43,14 +40,10 @@ object SessionService {
this.account = account this.account = account
} }
sessions.set( sessions.set(Session(token))
AuthenticationSession(token)
)
} }
fun getAll( fun getAll(principal: Principal): List<SessionResponse> {
principal: AuthenticationPrincipal,
): List<SessionResponse> {
if (principal.role == USER) if (principal.role == USER)
throw ForbiddenException() throw ForbiddenException()
@@ -60,10 +53,7 @@ object SessionService {
.map(SessionEntity::toResponse) .map(SessionEntity::toResponse)
} }
fun deleteId( fun deleteId(principal: Principal, id: Long) {
principal: AuthenticationPrincipal,
id: Long,
) {
if (principal.role == USER) if (principal.role == USER)
throw ForbiddenException() throw ForbiddenException()
@@ -77,18 +67,13 @@ object SessionService {
SessionRepository.delete(session) SessionRepository.delete(session)
} }
fun getCurrent( fun getCurrent(principal: Principal): SessionResponse {
principal: AuthenticationPrincipal,
): SessionResponse {
return principal.session.toResponse() return principal.session.toResponse()
} }
fun deleteCurrent( fun deleteCurrent(principal: Principal, sessions: CurrentSession) {
principal: AuthenticationPrincipal,
sessions: CurrentSession,
) {
SessionRepository.delete(principal.session) SessionRepository.delete(principal.session)
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
} }

View File

@@ -1,13 +1,11 @@
package com.infendro.account.util package com.infendro.account.util
import com.infendro.encoding.Hex import com.infendro.encoding.Base16
import com.infendro.hash.SHA256 import com.infendro.hash.sha2.`SHA-256`
object Hasher { object Hasher {
fun hash( fun hash(value: String): String {
value: String, val bytes = `SHA-256`.hash(value.encodeToByteArray())
): String { return Base16.encode(bytes).decodeToString()
val bytes = SHA256.hash(value.toByteArray())
return Hex.encode(bytes).decodeToString()
} }
} }

View File

@@ -1,29 +1,27 @@
package com.infendro.account.util package com.infendro.account.util
import com.infendro.encoding.Base32 import com.infendro.encoding.Base32
import com.infendro.hash.SHA256 import com.infendro.hash.sha2.`SHA-256`
import com.infendro.otp.SecretGenerator
import com.infendro.otp.TOTP import com.infendro.otp.TOTP
import com.infendro.random.csprng.`HMAC-DRBG`
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
object OTP { object OTP {
private val random = `HMAC-DRBG`(`SHA-256`)
private val totp = TOTP( private val totp = TOTP(
function = SHA256, function = `SHA-256`,
length = 8, length = 8,
period = 30.seconds, period = 30.seconds,
) )
fun verify( fun verify(secret: String, otp: String): Boolean {
secret: String, val bytes = Base32.decode(secret.encodeToByteArray())
otp: String,
): Boolean {
val bytes = Base32.decode(secret.toByteArray())
return totp.verify(bytes, Clock.System.now(), otp) return totp.verify(bytes, Clock.System.now(), otp)
} }
fun generateSecret(): String { fun generateSecret(): String {
val bytes = SecretGenerator.generate(totp.hotp.function) val bytes = random.nextBytes(32)
return Base32.encode(bytes).decodeToString() return Base32.encode(bytes).decodeToString()
} }
} }

View File

@@ -0,0 +1,23 @@
package com.infendro.account.util
import com.infendro.encoding.Base16
import com.infendro.hash.sha2.`SHA-256`
import com.infendro.kdf.PBKDF2
import com.infendro.random.csprng.`HMAC-DRBG`
object PasswordHasher {
private val random = `HMAC-DRBG`(`SHA-256`)
private val kdf = PBKDF2(32, 100_000, `SHA-256`)
fun hash(value: String, salt: String): String {
val value = value.encodeToByteArray()
val salt = Base16.decode(salt.encodeToByteArray())
return kdf.hash(value, salt)
.let { Base16.encode(it).decodeToString() }
}
fun generateSalt(): String {
return random.nextBytes(16)
.let { Base16.encode(it).decodeToString() }
}
}

View File

@@ -1,26 +0,0 @@
package com.infendro.account.util
import com.infendro.encoding.Hex
import com.infendro.hash.SHA256
import com.infendro.kdf.PBKDF2
import com.infendro.random.PRNG
object SecureHasher {
private val random = PRNG.HMAC
private val kdf = PBKDF2(SHA256)
fun hash(
value: String,
salt: String,
): String {
val value = value.encodeToByteArray()
val salt = Hex.decode(salt.encodeToByteArray())
return kdf.hash(value, salt, 100_000, 32)
.let { Hex.encode(it).decodeToString() }
}
fun generateSalt(): String {
return random.nextBytes(16)
.let { Hex.encode(it).decodeToString() }
}
}

View File

@@ -1,13 +1,14 @@
package com.infendro.account.util package com.infendro.account.util
import com.infendro.encoding.Hex import com.infendro.encoding.Base16
import com.infendro.random.PRNG import com.infendro.hash.sha2.`SHA-256`
import com.infendro.random.csprng.`HMAC-DRBG`
object TokenGenerator { object TokenGenerator {
private val random = PRNG.HMAC private val random = `HMAC-DRBG`(`SHA-256`)
fun generate(): String { fun generate(): String {
return random.nextBytes(32) return random.nextBytes(32)
.let { Hex.encode(it).decodeToString() } .let { Base16.encode(it).decodeToString() }
} }
} }