Compare commits

..

2 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
12 changed files with 157 additions and 143 deletions

View File

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

View File

@@ -1,9 +1,7 @@
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 initial
import io.ktor.server.application.*
import org.jetbrains.exposed.v1.jdbc.Database
@@ -17,56 +15,6 @@ fun Application.configureDatabase() {
)
install(Migration) {
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
}
}
initial()
}
}

View File

@@ -1,25 +1,14 @@
package com.infendro.account.config
import com.infendro.account.exception.client.UnauthorizedException
import com.infendro.account.model.Role
import com.infendro.account.model.entity.AccountEntity
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.*
import io.ktor.server.auth.*
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 AuthenticationSession(
val token: String,
)
data class AuthenticationPrincipal(
data class Principal(
val account: AccountEntity,
val session: SessionEntity,
) {
@@ -28,34 +17,20 @@ data class AuthenticationPrincipal(
}
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) {
session<AuthenticationSession> {
bearer {
authenticate { credential ->
SessionRepository.by(credential.token)?.principal
}
}
session<Session> {
validate { session ->
val session = SessionRepository
.singleOrNull { SessionTable.tokenHash eq Hasher.hash(session.token) }
?: return@validate null
SessionRepository.by(session.token)?.principal
?.also { sessions.set(session) }
}
}
}
}
sessions.set(session)
AuthenticationPrincipal(session.account(), session)
}
challenge {
call.sessions.clear<AuthenticationSession>()
throw UnauthorizedException()
}
}
}
}
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

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

View File

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

View File

@@ -1,6 +1,6 @@
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.service.SessionService
import io.ktor.http.HttpStatusCode.Companion.OK
@@ -42,14 +42,14 @@ fun Routing.session() {
authenticate {
get<Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!!
val principal = call.principal<Principal>()!!
SessionService.getAll(principal)
.also { call.respond(it) }
}
delete<Session.Id> { resource ->
val principal = call.principal<AuthenticationPrincipal>()!!
val principal = call.principal<Principal>()!!
val id = resource.id
SessionService.deleteId(principal, id)
@@ -57,14 +57,14 @@ fun Routing.session() {
}
get<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
val principal = call.principal<Principal>()!!
SessionService.getCurrent(principal)
.also { call.respond(it) }
}
delete<Session.Current> {
val principal = call.principal<AuthenticationPrincipal>()!!
val principal = call.principal<Principal>()!!
SessionService.deleteCurrent(principal, call.sessions)
call.respond(OK)

View File

@@ -1,6 +1,6 @@
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.PostAccessResponse
import com.infendro.account.dto.response.toResponse
@@ -14,7 +14,7 @@ import com.infendro.account.util.TokenGenerator
import org.jetbrains.exposed.v1.core.eq
object AccessService {
fun post(principal: AuthenticationPrincipal): PostAccessResponse {
fun post(principal: Principal): PostAccessResponse {
if (principal.role != OWNER)
throw ForbiddenException()
@@ -28,7 +28,7 @@ object AccessService {
)
}
fun getAll(principal: AuthenticationPrincipal): List<AccessResponse> {
fun getAll(principal: Principal): List<AccessResponse> {
if (principal.role != OWNER)
throw ForbiddenException()
@@ -37,7 +37,7 @@ object AccessService {
.map { it.toResponse() }
}
fun deleteId(principal: AuthenticationPrincipal, id: Long) {
fun deleteId(principal: Principal, id: Long) {
if (principal.role != OWNER)
throw ForbiddenException()

View File

@@ -1,7 +1,7 @@
package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.config.AuthenticationSession
import com.infendro.account.config.Principal
import com.infendro.account.config.Session
import com.infendro.account.dto.request.*
import com.infendro.account.dto.response.*
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)
throw ForbiddenException()
@@ -57,18 +57,18 @@ object AccountService {
.map(AccountEntity::toResponse)
}
fun getCurrent(principal: AuthenticationPrincipal): AccountResponse {
fun getCurrent(principal: Principal): AccountResponse {
return principal.account.toResponse()
}
fun deleteCurrent(principal: AuthenticationPrincipal) {
fun deleteCurrent(principal: Principal) {
if (principal.role == OWNER)
throw ForbiddenException()
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))
throw UnauthorizedException()
@@ -77,10 +77,10 @@ object AccountService {
}
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))
throw UnauthorizedException()
@@ -92,10 +92,14 @@ object AccountService {
}
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))
throw UnauthorizedException()
@@ -105,19 +109,19 @@ object AccountService {
}
SessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
sessions.clear<Session>()
return PostAccountCurrentSecretResponse(
secret = secret,
)
}
fun getCurrentSessionAll(principal: AuthenticationPrincipal): List<SessionResponse> {
fun getCurrentSessionAll(principal: Principal): List<SessionResponse> {
return principal.account.sessions()
.map(SessionEntity::toResponse)
}
fun deleteCurrentSessionId(principal: AuthenticationPrincipal, sessions: CurrentSession, id: Long) {
fun deleteCurrentSessionId(principal: Principal, sessions: CurrentSession, id: Long) {
val session = SessionRepository
.singleOrNull { SessionTable.id eq id }
?: throw NotFoundException()
@@ -128,10 +132,10 @@ object AccountService {
SessionRepository.delete(session)
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
.singleOrNull { AccountTable.id eq id }
?: throw NotFoundException()
@@ -142,7 +146,7 @@ object AccountService {
AccountRepository.delete(account)
}
fun putIdRole(principal: AuthenticationPrincipal, id: Long, request: PutAccountIdRoleRequest) {
fun putIdRole(principal: Principal, id: Long, request: PutAccountIdRoleRequest) {
val account = AccountRepository
.singleOrNull { AccountTable.id eq id }
?: throw NotFoundException()

View File

@@ -1,7 +1,7 @@
package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.config.AuthenticationSession
import com.infendro.account.config.Principal
import com.infendro.account.config.Session
import com.infendro.account.dto.request.PostSessionRequest
import com.infendro.account.dto.response.SessionResponse
import com.infendro.account.dto.response.toResponse
@@ -40,10 +40,10 @@ object SessionService {
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)
throw ForbiddenException()
@@ -53,7 +53,7 @@ object SessionService {
.map(SessionEntity::toResponse)
}
fun deleteId(principal: AuthenticationPrincipal, id: Long) {
fun deleteId(principal: Principal, id: Long) {
if (principal.role == USER)
throw ForbiddenException()
@@ -67,13 +67,13 @@ object SessionService {
SessionRepository.delete(session)
}
fun getCurrent(principal: AuthenticationPrincipal): SessionResponse {
fun getCurrent(principal: Principal): SessionResponse {
return principal.session.toResponse()
}
fun deleteCurrent(principal: AuthenticationPrincipal, sessions: CurrentSession) {
fun deleteCurrent(principal: Principal, sessions: CurrentSession) {
SessionRepository.delete(principal.session)
sessions.clear<AuthenticationSession>()
sessions.clear<Session>()
}
}