diff --git a/build.gradle.kts b/build.gradle.kts index e32ae0b..688f42c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/main/kotlin/pw/binom/mirror/api/config/ApiKeyFilter.kt b/src/main/kotlin/pw/binom/mirror/api/config/ApiKeyFilter.kt new file mode 100644 index 0000000..4d15927 --- /dev/null +++ b/src/main/kotlin/pw/binom/mirror/api/config/ApiKeyFilter.kt @@ -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) + } +} diff --git a/src/main/kotlin/pw/binom/mirror/api/config/AppProperties.kt b/src/main/kotlin/pw/binom/mirror/api/config/AppProperties.kt index dbe8469..36fe98f 100644 --- a/src/main/kotlin/pw/binom/mirror/api/config/AppProperties.kt +++ b/src/main/kotlin/pw/binom/mirror/api/config/AppProperties.kt @@ -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/") diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b95db91..83d0106 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,5 @@ app: + api-key: ${MIRROR_API_KEY:} jellyfin: url: https://jellyfin.binom.pw/ api-key: ${JELLYFIN_API_KEY} diff --git a/src/test/kotlin/pw/binom/mirror/api/ApiKeyFilterTest.kt b/src/test/kotlin/pw/binom/mirror/api/ApiKeyFilterTest.kt new file mode 100644 index 0000000..462c321 --- /dev/null +++ b/src/test/kotlin/pw/binom/mirror/api/ApiKeyFilterTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/pw/binom/mirror/api/DevModeFilterTest.kt b/src/test/kotlin/pw/binom/mirror/api/DevModeFilterTest.kt new file mode 100644 index 0000000..bbb84e4 --- /dev/null +++ b/src/test/kotlin/pw/binom/mirror/api/DevModeFilterTest.kt @@ -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) + } +}