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]
kotlin = "2.2.10"
ktor = "3.1.3"
ktor = "3.2.3"
exposed = "0.60.0"
postgres = "42.7.5"
koin = "4.0.3"
koin = "4.1.0"
random = "1.0.0"
otp = "1.1.1"
kdf = "1.0.3"

View File

@@ -1,8 +1,15 @@
package com.infendro.account.config
import com.infendro.account.model.migrate
import io.ktor.server.application.Application
import com.infendro.account.model.Role
import com.infendro.account.model.entity.MigrationTable
import com.infendro.account.model.migration
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.util.SecureHasher
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject
fun Application.configureDatabase() {
val config = environment.config.database
@@ -14,3 +21,63 @@ fun Application.configureDatabase() {
)
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 org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.ktor.plugin.koin
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureDependencyInjection() {
koin {
install(Koin) {
slf4jLogger()
modules(module)
}

View File

@@ -34,10 +34,7 @@ fun Application.configureSecurity() {
val config = environment.config.security
install(Sessions) {
cookie<AuthenticationSession>(
"Authentication",
SessionStorageMemory(),
) {
cookie<AuthenticationSession>("Authentication") {
cookie.domain = config.cookie.domain
cookie.path = config.cookie.path
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
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.util.SecureHasher
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.ktor.ext.inject
fun Application.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
}
}
}
private fun Application.migration(
fun Application.migration(
name: String,
block: Transaction.() -> Unit,
) {

View File

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

View File

@@ -56,4 +56,4 @@ class AccessService(
accessRepository.delete(access)
}
}
}

View File

@@ -2,24 +2,15 @@ package com.infendro.account.service
import com.infendro.account.config.AuthenticationPrincipal
import com.infendro.account.config.AuthenticationSession
import com.infendro.account.dto.request.PostAccountRequest
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.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.dto.request.*
import com.infendro.account.dto.response.*
import com.infendro.account.exception.client.ConflictException
import com.infendro.account.exception.client.ForbiddenException
import com.infendro.account.exception.client.NotFoundException
import com.infendro.account.exception.client.UnauthorizedException
import com.infendro.account.model.Role.OWNER
import com.infendro.account.model.Role.USER
import com.infendro.account.model.entity.AccessTable
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.entity.*
import com.infendro.account.model.repository.AccessRepository
import com.infendro.account.model.repository.AccountRepository
import com.infendro.account.model.repository.SessionRepository
@@ -77,9 +68,7 @@ class AccountService(
fun getCurrent(
principal: AuthenticationPrincipal,
): AccountResponse {
val (account, _) = principal
return account.toResponse()
return principal.account.toResponse()
}
fun deleteCurrent(
@@ -105,7 +94,6 @@ class AccountService(
sessionRepository.delete(principal.account.sessions())
sessions.clear<AuthenticationSession>()
}
fun putCurrentPassword(
@@ -127,11 +115,32 @@ class AccountService(
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(
principal: AuthenticationPrincipal,
): List<SessionResponse> {
return principal.account.sessions()
.map { it.toResponse() }
.map(SessionEntity::toResponse)
}
fun deleteCurrentSessionId(

View File

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

View File

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