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