From a4747e81f4e21f2d30d65d0b839ca34a1c6490d7 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Tue, 27 Aug 2024 18:00:04 +0800 Subject: [PATCH] feat(kobo): add API key support --- ERRORCODES.md | 1 + .../sqlite/V20240529120933__apikey.sql | 18 ++++ .../org/gotson/komga/domain/model/ApiKey.kt | 13 +++ .../domain/model/AuthenticationActivity.kt | 2 + .../AuthenticationActivityRepository.kt | 5 +- .../domain/persistence/KomgaUserRepository.kt | 24 +++++ .../jooq/main/AuthenticationActivityDao.kt | 12 ++- .../infrastructure/jooq/main/KomgaUserDao.kt | 79 +++++++++++++++ .../infrastructure/security/KomgaPrincipal.kt | 7 +- .../infrastructure/security/LoginListener.kt | 19 +++- .../security/PasswordEncoderConfiguration.kt | 6 +- .../security/SecurityConfiguration.kt | 79 +++++++++++++-- .../infrastructure/security/TokenEncoder.kt | 10 ++ .../apikey/ApiKeyAuthenticationFilter.kt | 97 +++++++++++++++++++ .../apikey/ApiKeyAuthenticationProvider.kt | 43 ++++++++ .../apikey/ApiKeyAuthenticationToken.kt | 26 +++++ .../security/apikey/ApiKeyGenerator.kt | 14 +++ .../UriRegexApiKeyAuthenticationConverter.kt | 30 ++++++ .../interfaces/api/rest/UserController.kt | 42 +++++++- .../interfaces/api/rest/dto/ApiKeyDto.kt | 26 +++++ .../api/rest/dto/ApiKeyRequestDto.kt | 8 ++ .../api/rest/dto/AuthenticationActivityDto.kt | 4 + .../domain/service/KomgaUserLifecycleTest.kt | 76 +++++++++++++++ .../interfaces/api/rest/MockSpringSecurity.kt | 8 +- .../interfaces/api/rest/UserControllerTest.kt | 95 ++++++++++++++++++ 25 files changed, 725 insertions(+), 19 deletions(-) create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt diff --git a/ERRORCODES.md b/ERRORCODES.md index 2c0c7799..4ebdc635 100644 --- a/ERRORCODES.md +++ b/ERRORCODES.md @@ -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 | diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql b/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql new file mode 100644 index 00000000..421002b8 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20240529120933__apikey.sql @@ -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; diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt new file mode 100644 index 00000000..af644afc --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/ApiKey.kt @@ -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 diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt index 66bce47d..df9f0858 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/AuthenticationActivity.kt @@ -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, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt index 691289c1..ca9068cd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/AuthenticationActivityRepository.kt @@ -14,7 +14,10 @@ interface AuthenticationActivityRepository { pageable: Pageable, ): Page - fun findMostRecentByUser(user: KomgaUser): AuthenticationActivity? + fun findMostRecentByUser( + user: KomgaUser, + apiKeyId: String?, + ): AuthenticationActivity? fun insert(activity: AuthenticationActivity) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt index 9005bc42..1c2a48be 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt @@ -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? + fun findAll(): Collection + fun findApiKeyByUserId(userId: String): Collection + 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 fun saveAnnouncementIdsRead( diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt index e1257ad4..7d490b2e 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/AuthenticationActivityDao.kt @@ -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, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt index ea103f1a..aa7fd986 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/KomgaUserDao.kt @@ -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 = + 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 = 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? { + 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(), + ) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt index 9de1d84a..e04c3057 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/KomgaPrincipal.kt @@ -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 = 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 = oAuth2User?.attributes ?: mutableMapOf() diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt index 635bf575..bb4f0827 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/LoginListener.kt @@ -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, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt index b8b0fb8d..02afedc8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/PasswordEncoderConfiguration.kt @@ -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) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index ee378f43..a8ea4796 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -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, private val oidcUserService: OAuth2UserService, 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(koboAuthenticationFilter()) + } + + return http.build() + } + + fun koboAuthenticationFilter(): Filter = + ApiKeyAuthenticationFilter( + apiKeyAuthenticationProvider(), + UriRegexApiKeyAuthenticationConverter(Regex("""\/kobo\/([\w-]+)"""), tokenEncoder, userAgentWebAuthenticationDetailsSource), + ) + + fun apiKeyAuthenticationProvider(): AuthenticationManager = + ProviderManager(apiKeyAuthenticationProvider).apply { + setAuthenticationEventPublisher(authenticationEventPublisher) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt new file mode 100644 index 00000000..a9372653 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/TokenEncoder.kt @@ -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 +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt new file mode 100644 index 00000000..c8237cfd --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationFilter.kt @@ -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) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt new file mode 100644 index 00000000..a78b407c --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationProvider.kt @@ -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) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt new file mode 100644 index 00000000..aea3237d --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyAuthenticationToken.kt @@ -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?) : 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?, + ) = ApiKeyAuthenticationToken(principal, credentials, authorities) + + fun unauthenticated( + principal: Any?, + credentials: Any?, + ) = ApiKeyAuthenticationToken(principal, credentials) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt new file mode 100644 index 00000000..c386a2ae --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/ApiKeyGenerator.kt @@ -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("-", "") +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt new file mode 100644 index 00000000..3733e58b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/apikey/UriRegexApiKeyAuthenticationConverter.kt @@ -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, +) : 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) } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt index c2f10451..87172fee 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/UserController.kt @@ -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 { + 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) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt new file mode 100644 index 00000000..296416aa --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyDto.kt @@ -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)) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt new file mode 100644 index 00000000..d76db837 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/ApiKeyRequestDto.kt @@ -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, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt index 0fc6db3a..4f490622 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/AuthenticationActivityDto.kt @@ -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, diff --git a/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt b/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt new file mode 100644 index 00000000..5f4e4d90 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/domain/service/KomgaUserLifecycleTest.kt @@ -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) + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt index e423e9e2..de158390 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/MockSpringSecurity.kt @@ -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 = [], val excludeLabels: Array = [], + val apiKey: String = "", ) class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory { @@ -58,7 +60,11 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory