feat(server): token-only admin message API with sender/receiver usernames

This commit is contained in:
Jens Reinemann 2026-05-18 16:13:38 +02:00
parent a1a9529b7d
commit 65913fa3b5
6 changed files with 335 additions and 82 deletions

View file

@ -0,0 +1,94 @@
---
name: admin-message-e2ee
description: "Admin-Kommunikation fuer E2EE-Testnachrichten auf dem Bollwerk-Server. Nutze diesen Skill fuer Generaltests mit POST /api/admin/send-message per Bearer-Admin-Token (ohne Admin-Login). Trigger: 'admin message', 'send-message', 'e2ee test', 'generaltest nachrichten', 'bob alice nachrichten als admin'."
---
# Skill: Admin Message E2EE Generaltest
Dieser Skill beschreibt den sicheren Testablauf fuer den Admin-Message-Dienst auf dem Server.
Ziel:
- E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen
- Endpunkt korrekt mit Bearer-Admin-Token verwenden
- Zustellung anschliessend ueber Conversation-API verifizieren
## Technischer Hintergrund
Der Endpunkt ist:
- `POST /api/admin/send-message`
Pflichtbedingungen:
- `Authorization: Bearer <BOLLWERK_ADMIN_TOKEN>`
Wichtig:
- Kein Admin-Login erforderlich.
- Absender wird explizit per `senderUsername` uebergeben (z. B. `bob` oder `alice`).
- Empfaenger kann per `receiverUsername` (z. B. `admin`) adressiert werden.
- Dieser Endpunkt eignet sich fuer Admin-initiierte Testnachrichten im E2EE-Format.
## Vorbedingungen
1. Server erreichbar (z. B. `https://bollwerk.online`)
2. Bearer-Admin-Token (`BOLLWERK_ADMIN_TOKEN`) vorhanden
## API-Schritte
1. Health pruefen
```powershell
Invoke-WebRequest -Uri "https://bollwerk.online/api/health" -UseBasicParsing
```
2. Userliste holen (IDs fuer admin/bob/alice)
```powershell
$users = Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/admin/users" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" }
```
3. E2EE-Admin-Nachricht senden
```powershell
$headers = @{
Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN"
"Content-Type" = "application/json"
}
$payload = @{
senderUsername = "bob"
receiverUsername = "admin"
body = "Testnachricht"
sentAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $payload
```
4. Zustellung verifizieren
```powershell
Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/<OTHER_USER_ID>" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" }
```
## Erwartete Fehlerbilder
- `401 Unauthorized`: Bearer-Token fehlt
- `403 Forbidden`: Bearer-Token stimmt nicht
- `404 Sender not found`: `senderUsername` existiert nicht
- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key
- `404 Receiver not found`: falsche `receiverId`
## Generaltest-Standardfall
Wenn User explizit "1x Bob, mehrere x Alice" wuenscht und Admin-Route genutzt wird:
- Sende 1 Nachricht an Admin-Konversation mit Prefix `[bob] ...`
- Sende 3 Nachrichten an Admin-Konversation mit `senderUsername = "alice"`
- Dokumentiere klar, welcher `senderUsername` je Request verwendet wurde
## Security-Hinweise
- Secrets nie im Chat ausgeben
- Tokens nur maskiert loggen
- Keine Passwoerter fuer diesen Endpunkt erforderlich

Binary file not shown.

View file

@ -6,6 +6,7 @@ import de.bollwerk.server.repository.MessageRepository
import de.bollwerk.server.repository.UserRepository
import de.bollwerk.server.routes.adminRoutes
import de.bollwerk.server.routes.authRoutes
import de.bollwerk.server.routes.adminMessageRoutes
import de.bollwerk.server.routes.inventoryRoutes
import de.bollwerk.server.routes.messageRoutes
import de.bollwerk.server.routes.userRoutes
@ -95,11 +96,16 @@ internal fun Application.configureRouting(
adminRoutes(userRepository, inventoryRepository, backupDir)
}
rateLimit(RATE_LIMIT_MESSAGES) {
messageRoutes(messageRepository, userRepository, wsManager, adminMessageService)
messageRoutes(messageRepository, userRepository, wsManager)
}
userRoutes(userRepository, wsManager)
}
// Token-only endpoint for E2EE general test messages
rateLimit(RATE_LIMIT_ADMIN) {
adminMessageRoutes(messageRepository, userRepository, wsManager, adminMessageService, adminToken)
}
// WebSocket auth via Authorization: Bearer header
webSocketRoutes(wsManager, jwtService, messageRepository)

View file

@ -0,0 +1,116 @@
package de.bollwerk.server.routes
import de.bollwerk.server.model.ErrorResponse
import de.bollwerk.server.repository.MessageRepository
import de.bollwerk.server.repository.UserRepository
import de.bollwerk.server.service.AdminMessageService
import de.bollwerk.server.websocket.WebSocketManager
import de.bollwerk.shared.model.SendMessageResponse
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
internal data class AdminSendMessageRequest(
val id: String? = null,
val senderUsername: String,
val receiverId: String? = null,
val receiverUsername: String? = null,
val body: String,
val sentAt: Long
)
internal fun Route.adminMessageRoutes(
messageRepository: MessageRepository,
userRepository: UserRepository,
wsManager: WebSocketManager,
adminMessageService: AdminMessageService,
adminToken: String
) {
route("/api/admin/send-message") {
post {
if (adminToken.isBlank()) {
return@post call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse(status = 500, message = "Server not configured for admin messages")
)
}
val authHeader = call.request.headers[HttpHeaders.Authorization]
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return@post call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized")
)
}
val token = authHeader.removePrefix("Bearer ").trim()
if (token != adminToken) {
return@post call.respond(
HttpStatusCode.Forbidden,
ErrorResponse(status = 403, message = "Forbidden")
)
}
val request = call.receive<AdminSendMessageRequest>()
if (request.body.isBlank()) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(status = 400, message = "Body must not be empty")
)
}
val sender = userRepository.findByUsername(request.senderUsername)
?: return@post call.respond(
HttpStatusCode.NotFound,
ErrorResponse(status = 404, message = "Sender not found")
)
val receiver = when {
!request.receiverId.isNullOrBlank() -> userRepository.findById(request.receiverId)
!request.receiverUsername.isNullOrBlank() -> userRepository.findByUsername(request.receiverUsername)
else -> null
}
?: return@post call.respond(
HttpStatusCode.NotFound,
ErrorResponse(status = 404, message = "Receiver not found")
)
val receiverId = receiver.id
if (userRepository.findById(receiverId) == null) {
return@post call.respond(
HttpStatusCode.NotFound,
ErrorResponse(status = 404, message = "Receiver not found")
)
}
val msgId = request.id ?: UUID.randomUUID().toString()
val encryptedBody = try {
adminMessageService.encryptMessage(receiverId, request.body)
} catch (e: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(status = 400, message = "Encryption failed: ${e.message}")
)
}
val message = messageRepository.save(
id = msgId,
senderId = sender.id,
senderUsername = sender.username,
receiverId = receiverId,
body = encryptedBody,
sentAt = request.sentAt
)
wsManager.notifyNewMessage(receiverId, message)
if (wsManager.isOnline(receiverId)) {
messageRepository.markDelivered(msgId)
}
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message))
}
}
}

View file

@ -4,7 +4,6 @@ import de.bollwerk.server.model.ErrorResponse
import de.bollwerk.server.repository.MessageRepository
import de.bollwerk.server.repository.UserRepository
import de.bollwerk.server.security.UserPrincipal
import de.bollwerk.server.service.AdminMessageService
import de.bollwerk.server.websocket.WebSocketManager
import de.bollwerk.shared.model.SendMessageResponse
import io.ktor.http.*
@ -28,8 +27,7 @@ internal data class SendMessageRequest(
internal fun Route.messageRoutes(
messageRepository: MessageRepository,
userRepository: UserRepository,
wsManager: WebSocketManager,
adminMessageService: AdminMessageService
wsManager: WebSocketManager
) {
route("/api/messages") {
post {
@ -97,82 +95,4 @@ internal fun Route.messageRoutes(
call.respond(HttpStatusCode.OK, messages)
}
}
route("/api/admin/send-message") {
post {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized")
)
// Only admins can send test messages
if (!principal.isAdmin) {
return@post call.respond(
HttpStatusCode.Forbidden,
ErrorResponse(status = 403, message = "Admin access required")
)
}
// Additional security: verify admin token header
val providedToken = call.request.headers["X-Admin-Token"]
val expectedToken = System.getenv("ADMIN_MESSAGE_TOKEN")
if (expectedToken.isNullOrBlank()) {
return@post call.respond(
HttpStatusCode.InternalServerError,
ErrorResponse(status = 500, message = "Server not configured for admin messages")
)
}
if (providedToken != expectedToken) {
return@post call.respond(
HttpStatusCode.Forbidden,
ErrorResponse(status = 403, message = "Invalid admin token")
)
}
val request = call.receive<SendMessageRequest>()
if (request.body.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(status = 400, message = "Body must not be empty")
)
return@post
}
if (userRepository.findById(request.receiverId) == null) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(status = 404, message = "Receiver not found")
)
return@post
}
val msgId = request.id ?: UUID.randomUUID().toString()
// Encrypt the message with the recipient's public key
val encryptedBody = try {
adminMessageService.encryptMessage(request.receiverId, request.body)
} catch (e: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(
status = 400,
message = "Encryption failed: ${e.message}"
)
)
}
val message = messageRepository.save(
id = msgId,
senderId = principal.userId,
senderUsername = principal.username,
receiverId = request.receiverId,
body = encryptedBody,
sentAt = request.sentAt
)
wsManager.notifyNewMessage(request.receiverId, message)
if (wsManager.isOnline(request.receiverId)) {
messageRepository.markDelivered(msgId)
}
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message))
}
}
}

View file

@ -0,0 +1,117 @@
package de.bollwerk.server
import de.bollwerk.server.db.DatabaseFactory
import de.bollwerk.server.model.ErrorResponse
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Test
class AdminMessageApiTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val adminJwt = createTestAccessToken(
userId = TEST_ADMIN_ID,
username = TEST_ADMIN_USERNAME,
isAdmin = true
)
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:admin_msg_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
private suspend fun ApplicationTestBuilder.createUser(username: String, password: String = "pass123"): String {
client.post("/api/admin/users") {
bearerAuth(adminJwt)
contentType(ContentType.Application.Json)
setBody("""{"username":"$username","password":"$password"}""")
}
val listResponse = client.get("/api/admin/users") { bearerAuth(adminJwt) }
val users = json.decodeFromString<JsonArray>(listResponse.bodyAsText())
return users.first { it.jsonObject["username"]?.jsonPrimitive?.content == username }
.jsonObject["id"]!!.jsonPrimitive.content
}
@Test
fun test_adminSendMessage_noAuth_returns401() = testApp {
val response = client.post("/api/admin/send-message") {
contentType(ContentType.Application.Json)
setBody(
"""{"senderUsername":"bob","receiverId":"any","body":"Hi","sentAt":1700000000000}"""
)
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_adminSendMessage_invalidToken_returns403() = testApp {
val response = client.post("/api/admin/send-message") {
header(HttpHeaders.Authorization, "Bearer wrong-token")
contentType(ContentType.Application.Json)
setBody(
"""{"senderUsername":"bob","receiverId":"any","body":"Hi","sentAt":1700000000000}"""
)
}
assertEquals(HttpStatusCode.Forbidden, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(403, error.status)
}
@Test
fun test_adminSendMessage_senderNotFound_returns404() = testApp {
val receiverId = createUser("receiver")
val response = client.post("/api/admin/send-message") {
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
contentType(ContentType.Application.Json)
setBody(
"""{"senderUsername":"missing-user","receiverId":"$receiverId","body":"Hi","sentAt":1700000000000}"""
)
}
assertEquals(HttpStatusCode.NotFound, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(404, error.status)
}
@Test
fun test_adminSendMessage_receiverNotFound_returns404() = testApp {
createUser("bob")
val response = client.post("/api/admin/send-message") {
header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN")
contentType(ContentType.Application.Json)
setBody(
"""{"senderUsername":"bob","receiverId":"missing-id","body":"Hi","sentAt":1700000000000}"""
)
}
assertEquals(HttpStatusCode.NotFound, response.status)
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(404, error.status)
}
}