feat(server): token-only admin message API with sender/receiver usernames
This commit is contained in:
parent
a1a9529b7d
commit
65913fa3b5
6 changed files with 335 additions and 82 deletions
94
.github/skills/admin-message-e2ee/SKILL.md
vendored
Normal file
94
.github/skills/admin-message-e2ee/SKILL.md
vendored
Normal 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
|
||||
BIN
.github/skills/publish/__pycache__/publish-apk.cpython-313.pyc
vendored
Normal file
BIN
.github/skills/publish/__pycache__/publish-apk.cpython-313.pyc
vendored
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt
Normal file
117
server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue