Add admin login endpoint

This commit is contained in:
Fabian Wolter
2026-06-14 07:53:29 +00:00
parent ac9523c73c
commit f637ba4b1d
7 changed files with 240 additions and 0 deletions
@@ -1,5 +1,6 @@
package de.ruvnox.tactical package de.ruvnox.tactical
import de.ruvnox.tactical.api.authRoutes
import de.ruvnox.tactical.api.statusRoutes import de.ruvnox.tactical.api.statusRoutes
import de.ruvnox.tactical.api.systemRoutes import de.ruvnox.tactical.api.systemRoutes
import io.ktor.server.application.Application import io.ktor.server.application.Application
@@ -37,5 +38,6 @@ fun Application.module() {
routing { routing {
statusRoutes() statusRoutes()
systemRoutes() systemRoutes()
authRoutes()
} }
} }
@@ -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
)
@@ -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<LoginRequest>() }.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
)
)
)
}
}
@@ -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()")
}
}
@@ -1,5 +1,16 @@
package de.ruvnox.tactical.repository 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 { object UserRepository {
fun countTotal(): Long { fun countTotal(): Long {
return queryLong("select count(*) from app_users") return queryLong("select count(*) from app_users")
@@ -8,4 +19,39 @@ object UserRepository {
fun countActive(): Long { fun countActive(): Long {
return queryLong("select count(*) from app_users where is_active = true") 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")
)
}
}
}
}
}
} }
@@ -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)
}
}
@@ -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) }
}
}