feat: API-ключ — фильтр X-API-Key на /api/**, /actuator открыт
Build Media Mirror API / Build and publish (release) Successful in 34s

- ключ из конфига (app.api-key), пустой = dev-режим без защиты
- 401 без/с неверным ключом, actuator не закрыт (k8s-пробы)
- добавлен spring-boot-starter-actuator
- тесты: 35 (401/200/actuator/dev-режим)
This commit is contained in:
Hermes Agent
2026-08-10 22:06:33 +03:00
parent ff72389ea6
commit 8d4a87f077
6 changed files with 111 additions and 0 deletions
+1
View File
@@ -33,6 +33,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.postgresql:postgresql") implementation("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-database-postgresql") implementation("org.flywaydb:flyway-database-postgresql")
@@ -0,0 +1,49 @@
package pw.binom.mirror.api.config
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import pw.binom.mirror.api.dto.ErrorResponse
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class ApiKeyFilter(
private val properties: AppProperties,
private val json: Json,
) : OncePerRequestFilter() {
private val log = LoggerFactory.getLogger(ApiKeyFilter::class.java)
init {
if (properties.apiKey.isEmpty()) {
log.warn("app.api-key is empty, API is unprotected (dev mode)")
}
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val apiKey = properties.apiKey
if (apiKey.isEmpty()) {
filterChain.doFilter(request, response)
return
}
if (request.requestURI.startsWith("/api/") && request.getHeader("X-API-Key") != apiKey) {
response.status = HttpStatus.UNAUTHORIZED.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.writer.write(json.encodeToString(ErrorResponse("Invalid or missing X-API-Key")))
return
}
filterChain.doFilter(request, response)
}
}
@@ -9,6 +9,8 @@ data class AppProperties(
val jellyfin: Jellyfin, val jellyfin: Jellyfin,
@param:DefaultValue @param:DefaultValue
val s3: S3, val s3: S3,
@param:DefaultValue("")
val apiKey: String,
) { ) {
data class Jellyfin( data class Jellyfin(
@param:DefaultValue("https://jellyfin.binom.pw/") @param:DefaultValue("https://jellyfin.binom.pw/")
+1
View File
@@ -1,4 +1,5 @@
app: app:
api-key: ${MIRROR_API_KEY:}
jellyfin: jellyfin:
url: https://jellyfin.binom.pw/ url: https://jellyfin.binom.pw/
api-key: ${JELLYFIN_API_KEY} api-key: ${JELLYFIN_API_KEY}
@@ -0,0 +1,39 @@
package pw.binom.mirror.api
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest(properties = ["app.api-key=test-secret"])
class ApiKeyFilterTest : AbstractIntegrationTest() {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `missing X-API-Key returns 401`() {
mockMvc.perform(get("/api/mirror"))
.andExpect(status().isUnauthorized)
}
@Test
fun `invalid X-API-Key returns 401`() {
mockMvc.perform(get("/api/mirror").header("X-API-Key", "wrong-key"))
.andExpect(status().isUnauthorized)
}
@Test
fun `valid X-API-Key is accepted`() {
mockMvc.perform(get("/api/mirror").header("X-API-Key", "test-secret"))
.andExpect(status().isOk)
}
@Test
fun `actuator health is not protected`() {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk)
}
}
@@ -0,0 +1,19 @@
package pw.binom.mirror.api
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
class DevModeFilterTest : AbstractIntegrationTest() {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `empty api key allows requests without X-API-Key`() {
mockMvc.perform(get("/api/mirror"))
.andExpect(status().isOk)
}
}