Refactor authentication

This commit is contained in:
2026-05-10 11:09:31 +02:00
parent f5ca766293
commit 9841b3bda3
12 changed files with 156 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
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.*
@@ -13,6 +14,7 @@ fun Application.module() {
configureValidation() configureValidation()
configureException() configureException()
configureSession()
configureSecurity() configureSecurity()
configureRouting() configureRouting()
} }

View File

@@ -1,9 +1,7 @@
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.PasswordHasher
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.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.Database
@@ -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 = PasswordHasher.generateSalt()
this.username = "infendro"
this.passwordHash = PasswordHasher.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.v1.core.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 {
authenticate { credential ->
SessionRepository.by(credential.token)?.principal
}
}
session<Session> {
validate { session -> validate { session ->
val session = SessionRepository SessionRepository.by(session.token)?.principal
.singleOrNull { SessionTable.tokenHash eq Hasher.hash(session.token) } ?.also { sessions.set(session) }
?: return@validate null }
}
}
}
sessions.set(session) val SessionEntity.principal: Principal
get() = Principal(account(), this)
AuthenticationPrincipal(session.account(), session)
}
challenge {
call.sessions.clear<AuthenticationSession>()
throw UnauthorizedException()
}
}
}
}

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

@@ -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

@@ -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
@@ -14,7 +14,7 @@ import com.infendro.account.util.TokenGenerator
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
object AccessService { object AccessService {
fun post(principal: AuthenticationPrincipal): PostAccessResponse { fun post(principal: Principal): PostAccessResponse {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -28,7 +28,7 @@ object AccessService {
) )
} }
fun getAll(principal: AuthenticationPrincipal): List<AccessResponse> { fun getAll(principal: Principal): List<AccessResponse> {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -37,7 +37,7 @@ object AccessService {
.map { it.toResponse() } .map { it.toResponse() }
} }
fun deleteId(principal: AuthenticationPrincipal, id: Long) { fun deleteId(principal: Principal, 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
@@ -48,7 +48,7 @@ object AccountService {
) )
} }
fun getAll(principal: AuthenticationPrincipal): List<AccountResponse> { fun getAll(principal: Principal): List<AccountResponse> {
if (principal.role != OWNER) if (principal.role != OWNER)
throw ForbiddenException() throw ForbiddenException()
@@ -57,18 +57,18 @@ object AccountService {
.map(AccountEntity::toResponse) .map(AccountEntity::toResponse)
} }
fun getCurrent(principal: AuthenticationPrincipal): AccountResponse { fun getCurrent(principal: Principal): AccountResponse {
return principal.account.toResponse() return principal.account.toResponse()
} }
fun deleteCurrent(principal: AuthenticationPrincipal) { fun deleteCurrent(principal: Principal) {
if (principal.role == OWNER) if (principal.role == OWNER)
throw ForbiddenException() throw ForbiddenException()
AccountRepository.delete(principal.account) AccountRepository.delete(principal.account)
} }
fun putCurrentUsername(principal: AuthenticationPrincipal, sessions: CurrentSession, request: PutAccountCurrentUsernameRequest) { fun putCurrentUsername(principal: Principal, sessions: CurrentSession, request: PutAccountCurrentUsernameRequest) {
if (!OTP.verify(principal.account.secret, request.otp)) if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException() throw UnauthorizedException()
@@ -77,10 +77,10 @@ object AccountService {
} }
SessionRepository.delete(principal.account.sessions()) SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
fun putCurrentPassword(principal: AuthenticationPrincipal, sessions: CurrentSession, request: PutAccountCurrentPasswordRequest) { fun putCurrentPassword(principal: Principal, sessions: CurrentSession, request: PutAccountCurrentPasswordRequest) {
if (!OTP.verify(principal.account.secret, request.otp)) if (!OTP.verify(principal.account.secret, request.otp))
throw UnauthorizedException() throw UnauthorizedException()
@@ -92,10 +92,14 @@ object AccountService {
} }
SessionRepository.delete(principal.account.sessions()) SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
fun postCurrentSecret(principal: AuthenticationPrincipal, sessions: CurrentSession, request: PostAccountCurrentSecretRequest): PostAccountCurrentSecretResponse { fun postCurrentSecret(
principal: Principal,
sessions: CurrentSession,
request: PostAccountCurrentSecretRequest
): PostAccountCurrentSecretResponse {
if (principal.account.passwordHash != PasswordHasher.hash(request.password, principal.account.passwordSalt)) if (principal.account.passwordHash != PasswordHasher.hash(request.password, principal.account.passwordSalt))
throw UnauthorizedException() throw UnauthorizedException()
@@ -105,19 +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(principal: AuthenticationPrincipal): List<SessionResponse> { fun getCurrentSessionAll(principal: Principal): List<SessionResponse> {
return principal.account.sessions() return principal.account.sessions()
.map(SessionEntity::toResponse) .map(SessionEntity::toResponse)
} }
fun deleteCurrentSessionId(principal: AuthenticationPrincipal, sessions: CurrentSession, id: Long) { fun deleteCurrentSessionId(principal: Principal, sessions: CurrentSession, id: Long) {
val session = SessionRepository val session = SessionRepository
.singleOrNull { SessionTable.id eq id } .singleOrNull { SessionTable.id eq id }
?: throw NotFoundException() ?: throw NotFoundException()
@@ -128,10 +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(principal: AuthenticationPrincipal, id: Long) { fun deleteId(principal: Principal, id: Long) {
val account = AccountRepository val account = AccountRepository
.singleOrNull { AccountTable.id eq id } .singleOrNull { AccountTable.id eq id }
?: throw NotFoundException() ?: throw NotFoundException()
@@ -142,7 +146,7 @@ object AccountService {
AccountRepository.delete(account) AccountRepository.delete(account)
} }
fun putIdRole(principal: AuthenticationPrincipal, id: Long, request: PutAccountIdRoleRequest) { fun putIdRole(principal: Principal, 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
@@ -40,10 +40,10 @@ object SessionService {
this.account = account this.account = account
} }
sessions.set(AuthenticationSession(token)) sessions.set(Session(token))
} }
fun getAll(principal: AuthenticationPrincipal): List<SessionResponse> { fun getAll(principal: Principal): List<SessionResponse> {
if (principal.role == USER) if (principal.role == USER)
throw ForbiddenException() throw ForbiddenException()
@@ -53,7 +53,7 @@ object SessionService {
.map(SessionEntity::toResponse) .map(SessionEntity::toResponse)
} }
fun deleteId(principal: AuthenticationPrincipal, id: Long) { fun deleteId(principal: Principal, id: Long) {
if (principal.role == USER) if (principal.role == USER)
throw ForbiddenException() throw ForbiddenException()
@@ -67,13 +67,13 @@ object SessionService {
SessionRepository.delete(session) SessionRepository.delete(session)
} }
fun getCurrent(principal: AuthenticationPrincipal): SessionResponse { fun getCurrent(principal: Principal): SessionResponse {
return principal.session.toResponse() return principal.session.toResponse()
} }
fun deleteCurrent(principal: AuthenticationPrincipal, sessions: CurrentSession) { fun deleteCurrent(principal: Principal, sessions: CurrentSession) {
SessionRepository.delete(principal.session) SessionRepository.delete(principal.session)
sessions.clear<AuthenticationSession>() sessions.clear<Session>()
} }
} }