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_1031 | ComicRack CBL Book is missing series or number |
| ERR_1032 | EPUB file has wrong media type | | ERR_1032 | EPUB file has wrong media type |
| ERR_1033 | Some entries are missing | | 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( data class AuthenticationActivity(
val userId: String? = null, val userId: String? = null,
val email: String? = null, val email: String? = null,
val apiKeyId: String? = null,
val apiKeyComment: String? = null,
val ip: String? = null, val ip: String? = null,
val userAgent: String? = null, val userAgent: String? = null,
val success: Boolean, val success: Boolean,

View File

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

View File

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

View File

@ -48,10 +48,14 @@ class AuthenticationActivityDao(
return findAll(conditions, pageable) return findAll(conditions, pageable)
} }
override fun findMostRecentByUser(user: KomgaUser): AuthenticationActivity? = override fun findMostRecentByUser(
user: KomgaUser,
apiKeyId: String?,
): AuthenticationActivity? =
dsl.selectFrom(aa) dsl.selectFrom(aa)
.where(aa.USER_ID.eq(user.id)) .where(aa.USER_ID.eq(user.id))
.or(aa.EMAIL.eq(user.email)) .or(aa.EMAIL.eq(user.email))
.apply { apiKeyId?.let { and(aa.API_KEY_ID.eq(it)) } }
.orderBy(aa.DATE_TIME.desc()) .orderBy(aa.DATE_TIME.desc())
.limit(1) .limit(1)
.fetchOne() .fetchOne()
@ -85,8 +89,8 @@ class AuthenticationActivityDao(
} }
override fun insert(activity: AuthenticationActivity) { override fun insert(activity: AuthenticationActivity) {
dsl.insertInto(aa, aa.USER_ID, aa.EMAIL, aa.IP, aa.USER_AGENT, aa.SUCCESS, aa.ERROR, aa.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.ip, activity.userAgent, activity.success, activity.error, activity.source) .values(activity.userId, activity.email, activity.apiKeyId, activity.apiKeyComment, activity.ip, activity.userAgent, activity.success, activity.error, activity.source)
.execute() .execute()
} }
@ -107,6 +111,8 @@ class AuthenticationActivityDao(
AuthenticationActivity( AuthenticationActivity(
userId = userId, userId = userId,
email = email, email = email,
apiKeyId = apiKeyId,
apiKeyComment = apiKeyComment,
ip = ip, ip = ip,
userAgent = userAgent, userAgent = userAgent,
success = success, 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.AgeRestriction
import org.gotson.komga.domain.model.AllowExclude 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.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.jooq.main.Tables import org.gotson.komga.jooq.main.Tables
import org.gotson.komga.jooq.main.tables.records.AnnouncementsReadRecord 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.gotson.komga.language.toCurrentTimeZone
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
@ -24,6 +26,7 @@ class KomgaUserDao(
private val ul = Tables.USER_LIBRARY_SHARING private val ul = Tables.USER_LIBRARY_SHARING
private val us = Tables.USER_SHARING private val us = Tables.USER_SHARING
private val ar = Tables.ANNOUNCEMENTS_READ private val ar = Tables.ANNOUNCEMENTS_READ
private val uak = Tables.USER_API_KEY
override fun count(): Long = dsl.fetchCount(u).toLong() override fun count(): Long = dsl.fetchCount(u).toLong()
@ -31,6 +34,14 @@ class KomgaUserDao(
selectBase() selectBase()
.fetchAndMap() .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? = override fun findByIdOrNull(id: String): KomgaUser? =
selectBase() selectBase()
.where(u.ID.equal(id)) .where(u.ID.equal(id))
@ -57,6 +68,7 @@ class KomgaUserDao(
roleAdmin = ur.roleAdmin, roleAdmin = ur.roleAdmin,
roleFileDownload = ur.roleFileDownload, roleFileDownload = ur.roleFileDownload,
rolePageStreaming = ur.rolePageStreaming, rolePageStreaming = ur.rolePageStreaming,
roleKoboSync = ur.roleKoboSync,
sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(), sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(),
sharedAllLibraries = ur.sharedAllLibraries, sharedAllLibraries = ur.sharedAllLibraries,
restrictions = restrictions =
@ -84,6 +96,7 @@ class KomgaUserDao(
.set(u.ROLE_ADMIN, user.roleAdmin) .set(u.ROLE_ADMIN, user.roleAdmin)
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload) .set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming) .set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries) .set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age) .set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
.set( .set(
@ -100,6 +113,15 @@ class KomgaUserDao(
insertSharingRestrictions(user) 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 @Transactional
override fun update(user: KomgaUser) { override fun update(user: KomgaUser) {
dsl.update(u) dsl.update(u)
@ -108,6 +130,7 @@ class KomgaUserDao(
.set(u.ROLE_ADMIN, user.roleAdmin) .set(u.ROLE_ADMIN, user.roleAdmin)
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload) .set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming) .set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries) .set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age) .set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
.set( .set(
@ -168,6 +191,7 @@ class KomgaUserDao(
@Transactional @Transactional
override fun delete(userId: String) { 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(ar).where(ar.USER_ID.equal(userId)).execute()
dsl.deleteFrom(us).where(us.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() dsl.deleteFrom(ul).where(ul.USER_ID.equal(userId)).execute()
@ -176,12 +200,27 @@ class KomgaUserDao(
@Transactional @Transactional
override fun deleteAll() { override fun deleteAll() {
dsl.deleteFrom(uak).execute()
dsl.deleteFrom(ar).execute() dsl.deleteFrom(ar).execute()
dsl.deleteFrom(us).execute() dsl.deleteFrom(us).execute()
dsl.deleteFrom(ul).execute() dsl.deleteFrom(ul).execute()
dsl.deleteFrom(u).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> = override fun findAnnouncementIdsReadByUserId(userId: String): Set<String> =
dsl.select(ar.ANNOUNCEMENT_ID) dsl.select(ar.ANNOUNCEMENT_ID)
.from(ar) .from(ar)
@ -194,9 +233,49 @@ class KomgaUserDao(
.where(u.EMAIL.equalIgnoreCase(email)), .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? = override fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? =
selectBase() selectBase()
.where(u.EMAIL.equalIgnoreCase(email)) .where(u.EMAIL.equalIgnoreCase(email))
.fetchAndMap() .fetchAndMap()
.firstOrNull() .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 package org.gotson.komga.infrastructure.security
import org.gotson.komga.domain.model.ApiKey
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
@ -13,6 +14,8 @@ class KomgaPrincipal(
val user: KomgaUser, val user: KomgaUser,
val oAuth2User: OAuth2User? = null, val oAuth2User: OAuth2User? = null,
val oidcUser: OidcUser? = null, val oidcUser: OidcUser? = null,
val apiKey: ApiKey? = null,
private val name: String = user.email,
) : UserDetails, OAuth2User, OidcUser { ) : UserDetails, OAuth2User, OidcUser {
override fun getAuthorities(): MutableCollection<out GrantedAuthority> = override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
user.roles user.roles
@ -21,7 +24,7 @@ class KomgaPrincipal(
override fun isEnabled() = true override fun isEnabled() = true
override fun getUsername() = user.email override fun getUsername() = name
override fun isCredentialsNonExpired() = true override fun isCredentialsNonExpired() = true
@ -31,7 +34,7 @@ class KomgaPrincipal(
override fun isAccountNonLocked() = true override fun isAccountNonLocked() = true
override fun getName() = user.email override fun getName() = name
override fun getAttributes(): MutableMap<String, Any> = oAuth2User?.attributes ?: mutableMapOf() 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.model.AuthenticationActivity
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.authentication.RememberMeAuthenticationToken import org.springframework.security.authentication.RememberMeAuthenticationToken
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent 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.authentication.event.AuthenticationSuccessEvent
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken
import org.springframework.security.web.authentication.WebAuthenticationDetails import org.springframework.security.web.authentication.WebAuthenticationDetails
@ -24,10 +26,13 @@ class LoginListener(
) { ) {
@EventListener @EventListener
fun onSuccess(event: AuthenticationSuccessEvent) { 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 = val source =
when (event.source) { when (event.source) {
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}" is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
is ApiKeyAuthenticationToken -> "ApiKey"
is UsernamePasswordAuthenticationToken -> "Password" is UsernamePasswordAuthenticationToken -> "Password"
is RememberMeAuthenticationToken -> "RememberMe" is RememberMeAuthenticationToken -> "RememberMe"
else -> null else -> null
@ -36,6 +41,8 @@ class LoginListener(
AuthenticationActivity( AuthenticationActivity(
userId = user.id, userId = user.id,
email = user.email, email = user.email,
apiKeyId = apiKey?.id,
apiKeyComment = apiKey?.comment,
ip = event.getIp(), ip = event.getIp(),
userAgent = event.getUserAgent(), userAgent = event.getUserAgent(),
success = true, success = true,
@ -48,18 +55,22 @@ class LoginListener(
@EventListener @EventListener
fun onFailure(event: AbstractAuthenticationFailureEvent) { 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 = val source =
when (event.source) { when (event.source) {
is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}" is OAuth2LoginAuthenticationToken -> "OAuth2:${(event.source as OAuth2LoginAuthenticationToken).clientRegistration.clientName}"
is ApiKeyAuthenticationToken -> "ApiKey"
is UsernamePasswordAuthenticationToken -> "Password" is UsernamePasswordAuthenticationToken -> "Password"
is RememberMeAuthenticationToken -> "RememberMe" is RememberMeAuthenticationToken -> "RememberMe"
else -> null else -> null
} }
val principal = event.authentication?.principal?.toString().orEmpty()
val activity = val activity =
AuthenticationActivity( AuthenticationActivity(
userId = userRepository.findByEmailIgnoreCaseOrNull(user)?.id, userId = userRepository.findByEmailIgnoreCaseOrNull(principal)?.id,
email = user, email = if (event.source !is ApiKeyAuthenticationToken) principal else null,
apiKeyComment = if (event.source is ApiKeyAuthenticationToken) principal else null,
ip = event.getIp(), ip = event.getIp(),
userAgent = event.getUserAgent(), userAgent = event.getUserAgent(),
success = false, 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.Bean
import org.springframework.context.annotation.Configuration 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.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
@Configuration @Configuration
class PasswordEncoderConfiguration { class PasswordEncoderConfiguration {
@Bean @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 package org.gotson.komga.infrastructure.security
import io.github.oshai.kotlinlogging.KotlinLogging 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_ADMIN
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
import org.gotson.komga.domain.model.ROLE_USER 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.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.autoconfigure.security.servlet.EndpointRequest
import org.springframework.boot.actuate.health.HealthEndpoint import org.springframework.boot.actuate.health.HealthEndpoint
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration 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.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity 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.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetailsService 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.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest 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.oidc.user.OidcUser
import org.springframework.security.oauth2.core.user.OAuth2User import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.security.web.SecurityFilterChain 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.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
@ -34,19 +45,22 @@ private val logger = KotlinLogging.logger {}
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) @EnableMethodSecurity(prePostEnabled = true)
class SecurityConfiguration( class SecurityConfiguration(
private val komgaProperties: KomgaProperties,
private val komgaSettingsProvider: KomgaSettingsProvider, private val komgaSettingsProvider: KomgaSettingsProvider,
private val komgaUserDetailsLifecycle: UserDetailsService, private val komgaUserDetailsService: UserDetailsService,
private val apiKeyAuthenticationProvider: ApiKeyAuthenticationProvider,
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>, private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>, private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
private val sessionCookieName: String, private val sessionCookieName: String,
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource, private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
private val sessionRegistry: SessionRegistry, private val theSessionRegistry: SessionRegistry,
private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint, private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint,
private val authenticationEventPublisher: AuthenticationEventPublisher,
private val tokenEncoder: TokenEncoder,
clientRegistrationRepository: InMemoryClientRegistrationRepository?, clientRegistrationRepository: InMemoryClientRegistrationRepository?,
) { ) {
private val oauth2Enabled = clientRegistrationRepository != null private val oauth2Enabled = clientRegistrationRepository != null
@Order(1)
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
http http
@ -92,6 +106,7 @@ class SecurityConfiguration(
headersConfigurer.cacheControl { it.disable() } // headers are set in WebMvcConfiguration headersConfigurer.cacheControl { it.disable() } // headers are set in WebMvcConfiguration
headersConfigurer.frameOptions { it.sameOrigin() } // for epubreader iframes headersConfigurer.frameOptions { it.sameOrigin() } // for epubreader iframes
} }
.userDetailsService(komgaUserDetailsService)
.httpBasic { .httpBasic {
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource) it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
} }
@ -103,7 +118,7 @@ class SecurityConfiguration(
.sessionManagement { session -> .sessionManagement { session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
session.sessionConcurrency { session.sessionConcurrency {
it.sessionRegistry(sessionRegistry) it.sessionRegistry(theSessionRegistry)
it.maximumSessions(-1) it.maximumSessions(-1)
} }
} }
@ -129,13 +144,15 @@ class SecurityConfiguration(
val url = "/login?server_redirect=Y&error=$errorMessage" val url = "/login?server_redirect=Y&error=$errorMessage"
SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception) SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception)
} }
oauth2.redirectionEndpoint {
}
} }
} }
http http
.rememberMe { .rememberMe {
it.rememberMeServices( it.rememberMeServices(
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsLifecycle).apply { TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsService).apply {
setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt()) setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt())
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource) setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
}, },
@ -144,4 +161,54 @@ class SecurityConfiguration(
return http.build() 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 jakarta.validation.Valid
import org.gotson.komga.domain.model.AgeRestriction import org.gotson.komga.domain.model.AgeRestriction
import org.gotson.komga.domain.model.ContentRestrictions 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_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD 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.ROLE_PAGE_STREAMING
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository 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.domain.service.KomgaUserLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal 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.AuthenticationActivityDto
import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto 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.UserCreationDto
import org.gotson.komga.interfaces.api.rest.dto.UserDto 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.UserUpdateDto
import org.gotson.komga.interfaces.api.rest.dto.redacted
import org.gotson.komga.interfaces.api.rest.dto.toDto import org.gotson.komga.interfaces.api.rest.dto.toDto
import org.springdoc.core.converters.models.PageableAsQueryParam import org.springdoc.core.converters.models.PageableAsQueryParam
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
@ -121,6 +126,7 @@ class UserController(
roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin, roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin,
roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload, roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload,
rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming, 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, sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries,
sharedLibrariesIds = sharedLibrariesIds =
if (isSet("sharedLibraries")) { if (isSet("sharedLibraries")) {
@ -234,9 +240,43 @@ class UserController(
fun getLatestAuthenticationActivityForUser( fun getLatestAuthenticationActivityForUser(
@PathVariable id: String, @PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(required = false, name = "apikey_id") apiKeyId: String?,
): AuthenticationActivityDto = ): AuthenticationActivityDto =
userRepository.findByIdOrNull(id)?.let { user -> userRepository.findByIdOrNull(id)?.let { user ->
authenticationActivityRepository.findMostRecentByUser(user)?.toDto() authenticationActivityRepository.findMostRecentByUser(user, apiKeyId)?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} ?: 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( data class AuthenticationActivityDto(
val userId: String?, val userId: String?,
val email: String?, val email: String?,
val apiKeyId: String? = null,
val apiKeyComment: String? = null,
val ip: String?, val ip: String?,
val userAgent: String?, val userAgent: String?,
val success: Boolean, val success: Boolean,
@ -21,6 +23,8 @@ fun AuthenticationActivity.toDto() =
AuthenticationActivityDto( AuthenticationActivityDto(
userId = userId, userId = userId,
email = email, email = email,
apiKeyId = apiKeyId,
apiKeyComment = apiKeyComment,
ip = ip, ip = ip,
userAgent = userAgent, userAgent = userAgent,
success = success, 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_FILE_DOWNLOAD
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
import org.gotson.komga.infrastructure.security.KomgaPrincipal 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.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContext import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@ -27,6 +28,7 @@ annotation class WithMockCustomUser(
val excludeAgeOver: Int = -1, val excludeAgeOver: Int = -1,
val allowLabels: Array<String> = [], val allowLabels: Array<String> = [],
val excludeLabels: Array<String> = [], val excludeLabels: Array<String> = [],
val apiKey: String = "",
) )
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> { class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
@ -58,7 +60,11 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
id = customUser.id, 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 context.authentication = auth
return context 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.persistence.LibraryRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.domain.service.LibraryLifecycle
import org.hamcrest.text.MatchesPattern
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll 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.http.MediaType
import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc 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.patch
import org.springframework.test.web.servlet.post 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()
}
}
} }