2 Commits
3 ... 5

Author SHA1 Message Date
Hermes Agent c100b52720 helm: секреты API_KEY (X-API-Key) и JELLYFIN_API_KEY (сканер)
Build Media Mirror API / Build and publish (release) Successful in 29s
2026-08-10 22:28:20 +03:00
Hermes Agent 8d4a87f077 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-режим)
2026-08-10 22:06:33 +03:00
10 changed files with 127 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")
+2
View File
@@ -9,8 +9,10 @@ data:
forward-headers-strategy: framework forward-headers-strategy: framework
app: app:
api-key: ${API_KEY}
jellyfin: jellyfin:
url: {{ $.Values.app.jellyfin.url | quote }} url: {{ $.Values.app.jellyfin.url | quote }}
api-key: ${JELLYFIN_API_KEY}
s3: s3:
url: {{ $.Values.app.s3.url | quote }} url: {{ $.Values.app.s3.url | quote }}
access-key: ${S3_ACCESS_KEY} access-key: ${S3_ACCESS_KEY}
+10
View File
@@ -69,6 +69,16 @@ spec:
key: dbPassword key: dbPassword
- name: DB_URL - name: DB_URL
value: "jdbc:postgresql://{{ $.Values.db.host }}:{{ $.Values.db.port }}/{{ $.Values.db.name }}" value: "jdbc:postgresql://{{ $.Values.db.host }}:{{ $.Values.db.port }}/{{ $.Values.db.name }}"
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: apiKey
- name: JELLYFIN_API_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: jellyfinApiKey
- name: S3_ACCESS_KEY - name: S3_ACCESS_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+2
View File
@@ -8,3 +8,5 @@ data:
dbPassword: {{ (required "DB password не установлен" $.Values.db.password) | b64enc | quote }} dbPassword: {{ (required "DB password не установлен" $.Values.db.password) | b64enc | quote }}
accessKey: {{ (required "Accesskey не установлен" $.Values.app.s3.accessKey) | b64enc | quote }} accessKey: {{ (required "Accesskey не установлен" $.Values.app.s3.accessKey) | b64enc | quote }}
secretKey: {{ (required "Secretkey не установлен" $.Values.app.s3.secretKey) | b64enc | quote }} secretKey: {{ (required "Secretkey не установлен" $.Values.app.s3.secretKey) | b64enc | quote }}
apiKey: {{ (required "ApiKey не установлен" $.Values.app.apiKey) | b64enc | quote }}
jellyfinApiKey: {{ (required "Jellyfin ApiKey не установлен" $.Values.app.jellyfin.apiKey) | b64enc | quote }}
+2
View File
@@ -23,8 +23,10 @@ db:
maxConnections: 10 maxConnections: 10
app: app:
apiKey: null
jellyfin: jellyfin:
url: null url: null
apiKey: null
s3: s3:
url: null url: null
accessKey: null accessKey: null
@@ -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)
}
}