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.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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user