From f637ba4b1dfb1cc4dea5f9bca3d91ac79fd0527f Mon Sep 17 00:00:00 2001 From: Fabian Wolter Date: Sun, 14 Jun 2026 07:53:29 +0000 Subject: [PATCH] Add admin login endpoint --- src/main/kotlin/de/ruvnox/tactical/Main.kt | 2 + .../kotlin/de/ruvnox/tactical/api/AuthDtos.kt | 33 +++++++++ .../de/ruvnox/tactical/api/AuthRoutes.kt | 70 +++++++++++++++++++ .../tactical/repository/SessionRepository.kt | 41 +++++++++++ .../tactical/repository/UserRepository.kt | 46 ++++++++++++ .../ruvnox/tactical/security/PasswordHash.kt | 28 ++++++++ .../ruvnox/tactical/security/TokenSupport.kt | 20 ++++++ 7 files changed, 240 insertions(+) create mode 100644 src/main/kotlin/de/ruvnox/tactical/api/AuthDtos.kt create mode 100644 src/main/kotlin/de/ruvnox/tactical/api/AuthRoutes.kt create mode 100644 src/main/kotlin/de/ruvnox/tactical/repository/SessionRepository.kt create mode 100644 src/main/kotlin/de/ruvnox/tactical/security/PasswordHash.kt create mode 100644 src/main/kotlin/de/ruvnox/tactical/security/TokenSupport.kt diff --git a/src/main/kotlin/de/ruvnox/tactical/Main.kt b/src/main/kotlin/de/ruvnox/tactical/Main.kt index aef60dc..206323c 100644 --- a/src/main/kotlin/de/ruvnox/tactical/Main.kt +++ b/src/main/kotlin/de/ruvnox/tactical/Main.kt @@ -1,5 +1,6 @@ package de.ruvnox.tactical +import de.ruvnox.tactical.api.authRoutes import de.ruvnox.tactical.api.statusRoutes import de.ruvnox.tactical.api.systemRoutes import io.ktor.server.application.Application @@ -37,5 +38,6 @@ fun Application.module() { routing { statusRoutes() systemRoutes() + authRoutes() } } diff --git a/src/main/kotlin/de/ruvnox/tactical/api/AuthDtos.kt b/src/main/kotlin/de/ruvnox/tactical/api/AuthDtos.kt new file mode 100644 index 0000000..08f703f --- /dev/null +++ b/src/main/kotlin/de/ruvnox/tactical/api/AuthDtos.kt @@ -0,0 +1,33 @@ +package de.ruvnox.tactical.api + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + val username: String, + val password: String +) + +@Serializable +data class AuthUserResponse( + val id: String, + val username: String, + val displayName: String, + val role: String +) + +@Serializable +data class LoginResponse( + val status: String, + val tokenType: String, + val accessToken: String, + val refreshToken: String, + val expiresInSeconds: Long, + val user: AuthUserResponse +) + +@Serializable +data class ErrorResponse( + val error: String, + val message: String +) diff --git a/src/main/kotlin/de/ruvnox/tactical/api/AuthRoutes.kt b/src/main/kotlin/de/ruvnox/tactical/api/AuthRoutes.kt new file mode 100644 index 0000000..97f5e56 --- /dev/null +++ b/src/main/kotlin/de/ruvnox/tactical/api/AuthRoutes.kt @@ -0,0 +1,70 @@ +package de.ruvnox.tactical.api + +import de.ruvnox.tactical.repository.SessionRepository +import de.ruvnox.tactical.repository.UserRepository +import de.ruvnox.tactical.security.PasswordHash +import de.ruvnox.tactical.security.TokenSupport +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post + +fun Route.authRoutes() { + post("/api/v1/auth/login") { + val request = runCatching { call.receive() }.getOrNull() + + if (request == null || request.username.isBlank() || request.password.isBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse( + error = "invalid_request", + message = "username and password are required" + ) + ) + return@post + } + + val user = UserRepository.findCredentialsByUsername(request.username.trim()) + + if ( + user == null || + !user.isActive || + !PasswordHash.verify(request.password, user.passwordHash) + ) { + call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse( + error = "invalid_credentials", + message = "Invalid username or password" + ) + ) + return@post + } + + val accessToken = TokenSupport.generateToken() + val refreshToken = TokenSupport.generateToken() + val refreshTokenHash = TokenSupport.sha256Hex(refreshToken) + + SessionRepository.createRefreshSession( + userId = user.id, + refreshTokenHash = refreshTokenHash + ) + + call.respond( + LoginResponse( + status = "ok", + tokenType = "Bearer", + accessToken = accessToken, + refreshToken = refreshToken, + expiresInSeconds = 3600, + user = AuthUserResponse( + id = user.id, + username = user.username, + displayName = user.displayName, + role = user.role + ) + ) + ) + } +} diff --git a/src/main/kotlin/de/ruvnox/tactical/repository/SessionRepository.kt b/src/main/kotlin/de/ruvnox/tactical/repository/SessionRepository.kt new file mode 100644 index 0000000..be2185c --- /dev/null +++ b/src/main/kotlin/de/ruvnox/tactical/repository/SessionRepository.kt @@ -0,0 +1,41 @@ +package de.ruvnox.tactical.repository + +import de.ruvnox.tactical.Database +import java.time.OffsetDateTime +import java.time.ZoneOffset + +object SessionRepository { + fun createRefreshSession( + userId: String, + refreshTokenHash: String, + expiresInDays: Long = 30 + ): OffsetDateTime { + val expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(expiresInDays) + + val sql = """ + insert into auth_sessions ( + user_id, + refresh_token_hash, + expires_at, + created_at, + last_seen_at + ) + values (?::uuid, ?, ?, now(), now()) + """.trimIndent() + + Database.connection().use { connection -> + connection.prepareStatement(sql).use { statement -> + statement.setString(1, userId) + statement.setString(2, refreshTokenHash) + statement.setObject(3, expiresAt) + statement.executeUpdate() + } + } + + return expiresAt + } + + fun countActive(): Long { + return queryLong("select count(*) from auth_sessions where revoked_at is null and expires_at > now()") + } +} diff --git a/src/main/kotlin/de/ruvnox/tactical/repository/UserRepository.kt b/src/main/kotlin/de/ruvnox/tactical/repository/UserRepository.kt index c529f8d..d9912b7 100644 --- a/src/main/kotlin/de/ruvnox/tactical/repository/UserRepository.kt +++ b/src/main/kotlin/de/ruvnox/tactical/repository/UserRepository.kt @@ -1,5 +1,16 @@ package de.ruvnox.tactical.repository +import de.ruvnox.tactical.Database + +data class UserCredentials( + val id: String, + val username: String, + val displayName: String, + val role: String, + val passwordHash: String?, + val isActive: Boolean +) + object UserRepository { fun countTotal(): Long { return queryLong("select count(*) from app_users") @@ -8,4 +19,39 @@ object UserRepository { fun countActive(): Long { return queryLong("select count(*) from app_users where is_active = true") } + + fun findCredentialsByUsername(username: String): UserCredentials? { + val sql = """ + select + id::text, + username, + display_name, + role, + password_hash, + is_active + from app_users + where username = ? + limit 1 + """.trimIndent() + + return Database.connection().use { connection -> + connection.prepareStatement(sql).use { statement -> + statement.setString(1, username) + statement.executeQuery().use { result -> + if (!result.next()) { + null + } else { + UserCredentials( + id = result.getString("id"), + username = result.getString("username"), + displayName = result.getString("display_name"), + role = result.getString("role"), + passwordHash = result.getString("password_hash"), + isActive = result.getBoolean("is_active") + ) + } + } + } + } + } } diff --git a/src/main/kotlin/de/ruvnox/tactical/security/PasswordHash.kt b/src/main/kotlin/de/ruvnox/tactical/security/PasswordHash.kt new file mode 100644 index 0000000..b746ec8 --- /dev/null +++ b/src/main/kotlin/de/ruvnox/tactical/security/PasswordHash.kt @@ -0,0 +1,28 @@ +package de.ruvnox.tactical.security + +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + +object PasswordHash { + fun verify(password: String, storedHash: String?): Boolean { + if (storedHash.isNullOrBlank()) return false + + val parts = storedHash.split("$") + if (parts.size != 4) return false + if (parts[0] != "pbkdf2_sha256") return false + + val iterations = parts[1].toIntOrNull() ?: return false + val salt = runCatching { Base64.getDecoder().decode(parts[2]) }.getOrNull() ?: return false + val expected = runCatching { Base64.getDecoder().decode(parts[3]) }.getOrNull() ?: return false + + val spec = PBEKeySpec(password.toCharArray(), salt, iterations, expected.size * 8) + val actual = SecretKeyFactory + .getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec) + .encoded + + return MessageDigest.isEqual(expected, actual) + } +} diff --git a/src/main/kotlin/de/ruvnox/tactical/security/TokenSupport.kt b/src/main/kotlin/de/ruvnox/tactical/security/TokenSupport.kt new file mode 100644 index 0000000..7fa623f --- /dev/null +++ b/src/main/kotlin/de/ruvnox/tactical/security/TokenSupport.kt @@ -0,0 +1,20 @@ +package de.ruvnox.tactical.security + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +object TokenSupport { + private val secureRandom = SecureRandom() + + fun generateToken(byteLength: Int = 32): String { + val bytes = ByteArray(byteLength) + secureRandom.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } +}