Add admin login endpoint
This commit is contained in:
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user