implement secret regeneration

This commit is contained in:
2025-08-19 23:26:05 +02:00
parent c25704f943
commit 7beeb39616
13 changed files with 146 additions and 111 deletions

View File

@@ -1,9 +1,9 @@
[versions] [versions]
kotlin = "2.2.10" kotlin = "2.2.10"
ktor = "3.1.3" ktor = "3.2.3"
exposed = "0.60.0" exposed = "0.60.0"
postgres = "42.7.5" postgres = "42.7.5"
koin = "4.0.3" koin = "4.1.0"
random = "1.0.0" random = "1.0.0"
otp = "1.1.1" otp = "1.1.1"
kdf = "1.0.3" kdf = "1.0.3"

View File

@@ -1,8 +1,15 @@
package com.infendro.account.config package com.infendro.account.config
import com.infendro.account.model.migrate import com.infendro.account.model.Role
import io.ktor.server.application.Application 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 io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database 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() { fun Application.configureDatabase() {
val config = environment.config.database val config = environment.config.database
@@ -14,3 +21,63 @@ fun Application.configureDatabase() {
) )
migrate() migrate()
} }
fun Application.migrate() {
transaction {
SchemaUtils.create(MigrationTable)
}
val accounts by inject<AccountRepository>()
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()
)
accounts.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

