mirror of
https://github.com/gotson/komga.git
synced 2025-01-05 10:17:02 +08:00
feat(kobo): add API key support
This commit is contained in:
parent
f3bce238c1
commit
a4747e81f4
@ -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 |
|
||||
|
@ -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;
|
@ -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
|
@ -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,
|
||||
|
@ -14,7 +14,10 @@ interface AuthenticationActivityRepository {
|
||||
pageable: Pageable,
|
||||
): Page<AuthenticationActivity>
|
||||
|
||||
fun findMostRecentByUser(user: KomgaUser): AuthenticationActivity?
|
||||
fun findMostRecentByUser(
|
||||
user: KomgaUser,
|
||||
apiKeyId: String?,
|
||||
): AuthenticationActivity?
|
||||
|
||||
fun insert(activity: AuthenticationActivity)
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.ApiKey
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
|
||||
interface KomgaUserRepository {
|
||||
@ -9,18 +10,41 @@ interface KomgaUserRepository {
|
||||
|
||||
fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser?
|
||||
|
||||
fun findByApiKeyOrNull(apiKey: String): Pair<KomgaUser, ApiKey>?
|
||||
|
||||
fun findAll(): Collection<KomgaUser>
|
||||
|
||||
fun findApiKeyByUserId(userId: String): Collection<ApiKey>
|
||||
|
||||
fun existsByEmailIgnoreCase(email: String): Boolean
|
||||
|
||||
fun existsApiKeyByIdAndUserId(
|
||||
apiKeyId: String,
|
||||
userId: String,
|
||||
): Boolean
|
||||
|
||||
fun existsApiKeyByCommentAndUserId(
|
||||
comment: String,
|
||||
userId: String,
|
||||
): Boolean
|
||||
|
||||
fun insert(user: KomgaUser)
|
||||
|
||||
fun insert(apiKey: ApiKey)
|
||||
|
||||
fun update(user: KomgaUser)
|
||||
|
||||
fun delete(userId: String)
|
||||
|
||||
fun deleteAll()
|
||||
|
||||
fun deleteApiKeyByIdAndUserId(
|
||||
apiKeyId: String,
|
||||
userId: String,
|
||||
)
|
||||
|
||||
fun deleteApiKeyByUserId(userId: String)
|
||||
|
||||
fun findAnnouncementIdsReadByUserId(userId: String): Set<String>
|
||||
|
||||
fun saveAnnouncementIdsRead(
|
||||
|
@ -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,
|
||||
|
@ -2,11 +2,13 @@ package org.gotson.komga.infrastructure.jooq.main
|
||||
|
||||
import org.gotson.komga.domain.model.AgeRestriction
|
||||
import org.gotson.komga.domain.model.AllowExclude
|
||||
import org.gotson.komga.domain.model.ApiKey
|
||||
import org.gotson.komga.domain.model.ContentRestrictions
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||
import org.gotson.komga.jooq.main.Tables
|
||||
import org.gotson.komga.jooq.main.tables.records.AnnouncementsReadRecord
|
||||
import org.gotson.komga.jooq.main.tables.records.UserApiKeyRecord
|
||||
import org.gotson.komga.language.toCurrentTimeZone
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
@ -24,6 +26,7 @@ class KomgaUserDao(
|
||||
private val ul = Tables.USER_LIBRARY_SHARING
|
||||
private val us = Tables.USER_SHARING
|
||||
private val ar = Tables.ANNOUNCEMENTS_READ
|
||||
private val uak = Tables.USER_API_KEY
|
||||
|
||||
override fun count(): Long = dsl.fetchCount(u).toLong()
|
||||
|
||||
@ -31,6 +34,14 @@ class KomgaUserDao(
|
||||
selectBase()
|
||||
.fetchAndMap()
|
||||
|
||||
override fun findApiKeyByUserId(userId: String): Collection<ApiKey> =
|
||||
dsl.selectFrom(uak)
|
||||
.where(uak.USER_ID.eq(userId))
|
||||
.fetchInto(uak)
|
||||
.map {
|
||||
it.toDomain()
|
||||
}
|
||||
|
||||
override fun findByIdOrNull(id: String): KomgaUser? =
|
||||
selectBase()
|
||||
.where(u.ID.equal(id))
|
||||
@ -57,6 +68,7 @@ class KomgaUserDao(
|
||||
roleAdmin = ur.roleAdmin,
|
||||
roleFileDownload = ur.roleFileDownload,
|
||||
rolePageStreaming = ur.rolePageStreaming,
|
||||
roleKoboSync = ur.roleKoboSync,
|
||||
sharedLibrariesIds = ulr.mapNotNull { it.libraryId }.toSet(),
|
||||
sharedAllLibraries = ur.sharedAllLibraries,
|
||||
restrictions =
|
||||
@ -84,6 +96,7 @@ class KomgaUserDao(
|
||||
.set(u.ROLE_ADMIN, user.roleAdmin)
|
||||
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
|
||||
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
|
||||
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
|
||||
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
|
||||
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
|
||||
.set(
|
||||
@ -100,6 +113,15 @@ class KomgaUserDao(
|
||||
insertSharingRestrictions(user)
|
||||
}
|
||||
|
||||
override fun insert(apiKey: ApiKey) {
|
||||
dsl.insertInto(uak)
|
||||
.set(uak.ID, apiKey.id)
|
||||
.set(uak.USER_ID, apiKey.userId)
|
||||
.set(uak.API_KEY, apiKey.key)
|
||||
.set(uak.COMMENT, apiKey.comment)
|
||||
.execute()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
override fun update(user: KomgaUser) {
|
||||
dsl.update(u)
|
||||
@ -108,6 +130,7 @@ class KomgaUserDao(
|
||||
.set(u.ROLE_ADMIN, user.roleAdmin)
|
||||
.set(u.ROLE_FILE_DOWNLOAD, user.roleFileDownload)
|
||||
.set(u.ROLE_PAGE_STREAMING, user.rolePageStreaming)
|
||||
.set(u.ROLE_KOBO_SYNC, user.roleKoboSync)
|
||||
.set(u.SHARED_ALL_LIBRARIES, user.sharedAllLibraries)
|
||||
.set(u.AGE_RESTRICTION, user.restrictions.ageRestriction?.age)
|
||||
.set(
|
||||
@ -168,6 +191,7 @@ class KomgaUserDao(
|
||||
|
||||
@Transactional
|
||||
override fun delete(userId: String) {
|
||||
dsl.deleteFrom(uak).where(uak.USER_ID.equal(userId)).execute()
|
||||
dsl.deleteFrom(ar).where(ar.USER_ID.equal(userId)).execute()
|
||||
dsl.deleteFrom(us).where(us.USER_ID.equal(userId)).execute()
|
||||
dsl.deleteFrom(ul).where(ul.USER_ID.equal(userId)).execute()
|
||||
@ -176,12 +200,27 @@ class KomgaUserDao(
|
||||
|
||||
@Transactional
|
||||
override fun deleteAll() {
|
||||
dsl.deleteFrom(uak).execute()
|
||||
dsl.deleteFrom(ar).execute()
|
||||
dsl.deleteFrom(us).execute()
|
||||
dsl.deleteFrom(ul).execute()
|
||||
dsl.deleteFrom(u).execute()
|
||||
}
|
||||
|
||||
override fun deleteApiKeyByIdAndUserId(
|
||||
apiKeyId: String,
|
||||
userId: String,
|
||||
) {
|
||||
dsl.deleteFrom(uak)
|
||||
.where(uak.ID.eq(apiKeyId))
|
||||
.and(uak.USER_ID.eq(userId))
|
||||
.execute()
|
||||
}
|
||||
|
||||
override fun deleteApiKeyByUserId(userId: String) {
|
||||
dsl.deleteFrom(uak).where(uak.USER_ID.eq(userId)).execute()
|
||||
}
|
||||
|
||||
override fun findAnnouncementIdsReadByUserId(userId: String): Set<String> =
|
||||
dsl.select(ar.ANNOUNCEMENT_ID)
|
||||
.from(ar)
|
||||
@ -194,9 +233,49 @@ class KomgaUserDao(
|
||||
.where(u.EMAIL.equalIgnoreCase(email)),
|
||||
)
|
||||
|
||||
override fun existsApiKeyByIdAndUserId(
|
||||
apiKeyId: String,
|
||||
userId: String,
|
||||
): Boolean =
|
||||
dsl.fetchExists(uak, uak.ID.eq(apiKeyId).and(uak.USER_ID.eq(userId)))
|
||||
|
||||
override fun existsApiKeyByCommentAndUserId(
|
||||
comment: String,
|
||||
userId: String,
|
||||
): Boolean =
|
||||
dsl.fetchExists(uak, uak.COMMENT.equalIgnoreCase(comment).and(uak.USER_ID.eq(userId)))
|
||||
|
||||
override fun findByEmailIgnoreCaseOrNull(email: String): KomgaUser? =
|
||||
selectBase()
|
||||
.where(u.EMAIL.equalIgnoreCase(email))
|
||||
.fetchAndMap()
|
||||
.firstOrNull()
|
||||
|
||||
override fun findByApiKeyOrNull(apiKey: String): Pair<KomgaUser, ApiKey>? {
|
||||
val user =
|
||||
selectBase()
|
||||
.leftJoin(uak).on(u.ID.eq(uak.USER_ID))
|
||||
.where(uak.API_KEY.eq(apiKey))
|
||||
.fetchAndMap()
|
||||
.firstOrNull() ?: return null
|
||||
|
||||
val key =
|
||||
dsl.selectFrom(uak)
|
||||
.where(uak.API_KEY.eq(apiKey))
|
||||
.fetchInto(uak)
|
||||
.map { it.toDomain() }
|
||||
.firstOrNull() ?: return null
|
||||
|
||||
return Pair(user, key)
|
||||
}
|
||||
|
||||
private fun UserApiKeyRecord.toDomain() =
|
||||
ApiKey(
|
||||
id = id,
|
||||
userId = userId,
|
||||
key = apiKey,
|
||||
comment = comment,
|
||||
createdDate = createdDate.toCurrentTimeZone(),
|
||||
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
|
||||
)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.gotson.komga.infrastructure.security
|
||||
|
||||
import org.gotson.komga.domain.model.ApiKey
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
@ -13,6 +14,8 @@ class KomgaPrincipal(
|
||||
val user: KomgaUser,
|
||||
val oAuth2User: OAuth2User? = null,
|
||||
val oidcUser: OidcUser? = null,
|
||||
val apiKey: ApiKey? = null,
|
||||
private val name: String = user.email,
|
||||
) : UserDetails, OAuth2User, OidcUser {
|
||||
override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
|
||||
user.roles
|
||||
@ -21,7 +24,7 @@ class KomgaPrincipal(
|
||||
|
||||
override fun isEnabled() = true
|
||||
|
||||
override fun getUsername() = user.email
|
||||
override fun getUsername() = name
|
||||
|
||||
override fun isCredentialsNonExpired() = true
|
||||
|
||||
@ -31,7 +34,7 @@ class KomgaPrincipal(
|
||||
|
||||
override fun isAccountNonLocked() = true
|
||||
|
||||
override fun getName() = user.email
|
||||
override fun getName() = name
|
||||
|
||||
override fun getAttributes(): MutableMap<String, Any> = oAuth2User?.attributes ?: mutableMapOf()
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) }
|
||||
}
|
||||
|
@ -1,20 +1,30 @@
|
||||
package org.gotson.komga.infrastructure.security
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.Filter
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
|
||||
import org.gotson.komga.domain.model.ROLE_USER
|
||||
import org.gotson.komga.infrastructure.configuration.KomgaProperties
|
||||
import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider
|
||||
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationFilter
|
||||
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationProvider
|
||||
import org.gotson.komga.infrastructure.security.apikey.UriRegexApiKeyAuthenticationConverter
|
||||
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.security.authentication.AuthenticationEventPublisher
|
||||
import org.springframework.security.authentication.AuthenticationManager
|
||||
import org.springframework.security.authentication.ProviderManager
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.annotation.web.invoke
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
@ -23,6 +33,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
|
||||
import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices
|
||||
@ -34,19 +45,22 @@ private val logger = KotlinLogging.logger {}
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(prePostEnabled = true)
|
||||
class SecurityConfiguration(
|
||||
private val komgaProperties: KomgaProperties,
|
||||
private val komgaSettingsProvider: KomgaSettingsProvider,
|
||||
private val komgaUserDetailsLifecycle: UserDetailsService,
|
||||
private val komgaUserDetailsService: UserDetailsService,
|
||||
private val apiKeyAuthenticationProvider: ApiKeyAuthenticationProvider,
|
||||
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
|
||||
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
|
||||
private val sessionCookieName: String,
|
||||
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
|
||||
private val sessionRegistry: SessionRegistry,
|
||||
private val theSessionRegistry: SessionRegistry,
|
||||
private val opdsAuthenticationEntryPoint: OpdsAuthenticationEntryPoint,
|
||||
private val authenticationEventPublisher: AuthenticationEventPublisher,
|
||||
private val tokenEncoder: TokenEncoder,
|
||||
clientRegistrationRepository: InMemoryClientRegistrationRepository?,
|
||||
) {
|
||||
private val oauth2Enabled = clientRegistrationRepository != null
|
||||
|
||||
@Order(1)
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
@ -92,6 +106,7 @@ class SecurityConfiguration(
|
||||
headersConfigurer.cacheControl { it.disable() } // headers are set in WebMvcConfiguration
|
||||
headersConfigurer.frameOptions { it.sameOrigin() } // for epubreader iframes
|
||||
}
|
||||
.userDetailsService(komgaUserDetailsService)
|
||||
.httpBasic {
|
||||
it.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
}
|
||||
@ -103,7 +118,7 @@ class SecurityConfiguration(
|
||||
.sessionManagement { session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
session.sessionConcurrency {
|
||||
it.sessionRegistry(sessionRegistry)
|
||||
it.sessionRegistry(theSessionRegistry)
|
||||
it.maximumSessions(-1)
|
||||
}
|
||||
}
|
||||
@ -129,13 +144,15 @@ class SecurityConfiguration(
|
||||
val url = "/login?server_redirect=Y&error=$errorMessage"
|
||||
SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception)
|
||||
}
|
||||
oauth2.redirectionEndpoint {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http
|
||||
.rememberMe {
|
||||
it.rememberMeServices(
|
||||
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsLifecycle).apply {
|
||||
TokenBasedRememberMeServices(komgaSettingsProvider.rememberMeKey, komgaUserDetailsService).apply {
|
||||
setTokenValiditySeconds(komgaSettingsProvider.rememberMeDuration.inWholeSeconds.toInt())
|
||||
setAuthenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
|
||||
},
|
||||
@ -144,4 +161,54 @@ class SecurityConfiguration(
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun koboFilterChain(
|
||||
http: HttpSecurity,
|
||||
encoder: PasswordEncoder,
|
||||
): SecurityFilterChain {
|
||||
http {
|
||||
cors {}
|
||||
|
||||
csrf { disable() }
|
||||
formLogin { disable() }
|
||||
httpBasic { disable() }
|
||||
logout { disable() }
|
||||
|
||||
securityMatcher("/kobo/**")
|
||||
authorizeHttpRequests {
|
||||
authorize(anyRequest, hasRole(ROLE_KOBO_SYNC))
|
||||
}
|
||||
|
||||
headers {
|
||||
cacheControl { disable() }
|
||||
}
|
||||
|
||||
// somehow the Kobo gets a Json issue when receiving the session ID in a cookie header
|
||||
// this happens when requesting /v1/user/profile
|
||||
// Kobo error: packetdump.warning) Invalid JSON script: QVariant(Invalid) "illegal value"
|
||||
// sessionManagement {
|
||||
// sessionCreationPolicy = SessionCreationPolicy.IF_REQUIRED
|
||||
// sessionConcurrency {
|
||||
// sessionRegistry = theSessionRegistry
|
||||
// maximumSessions = -1
|
||||
// }
|
||||
// }
|
||||
|
||||
addFilterBefore<AnonymousAuthenticationFilter>(koboAuthenticationFilter())
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
fun koboAuthenticationFilter(): Filter =
|
||||
ApiKeyAuthenticationFilter(
|
||||
apiKeyAuthenticationProvider(),
|
||||
UriRegexApiKeyAuthenticationConverter(Regex("""\/kobo\/([\w-]+)"""), tokenEncoder, userAgentWebAuthenticationDetailsSource),
|
||||
)
|
||||
|
||||
fun apiKeyAuthenticationProvider(): AuthenticationManager =
|
||||
ProviderManager(apiKeyAuthenticationProvider).apply {
|
||||
setAuthenticationEventPublisher(authenticationEventPublisher)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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("-", "")
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.Parameter
|
||||
import jakarta.validation.Valid
|
||||
import org.gotson.komga.domain.model.AgeRestriction
|
||||
import org.gotson.komga.domain.model.ContentRestrictions
|
||||
import org.gotson.komga.domain.model.DuplicateNameException
|
||||
import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
||||
import org.gotson.komga.domain.model.ROLE_KOBO_SYNC
|
||||
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
|
||||
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
|
||||
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
|
||||
@ -15,11 +17,14 @@ import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.service.KomgaUserLifecycle
|
||||
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.ApiKeyRequestDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.AuthenticationActivityDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.PasswordUpdateDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.UserCreationDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.UserDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.UserUpdateDto
|
||||
import org.gotson.komga.interfaces.api.rest.dto.redacted
|
||||
import org.gotson.komga.interfaces.api.rest.dto.toDto
|
||||
import org.springdoc.core.converters.models.PageableAsQueryParam
|
||||
import org.springframework.core.env.Environment
|
||||
@ -121,6 +126,7 @@ class UserController(
|
||||
roleAdmin = if (isSet("roles")) roles!!.contains(ROLE_ADMIN) else existing.roleAdmin,
|
||||
roleFileDownload = if (isSet("roles")) roles!!.contains(ROLE_FILE_DOWNLOAD) else existing.roleFileDownload,
|
||||
rolePageStreaming = if (isSet("roles")) roles!!.contains(ROLE_PAGE_STREAMING) else existing.rolePageStreaming,
|
||||
roleKoboSync = if (isSet("roles")) roles!!.contains(ROLE_KOBO_SYNC) else existing.roleKoboSync,
|
||||
sharedAllLibraries = if (isSet("sharedLibraries")) sharedLibraries!!.all else existing.sharedAllLibraries,
|
||||
sharedLibrariesIds =
|
||||
if (isSet("sharedLibraries")) {
|
||||
@ -234,9 +240,43 @@ class UserController(
|
||||
fun getLatestAuthenticationActivityForUser(
|
||||
@PathVariable id: String,
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@RequestParam(required = false, name = "apikey_id") apiKeyId: String?,
|
||||
): AuthenticationActivityDto =
|
||||
userRepository.findByIdOrNull(id)?.let { user ->
|
||||
authenticationActivityRepository.findMostRecentByUser(user)?.toDto()
|
||||
authenticationActivityRepository.findMostRecentByUser(user, apiKeyId)?.toDto()
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
|
||||
@GetMapping("me/api-keys")
|
||||
fun getApiKeys(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
): Collection<ApiKeyDto> {
|
||||
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
return userRepository.findApiKeyByUserId(principal.user.id).map { it.toDto().redacted() }
|
||||
}
|
||||
|
||||
@PostMapping("me/api-keys")
|
||||
fun createApiKey(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@Valid @RequestBody apiKeyRequest: ApiKeyRequestDto,
|
||||
): ApiKeyDto {
|
||||
if (demo) throw ResponseStatusException(HttpStatus.FORBIDDEN)
|
||||
return try {
|
||||
userLifecycle.createApiKey(principal.user, apiKeyRequest.comment)?.toDto()
|
||||
} catch (e: DuplicateNameException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code)
|
||||
}
|
||||
?: throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Failed to generate API key")
|
||||
}
|
||||
|
||||
@DeleteMapping("me/api-keys/{keyId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun deleteApiKey(
|
||||
@AuthenticationPrincipal principal: KomgaPrincipal,
|
||||
@PathVariable keyId: String,
|
||||
) {
|
||||
if (!userRepository.existsApiKeyByIdAndUserId(keyId, principal.user.id))
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND)
|
||||
userRepository.deleteApiKeyByIdAndUserId(keyId, principal.user.id)
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
@ -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,
|
||||
)
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
|
||||
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
|
||||
import org.gotson.komga.domain.model.ROLE_PAGE_STREAMING
|
||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||
import org.gotson.komga.infrastructure.security.apikey.ApiKeyAuthenticationToken
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.context.SecurityContext
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
@ -27,6 +28,7 @@ annotation class WithMockCustomUser(
|
||||
val excludeAgeOver: Int = -1,
|
||||
val allowLabels: Array<String> = [],
|
||||
val excludeLabels: Array<String> = [],
|
||||
val apiKey: String = "",
|
||||
)
|
||||
|
||||
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
|
||||
@ -58,7 +60,11 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
|
||||
id = customUser.id,
|
||||
),
|
||||
)
|
||||
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
|
||||
val auth =
|
||||
if (customUser.apiKey.isNotEmpty())
|
||||
ApiKeyAuthenticationToken.authenticated(customUser.apiKey, customUser.apiKey, principal.authorities)
|
||||
else
|
||||
UsernamePasswordAuthenticationToken(principal, "", principal.authorities)
|
||||
context.authentication = auth
|
||||
return context
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import org.gotson.komga.domain.persistence.KomgaUserRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.service.KomgaUserLifecycle
|
||||
import org.gotson.komga.domain.service.LibraryLifecycle
|
||||
import org.hamcrest.text.MatchesPattern
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
@ -26,6 +27,8 @@ import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.delete
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.patch
|
||||
import org.springframework.test.web.servlet.post
|
||||
|
||||
@ -440,4 +443,96 @@ class UserControllerTest(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ApiKey {
|
||||
@AfterEach
|
||||
fun cleanup() {
|
||||
userRepository.deleteApiKeyByUserId(admin.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(id = "admin")
|
||||
fun `given user when creating API key then it is returned in plain text`() {
|
||||
// language=JSON
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"comment": "test api key"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v2/users/me/api-keys") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.userId") { value(admin.id) }
|
||||
jsonPath("$.key") { value(MatchesPattern(Regex("""[^*]+""").toPattern())) }
|
||||
jsonPath("$.comment") { value("test api key") }
|
||||
}
|
||||
|
||||
with(userRepository.findApiKeyByUserId(admin.id)) {
|
||||
assertThat(this).hasSize(1)
|
||||
with(this.first()!!) {
|
||||
assertThat(this.userId).isEqualTo(admin.id)
|
||||
assertThat(this.comment).isEqualTo("test api key")
|
||||
}
|
||||
}
|
||||
|
||||
mockMvc.get("/api/v2/users/me/api-keys")
|
||||
.andExpect {
|
||||
status { isOk() }
|
||||
jsonPath("$.length()") { value(1) }
|
||||
jsonPath("$[0].userId") { value(admin.id) }
|
||||
jsonPath("$[0].key") { value(MatchesPattern(Regex("""[*]+""").toPattern())) }
|
||||
jsonPath("$[0].comment") { value("test api key") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(id = "admin")
|
||||
fun `given user when creating API key without comment then returns bad request`() {
|
||||
// language=JSON
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"comment": ""
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
mockMvc.post("/api/v2/users/me/api-keys") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = jsonString
|
||||
}.andExpect {
|
||||
status { isBadRequest() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(id = "admin")
|
||||
fun `given user with api key when deleting API key then it is deleted`() {
|
||||
val apiKey = userLifecycle.createApiKey(admin, "test")!!
|
||||
|
||||
mockMvc.delete("/api/v2/users/me/api-keys/${apiKey.id}")
|
||||
.andExpect {
|
||||
status { isNoContent() }
|
||||
}
|
||||
|
||||
assertThat(userRepository.findApiKeyByUserId(admin.id)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockCustomUser(id = "admin")
|
||||
fun `given user with api key when deleting different API key ID then returns bad request`() {
|
||||
val apiKey = userLifecycle.createApiKey(admin, "test")!!
|
||||
|
||||
mockMvc.delete("/api/v2/users/me/api-keys/abc123")
|
||||
.andExpect {
|
||||
status { isNotFound() }
|
||||
}
|
||||
|
||||
assertThat(userRepository.findApiKeyByUserId(admin.id)).isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user