feat(kobo): add API key support

This commit is contained in:
Gauthier Roebroeck 2024-08-27 18:00:04 +08:00
parent f3bce238c1
commit a4747e81f4
25 changed files with 725 additions and 19 deletions

View File

@ -37,3 +37,4 @@
| ERR_1031 | ComicRack CBL Book is missing series or number |
| ERR_1032 | EPUB file has wrong media type |
| ERR_1033 | Some entries are missing |
| ERR_1034 | An API key with that comment already exists |

View File

@ -0,0 +1,18 @@
CREATE TABLE USER_API_KEY
(
ID varchar NOT NULL PRIMARY KEY,
USER_ID varchar NOT NULL,
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
API_KEY varchar NOT NULL UNIQUE,
COMMENT varchar NOT NULL,
FOREIGN KEY (USER_ID) REFERENCES USER (ID)
);
create index if not exists idx__user_api_key__user_id
on USER_API_KEY (USER_ID);
ALTER TABLE AUTHENTICATION_ACTIVITY
ADD COLUMN API_KEY_ID varchar NULL DEFAULT NULL;
ALTER TABLE AUTHENTICATION_ACTIVITY
ADD COLUMN API_KEY_COMMENT varchar NULL DEFAULT NULL;

View File

@ -0,0 +1,13 @@
package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.time.LocalDateTime
data class ApiKey(
val id: String = TsidCreator.getTsid256().toString(),
val userId: String,
val key: String,
val comment: String,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = createdDate,
) : Auditable

View File