@@ -11,11 +11,11 @@ import com.infendro.account.service.SessionService
import io.ktor.server.application.* import io.ktor.server.application.*
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.ktor.plugin.koin import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger import org.koin.logger.slf4jLogger
fun Application.configureDependencyInjection() { fun Application.configureDependencyInjection() {
koin { install(Koin) {
slf4jLogger() slf4jLogger()
modules(module) modules(module)
} }

View File

@@ -34,10 +34,7 @@ fun Application.configureSecurity() {
val config = environment.config.security val config = environment.config.security
install(Sessions) { install(Sessions) {
cookie<AuthenticationSession>( cookie<AuthenticationSession>("Authentication") {
"Authentication",
SessionStorageMemory(),
) {
cookie.domain = config.cookie.domain cookie.domain = config.cookie.domain
cookie.path = config.cookie.path cookie.path = config.cookie.path
cookie.httpOnly = true cookie.httpOnly = true

View File

@@ -0,0 +1,8 @@
package com.infendro.account.dto.request
import kotlinx.serialization.Serializable
@Serializable
data class PostAccountCurrentSecretRequest(
val password: String,
)

View File

@@ -0,0 +1,8 @@
package com.infendro.account.dto.response
import kotlinx.serialization.Serializable
@Serializable
data class PostAccountCurrentSecretResponse(
val secret: String,
)

View File

@@ -1,77 +1,14 @@
package com.infendro.account.model package com.infendro.account.model
import com.infendro.account.model.entity.MigrationTable import com.infendro.account.model.entity.MigrationTable
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.MigrationRepository import com.infendro.account.model.repository.MigrationRepository
import com.infendro.account.util.SecureHasher
import io.ktor.server.application.* import io.ktor.server.application.*
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.Transaction import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject import org.koin.ktor.ext.inject
fun Application.migrate() { fun Application.migration(
transaction {
SchemaUtils.create(MigrationTable)
}
val accounts by inject<AccountRepository>()
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()
)
accounts.insert {
val salt = SecureHasher.generateSalt()
this.username = "infendro"
this.passwordHash = SecureHasher.hash("password", salt)
this.passwordSalt = salt
this.secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA===="
this.role = Role.OWNER
}
}
}
private fun Application.migration(
name: String, name: String,
block: Transaction.() -> Unit, block: Transaction.() -> Unit,
) { ) {

View File

@@ -1,23 +1,18 @@
package com.infendro.account.routing package com.infendro.account.routing
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.dto.request.PostAccountRequest import com.infendro.account.dto.request.*
import com.infendro.account.dto.request.PutAccountCurrentPasswordRequest
import com.infendro.account.dto.request.PutAccountCurrentUsernameRequest
import com.infendro.account.dto.request.PutAccountIdRoleRequest
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
import io.ktor.resources.Resource import io.ktor.resources.*
import io.ktor.server.auth.authenticate import io.ktor.server.auth.*
import io.ktor.server.auth.principal import io.ktor.server.request.*
import io.ktor.server.request.receive import io.ktor.server.resources.*
import io.ktor.server.resources.delete
import io.ktor.server.resources.get
import io.ktor.server.resources.post import io.ktor.server.resources.post
import io.ktor.server.resources.put import io.ktor.server.resources.put
import io.ktor.server.response.respond import io.ktor.server.response.*
import io.ktor.server.routing.Routing import io.ktor.server.routing.Routing
import io.ktor.server.sessions.sessions import io.ktor.server.sessions.*
import org.koin.ktor.ext.inject import org.koin.ktor.ext.inject
@Resource("/account") @Resource("/account")
@@ -41,6 +36,11 @@ private class Account {
val parent: Current, val parent: Current,
) )
@Resource("/secret")
class Secret(
val parent: Current,
)
@Resource("/session") @Resource("/session")
class Session( class Session(
val parent: Current, val parent: Current,
@@ -118,6 +118,14 @@ fun Routing.account() {
call.respond(OK) call.respond(OK)
} }
post<Account.Current.Secret> {
val principal = call.principal<AuthenticationPrincipal>()!!
val request = call.receive<PostAccountCurrentSecretRequest>()
accountService.postCurrentSecret(principal, call.sessions, request)
.also { call.respond(it) }
}
get<Account.Current.Session.All> { get<Account.Current.Session.All> {
val principal = call.principal<AuthenticationPrincipal>()!! val principal = call.principal<AuthenticationPrincipal>()!!

View File

@@ -2,24 +2,15 @@ package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.config.AuthenticationSession import com.infendro.account.config.AuthenticationSession
import com.infendro.account.dto.request.PostAccountRequest import com.infendro.account.dto.request.*
import com.infendro.account.dto.request.PutAccountCurrentPasswordRequest import com.infendro.account.dto.response.*
import com.infendro.account.dto.request.PutAccountCurrentUsernameRequest
import com.infendro.account.dto.request.PutAccountIdRoleRequest
import com.infendro.account.dto.response.AccountResponse
import com.infendro.account.dto.response.PostAccountResponse
import com.infendro.account.dto.response.SessionResponse
import com.infendro.account.dto.response.toResponse
import com.infendro.account.exception.client.ConflictException import com.infendro.account.exception.client.ConflictException
import com.infendro.account.exception.client.ForbiddenException import com.infendro.account.exception.client.ForbiddenException
import com.infendro.account.exception.client.NotFoundException import com.infendro.account.exception.client.NotFoundException
import com.infendro.account.exception.client.UnauthorizedException import com.infendro.account.exception.client.UnauthorizedException
import com.infendro.account.model.Role.OWNER import com.infendro.account.model.Role.OWNER
import com.infendro.account.model.Role.USER import com.infendro.account.model.Role.USER
import com.infendro.account.model.entity.AccessTable import com.infendro.account.model.entity.*
import com.infendro.account.model.entity.AccountEntity
import com.infendro.account.model.entity.AccountTable
import com.infendro.account.model.entity.SessionTable
import com.infendro.account.model.repository.AccessRepository import com.infendro.account.model.repository.AccessRepository
import com.infendro.account.model.repository.AccountRepository import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.SessionRepository import com.infendro.account.model.repository.SessionRepository
@@ -77,9 +68,7 @@ class AccountService(
fun getCurrent( fun getCurrent(
principal: AuthenticationPrincipal, principal: AuthenticationPrincipal,
): AccountResponse { ): AccountResponse {
val (account, _) = principal return principal.account.toResponse()
return account.toResponse()
} }
fun deleteCurrent( fun deleteCurrent(
@@ -105,7 +94,6 @@ class AccountService(
sessionRepository.delete(principal.account.sessions()) sessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>() sessions.clear<AuthenticationSession>()
} }
fun putCurrentPassword( fun putCurrentPassword(
@@ -127,11 +115,32 @@ class AccountService(
sessions.clear<AuthenticationSession>() sessions.clear<AuthenticationSession>()
} }
fun postCurrentSecret(
principal: AuthenticationPrincipal,
sessions: CurrentSession,
request: PostAccountCurrentSecretRequest,
): PostAccountCurrentSecretResponse {
if (principal.account.passwordHash != SecureHasher.hash(request.password, principal.account.passwordSalt))
throw UnauthorizedException()
val secret = OTP.generateSecret()
accountRepository.update(principal.account) {
this.secret = secret
}
sessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
return PostAccountCurrentSecretResponse(
secret = secret
)
}
fun getCurrentSessionAll( fun getCurrentSessionAll(
principal: AuthenticationPrincipal, principal: AuthenticationPrincipal,
): List<SessionResponse> { ): List<SessionResponse> {
return principal.account.sessions() return principal.account.sessions()
.map { it.toResponse() } .map(SessionEntity::toResponse)
} }
fun deleteCurrentSessionId( fun deleteCurrentSessionId(

View File

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

View File

@@ -10,6 +10,7 @@ import com.infendro.account.exception.client.NotFoundException
import com.infendro.account.exception.client.UnauthorizedException import com.infendro.account.exception.client.UnauthorizedException
import com.infendro.account.model.Role.USER import com.infendro.account.model.Role.USER
import com.infendro.account.model.entity.AccountTable import com.infendro.account.model.entity.AccountTable
import com.infendro.account.model.entity.SessionEntity
import com.infendro.account.model.entity.SessionTable import com.infendro.account.model.entity.SessionTable
import com.infendro.account.model.repository.AccountRepository import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.SessionRepository import com.infendro.account.model.repository.SessionRepository
@@ -17,9 +18,7 @@ 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.SecureHasher
import com.infendro.account.util.TokenGenerator import com.infendro.account.util.TokenGenerator
import io.ktor.server.sessions.CurrentSession import io.ktor.server.sessions.*
import io.ktor.server.sessions.clear
import io.ktor.server.sessions.set
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
@@ -47,7 +46,9 @@ class SessionService(
this.account = account this.account = account
} }
sessions.set(AuthenticationSession(token)) sessions.set(
AuthenticationSession(token)
)
} }
fun getAll( fun getAll(
@@ -59,7 +60,7 @@ class SessionService(
return accountRepository return accountRepository
.all { AccountTable.role inList principal.role.children } .all { AccountTable.role inList principal.role.children }
.flatMap { it.sessions() } .flatMap { it.sessions() }
.map { it.toResponse() } .map(SessionEntity::toResponse)
} }
fun deleteId( fun deleteId(

View File

@@ -8,6 +8,6 @@ object Hasher {
value: String, value: String,
): String { ): String {
val bytes = SHA256.hash(value.toByteArray()) val bytes = SHA256.hash(value.toByteArray())
return Hex.encode(bytes).toString() return Hex.encode(bytes).decodeToString()
} }
} }