feat: API-ключ — фильтр X-API-Key на /api/**, /actuator открыт
Build Media Mirror API / Build and publish (release) Successful in 34s
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:
@@ -33,6 +33,7 @@ dependencies {
|
||||
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-flyway")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.postgresql: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,
|
||||
@param:DefaultValue
|
||||
val s3: S3,
|
||||
@param:DefaultValue("")
|
||||
val apiKey: String,
|
||||
) {
|
||||
data class Jellyfin(
|
||||
@param:DefaultValue("https://jellyfin.binom.pw/")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
app:
|
||||
api-key: ${MIRROR_API_KEY:}
|
||||
jellyfin:
|
||||
url: https://jellyfin.binom.pw/
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user