@ -5,6 +5,8 @@ import java.time.LocalDateTime
data class AuthenticationActivity(
val userId: String? = null,
val email: String? = null,
val apiKeyId: String? = null,
val apiKeyComment: String? = null,
val ip: String? = null,
val userAgent: String? = null,
val success: Boolean,

View File

@ -14,7 +14,10 @@ interface AuthenticationActivityRepository {
pageable: Pageable,
): Page<AuthenticationActivity>
fun findMostRecentByUser(user: KomgaUser): AuthenticationActivity?
fun findMostRecentByUser(
user: KomgaUser,
apiKeyId: String?,
): AuthenticationActivity?
fun insert(activity: AuthenticationActivity)

View File

@ -1,5 +1,6 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.domain.model.KomgaUser
interface KomgaUserRepository {
@ -9,18 +10,41 @@ interface KomgaUserRepository {
fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser?
fun findByApiKeyOrNull(apiKey: String): Pair<KomgaUser, ApiKey>?
fun findAll(): Collection<KomgaUser>
fun findApiKeyByUserId(userId: String): Collection<ApiKey>
fun existsByEmailIgnoreCase(email: String): Boolean
fun existsApiKeyByIdAndUserId(
apiKeyId: String,
userId: String,
): Boolean
fun existsApiKeyByCommentAndUserId(
comment: String,
userId: String,
): Boolean
fun insert(user: KomgaUser)
fun insert(apiKey: ApiKey)
fun update(user: KomgaUser)
fun delete(userId: String)
fun deleteAll()
fun deleteApiKeyByIdAndUserId(
apiKeyId: String,
userId: String,
)
fun deleteApiKeyByUserId(userId: String)
fun findAnnouncementIdsReadByUserId(userId: String): Set<String>
fun saveAnnouncementIdsRead(

View File

@ -48,10 +48,14 @@ class AuthenticationActivityDao(
return findAll(conditions, pageable)
}
override fun findMostRecentByUser(user: KomgaUser): AuthenticationActivity? =
override fun findMostRecentByUser(
user: KomgaUser,
apiKeyId: String?,
): AuthenticationActivity? =
dsl.selectFrom(aa)
.where(aa.USER_ID.eq(user.id))
.or(aa.EMAIL.eq(user.email))
.apply { apiKeyId?.let { and(aa.API_KEY_ID.eq(it)) } }
.orderBy(aa.DATE_TIME.desc())
.limit(1)
.fetchOne()
@ -85,8 +89,8 @@ class AuthenticationActivityDao(
}
override fun insert(activity: AuthenticationActivity) {
dsl.insertInto(aa, aa.USER_ID, aa.EMAIL, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR, aa.SOURCE)
.values(activity.userId, activity.email, activity.ip, activity.userAgent, activity.success, activity.error, activity.source)
dsl.insertInto(aa, aa.USER_ID, aa.EMAIL, aa.API_KEY_ID, aa.API_KEY_COMMENT, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR, aa.SOURCE)
.values(activity.userId, activity.email, activity.apiKeyId, activity.apiKeyComment, activity.ip, activity.userAgent, activity.success, activity.error, activity.source)
.execute()
}
@ -107,6 +111,8 @@ class AuthenticationActivityDao(
AuthenticationActivity(
userId = userId,
email = email,
apiKeyId = apiKeyId,
apiKeyComment = apiKeyComment,
ip = ip,
userAgent = userAgent,
success = success,

View File

@ -2,11 +2,13 @@ package org.gotson.komga.infrastructure.jooq.main
import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.AllowExclude
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.AnnouncementsReadRecord
import org.gotson.komga.jooq.main.tables.records.UserApiKeyRecord
import org.gotson.komga.language.toCurrentTimeZone
import org.jooq.DSLContext
import org.jooq.Record
@ -24,6 +26,7 @@ class KomgaUserDao(
private val ul = Tables.USER_LIBRARY_SHARING
private val us = Tables.USER_SHARING
private val ar = Tables.ANNOUNCEMENTS_READ
private val uak = Tables.USER_API_KEY
override fun count(): Long = dsl.fetchCount(u).toLong()
@ -31,6 +34,14 @@ class KomgaUserDao(
selectBase()
.fetchAndMap()
override fun findApiKeyByUserId(userId: String): Collection<ApiKey> =
dsl.selectFrom(uak)
.where(uak.USER_ID.eq(userId))
.fetchInto(uak)
.map {
it.toDomain()
}
override fun findByIdOrNull(id: String): KomgaUser? =
selectBase()
.where(u.ID.equal(id))
@ -57,6 +68,7 @@ class KomgaUserDao(
roleAdmin = ur.roleAdmin,
roleFileDownload = ur.roleFileDownload,
rolePageStreaming = ur.rolePageStreaming,
roleKoboSync = ur.roleKoboSync,
sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(),
sharedAllLibraries = ur.sharedAllLibraries,
restrictions =
@ -84,6 +96,7 @@ class KomgaUserDao(
.set(u.ROLE_ADMIN, user.roleAdmin)
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
.set(
@ -100,6 +113,15 @@ class KomgaUserDao(
insertSharingRestrictions(user)
}
override fun insert(apiKey: ApiKey) {
dsl.insertInto(uak)
.set(uak.ID, apiKey.id)
.set(uak.USER_ID, apiKey.userId)
.set(uak.API_KEY, apiKey.key)
.set(uak.COMMENT, apiKey.comment)
.execute()
}
@Transactional
override fun update(user: KomgaUser) {
dsl.update(u)
@ -108,6 +130,7 @@ class KomgaUserDao(
.set(u.ROLE_ADMIN, user.roleAdmin)
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
.set(
@ -168,6 +191,7 @@ class KomgaUserDao(
@Transactional
override fun delete(userId: String) {
dsl.deleteFrom(uak).where(uak.USER_ID.equal(userId)).execute()
dsl.deleteFrom(ar).where(ar.USER_ID.equal(userId)).execute()
dsl.deleteFrom(us).where(us.USER_ID.equal(userId)).execute()
dsl.deleteFrom(ul).where(ul.USER_ID.equal(userId)).execute()
@ -176,12 +200,27 @@ class KomgaUserDao(
@Transactional
override fun deleteAll() {
dsl.deleteFrom(uak).execute()
dsl.deleteFrom(ar).execute()
dsl.deleteFrom(us).execute()
dsl.deleteFrom(ul).execute()
dsl.deleteFrom(u).execute()
}
override fun deleteApiKeyByIdAndUserId(
apiKeyId: String,
userId: String,
) {
dsl.deleteFrom(uak)
.where(uak.ID.eq(apiKeyId))
.and(uak.USER_ID.eq(userId))
.execute()
}
override fun deleteApiKeyByUserId(userId: String) {
dsl.deleteFrom(uak).where(uak.USER_ID.eq(userId)).execute()
}
override fun findAnnouncementIdsReadByUserId(userId: String): Set<String> =
dsl.select(ar.ANNOUNCEMENT_ID)
.from(ar)
@ -194,9 +233,49 @@ class KomgaUserDao(
.where(u.EMAIL.equalIgnoreCase(email)),
)
override fun existsApiKeyByIdAndUserId(
apiKeyId: String,
userId: String,
): Boolean =
dsl.fetchExists(uak, uak.ID.eq(apiKeyId).and(uak.USER_ID.eq(userId)))
override fun existsApiKeyByCommentAndUserId(
comment: String,
userId: String,
): Boolean =
dsl.fetchExists(uak, uak.COMMENT.equalIgnoreCase(comment).and(uak.USER_ID.eq(userId)))
override fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? =
selectBase()
.where(u.EMAIL.equalIgnoreCase(email))
.fetchAndMap()
.firstOrNull()
override fun findByApiKeyOrNull(apiKey: String): Pair<KomgaUser, ApiKey>? {
val user =
selectBase()
.leftJoin(uak).on(u.ID.eq(uak.USER_ID))
.where(uak.API_KEY.eq(apiKey))
.fetchAndMap()
.firstOrNull() ?: return null
val key =
dsl.selectFrom(uak)
.where(uak.API_KEY.eq(apiKey))
.fetchInto(uak)
.map { it.toDomain() }
.firstOrNull() ?: return null
return Pair(user, key)
}
private fun UserApiKeyRecord.toDomain() =
ApiKey(
id = id,
userId = userId,
key = apiKey,
comment = comment,
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
)
}

View File

@ -1,5 +1,6 @@
package org.gotson.komga.infrastructure.security
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.domain.model.KomgaUser
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
@ -13,6 +14,8 @@ class KomgaPrincipal(
val user: KomgaUser,
val oAuth2User: OAuth2User? = null,
val oidcUser: OidcUser? = null,
val apiKey: ApiKey? = null,
private val name: String = user.email,
) : UserDetails, OAuth2User, OidcUser {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
user.roles
@ -21,7 +24,7 @@ class KomgaPrincipal(
override fun isEnabled() = true
override fun getUsername() = user.email
override fun getUsername() = name
override fun isCredentialsNonExpired() = true
@ -31,7 +34,7 @@ class KomgaPrincipal(
override fun isAccountNonLocked() = true
override fun getName() = user.email
override fun getName() = name
override fun getAttributes(): MutableMap<String, Any> = oAuth2User?.attributes ?: mutableMapOf()

View File

@ -4,11 +4,13 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gotson.komga.domain.model.AuthenticationActivity
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken
import org.springframework.context.event.EventListener
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.authentication.RememberMeAuthenticationToken
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent
import org.springframework.security.authentication.event.AuthenticationFailureProviderNotFoundEvent
import org.springframework.security.authentication.event.AuthenticationSuccessEvent
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken
import org.springframework.security.web.authentication.WebAuthenticationDetails
@ -24,10 +26,13 @@ class LoginListener(
) {
@EventListener
fun onSuccess(event: AuthenticationSuccessEvent) {
val user = (event.authentication.principal as KomgaPrincipal).user
val komgaPrincipal = event.authentication.principal as KomgaPrincipal
val user = komgaPrincipal.user
val apiKey = komgaPrincipal.apiKey
val source =
when (event.source) {
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
is ApiKeyAuthenticationToken -> "ApiKey"
is UsernamePasswordAuthenticationToken -> "Password"
is RememberMeAuthenticationToken -> "RememberMe"
else -> null
@ -36,6 +41,8 @@ class LoginListener(
AuthenticationActivity(
userId = user.id,
email = user.email,
apiKeyId = apiKey?.id,
apiKeyComment = apiKey?.comment,
ip = event.getIp(),
userAgent = event.getUserAgent(),
success = true,
@ -48,18 +55,22 @@ class LoginListener(
@EventListener
fun onFailure(event: AbstractAuthenticationFailureEvent) {
val user = event.authentication?.principal?.toString().orEmpty()
// somehow we get 2 events with bad credentials, so discard this one
if (event is AuthenticationFailureProviderNotFoundEvent) return
val source =
when (event.source) {
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
is ApiKeyAuthenticationToken -> "ApiKey"
is UsernamePasswordAuthenticationToken -> "Password"
is RememberMeAuthenticationToken -> "RememberMe"
else -> null
}
val principal = event.authentication?.principal?.toString().orEmpty()
val activity =
AuthenticationActivity(
userId = userRepository.findByEmailIgnoreCaseOrNull(user)?.id,
email = user,
userId = userRepository.findByEmailIgnoreCaseOrNull(principal)?.id,
email = if (event.source !is ApiKeyAuthenticationToken) principal else null,
apiKeyComment = if (event.source is ApiKeyAuthenticationToken) principal else null,
ip = event.getIp(),
userAgent = event.getUserAgent(),
success = false,

View File

@ -2,11 +2,15 @@ package org.gotson.komga.infrastructure.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.core.token.Sha512DigestUtils
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class PasswordEncoderConfiguration {
@Bean
fun getEncoder(): PasswordEncoder = BCryptPasswordEncoder()
fun getPasswordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun getTokenEncoder(): TokenEncoder = TokenEncoder { rawPassword -> Sha512DigestUtils.shaHex(rawPassword) }
}

View File

@ -1,20 +1,30 @@
package org.gotson.komga.infrastructure.security
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.Filter
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_USER
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationFilter
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationProvider
import org.gotson.komga.infrastructure.security.apikey.UriRegexApiKeyAuthenticationConverter
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest
import org.springframework.boot.actuate.health.HealthEndpoint
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.authentication.AuthenticationEventPublisher
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.authentication.ProviderManager
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
@ -23,6 +33,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
@ -34,19 +45,22 @@ private val logger = KotlinLogging.logger {}
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfiguration(
private val komgaProperties: KomgaProperties,
private val komgaSettingsProvider: KomgaSettingsProvider,
private val komgaUserDetailsLifecycle: UserDetailsService,
private val komgaUserDetailsService: UserDetailsService,
private val apiKeyAuthenticationProvider: ApiKeyAuthenticationProvider,
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
private val sessionCookieName: String,
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
private val sessionRegistry: SessionRegistry,
private val theSessionRegistry: SessionRegistry,
private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint,
private val authenticationEventPublisher: AuthenticationEventPublisher,
private val tokenEncoder: TokenEncoder,
clientRegistrationRepository: InMemoryClientRegistrationRepository?,
) {
private val oauth2Enabled = clientRegistrationRepository != null
@Order(1)
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
@ -92,6 +106,7 @@ class SecurityConfiguration(
headersConfigurer.cacheControl { it.disable() } // headers are set in WebMvcConfiguration
headersConfigurer.frameOptions { it.sameOrigin() } // for epubreader iframes
}
.userDetailsService(komgaUserDetailsService)
.httpBasic {
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
}
@ -103,7 +118,7 @@ class SecurityConfiguration(
.sessionManagement { session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
session.sessionConcurrency {
it.sessionRegistry(sessionRegistry)
it.sessionRegistry(theSessionRegistry)
it.maximumSessions(-1)
}
}
@ -129,13 +144,15 @@ class SecurityConfiguration(
val url = "/login?server_redirect=Y&error=$errorMessage"
SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception)
}
oauth2.redirectionEndpoint {
}
}
}
http
.rememberMe {
it.rememberMeServices(
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsLifecycle).apply {
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsService).apply {
setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt())
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
},
@ -144,4 +161,54 @@ class SecurityConfiguration(
return http.build()
}
@Bean
fun koboFilterChain(
http: HttpSecurity,
encoder: PasswordEncoder,
): SecurityFilterChain {
http {
cors {}
csrf { disable() }
formLogin { disable() }
httpBasic { disable() }
logout { disable() }
securityMatcher("/kobo/**")
authorizeHttpRequests {
authorize(anyRequest, hasRole(ROLE_KOBO_SYNC))
}
headers {
cacheControl { disable() }
}
// somehow the Kobo gets a Json issue when receiving the session ID in a cookie header
// this happens when requesting /v1/user/profile
// Kobo error: packetdump.warning) Invalid JSON script: QVariant(Invalid) "illegal value"
// sessionManagement {
// sessionCreationPolicy = SessionCreationPolicy.IF_REQUIRED
// sessionConcurrency {
// sessionRegistry = theSessionRegistry
// maximumSessions = -1
// }
// }
addFilterBefore<AnonymousAuthenticationFilter>(koboAuthenticationFilter())
}
return http.build()
}
fun koboAuthenticationFilter(): Filter =
ApiKeyAuthenticationFilter(
apiKeyAuthenticationProvider(),
UriRegexApiKeyAuthenticationConverter(Regex("""\/kobo\/([\w-]+)"""), tokenEncoder, userAgentWebAuthenticationDetailsSource),
)
fun apiKeyAuthenticationProvider(): AuthenticationManager =
ProviderManager(apiKeyAuthenticationProvider).apply {
setAuthenticationEventPublisher(authenticationEventPublisher)
}
}

View File

@ -0,0 +1,10 @@
package org.gotson.komga.infrastructure.security
/**
* Service interface for encoding tokens.
* Contrary to password encoding, token encoding is deterministic, so that lookups can be done using
* only the token, without a username.
*/
fun interface TokenEncoder {
fun encode(rawPassword: String): String
}

View File

@ -0,0 +1,97 @@
package org.gotson.komga.infrastructure.security.apikey
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.core.Authentication
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.context.SecurityContextHolderStrategy
import org.springframework.security.web.authentication.AuthenticationConverter
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler
import org.springframework.security.web.authentication.AuthenticationFailureHandler
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
import org.springframework.security.web.context.SecurityContextRepository
import org.springframework.web.filter.OncePerRequestFilter
class ApiKeyAuthenticationFilter(
private val authenticationManager: AuthenticationManager,
private val authenticationConverter: AuthenticationConverter,
) : OncePerRequestFilter() {
private val securityContextHolderStrategy: SecurityContextHolderStrategy =
SecurityContextHolder
.getContextHolderStrategy()
private val securityContextRepository: SecurityContextRepository = RequestAttributeSecurityContextRepository()
private val failureHandler: AuthenticationFailureHandler =
AuthenticationEntryPointFailureHandler(
HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
try {
val authRequest = authenticationConverter.convert(request)
if (authRequest == null) {
filterChain.doFilter(request, response)
return
}
if (authenticationIsRequired(authRequest.name)) {
val authResult = authenticationManager.authenticate(authRequest)
if (authResult == null) {
filterChain.doFilter(request, response)
return
}
successfulAuthentication(request, response, filterChain, authResult)
}
} catch (ex: AuthenticationException) {
unsuccessfulAuthentication(request, response, ex)
}
}
private fun unsuccessfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
failed: AuthenticationException,
) {
securityContextHolderStrategy.clearContext()
failureHandler.onAuthenticationFailure(request, response, failed)
}
private fun successfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
authentication: Authentication,
) {
val context =
securityContextHolderStrategy.createEmptyContext().apply {
this.authentication = authentication
}
securityContextHolderStrategy.context = context
securityContextRepository.saveContext(context, request, response)
filterChain.doFilter(request, response)
}
private fun authenticationIsRequired(username: String): Boolean {
// Only reauthenticate if username doesn't match SecurityContextHolder and user isn't authenticated
val existingAuth = this.securityContextHolderStrategy.context.authentication
if (existingAuth == null || existingAuth.name != username || !existingAuth.isAuthenticated) {
return true
}
// Handle unusual condition where an AnonymousAuthenticationToken is already
// present. This shouldn't happen very often, as ApiKeyAuthenticationFilter is
// meant to be earlier in the filter chain than AnonymousAuthenticationFilter.
// Also check that the existing token is of type ApiKeyAuthenticationToken.
// This would prevent reusing a session obtained from Basic Auth for example.
return (existingAuth is AnonymousAuthenticationToken || existingAuth !is ApiKeyAuthenticationToken)
}
}

View File

@ -0,0 +1,43 @@
package org.gotson.komga.infrastructure.security.apikey
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component
/**
* A provider to lookup API keys in the repository.
*/
@Component
class ApiKeyAuthenticationProvider(
private val userRepository: KomgaUserRepository,
) : AbstractUserDetailsAuthenticationProvider() {
override fun additionalAuthenticationChecks(
userDetails: UserDetails?,
authentication: UsernamePasswordAuthenticationToken?,
) = Unit
override fun retrieveUser(
username: String,
authentication: UsernamePasswordAuthenticationToken,
): UserDetails =
userRepository.findByApiKeyOrNull(authentication.credentials.toString())?.let { (user, apiKey) ->
KomgaPrincipal(user, apiKey = apiKey, name = authentication.name)
} ?: throw BadCredentialsException("Bad credentials")
override fun createSuccessAuthentication(
principal: Any?,
authentication: Authentication?,
user: UserDetails?,
): Authentication =
ApiKeyAuthenticationToken.authenticated(principal, authentication?.credentials, user!!.authorities)
.apply { details = authentication?.details }
.also { logger.debug("Authenticated user") }
override fun supports(authentication: Class<*>): Boolean =
ApiKeyAuthenticationToken::class.java.isAssignableFrom(authentication)
}

View File

@ -0,0 +1,26 @@
package org.gotson.komga.infrastructure.security.apikey
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
/**
* A specialization of [UsernamePasswordAuthenticationToken] to store API keys.
*/
class ApiKeyAuthenticationToken private constructor(principal: Any?, credentials: Any?, authorities: Collection<GrantedAuthority>?) : UsernamePasswordAuthenticationToken(principal, credentials, authorities) {
private constructor(principal: Any?, credentials: Any?) : this(principal, credentials, null) {
isAuthenticated = false
}
companion object {
fun authenticated(
principal: Any?,
credentials: Any?,
authorities: Collection<GrantedAuthority>?,
) = ApiKeyAuthenticationToken(principal, credentials, authorities)
fun unauthenticated(
principal: Any?,
credentials: Any?,
) = ApiKeyAuthenticationToken(principal, credentials)
}
}

View File

@ -0,0 +1,14 @@
package org.gotson.komga.infrastructure.security.apikey
import org.springframework.stereotype.Component
import java.util.UUID
/**
* API key generator.
* Uses a random UUID v4 without dashes
*/
@Component
class ApiKeyGenerator {
fun generate() =
UUID.randomUUID().toString().replace("-", "")
}

View File

@ -0,0 +1,30 @@
package org.gotson.komga.infrastructure.security.apikey
import jakarta.servlet.http.HttpServletRequest
import org.gotson.komga.infrastructure.security.TokenEncoder
import org.springframework.security.authentication.AuthenticationDetailsSource
import org.springframework.security.core.Authentication
import org.springframework.security.web.authentication.AuthenticationConverter
/**
* A strategy that uses a regex to retrieve the API key from the
* request URI, and convert it to an [ApiKeyAuthenticationToken]
*
* @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 authenticationDetailsSource the [AuthenticationDetailsSource] to enrich the [Authentication] details
*/
class UriRegexApiKeyAuthenticationConverter(
private val tokenRegex: Regex,
private val tokenEncoder: TokenEncoder,
private val authenticationDetailsSource: AuthenticationDetailsSource<HttpServletRequest, *>,
) : AuthenticationConverter {
override fun convert(request: HttpServletRequest): Authentication? =
request.requestURI?.let {
tokenRegex.find(it)?.groupValues?.lastOrNull()
}?.let {
val (maskedToken, hashedToken) = it.take(6) + "*".repeat(6) to tokenEncoder.encode(it)
ApiKeyAuthenticationToken.unauthenticated(maskedToken, hashedToken)
.apply { details = authenticationDetailsSource.buildDetails(request) }
}
}

View File

@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid
import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
@ -15,11 +17,14 @@ import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyDto
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyRequestDto
import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto
import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto
import org.gotson.komga.interfaces.api.rest.dto.UserDto
import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.redacted
import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.springdoc.core.converters.models.PageableAsQueryParam
import org.springframework.core.env.Environment
@ -121,6 +126,7 @@ class UserController(
roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin,
roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload,
rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming,
roleKoboSync = if (isSet("roles")) roles!!.contains(ROLE_KOBO_SYNC) else existing.roleKoboSync,
sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries,
sharedLibrariesIds =
if (isSet("sharedLibraries")) {
@ -234,9 +240,43 @@ class UserController(
fun getLatestAuthenticationActivityForUser(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(required = false, name = "apikey_id") apiKeyId: String?,
): AuthenticationActivityDto =
userRepository.findByIdOrNull(id)?.let { user ->
authenticationActivityRepository.findMostRecentByUser(user)?.toDto()
authenticationActivityRepository.findMostRecentByUser(user, apiKeyId)?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@GetMapping("me/api-keys")
fun getApiKeys(
@AuthenticationPrincipal principal: KomgaPrincipal,
): Collection<ApiKeyDto> {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
return userRepository.findApiKeyByUserId(principal.user.id).map { it.toDto().redacted() }
}
@PostMapping("me/api-keys")
fun createApiKey(
@AuthenticationPrincipal principal: KomgaPrincipal,
@Valid @RequestBody apiKeyRequest: ApiKeyRequestDto,
): ApiKeyDto {
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
return try {
userLifecycle.createApiKey(principal.user, apiKeyRequest.comment)?.toDto()
} catch (e: DuplicateNameException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code)
}
?: throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Failed to generate API key")
}
@DeleteMapping("me/api-keys/{keyId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteApiKey(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable keyId: String,
) {
if (!userRepository.existsApiKeyByIdAndUserId(keyId, principal.user.id))
throw ResponseStatusException(HttpStatus.NOT_FOUND)
userRepository.deleteApiKeyByIdAndUserId(keyId, principal.user.id)
}
}

View File

@ -0,0 +1,26 @@
package org.gotson.komga.interfaces.api.rest.dto
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.language.toUTCZoned
import java.time.ZonedDateTime
data class ApiKeyDto(
val id: String,
val userId: String,
val key: String,
val comment: String,
val createdDate: ZonedDateTime,
val lastModifiedDate: ZonedDateTime,
)
fun ApiKey.toDto() =
ApiKeyDto(
id = id,
userId = userId,
key = key,
comment = comment,
createdDate = createdDate.toUTCZoned(),
lastModifiedDate = createdDate.toUTCZoned(),
)
fun ApiKeyDto.redacted() = copy(key = "*".repeat(6))

View File

@ -0,0 +1,8 @@
package org.gotson.komga.interfaces.api.rest.dto
import jakarta.validation.constraints.NotBlank
data class ApiKeyRequestDto(
@get:NotBlank
val comment: String,
)

View File

@ -8,6 +8,8 @@ import java.time.LocalDateTime
data class AuthenticationActivityDto(
val userId: String?,
val email: String?,
val apiKeyId: String? = null,
val apiKeyComment: String? = null,
val ip: String?,
val userAgent: String?,
val success: Boolean,
@ -21,6 +23,8 @@ fun AuthenticationActivity.toDto() =
AuthenticationActivityDto(
userId = userId,
email = email,
apiKeyId = apiKeyId,
apiKeyComment = apiKeyComment,
ip = ip,
userAgent = userAgent,
success = success,

View File

@ -0,0 +1,76 @@
package org.gotson.komga.domain.service
import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.infrastructure.security.apikey.ApiKeyGenerator
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class KomgaUserLifecycleTest(
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
) {
@SpykBean
private lateinit var apiKeyGenerator: ApiKeyGenerator
private val user1 = KomgaUser("user1@example.org", "", false)
private val user2 = KomgaUser("user2@example.org", "", false)
@BeforeAll
fun setup() {
userRepository.insert(user1)
userRepository.insert(user2)
}
@AfterEach
fun cleanup() {
userRepository.deleteApiKeyByUserId(user1.id)
userRepository.deleteApiKeyByUserId(user2.id)
}
@AfterAll
fun teardown() {
userRepository.deleteAll()
}
@Test
fun `given existing api key when api key cannot be uniquely generated then it returns null`() {
// given
val uuid = ApiKeyGenerator().generate()
every { apiKeyGenerator.generate() } returns uuid
userLifecycle.createApiKey(user1, "test key")
// when
val apiKey = userLifecycle.createApiKey(user1, "test key 2")
val apiKey2 = userLifecycle.createApiKey(user2, "test key 3")
// then
assertThat(apiKey).isNull()
assertThat(apiKey2).isNull()
}
@ParameterizedTest
@ValueSource(strings = ["test", "TEST", " test "])
fun `given existing api key comment when api key with same comment is generated then it throws exception`(comment: String) {
// given
userLifecycle.createApiKey(user1, "test")
// when
val thrown = catchThrowable { userLifecycle.createApiKey(user1, comment) }
// then
assertThat(thrown).isExactlyInstanceOf(DuplicateNameException::class.java)
}
}

View File

@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
@ -27,6 +28,7 @@ annotation class WithMockCustomUser(
val excludeAgeOver: Int = -1,
val allowLabels: Array<String> = [],
val excludeLabels: Array<String> = [],
val apiKey: String = "",
)
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
@ -58,7 +60,11 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
id = customUser.id,
),
)
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
val auth =
if (customUser.apiKey.isNotEmpty())
ApiKeyAuthenticationToken.authenticated(customUser.apiKey, customUser.apiKey, principal.authorities)
else
UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
context.authentication = auth
return context
}

View File

@ -13,6 +13,7 @@ import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle
import org.hamcrest.text.MatchesPattern
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
@ -26,6 +27,8 @@ import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
@ -440,4 +443,96 @@ class UserControllerTest(
}
}
}
@Nested
inner class ApiKey {
@AfterEach
fun cleanup() {
userRepository.deleteApiKeyByUserId(admin.id)
}
@Test
@WithMockCustomUser(id = "admin")
fun `given user when creating API key then it is returned in plain text`() {
// language=JSON
val jsonString =
"""
{
"comment": "test api key"
}
""".trimIndent()
mockMvc.post("/api/v2/users/me/api-keys") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isOk() }
jsonPath("$.userId") { value(admin.id) }
jsonPath("$.key") { value(MatchesPattern(Regex("""[^*]+""").toPattern())) }
jsonPath("$.comment") { value("test api key") }
}
with(userRepository.findApiKeyByUserId(admin.id)) {
assertThat(this).hasSize(1)
with(this.first()!!) {
assertThat(this.userId).isEqualTo(admin.id)
assertThat(this.comment).isEqualTo("test api key")
}
}
mockMvc.get("/api/v2/users/me/api-keys")
.andExpect {
status { isOk() }
jsonPath("$.length()") { value(1) }
jsonPath("$[0].userId") { value(admin.id) }
jsonPath("$[0].key") { value(MatchesPattern(Regex("""[*]+""").toPattern())) }
jsonPath("$[0].comment") { value("test api key") }
}
}
@Test
@WithMockCustomUser(id = "admin")
fun `given user when creating API key without comment then returns bad request`() {
// language=JSON
val jsonString =
"""
{
"comment": ""
}
""".trimIndent()
mockMvc.post("/api/v2/users/me/api-keys") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest() }
}
}
@Test
@WithMockCustomUser(id = "admin")
fun `given user with api key when deleting API key then it is deleted`() {
val apiKey = userLifecycle.createApiKey(admin, "test")!!
mockMvc.delete("/api/v2/users/me/api-keys/${apiKey.id}")
.andExpect {
status { isNoContent() }
}
assertThat(userRepository.findApiKeyByUserId(admin.id)).isEmpty()
}
@Test
@WithMockCustomUser(id = "admin")
fun `given user with api key when deleting different API key ID then returns bad request`() {
val apiKey = userLifecycle.createApiKey(admin, "test")!!
mockMvc.delete("/api/v2/users/me/api-keys/abc123")
.andExpect {
status { isNotFound() }
}
assertThat(userRepository.findApiKeyByUserId(admin.id)).isNotEmpty()
}
}
}