fix(api): empty content when x-api-key is sent alongside session

Closes: #2099
This commit is contained in:
Gauthier Roebroeck 2025-10-08 14:47:10 +08:00
parent bdca990e82
commit 5a5f8d701e
5 changed files with 22 additions and 8 deletions

View file

@ -20,6 +20,8 @@ class Hasher {
return computeHash(path.inputStream()) return computeHash(path.inputStream())
} }
fun computeHash(string: String): String = computeHash(string.byteInputStream())
fun computeHash(stream: InputStream): String { fun computeHash(stream: InputStream): String {
val hash = Algorithm.XXH3_128.Seeded(SEED.toLong()).createDigest() val hash = Algorithm.XXH3_128.Seeded(SEED.toLong()).createDigest()

View file

@ -3,6 +3,7 @@ package org.gotson.komga.infrastructure.security
import jakarta.servlet.Filter import jakarta.servlet.Filter
import org.gotson.komga.domain.model.UserRoles import org.gotson.komga.domain.model.UserRoles
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationFilter import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationFilter
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationProvider import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationProvider
import org.gotson.komga.infrastructure.security.apikey.HeaderApiKeyAuthenticationConverter import org.gotson.komga.infrastructure.security.apikey.HeaderApiKeyAuthenticationConverter
@ -34,6 +35,7 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFi
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher
@Configuration @Configuration
@ -51,6 +53,7 @@ class SecurityConfiguration(
private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint, private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint,
private val authenticationEventPublisher: AuthenticationEventPublisher, private val authenticationEventPublisher: AuthenticationEventPublisher,
private val tokenEncoder: TokenEncoder, private val tokenEncoder: TokenEncoder,
private val hasher: Hasher,
clientRegistrationRepository: InMemoryClientRegistrationRepository?, clientRegistrationRepository: InMemoryClientRegistrationRepository?,
) { ) {
private val oauth2Enabled = clientRegistrationRepository != null private val oauth2Enabled = clientRegistrationRepository != null
@ -158,7 +161,7 @@ class SecurityConfiguration(
) )
} }
http.addFilterBefore(restAuthenticationFilter(), AnonymousAuthenticationFilter::class.java) http.addFilterAfter(restAuthenticationFilter(), BasicAuthenticationFilter::class.java)
return http.build() return http.build()
} }
@ -239,19 +242,19 @@ class SecurityConfiguration(
fun koboAuthenticationFilter(): Filter = fun koboAuthenticationFilter(): Filter =
ApiKeyAuthenticationFilter( ApiKeyAuthenticationFilter(
apiKeyAuthenticationProvider(), apiKeyAuthenticationProvider(),
UriRegexApiKeyAuthenticationConverter(Regex("""/kobo/([\w-]+)"""), tokenEncoder, userAgentWebAuthenticationDetailsSource), UriRegexApiKeyAuthenticationConverter(Regex("""/kobo/([\w-]+)"""), hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource),
) )
fun kosyncAuthenticationFilter(): Filter = fun kosyncAuthenticationFilter(): Filter =
ApiKeyAuthenticationFilter( ApiKeyAuthenticationFilter(
apiKeyAuthenticationProvider(), apiKeyAuthenticationProvider(),
HeaderApiKeyAuthenticationConverter("X-Auth-User", tokenEncoder, userAgentWebAuthenticationDetailsSource), HeaderApiKeyAuthenticationConverter("X-Auth-User", hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource),
) )
fun restAuthenticationFilter(): Filter = fun restAuthenticationFilter(): Filter =
ApiKeyAuthenticationFilter( ApiKeyAuthenticationFilter(
apiKeyAuthenticationProvider(), apiKeyAuthenticationProvider(),
HeaderApiKeyAuthenticationConverter("X-API-Key", tokenEncoder, userAgentWebAuthenticationDetailsSource), HeaderApiKeyAuthenticationConverter("X-API-Key", hasher, tokenEncoder, userAgentWebAuthenticationDetailsSource),
) )
fun apiKeyAuthenticationProvider(): AuthenticationManager = fun apiKeyAuthenticationProvider(): AuthenticationManager =

View file

@ -55,6 +55,8 @@ class ApiKeyAuthenticationFilter(
} catch (ex: AuthenticationException) { } catch (ex: AuthenticationException) {
unsuccessfulAuthentication(request, response, ex) unsuccessfulAuthentication(request, response, ex)
} }
filterChain.doFilter(request, response)
} }
private fun unsuccessfulAuthentication( private fun unsuccessfulAuthentication(
@ -78,7 +80,6 @@ class ApiKeyAuthenticationFilter(
} }
securityContextHolderStrategy.context = context securityContextHolderStrategy.context = context
securityContextRepository.saveContext(context, request, response) securityContextRepository.saveContext(context, request, response)
filterChain.doFilter(request, response)
} }
private fun authenticationIsRequired(username: String): Boolean { private fun authenticationIsRequired(username: String): Boolean {

View file

@ -1,6 +1,7 @@
package org.gotson.komga.infrastructure.security.apikey package org.gotson.komga.infrastructure.security.apikey
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.infrastructure.security.TokenEncoder import org.gotson.komga.infrastructure.security.TokenEncoder
import org.springframework.security.authentication.AuthenticationDetailsSource import org.springframework.security.authentication.AuthenticationDetailsSource
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@ -11,11 +12,13 @@ import org.springframework.security.web.authentication.AuthenticationConverter
* and convert it to an [ApiKeyAuthenticationToken] * and convert it to an [ApiKeyAuthenticationToken]
* *
* @property headerName the header name from which to retrieve the API key * @property headerName the header name from which to retrieve the API key
* @property hasher the hasher to use to encode the API key as username in the [Authentication] object
* @property tokenEncoder the encoder to use to encode the API key in the [Authentication] object * @property tokenEncoder the encoder to use to encode the API key in the [Authentication] object
* @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details * @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details
*/ */
class HeaderApiKeyAuthenticationConverter( class HeaderApiKeyAuthenticationConverter(
private val headerName: String, private val headerName: String,
private val hasher: Hasher,
private val tokenEncoder: TokenEncoder, private val tokenEncoder: TokenEncoder,
private val authenticationDetailsSource: AuthenticationDetailsSource<HttpServletRequest, *>, private val authenticationDetailsSource: AuthenticationDetailsSource<HttpServletRequest, *>,
) : AuthenticationConverter { ) : AuthenticationConverter {
@ -23,7 +26,8 @@ class HeaderApiKeyAuthenticationConverter(
request request
.getHeader(headerName) .getHeader(headerName)
?.let { ?.let {
val (maskedToken, hashedToken) = it.take(6) + "*".repeat(6) to tokenEncoder.encode(it) val maskedToken = hasher.computeHash(it)
val hashedToken = tokenEncoder.encode(it)
ApiKeyAuthenticationToken ApiKeyAuthenticationToken
.unauthenticated(maskedToken, hashedToken) .unauthenticated(maskedToken, hashedToken)
.apply { details = authenticationDetailsSource.buildDetails(request) } .apply { details = authenticationDetailsSource.buildDetails(request) }

View file

@ -1,6 +1,7 @@
package org.gotson.komga.infrastructure.security.apikey package org.gotson.komga.infrastructure.security.apikey
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.infrastructure.security.TokenEncoder import org.gotson.komga.infrastructure.security.TokenEncoder
import org.springframework.security.authentication.AuthenticationDetailsSource import org.springframework.security.authentication.AuthenticationDetailsSource
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@ -11,11 +12,13 @@ import org.springframework.security.web.authentication.AuthenticationConverter
* request URI, and convert it to an [ApiKeyAuthenticationToken] * request URI, and convert it to an [ApiKeyAuthenticationToken]
* *
* @property tokenRegex the regex used to extract the API key * @property tokenRegex the regex used to extract the API key
* @property tokenEncoder the encoder to use to encode the API key in the [Authentication] object * @property hasher the hasher to use to encode the API key as username in the [Authentication] object
* @property tokenEncoder the encoder to use to encode the API key as credentials in the [Authentication] object
* @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details * @property authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details
*/ */
class UriRegexApiKeyAuthenticationConverter( class UriRegexApiKeyAuthenticationConverter(
private val tokenRegex: Regex, private val tokenRegex: Regex,
private val hasher: Hasher,
private val tokenEncoder: TokenEncoder, private val tokenEncoder: TokenEncoder,
private val authenticationDetailsSource: AuthenticationDetailsSource<HttpServletRequest, *>, private val authenticationDetailsSource: AuthenticationDetailsSource<HttpServletRequest, *>,
) : AuthenticationConverter { ) : AuthenticationConverter {
@ -24,7 +27,8 @@ class UriRegexApiKeyAuthenticationConverter(
?.let { ?.let {
tokenRegex.find(it)?.groupValues?.lastOrNull() tokenRegex.find(it)?.groupValues?.lastOrNull()
}?.let { }?.let {
val (maskedToken, hashedToken) = it.take(6) + "*".repeat(6) to tokenEncoder.encode(it) val maskedToken = hasher.computeHash(it)
val hashedToken = tokenEncoder.encode(it)
ApiKeyAuthenticationToken ApiKeyAuthenticationToken
.unauthenticated(maskedToken, hashedToken) .unauthenticated(maskedToken, hashedToken)
.apply { details = authenticationDetailsSource.buildDetails(request) } .apply { details = authenticationDetailsSource.buildDetails(request) }